diff --git a/ONBOARDING.md b/ONBOARDING.md index 548a4ecc6cb3fbe98c83dc62f29c6011530213ec..199f27a765d1e2c7dc0bd6f7b64b66af7dc1696d 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -145,24 +145,22 @@ After activating each, walk its setup wizard to completion. Skip any optional in - FluentCRM: `wp_fc_*` tables exist (subscribers, campaigns, etc.). Admin page renders. - Fluent Support: `wp_fs_tickets` table exists. Admin page at `?page=fluent-support` renders. **Watch for the prefix-orphan trap** — if tables ended up at a non-standard prefix (e.g., from a cloned DB), that's a separate fix not part of fresh JNO. -### Step 5 — Clone SA-Orchestration plugin source +### Step 5 — Install SA-Orchestration plugin (pre-staged in this repo) -Verify the canonical SA-Orchestration repository URL with the human first. The likely location: +**The plugin source is already in this repo at `/sa-orchestration/`** — no GitHub clone is needed. Operational doctrine MD files are at `/SA-orchestration MD/` for agent reference. -``` -git clone https://github.com/SleeperAgents/SA-Orchestration.git /tmp/sa-orchestration-src -``` - -Then copy the plugin tree into the local WP install: +Copy (or symlink) the pre-staged plugin tree into the local WP install: ``` -cp -r /tmp/sa-orchestration-src/plugin/sa-orchestration /plugins/sa-orchestration +cp -r /sa-orchestration /plugins/sa-orchestration ``` -Adjust path syntax for the dev's OS. On Windows: use the WP install's `wp-content\plugins\` path. +Adjust path syntax for the developer's OS. On Windows: use the WP install's `wp-content\plugins\` path. The dev's Claude resolves `` and `` from the developer's actual environment. **Verify**: `/wp-content/plugins/sa-orchestration/sa-orchestration.php` exists. +If the pre-staged `sa-orchestration/` directory is missing for some reason, **stop and surface to the human**. Do not invent a clone URL. The seed should always include the plugin tree at `/sa-orchestration/`; absence indicates a corrupted seed, not a missing dependency. + ### Step 6 — Place the Asterion canonical corpus The SA-Orchestration plugin reads its canonical content from a filterable path. For local JNO, point that path at the corpus copy in this repo. diff --git a/SA-orchestration MD/Archvie/MidCoast/CLAUDE.md b/SA-orchestration MD/Archvie/MidCoast/CLAUDE.md new file mode 100644 index 0000000000000000000000000000000000000000..8f4c32738e8e63fbf6a02d6ce0deae5d14bb2c35 --- /dev/null +++ b/SA-orchestration MD/Archvie/MidCoast/CLAUDE.md @@ -0,0 +1,267 @@ +# QB Inventory Bin Audit — Project Context for Claude + +## What this is +A WordPress plugin for **MidCoast Engines** that manages a QuickBooks Desktop inventory audit system. +Two components work together: + +1. **WordPress plugin** (`midcoast_audit.php` + `lib/` + `views/`) — hosted on WP Engine at `sleeperagents.wpengine.com`. Provides admin UI, database, and REST API. +2. **C# Windows Service** (`QBSyncService/`) — runs on the on-premises machine that hosts QuickBooks Desktop at MidCoast Engines. Polls WordPress every 60 seconds, executes QBXML commands against QB Desktop via COM, posts results back. + +## Architecture + +``` +[MidCoast Engines — on-site QB machine] [WP Engine — internet] + QB Desktop (running) + C# Windows Service (QBSyncService.exe) ──HTTPS──▶ WordPress REST API + - polls /wp-json/qbinv/v1/sync/queue - stores sync queue + - executes QBXML via COM SDK - admin UI + - posts results back - heartbeat status +``` + +The C# service is developed on Thomas's laptop (VS 2026) and deployed to the QB machine on-site via Andrew using FileZilla. Thomas uses **Tailscale + RDP** for remote access to the QB machine without requiring anyone on-site. + +## WordPress Plugin + +### Entry point +`midcoast_audit.php` — registers autoloader, hooks, admin menu, REST API, SOAP endpoint. + +### Key constant +`PUBLIC_AUDIT_KEY = 'steelblue123'` — used for the public-facing shortcode `[qbinv_audit_public]`. + +### lib/ files +| File | Purpose | +|---|---| +| `DB.php` | Table names, `install()` (dbDelta), schema | +| `QBSync.php` | Queue helpers + QBWC SOAP service class | +| `REST.php` | REST endpoints consumed by the C# service | +| `Actions.php` | POST mutation handler; fires `qbinv_entry_changed` action on qty save | +| `Importer.php` | Legacy CSV import | +| `Router.php` | URL helpers | +| `Helpers.php` | Shared utilities | +| `QueryBuilder.php` | Dynamic query builder for parts/shelves browse | +| `TableRenderer.php` | HTML table output | + +### views/ files +| File | Purpose | +|---|---| +| `Upload.php` | Import page — primary: live QB pull; secondary: CSV legacy | +| `QBSync.php` | Sync queue admin + settings (token, QBWC creds, .qwc download) | +| `Parts.php` | Browse/edit parts | +| `Shelves.php` | Browse/edit shelves | +| `Audit.php` | Official audit view | + +### Database tables (all prefixed with `{wpdb->prefix}qbinv_`) +- `parts` — inventory parts (part_number, preferred_description, etc.) +- `locations` — bin/shelf locations +- `entries` — qty per part per location +- `uploads` — import history +- `sync_queue` — QB sync action queue + +### REST endpoints (`/wp-json/qbinv/v1/`) +All require `X-QBSync-Token` header matching WP option `qbinv_sync_token`, **except** `/sync/interrupt` which requires `manage_options` capability. + +| Method | Path | Purpose | +|---|---|---| +| GET | `/sync/queue` | Fetch up to 50 pending actions; auto-rescues stale processing rows | +| POST | `/sync/{id}/claim` | Mark action as processing (atomic); sets `processed_at` | +| POST | `/sync/{id}/done` | Save QB response, mark done; fires inventory parser if applicable | +| POST | `/sync/{id}/fail` | Save error message, mark error, increment retries | +| POST | `/sync/heartbeat` | C# service posts version+host+phase+stats; WP stores all | +| GET | `/sync/config` | Remote config (poll interval, mode, interrupt flag, etc.) | +| POST | `/sync/interrupt` | Admin sets abort flag (one-shot, auto-cleared on next config fetch) | +| POST | `/sync/update-status` | C# service reports self-update progress | + +### WP options used +| Option | Purpose | +|---|---| +| `qbinv_sync_token` | API token — must match `appsettings.json ApiToken` | +| `qbinv_wc_username` | QBWC username (legacy SOAP path) | +| `qbinv_wc_password` | QBWC password (legacy SOAP path) | +| `qbinv_adjustment_account` | QB account name for InventoryAdjustmentAdd | +| `qbinv_auto_sync_qty` | Auto-enqueue QB adjustment when qty saved in WP | +| `qbinv_service_last_seen` | Last heartbeat timestamp — drives online/offline dot | +| `qbinv_service_host` | Machine name from last heartbeat | +| `qbinv_service_version` | Service version from last heartbeat | +| `qbinv_service_cycles` | Poll cycles completed since last service start | +| `qbinv_service_items_found` | Pending items found on last poll | +| `qbinv_service_qb_status` | QB connection status: not_attempted / connected / ok / error / interrupted | +| `qbinv_service_last_error` | Last error message from service | +| `qbinv_service_phase` | Current cycle phase string (e.g. "QB: executing 1/1 — ItemInventoryQueryRq") | +| `qbinv_service_log_tail` | Last 100 log lines (autoload=false — fetched explicitly) | +| `qbinv_sync_interrupt` | One-shot abort flag; set by admin Abort button; cleared on config read | +| `qbinv_mode` | `read_only` or `read_write` — gates write actions service-side | + +## C# Windows Service (`QBSyncService/`) + +### Files +| File | Purpose | +|---|---| +| `Program.cs` | Host bootstrap, Serilog, typed HttpClient registration | +| `Worker.cs` | BackgroundService poll loop — heartbeat then RunCycleAsync | +| `WordPressClient.cs` | Typed HTTP client for all WP REST calls | +| `QuickBooksSession.cs` | QB COM wrapper — static `RunSession()` pattern | +| `Models/SyncAction.cs` | Maps to sync_queue row; `BuildFullQbxml()` wraps snippet in envelope | +| `Models/SyncConfig.cs` | Remote config model deserialized from `/sync/config` | +| `appsettings.json` | Config — WP URL, API token, poll interval, QB company file | + +### Key technical details — STA threading (critical) + +**3-phase cycle design** — HTTP and COM are never interleaved: +1. **Phase 1 (HTTP)**: `GetPendingAsync` + `ClaimAsync` for all actions +2. **Phase 2 (COM)**: `QuickBooksSession.RunSession()` — opens QB, executes all actions, closes QB, all on one dedicated STA thread without any awaits or HTTP calls +3. **Phase 3 (HTTP)**: `CompleteAsync` / `FailAsync` for each result + +**Why this matters**: QB's COM server invalidates the session if the STA thread is idle between calls. Any `await` inside the old loop (e.g. `await ClaimAsync()` between `Open()` and `Execute()`) would idle the STA thread long enough for QB to invalidate the session → `COM object separated from its underlying RCW`. + +**`QuickBooksSession.RunSession()`** — static factory. Creates a dedicated STA thread, runs Open → body → Close as one uninterrupted block, joins the thread (10-minute timeout), re-throws any exception via `ExceptionDispatchInfo`. The `Execute()` method is a simple instance method — no marshaling needed because the caller is already on the STA thread. + +**Session timeout**: `thread.Join(TimeSpan.FromMinutes(10))` — if QB hangs (frozen/crashed), throws `TimeoutException` after 10 minutes rather than blocking the service forever. + +- **Late-bound COM**: Uses `Type.GetTypeFromProgID("QBXMLRP2.RequestProcessor")` — no compile-time SDK reference. SDK only required at runtime on the QB machine. +- Logs to `C:\ProgramData\QBSyncService\logs\qbsync-YYYYMMDD.log` and console. + +### Remote interrupt / abort +- Admin clicks **Abort** in WP Admin → QB Sync Queue +- Browser POSTs to `/sync/interrupt` → sets `qbinv_sync_interrupt = true` +- Service reads `interrupt_requested: true` from next `/sync/config` call (within 60s); WP auto-clears the flag +- Service fails all claimed actions with "Interrupted by remote admin command." and skips QB session +- Interrupt only fires between actions — cannot cancel a mid-COM-call. Session timeout handles hung QB. + +### Phase tracking +`_currentPhase` string updated throughout each cycle and sent in every heartbeat: +- `"idle"` — between cycles +- `"fetching queue"` — calling GetPending +- `"claiming (N action(s))"` — claiming loop +- `"opening QB session"` — before RunSession +- `"QB: executing N/M — ActionType"` — inside RunSession, per action +- `"reporting N result(s)"` — Phase 3 HTTP +- `"interrupted"` — after abort +- `"stopped"` — service shutdown + +### NuGet packages +- `Microsoft.Extensions.Hosting.WindowsServices` — Windows Service registration +- `Serilog.Extensions.Hosting` + `Serilog.Settings.Configuration` + `Serilog.Sinks.Console` + `Serilog.Sinks.File` — structured logging +- `Microsoft.Extensions.Http` — `AddHttpClient()` + +### appsettings.json (do not commit real token to public repo) +```json +{ + "QBSync": { + "WordPressUrl": "https://sleeperagents.wpengine.com/", + "ApiToken": "...", + "PollIntervalSeconds": 60, + "QuickBooksCompanyFile": "" + } +} +``` + +## Deployment + +### WordPress (WP Engine) +- FTP/SFTP files to live site after changes +- After deploying: **Settings → Permalinks → Save** to flush rewrite rules +- Purge WP Engine page cache after major changes +- **short_open_tag=On** is active on WP Engine — never write `.TLG` → `.TLG.bak` and reopen + +### On-site infrastructure (MidCoast Engines location) +- **Lantronix device server** — on local LAN, exposes AIS box serial port as raw TCP socket on port `10001` + - Connect via: `nc 10001` — streams NMEA 0183 AIS sentences directly + - No Lantronix CPR software needed for Linux/TCP +- **Linux box** — on-site, running a webserver, on same LAN + - Plan: install Tailscale, use as persistent AIS reader + - Install Tailscale: `curl -fsSL https://tailscale.com/install.sh | sh && sudo tailscale up` +- **AIS data format**: NMEA 0183 (`!AIVDM` = other vessels, `!AIVDO` = own vessel) — needs decoder like `gpsd` +- **IDEC RJ1S-CL-D12 relay** — 12V DC coil, plug-in style. No reset switch — replace if failed (~$10-15) + +## Diagnosing service issues + +- Run EXE directly for console output: `C:\QBSyncService\QBSyncService.exe` +- Check logs: `C:\ProgramData\QBSyncService\logs\qbsync-YYYYMMDD.log` +- QB frozen: rename `C:\QuickBooks\.TLG` → `.TLG.bak`, reopen QB +- Task not starting: Task Scheduler → right-click → Run → check Last Run Result +- Wrong binary deployed: check log first line — must say `Base poll:` not `Poll interval:` +- Service appears stuck: use **Abort** button in WP Admin → QB Sync Queue; flag picked up within 60s + +## Pending / future work +- **First successful sync** — confirm `ItemInventoryQueryRq` goes pending→processing→done and parts table populates +- **Reset & Re-Import** — after successful sync, flush bad CSV data with "Reset & Re-Import from QB" +- **QB auto-launch on Administrator login** — Task Scheduler task to open QB Desktop on login so it survives reboots +- **AIS integration** — Linux box reads Lantronix TCP → gpsd → decoded vessel JSON +- **Auto-start hardening** — verify QB Desktop, service, and Tailscale all survive cold power cycle diff --git a/SA-orchestration MD/Archvie/MidCoast/DEPLOY.md b/SA-orchestration MD/Archvie/MidCoast/DEPLOY.md new file mode 100644 index 0000000000000000000000000000000000000000..31d3a3b694c21b476ad5c19491cbaf977ad410ea --- /dev/null +++ b/SA-orchestration MD/Archvie/MidCoast/DEPLOY.md @@ -0,0 +1,128 @@ +# QB Sync — Deploy Instructions (for Mark) + +You publish two things: **C# binaries** to WP Engine's uploads folder, and **PHP files** to the plugin directory. Andrew downloads one file from WP Engine and double-clicks it. That's the whole chain. + +--- + +## One-time setup (first deploy only) + +On WP Engine, create this folder so the installer has a download target: +``` +/wp-content/uploads/qbsync/ +``` + +Via WP Engine's SFTP/SSH, or the WP Engine file manager. Make sure it's web-reachable — test by browsing to: +``` +https://sleeperagents.org/wp-content/uploads/qbsync/ +``` +You should get a directory listing or a 403 (either is fine; a 404 means the folder isn't there). + +--- + +## Compile + +In VS 2026 on the laptop: + +1. **Publish QBSyncService** + - Right-click `QBSyncService` project → **Publish** → **Folder** + - Target: `bin\Release\net8.0-windows\win-x64\publish\win-x64\` + - Configuration: `Release | win-x64` + - Deployment mode: **Self-contained** + - File publish options: **Produce single file** ✓ / **Enable ReadyToRun** (optional, faster startup) + - Publish + - Expected output: one file `QBSyncService.exe` (~70 MB) + +2. **Publish QBSyncUpdater** + - Right-click `QBSyncUpdater` project → **Publish** → **Folder** + - Same settings as above + - Expected output: one file `QBSyncUpdater.exe` (~15 MB) + +--- + +## Upload to WP Engine + +Upload **only these two files** to `/wp-content/uploads/qbsync/`: + +``` +QBSyncService.exe +QBSyncUpdater.exe +``` + +You do NOT need to upload `appsettings.json` or `updater.json` — the installer generates them on Andrew's machine from his on-screen answers (baked defaults + any overrides he types). + +Verify both files are reachable: +``` +https://sleeperagents.org/wp-content/uploads/qbsync/QBSyncService.exe +https://sleeperagents.org/wp-content/uploads/qbsync/QBSyncUpdater.exe +``` +Both should start downloading when clicked in a browser. + +--- + +## Deploy PHP changes + +Via SFTP to WP Engine, upload the changed plugin files: + +``` +lib/REST.php (adds /sync/restart, restart_requested, updater update channel) +lib/QBSync.php (iterator back, no OwnerID) +``` + +After upload: +- WP Admin → Settings → Permalinks → Save (flushes the REST route cache so `/sync/restart` is live) +- Purge WP Engine page cache + +Verify the new endpoint exists (should return 401 without a token — that's good, means the route registered): +``` +curl -X POST https://sleeperagents.org/wp-json/qbinv/v1/sync/restart +``` + +--- + +## Get the token to Andrew + +The installer needs the API token (value of the WP option `qbinv_sync_token`). You have two options: + +- **A. Email/SMS it to Andrew** — he types it in during install (one-time). +- **B. Pre-bake it** — edit `QBSyncUpdater/InteractiveInstall.cs`, set `DefaultApiToken` to the real token, republish. Andrew just hits Enter through that prompt. Don't commit the token to git. + +Option B is smoother for Andrew. Option A keeps the token out of any binary that's sitting on WP Engine's public `/uploads/` folder. + +--- + +## Send Andrew this link + +> Download and run this file on the QuickBooks machine: +> https://sleeperagents.org/wp-content/uploads/qbsync/QBSyncUpdater.exe +> +> Detailed steps in INSTALL.md (also attached). + +That's it. Andrew gets ONE link. The installer handles everything else. + +--- + +## After Andrew's install + +You should see within 60 seconds: + +- WP Admin → **QB Inventory → QB Sync** shows a green dot with a recent heartbeat +- Service version `1.0.0.0`, host `MCVMFARM2`, QB: OK +- **Two** scheduled tasks on the machine: + - `QB Sync Service` — the actual worker + - `QB Sync Updater` — the monitor/patcher + +From now on: +- Click **Restart** in WP Admin → updater cycles the service within 30s +- Push a new `QBSyncService.exe` to `/wp-content/uploads/qbsync/` and set `qbinv_update_available=true` + `qbinv_update_url` → updater downloads it and swaps it in on its next poll. No FileZilla. No Andrew. +- Same channel works in reverse for the updater itself via `qbinv_updater_update_*` options. + +--- + +## Rolling back + +If the new updater misbehaves, SSH to WP Engine and replace `QBSyncUpdater.exe` with the previous known-good copy. On the QB machine, the **running** updater won't notice (it already loaded) — you'd need Andrew to re-run the installer. Keep a backup copy of each published version in a dated subfolder: + +``` +/wp-content/uploads/qbsync/archive/2026-04-21/QBSyncUpdater.exe +/wp-content/uploads/qbsync/archive/2026-04-21/QBSyncService.exe +``` diff --git a/SA-orchestration MD/Archvie/MidCoast/INSTALL.md b/SA-orchestration MD/Archvie/MidCoast/INSTALL.md new file mode 100644 index 0000000000000000000000000000000000000000..662e3500d1ad72449f66c56d9e8ce90cfbd375c3 --- /dev/null +++ b/SA-orchestration MD/Archvie/MidCoast/INSTALL.md @@ -0,0 +1,161 @@ +# QB Sync — Installation (for Andrew) + +**What this is:** A small background service that lets MidCoast Engines' WordPress site read and update QuickBooks inventory automatically. Once installed it starts itself at logon and stays out of the way — you'll see a small icon in the system tray when it's running. + +**What you'll do:** Download one file. Run it. Click through a short setup window. Check for a tray icon. Done. + +**Time required:** About 90 seconds. + +--- + +## 1. Download + +Open this link in any browser on the QuickBooks machine (`mcvmfarm2`): + +> https://sleeperagents.org/wp-content/uploads/qbsync/QBSyncUpdater.exe + +Save the file anywhere — Desktop is fine. It's about 20 MB. + +--- + +## 2. Run it as administrator + +Find the downloaded `QBSyncUpdater.exe`. + +**Right-click → Run as administrator.** + +Windows will show a UAC prompt ("Do you want to allow this app to make changes?"). Click **Yes**. + +A window opens titled **"QB Sync — Installer"**. This is the only screen you'll interact with. + +--- + +## 3. Review the setup window + +The window has three sections before you click Install: + +### Environment (top) +Read-only info the installer detected about your machine. Confirms: +- **Machine / Windows user** — should say `MCVMFARM2` and `MCVMFARM2\Administrator`. +- **Admin rights** — should be green ✓. +- **QuickBooks SDK** — if green ✓, you're ready. If red ✗ with an **Install SDK now** button, click that button *before* clicking Install. It auto-downloads and silently installs the Intuit SDK (takes 1–2 minutes). +- **QB company file** — shows what it detected in `C:\QuickBooks\`. Informational only. + +### Configuration (middle) +Three fields, mostly pre-filled: +- **WordPress URL** — already set to `https://sleeperagents.org/`. Leave alone. +- **API token** — either pre-filled or blank. **If blank, Mark will send you the value to paste in.** It's a long random string — paste exactly, no spaces. +- **QB company file** — leave blank (auto-detects whatever QB has open). + +There's a **Test Connection** button next to the token field. Click it *before* installing to confirm WordPress accepts your token. You should see a green "Token accepted" line in the log area at the bottom. If you see red, double-check the token with Mark before proceeding. + +### Install button (bottom) +Click **Install**. Progress bar and status log fill in: + +``` +✓ Existing tasks stopped +✓ Service downloaded (70 MB) +✓ Updater copied to C:\QBSyncService\QBSyncUpdater.exe +✓ Wrote appsettings.json +✓ Wrote updater.json +✓ Registered "QB Sync Updater" +✓ Registered "QB Sync Service" +✓ Started "QB Sync Updater" +✓ Started "QB Sync Service" +✓ Reached https://sleeperagents.org/wp-json/qbinv/v1/sync/config — token accepted. + +════ Installation complete and verified. ════ +``` + +When you see **"Installation complete and verified"**, click **Close**. + +--- + +## 4. Find the tray icon + +Within ~30 seconds of install, a small circle icon should appear in your system tray (bottom-right corner, near the clock). + +- **Green** ✓ — everything healthy. +- **Amber** — WordPress has work queued; the service is about to act on it. +- **Red** ✗ — something's wrong. Hover for details. +- **Grey** — starting up / missing config. + +### Windows 11: pin the icon so it stays visible + +On Windows 11, new tray icons hide behind the ⌃ chevron by default. To pin it: + +1. Click the ⌃ chevron (shows hidden icons). +2. **Drag the QB Sync circle icon** from the overflow panel down into the visible tray area. +3. Release. It now stays visible permanently. + +### Windows 10 / Server 2016 + +Usually visible immediately. If not, right-click the taskbar → Taskbar settings → scroll to *Notification area* → *Select which icons appear on the taskbar* → turn on **QB Sync**. + +--- + +## 5. Test the tray icon + +Right-click the tray icon. You should see this menu: + +``` +QB Sync — Last poll: 12s ago +──────────────────── +Open Status Window… +Open Admin in Browser +Open Log Folder +──────────────────── +Restart Service +Force Config Poll Now +──────────────────── +Exit +``` + +**Double-click the tray icon** to open the Status Window. Confirm it shows: +- Green dot + "Service is healthy" +- "Last WP poll: Ns ago" (updating every second) +- QB SDK: ✓ COM ProgID present +- Log tail scrolling at the bottom + +If all that looks good, close the Status Window (it hides back to the tray — the service keeps running) and you're done. + +--- + +## That's it + +Nothing else to do. The service runs automatically from now on, including after reboots (as long as Administrator logs in). + +You won't need FileZilla for this service again — Mark can push updates from WordPress and the service installs them itself on its next poll. + +--- + +## If something doesn't look right + +**No tray icon after install:** +- Open Task Scheduler (Start → type "Task Scheduler"). Look for two tasks: `QB Sync Service` and `QB Sync Updater`. Both should show "Running" status. +- If they say "Ready" instead, right-click each one → Run. +- On Windows 11 check the ⌃ chevron (step 4 above). + +**Red dot after install:** +- Right-click tray → **Open Status Window**. Read the "Last WP error" line — that's the specific problem. +- Most common: token mismatch. Right-click tray → Exit, re-run installer, pay close attention to the token field. + +**"Installation complete, connection not verified":** +- The install still worked — just the token was wrong or WP was unreachable. +- Either re-run the installer with the corrected token, or edit these files directly and restart the tasks: + - `C:\QBSyncService\appsettings.json` + - `C:\QBSyncService\updater.json` + +**Send Mark a screenshot** of the Status Window (or the install log area) if anything looks off — it tells us exactly which step failed. + +--- + +## If you need to uninstall + +Right-click the tray icon → Exit. Then from an Administrator Command Prompt: + +``` +C:\QBSyncService\QBSyncUpdater.exe --uninstall +``` + +Both scheduled tasks are removed and `C:\QBSyncService\` is deleted. Logs in `C:\ProgramData\QBSyncService\logs\` are left behind for reference. diff --git a/SA-orchestration MD/Archvie/MidCoast/README.md b/SA-orchestration MD/Archvie/MidCoast/README.md new file mode 100644 index 0000000000000000000000000000000000000000..49eb656b435cf9bdcbe2b7d499f59da0fa661aee --- /dev/null +++ b/SA-orchestration MD/Archvie/MidCoast/README.md @@ -0,0 +1 @@ +# MidCoastEngines \ No newline at end of file diff --git a/SA-orchestration MD/Archvie/sa-core/README.md b/SA-orchestration MD/Archvie/sa-core/README.md new file mode 100644 index 0000000000000000000000000000000000000000..06a52231bf0c7f486abaf2abdff786dbd58af9a4 --- /dev/null +++ b/SA-orchestration MD/Archvie/sa-core/README.md @@ -0,0 +1,185 @@ +# Core + +The required substrate of the SleeperAgent framework. + +A space is a SleeperAgent space only if Core is online underneath it. Everything above Core is **manufactured processing** — subsystems built on top of Core's primitive set. The primitive set is closed; subsystems extend the framework by composition, not by adding new primitives. + +Core is a WordPress plugin. It ships as `sa-core` on disk. It is the unified package into which every prior Sleeper Agents subsystem (`sleeperagent/`, `saws_plugin/`, `CMP_JNO/`, `sleeperagents-svg-chat/`, `sleeperagents-svg-workspace/`, `sleeperagents-organizations/`, `sa-jira-bridge/`, `take2/`, `carma-audit/`) absorbs over time. + +## What Core provides + +- **Primitive layer** — the closed set of shapes the field is built from: Entity, Edge, InfluenceRing, Membrane, IOPort, ACL, GroupingOverlay. +- **Orchestration** — SubTokenEvent (every internal step visible to the billing principal), GeodesicMarker + GeodesicSeeder (memory of known-failure paths, pre-prompt seeding around them), StructuralAudit (time-indexed readout of attributed acts). +- **Billing** — PricingPolicy (decomposed cost calculation), CredentialPool (multi-tenant encrypted credentials), TokenLedger (immutable, settlement-ready). +- **Packaging** — Capability registry, Package, PackageAssignment (binds package to org/subdomain over time), CapabilityGate (the runtime yes/no with audited denials), SiteProvisioner (in-site activation handler for WPEngine clones). +- **Operating memory** — `SA_Operating_Memory` ingests filesystem trees (repo → folder → file → class/function/heading → method/paragraph → line) as entities at bands 0–5. `SA_Doc_Lens` parses markdown; `SA_Code_Lens` parses PHP via `token_get_all` (no execution). Self-hosting (Pillar 0.9): our own `.md` and `.php` files are navigable as first-class entities. +- **Adapter contract** — `SA_Provider_Adapter` interface. `SA_Dummy_Adapter` is the reference implementation and conformance anchor. + +## Subsystem posture + +Subsystems sit on a spectrum from *manual mirror* to *discernment*: + +- **Manual mirror** — records what the user does, reflects it back, lets them manually re-apply. Low ambition, low risk. +- **Discernment** — infers what the user means, proposes a default path, asks the user to tune small corrections. High ambition, higher value. + +Core biases every subsystem toward discernment. Given the right contextual knowledge, the human's role is **tuning perception**, not operating tools. The machine simulates perception across the spectrum; the human corrects the simulation through small, audited adjustments. + +Core itself is neither; Core is the substrate. Discernment is expressed by subsystems built above. + +## Status + +Alpha. Phase 2 Step 1 of 10. + +Delivered: schema + interfaces + primitive/orchestration/billing/packaging classes + reference dummy adapter + structural audit. Activating the plugin creates all `{wp_prefix}sa_*` tables idempotently via `dbDelta`. + +**Delivered in the second slice (executor + REST + admin UI):** + +- `SA_Provider_Registry` — adapter lookup with capability-flag selection (replaces the legacy binary routing). +- `SA_Context_Piper` — LLM-mediated context selection via edge walk. Given a root entity, gathers neighbors, asks the LLM which are relevant (recorded as a `router` sub-token event), folds selected payloads into the prompt. This is the concrete "context piping via tree-grid" mechanism. +- `SA_Executor` — end-to-end chain: `context piping → geodesic seeding → adapter invocation → sub-token recording → parent + response entities → edges → token ledger → structural audit`. +- REST routes at `/wp-json/sa-core/v1/`: + - `POST /prompt` — run a prompt (optional `root_entity_id` enables context piping). + - `GET /prompt/{id}/trace` — full sub-token graph for a prompt. + - `POST /subtoken/{id}/verdict` — mark good/bad (bad auto-seeds a geodesic marker). + - `GET /memory?path=...` — resolve a path expression to an entity. + - `POST /memory/ingest` — run `ingest_root` (admin only). +- WP admin page *SA Core → Chat* with textarea, optional root-entity input, sub-token trace, and per-step verdict buttons. + +**Not yet delivered:** + +- Presentation layer (zoom-quantum engine, circle primitive, halo renderer, perspective projection). Build order Step 2. +- Migration from the legacy `saws_prompts` / `saws_sessions` tables into `sa_entity` + `sa_token_ledger`. Build order Step 4, Triage item 1. +- WPEngine clone-and-route API client. `SA_Site_Provisioner::activate_site()` picks up *after* WPEngine has cloned the template and routed the subdomain. +- Live provider adapters (OpenAI, Anthropic, Gemini, local) beyond the dummy. Build order Step 8. + +## Install + +1. Drop the `sa-core/` directory into `wp-content/plugins/` on the target WordPress site. +2. Activate *Sleeper Agents Core* in wp-admin. +3. All tables with prefix `{wp_prefix}sa_*` are created idempotently. +4. Safe to activate alongside existing Sleeper Agents plugins. No name collisions, no admin menus (yet), no REST routes (yet). + +Requires PHP 8.1+ and WordPress 6.3+. Production deployments should install libsodium (the CredentialPool falls back to AES-256-CBC via OpenSSL if sodium is absent; the fallback works but is not production-grade). + +## Database tables + +All prefixed `{wp_prefix}sa_`: + +| Table | Role | +|---|---| +| `entity` | Universal unit of the field | +| `edge` | Typed, weighted, band-gated relationships | +| `influence_ring` | Concentric sphere-of-influence bands | +| `membrane` | Per-entity veils | +| `io_port` | Typed anchors on membraned entities | +| `acl` | Per-entity access control, four axes | +| `grouping_overlay` | Attributed, non-destructive re-parentings | +| `subtoken_event` | Every internal orchestration step | +| `geodesic_marker` | Known-failure memory | +| `structural_audit` | Time-indexed readout of attributed acts | +| `pricing_policy` | Per-org/provider/model cost policy | +| `credential_pool` | Multi-tenant encrypted credentials | +| `token_ledger` | Immutable, cost-decomposed, settlement-ready | +| `package` | Purchased SKU with enumerated capabilities | +| `package_assignment` | Binds a package to an org (subdomain) over time | +| `capability` | Enumerable, per-subsystem permission strings | + +## Layout + +``` +sa-core/ +├── sa-core.php # plugin header + hooks +├── includes/ +│ ├── class-sa-core.php # autoload + bootstrap +│ ├── class-sa-migrations.php # schema install for every Core table +│ ├── interfaces/ +│ │ └── interface-provider-adapter.php +│ ├── primitives/ +│ ├── orchestration/ +│ ├── billing/ +│ ├── packaging/ +│ ├── memory/ +│ │ ├── class-sa-operating-memory.php +│ │ ├── class-sa-doc-lens.php +│ │ └── class-sa-code-lens.php +│ ├── executor/ +│ │ ├── class-sa-provider-registry.php +│ │ ├── class-sa-context-piper.php +│ │ └── class-sa-executor.php +│ ├── rest/ +│ │ ├── class-sa-rest.php +│ │ ├── class-sa-rest-prompt.php +│ │ ├── class-sa-rest-verdict.php +│ │ └── class-sa-rest-memory.php +│ ├── admin/ +│ │ └── class-sa-admin.php +│ └── adapters/ +│ └── class-sa-dummy-adapter.php +├── templates/ +│ └── admin-chat.php +├── assets/ +│ ├── admin-chat.css +│ └── admin-chat.js +├── docs/ +│ └── ARCHITECTURE.md # the canonical spec +├── LICENSE +└── README.md +``` + +## Operating memory — map any .md or .php location to an entity + +Register a root once: + +```php +SA_Operating_Memory::register_root('sa-core', SA_CORE_DIR); +``` + +Then resolve any location along the band hierarchy: + +```php +// Band 0 — the repo root +$repo = SA_Operating_Memory::view('sa-core/'); + +// Band 1 — a folder +$primitives = SA_Operating_Memory::view('sa-core/includes/primitives/'); + +// Band 2 — a file +$arch = SA_Operating_Memory::view('sa-core/docs/ARCHITECTURE.md'); +$ent = SA_Operating_Memory::view('sa-core/includes/primitives/class-sa-entity.php'); + +// Band 3 — a markdown heading or a PHP class +$pillar0 = SA_Operating_Memory::view('sa-core/docs/ARCHITECTURE.md:Pillar 0'); +$class = SA_Operating_Memory::view('sa-core/includes/primitives/class-sa-entity.php:SA_Entity'); + +// Band 4 — a sub-heading, a PHP method, or a PHP property +$create = SA_Operating_Memory::view('sa-core/includes/primitives/class-sa-entity.php:SA_Entity::create'); +$id_prop = SA_Operating_Memory::view('sa-core/includes/primitives/class-sa-entity.php:SA_Entity::$id'); +$subhead = SA_Operating_Memory::view('sa-core/docs/ARCHITECTURE.md:Canonical glossary::Entity'); + +// Band 5 — a specific line +$line_42 = SA_Operating_Memory::view('sa-core/includes/primitives/class-sa-entity.php#42'); +``` + +Each `view()` call is idempotent and deterministic — the same expression always returns the same entity id (hash of the expression). Entities persist to `sa_entity` once viewed, so they can be annotated (verdict), wrapped (membrane), connected (edges), or re-parented (grouping overlay) like any other entity. + +For bulk ingestion to band 2 (file-level, one pass across the whole repo): + +```php +SA_Operating_Memory::ingest_root('sa-core', [ + 'org_id' => $org_id, + 'created_by' => $user_id, +]); +``` + +Band 3+ (classes, methods, headings) are materialized lazily on first `view()` to keep the ingestion cheap. + +## Deployment posture + +One WordPress site per customer on WPEngine, on its own subdomain. `sa-core` is activated on every provisioned site. `SA_Site_Provisioner::activate_site()` is the in-site activation handler called after WPEngine clones the template and routes the subdomain. + +Each subdomain maps to one `org_id`; `SA_Site_Provisioner::current_org_id()` resolves it. The active `SA_Package_Assignment` for that org determines which capabilities are enabled. `SA_Capability_Gate::allows()` is the runtime check every subsystem consults. + +## See also + +- `docs/ARCHITECTURE.md` — the canonical spec this plugin is built against. +- The legacy working tree at `../ChatBot/` (private) — contains the pre-absorption subsystems that migrate into Core over Build order steps 4–7. diff --git a/SA-orchestration MD/Archvie/sa-core/docs/ARCHITECTURE.md b/SA-orchestration MD/Archvie/sa-core/docs/ARCHITECTURE.md new file mode 100644 index 0000000000000000000000000000000000000000..d0971f3cd2e22c902a351f70a6f46e723a0c2774 --- /dev/null +++ b/SA-orchestration MD/Archvie/sa-core/docs/ARCHITECTURE.md @@ -0,0 +1,1600 @@ +# MissionNet / S.A.W.S. — Canonical Architecture Specification + +## Mission statement + +> Core is a causal mediation system for sovereign realms. It does not replace the native homes of information. It coordinates, traces, projects, and acts across them while preserving origin, authority, and recoverable lineage. +> +> Core remains coherent because it relates to its subsystems through temporal mediation rather than static containment. + +## Attribution + +Two distinct authorship layers. The distinction is load-bearing. + +- **Conceptual framework** (the Russell–Ouroboros Conjecture and its formalization; the Prime Prompt Conjecture and PR notation; the Cognitive Heatsink model; the quanta-of-LLMs and information-lattice framing; the truth-class asymmetry that prevents paradox; the build-with-MissionNet-in-mind directive that made the other subsystems absorbable): authored independently by **Mark Holak** over a long history of personal designs. Preserved verbatim under `docs/specs/concepts/` with explicit frontmatter. Governed by Invariant **I9** — attributable concepts must not be rephrased as AI-original. +- **Core codebase and this spec pack** (sa-core the WordPress plugin; the enforcement layer at `includes/enforcement/`; the adapter interfaces; the spec pack at `docs/specs/`): © **Sleeper Agents LLC**. Core is one implementation of the framework; the framework is portable and could be implemented elsewhere. + +*Authoring context:* this specification distills the 25 core truths, 11 required conclusions, provider-neutrality contract, and visual-code bias carried in the `/missionnet-prime` brief, then grounds them against the Sleeper Agents working tree at `F:\SleeperAgents\ChatBot\` and the sibling carma-audit tree at `F:\SleeperAgents\CARMA\LIVE\carma-audit\`. Every load-bearing claim is citation-anchored; every surface that will be built has a reference file. The spec is the stable contract the Core is built against. Code is in scope. + +## Formal spec pack + +The enforceable formal layer lives at [`docs/specs/`](specs/README.md): + +- [`01-ontology.md`](specs/01-ontology.md) — vocabulary lock. +- [`02-invariants.md`](specs/02-invariants.md) — nine non-negotiable laws (I1–I9) with enforcement hooks. +- [`03-realm-adapter-contract.md`](specs/03-realm-adapter-contract.md) — formal adapter contract. +- [`concepts/`](specs/concepts/) — Mark Holak's preserved conceptual artifacts. + +Specs 04–12 (canonicality/projection, event/command/audit, identity/authority, policy, cache/freshness, AI mediation, mission/job orchestration, interface contract, observability/integrity) are planned and scoped in the spec-pack index. + +## Foundational asymmetry + +Core distinguishes between **six truth classes** — `canonical`, `projected`, `cached`, `inferred`, `derived`, `synthetic` — which cannot collapse into each other. That asymmetry is what makes the ouroboros non-paradoxical: a system that consumes itself can only stay coherent if the act of consumption preserves distinctions as it moves. The timeless lattice translates through spacetime *because* the asymmetry survives the motion. + +Invariants I2 and I7 enforce the asymmetry at runtime. `SA_Entity::create()` rejects any entity that omits its truth class; `SA_Invariants::check_ai_output_not_canonical()` rejects any AI-sourced entity stamped canonical without source_refs or human affirmation. The asymmetry is not decorative — it is what prevents self-reference from collapsing into itself. + +*Ouroboros prologue:* Core and its subsystems are written in parallel by different hands (some human, some Claude-assisted, some mine). None of them sits still while the others evolve. The architecture embraces this: Core **bends toward** the solved patterns already demonstrated in sibling projects — it does not steal their code, it shapes itself to receive them. When concept-canvas proved the shape grammar, Core adopted the grammar's vocabulary, not the file. When PortTracker proved rigid panels can become liquid per-viewer perception, Core added Projection Slots, not Leaflet. When CellPond proved complexity density can subdivide on demand, Core named the principle as Pillar 0.25, not the rAF loop. Subsystems in turn bend toward Core's primitive set. They meet in the middle. The whole thing started on its tail and keeps feeding itself. + +### Russell–Ouroboros Conjecture (Formalized) + +*Named by the product owner during authorship of the spec.* + +Classical self-reference fails under static evaluation. Russell's paradox demonstrates that containment as a **timeless membership relation** produces contradiction when applied to self-referential systems. + +This system does not use containment. It uses **consumption**. + +> **Containment is static. Consumption is motion.** + +A system that "contains itself" must resolve a boolean contradiction. A system that "consumes itself" evolves through state, and therefore resolves through **process, not truth assignment**. + +#### Core principle + +Core does not contain its subsystems. Core **consumes, mediates, and re-emits** across them. + +- No subsystem is owned unless explicitly absorbed. +- No external system is redefined as internal. +- All relationships are **temporal and traceable**. + +This preserves system integrity while enabling unified interaction. + +#### Absorbed vs. Incorporated (causal distinction) + +**Absorbed.** Sleeper Agents code becomes part of Core's causal body. (`sleeperagent`, `carma-audit`, `concept-canvas`, future PortTracker UI.) + +**Incorporated.** External systems remain sovereign. (FluentCRM, Fluent Support, Fluent Boards, Jira, QuickBase, QuickBooks, PortTracker's Node backend, arbitrary APIs.) + +> Incorporated systems are not pulled inward. They are traversed. + +This distinction enforces **causal origin preservation**. + +#### The tool test (causal integrity check) + +If Core disappears: + +- FluentCRM continues operating. +- QuickBooks continues operating. +- Jira continues operating. + +Therefore: + +> Core is not a container. Core is a **field of interaction**. + +Cache may exist, but: TTL-bound, non-canonical, never a source of truth. The origin always remains in its native realm. + +#### Realm adapters (conduits, not importers) + +Adapters do not ingest in the sense of claiming. They **mediate flow**. + +- Bidirectional by default (pull + push). +- Structurally similar by design. +- No ownership of data. +- No mutation without trace. + +> Data passes through Core. It does not terminate in Core. + +#### Russell–Ouroboros resolution (formal) + +The paradox dissolves under motion. Instead of asking "does the set contain itself?", we ask "what is the state of the system as it transforms itself?" + +$$ +S_{t+1} = F(C_t, S_t) +$$ + +Where: + +- $S$ = total system +- $C \subseteq S$ = consuming subset (the Core mediation layer) +- $F$ = transformation across realms + +The consuming subset is **not exempt**. Core is subject to its own mediation and audit. Every invocation Core makes — provider calls, ingest mutations, projection renders, geodesic seedings — is itself consumed into the audit. The processor is processed. + +> The system processes itself, including the processor. + +This is the Ouroboros condition. + +#### Causal overlay + +This system operates as a **causal lattice**, not a container. + +- Every action produces a traceable lineage. +- No state exists without origin. +- No transformation occurs without record. + +> Consumption introduces motion. Motion introduces causality. Causality introduces trace. + +Core enforces: **origin traceability**, **non-destructive lineage**, **reconstructability of state**. This is how motion avoids paradox. + +#### Stationary vs. activated states + +**At rest.** Systems remain isolated in their native domains. No forced unification. + +**When touched.** Core activates. Mediation occurs. Causal balance is enforced. + +> Humans are the activation function. + +No unnecessary motion. No artificial coupling. + +#### Echo risk (critical constraint) + +Because Core consumes and re-emits, there is risk of recursive amplification — echo loops where inferences from prior rounds re-enter as authoritative input. Mitigation lives in four mechanisms: + +- **Origin tagging** — every mutation records the actor and the originating intent. +- **Path tracing** — `SA_Structural_Audit::trace_origin()` walks any state back to the first human-attributed act. +- **Recursion boundaries** — per-package policy on hop count without fresh human input. +- **Audit verification layers** — the processor is processed; Core's own invocations are auditable too. + +> Without origin discipline, consumption becomes distortion. + +#### Final framing + +This architecture is not a platform. It is not an aggregator. It is not a data warehouse. + +> It is a **causal mediation field** that allows independent systems to interact without surrendering identity. + +Russell's paradox breaks systems that attempt to statically contain themselves. The **Russell–Ouroboros Conjecture** states: + +> A self-referential system remains coherent when its self-interaction is expressed as a time-evolving causal process rather than a static membership condition. + +Every pillar, adapter contract, and build step in this spec is an expression of the conjecture. Where the spec appears philosophical, it is actually specifying invariants the code enforces (see `SA_Structural_Audit::trace_origin`, `SA_Realm_Adapter::pull/push`, `SA_Context_Bundle::signature_of`, Pillar 0.25's no-polling rule, and R15/R16 risk mitigations). + +--- + +## Executive synthesis + +**MissionNet is a spatial information field whose total informational content is conserved across zoom levels.** Zoom is not a viewport transform; it is a *semantic quantum*. Zooming out auto-compresses proximate entities into atoms of truth; zooming in auto-expands atoms into concentric bands of influence. The circle ↔ rounded-rectangle morph is the physical signature of a zoom-quantum boundary: circle = compressed atom, rrect = expanded container. The field is time-indifferent — time-derived things (summaries, averages, perturbations, audit logs) are *emergent outputs* of sampling the field, not structural features of it. + +**S.A.W.S. (SleeperAgent Workspace System) is the provider-neutral orchestration and exchange layer beneath MissionNet.** Every LLM call, embedding, retrieval, tool invocation, sub-agent hop, and router decision is a first-class entity. The universal accounting invariant is *"I spent a token"* — and every sub-token the orchestrator spends on the user's behalf is **visible to that user**, annotatable as `good` or `bad`, and fed into a geodesic memory that seeds future prompts around known failure paths. Pricing is policy, not law; provider choice is a capability question, not a credential question; credentials are multi-tenant pools, not a single global key. + +**They are one system, not two.** Document mode, art mode, code view, admin view, billing view, and map view are all projections of the same entity graph. Membranes wrap entities into black-box domains with typed I/O ports; billing principals alone may unfold a membrane's interior. Imposed groupings are attributed, non-destructive overlays — the same data can be parented differently by different authors, and every viewer sees a projection tailored to their permissions and active overlays. This is the most powerful available mediation of knowledge: universal field, per-viewer rendering, auditable structural acts. + +**The product posture is symbiotic.** The machine simulates perception; the human inscribes *curved intent* (geodesics, not straight lines, because real choices bend around constraints the machine does not fully model). MissionNet shows geometry; the human elects a path. The system does not solve everyone's problems — it offers tools modular enough for users to solve their own, from a small closed primitive set extensible by composition. MissionNet navigates itself: its own code, schemas, policies, overlays, and ledger rows render as entities in the field. The "recursive snakes eating itself while creating more of itself. Ever replenishing" is literal. + +**Core architectural commitment:** absorb every subsystem in the working tree — `sleeperagent/`, `saws_plugin/`, `CMP_JNO/`, `sleeperagents-svg-chat/`, `sleeperagents-svg-workspace/`, `sleeperagents-organizations/`, `sa-jira-bridge/`, `take2/`, `salib/`, and the branded `carma-audit/` — into a single Core. Branded wrappers remain as customer-facing builds fed by the Core, not as independent projects. Today's working tree is **not canonical**: SAWS was at some point overwritten by the token-tracking service and must be reconstructed from the Core's primitive layer rather than salvaged line-by-line. + +--- + +## Folder map + +Themed inventory of the audited corpus, with file paths anchored to the root `F:\SleeperAgents\ChatBot\` (and `F:\SleeperAgents\CARMA\LIVE\carma-audit\` where noted). + +### SAWS plugin lineage (three divergent copies — one canonical) + +- **`sleeperagent/`** — the most evolved baseline. 22 PHP files, 5 JS, 2 CSS. Contains the live SAWS spine (as of the post-overwrite state). + - `sleeperagent.php` — plugin bootstrap; header references `https://sleeperagents.org/wp-admin/admin-post.php?action=saws_export_flat`. + - `includes/class-saws-db.php` — canonical schema for `saws_prompts`, `saws_sessions`, `saws_files`, `saws_nodes`, with `cost_usd DECIMAL(10,6) NULL` column. + - `includes/class-saws-executor.php` — 121 lines; routes to provider; audits invocation `source` (`chat`, `salib`, `jira`, `system`). + - `includes/class-saws-chat.php` — REST endpoint + binary provider selection (`$has_openai_key ? 'openai' : 'dummy'`). + - `includes/class-saws-admin.php` — 294-line split-pane admin UI; hardcoded rate `$0.002/1K` at line 120; Markdown/Raw toggle. + - `includes/class-saws-files.php` — 266 lines; SHA256 dedup, MIME validation, status workflow (`uploaded → queued → processing → ready/failed`), OpenAI file-API integration. + - `includes/class-saws-render.php` — PDF text extraction via `Smalot\PdfParser\Parser`. + - `includes/class-saws-jobs.php` — Action Scheduler integration for background queues. + - `includes/class-saws-nodes.php` — node CRUD with `org_id` on the nodes table (but **not** on `saws_prompts`). + - `includes/class-saws-tasks.php` — task CRUD with `org_id`, group access control. + - `includes/class-saws-init.php` — activation/deactivation hooks, heartbeat table, WP-CLI registration. + - `includes/models/class-saws-model-openai.php` — OpenAI chat-completions adapter; returns normalized `{response, model, usage, latency_ms, request_id}`. + - `includes/models/class-saws-model-assistant.php` — OpenAI Assistants API specialization (file-capable, hardcoded `gpt-4-1106-preview`). + - `includes/models/class-saws-model-dummy.php` — echo fallback. + - `includes/Parsedown.php` — markdown renderer. + - `assets/modal.js` — file upload modal; contains `TODO: Load actual folders from database via AJAX`. +- **`CMP_JNO/`** — older baseline. 16 PHP, 2 JS, 1 CSS. Missing `class-saws-db-migration.php`, `class-saws-jobs.php`, `class-saws-nodes.php`, `class-saws-render.php`, `class-saws-files.php`. Superseded. +- **`saws_plugin/`** — earliest experiment. 14 PHP, 2 JS, 1 CSS. Frozen Jan 17. Superseded. Has own `.git`. + +### SVG presentation plugins + +- **`sleeperagents-svg-chat/`** — SVG-based 1:1 chat visualizer. + - `sleeperagents-svg-chat.php` — hardcoded upstream `https://nujacsaints-corporatechatapi.hf.space/v1/chat` at line 10; demo auto-login `nujacsaints@gmail.com` at line 93. + - `assets/js/sa-svg-chat.js` — rounded-rect nodes (`rx=10`), user x=140 / assistant x=380, vertical spacing 80, cubic-bezier dashed links, IP-based color mapping. + - `assets/css/sa-svg-chat.css` — user node `#AFC`, assistant node `#ACF`, link stroke `#94a3b8`. +- **`sleeperagents-svg-workspace/`** — multi-user collaborative SVG workspace, no LLM backend. + - `sa-svg-workspace.php` — REST endpoint `/wp-json/sa-svg-workspace/v1/state`; tables `sa_svg_workspace_nodes`, `sa_svg_workspace_links`. + - `assets/js/sa-svg-workspace.js` — ~600 lines; `screenToWorld` / `worldToScreen` pan-zoom math; CRUD icons (`👁 ✅ ✏ ✖`); 3000ms polling loop at line 59; 8-tool toolbar; selection highlight `#38bdf8`. + - `assets/css/sa-svg-workspace.css` — dark palette `#0b1220` → `#020617` radial gradient. + +### Tenancy, integrations, bridges + +- **`sleeperagents-organizations/`** — multi-tenancy foundation. + - `sleeperagents-organizations.php` — CPT `sa_organization`, role `sa_org_admin`, usermeta `sa_org_id`, URL rewrites `/org/{slug}/portal` and `/org/{slug}/info`, login redirect to portal. +- **`sa-jira-bridge/`** — most mature subsystem; 20 PHP files across OAuth, Jira client, rooms, REST, admin. + - Key files: `includes/class-sa-jira-bridge.php`, `includes/jira/class-sa-jira-client.php`, `includes/jira/class-sa-jira-system-client.php`, `includes/rest/class-sa-jira-rest.php`, `includes/rooms/class-sa-jira-room-repository.php`, `includes/auth/class-sa-jira-oauth.php`. +- **`salib/`** — empty shared library (reserved slot). +- **`take2/`** — standalone FastAPI LLM bridge. + - `app.py` — in-memory metrics `record_request(session_id, tokens_used)`; env-configurable `TESSAI_LLM_URL` / `TESSAI_LLM_KEY`; echo fallback; port 7860 (HuggingFace Space). + - `Dockerfile`, `requirements.txt` (fastapi, uvicorn, httpx, pydantic). + +### Onboarding + +- **`Onboarding/PDF Trainer/`** — teleprompter-style training player. + - `index.php` — segment/pace timeline UI; load-bearing pacing logic: *"Progress cue: fill background of teleprompter steadily through the segment"*. + - `transcript.json` — 116-line segment script with timing hints. + +### Experimental / deprecated / unclear + +- **`take2/.git/`** — git history for the FastAPI bridge. +- **`saws_plugin/.git/`** — frozen git history. + +### Duplicate licensing stubs (ignore for vision content) + +- `CMP_JNO/README.md`, `sleeperagent/README.md`, `saws_plugin/README.md` — identical GPL stub. +- `CMP_JNO/NOTICE.md`, `sleeperagent/NOTICE.md`, `saws_plugin/NOTICE.md` — identical copyright. Contains canonical expansion: *"SleeperAgent Workspace System (SAWS) Copyright (c) 2025 SleeperAgents LLC."*. +- `CMP_JNO/scaffolding.txt`, `sleeperagent/scaffolding.txt`, `saws_plugin/scaffolding.txt` — identical directory template. + +### CARMA (Sleeper Agents product, customer-branded) + +- **`F:\SleeperAgents\CARMA\LIVE\carma-audit/`** — WordPress maintenance toolset. GPL-2.0, ~263 KB, 7 PHP / 2 JS / 2 CSS. + - `carma-audit.php` — bootstrap, menu registration, `carma_log()` polyfill. + - `includes/class-audit-scanner.php` (357 lines) — filesystem treemap; public API `run()`, `available_roots()`. + - `includes/class-audit-refs.php` (448 lines) — DB reference scanner, duplicate detection, reference rewrite, file deletion; public API `find()`, `analyse_duplicates()`, `resolve()`, `preview()`. + - `includes/class-menu-capture.php` (184 lines) — snapshots `$menu`/`$submenu` at `PHP_INT_MAX - 1`; public API `get_cache()`, `force_reset()`. + - `includes/class-menu-override.php` (672 lines) — applies theme at `PHP_INT_MAX`; per-user / per-role / global scope; 4 item types × 4 statuses. + - `admin/class-audit-admin.php` (468 lines) — Scanner/Dedup/Resolver/Settings pages + 6 AJAX endpoints. + - `admin/class-menu-admin.php` (761 lines) — Menu Manager UI + 6 AJAX endpoints + 728-icon dashicon registry. + - `admin/js/audit.js` (681 lines) — D3 v7 treemap renderer, dedup table, resolver queue. + - `admin/js/menu-manager.js` (1,580 lines) — drag-drop menu editor, theme CRUD, preview sync, SVG connectors. + +### Vision prose (note: minimal — the vision lives in the `/missionnet-prime` brief) + +The working tree contains very little standalone vision prose. The `Onboarding/PDF Trainer/transcript.json` pacing script and the `NOTICE.md` files are the only durable documentation. All other vision content is embedded in comments, CSS palettes, and schema column choices. This specification treats the `/missionnet-prime` brief as the primary vision source and the folder as the grounding implementation substrate. + +--- + +## Canonical glossary + +Normalized terminology. Where the tree uses multiple words for one concept, the first term is canonical. + +- **Entity** — the universal unit of the field. Renders as circle (compressed atom) or rounded rectangle (expanded container). Carries position, edges, halo, membrane, ports, ACL, overlays. *(Supersedes: "node", "card", "shape", "item", "prompt row", "workspace node".)* +- **Circle (atom)** — an entity whose contents are currently compressed; rendered as a circle (`rx = min(w,h)/2`). The band at which this is the default rendering is its *compression band*. +- **Rounded rectangle (container)** — an entity whose contents are currently expanded; rendered as a rrect (`rx = 10` today). The band at which this is the default rendering is its *expansion band*. +- **Band (zoom quantum)** — a discretized zoom level with its own rendering contract. Crossing a band boundary triggers the morph of an entity's shape, the fade of its halo, and the fold/unfold of its children. Six illustrative bands: *domain → workspace → document → section → paragraph → leaf*. +- **Auto-compress / auto-group** — when the viewport zooms out across an entity's compression band, spatially- or semantically-proximate entities squeeze into a single synthetic parent atom. The parent's `auto_group_id` attributes the grouping to the field itself (as opposed to a human author). +- **Auto-expand / unfold** — the inverse; zooming in past an entity's expansion band reveals its children, replacing the atom with a container. Previously-implicit influence rings become explicit. +- **Influence halo** — a set of concentric rings drawn around an entity at its expansion band, each ring listing weighted references to other entities. Inner rings are strongest/closest influences. The halo is how the sphere of influence is rendered. +- **Field** — the totality of entities, edges, halos, overlays, and sub-token events. Governed by the conservation invariant (below). +- **Conservation invariant** — total informational content is conserved across bands. Compression does not destroy information; it redistributes density. Zooming back out must reconstruct the same entity with the same halo (deterministic signatures required). +- **Membrane** — a declarative veil on an entity that turns it into a black-box domain. Opaque to unauthorized viewers, translucent to the billing principal (Pillar 0.75), unfoldable by permissioned viewers (ACL.unfold). +- **I/O port** — a typed edge anchor exposed by a membraned entity. The only integration surface visible to outsiders. +- **Edge** — a typed, weighted, band-gated relationship between entities (`link | contains | depends | influences | flows-to`). May terminate on entity bodies or on named ports. +- **ACL** — per-entity access control list with scope (`private | shared | org | public`) and four axes (`read | unfold | modify | impose_grouping`). Layered on top of `org_id` tenancy. +- **Grouping overlay** — a first-class, attributed, non-destructive re-parenting of an entity set under a synthetic parent, visible only to viewers within the overlay's scope. +- **Perspective projection** — the rendering function `view = project(field, viewer, active_overlays, permissions)`. Every rendering is viewer-parameterized from day one; there is no default view that bypasses projection. +- **Sub-token event** — a first-class entity at band 5 inside a parent prompt invocation, recording an internal orchestration step (embed, retrieve, tool, subagent, completion, rerank, self-critique, router). Annotatable by the billing principal. +- **Geodesic marker** — a memory record that a particular sub-step kind failed under a particular trigger signature. Seeded into future prompts to route around the failure. +- **Geodesic seeder** — a deterministic pre-prompt pass that matches the upcoming invocation against geodesic markers and injects avoidance hints before the provider sees the prompt. +- **Provider adapter** — a membraned entity implementing the adapter interface. Input/output ports are visible; internals are veiled except to the billing principal. +- **Pricing policy** — a configurable object that computes cost from `{prompt_tokens, completion_tokens, provider, model, org_id}` using per-org/per-provider rates, markup, transaction fee, management fee. +- **Credential pool** — a multi-tenant, multi-provider, encrypted credential store. Replaces the current single global OpenAI key. +- **Core** — the unified package into which every current subsystem absorbs. Working name `sa-core/`. +- **Subsystem** — a coherent slice of the Core (SAWS executor, presentation, tenancy, Jira, carma-audit, etc.). A subsystem may carry a customer-facing brand (e.g., CARMA) without being a separable project. +- **SAWS** — the provider-neutral orchestration + exchange subsystem of the Core. *Expanded: SleeperAgent Workspace System (per `sleeperagent/NOTICE.md`).* +- **MissionNet** — the art-first lattice UI subsystem of the Core. Two views of one field with S.A.W.S. +- **CARMA** — customer-facing brand for the WordPress toolset currently at `F:\SleeperAgents\CARMA\LIVE\carma-audit\`. A subsystem of the Core, not a third-party integration. +- **Billing principal** — the user / org debited for a given invocation. Has unfold rights on the membranes of that invocation's sub-token graph for as long as the ledger row is retained. +- **Curved intent** — a human-elected path through the field that respects real-world constraints the machine does not fully model. Geodesics, not straight lines. +- **Ouroboros commitment** — MissionNet must render itself; its own schemas, policies, overlays, and code are first-class entities in the field. + +--- + +## Core architectural pillars + +Four philosophical pillars anchor the system. Every downstream pillar is derivative. + +### Pillar 0 — The recursive field + +MissionNet is one spatial information field. Total informational content is **conserved across zoom levels**, analogous to energy conservation but purely spatial. Zoom is a **semantic quantum**, not a viewport transform. + +- **Auto-compress → auto-group.** Zooming out causes proximate entities to squeeze together into a new parent atom. The recursion (core truth 4) is produced *by the field* as a function of compression, not authored by hand. +- **Auto-expand → banded sphere of influence.** Zooming in unfolds an atom. Around it, concentric bands of influence become visible — inner bands are strongest, outer bands are weakest. +- **Quantized bands, not continuous zoom.** Each band has its own rendering contract. Crossing a boundary is a morph event. +- **The circle ↔ rrect morph IS the band-crossing.** Circle = compressed atom; rrect = expanded container. The morph is not decorative; it is the physical signature of crossing a quantum. (Reinterprets core truths 5–7.) +- **Time-indifference.** The field is time-invariant. Timelines, running averages, witnessed perturbations, audit logs — these are **emergent outputs** of sampling the field, not structural features of it. +- **Sphere of influence as a visual primitive.** Concentric rings reference other entities weighted by influence scalars. The halo is the atom's contents re-expressed in ring form at the current zoom band. + +Consequences: the entity schema carries weighted/banded influence edges; the foundational motion primitive is *band-crossing morph*; the minimap is mandatory because the conservation invariant is only legible when two bands are simultaneously in view. + +### Pillar 0.25 — Causal balance, stationary at rest, complexity density on-demand subdivision + +This pillar names the runtime dynamics of the field — how it behaves when touched vs. when untouched. It is peer to Pillar 0 (static structure) and precedes Pillar 0.5 (access control). + +**Causal balance, distributed.** The field is a balanced causal state where every perturbation propagates through weighted, typed edges to dependent projections. Closer to biological neural activation than to classical request/response: a write to entity X triggers re-projection of every slot whose query depends on X, which may trigger further re-projections through influence halos. The field is always balancing when touched. + +**Stationary at rest.** When no perturbation is occurring, the field is **genuinely still**. No polling. No background ticks. No heartbeats. No `setInterval`. No `requestAnimationFrame` re-arming on empty frames. The renderer paints once and holds the frame until the next real event. Zero CPU, zero network, zero audit chatter. Time-indifference from Pillar 0 is realized in runtime. + +*Non-compliance example:* CellPond's main loop at `F:\SleeperAgents\CellPond\the-one-true-todey-file-of-cellpond.js` line ~3011 re-arms `requestAnimationFrame` unconditionally every frame, burning GPU on idle frames. Core forbids this pattern. A Core renderer that binds to rAF must gate: paint on viewport change, entity mutation, or explicit user gesture; otherwise release the frame. + +**Complexity density on-demand subdivision.** Detail materializes only where attention is. Entities below the expansion band render as compressed atoms, not as hidden nested trees. Influence halos become explicit only when their band is entered. Grouping overlays are layered at render time, not eagerly precomputed. A slot's query is evaluated at the current viewport's band, not at band 5 preemptively. This aligns with Pillar 0's conservation invariant (information is conserved, but *density* is the variable that redistributes) and adds the runtime rule: **never materialize what the viewer is not currently attending.** + +*Reference pattern:* CellPond's `splitCell` (same file, ~line 1393) and `isCellVisible` (~line 563) prove the fractal-subdivide + visibility-cull approach for cellular grids. Core adopts the **pattern** (lazy subdivision, visible-region filter) at the entity-graph level: child entities materialize on first `view()` into their parent's expansion band; off-viewport entities do not render, do not emit halos, do not emit events. + +**Action paired with user interaction.** Every change in the field is attributable to an act: a user gesture, a service-principal ingest, an internal orchestration step, or a scheduled maintenance window. Core has no autonomous demon. Even the geodesic seeder runs only when a prompt is submitted; structural audit writes only when a structural act happens; pricing re-resolution happens only on ledger append. If the field ever appears to be doing something with no attributable actor, that is a defect. + +**Balance detection.** After every event chain completes, the structural audit records the chain's closure. Downstream projections that re-render carry a `balanced_at` timestamp on their cache entries. "Instantly balancing always" is the continuous limit; "balanced" is the achieved state. The system can always answer: *is this projection up to date as of write T?* + +**Implications for every subsystem:** +- No polling loops. Replace with events, SSE, or webhooks. +- No background tickers without a named maintenance window and an audit row. +- Renderers must gate rAF on real state changes or user gestures. +- Lazy materialization is the default; eager evaluation requires justification. +- Every autonomous-looking act must resolve to an originating actor (user, service principal, or scheduled policy). + + + +No viewer ever sees the whole field as-is. What a viewer sees is always a *projection* shaped by membranes, imposed groupings, and permissions. + +- **Membrane.** A declarative veil on an entity; across it only typed I/O ports are visible. Outsiders see a closed atom with ports; permissioned insiders unfold the interior. +- **I/O port.** The only edge anchor exposed by a membraned entity. A typed payload contract that outsiders can bind to without seeing the mechanism. +- **Imposed grouping (overlay).** A first-class, attributed, non-destructive re-parenting of an entity set. The same data can carry many simultaneous groupings; each records `{author_id, created_at, scope, reason, members}`. +- **Perspective projection.** `view = project(field, viewer, active_overlays, permissions)`. Two people looking at "the same" region can legitimately see different parent-child structure, different membrane states, different halo contents, different subset of entities. +- **Audited mediation.** Every structural act (membrane change, permission change, grouping, unfold) is attributed and timestamped. The audit log is a *time-indexed projection*, consistent with Pillar 0's time-indifference. + +Non-goal: cryptographic sealing. The membrane is access-control and perception-control, not a TEE. + +### Pillar 0.75 — Radical transparency of the orchestration stack + geodesic memory + +S.A.W.S. rejects the "hide the sub-tokens" posture common to orchestration platforms. + +- **Billing-principal translucency.** Pillar 0.5 veils interiors to outsiders; Pillar 0.75 adds the exception that *the principal debited for an operation may unfold its membrane*. The membrane remains opaque to everyone else. "If a user pays for it, we expose it." +- **Every sub-token is a first-class entity.** Embed, retrieve, tool, subagent, completion, rerank, self-critique, router — all are band-5 entities inside the parent prompt's band-4 container. Each carries `{kind, provider, model, tokens, cost, latency, step_index, verdict, verdict_author, verdict_reason, verdict_markers}`. +- **Per-sub-step verdicts.** The user can click any sub-step and mark it `good` or `bad` with a reason. This does not rewrite history; it layers attribution. +- **Geodesic memory.** Verdict-marked failures become `GeodesicMarker` records. Before every subsequent prompt, a `GeodesicSeeder` pass matches the invocation against markers and injects avoidance hints. Over time the system routes around known failure paths by construction. This is the architectural formalization of the user's own practice — *seed curve information geodesists to avoid known pitfalls.* +- **Marker scope.** Private markers stay with the author; org markers apply across the org; public markers are opt-in pooled knowledge. + +Non-goal: opening the provider's internals. We see S.A.W.S.'s own orchestration end-to-end, not what happens inside OpenAI's GPU. + +### Pillar 0.9 — Primitives, extensibility, symbiosis, self-hosting + +The product's posture toward its own evolution. + +- **Closed primitive set.** Field is built from a small vocabulary: circle, rrect, membrane, port, edge (typed/weighted/banded), influence halo, grouping overlay, sub-token event, geodesic marker. Everything the system represents is **composed** from this set. New primitives require explicit justification. +- **Config-first, editor-second, document-always.** Most surfaces are driven by plain-text / JSON. Visual editors appear where traffic justifies them. Because rrect is already a document reader, every configurable surface has a third paradigm: read it as a document. Three paradigms for every concept — text to author, visual to edit, geometry to navigate. +- **Human-machine symbiosis.** Machine's role is **perception** (simulation, aggregation, rendering, automation within a defined context). Human's role is **real-world impact** (evaluating risk, electing a path, inscribing curved intent). The system shows geometry; the human elects a path. When multiple paths are plausible, the system surfaces ambiguity rather than picking. Inverse of the usual "AI takes over" posture. +- **Self-hosting as mission.** MissionNet must navigate itself. Its code, schemas, policies, overlays, motion presets, geodesic markers, adapter registry — all are first-class entities in the field. *"The recursive snakes eating itself while creating more of itself. Ever replenishing."* is a literal architectural claim. +- **The "broken out CNN of humans" image.** Zoom bands are layers, perspective projections are filters, influence halos are feature maps — but humans sit at every activation function, supplying real-time context and choosing which paths propagate. No layer is a closed black box to the billing principal. +- **Mission posture, not feature list.** The product does not solve every user's problem. It offers tools modular enough to let users solve their own. *"We only have to show the geometry of what they want to achieve."* + +- **Core vs. manufactured processing.** Core is the minimum substrate: primitives, ledger, projection, packaging. **Nothing is part of the SleeperAgent framework unless Core is online underneath it.** Everything above Core is *manufactured processing* — subsystems built on top of the primitive set. There is no "Core-less" product; a space without Core is not a SleeperAgent space. + +- **Manual-mirror → discernment spectrum.** Every manufactured-processing subsystem sits somewhere on a spectrum. At one end, a subsystem *mirrors* the user's actions (record, replay, reflect back). At the other end, a subsystem *discerns* the user's intent (infer from partial action, propose completions, offer a default path the user corrects). The architectural bias is toward discernment. Rationale: given the right contextual knowledge, the human's role is **tuning perception**, not operating tools. The human corrects the system's reading of them; the system does not act on the human's behalf without consent. This sharpens the symbiosis claim — machine simulates perception across a mirror→discernment spectrum; human tunes that simulation through small, audited corrections. + +--- + +### Derived pillar — Two views, one system (document ⟷ art) + +Document mode and art mode are projections of the same entity graph. Document mode is band-5 sampling with linear reading order; art mode is band-N sampling with spatial layout. Same field, different sampling contract. Both honor the viewer's membranes, imposed overlays, and permissions — redactions and imposed-grouping reorderings are legitimate. + +### Derived pillar — Shape grammar (circle + rrect only, with semantic meaning) + +- Circle = compressed atom; rrect = expanded container. +- Morph is triggered by band-crossing, not by user click. +- Containment is a `` parent; recursion is `` inside ``; halo is a sibling `` rendered as concentric `` rings between the atom and its parent. +- Only two primitives are necessary because each can contain, recurse, expand, collapse, and represent organization (core truth 6). + +### Derived pillar — Motion grammar (band-crossing first, presets second) + +Foundational primitive is `onBandCross`. All other motion is a JSON preset: `{name, easing, duration, scale-from, scale-to, rx-from, rx-to, delay-by-depth, halo-fade-curve}`. Injection points: + +- `onEnter`, `onExit` — entity lifecycle +- `onPromote`, `onDemote` — parent change +- `onLink`, `onMerge` — edge creation +- `onBandCross` — foundational; zoom-quantum boundary +- `onAutoGroup` / `onUnfold` — field-driven recursion +- `onMembraneVeil` / `onMembraneUnveil` — permission-gated opacity +- `onOverlayApply` / `onOverlayRemove` — imposed grouping fade +- `onPerspectiveSwitch` — viewer or overlay-set change → re-projection pass + +A full timeline sequencer is deferred. The `Onboarding/PDF Trainer/index.php` segment/pace model is a reusable seed for preset shapes. + +### Derived pillar — Linking, merging, containment, dependency + +- Default: soft touch does nothing (consistent with the current workspace having no auto-link). +- Link tool active → soft touch creates a `link` edge (intentional=true). +- Merge tool active → soft touch creates a new parent rrect containing both children; `children_order` preserves pre-merge orientation; relative `grid_cell` positions are retained. +- Containment renders as a `contains` edge; dependency renders as `depends`; influence renders as `influences` with a scalar weight; flow renders as `flows-to`. +- Edges render as the existing cubic-bezier dashed path (already in `sa-svg-chat.js` and `sa-svg-workspace.js`). + +### Derived pillar — Universal zoom bands (code is a special case) + +Bands are a property of the whole field. Six illustrative bands: + +``` +band 0 — domain / organization (code: plugin / repo) +band 1 — workspace / project (code: folder) +band 2 — document / module (code: file) +band 3 — section / class / function (code: class / function) +band 4 — paragraph / block / statement (code: block / statement) +band 5 — word / token / leaf (code: identifier / keyword) +``` + +Each band is both a zoom-level filter and a rendering contract. The conservation invariant forbids information loss when crossing bands: compressing a file-entity into a folder-entity must retain a deterministic signature (schema hash, symbol count, content digest) so zooming back in reconstructs the same entity with the same halo. + +### Derived pillar — Contour rendering of the tensor influence field + +The discrete `InfluenceRing` primitive (Pillar 0) is the quantized readout of a continuous underlying structure. The continuous structure is a **tensor influence field** — at every point in the view, the field has components for size, scale, relation-strength, and distance-to-kin. Rings are the band-snapped rendering; **contours are the continuous rendering**. + +**Contour mode.** Iso-contour lines trace curves of equal influence across the viewport. Each entity emits a falloff into surrounding space. Overlapping falloffs sum to a scalar (or multi-channel) field; the contour lines are the iso-levels of that field. Every entity "projects" its influence geometrically; the viewer sees a topographic map of where attention/relation is concentrated. + +The algorithm is whatever the renderer chooses (marching squares, level-set tracing, signed-distance-field accumulation, custom rasterizer) over Canvas, SVG, or WebGL. `https://observablehq.com/@d3/contours` is a reference to the *visual effect* we're targeting — not a library Core must adopt. Bend toward the pattern, don't steal the implementation. + +**Tensor, not scalar.** Influence is not a single number. At minimum it decomposes into: + +- **size** — the entity's raw mass/importance at this band +- **scale** — how far its influence reaches (inverse falloff radius) +- **relation strength** — per-kind weighted edges (`depends` counts differently from `link`) +- **distance to kin** — current spatial separation from related entities + +These components are rendered as multi-channel contour overlays (different colors, different iso-intervals), or combined into a single composite field at the viewer's preference. + +**Crowded contours trigger merge.** As zoom-out compresses the field, contour regions around proximate entities grow visually tighter until they overlap. The overlap is the *visual signal* that Pillar 0's auto-compress threshold is imminent. When contours fully collide, the auto-group mechanism fires and the involved entities squeeze into a new parent atom. This makes the compression trigger **visceral and visible**, not an invisible numerical threshold. + +Two layers of meaning in the contour visualization: + +1. **Your motion relative to the field** — panning, zooming, and focusing shift the viewport; contours flow under your movement. This is self-perception. +2. **The field's response to your perspective** — contours re-sum when overlays change, when viewer ACL filters different entities, when a peer's write lands. This is the field's self-behavior visible to you. + +**Schema note.** `SA_Influence_Ring.members` currently stores `{entity_id, weight}`. Under contour rendering, `weight` evolves into a small tensor `{size, scale, relation, distance}` or a named component vector. Backward compatible — the scalar weight remains the default "composite" channel. + +**Runtime note.** Contours honor Pillar 0.25: they re-compute only on viewport change, entity mutation, or overlay change. Idle viewer → static contour frame. No polling. + +### Derived pillar — Token exchange (formal interface, policy objects, immutable ledger) + +- **Provider_Adapter interface** — every adapter returns `{response, model, usage{prompt, completion, total}, latency_ms, request_id}`, plus `get_rates()` and `get_capabilities()`. Current classes (`SAWS_Model_OpenAI`, `SAWS_Model_Dummy`, `SAWS_Model_Assistant`) already return this shape informally; the Core formalizes it. +- **Pricing_Policy object** — `{provider, model, org_id, prompt_rate_per_1k, completion_rate_per_1k, markup_percent, transaction_fee, management_fee_percent, credential_mode}`. Replaces the hardcoded `$0.002/1000` at `sleeperagent/includes/class-saws-admin.php:120`. +- **Credential_Pool** — multi-tenant (org-scoped), multi-provider, encrypted. Replaces the single global `saws_options.openai_key`. +- **saws_token_ledger** table — immutable, org-scoped, cost-decomposed (`base_cost`, `markup_amount`, `transaction_fee`, `management_fee`, `settlement_status`). Closes the `saws_prompts.org_id` gap. +- **Capability flags table** — `supports_file_upload`, `supports_vision`, `supports_function_calling`, `max_context_tokens`, etc. Drives routing away from the binary `has_openai ? openai : dummy` pattern at `sleeperagent/includes/class-saws-chat.php`. +- **Pricing is policy, not law** — configurable business logic requiring contract and compliance review. The spec does not claim pricing policies are legally guaranteed (core truth 24). + +--- + +## Contradictions and resolutions + +Genuine conflicts, ambiguity, duplicates detected between the `/missionnet-prime` brief and the working tree (and within the brief itself). + +### 1. Recursive grouping: authored vs. emergent + +*Conflict:* Core truth 4 says recursive grouping is foundational. But the brief does not specify whether groupings are human-authored or emergent. The user's later clarification makes both true. + +*Resolution:* Entity schema carries both `parent_id` (human-authored, from drag-merge or intentional linking) and `auto_group_id` (field-produced, from auto-compress). Both are first-class; they layer. Additionally, `GroupingOverlay` carries imposed groupings attributed to authors with a scope (private/shared/org/public). Three mechanisms coexist without collision. + +### 2. Navigation: morphing vs. routing + +*Conflict:* Core truth 7 says navigation should prefer morphing/zooming/tweening over hard page changes. But the working tree uses standard WordPress admin routing (`admin.php?page=carma-audit`, `/wp-json/sa-svg-workspace/v1/state`). + +*Resolution:* Routes become *entity addresses*, not navigation primitives. A URL resolves to an entity id + band + viewer context; the shell always morphs into that target rather than hard-loading a page. Hard routing is retained only as a deep-link bookmark mechanism. `CA_Menu_Override`'s late-priority intercept pattern is the template for retargeting every WP admin route. + +### 3. Token layer: product vs. ledger + +*Conflict:* Core truth 19 requires the universal invariant *"I spent a token."* Core truth 20 requires provider-neutral future-proofing toward blockchain settlement. Core truth 22 forbids naive resale. The current schema has `cost_usd DECIMAL(10,6) NULL` but never writes to it (per `sleeperagent/includes/class-saws-db.php:163` — only saves if `$meta['cost_usd']` provided, which it never is). + +*Resolution:* Token ledger separated from the prompts table. `saws_prompts` remains a conversation-audit table; `saws_token_ledger` is the immutable accounting table with org scope and decomposed cost. Blockchain settlement is a deferred *read* of the ledger, not a schema change. Pricing policy remains configurable; no hard-coded rate ships. + +### 4. Sub-tokens: hidden mechanics vs. exposed orchestration + +*Conflict:* The industry norm is to bury sub-token usage inside the parent invocation. Core truth 22 says the product is orchestration, not resale — which implies orchestration must be visible to the buyer. The user's addition of Pillar 0.75 resolves this decisively. + +*Resolution:* Every sub-token is a first-class `SubTokenEvent` entity, read-only to the billing principal by default and annotatable with `good`/`bad` verdicts. Membranes are translucent to the billing principal. "If a user pays for it, we expose it." Geodesic markers compound user feedback into automated future-prompt seeding. + +### 5. Three plugin copies (CMP_JNO / sleeperagent / saws_plugin) + +*Conflict:* Near-identical plugins that have diverged. The user reports SAWS was at some point **overwritten by the token-tracking service**. + +*Resolution:* Absorb `sleeperagent/` into the Core as the SAWS subsystem, then diff against `CMP_JNO/` and `saws_plugin/` to salvage pre-overwrite functionality. Retire the three as standalone plugins. Reconstruct the SAWS spine from the Core's primitive layer; today's tree is not authoritative. + +### 6. svg-chat: demo assumptions vs. product architecture + +*Conflict:* `sleeperagents-svg-chat/sleeperagents-svg-chat.php:10` hardcodes the upstream to `https://nujacsaints-corporatechatapi.hf.space/v1/chat`; line 93 auto-logs a demo user `nujacsaints@gmail.com`. This is demo residue. + +*Resolution:* Both retire. The upstream becomes an adapter-selected target; the login becomes ACL-scoped. The SVG chat becomes a presentation mode on the Core's entity graph, not a separate plugin with its own backend. + +### 7. Realtime: polling vs. push + +*Conflict:* `sleeperagents-svg-workspace/assets/js/sa-svg-workspace.js:59` polls every 3000ms. This doesn't scale to multi-user realtime across the whole field. + +*Resolution:* Mark polling as a transitional pattern. WebSocket or Server-Sent Events replaces it before carma-audit absorption Phase B ships. Polling may remain as a fallback for constrained hosts. + +### 8. CARMA terminology: "integration" vs. "subsystem" + +*Conflict:* Earlier drafts of this spec treated carma-audit as a third-party integration target. The user corrected: CARMA is 100% a Sleeper Agents product, branded for customer clarity. + +*Resolution:* Every reference to "CARMA integration" becomes "carma-audit absorption." The branded name remains for customer-facing surfaces; internally it is a Core subsystem. + +### 9. Motion: full sequencer vs. preset table + +*Conflict:* Core truth 10 says motion authoring may start as JSON presets; core truths 9 and 7 suggest something much more alive. + +*Resolution:* Band-crossing morph is the foundational motion primitive baked into the rendering engine. Presets cover lifecycle, permission, and overlay events. A full timeline sequencer is deferred. This allows the "alive, elastic, contextual, intentional" feel (core truth 9) without the scope of authoring tools. + +### 10. Pricing policy: "safe" vs. configurable + +*Conflict:* Core truth 23 lists five pricing modes; core truth 24 forbids representing any of them as legally guaranteed. + +*Resolution:* `Pricing_Policy` is a configuration object with `credential_mode ∈ {managed, client_supplied, org_pool}`. The spec preserves all five modes as policy. Legal/compliance review sits outside the product — handled operationally, not encoded as a flag. + +### 11. Implementation sequence: truth 25 vs. presentation-first temptation + +*Conflict:* Core truth 25 orders: (a) token exchange, (b) presentation, (c) visual code, (d) integrations. The aesthetic heart tempts a presentation-first order. + +*Resolution:* Build order (below) honors truth 25a by making the ledger + primitive layer step 1. Step 2 is the zoom-quantum engine (the central artistic deliverable). Step 3 is visual code of the Core itself (self-hosting). Step 4+ are integrations. The aesthetic delivers second because the ledger is load-bearing for everything downstream including the sub-token visualization. + +### 12. Self-hosting: recursive risk + +*Conflict:* Core truth 16 says code is part of the lattice. Core truth 17 says document view and art view are the same information at different fragmentation. Pillar 0.9 says MissionNet must self-host. Taken together, this creates a chicken-and-egg: the system must render itself before it can render itself coherently. + +*Resolution:* Visual code of `sleeperagent/` (as band-2 entities) is milestone 3 — after the ledger and zoom-quantum engine are working on synthetic data. Self-hosting becomes real once the real code survives its own projection pipeline. + +--- + +## Canonical system model + +The unified model in one pass. + +### Entity schema (authoritative) + +``` +Entity { + id uuid + kind "circle" | "rrect" + role "container" | "leaf" | "link-anchor" | "agent" | + "message" | "billing-event" | "code-unit" | + "policy" | "overlay-ref" + parent_id uuid | null // authored recursion + children_order uuid[] + auto_group_id uuid | null // field-produced recursion + compression_band int // band at which this is an atom + expansion_band int // band at which contents unfold + ratio_hint { w, h, min_w, min_h } + layout_mode "free" | "grid" | "stack" | "map" + position { x, y, rx?, ry? } + grid_cell { col, row, span_c, span_r } | null + payload { title?, body_md?, code_ref?, agent_ref?, + bill_ref?, policy_ref? } + edges_out Edge[] + influence_halo InfluenceRing[] + motion_presets string[] + source_lattice { domain, zoom_band } + org_id uuid | null + membrane Membrane | null + io_ports IOPort[] + acl ACL + created_by uuid + created_at ts + updated_at ts +} + +Edge { + target_id uuid + kind "link" | "contains" | "depends" | + "influences" | "flows-to" + weight float // 0..1, for influence edges + band int | null // visibility gate + intentional bool // explicit vs. emergent + from_port string | null + to_port string | null +} + +InfluenceRing { + ring_index int // 0 = innermost/strongest + band int + members { entity_id: uuid, weight: float }[] +} + +Membrane { + opacity "opaque" | "translucent" + render_treatment string + created_by uuid + created_at ts + reason string | null +} + +IOPort { + name string + direction "in" | "out" | "bidi" + payload_kind string + required_permission_to_trace string | null +} + +ACL { + scope "private" | "shared" | "org" | "public" + read principal_ref[] + unfold principal_ref[] + modify principal_ref[] + impose_grouping principal_ref[] +} + +GroupingOverlay { + id uuid + author_id uuid + created_at ts + scope "private" | "shared" | "org" | "public" + reason string | null + synthetic_parent { id, kind, position, ratio_hint } + members uuid[] + shadows_parent bool +} + +SubTokenEvent { + id uuid + parent_prompt_id uuid + kind "embed" | "retrieve" | "tool" | "subagent" | + "completion" | "rerank" | "self-critique" | "router" + provider string + model string + input_payload_ref string // content-addressed, ACL-gated + output_payload_ref string + prompt_tokens int + completion_tokens int + latency_ms int + cost_usd decimal(10,6) + step_index int + verdict "good" | "bad" | null + verdict_author uuid | null + verdict_reason string | null + verdict_markers string[] +} + +GeodesicMarker { + id uuid + owner_scope "private" | "org" | "public" + owner_id uuid + trigger_signature string + failing_step_kind string + failing_tool_ref string | null + avoidance_hint text + success_weight float // EMA + created_from uuid // SubTokenEvent + created_at ts + superseded_by uuid | null +} + +PricingPolicy { + id uuid + org_id uuid | null // null = default + provider string + model string | null // null = provider-wide + prompt_rate_per_1k decimal(10,6) + completion_rate_per_1k decimal(10,6) + markup_percent decimal(5,2) + transaction_fee decimal(10,6) + management_fee_percent decimal(5,2) + credential_mode "managed" | "client_supplied" | "org_pool" + credential_ref uuid | null + effective_from ts + effective_to ts | null +} + +CredentialPool { + id uuid + org_id uuid | null + provider string + encrypted_key bytes + mode "managed" | "client_supplied" | "rotatable" + rotation_days int | null + is_active bool + created_at ts + updated_at ts +} + +TokenLedgerRow { + id uuid + org_id uuid + user_id uuid + session_id uuid + prompt_id uuid + provider string + model string + prompt_tokens int + completion_tokens int + total_tokens int + base_cost decimal(10,6) + markup_amount decimal(10,6) + transaction_fee decimal(10,6) + management_fee decimal(10,6) + total_cost decimal(10,6) + billing_cycle string // YYYY-MM + invoice_id uuid | null + settlement_status "pending" | "invoiced" | "paid" | "disputed" + created_at ts +} + +StructuralAuditRow { + id uuid + actor_id uuid + actor_scope "user" | "system" | "automation" + act string // e.g., "membrane.created", + // "overlay.applied", + // "permission.changed" + target_entity_id uuid + before_snapshot jsonb | null + after_snapshot jsonb | null + reason string | null + occurred_at ts +} +``` + +### Visual grammar (minimum complete) + +Two primitives; two morph poles. + +- **Circle** — `` (SVG) with optional ``-wrapped halo of concentric rings. Serves as the compressed atom. +- **Rounded rectangle** — `` or equivalent SVG with ``-wrapped children. Serves as the expanded container. +- **Morph contract** — `rx` interpolates between `min(w,h)/2` (circle) and `10` (rrect). Identity of the element is preserved across the morph; the DOM node is the same before and after. This satisfies required conclusion 4 (layout identity preserved during morph). +- **Containment** — `` nesting. Recursion is `` inside ``. +- **Halo** — sibling `` of concentric `` rings. Each ring's opacity is a function of `(current_band - ring.band)`; rings fade as viewport zooms past. +- **Edge** — `` cubic bezier, dashed (existing convention in `sa-svg-chat.js` and `sa-svg-workspace.js`), with endpoint anchors bound to `{entity|port}`. +- **Selection and state** — stroke `#38bdf8` (existing convention). Membrane border treatment is an additional CSS class. +- **Port** — named anchor slot on the rrect/circle border; rendered as a small tangent circle or notch. + +### Motion grammar (minimum complete) + +One foundational primitive + preset table. + +- **Foundational:** `onBandCross`. Fires when the viewport's active band crosses an entity's `compression_band` or `expansion_band`. Triggers: morph (rx-from → rx-to), halo fade, child fold/unfold, edge anchor rebinding. +- **Preset events:** `onEnter`, `onExit`, `onPromote`, `onDemote`, `onLink`, `onMerge`, `onAutoGroup`, `onUnfold`, `onMembraneVeil`, `onMembraneUnveil`, `onOverlayApply`, `onOverlayRemove`, `onPerspectiveSwitch`. +- **Preset JSON shape:** `{name, easing, duration_ms, from, to, delay_by_depth, halo_fade_curve}`. `from` and `to` are property bags (`{rx, scale, opacity, x, y, ring_opacity}`). +- **Preset authoring:** first version is a JSON file (canonical path `sa-core/presets/motion.json`). Visual preset editor is added when traffic justifies it (Pillar 0.9). The `Onboarding/PDF Trainer/index.php` segment/pace model is a starting corpus. + +### Code grammar (visual code model) + +One entity graph across bands. Same projection pipeline; code-specific band mapping: + +``` +band 0 — plugin / repo (Core subsystem, carma-audit, sa-jira-bridge) +band 1 — folder (includes/, assets/, admin/) +band 2 — file (class-saws-executor.php) +band 3 — class / function (SAWS_Executor::execute) +band 4 — block / statement (if/else, loop body, try) +band 5 — identifier / keyword (function name, variable, keyword) +``` + +- `code_ref` on the entity: `{path, line_start, line_end, symbol?}`. +- Band-0 through band-3 render with the same circle/rrect primitives as any other entity. Band-4 and band-5 use the rrect's document-reader mode with monospace overlay and in-place edit affordance. +- Dependency (imports, function calls, class inheritance) renders as `depends` edges with `band` gated so they appear only at the band where the dependency makes sense. +- Conservation signature for code entities is a deterministic hash of `{path, content_digest, symbol_count}`. + +### Token exchange model + +The full chain: + +``` +User prompt + ↓ (GeodesicSeeder matches markers, injects avoidance hints) + ↓ +Augmented prompt + chosen Provider_Adapter + ↓ (chosen via CapabilityFlagRegistry + Pricing_Policy + CredentialPool) + ↓ +Provider invocation (membraned) + ↓ +Sub-token events emitted for every internal step + ↓ +Normalized output {response, model, usage, latency_ms, request_id} + ↓ +Pricing_Policy.calculate_cost(prompt, completion) → TokenLedgerRow + ↓ +Billing principal can unfold the membrane and annotate any SubTokenEvent + ↓ +Bad verdicts → new GeodesicMarker records → routes next prompt around failure +``` + +Payment and settlement are *downstream reads* of `TokenLedgerRow`. The blockchain future is a rendering of this ledger into a settlement-friendly format; it does not change the schema. + +### Perspective projection + +``` +view = project(field, viewer, active_overlays, permissions) +``` + +Concrete projection steps per render frame: + +1. Filter entities by `ACL.read ⊇ viewer`. +2. Apply `active_overlays` to re-parent entities into their synthetic parents (respecting overlay scope against `viewer`). +3. For each entity with a membrane: if `viewer ∈ ACL.unfold` (or viewer is the billing principal and this is a provider invocation), render expanded; else render as a closed atom with I/O ports. +4. Compute current zoom band; morph entities whose `compression_band` / `expansion_band` falls on the band boundary. +5. Render halos with ring opacity as a function of band distance. +6. Draw edges whose `band` includes the current band. +7. Render the minimap at the next coarser band, always in view. + +### Projection invariants + +- Identity preservation: an entity keeps its SVG DOM id across band crossings, overlay applications, and morphs. +- Determinism: given the same `(field_state, viewer, overlays, band)`, the projection is reproducible. +- Non-destructiveness: overlays and perspective filters never mutate `field` state; they are applied at render time. +- Conservation: zoom-out followed by zoom-in to the same band reproduces the same halo content (required conclusion anchor). + +--- + +## Recommended implementation stack + +Four tiers, clearly separated. + +### Foundational (ships in the first release) + +- **PHP 8.1+** as the Core's server-side language. WordPress remains the current host surface for the SAWS subsystem. +- **SVG + HTML + CSS** as the presentation substrate. All primitives render as SVG. CSS handles theming and static motion. No framework dependency. +- **Vanilla JS** for state and interaction, matching the existing `sa-svg-workspace.js` / `sa-svg-chat.js` idioms. `screenToWorld` / `worldToScreen` math is adopted as-is. +- **Native CSS transitions + SMIL** for the band-crossing morph and halo fades. Motion presets are JSON-authored. +- **SQL (MySQL/MariaDB via WPDB)** for the Core tables: entity, edge, halo, membrane, port, ACL, overlay, sub-token event, geodesic marker, pricing policy, credential pool, token ledger, structural audit. +- **D3 v7** where the Scanner subsystem's treemap already uses it (absorbed from carma-audit). +- **Parsedown** for markdown rendering (existing `sleeperagent/includes/Parsedown.php`). + +### Optional (add where traffic justifies it) + +- `requestAnimationFrame` + tiny tween helper for smoother band-crossing easing beyond what CSS/SMIL affords. +- Visual motion-preset editor — only after the JSON surface is stable and a second author needs to edit presets. +- Visual entity-schema editor for the closed primitive set — only after self-hosting ships. +- Per-entity custom renderers for specialized domains (e.g., a spreadsheet renderer for tabular data). Extends, does not replace, the circle/rrect primitives. + +### Experimental (clearly marked, not foundational) + +- **WebSocket / Server-Sent Events** to replace the 3000ms polling loop at `sleeperagents-svg-workspace/assets/js/sa-svg-workspace.js:59`. +- **WebGPU / ``** for high-density minimap rendering when the SVG minimap hits performance limits. +- **Shadow DOM** for widget isolation when absorbed subsystems carry CSS that would otherwise leak. +- **Service Worker** for offline-first projection caching. + +### Deferred + +- **Full motion timeline sequencer.** JSON presets + band-crossing foundational primitive cover the first release. +- **Blockchain settlement.** `TokenLedgerRow` schema is settlement-friendly; the on-chain layer is a later read. +- **Cryptographic membrane sealing.** Membranes are access-control, not enclaves. TEE-level sealing is a later concern. +- **Client-side Anthropic / Gemini / local SDKs.** Added as adapters pass the conformance suite. +- **Visual timeline sequencer for sub-token events.** Sub-tokens render as band-5 entities from day one; a dedicated timeline UI comes later. + +--- + +## Deployment posture + +**Host:** WPEngine. **Unit of deployment:** a WordPress site per customer, spun up programmatically on a subdomain (e.g., `acme.sleeperagents.org`, `initech.sleeperagents.org`). **Per-site gating:** what the user sees and can do is determined by the package they purchased. + +Architectural consequences: + +- **The Core is a WordPress plugin** (`sa-core`), activated on every provisioned site. Everything else is a subsystem of or a customer-facing brand on top of it. +- **Provisioning is programmatic and deterministic.** A `SiteProvisioner` service clones a canonical template site, maps a subdomain, installs `sa-core` (if not inherited from the template), seeds the new site's `PackageAssignment` row, and activates the subsystems the package enables. +- **Package is a first-class entity.** Packages gate capabilities, not just features. A capability is `sa-core:*` (e.g., `sa-core:sub-token-unfold`, `sa-core:impose-grouping`, `sa-core:adapter.anthropic`, `sa-core:admin-takeover`). The capability gate is consulted by every subsystem before rendering or acting. +- **Package is also an overlay.** Under Pillar 0.5, the active package is an `active_overlay` fed into the projection pipeline. Two customers on two subdomains looking at "the same" underlying system legitimately see different fields — because their packages admit different capabilities. The mediation-of-knowledge claim applies to commercial surfaces, not just social ones. +- **Subdomain ≡ tenant root.** The `org_id` that scopes entities, ledger rows, ACLs, and overlays is derived from the subdomain at site-provision time. Cross-subdomain data flow is explicit and audited. +- **WPEngine constraints become design constraints.** No shell exec, no long-running processes, no arbitrary outbound (unless on the allow-list), constrained `max_execution_time`. The Core's orchestration must be incremental (Action Scheduler is already in use at `sleeperagent/includes/class-saws-jobs.php`), chunked, and resumable. The Scanner's transient-based resumable execution pattern (`CA_Scanner`) is the template. +- **Customer-facing brands ride on top.** Under Pillar 0.5 + this deployment posture, a single subdomain can wear a customer-facing brand (e.g., "CARMA" for WP-maintenance-focused packages, "MissionNet" for art-space-focused packages). Branding is a theme + package combination, not a fork. +- **Package schema needs per-subsystem capability matrices.** The MissionNet art surface, the SAWS orchestration surface, the CARMA toolset, and future subsystems each declare the capabilities they recognize; the Package row enumerates which are enabled. + +The deployment posture does not change the primitive set or any pillar; it adds a projection (the package) and a provisioning pipeline (the clone-and-activate path). Both are expressed in the existing entity + overlay + ACL vocabulary. + +### Package schema + +``` +Package { + id uuid + slug string // e.g., "art-suite", "compliance-pack" + name string // human-facing + description text + price_tier string // arbitrary (billing-side concern) + capabilities string[] // capability strings enabled + default_overlays uuid[] // GroupingOverlays auto-activated + default_membranes uuid[] // Membranes auto-applied + created_at ts + updated_at ts +} + +PackageAssignment { + id uuid + org_id uuid // subdomain-derived tenant root + package_id uuid + effective_from ts + effective_to ts | null + assigned_by uuid + source "provisioner" | "upgrade" | "downgrade" | "manual" + created_at ts +} + +Capability { + id string // e.g., "sa-core:sub-token-unfold" + subsystem string // "sa-core" | "missionnet" | "carma" | "jira" | ... + description text + default_in_package uuid | null // default package that grants this +} +``` + +### Provisioning pipeline + +``` +Clone template site (WPEngine API) + ↓ +Map subdomain → new site + ↓ +Install + activate sa-core plugin (may inherit from template) + ↓ +Derive org_id from subdomain + ↓ +Seed PackageAssignment from purchase event + ↓ +Run package activation hooks (activate overlays, apply membranes, install subsystem brands) + ↓ +Emit StructuralAuditRow: "site.provisioned" + ↓ +Hand off subdomain URL to customer +``` + +## Realm adapters — the incorporation path + +The `SA_Realm_Adapter` interface bridges any external realm into Core's entity grammar. A realm is any external surface with a coherent identity: FluentCRM, Fluent Support, Fluent Boards, Jira, QuickBase, QuickBooks, Salesforce, HubSpot, PortTracker's Node server, a Node microservice, a CSV sync, a SOAP endpoint, a database. **Any API, any interface, any data layer.** + +**The contract (five static methods):** + +- `realm_id(): string` — short stable identifier (`fluent-crm`, `quickbooks`, `jira`). +- `capabilities(): string[]` — any subset of `read`, `write`, `watch`, `bulk`, `incremental`, `idempotent_push`. +- `concept_map(): array` — realm concepts → Core entity roles + edge kinds. Example for Fluent Support: ticket ↦ role `job`, agent ↦ role `agent`, customer ↦ role `agent`, reply ↦ role `message`; edges `ticket→agent` as `depends`, `reply→ticket` as `contains`. +- `pull(concept, filter, context): mutation[]` — return a list of SA_Ingest-shaped mutations. Entities MUST carry `external_key` so ingest produces deterministic UUIDs linked to the realm's native ids. +- `push(concept, entity, context): {ok, external_id?, error?}` — apply a Core entity change back into the realm. +- `watch_hooks(): hookspec[]` — WordPress action names or realm webhooks to subscribe to. Core wires these on boot. + +**What this unlocks, concretely:** + +- **Fluent stack** (FluentCRM, Fluent Support, Fluent Boards): in-WP realm adapters that hook `fluentcrm/contact_created`, `fluent_support/ticket_created`, `fluent_boards/card_moved` and emit ingest mutations. Contacts/Tickets/Cards/Boards become entities. Campaigns become containers. Replies become `message` entities linked to their ticket. Every Fluent record remains canonical in Fluent; Core incorporates. +- **Jira** (building on existing `sa-jira-bridge/`): issues, projects, sprints, comments become entities. The bridge's OAuth + system-token clients already implement the right auth flows. Core formalizes the mapping. +- **QuickBase**: apps become containers, tables become containers, records become leaves. QuickBase's relationships become edges. Bulk pull via the Records API; watch via webhooks. +- **QuickBooks**: customers, vendors, invoices, bills, accounts become entities. Transactions become `flows-to` edges. Accounting integrity stays in QuickBooks; Core surfaces relationships (e.g., which projects drove which invoices). +- **Arbitrary REST / GraphQL / DB**: a generic config-driven adapter (future slice) lets a client register a realm declaratively without writing code — pattern-mapping only. + +**Registry and config.** `SA_Realm_Registry::register($class)` adds an adapter at boot. `SA_Realm_Registry::configure($realm_id, $config, $service_principal_id)` persists per-install config (auth secrets, concept overrides, bound service principal, last sync cursor). `SA_Realm_Registry::sync_pull($realm_id, $concept, $filter)` runs a pull + ingests the result in one call, suitable for WP-CLI or scheduled sync. + +**Tool test gate.** Before shipping a realm adapter, confirm: *if the adapter stops running, does the realm still work at its native home?* If no, the adapter has crossed from incorporation into absorption — which is a different category and must be signaled as such. + +## LLM role specialization and synchronized multi-model context + +Different LLMs are good at different things. The same conversation may benefit from a conversational driver (narrative alignment, discovery) and a distinct extractor (turn conversation into typed action items). Core supports role-specialized routing over the shared entity graph. + +**Canonical LLM roles.** Declared by `SA_Provider_Adapter::roles()`: + +- `conversation` — long-form narrative alignment, discovery, framing. +- `extraction` — convert conversation into typed action items / structured JSON. +- `summarization` — compress a bundle into a canonical digest. +- `code` — author / edit / refactor source. +- `critic` — evaluate outputs; return good/bad + reason. Used for self-critique chains. +- `router` — choose which neighbors to include as context (the existing `SA_Context_Piper` role). +- `vision` — image-in, text-or-JSON-out. +- `embed` — produce a vector for similarity / retrieval. + +**Routing.** `SA_Provider_Registry::select_for_role($role, $required_capabilities, $prefer)` resolves to a registered adapter that declares the role and meets capability constraints. A session may invoke multiple adapters in sequence: ChatGPT drives the conversation, Claude extracts action items, a third model critiques the result. All three see the same source material. + +**Shared source material.** `SA_Context_Bundle` pins a set of entity ids under a slug and a deterministic signature. Two adapters called against the same bundle receive the same context preface. If the pinned entities change, the signature changes — callers can cache against it. `bundle->render_for($viewer)` respects ACL, redacting entities the viewer cannot read. + +**Why this resolves the LLM echo problem.** When model A's output is fed to model B and B's output is fed back to A, a naive architecture lets the chain drift. In Core, every inference is a `SubTokenEvent` with a parent_prompt_id. `SA_Structural_Audit::trace_origin()` walks backward from any state to the first human-attributed act. Feedback loops are identifiable because the tail is always findable. This is the **Russell-Ouroboros Conjecture made operational** — consumption in motion, always traceable back to an originating intent. + +**Non-goals.** Core does not orchestrate LLM chains for you (that is for the Executor and downstream orchestrators to compose). Core guarantees the traceability, the shared context, and the role-based routing. Composition is your art. + +## External service absorption — the ingest + slots primitive layer + +Core ships compatibility primitives so any external subsystem (PortTracker's Node server, CARMA's PHP modules, take2's FastAPI, Jira bots, future unknown services) can flow entities and surfaces into the field without being ported. + +**Service Principal.** A non-human authenticated identity. Minted via `SA_Service_Principal::mint()`; the raw bearer token is shown once and stored as `sha256(token)`. Scopes (`ingest`, `read`, `admin`, or `*`) limit what a principal may do. Every action attributed to the principal in the structural audit — no anonymous writes. + +**Ingest endpoint.** `POST /wp-json/sa-core/v1/ingest` accepts authenticated batches of typed mutations: + +``` +Authorization: Bearer sp__<32hex> + +{ + "source": "porttracker", + "idempotency_key": "porttracker-20260420-batch-0001", + "mutations": [ + { "op": "upsert_entity", + "entity": { "external_key": "mmsi:366908640", "role": "agent", "kind": "circle", + "payload": {"title":"AUSTIN STELLA","mmsi":"366908640","lat":29.7,"lng":-95.0}}}, + { "op": "upsert_edge", + "edge": { "source_id": "...", "target_id": "...", + "kind": "link", "weight": 1.0, "intentional": true } }, + { "op": "state_transition", "entity_id": "...", "from": "inbound", "to": "moored", + "at": "2026-04-20T14:32:00Z" }, + { "op": "attach_source", "entity_id": "...", "priority": 10, + "source_payload": { "raw_ais_sentence": "..." } }, + { "op": "record_subtoken", + "event": { "parent_prompt_id": "...", "kind": "tool", + "provider": "porttracker", "prompt_tokens": 0, "completion_tokens": 0 } } + ] +} +``` + +Supported ops: + +- `upsert_entity` — create-or-merge. `external_key` yields a deterministic UUID so callers stay idempotent without coordinating Core UUIDs. +- `upsert_edge` — typed edge with deterministic id over `(source, source_id, target_id, kind)`. +- `state_transition` — audit a state change without mutating the body. +- `attach_source` — record that a source provided this entity at this priority (for multi-source merge). +- `record_subtoken` — external tool emits its own sub-token event. + +Idempotency: `(service_principal_id, idempotency_key)` is unique. Replays return `result: "replayed"` with the original processed count. + +**Entity Source provenance.** `sa_entity_source` records which source last provided each entity with a priority and payload snapshot. A Vessel updated by both AISStream and a Comar NMEA feed carries both source rows; priority decides whose payload renders by default. This generalizes the pattern PortTracker's `server.js` already uses internally for AIS source merge. + +**Projection Slot.** A named rendering surface bound to an entity query (`SA_Projection_Slot`). PortTracker's seven rigid panels generalize as seven rows. Each slot carries `slug`, `package_id`, `layout_hint` (left/right/map/bottom/free), `renderer_hint` (list/map/detail/timeline/chain/contour), `query_spec`, and `viewer_override_allowed`. + +**Projection Slot Override.** Per-viewer tuning within the slot's allowance — position, collapsed, pinned, hidden. Non-destructive; clearing reverts to defaults. Every override change is audited. + +**REST:** + +- `GET /wp-json/sa-core/v1/slots` — list slots visible to the current viewer with effective (defaults + override) state. +- `POST /wp-json/sa-core/v1/slots/{slug}/override` — upsert viewer override. +- `DELETE /wp-json/sa-core/v1/slots/{slug}/override` — clear override. + +**Pillar interactions.** The ingest path is authored under Pillar 0.25 — every mutation is an attributable act, no polling, no idle ticks. Projection slots + overrides are the practical expression of Pillar 0.5 (each viewer sees a projection tailored to their overrides + package + ACL). The contour-rendering derived pillar composes with slots: a slot's `renderer_hint` can be `contour`, rendering a continuous influence field over the same entity query that a `list` slot would render as bullet points. + +## Build order + +Highest-leverage sequence. Each step gates the next. + +### Step 1 — Unified Entity + Token Ledger + Sub-token ledger + Geodesic memory (foundational) + +Deliver: + +- Entity, Edge, InfluenceRing, Membrane, IOPort, ACL, GroupingOverlay tables (new). +- SubTokenEvent, GeodesicMarker, GeodesicSeeder tables + the seeder pre-prompt pass (new). +- PricingPolicy, CredentialPool, TokenLedgerRow tables (new). +- StructuralAuditRow table (new). +- `Provider_Adapter` PHP interface formalizing the shape already returned by `SAWS_Model_OpenAI::query()`. +- `CapabilityFlagRegistry` table + routing logic (replaces the `has_openai ? openai : dummy` binary at `sleeperagent/includes/class-saws-chat.php:91-105`). +- Migration pass on `saws_prompts` to add `org_id` (fixes an integrity gap noted in triage). +- Back-attribution pass to populate `org_id` on historic rows via `user → sa_org_id` mapping. +- Billing-principal translucency rule in the ACL evaluator. +- Verdict annotation endpoint (`POST /wp-json/sa-core/v1/subtoken/{id}/verdict`). + +Honors core truth 25a (unified token transaction exchange first). + +### Step 2 — Presentation core: the zoom-quantum engine + perspective projection + +**Seed:** `sa-core/presentation/concept-canvas.html` already implements the water-droplet shape grammar, squish-on-drag deformation, collision-touch membrane, 2-second-hold forced merge with particle bloop, rolodex context flip, and stretch-to-snap pull-out. The adaptation pass wires it to persistent entities and adds bands/halos/minimap. See `sa-core/presentation/README.md` for the concept-canvas → Core-primitive mapping. + +Deliver: + +- Circle primitive added to the SVG layer alongside the existing rrect (currently only in `sa-svg-workspace.js` and `sa-svg-chat.js`). +- Band-crossing morph as the foundational motion primitive (rrect↔circle, child fold/unfold, halo fade, edge anchor rebinding). +- Concentric-ring halo renderer (sibling `` per entity). +- Auto-compress / auto-group mechanism: spatial + semantic proximity → new synthetic parent atom on zoom-out. +- Auto-expand / unfold mechanism: zoom-in reveals bands as rings, then as child entities. +- Minimap as a **mandatory** second band always in view — the conservation invariant must be visually legible. +- Free ↔ grid layout toggle per entity. +- Perspective projection pipeline: `view = project(field, viewer, active_overlays, permissions)`. Viewer-parameterized from day one. No default bypass. +- Membrane rendering (veiled atoms get distinct border/halo treatment; port anchors exposed). +- Overlay layering (imposed groupings render as synthetic parent atoms with author attribution visible on hover/inspect). +- REST endpoint for live state, using SSE or WebSocket (replaces the 3000ms poll at `sa-svg-workspace.js:59`). + +Anchored in the existing `sa-svg-workspace.js` render loop and `screenToWorld` / `worldToScreen` math. + +Honors core truth 25b (presentation core / animation-shape-space). + +### Step 3 — Visual code: render the Core itself + +Deliver: + +- The Core's own `sleeperagent/`-equivalent subsystem rendered as band-0 → band-3 entities via the pipeline built in Step 2. +- Provider adapter classes rendered as veiled membranes; their interiors unfoldable by the billing principal on live invocations. +- Self-hosting verification gate: the zoom-quantum engine handles a real, complex entity graph without special-casing. + +Honors core truth 25c (visual code) and Pillar 0.9 (self-hosting). + +### Step 4 — Absorb SAWS lineage + tenancy + presentation plugins into the Core + +Deliver: + +- Reconstruct the pre-overwrite SAWS functionality from `CMP_JNO/` + `sleeperagent/` + `saws_plugin/` diffs and re-implement against the Core's primitive layer. +- Absorb `sleeperagents-svg-chat/` + `sleeperagents-svg-workspace/` as two surfaces of the same entity model. +- Retire hardcoded HuggingFace endpoint and demo login in `sleeperagents-svg-chat/sleeperagents-svg-chat.php:10, 93`. +- Retire hardcoded `$0.002/1K` rate at `sleeperagent/includes/class-saws-admin.php:120`. +- Absorb `sleeperagents-organizations/` as the Core's ACL-scope provider. +- Absorb `sa-jira-bridge/` as a first-class domain adapter inside the Core. +- Keep `take2/app.py` as the reference provider-bridge microservice (not absorbed into PHP). +- Adopt `salib/` as the Core's shared PHP library directory. + +Honors core truth 25d (downstream integrations). + +### Step 5 — carma-audit absorption Phase A + +Deliver: + +- carma-audit ships as-is under its current customer-facing brand. +- Internally, its source of truth is the Core's primitive layer (credentials, ACL, ledger). +- No code changes to carma-audit itself. + +### Step 6 — carma-audit absorption Phase B + +Deliver: + +- `CA_Scanner`, `CA_Refs`, `CA_Menu_Capture`, `CA_Menu_Override` absorbed into the Core's primitive layer. +- Scanner treemap becomes a live band-0 → band-2 filesystem lattice view in the art space. +- Menu Manager becomes the visual `GroupingOverlay` editor (per-user / per-role / global scope promoted as the reference ACL implementation for the whole field). +- Resolver becomes the structural cleanup operator over entities. +- Branded `CARMA` UI re-rendered through the projection pipeline. + +### Step 7 — carma-audit absorption Phase C (deferred) + +Deliver: + +- `CA_Menu_Override` retargeted to emit entity updates instead of CSS/JS mutations. +- Entire WP admin chrome rendered by the projection pipeline. +- Every admin page is a band-2 entity; every widget is band-3; settings are band-4. + +Gated on Phase B success and a separate business decision. + +### Step 7.5 — PortTracker absorption Phase A (reverse-proxy + identity) + +Deliver: + +- PortTracker's Node server runs as-is (AIS decoder, multi-source merger, dispatch scraper, Cheerio parser stay Node-side). +- Core reverse-proxies PortTracker behind its auth + ACL layer. No unauthenticated LAN access. +- A `SA_Service_Principal` minted for the PortTracker server so it can push state changes into Core via the ingest endpoint without end-user impersonation. + +Anchored in `F:\SleeperAgents\PortTracker\server.js` (~2,290 lines). No code changes required in PortTracker itself for this phase. + +### Step 7.6 — PortTracker absorption Phase B (entities + slots) + +Deliver: + +- PortTracker pushes Vessel, DispatchJob, Crew, Dock, Route, VoyageCall, ServiceRequest, Task updates into `sa_entity` + `sa_edge` via `POST /sa-core/v1/ingest`. The `data/*.json` files become import seeds, not runtime truth. +- The bidirectional `bindings.json` (mmsi ↔ dispatch ref) is promoted to typed `SA_Edge` rows with weight + author + audit. +- PortTracker's seven rigid panels (left dispatch list, map, right vessel-detail, conditions, crew, tactical, job-chain) become `SA_Projection_Slot` rows. The map panel uses a maritime-specific renderer hint; per-user overrides allow operators to pin/collapse/hide slots within the package's allowance. +- Structural audit captures every VoyageCall state transition (detected → inbound → moored → dispatched → complete) as an attributable act. + +### Step 7.7 — PortTracker absorption Phase C (full UI replacement, deferred) + +Deliver: + +- `public/index.html` (~7,805 lines) is retired. +- The seven projection slots render through Core's presentation layer (Build order Step 2). The Leaflet map becomes a specialized renderer plugin for `layout_mode='map'` entities. +- PortTracker's Node server becomes a pure data service — no UI responsibilities. + +Gated on Step 2 (presentation core) and Phase B success. Coordinated with a separate PortTracker thread. + +### Step 8 — Provider expansion + +Deliver: + +- Anthropic adapter (`SAWS_Model_Anthropic`) against the formalized `Provider_Adapter` interface. +- Gemini adapter (`SAWS_Model_Gemini`). +- Local adapter (`SAWS_Model_Local`, for Ollama or similar). +- Conformance suite that each adapter must pass before admission. + +### Step 9 — Onboarding as a first-class motion-preset source + +Deliver: + +- The `Onboarding/PDF Trainer/` teleprompter retargeted as a motion-preset authoring surface. +- Segment/pace metadata becomes a canonical preset category. + +### Step 10 — Settlement read layer (deferred) + +Deliver: + +- On-chain / off-chain settlement readers against `TokenLedgerRow`. +- Invoice generation aggregator. + +--- + +## Risks and traps + +Named failure modes that would accidentally ruin the vision. + +### R1 — Flattening the field into a generic dashboard + +*Trap:* Under delivery pressure, someone ships a "sessions list + conversation pane" view (as already exists at `sleeperagent/includes/class-saws-admin.php`) and calls it done. + +*Mitigation:* The Step 2 presentation core must be in place before any new admin surface ships. New surfaces render through the projection pipeline, not as bespoke WP admin pages. + +### R2 — Page routing sneaks back in + +*Trap:* Because WordPress routes are easy, engineers keep adding `admin.php?page=...` pages. + +*Mitigation:* Routes become entity addresses (see Contradiction 2). `CA_Menu_Override`'s late-priority intercept is the template. PR review gate: any new `add_submenu_page` must also carry an entity-id resolver. + +### R3 — Sub-token concealment creeps in + +*Trap:* A developer, wanting a "clean" user experience, hides a retrieval or reranker call from the user-facing view. + +*Mitigation:* Pillar 0.75 forbids it. Every internal invocation of an LLM, a tool, a sub-agent, or a router MUST emit a `SubTokenEvent`. Code review: any call to a provider SDK without a corresponding `SubTokenEvent::record()` is a defect. + +### R4 — Pricing policy drifts into hardcoded rates + +*Trap:* A contractor writes `* 0.002` somewhere (like the current `class-saws-admin.php:120`) because it's faster than reading the policy table. + +*Mitigation:* No pricing arithmetic outside `PricingPolicy::calculate_cost()`. Grep gate: `grep -rE '\*\s*0\.(00)?[0-9]+' Core/` must return zero hits in billing paths. + +### R5 — Three plugin copies regenerate + +*Trap:* After the Core absorbs SAWS, someone clones `sleeperagent/` back out for a "quick fix." + +*Mitigation:* Archive `sleeperagent/`, `saws_plugin/`, and `CMP_JNO/` explicitly and mark them read-only. Redirect their plugin slugs to the Core. + +### R6 — The SAWS overwrite repeats + +*Trap:* The token-tracking service once overwrote the SAWS spine. Without care, a future subsystem absorption overwrites another spine. + +*Mitigation:* Every absorption is a Phase A (ship-as-is fed by Core) → Phase B (merge into primitive layer) → Phase C (re-render) sequence with explicit gates. No in-place overwrites. Structural audit log records every absorption act. + +### R7 — Membrane becomes a security theater + +*Trap:* Users assume membranes are cryptographically sealed; a leak embarrasses the product. + +*Mitigation:* UI never implies cryptographic sealing. Membrane documentation is explicit: access-control and perception-control, not an enclave. Real sealing is deferred (see Out of scope). + +### R8 — Motion becomes decorative instead of load-bearing + +*Trap:* Motion is added "because it looks nice" and drifts away from band-crossing semantics. + +*Mitigation:* Every motion preset must attach to a named event in the motion grammar. Unattached presets (`onJustBecauseItLooksNice`) don't exist in the system. + +### R9 — Visual code devolves into a pretty file browser + +*Trap:* Step 3 ships as a treemap of folders with no dependency edges, no band-4/band-5 rendering, no halo of influence. + +*Mitigation:* Step 3's exit criteria explicitly include band-3 class/function rendering with dependency edges and influence halos. No partial delivery that skips band-4/5. + +### R10 — Geodesic memory becomes a ghost + +*Trap:* Markers accumulate, but the seeder is never wired into the pre-prompt pass. User verdicts have no effect on future prompts. + +*Mitigation:* Step 1 deliverables explicitly include the seeder pass. An integration test fires every build: a bad-verdict marker in the test DB must cause the seeder to inject its avoidance_hint on the next matching prompt. + +### R11 — Provider neutrality slips + +*Trap:* The Core ships with OpenAI as a "first-class" provider and Anthropic as "also supported" — reimplementing the exact competitive-vs-orchestration confusion the user wants to avoid. + +*Mitigation:* Conformance suite is adapter-agnostic. Documentation never lists providers in a featured/non-featured split. Capability flags replace provider-specific branching in Core code. + +### R12 — Self-hosting becomes a demo + +*Trap:* Step 3 ships as a canned screenshot of the Core rendered in the Core. A month later, the code has drifted and the screenshot is stale. + +*Mitigation:* Self-hosting is continuous: the Core's presentation layer is wired to read the actual codebase at runtime, not a snapshot. CI renders the Core into itself and diffs for structural drift. + +### R13 — Human-in-the-loop gets automated away + +*Trap:* A feature request reads "auto-resolve the ambiguity when there are multiple plausible paths" and gets implemented. This violates Pillar 0.9. + +*Mitigation:* When there are multiple plausible paths, the system surfaces the ambiguity. Automating the choice requires an explicit, scoped, audited policy decision — it is not a default behavior. + +### R14 — Extensibility explodes into plugin sprawl + +*Trap:* External developers ask for new primitives and the spec says yes to each, reinflating the plugin-soup the Core was meant to collapse. + +*Mitigation:* Primitive set is closed. Extensions are compositions. Adding a primitive requires justification across multiple concerns and a rendering-pipeline update, not a plugin header. + +### R15 — LLM echo chamber (inference reinforcing itself) + +*Trap:* Model A's output is fed into model B's context. B's output is fed back into A's next prompt. Over many hops, the conversation drifts from any original human intent — the models agree with each other about facts that never had a human source. Quality decays invisibly because every step looks locally reasonable. + +*Mitigation:* Every adapter invocation writes a `SubTokenEvent` with a `parent_prompt_id`. `SA_Structural_Audit::trace_origin($id)` walks backward until the first `actor_scope='user'` act is found. Any claim in a downstream response can be traced to its originating human act; claims that don't have one are surfaced as such. + +**Operational rule:** a session that has run more than N adapter hops without a fresh human act must either surface a "back up to origin" affordance or tag the current state as "drift candidate." Exact N is a per-package policy. This makes echo chambers audible — you can always hear the snake's original tail. + +### R16 — Realm incorporation silently becomes ownership + +*Trap:* A realm adapter caches so aggressively, or pushes so rarely, that clients start treating Core as the source of truth. The realm's native interface lags; workflow shifts to Core; if Core goes down, the client is now locked out of their own data. + +*Mitigation:* Realm adapters are **conduits**. Cache has a hard TTL (per-adapter, typically seconds to minutes). `push()` is called on every Core-side write, not batched. The tool test (*if Core disappeared tomorrow, does the realm still work?*) is revalidated each adapter release. If a realm adapter fails the tool test, it is reclassified as absorption and the client is notified — this is a contractual, not just technical, boundary. + +--- + +## Open questions + +Questions the working tree and the brief do not resolve. Each needs a decision before or during its related milestone. + +### Q1 — What is the canonical band count, and how are bands labeled per domain? + +The spec names six bands (0–5). Is this count exact or illustrative? Different domains (prose, code, filesystem, tokens) may want different granularities. A canonical band schema is needed before Step 2 ships. + +### Q2 — What computes the conservation signature of a compressed atom? + +Conservation requires that zooming back out reconstructs the same halo. This needs a deterministic per-entity signature function. Candidates: content hash, structural hash, symbol count, or a weighted combination. Decision needed before Step 2. + +### Q3 — What drives auto-group proximity: spatial, semantic, or both? + +Pillar 0's "auto-compress squeezes proximate entities" needs a concrete proximity metric. Purely spatial (Euclidean in the free layout) is easy; purely semantic (embedding similarity) requires a vector store; combined requires weighting. Decision needed before Step 2 auto-group implementation. + +### Q4 — Where does the geodesic trigger signature live, and how is fuzziness bounded? + +A `GeodesicMarker.trigger_signature` must match an upcoming prompt closely enough to fire but not so broadly that every prompt fires every marker. Is the signature an embedding, a structured feature vector, a regex, or a prompt-template fingerprint? Bound on false-positive rate? + +### Q5 — How does the Core handle provider-specific streaming? + +`Provider_Adapter::query` returns a single normalized response. Streaming (OpenAI SSE, Anthropic streaming) is used in some UX flows but not currently in `sleeperagent/`. Does the Core model streaming as a series of `SubTokenEvent` rows, or as a single event with a progressive payload? Decision needed before Step 8 (provider expansion) or whenever streaming UX ships. + +### Q6 — What is the retention window for billing-principal unfold rights on sub-tokens? + +Pillar 0.75 says "for as long as the ledger row is retained." Retention policy is undefined. 90 days? Indefinite? Org-configurable? + +### Q7 — What happens when an imposed grouping contradicts an authored `parent_id`? + +`GroupingOverlay.shadows_parent` controls this per overlay. But if a viewer has multiple overlays active and they disagree, what wins? Decision needed: overlay precedence rule (creation order, explicit priority, author role). + +### Q8 — Is CARMA a renaming or a packaging? + +If Phase B merges carma-audit's classes into the Core, the CARMA brand continues as a customer-facing skin. Is there a scenario where CARMA ships features the Core doesn't, or vice versa? Product decision. + +### Q9 — How is "curved intent" captured in the UI? + +Pillar 0.9 says humans inscribe curved intent. Concretely: is there a UI gesture for "elect this path from these alternatives," and how is the election attributed in the structural audit log? Design needed before Step 2. + +### Q10 — What is the Core's public name? + +Working name `sa-core/`. Final decision needed before Step 4 absorbs renamed code into the unified package. + +### Q11 — Is carma-audit Phase C ever going to ship? + +"Deferred and contingent on Phase B success" is the current position. A go/no-go decision during Phase B may close this question. + +### Q12 — How does the motion preset library get authored long-term? + +Pillar 0.9 says config-first, editor-second. When does the editor-second tipping point hit for motion presets specifically? An explicit trigger (e.g., "when the third non-engineer starts authoring presets") would help. + +--- + +## Source evidence appendix + +Citations for the load-bearing claims. File paths anchored to `F:\SleeperAgents\ChatBot\` unless marked CARMA. + +### On SAWS terminology and ownership + +> *"SleeperAgent Workspace System (SAWS) Copyright (c) 2025 SleeperAgents LLC."* + +— `sleeperagent/NOTICE.md` (and identical copies in `CMP_JNO/NOTICE.md`, `saws_plugin/NOTICE.md`). Establishes the canonical expansion of S.A.W.S. + +### On the token-accounting schema + +Columns from `sleeperagent/includes/class-saws-db.php` (the `saws_prompts` CREATE TABLE): + +``` +prompt_tokens INT(10) UNSIGNED NULL, +completion_tokens INT(10) UNSIGNED NULL, +total_tokens INT(10) UNSIGNED NULL, +latency_ms INT(10) UNSIGNED NULL, +cost_usd DECIMAL(10,6) NULL, +request_id VARCHAR(100) NULL, +``` + +— Anchors the claim that token accounting is already core-level but `cost_usd` is never written. Contradiction 3 resolution. + +### On the hardcoded pricing rate + +``` +$cost_estimate = number_format((float)($s->total_tokens ?? 0) / 1000 * 0.002, 6); +``` + +— `sleeperagent/includes/class-saws-admin.php:120`. Anchors the claim that pricing is hardcoded and must retire (Risk R4, Contradiction 10). + +### On the binary provider routing + +``` +$has_openai_key = isset($opts['openai_key']) && trim($opts['openai_key']) !== ''; +$provider = $has_openai_key ? 'openai' : 'dummy'; +``` + +— `sleeperagent/includes/class-saws-chat.php` (approx lines 91–105). Anchors the claim that provider routing is credential-driven rather than capability-driven. Replaced by `CapabilityFlagRegistry` in Step 1. + +### On the single global credential + +> *"This key is used for all chat requests. Users pick the model inside chat."* + +— `sleeperagent/includes/class-saws-admin.php:field_openai_key` (approx lines 224–230). Anchors the claim that credentials are global-single and must become a `CredentialPool`. + +### On the normalized adapter output shape + +``` +return [ + 'response' => trim($text), + 'model' => $used_model, + 'usage' => isset($body['usage']) && is_array($body['usage']) ? $body['usage'] : [], + 'latency_ms' => $latency_ms, + 'request_id' => $request_id ?: null, +]; +``` + +— `sleeperagent/includes/models/class-saws-model-openai.php` (query return). Anchors the claim that a de-facto `Provider_Adapter` contract already exists and just needs formalization. + +### On the hardcoded HuggingFace endpoint and demo login (svg-chat) + +- `sleeperagents-svg-chat/sleeperagents-svg-chat.php:10` — upstream hardcoded to `https://nujacsaints-corporatechatapi.hf.space/v1/chat`. +- `sleeperagents-svg-chat/sleeperagents-svg-chat.php:93` — demo auto-login `nujacsaints@gmail.com`. + +— Anchors Contradiction 6 and Risk R5. + +### On the SVG workspace polling loop + +``` +setInterval(poll, 3000) +``` + +— `sleeperagents-svg-workspace/assets/js/sa-svg-workspace.js:59`. Anchors Contradiction 7 and the experimental-tier realtime transport recommendation. + +### On the SVG shape primitive already in use + +- `sleeperagents-svg-workspace/assets/js/sa-svg-workspace.js` renders nodes as `` with `rx=10, ry=10`. +- `sleeperagents-svg-chat/assets/js/sa-svg-chat.js` uses the identical convention with `rx=10`. + +— Anchors the derived shape-grammar pillar's claim that rrect is already implemented and circle must be added. + +### On the SVG workspace pan/zoom math + +- `screenToWorld(clientX, clientY)` and `worldToScreen(x, y)` helpers in `sleeperagents-svg-workspace/assets/js/sa-svg-workspace.js`. + +— Anchors the recommended reuse in Step 2. + +### On the organizational tenancy scaffold + +- CPT constant: `const CPT = 'sa_organization';` +- Role constant: `const ROLE_ORG_ADMIN = 'sa_org_admin';` +- User-meta key: `const USERMETA_ORG_ID = 'sa_org_id';` + +— `sleeperagents-organizations/sleeperagents-organizations.php:14-43`. Anchors the adoption of `sleeperagents-organizations/` as the Core's ACL-scope provider (Step 4). + +### On the sub-token cost column being unwritten + +``` +if (isset($meta['cost_usd'])) $data['cost_usd'] = (float)$meta['cost_usd']; +``` + +— `sleeperagent/includes/class-saws-db.php:163` (approx). Anchors the claim that the cost column is conditionally saved but never populated, and that a real calculation path is required. + +### On the executor source-auditing + +``` +// Audit source of invocation +$meta['source'] = $source; +``` + +— `sleeperagent/includes/class-saws-executor.php:104-106` (approx). Anchors the claim that per-invocation attribution (chat, salib, jira, system) already exists as metadata but lacks a dedicated schema column. + +### On the take2 in-memory metrics pattern + +```python +def record_request(session_id: str, tokens_used: int) -> None: + now = time.time() + metrics["total_requests"] += 1 + metrics["total_tokens"] += tokens_used + ... +``` + +— `take2/app.py:70-86`. Anchors the recommendation that `take2/` is the reference LLM bridge whose pattern formalizes into a `Provider_Adapter` implementation that happens to run outside PHP. + +### On CARMA menu capture priority + +> *"Hooks into admin_menu at the highest possible priority (PHP_INT_MAX - 1) so it runs after every plugin, theme, and core has registered its items."* + +— `F:\SleeperAgents\CARMA\LIVE\carma-audit\includes\class-menu-capture.php` (class docblock, lines 2–12). Anchors the claim that recursive admin takeover is mechanically possible. + +### On CARMA menu override priority and scope + +> *"Reads the active theme (selected per-user, per-role, or global) and applies it to WordPress's $menu / $submenu globals at priority PHP_INT_MAX."* + +— `F:\SleeperAgents\CARMA\LIVE\carma-audit\includes\class-menu-override.php` (class docblock, lines 2–26). Anchors the claim that `CA_Menu_Override` is the reference implementation of Pillar 0.5's perspective projection. + +### On CARMA scanner public API + +> *"Walks a directory tree and returns a nested folder structure suitable for treemap rendering. Individual files are NEVER included in the output — only folder nodes with aggregated size/count."* + +— `F:\SleeperAgents\CARMA\LIVE\carma-audit\includes\class-audit-scanner.php` (class docblock, lines 3–23). Anchors the claim that the Scanner is already a band-0 → band-2 compression visualizer. + +### On CARMA's public class API surface (no WP hooks) + +- `CA_Scanner::run(array $settings, string $token_id = ''): array` +- `CA_Scanner::available_roots(): array` +- `CA_Refs::find(string $search_term, array $extra_tables = []): array` +- `CA_Refs::analyse_duplicates(string $root_path, string $strategy = 'both', array $extra_tables = []): array` +- `CA_Refs::resolve(string $canonical_url, array $candidates, string $mode = 'safe', array $extra_tables = []): array` +- `CA_Menu_Capture::get_cache(): array` +- `CA_Menu_Capture::force_reset(): void` +- `CA_Menu_Override::resolve_theme_for_current_user(): ?array` +- `CA_Menu_Override::get_all_themes(): array` +- `CA_Menu_Override::save_themes(array $themes): void` + +— `F:\SleeperAgents\CARMA\LIVE\carma-audit\includes\*.php`. Anchors Phase B's absorption plan: Core consumes the carma-audit subsystem through exactly these static methods. + +### On Onboarding as a motion-preset source + +> *"Progress cue: fill background of teleprompter steadily through the segment"* + +— `Onboarding/PDF Trainer/index.php` (UI directive). Anchors the recommendation that the teleprompter's segment/pace model seeds the initial motion preset library (Step 9). + +### On the presentation-core seed (concept-canvas) + +> *"drag to move · right-click for context · merge by collision · pull to link"* + +— `sa-core/presentation/concept-canvas.html` (on-canvas hint text). Single-file vanilla HTML/SVG/CSS that implements the water-droplet shape grammar, squish-on-drag deformation, collision-touch membrane, 2-second-hold merge with particle bloop, rolodex context flip, and stretch-to-snap pull-out. Absorbed as the presentation-core seed for Build order Step 2. Physics grammar to motion-grammar mapping is in `sa-core/presentation/README.md`. + +Additional load-bearing snippets from the same file: + +> ```javascript +> const squishX = 1 + Math.abs(lastVX) * 0.012; +> const squishY = 1 + Math.abs(lastVY) * 0.012; +> dragging.circleEl.setAttribute('rx', dragging.r * squishX); +> dragging.circleEl.setAttribute('ry', dragging.r / squishX); +> ``` + +— `concept-canvas.html` (drag handler). Anchors the claim that the `rx`/`ry` interpolation needed for the circle↔rrect band-crossing morph is the same math already running in the prototype's drag squish. + +> ```javascript +> if (minDist < (node.r + closest.r) * 0.65) { +> if (!mergeTimer || mergeTarget !== closest) { +> mergeTarget = closest; +> clearMergeTimer(); +> mergeTimer = setTimeout(() => { +> if (dragging === node) mergeNodes(node, closest); +> }, 2000); +> } +> } +> ``` + +— `concept-canvas.html` (`checkMergeProximity`). Anchors the forced-merge → `GroupingOverlay` translation and the 2-second hold convention. + +### On the SAWS overwrite incident + +No in-tree artifact. This is user-reported: + +> *"The SAWS platform got overwritten by some of the token tracking service."* + +— Conversation input from the product owner. Anchors Triage item 1 and Risk R6. Phase 2's SAWS reconstruction must diff `CMP_JNO/` (older baseline) and `saws_plugin/` (earliest) against `sleeperagent/` (post-overwrite) to recover any lost behavior before re-implementing against the Core's primitive layer. + +### On the three-copy plugin divergence + +- `CMP_JNO/` — 16 PHP files; missing `class-saws-db-migration.php`, `class-saws-jobs.php`, `class-saws-nodes.php`, `class-saws-render.php`, `class-saws-files.php` (relative to `sleeperagent/`). +- `sleeperagent/` — 22 PHP files; most evolved. +- `saws_plugin/` — 14 PHP files; earliest; has own `.git`. + +— Directory audit, cross-referenced with each plugin's `includes/` listing. Anchors Triage item 2 and Risk R5. + +### On the sa-jira-bridge maturity + +- 20 PHP files across OAuth, Jira client (user + system modes), REST endpoints, room repository, room scheduler, capabilities, connections resolver. +- Consistent error shape `{ok: bool, error?: string, data?: mixed}`. +- Connection Resolution pattern via `class-sa-connections-resolver.php`. + +— Directory audit of `sa-jira-bridge/includes/`. Anchors the claim that Jira bridge is the most mature subsystem and the pattern-setter for domain adapters (Step 4). + +### On PortTracker as the external-service absorption case study + +PortTracker at `F:\SleeperAgents\PortTracker\`: + +- **Monolith server**: `server.js` (~2,290 lines) — AIS 6-bit decoder, multi-source priority merger (AISStream.io WSS + Comar R450 NMEA TCP), Cheerio dispatch scraper for `app.houstonboatmen.com`, ~30 REST endpoints, WebSocket broadcast. Node 18 + Express 4 + `ws` 8. **No TypeScript. No ORM. JSON files in `data/` as runtime truth.** +- **Rigid panels**: `public/index.html` (~7,805 lines) hardcodes seven panels with `width: 260px` / `width: 270px` inline styles. No draggable layout. Every operator sees the same layout. +- **Untyped bindings**: `data/bindings.json` stores `mmsi ↔ dispatch_ref` as bidirectional strings. No relationship typing, no attribution, no audit. +- **Unauthenticated**: assumes trusted LAN. No JWT, no session, no API key. + +Anchors Steps 7.5 / 7.6 / 7.7 (PortTracker absorption phases A/B/C). Core bends toward PortTracker's source-priority merge pattern (promoted to `sa_entity_source`), its rigid-panel concept (promoted to `sa_projection_slot`), and its bidirectional bindings (promoted to typed `sa_edge` rows). Core improves by adding ACL, audit, typed relationships, and per-viewer projection overrides — all without porting the Node-side AIS decoder. + +### On CellPond proving on-demand subdivision (and not stationary-at-rest) + +CellPond at `F:\SleeperAgents\CellPond\the-one-true-todey-file-of-cellpond.js` (~9,868 lines, single file). Visual cellular automaton; users drag-compose rules; cells split and merge fractally. + +> `splitCell` at ~line 1393 and `isCellVisible` at ~line 563 + +— Anchors the on-demand subdivision + visibility-region filter patterns Core adopts at the entity-graph level. Cells only subdivide when a rule fires on them; off-viewport cells don't render, don't compute. + +> `requestAnimationFrame(colourTodeTick)` at ~line 3011 (re-arms unconditionally) + +— Anchors the **counter-example** for Pillar 0.25's "stationary at rest" rule: CellPond burns rAF frames on idle. Core forbids this pattern and requires rAF gating on real state change or explicit user gesture. Bend toward the subdivision pattern, improve on the idle behavior. + +### On concept-canvas + PortTracker + CellPond as ouroboros receipts + +Three distinct projects, three different stacks, three different authors (some human, some Claude), all built with MissionNet in mind: + +- concept-canvas proved the shape grammar (circle/rrect morph, collision deform, merge-with-bloop, stretch-to-snap). +- PortTracker proved the panel-as-query pattern (rigid today, liquid tomorrow) and source-priority merge. +- CellPond proved on-demand fractal subdivision and visibility-region filtering. + +Core's job is to **bend toward** all three — name their patterns as primitives, accept their data via ingest, render their surfaces through Projection Slots — without stealing code. The subsystems keep living where they live; Core becomes the substrate they share. + +--- + +*End of specification. Phase 2 is ongoing; the spec is amended as subsystems arrive and new patterns are proven. Ouroboros — the tail feeds the head; neither one started first.* diff --git a/SA-orchestration MD/Archvie/sa-core/docs/specs/01-ontology.md b/SA-orchestration MD/Archvie/sa-core/docs/specs/01-ontology.md new file mode 100644 index 0000000000000000000000000000000000000000..acbed760e8dac1b060b27d1f5c664408d407f4c6 --- /dev/null +++ b/SA-orchestration MD/Archvie/sa-core/docs/specs/01-ontology.md @@ -0,0 +1,281 @@ +# MissionNet Ontology (v0.1) + +*Implementation © Sleeper Agents LLC. Conceptual framework authored independently by Mark Holak; see `docs/specs/concepts/`.* + +This is the vocabulary lock. Every downstream doc, every class, every log message, and every UI surface uses these terms precisely. Drift here = drift everywhere. + +--- + +## Primary nouns + +### Core + +The required substrate. One WordPress plugin (`sa-core`). A space is a SleeperAgent space only when Core is online underneath it. + +### Realm + +An externally sovereign system of record or operation. FluentCRM is a realm. Jira is a realm. QuickBase is a realm. QuickBooks is a realm. PortTracker's Node backend is a realm. An arbitrary REST API is a realm. A client's database is a realm. A realm is sovereign because its integrity does not depend on Core. + +### Subsystem + +A coherent slice of Core itself. SAWS-executor is a subsystem. CARMA (the absorbed WP maintenance toolset) is a subsystem. MissionNet (the presentation layer) is a subsystem. A customer-facing brand may wrap a subsystem; the brand is cosmetic, the subsystem is structural. + +### Adapter + +A bidirectional conduit between Core and a realm. See the **Realm Adapter Contract** (`03-realm-adapter-contract.md`). Adapters do not own realm data; they mediate flow. A provider adapter (`SA_Provider_Adapter`) is a specialized adapter for LLM and tool providers; a realm adapter (`SA_Realm_Adapter`) is the general-case conduit. + +### Entity + +The universal unit of the field inside Core. Rendered as circle (compressed atom) or rounded-rect (expanded container). Carries band hints, ratio, position, payload, ACL, provenance, and truth class. + +### Record + +A specific row in a realm's native schema. A record's canonical home is the realm. A record's Core-side reflection is a **Projection**. + +### Projection + +Core's non-canonical mirror of a realm record. Stored as an entity with `truth_class = projected`, `origin_realm` + `origin_id` populated, and TTL-bounded when cached. + +### Canonical Source + +The realm in which a record is authoritative. Only the canonical source may author canonical state for its own records. Core cannot author canonical on a realm's behalf. + +### Event + +Something that happened. Immutable, time-indexed. Recorded to `sa_structural_audit` with actor, target, before/after, correlation_id, causation_id, policy_basis. + +### Command + +An instruction or intent. Typically initiated by an actor or a scheduled policy. A command may yield one or more events when executed. + +### Actor + +The who of an action. One of: a WordPress user id (human), a service principal id (non-human authenticated identity), or the string `system` (Core's own internal automation under an audited policy). + +### Authority + +The scope of what an actor may do. Held by role, package capabilities, ACL entries, and adapter capability matrices. + +### Session + +A grouping of related commands and events initiated by a single actor or chain. Correlated by a shared `correlation_id`. + +### Mission + +A composite objective that spans one or more sessions. (Future spec — see `10-mission-orchestration.md` when written.) + +### Quest + +A unit of narrative direction. Defines target, not-target, and admissible uncertainty for a body of work. Quests are authored, not executed: their canonical home is the planning surface (Boards in MissionNet's instance). + +Quests describe what the work is *about*. Resolution of a Quest is a narrative judgment — was the direction met, was it abandoned, was it superseded — made by a human. + +Distinct from a Job. See Job below; see also Distinguishing pairs. + +### Task / Job + +A unit of executable work within a Quest. May be scheduled, assigned, retried, or delegated. Jobs describe what is *done*. Resolution of a Job is a real-world consequence — the work either happened or did not — measured against reality, not against the system. + +A Quest may produce one or more Jobs. A Job belongs to one Quest. Conversion between Quest and Job is not automatic; see I10. + +### Attention + +A routing signal — never an instruction, never a state mutation. Attention asks for arbitration; it does not perform it. NPCs (agents, automated processes) may emit Attention. Only humans may resolve it. + +The infrastructure: the attention log records signals with actor, target, correlation_id, causation_id. Read-only by construction; resolving Attention requires the human to take a separate, explicit act. + +Sub-doctrine: see `concepts/attention-doctrine.md` and `concepts/compass-doctrine.md`. + +### Realm Signal + +A low-cost notification from a realm ("something changed") that Core may choose to consume, queue, or ignore based on policy. Previously named "Signal"; renamed to free the unqualified term for the doctrinal meaning below. + +### Signal + +*(Authored by Mark Holak. Preserved under Invariant **I9**.)* + +The measurable alignment behavior of participants — users, workers, agents, models, systems — as they traverse an authored **acceleration field** (a mission, prompt, workflow, book, interface, instruction set, or any artifact that defines target, not-target, and admissible uncertainty). + +Signal is *not* raw observability. Pillar 0.75's sub-token graph is the instrument; Signal is what the instrument reads. Participants interact with an authored field and produce measurable interaction outcomes — some align, some scatter, some ricochet, some go inert, some reach harmonic closure. The Signal Telemetry Doctrine is the interpretation layer sitting over Prime Prompt Conjecture and Cognitive Heatsink: PPC and Heatsink describe how trajectory is compressed and dissipated; Signal reads the alignment produced at the other end. + +The enumerated behaviors (align / scatter / ricochet / inert / harmonic closure) are **exemplary, not definitive** — additional behaviors may emerge as the system is used. See `concepts/signal-telemetry-doctrine.md` for the full doctrine and its mapping onto Core primitives. + +### Artifact + +A file, document, image, or embedding. Stored in the native realm when possible; projected into Core via adapter. + +### Trace + +The causal ancestry of a state or an event. Walked by `SA_Structural_Audit::trace_origin()`. Preserved by correlation_id and causation_id on every audit row. + +### Truth Class + +See `SA_Truth_Class` and Invariant **I2**. One of: `canonical | projected | cached | inferred | derived | synthetic`. Every entity declares one. + +### State Class + +See `SA_State_Class`. The lifecycle position of a datum. One of: `resting | observed | projected | pending_action | acted | verified | disputed | stale | superseded`. + +### Provenance + +The origin record attached to every entity: `origin_realm`, `origin_id`, `actor`, `observed_at`, `causation_id`, `correlation_id`, optional `derived_from`, `source_refs`, `provenance_hash`. + +### Membrane + +A per-entity veil that turns the entity into a black-box domain. Only typed I/O ports are visible to outsiders. Billing principals may unfold the membrane they are charged for. + +### I/O Port + +A typed edge anchor on a membraned entity. The only integration surface visible to non-insiders. + +### Grouping Overlay + +A first-class attributed, non-destructive re-parenting of entities. Per-scope (private / shared / org / public), per-author. Applied at render time. + +### Projection Slot + +A named rendering surface bound to an entity query. A package defines its slot set; a viewer may override within the slot's allowance. + +### Projection Source + +A named, viewer-scoped row producer eligible to be unioned with other sources at a surface's Projection Arbiter. Each source owns one query path and applies its own visibility gate (`row_visible`) before returning rows. A source is NOT any method on `SA_Projection`; it is specifically a row producer composable into a surface's arbiter. Today's sources: `chat` (`recent_prompts` — chat-domain-joined) and `realm` (`recent_entities` — role/origin-realm-scoped). Sources are kernel-owned; third parties contribute realm adapters (which feed `realm`), not sources. + +### Projection Arbiter + +A surface-scoped composer that enumerates the Projection Sources feeding that surface, invokes them for the current viewer, and unions their output into the surface's response shape. The arbiter owns source enumeration, union strategy, and per-source instrumentation (`window.sources` readout). Today implemented as one method per surface on `SA_Projection` (`for_mission` is the only instance). Not a standalone class: at two sources with no arbitration rules (dedup, sort-merge, ACL-at-merge), a class would be ceremony. Promotion to a class is deferred until (a) a third source, (b) a concrete arbitration rule, or (c) third-party source registration lands. Surfaces with a single source (`/thread`, `/trace`, `/lattice/subtree`) do not go through an arbiter and do not need one. + +### Influence Halo / Ring + +Concentric bands around an entity carrying weighted references to related entities. The continuous limit of discrete rings is the contour mode (`docs/ARCHITECTURE.md` — Derived pillar: Contour rendering). + +### Sub-Token Event + +Every internal orchestration step inside a provider invocation (embed, retrieve, tool call, subagent hop, completion, rerank, self-critique, router). First-class entity at band 5 inside the parent prompt's band 4 container. Visible and annotatable by the billing principal. + +### Geodesic Marker + +A memory record of a failure path in the prompt manifold. Seeded from bad verdicts on sub-tokens. Consumed by `SA_Geodesic_Seeder` to route around known failures. + +### Context Bundle + +A named, pinned slice of the entity graph with a deterministic signature. Multiple adapters (including specialized LLMs) bind to the same bundle for synchronized context. + +### Package + +The purchased SKU. Enumerates enabled capabilities, default overlays, default membranes. Assigned to an org. + +### Capability + +An enumerable permission string (`sa-core:sub-token-unfold`, `missionnet:impose-grouping`, `carma:admin-takeover`). Package-gated. + +### Service Principal + +A non-human authenticated identity. External services (PortTracker's Node, ingest clients, Jira bots) use service principal tokens to push mutations without impersonating a user. + +### Invariant + +A non-negotiable law the system upholds at runtime. See `02-invariants.md`. Violations are logged to `sa_invariant_violation`. + +### Adapter Certification + +The gate that promotes a realm adapter from "in development" to "live." Checklist runs against the Realm Adapter Contract invariants. See `03-realm-adapter-contract.md`. + +### Admission Contract + +The per-layer contract a new primitive — or a new instance of an existing primitive — must honor to be validly admitted into the system. + +A *named principle*, not a uniform checklist family. The principle is: **admission is layer-owned and layer-shaped**, not uniform across layers. Each layer's admission contract takes whatever form that layer's nature demands (a certification checklist for realms, runtime invariants for entities, an op whitelist for mutations, a return-shape obligation for projection sources, etc.). + +This principle does not add a new commitment. It names a shape already diffused through: + +- **Pillar 0.5** — everything rendered is `project(field, viewer, overlays, permissions)`. A new primitive must have defined behavior under all four inputs. +- **Pillar 0.75** — every orchestration step is observable. Anything action-shaped must declare how it emits observability (sub-token events, audit rows, source attribution). +- **Pillar 0.9** — the primitive set is closed; new capabilities are compositions of existing primitives. Admission = proof of valid composition. +- **`03-realm-adapter-contract.md`** — the single fully worked-out formal instance of a per-layer admission contract. + +See `docs/ARCHITECTURE.md` for the pillars themselves. This entry does not amend them; it gives the shape they imply a name. + +**Status ledger.** The ledger is the term's practical content. It records which primitive layers currently have formal contracts, which have informal ones, which are absent, and which are intentionally deferred. New rows enter only when a new primitive layer enters the closed set — not per instance, and not per cross-layer attribute. + +| Layer | Contract state | Reference / note | +|---|---|---| +| Realm | Formal | `03-realm-adapter-contract.md` + `sa_adapter_certification` table | +| Entity | Formal | `02-invariants.md` I2 / I4 / I7 + `SA_Truth_Class` / `SA_State_Class` validation in `SA_Entity::create` | +| Mutation | Formal | `SA_Ingest` op whitelist, idempotency-key discipline, service-principal scope, deterministic UUID rule | +| Projection Source | Informal | "Returns viewer-gated rows in arbiter-compatible union shape." Implied by the Projection Arbiter promotion; not yet written as a standalone contract. | +| Grammar | Absent | No admission rules for a new grammar today. Chat-lineage / equal-peer / containment-pack are ad-hoc in client code. | +| Surface inspection | Informal | Three discriminable cases now operative in `/mission/`'s lens-routing decision (`renderCurrentProjection` in `assets/mission.js`), driven by a shape predicate over `(entity.origin_realm, sub_token_count)` — no realm-specific code: (a) **chat-shaped** (`!origin_realm`) → trace lens; (b) **structural-only** (`origin_realm` set, `sub_token_count == 0`) → structural inspector; (c) **structural-with-sub-tokens** (`origin_realm` set, `sub_token_count > 0`) → hybrid lens (structural inspector + Sub-tokens section in step_index ASC order, with shape-aware labels read from `SubTokenEvent.metadata` rather than chat-shaped fallbacks). Discriminator field `sub_token_count` lives on the `/entity/{id}/inspect` projection response (additive, no schema change). Concrete instances: chat prompts (case a), FS containers/leaves (case b), GitHub Actions workflow runs (case c). Promotion to Formal (separate contract file) requires a fourth case or a second concrete hybrid example, whichever forces the discriminator's edge cases into a written rule. Until then the contract is the inline behavior recorded here. | +| Toolbox action | Formal | `06-toolbox-action-contract.md` (v0.1, 7 clauses). Two concrete instances: `/mission/`-native "Re-scan FS" and "Re-scan GH" buttons (both admin-only). Promotion criteria from Informal → Formal were satisfied by the second instance (GitHub Actions re-scan) cleanly fitting the same seven-clause shape: semantic binding (1 button → 1 REST endpoint), permission gate (manage_options at template AND REST), audit obligation (one operator-attribution structural-audit row per invocation), parameter shape (render-time derivation, no click-time dialogs), feedback contract (busy/success/failed state cycle), refresh obligation (`load()` on success), idempotency (zero net entity-count drift on repeat). Out-of-scope categories (parameter dialogs, destructive actions, multi-step compositions) explicitly deferred to future contract revisions when concrete instances drive them. | +| Membrane | Deferred | Primitive defined in this ontology; admission contract (port typing, opacity rules, billing-principal translucency) deferred until first implementation. | +| Overlay | Deferred | Primitive defined in this ontology; admission contract (scope, attribution, idempotency, render-time layering) deferred until first implementation. | +| Credential | Deferred | Surfaced by the GitHub Actions third-domain proof, which uses a filter-driven PAT (`sa_core_github_token`) as its smallest scope-honoring credential path. The general contract — how external-realm credentials enter the system (OAuth flows, refresh-token lifecycle, per-user vs per-org binding, scope-limited storage) — is not yet written. Existing primitives partially cover adjacent concerns: `SA_Credential_Pool` (LLM provider keys, single-secret-per-record), `SA_Service_Principal` (inbound bearer tokens), filter-driven adapter config (FS, GitHub Actions). None compose into a unified credential-admission shape. Authorized work will likely extend `SA_Credential_Pool` to handle multi-step OAuth tokens and add the operator UX (connect, store, scope, retire) as one slice's deliverable, then retire ad-hoc filter configs. First instance: GitHub. Second instance later: Jira / Linear / etc. | +| Camera-Projection Convergence | Pinned | Filed during the dependency audit preceding the Hybrid Inspector Lens slice. Concept: under Pillar 0's full vision, camera transitions at band boundaries are themselves projection-resolution events, not viewport transforms. Today the camera is degenerate (purely affine pan/zoom over fixed layout). Under Rung 4 phase 2+, zoom-band crossings re-layout via auto-compress / auto-expand, making camera a constrained re-projection over the referential manifold rather than a viewport adjustment. Concrete formalization deferred until a second example of camera-as-projection lands alongside Jump-2 (auto-descend on band-cross) or Jump-3 (membrane unwrap on jump). At promotion time this row will move from Pinned to Absent or Informal depending on which Rung 4 mechanic delivers it. Sits at the same admission-contract layer as Surface Inspection / Toolbox / Grammar — between primitives and rendering. | + +**Role and kind are NOT admission layers.** They are cross-layer discriminant attributes. A new role's admission is governed jointly by entity invariants (structural constraints: e.g. `message` requires `payload.role ∈ {user,assistant,system}`), grammar admission (visual and inspection-lens treatment), and projection source contract (which sources include the role). No independent role-admission contract. `kind` (circle / rrect) is narrower still — a pure grammar discriminant with no invariant or projection overlap. + +**What this entry does NOT do.** + +- Does not mandate a uniform contract shape across layers. Forcing realm-style checklists onto grammars or toolbox actions is the principle's failure mode. +- Does not create a runtime gate. Existing enforcement (invariants, certification table) stays as-is; no new machinery is implied. +- Does not forbid introducing a primitive without a contract in place. Absent rows in the ledger are honest debt markers, not refusal signals. + +### Prime Prompt + +*(Authored by Mark Holak.)* A minimal causal prompt that, when injected into a capable model, reconstructs the trajectory of an originating system without requiring replay of the full conversation history. See `docs/specs/concepts/prime-prompt-conjecture.md`. + +### Cognitive Heatsink + +*(Authored by Mark Holak.)* A thermodynamic model of thought-to-resolution through computational dissipation. See `docs/specs/concepts/cognitive-heatsink.md`. Maps to sub-token events as discrete thermal transfer steps. + +### Reference Traversal Continuity + +*(Authored by Mark Holak. Preserved under Invariant **I9**.)* + +A kernel-level commitment that the act of following a reference from one entity to another is invariant across the kinds of entities being traversed and across the realm or representational layer the traversal currently inhabits. The traversal does not truncate at realm boundaries, truth-class boundaries, type-layer boundaries, or observability boundaries. + +Continuity is carried by the existing kernel-side bookkeeping (`parent_id`, `origin_realm` + `origin_id`, `correlation_id`, `causation_id`, `derived_from`, edge kinds, sub-token parentage). The doctrine names what those primitives jointly enable. + +This is a doctrine of **motion**, not of **geometry**. Projection grammars (chat-lineage, containment-pack, equal-peer, future grammars) are downstream rendering decisions that choose how to display a region of the traversal field; they neither define, constrain, nor constitute the traversal itself. Coil, hydra, shell, treemap, and every other rendered cluster shape are choreographies — many other choreographies are equally valid. The motion neither requires nor privileges any of them. + +Articulated immediately after the GitHub Actions third-domain proof, which made the cross-domain continuity concretely visible: chat sub-tokens, filesystem containment, and workflow run/job/step descents are not three patterns held together by the adapter contract; they are three surface expressions of one continuous motion that the adapter contract gates entries to. See `concepts/reference-traversal-continuity.md` for the full doctrine and its relationship to companion concepts (Pillar 0.5 perspective projection, Pillar 0.75 transparency, Pillar 0.9 closed primitive set, Prime Prompt Conjecture, Cognitive Heatsink, Signal Telemetry Doctrine). + +--- + +## Distinguishing pairs (do not confuse) + +- **Realm** vs. **Subsystem** — realms are sovereign external systems; subsystems are parts of Core. +- **Incorporate** vs. **Absorb** — realms are incorporated (conduit); SA-authored code is absorbed (consolidated). +- **Event** vs. **Command** — events are records of what happened; commands are instructions or intents. +- **Canonical** vs. **Projected** — canonical is truth at its native home; projected is Core's non-canonical mirror. +- **Projected** vs. **Cached** — both are non-canonical; cached carries an explicit TTL; projected is kept live and refreshable. +- **Inferred** vs. **Derived** — inferred is produced by an LLM or heuristic; derived is deterministic computation from known inputs. +- **Entity** vs. **Record** — an entity lives in Core; a record lives in a realm; a projection is an entity that *represents* a record. +- **Projection** (the mirror) vs. **Projection Source** vs. **Projection Arbiter** vs. **Projection Slot** — four distinct read-path concepts that share a word. *Projection* (the mirror) is the entity-side reflection of a realm record (truth-class level). *Projection Source* is a viewer-scoped row producer (read-path composition level). *Projection Arbiter* is the surface-scoped composer of sources (read-path orchestration level). *Projection Slot* is a named rendering surface bound to a query (rendering level). The four do not substitute for each other. +- **Quest** vs. **Job** — quests are narrative direction (authored, judged); jobs are executable work (scheduled, performed, resolved by reality). One quest may yield many jobs; no automatic promotion in either direction. See I10. + +--- + +## Usage rules + +1. Every spec document MUST use these terms in their defined sense. +2. When a new term enters the vocabulary, it MUST be added here before being used in other specs or in code. +3. Class names and log messages SHOULD mirror these terms. +4. When translation between Core's vocabulary and a realm's native vocabulary is necessary, it happens in the adapter, not in the spec. + +--- + +## Attribution + +The framework expressed in this document sits inside a larger lineage: + +``` +CR ⊇ CA ⊇ JourneySeeker ⊇ MissionNet/MeshNet ⊇ SA-Orchestration ⊇ Brand ⊇ Client +``` + +Causal Relativity, Causal Agentics, and JourneySeeker are pre-existing intellectual property authored by Mark Holak, preserved under Invariant **I9** and Section 5.1 carve-outs of the CTO Employment & Equity Agreement. MissionNet/MeshNet is the Corporate-Campaign theme. SA-Orchestration is the current implementation slice. Brand and Client labels are the rendering layer (see `concepts/concept-vs-label.md`). + +Each layer below renders, specializes, or themes the layer above. No layer below replaces the layer above. See `concepts/lineage-chain.md` for the full doctrine. + +Implementation and glossary in this document: © Sleeper Agents LLC. + +Named conceptual framework elements (Prime Prompt, Cognitive Heatsink, Russell-Ouroboros Conjecture, PR notation, quanta-of-LLMs / information-lattice framing): authored independently by Mark Holak. See `docs/specs/concepts/` for the preserved artifacts and full author credit. diff --git a/SA-orchestration MD/Archvie/sa-core/docs/specs/02-invariants.md b/SA-orchestration MD/Archvie/sa-core/docs/specs/02-invariants.md new file mode 100644 index 0000000000000000000000000000000000000000..6b427f948a3d4a846bdd3db5a74e5c92adcb495e --- /dev/null +++ b/SA-orchestration MD/Archvie/sa-core/docs/specs/02-invariants.md @@ -0,0 +1,180 @@ +# MissionNet System Invariants (v0.1) + +*Authored by Mark Holak. Adapted into Core by implementation.* + +These are not implementation details. They are **laws**. Violations are recorded to `sa_invariant_violation`; severe violations raise exceptions. Code that violates an invariant fails review. + +The enforcement surface lives in `includes/enforcement/class-sa-invariants.php`. Every invariant here has a stable id (`I1`–`I9`) used in the violation log. A bug tracker can link against these ids. + +--- + +## Foundational asymmetry + +The invariants below exist because Core distinguishes between truth classes: **canonical, projected, cached, inferred, derived, synthetic**. These classes are asymmetric — they cannot collapse into each other. That asymmetry is what prevents Russell's paradox from re-entering through the back door: a system that consumes itself can only stay coherent if the act of consumption preserves distinctions as it moves. The timeless lattice translates through spacetime precisely because the asymmetry survives the motion. + +Without these invariants, the asymmetry decays, the classes collapse, and the system begins to lie to itself about what is real. + +--- + +## I1 — Core must not claim canonical ownership of incorporated realm data + +Incorporated realms (FluentCRM, Jira, QuickBase, QuickBooks, PortTracker's Node service, arbitrary APIs) remain the canonical source of their own records. Core mirrors, mediates, and audits — never authors canonical state on a realm's behalf unless that realm is Core itself. + +**Enforcement:** +- `SA_Ingest::upsert_entity` stamps `truth_class = projected` by default on all ingested realm records. +- A realm adapter's `truth_class_for_concept()` may *only* return `canonical` for concepts whose canonical home is Core (rare). +- Any attempt to write `truth_class = canonical` on an entity with a non-`sa-core` `origin_realm` is rejected. + +**Violation severity:** fatal. + +--- + +## I2 — Every surfaced datum must declare truth_class + +Entities, projections, cache rows, AI summaries, and adapter payloads must carry a valid `truth_class`. Without this, viewers cannot distinguish a canonical fact from an LLM guess. + +**Enforcement:** +- `SA_Entity::create()` calls `SA_Invariants::check_truth_class_declared()`. +- Default for unknowable-origin entities is `canonical`; default for ingest is `projected`; default for LLM output is `inferred`. All three are valid; absence of any value is not. + +**Violation severity:** fatal. + +--- + +## I3 — Every cross-realm action must be causally traceable + +A change initiated in Core that reaches (or originated in) an external realm must trace to an actor, a causation id, and a correlation id. `SA_Structural_Audit::trace_origin()` must be able to walk backward from any resulting state to the first user-attributed act. + +**Enforcement:** +- `SA_Provenance::enforce()` is called before any cross-realm mutation. +- Audit rows carry `correlation_id` + `causation_id`. + +**Violation severity:** error (blocks the action; recoverable by retrying with provenance attached). + +--- + +## I4 — No adapter may emit a record without native identity + realm provenance + +Adapter pulls MUST populate `origin_realm` + `origin_id` (directly or via `external_key`). Without this, the record cannot be re-matched, cannot be pushed back, and cannot be audited across the realm boundary. + +**Enforcement:** +- `SA_Invariants::check_realm_provenance()` is called on every ingest mutation. +- `SA_Ingest` rejects payloads missing these fields. + +**Violation severity:** fatal. + +--- + +## I5 — MissionNet must degrade without corrupting realm integrity + +When Core fails (offline, bugged, under attack), the client's native realms must not suffer collateral damage. This is the philosophical *tool test* expressed as a technical law. + +**Enforcement:** +- Adapter writes (`push`) must be idempotent per `idempotency_rules()`. +- Bulk operations must be chunked + resumable. +- Health-check endpoints must fail closed — Core not reachable means ingest halts, not silently half-writes. +- Adapter certification checklist item: `failure_path_verified` + `rollback_defined`. + +**Violation severity:** warn at design time, error at runtime. + +--- + +## I6 — Cache may never silently replace source verification in high-trust paths + +Cache accelerates; cache does not become truth. When the caller asks for a high-trust answer (regulated domains, financial totals, legal records), Core must re-verify against the canonical realm, not return a cached projection. + +**Enforcement:** +- Per-adapter cache TTLs declared in `capability_matrix()`. +- High-trust query paths pass `verify_live: true` to the executor/realm; cache is skipped. +- A future policy engine may classify concepts as high-trust and force verification. + +**Violation severity:** error in high-trust contexts; warn otherwise. + +--- + +## I7 — AI output must remain explicitly derivative + +No LLM output may be stored or surfaced as canonical truth without an explicit canonical source reference or a recorded human affirmation. + +**Enforcement:** +- `SA_Executor` stamps completion entities with `truth_class = inferred`. +- `SA_Entity::create()` calls `SA_Invariants::check_ai_output_not_canonical()`. Attempting to create an AI-sourced entity with `truth_class = canonical` without either `source_refs` (non-empty) or `human_affirmed_at`+`human_affirmed_by` is rejected. +- Promotion from `inferred` → `canonical` or `projected` requires an explicit endpoint call (future work) that records the promoting actor + timestamp. + +**Violation severity:** fatal. + +--- + +## I8 — Destroying Core must not destroy incorporated realms + +This is I5's strongest form. If Core is deleted tomorrow, FluentCRM still works, QuickBooks still works, Jira still works, PortTracker still works. Core owns no canonical realm data. Cache is non-canonical by construction. Push-backs are immediate, not batched into Core's own persistence. + +**Enforcement:** +- Adapter certification checklist item: `source_identity_preserved` + `native_ids_preserved`. +- Contractual, not just technical: the product terms explicitly disclaim any Core-only source of truth for realm concepts. +- Periodic audit: `SA_Adapter_Certification` re-runs on each adapter release and the passed flag is visible to operators. + +**Violation severity:** fatal at certification; a failing adapter cannot go live. + +--- + +## I9 — Attributable concepts and the framework lineage must not be rephrased as AI-original + +Both **named concepts** and **the framework lineage that contains them** must carry author attribution wherever surfaced. + +**Named concepts** (authored by a human and preserved verbatim under this invariant) include the Russell-Ouroboros Conjecture, the Prime Prompt Conjecture + PR notation, the Cognitive Heatsink model, the Signal Telemetry Doctrine, the Reference Traversal Continuity Doctrine, the Story Seed pattern, the Concept-vs-Label doctrine, the Compass Doctrine, the Attention Doctrine, the Quest-vs-Job distinction, and any subsequent attributable work added to `docs/specs/concepts/`. + +**The framework lineage** is the chain Causal Relativity ⊇ Causal Agentics ⊇ JourneySeeker ⊇ MissionNet/MeshNet ⊇ SA-Orchestration ⊇ Brand ⊇ Client (see `concepts/lineage-chain.md`). The structural primitives at each layer (truth class taxonomy, compass roles, description-as-contract, seed-vs-canonical, projection vs canonical, actor/realm/adapter, Quest/Job distinction) are framework primitives, not implementation primitives. + +AI-generated text that paraphrases or adapts either named concepts or framework primitives must reference the original author and must not present the concept as newly originated by the AI or by the implementation layer. + +**Enforcement:** + +- The spec pack under `docs/specs/` carries attribution frontmatter. +- `docs/specs/concepts/` preserves the original authored artifacts verbatim with explicit author credit. +- AI output passing through Core that draws on attributable concepts or framework primitives must reference them (adapter-level policy, future work). +- Adapter certification: AI summarizers that produce text must not strip authored-concept attribution. +- Seed generators must not synthesize content that re-authors framework primitives as implementation-original. INTERPRETIVE-mode synthesis is bound by this clause. + +**Violation severity:** warn at generation time; fatal at publication time. + +--- + +## I10 — No automatic conversion between Quest and Job + +A Quest may not be promoted to a Job, and a Job may not be elevated to a Quest, without an explicit human act recording the conversion. + +The reason: a Quest lives in the realm of authored intent — admissible uncertainty, possible alternatives, future framing. A Job lives in the realm of resolved consequence — it happened or did not. Auto-conversion collapses one into the other and erases the distinction the author made between direction and execution. + +**Enforcement:** + +- Quest → Job conversion requires an actor + timestamp + recorded conversion event in the audit log. +- Job → Quest elevation (rare) requires the same. +- A "Convert to Job" UI action that creates the conversion event with explicit human click is acceptable. A scheduled background job that auto-promotes without that event is not. +- Reality resolves Jobs; humans resolve Quests. A system that conflates the two collapses the compass (see `concepts/compass-doctrine.md`). + +**Violation severity:** error. + +--- + +## Notation + +Every invariant check logs a row to `sa_invariant_violation` with: + +- `invariant_id` — one of `I1`…`I9` +- `severity` — `warn | error | fatal` +- `target_entity_id` / `target_audit_id` +- `actor_id` +- `details` — JSON-encoded context +- `acknowledged_at` / `acknowledged_by` — operational triage + +Operational review dashboards can render violations grouped by `invariant_id` and sort by severity. + +--- + +## Next + +- Wire all cross-realm mutation paths (push, pull, ingest) through `SA_Invariants::assert_or_throw()`. +- Add enforcement to the presentation layer: UI must display `truth_class` on every surfaced datum (stamp in the top corner of every rendered entity). +- Extend audit trace with chain-coherence checks: if a row claims a causation_id, that row must exist. +- Build the policy engine layer so I6 (high-trust) and I9 (attribution) become user-configurable. diff --git a/SA-orchestration MD/Archvie/sa-core/docs/specs/03-realm-adapter-contract.md b/SA-orchestration MD/Archvie/sa-core/docs/specs/03-realm-adapter-contract.md new file mode 100644 index 0000000000000000000000000000000000000000..99066fa6346c03281a928f49e73b5fbadfb5df19 --- /dev/null +++ b/SA-orchestration MD/Archvie/sa-core/docs/specs/03-realm-adapter-contract.md @@ -0,0 +1,282 @@ +# MissionNet Realm Adapter Contract (v0.1) + +*Implementation © Sleeper Agents LLC. Contract governed by invariants authored with Mark Holak's conceptual framework.* + +Every realm adapter (FluentCRM, Fluent Support, Fluent Boards, Jira, QuickBase, QuickBooks, Salesforce, HubSpot, PortTracker's Node service, arbitrary REST/GraphQL/DB) implements `SA_Realm_Adapter`. This document is the formal contract. + +An adapter is **complete when it preserves origin, survives failure, emits audit, and passes certification** — not when it "functions." + +--- + +## Interface + +```php +interface SA_Realm_Adapter { + public static function realm_id(): string; + public static function capabilities(): array; + public static function concept_map(): array; + public static function pull(string $concept, array $filter, array $context): array; + public static function push(string $concept, SA_Entity $entity, array $context): array; + public static function watch_hooks(): array; + public static function capability_matrix(): array; // formal gates + public static function idempotency_rules(): array; // replay behavior per op + public static function truth_class_for_concept(string $concept): string; +} +``` + +--- + +## `realm_id()` + +Short, stable identifier. Examples: `fluent-crm`, `fluent-support`, `fluent-boards`, `jira`, `quickbase`, `quickbooks`, `porttracker-node`. + +Must be unique across all adapters in a given Core install. Used as the `origin_realm` on every entity the adapter produces. + +--- + +## `capabilities()` + +Set of strings from: `read`, `write`, `watch`, `bulk`, `incremental`, `idempotent_push`. + +- `read` — adapter can pull records from the realm. +- `write` — adapter can push changes back into the realm. +- `watch` — adapter subscribes to realm events (webhook or WP hooks). +- `bulk` — adapter supports full snapshot pulls. +- `incremental` — adapter supports `since` cursor / delta pulls. +- `idempotent_push` — repeated push calls with the same payload are safe. + +A read-only adapter declares only `read` (and optionally `watch`, `bulk`, `incremental`). + +--- + +## `payload.attributes` namespace convention + +Structured realm-side metadata that Core stores on an ingested entity goes under `payload.attributes` — a reserved sub-object. Freeform realm-native blobs remain elsewhere in `payload` (e.g., `payload.raw`, `payload.body_markdown`), but any field an adapter intends to be *addressable* — readable by the trace panel, queryable by future projections, or filter-eligible — belongs in `attributes`. + +```php +$entity->payload = [ + 'attributes' => [ + 'mime_type' => 'application/pdf', + 'size_bytes' => 12345, + 'modified_at' => '2026-04-23T10:15:00Z', + 'basename' => 'spec.pdf', + 'extension' => 'pdf', + ], + 'raw' => [/* whatever realm-native detail the adapter wants to retain */], +]; +``` + +**Purpose.** Two-fold: + +1. Adapters converge on a predictable shape for structured fields. The FS adapter, the Jira adapter, the QuickBase adapter all put their structured metadata in `attributes`. A downstream reader (trace panel, future filter surface) knows where to look without realm-specific code. +2. A later promotion to typed columns — if cross-realm queries demand it — is a rename migration, not a schema redesign. `payload.attributes.mime_type` becomes `entity.attributes.mime_type` or a separate `sa_entity_attribute` table without touching adapter code. + +**Discipline.** `attributes` values SHOULD be JSON-scalar (string / number / boolean / null) or flat arrays thereof. Nested objects inside `attributes` are permitted but signal that a sub-concept may want its own entity and containment edge instead. + +**Not canonical.** The `attributes` convention does not change truth-class. A projected entity's `payload.attributes` is still projection data; the realm remains canonical. + +--- + +## `concept_map()` + +Declares how realm concepts translate into Core entity roles and edge kinds. + +```php +[ + 'ticket' => ['role' => 'job', 'band' => 3], + 'agent' => ['role' => 'agent', 'band' => 3], + 'customer'=> ['role' => 'agent', 'band' => 3], + 'reply' => ['role' => 'message', 'band' => 4], + 'edges' => [ + 'ticket->agent' => ['kind' => 'depends', 'weight' => 0.8], + 'ticket->customer' => ['kind' => 'depends', 'weight' => 1.0], + 'reply->ticket' => ['kind' => 'contains', 'weight' => 1.0], + ], +] +``` + +Concept names are adapter-scoped (no collision across realms). Edge rules describe how relationships in the realm become typed edges in Core. + +--- + +## `capability_matrix()` + +Turns "tool not silo" into enforceable behavior: + +```php +[ + 'read' => true, + 'write' => true, + 'delete' => false, + 'impersonation' => false, + 'service_principal' => true, + 'human_confirmation_required' => ['delete', 'bulk_write', 'schema_change'], + 'simulation_only_mode' => true, +] +``` + +Core consults this matrix before dispatching every operation. An adapter that does not declare `delete = true` cannot delete; an operation listed in `human_confirmation_required` cannot execute without a confirmed human action; `simulation_only_mode = true` enables dry-run mode for destructive operations. + +--- + +## `idempotency_rules()` + +Per-operation replay behavior: + +```php +[ + 'pull' => [ + 'idempotent' => true, + 'replay_safe' => true, + 'conflict_resolution' => 'last_write_wins', + ], + 'push' => [ + 'idempotent' => false, + 'replay_safe' => false, + 'conflict_resolution' => 'reject', + ], + 'watch' => [ + 'idempotent' => true, + 'replay_safe' => true, + 'conflict_resolution' => 'last_write_wins', + ], +] +``` + +`conflict_resolution` values: `last_write_wins` | `reject` | `human_review`. + +A non-idempotent `push` must never be retried without an explicit policy decision. + +--- + +## `pull(concept, filter, context)` + +Returns a list of `SA_Ingest`-shaped mutations: + +```php +[ + [ + 'op' => 'upsert_entity', + 'entity' => [ + 'external_key' => '', // REQUIRED (or origin_id directly) + 'role' => 'job', + 'kind' => 'rrect', + 'payload' => [...], + 'truth_class' => SA_Truth_Class::PROJECTED, // default + ], + ], + [ + 'op' => 'upsert_edge', + 'edge' => [ + 'source_id' => '...', + 'target_id' => '...', + 'kind' => 'depends', + 'weight' => 0.8, + 'intentional' => true, + ], + ], + [ + 'op' => 'attach_source', + 'entity_id' => '...', + 'priority' => 10, + 'source_payload' => [...], + ], +] +``` + +**Provenance minimums are MANDATORY** (Invariant I4). Every upserted entity must carry either `external_key` (from which `origin_realm` + `origin_id` are derived) or `origin_realm` + `origin_id` explicitly. + +**Truth class default is `projected`** — non-canonical mirror of the realm's canonical state. Only adapters whose concept is genuinely advisory (weather forecasts, suggestions) may return `inferred`. + +--- + +## `push(concept, entity, context)` + +Apply a Core-side change back into the realm. + +Returns: + +```php +[ + 'ok' => true, + 'external_id' => '', + 'error' => null, + 'trace_token' => '', +] +``` + +**Idempotency:** if declared in `idempotency_rules()`, `push` MUST be safe to retry. The adapter is responsible for deduplicating on the realm side (using the entity's `id` or a synthetic idempotency key). + +**Never mutate without action path.** A push that was not triggered by a command (or an audited automation policy) is a protocol violation. + +--- + +## `watch_hooks()` + +```php +[ + ['hook' => 'fluentcrm/contact_created', 'handler' => [self::class, 'on_contact_created'], 'priority' => 10], + ['hook' => 'fluent_support/ticket_reply', 'handler' => [self::class, 'on_reply'], 'priority' => 10], +] +``` + +Core's `SA_Realm_Registry::wire_all_watchers()` attaches these on boot. The handler must emit ingest mutations (not directly mutate Core state), so that the ingest pipeline's enforcement gates apply uniformly. + +--- + +## `truth_class_for_concept(concept)` + +Returns one of `SA_Truth_Class::ALL`. Default: `projected`. + +- Realm concepts that are authoritative at the realm: `projected`. +- Realm concepts that are explicitly advisory: `inferred`. +- Core-authored concepts (rare): `canonical`. +- Cache-only concepts with TTL: `cached` (declared by the adapter's cache layer, not usually by the adapter itself). + +--- + +## Certification Checklist + +An adapter is **not live** until these pass. Recorded to `sa_adapter_certification`. + +| Check | Meaning | +|---|---| +| `source_identity_preserved` | Realm's native id is preserved on every ingested record and round-trippable through push. | +| `native_ids_preserved` | `origin_id` survives every transformation in and out. | +| `read_path_verified` | Pull returns records that successfully deserialize as Core entities. | +| `write_path_verified` | Push creates or updates a realm record and returns the native id. | +| `failure_path_verified` | Failures (network, 4xx, 5xx, timeouts) raise structured errors; no silent half-writes. | +| `stale_cache_behavior_verified` | When cache is stale and the caller demands high-trust, the adapter re-verifies against the realm. | +| `audit_event_emitted` | Every pull and push produces a `sa_structural_audit` row with `origin_realm`, `correlation_id`, `causation_id`. | +| `rollback_defined` | There is a documented compensation path for failed pushes or partial batches. | + +All eight must be `true` before an adapter moves from development to live. A failing certification row prevents the realm from being included in the active routing set. + +--- + +## Behavioral guarantees (enforced at interface level) + +- Adapter **never mutates without an action path**. A push requires a command; a pull is triggered by an explicit sync or a watch event. +- Adapter **never strips source metadata**. `origin_realm`, `origin_id`, `observed_at` survive every transformation. +- Every record returned includes native id and source realm. +- Outbound writes return status, external id, and (where available) a trace token. +- Retries are idempotent where declared; non-idempotent operations require explicit policy approval to retry. +- Error classes are normalized to `{ok, error, error_code, detail}`. + +--- + +## The tool test + +Before any realm adapter is declared live, the operator asks: + +> If Core disappears tomorrow, does this realm still work at its native home? + +If the answer is anything but *yes*, the adapter has crossed from incorporation into absorption. That is a different category, governed by a different process (code consolidation, not conduit), and must be signaled as such. The user and the operator must both consent before an adapter is ever permitted to cross this boundary. + +--- + +## Attribution + +Contract shape and adapter interface: © Sleeper Agents LLC. + +The invariants that the contract enforces (I4 realm provenance, I5 graceful degradation, I6 cache discipline, I8 tool test) implement conceptual commitments authored independently by Mark Holak. See `docs/specs/concepts/` for the foundational framework. diff --git a/SA-orchestration MD/Archvie/sa-core/docs/specs/04-async-execution.md b/SA-orchestration MD/Archvie/sa-core/docs/specs/04-async-execution.md new file mode 100644 index 0000000000000000000000000000000000000000..bec9089e44a75641e28906892b9b9903778864ae --- /dev/null +++ b/SA-orchestration MD/Archvie/sa-core/docs/specs/04-async-execution.md @@ -0,0 +1,678 @@ +# Async Execution — Design Specification + +Status: **design approved; Phase 1 implementing as foundational substrate.** +Target rung: 2.5 (between projection completion and zoom-quantum). +Scope: one foundational pillar. Implementation proceeds phase-by-phase under separate directives. + +## Framing (authoritative) + +The pillar is **ownership of execution**, not "offline" as a feature. + +Three layers, kept distinct: + +- **Now Session** — the user-facing scope. The browser tab, the compose bar, + the field as it reads during an interactive moment. UX lives here. In + Phase 1 this is UNCHANGED: prompts still feel like "I asked here, it is + working here, this thread is mine now." + +- **Shadow job** — the execution substrate. A server-owned lifecycle record + (`sa_job`) that tracks what the executor is actually doing. Introduced in + Phase 1 as foundational infrastructure, with no UX surfacing. The seam + that makes later decoupling possible without rewriting retries, state + transitions, frontier selection, failure handling, or field updates. + +- **Offline / Subconscious** — a future user-facing mode built on the + shadow-job substrate. Marketing layer for background / recoverable / long- + running work. NOT introduced by Phase 1. Introduced only when a later + directive surfaces it. + +The instruction the rest of this spec serves: + +> Treat shadow jobs as foundational infrastructure introduced now, but keep +> their behavior scoped to the current Now Session until a later directive +> surfaces them as an explicit Offline/Subconscious capability. + +--- + +## 0. Scope note + +This spec describes **async prompt execution** as a first-class capability. + +It does NOT cover: +- The client-state race bug-fix (a separate, independent commit — closes a symptom of the sync model; lands before or alongside phase 1 here). +- Implementation commits (each phase below becomes a directive; each directive becomes one or more commits). + +It DOES cover: +- What a prompt job is, and how it differs from the current request. +- The canonical state machine. +- The storage model. +- The worker strategy (comparison + decision + rationale). +- The client interaction model (comparison + phased choice). +- A phased migration plan where each phase ships a working system. +- The failure / retry / recovery model. +- Explicit tradeoffs and risks. +- Measurable success criteria. + +--- + +## 1. Motivation and Current Limitations + +### Today + +- `POST /sa-core/v1/prompt` is synchronous. The REST thread calls `SA_Executor::run`, which calls the adapter via `wp_remote_post`, which blocks the PHP request for up to 180 seconds (after the recent timeout raise). +- The browser tab owns the entire prompt lifecycle. The client's `fetch()` holds the connection; if the tab closes, the server continues running but the user loses all visibility into the outcome. +- Two prompts submitted in parallel are two parallel PHP workers, each doing a long cURL wait, with zero coordination, no priority, and no recovery if one of them crashes. +- The just-observed concurrent-submit race (two prompts attaching to the same parent) is a symptom: client-side `activeCtxRootId` is the only source of truth for "which node is the continuation root," so timing conflicts between user navigation and async resolution are inevitable. + +### Why this is structural, not cosmetic + +1. **Rung 5 (operator realism) is blocked.** Tests that simulate load, capacity planning, reliability SLAs, and meaningful metrics all require async job execution. Every one of these is meaningless against a sync model. +2. **Rung 4 (zoom-quantum engine) gets much harder on sync.** The engine wants cheap state-transition re-renders. Pushing state through long sync calls + `/mission/recent` refreshes introduces races we'd have to hunt repeatedly. +3. **Rungs 3 and 6 (self-hosting, carma-audit absorption) produce entity streams that shouldn't block HTTP.** Scanner runs, doc ingestion, code ingestion — none of these should tie up a web request. +4. **The tab is not a reliable process owner.** Real work — long code generations, multi-step agent chains, background analyses — must survive a closed tab. +5. **Browser-side state conflicts are fundamental with sync.** The concurrent-submit race is one; future race surfaces (multi-user live editing, real-time collaboration) are coming. Moving execution state authority to the server closes these by construction. + +--- + +## 2. Execution Model + +### What is a "prompt job" + +A **prompt job** is the unit of work that executes the `SA_Executor` pipeline against a specific prompt entity. It is: + +- A durable record with its own UUID identity (distinct from the prompt entity). +- A pointer to a persisted prompt entity (`prompt_id`). +- A member of the queue with a state, a priority, and a retry budget. +- Owned by the server, not the browser. +- Inspectable, retriable, cancellable, supersedable — as a first-class operation. + +### How it differs from the current request model + +| Concern | Today (sync) | Proposed (async) | +|---|---|---| +| Who owns the pipeline's lifecycle? | The REST thread | The worker layer | +| Where is "in-progress" state? | PHP memory + browser memory | `sa_job` DB row | +| Does closing the tab kill the work? | No (it continues) but the user loses visibility | No — and the user regains visibility on reload | +| Can two prompts run simultaneously? | Only as two parallel PHP workers with no coordination | Yes, first-class, priority-ordered | +| Can a failed execution be retried cleanly? | Via `retry_prompt` after the fact | Built into the state machine | +| Can the user inspect queued / running work? | No | Yes (CLI phase 2; UI later) | + +### Canonical lifecycle + +1. **Submit.** `POST /prompt` creates: + - Prompt entity (as today, synchronously within the request — this is fast; the adapter call is what blocks). + - `sa_job` row with `state='queued'`. + - Returns **`202 Accepted`** with `prompt_id`, `job_id`, and initial state. Target: response within 50ms. +2. **Enqueue.** Client adds the prompt to its `entities[]` array with the job's state, renders a "queued" drop. Client begins polling (phase 2) or listening via SSE (phase 4). +3. **Claim.** A worker polls for the highest-priority queued job. Transactional update: `state='queued'` → `state='running'`, `worker_claim=`, `claimed_at=now`. +4. **Run.** The worker invokes `SA_Executor::run_claimed(job_id)`. This is a refactored executor entry-point that operates on an already-persisted prompt + job, not a fresh submit. +5. **Complete.** On adapter success, the worker writes the response entity, the `flows-to` edge, the ledger row, the structural audit row (as today), and transitions the job to `state='succeeded'` with `response_id` set. +6. **Observe.** Client sees state transitions via polling or SSE, updates the drop's visual state class accordingly. +7. **Fail (if applicable).** On adapter error, the worker classifies the error, applies retry policy, and either re-queues the job with backoff OR marks it `state='failed'`. + +--- + +## 3. State Machine + +### States + +| State | Meaning | Terminal? | +|---|---|---| +| `queued` | Created; waiting for a worker | No | +| `running` | Claimed by a worker; adapter call in flight | No | +| `succeeded` | Adapter returned OK; response entity persisted | Yes | +| `failed` | Retry budget exhausted or non-retryable error | Yes | +| `cancelled` | User or operator intervention | Yes | +| `superseded` | Replaced by a newer job (e.g., user retried) | Yes | + +Once a job enters a terminal state, it does not change. A retry does **not** mutate an existing job — it creates a **new job** that references the old one via `supersedes`. + +### Transitions + +``` +queued → running : worker successfully claims +queued → cancelled : user/operator action before claim +running → succeeded : adapter OK + response entity written +running → queued : transient failure; attempt < max_attempts; apply backoff +running → failed : retry budget exhausted OR non-retryable error +running → failed : stuck (started > 15 min ago, no heartbeat) via cleanup +running → cancelled : operator kill (response discarded even if it arrives later) +succeeded → superseded : user-initiated retry replaces result +failed → superseded : user-initiated retry +cancelled → superseded : user-initiated retry after cancel +``` + +### Heartbeat model + +While `state='running'`, the worker MUST update `running_heartbeat_at` every 30 seconds. A separate cleanup task (run via system cron every 5 minutes) reclaims jobs where `running_heartbeat_at < now() - 5min` back to `state='queued'` (if `attempt < max_attempts`) or to `state='failed'` (else). + +### Triggers + +- `queued` ← `SA_Executor::enqueue` (called from REST handler). +- `running` ← worker process picks up a `queued` job and wins the claim race. +- `succeeded` ← worker completes execution and commits. +- `failed` ← worker exhausts retries OR cleanup declares job stuck. +- `cancelled` ← CLI `wp sa-core cancel-job ` OR admin action. +- `superseded` ← a new job is created referring to this one via `supersedes`. + +--- + +## 4. Storage Model + +### Decision: new `sa_job` table + +Not overloading `sa_entity`. + +Rationale: +- `sa_entity` represents authored knowledge: the user's prompt, the LLM's response, the derived context. It has one durable identity per artifact. +- `sa_job` represents execution: a prompt may have multiple execution attempts (retries), each with its own claim, timing, errors, heartbeats. Conflating these with entity state confuses semantics and crowds the entity row. +- Keeping them separate means: `sa_entity` stays durable knowledge; `sa_job` stays ephemeral execution records. Queries stay clean: "show me the user's recent prompts" is a `sa_entity` query; "show me what's queued" is a `sa_job` query. + +### Schema + +```sql +CREATE TABLE {prefix}sa_job ( + id CHAR(36) NOT NULL, + prompt_id CHAR(36) NOT NULL, + + state VARCHAR(16) NOT NULL DEFAULT 'queued', + -- queued | running | succeeded | failed | cancelled | superseded + + priority TINYINT UNSIGNED NOT NULL DEFAULT 50, + -- lower number = higher priority + -- 0-10: urgent (interactive, current-tab) + -- 20-40: normal user-initiated background + -- 50: default + -- 60-90: bulk / scheduled / idle-time + -- 100: lowest (opportunistic) + + attempt SMALLINT UNSIGNED NOT NULL DEFAULT 1, + max_attempts SMALLINT UNSIGNED NOT NULL DEFAULT 3, + + execution_options LONGTEXT NOT NULL, + -- JSON: { model?, prefer_provider?, depth?, router_model?, ... } + + worker_claim VARCHAR(64) NULL, + claimed_at DATETIME NULL, + started_at DATETIME NULL, + running_heartbeat_at DATETIME NULL, + finished_at DATETIME NULL, + + response_id CHAR(36) NULL, + -- set on succeeded; points to the response entity + + error_kind VARCHAR(32) NULL, + -- timeout | rate_limit | provider_5xx | provider_4xx + -- | parse | auth | worker_crash | stuck | unknown + + error_message TEXT NULL, + + supersedes CHAR(36) NULL, + -- if this job replaces a previous attempt + + org_id BIGINT UNSIGNED NOT NULL, + created_by BIGINT UNSIGNED NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + + PRIMARY KEY (id), + KEY idx_state_priority_created (state, priority, created_at), + KEY idx_prompt (prompt_id), + KEY idx_worker_claim (worker_claim), + KEY idx_org_user_state (org_id, created_by, state), + KEY idx_heartbeat (state, running_heartbeat_at) +) +``` + +### Interaction with existing entity fields + +| Field | Lives on | Set when | Changed by jobs? | +|---|---|---|---| +| `correlation_id` | `sa_entity` | At prompt creation time (unchanged) | No | +| `parent_id` | `sa_entity` | At prompt creation time (continuation semantics, unchanged) | No | +| `causation_id` | `sa_entity` | At prompt creation time (unchanged) | No | +| `source_refs` | `sa_entity` (response) | Written by worker at job completion | Indirectly (job runs the executor which sets it) | +| `truth_class` / `state_class` | `sa_entity` | Set at write time (unchanged) | No | + +The `sa_job` table is orthogonal to the entity graph. Entities are durable structural records. Jobs are execution metadata. + +### Retry and supersession + +When a user retries a failed or succeeded prompt: +1. Create a **new** `sa_job` row with the same `prompt_id`, new UUID, `attempt=1`, `state=queued`, `supersedes=`. +2. The old job stays in place (terminal state preserved). +3. If the retry path requires cleaning up a prior response entity (as the current `retry_prompt` does for failed responses), that happens in the worker that claims the new job, NOT at enqueue time. + +This model means every execution attempt is recorded and queryable. "Show me the history of this prompt" becomes `SELECT * FROM sa_job WHERE prompt_id = X ORDER BY created_at`. + +--- + +## 5. Worker Strategy + +### Decision: custom worker, driven by WP-CLI + system cron + +### Comparison + +**Option A: WP-Cron alone** — not chosen. +- Pros: zero external dependencies. +- Cons: WP-Cron spawns only on page visits (unreliable for low-traffic sites); each spawn is bound by `max_execution_time`; no native priority; no concurrency management. The "real cron hitting `wp-cron.php`" workaround mitigates the first point but the others remain. + +**Option B: Action Scheduler (the library)** — fallback / future option. +- Pros: mature, battle-tested by WooCommerce and Mailpoet, has built-in claim/retry/logs, comes with an admin UI, well-understood in the WP ecosystem. +- Cons: adds a dependency (~200KB library, or plugin requirement); its schema and patterns are WooCommerce-flavored; it wraps our job concept with its own scheduling layer that we'd then have to thread through. +- Worth reconsidering if we ever need to interoperate with a WooCommerce-running site that already uses AS. + +**Option C: Custom worker** — chosen. +- Rationale: our `sa_job` table **is** our job system. Its state machine is specific to our semantics (prompt-centric, projection-aware, org-scoped). Adding Action Scheduler on top means we'd write adapters between our table and theirs. A custom worker is ~300 lines of code, directly aligned with our data model. +- Cons: we own the scheduler code. Edge cases (claim races, heartbeat recovery, backoff) must be solved by us. But: the surface is narrow, there's no distributed coordination to worry about (single-node plugin), and the patterns are well-known. + +### Custom worker implementation + +``` +wp sa-core run-worker [--once] [--batch-size=N] [--priority-ceiling=N] +``` + +Behavior in one iteration: +1. Generate or resume a `worker_id` for this process (UUID). +2. Claim a batch of jobs: + ```sql + UPDATE sa_job + SET state='running', + worker_claim=, + claimed_at=NOW(), + started_at=NOW(), + running_heartbeat_at=NOW() + WHERE state='queued' + AND priority <= + ORDER BY priority ASC, created_at ASC + LIMIT + ``` + The single-statement UPDATE is the claim atomic: whichever worker's UPDATE lands first, wins. +3. Re-query the claimed rows by `worker_claim=` to get the actual job ids. +4. For each claimed job: + - Call `SA_Executor::run_claimed(job_id)`. + - On success: set state='succeeded', response_id, finished_at. + - On retryable failure: if attempt < max_attempts, set state='queued' with backoff delay (encoded via `created_at += backoff`); else state='failed'. + - On non-retryable failure: state='failed' immediately. +5. Spawn a heartbeat thread/callback that updates `running_heartbeat_at` every 30s while the adapter is in flight. +6. Sleep 2 seconds. If `--once`, exit. Otherwise loop. + +### Driving the worker + +Three supported modes: + +**Mode 1: Single-shot via system cron** (recommended for production) +```cron +* * * * * wp sa-core run-worker --once --batch-size=5 +``` +Every minute, claim up to 5 jobs, process them, exit. Reliable, bounded, manageable. + +**Mode 2: Long-running service** (optional for high-volume) +``` +wp sa-core run-worker +``` +Starts a persistent worker. Polls continuously. Should be supervised (systemd / supervisor) for restarts on crash. + +**Mode 3: WP-Cron via wp-cron.php** (default; fallback) +If a site has no system cron, register `sa-core-process-queue` as a WP-Cron event firing every minute. Relies on site traffic; acceptable for dev and low-traffic installs. + +### Heartbeat and stuck-job cleanup + +Separate command: +``` +wp sa-core reclaim-stuck-jobs +``` +System cron: every 5 minutes. + +Behavior: +- Find jobs where `state='running'` AND `running_heartbeat_at < now() - 5 minutes`. +- For each: + - If `attempt < max_attempts` AND `started_at > now() - 15 minutes`: reclaim to `state='queued'`, clear `worker_claim`. + - Else: `state='failed'`, `error_kind='stuck'`. + +### Double-execution safety + +Risk: worker A claims a job, loses heartbeat, cleanup reclaims to queued, worker B claims and runs. Worker A's adapter call may still be in flight. Both workers could reach the "complete" step. + +Mitigation: all state-finalization writes (`state='succeeded'`, `response_id`, etc.) are conditioned on `WHERE state='running' AND worker_claim=`. Whichever update lands second sees no matching row and aborts silently. The first worker's result wins; the other worker's result is discarded (wasted work, but no data corruption). + +--- + +## 6. Client Interaction Model + +### Decision: polling in phase 2, SSE in phase 4, WebSocket deferred + +### Comparison + +| Protocol | Pros | Cons | Chosen? | +|---|---|---|---| +| **Polling** | Simplest; works anywhere; zero new infra; trivial to implement | Latency up to poll interval; wastes bandwidth when idle (mitigated by only polling while in-flight jobs exist) | Phase 2 | +| **Server-Sent Events** | Efficient for state updates; built into browsers (`EventSource`); one-way but that's what we need | Requires long-lived HTTP connection; may conflict with PHP `max_execution_time` on some hosts; harder to debug | Phase 4 | +| **WebSocket** | Full-duplex; lowest latency; richest protocol | Needs separate infra (Node or Ratchet-in-PHP); significant deployment burden; overkill for our scale | Deferred | + +### Polling protocol (phase 2) + +**Submit flow:** + +1. Client calls `POST /prompt` with prompt text + root_entity_id + etc. +2. Server returns `202` with `{ prompt_id, job_id, state: 'queued' }`. +3. Client: + - Adds entity to `entities[]` with `state='queued'`, renders as queued drop. + - Starts a poll loop if not already running. + +**Poll loop:** + +- Every 2 seconds, while at least one in-flight (`queued` or `running`) job exists: + - Call `GET /sa-core/v1/mission/recent?since=`. + - Server returns entities with any updates since the timestamp. + - Client merges results by `prompt_id` (does not replace array wholesale). + - If all known prompts are terminal (succeeded, failed, cancelled, superseded) or no in-flight jobs remain, stop polling. + +**Server endpoint changes (phase 2):** + +- `GET /mission/recent` accepts `since=`. Returns only prompts updated since that time (joins `sa_job.updated_at` into the "is this updated" test). +- Response includes an `as_of` timestamp the client uses as the next `since` value. + +### Field state → visual class mapping + +| Job state | CSS class on drop | Existing / new | Visual | +|---|---|---|---| +| `queued` | `is-queued` | **New** | Slow breath, paler halo; "waiting" reading | +| `running` | `is-pending` | Existing | Current pending animation (dashed rotating halo) | +| `succeeded` | (none) | Existing | Default settled drop | +| `failed` | `is-orphan` | Existing | Muted salmon halo + retry chip | +| `cancelled` | `is-cancelled` | **New** | Greyed-out, no retry chip | +| `superseded` | hidden OR `.is-superseded` | **New** | Typically hidden; shown faded if a debug toggle enabled | + +Transitions: +- `queued → running`: swap `is-queued` for `is-pending`. +- `running → succeeded`: remove `is-pending`, merge real response data, `renderField()` for any layout adjustment. +- `running → failed`: swap `is-pending` for `is-orphan`. + +### SSE protocol (phase 4, future) + +- Client opens `EventSource('/sa-core/v1/mission/events')` instead of polling. +- Server streams events like `event: job.state_changed\ndata: {"prompt_id":"…","state":"succeeded",…}\n\n` whenever a job's state changes for this viewer. +- Client falls back to polling if the `EventSource` fails. + +### WebSocket (deferred) + +Adds separate infra (Node sidecar, Ratchet-in-PHP, or a managed service like Pusher). No concrete need yet. Revisit when multi-user real-time collaboration becomes a live concern. + +--- + +## 7. Migration Plan + +### Principle: each phase ships a working system + +No phase leaves the codebase in a half-broken state. At every phase boundary, a user can submit a prompt and see a result. + +### Phase 0 — Concurrent-submit race fix (prerequisite, separate scope) + +- Single commit, fixes the browser-state race in the current sync model. +- Does NOT introduce async. +- Makes parallel-submit testing reliable, which phase 2 needs. + +### Phase 1 — Schema + shadow jobs (1–2 commits, zero behavior change) + +Goal: write `sa_job` rows alongside synchronous execution, so the data layer exists before the execution path changes. + +Work: +- Migration: create `sa_job` table (schema in §4). +- `SA_Executor::run` remains synchronous. At the end, it writes a `sa_job` row with `state='succeeded'` (or `'failed'` if the adapter errored). +- No REST change. No client change. No worker yet. + +Test: CLI proof submits a prompt, verifies an `sa_job` row with correct state exists and references the prompt. + +Rollback: drop the table via migration down; no user impact. + +### Phase 2 — Async execution path (3–4 commits) + +Goal: move adapter invocation out of the REST handler. + +Work: +- Refactor `SA_Executor` into two entry-points: + - `SA_Executor::run` — retained for direct synchronous use (CLI proofs, back-compat). + - `SA_Executor::enqueue($prompt_text, $options)` — writes prompt entity + queued job, returns ids. Fast. + - `SA_Executor::run_claimed($job_id)` — worker's entry-point; runs the pipeline against an existing prompt + job. +- `POST /prompt`: call `enqueue`, return `202` with `{ prompt_id, job_id }`. +- Client `submitPrompt`: handle `202`, render queued drop, start poll loop. +- Server: `GET /mission/recent` accepts `since` param; returns updated rows. +- Add worker CLI: `wp sa-core run-worker`. +- Add heartbeat cleanup: `wp sa-core reclaim-stuck-jobs`. +- New CSS classes: `is-queued`, `is-cancelled`. + +Test: +- CLI proof: submit a prompt, verify `state=queued`, run worker once, verify `state=running` → `state=succeeded`. +- Concurrent-submit proof: submit two prompts with different priorities (10 and 90), run worker, verify priority=10 runs first. +- Retry proof: submit, simulate transient failure (mock adapter), verify re-queue with backoff and eventual success. + +Rollback: revert `POST /prompt` to call `run` synchronously. `sa_job` rows for already-queued jobs need manual cleanup (or a CLI command to replay them synchronously). Worker command stays but unused. + +### Phase 3 — Priority queue (1–2 commits) + +Goal: expose priority so active-thread prompts outrank bulk / background work. + +Work: +- `SA_Executor::enqueue` accepts `priority` in options (default 50). +- Client: no UI change. Still submits at default. Future bulk operations (Scanner, Code Lens ingestion, etc.) pass higher priority values. +- Worker query already orders by priority — schema supports it from phase 1. + +Test: submit 3 prompts with priorities {10, 50, 90}; verify worker claims in that order. + +Rollback: trivial; remove priority from enqueue options, falls back to default. + +### Phase 4 — SSE layer (2–3 commits) + +Goal: eliminate polling overhead. + +Work: +- Add `GET /sa-core/v1/mission/events` endpoint that streams SSE. +- Server-side: when a job's state changes, broadcast an SSE event to that viewer's stream. +- Client: detect `EventSource` support; subscribe if available; else continue polling. + +Test: compare SSE event timing vs polling; verify identical state propagation; verify graceful fallback when connection drops. + +Rollback: client falls back to polling automatically if SSE endpoint returns 404 or connection fails. + +### Phase 5 — Hardening (2–4 commits) + +Goal: operator visibility, intervention, metrics. + +Work: +- `wp sa-core list-jobs [--state=] [--org=] [--since=]` — list jobs with filters. +- `wp sa-core job-details ` — full history for a job. +- `wp sa-core cancel-job ` — operator cancellation. +- `wp sa-core retry-job ` — explicit requeue. +- Optional: `wp sa-core queue-metrics` — count by state, average latency per org, error rate. + +Test: CLI proofs for each command. + +### Phase 6 — async-native features (optional, post-pillar) + +Built on the same `sa_job` infrastructure, no further schema changes: + +- Background bulk operations (Scanner, Code Lens, Doc Lens at scale). +- Scheduled prompts (run at 2am) via a `scheduled_for` column on `sa_job`. +- Multi-prompt chains (job B starts after job A succeeds). + +Not part of the async pillar's completion criteria; listed for continuity. + +### Test discipline at each phase + +Every phase ships one or more CLI proofs in the `includes/cli/` pattern already established (geodesic-proof, promotion-proof, projection-proof, claim-continuation): + +- `async-execution-proof`: state progression queued → running → succeeded against a synthetic prompt. +- `priority-queue-proof`: three synthetic prompts at different priorities; verify claim order. +- `retry-proof`: simulated transient adapter failure; verify re-queue and eventual success. +- `heartbeat-proof`: start a synthetic running job, sleep past heartbeat window, run reclaim, verify state. +- `concurrent-safety-proof`: simulate double-claim race; verify only one worker's writes take effect. + +--- + +## 8. Failure and Retry Model + +### Error classification + +Every failure gets an `error_kind`: + +| `error_kind` | Meaning | Retryable? | Backoff | +|---|---|---|---| +| `timeout` | cURL 28 or executor-level timeout | Yes | Exponential (5s, 25s, 125s + jitter) | +| `rate_limit` | Adapter HTTP 429 | Yes | Respect `Retry-After` header; else 60s min | +| `provider_5xx` | Adapter returned 5xx | Yes | Exponential | +| `provider_4xx` (non-429) | Adapter returned 4xx | No | N/A | +| `parse` | Response body couldn't be parsed | No | N/A | +| `auth` | Credentials invalid | No | N/A (needs operator) | +| `worker_crash` | Worker died mid-execution | Yes (via reclaim) | Immediate | +| `stuck` | Job has been running > 15 min | No | Terminal; mark failed | +| `unknown` | Catch-all for unclassified errors | Yes (conservative default) | Exponential | + +### Retry rules + +- Default `max_attempts = 3`. Configurable per-enqueue via options. +- Backoff stored as a delay on the re-queued job's `created_at`: `created_at = now() + backoff`. Worker query only claims jobs where `created_at <= now()`, so backoff is honored naturally. +- Backoff durations: + - Attempt 2: 5s + random(0, 5s) + - Attempt 3: 25s + random(0, 15s) + - (After attempt 3 fails: terminal) + +### Timeout handling + +- **Adapter timeout (180s)**: cURL 28. `error_kind='timeout'`. Retryable. +- **PHP `max_execution_time` (300s)**: worker process killed. Heartbeat lost. Reclaimed by `reclaim-stuck-jobs`. +- **Hard job ceiling (15 minutes)**: a running job started > 15 min ago with no recent heartbeat is declared stuck. `error_kind='stuck'`. Terminal. + +### Dead-letter handling + +- Jobs that exhaust `max_attempts` go to `state='failed'` with `error_kind` set. +- They remain visible in the user's field as `is-orphan` drops (same treatment as today's orphans from sync-failure). +- User can retry via the retry chip (same UX as today). Retry creates a new `sa_job` row with `supersedes=`. +- Operator CLI: `wp sa-core list-jobs --state=failed` surfaces recent failures. +- Operator can run `wp sa-core retry-job ` to force a retry without user interaction. + +### Stuck-job recovery + +- `wp sa-core reclaim-stuck-jobs` runs via system cron every 5 minutes. +- Query: `state='running' AND running_heartbeat_at < now() - INTERVAL 5 MINUTE`. +- For each: + - If `attempt < max_attempts` AND `started_at > now() - 15min`: reclaim to `state='queued'`, clear `worker_claim`. + - Else: `state='failed'`, `error_kind='stuck'`. + +### Double-execution safety + +Mitigation already detailed in §5: all finalization writes are conditioned on `WHERE state='running' AND worker_claim=`. Second-arriving writer sees no matching row and exits silently. First-arriving result wins. + +Additional safeguard: response entity writes are keyed on a deterministic id derived from `(prompt_id, adapter_request_id)` when available, so duplicate writes collide on primary key and the second `INSERT` fails gracefully (caught by the finalization code). + +--- + +## 9. Risks and Tradeoffs + +### Risk: cron reliability on managed hosts + +WP Engine (and many managed hosts) run cron via `wp-cron.php` spawned on page requests. On low-traffic sites this is unreliable. + +**Mitigation.** Document that production deployments SHOULD use a real system cron hitting `wp sa-core run-worker --once`. The command is standalone (doesn't depend on wp-cron's spawning). An on-page-load spawn continues to work for dev and low-volume sites. + +### Risk: worker compute resource pressure + +A long-running worker process consumes PHP process slots. On shared hosting, this may conflict with web requests. + +**Mitigation.** Default worker mode is `--once` (cron-driven, not service-style). Batch size is tunable. For high-volume users, the operator can tune cron frequency + batch size to stay within their host's limits. + +### Risk: `sa_job` table growth + +Every prompt creates ≥ 1 job. Retries create additional jobs per prompt. Over time, the table grows unbounded. + +**Mitigation.** Optional archival policy (phase 5 or later): jobs in a terminal state older than 30 days get archived or removed. `wp sa-core archive-old-jobs --before=`. Indexes keep queries fast regardless of row count at expected volumes; archival is an optimization, not a correctness concern. + +### Risk: client polling overhead + +Many users × many tabs × many in-flight jobs = many simultaneous polls. + +**Mitigation.** +- Polls happen only while in-flight jobs exist; idle tabs don't poll. +- Poll interval is 2s; tunable. +- Phase 4 (SSE) eliminates this risk entirely for modern browsers. + +### Risk: stale merges on client + +If the client has an in-memory edit (user typing in compose) and a poll fetches fresh entity data, we must not discard user input. + +**Mitigation.** Poll merges by `prompt_id` and only updates entity-state fields (response_id, state, context_ids, etc.). Compose-input state lives in the DOM, untouched by poll handling. + +### Risk: concurrent-submit out-of-order display + +Two prompts submitted close together may complete in the reverse order (e.g., P1 is slow, P2 is fast). The field would render P2 before P1. + +**Mitigation.** Field display order is by `prompt.created_at`, not job completion order. Stable regardless of async resolution order. The visual `is-queued` vs `is-running` vs settled state makes the running state transparent to the user. + +### Tradeoff: consistency window vs latency + +Async means there is a window where client shows `state='queued'` while server has already transitioned to `state='running'`. Polling latency is up to 2s. SSE tightens this to sub-second. + +**Accepted.** Up to 2s of client-side staleness is not a meaningful UX regression; phase 4 closes it. + +### Tradeoff: simplicity vs ecosystem reuse + +Custom worker vs Action Scheduler. Custom is simpler for our narrow scope but means we own the scheduler. AS is richer but adds a dependency and adapter layer. + +**Accepted.** Custom for phase 2. If we ever need to coexist with an AS-using site, the `sa_job` table's facade can be driven by either our worker or an AS callback without changing job semantics. + +### Risk: migrating users mid-transition + +Between phase 1 (shadow jobs written) and phase 2 (async path switched on), a user's `sa_job` rows represent historical sync executions. When phase 2 lands, any prompt submitted mid-transition could have incomplete metadata. + +**Mitigation.** Phase 2 rollout during a low-traffic window. On deploy, any `sa_job` rows with `state='queued'` from pre-phase-2 (there shouldn't be any, but defensively) are either processed by the first worker run or marked `state='failed'` with `error_kind='migration'` for manual review. + +--- + +## 10. Success Criteria + +The async pillar is complete (through phase 2) when: + +1. `POST /prompt` returns within 100ms, 99th percentile, regardless of adapter latency. +2. Closing the browser tab during a prompt's execution does NOT cancel the job; reopening the site shows the completed result. +3. Two prompts submitted to different continuation parents from different tabs are processed correctly, with no race-induced data corruption. +4. A cURL timeout on the adapter causes an automatic retry with exponential backoff; a permanently-failing prompt surfaces as a retriable orphan after `max_attempts` exhaustion. +5. A crashed worker's job is reclaimed within 5 minutes and either completes on the next attempt or fails cleanly. +6. The CLI proofs described in §7's test discipline section all pass. +7. Existing `/mission` UI works throughout — no regressions on selection, trace, thread view, promote, verdict, continuation, retry, or content-collapse. +8. The `wp sa-core run-worker` command can be configured to run under standard system cron without supervisor-style infra. + +The pillar is **complete through phase 4** when additionally: + +9. SSE delivers state updates within 200ms of server-side transition. +10. Polling mode still works identically as a fallback when SSE is unavailable. + +--- + +## 11. Out of Scope (deliberately deferred) + +- **Multi-node distributed coordination.** Single-node assumption in claim locking. Revisit if horizontal scaling ever becomes a concern. +- **Priority inversion prevention.** A long-running low-priority job blocking urgent jobs is theoretically possible but rare in our expected volume. Observe first; fix if it ever occurs. +- **User-facing queue inspection UI.** CLI only through phase 5. A UI can be added later; `sa_job` data is already viewer-scoped via `org_id + created_by` indexing. +- **Scheduled prompts** (`scheduled_for`): phase 6 (optional). +- **Multi-prompt chains** (dependent jobs): phase 6 (optional). +- **Per-org quotas** (max N concurrent running jobs): defer until multi-tenant scaling matters. +- **Cross-process worker coordination** (multiple machines): defer until single-machine capacity is exhausted. +- **Real-time collaboration** (two users editing the same field simultaneously): defer to the ACL / perspective-projection pillar. + +--- + +## 12. Appendix: Summary of Design Decisions + +| Concern | Decision | Rationale | +|---|---|---| +| Storage | New `sa_job` table | Separate execution from knowledge; supports multiple attempts cleanly | +| Worker | Custom WP-CLI runner | Narrow scope; direct control; ~300 lines; no new dependency | +| Scheduling driver | System cron → `wp sa-core run-worker --once` | Reliable across hosts; standalone from wp-cron | +| State machine | 6 states + heartbeat + supersession | Covers all observed outcomes; no ambiguity | +| Client protocol | Polling (phase 2) → SSE (phase 4) | Ships working async in phase 2; optimizes in phase 4 | +| Retry | Exponential backoff, max_attempts=3 | Handles transient failures; bounded pessimism | +| Rollback | Phase-by-phase; each has a defined revert | Safe to implement incrementally | +| Migration | Shadow jobs first (phase 1) → switch path (phase 2) | Data layer proven before execution switch | + +This spec is the contract. Implementation directives reference it by phase. diff --git a/SA-orchestration MD/Archvie/sa-core/docs/specs/05-zoom-quantum-engine.md b/SA-orchestration MD/Archvie/sa-core/docs/specs/05-zoom-quantum-engine.md new file mode 100644 index 0000000000000000000000000000000000000000..42577622159efb0daf552c5f97ca3b102c70a7ca --- /dev/null +++ b/SA-orchestration MD/Archvie/sa-core/docs/specs/05-zoom-quantum-engine.md @@ -0,0 +1,373 @@ +# Zoom-Quantum Engine — Design Specification (Rung 4) + +Status: **design draft; coil substrate shipped (Rung 3); phase plan pending per-phase directive.** +Target rung: 4. +Scope: multi-focal zoom as a native field gesture, not a viewport transform. Per-correlation zoom, hyperbolic envelope, cross-region pull-connection. All projections remain 2D. + +--- + +## Framing (authoritative) + +**Zoom is a semantic quantum, not a viewport change.** Zoom belongs to a *domain of influence*, not to the screen. A conversation can be zoomed without touching any other conversation. The field carries regional density depth of zoom. + +Three layers of zoom, kept distinct: + +- **Global zoom** — the default, existing affordance (SVG viewBox). The whole field pans and scales. Useful for "where am I on the mission." +- **Regional zoom** — per-correlation, introduced by this pillar. Each thread carries its own scalar zoom state. The user can be deeply inside thread A while thread B stays at rest. Multiple threads can be simultaneously focused at independent depths. +- **Hyperbolic envelope** — when a regional zoom exceeds threshold, the thread's head wraps the visible workspace as a Poincaré-disk horizon. "You are inside this thread." The tail stretches toward the asymptotic center; the head becomes the event horizon. + +The instruction the rest of this spec serves: + +> The default case is to zoom the whole field. The innovation is to zoom a *conversation* — one region of influence — without perturbing the rest of the field. When two regions are simultaneously focused, the user can pull a connection between them instead of splitting the screen. Multi-focal composition is the posture; hyperbolic geometry is how we keep "where is my context" legible while compression grows infinite. + +--- + +## 0. Scope note + +This spec describes the **zoom-quantum engine** as a user-facing field behavior + its projection math. + +It DOES cover: +- The per-correlation zoom state model +- The math of the hyperbolic envelope (Möbius transform on a Poincaré disk) +- How regional zoom composes with the already-shipped log-spiral coil +- Multi-focal simultaneous zoom +- Cross-correlation pull-connection as a new edge primitive +- Gesture vocabulary +- Phasing + +It does NOT cover: +- The Prime-Prompt / compression mechanic (separate Pillar 0 concern, deferred) +- Server-side data model changes beyond a new edge kind (zoom is client UI state) +- A split-screen or multi-window layout (explicitly rejected — the hyperbolic projection replaces it) +- WebGL / 3D rendering (explicitly rejected — all projections are 2D) + +--- + +## 1. What already exists + +Rung 3 shipped the **logarithmic-spiral coil** (commit `de4de7e`). Each correlation group renders as: + +``` +r_t = r₀ · γ^t (log-spiral radius) +θ_t = baseAngle + t·dθ (angular step) +scale_t = γ^t (same decay → self-similar under zoom) +opacity_t = max(o_min, 1 − f·t) +``` + +The coil is already scale-invariant: a uniform scale factor `k` maps the spiral onto itself shifted by `log_γ(k)` turns. The infrastructure for "zoom descends into the tail" is in place on the math side; Rung 4 adds the UI, the per-correlation state, and the hyperbolic envelope. + +The `.is-coiled` class and `data-coil-index` attribute on each drop are hooks for this rung. + +--- + +## 2. Primitives + +### 2.1 Per-correlation zoom state + +``` +CoilZoomState { + correlation_id : string The thread this state belongs to + zoom : float (≥ 0) 0 = rest; 1 = first quantum in; 2 = second; ... + focal_x : float optional: where on the screen the focus anchors + focal_y : float (defaults to the current head position) + pinned : bool if true, survives other zooms of other threads; + if false, collapses to rest when another + thread is focused (single-focus convention) +} +``` + +Stored client-side. Not persisted by default; optional SA_Settings scope=user +persistence (`field.coil.zoom_state`) is a follow-on if the UX calls for it. + +### 2.2 Focal stack + +``` +FocalStack = CoilZoomState[] +``` + +The ordered list of currently-focused coils. Zero-or-one at rest is the default; multi-focal is the innovation. Rendering composes their transforms. + +### 2.3 Hyperbolic envelope + +Threshold: when `zoom ≥ HYPERBOLIC_THRESHOLD` (configurable; 2.0 is a reasonable starting point), the coil's head enters **envelope mode**: + +- The head dilates toward the workspace boundary. +- The rest of the coil (and any other visible content within this region) is rendered through a Möbius transform on the Poincaré disk. +- The edge of the viewport becomes the ideal boundary at infinity. + +Mathematically, with the focal point at origin `a = 0`: + +``` +w = (z - a) / (1 - ā·z) +``` + +For `a = 0` this reduces to `w = z`, so we introduce zoom via composition with a pure dilation before the Möbius map: + +``` +z' = scale_by_zoom · z +w = (z' - a) / (1 - ā·z') +``` + +At low zoom, the transform is near-identity. As zoom grows, points near the focal point expand outward; points near the ideal boundary asymptote there but never cross. This is the "event horizon" — infinite expansion curls into the hyperbolic limit. + +### 2.4 Pull-connection (cross-region edge) + +A new edge kind: + +``` +kind: 'pull-connection' (or 'cross-region') +source_id: entity A's id +target_id: entity B's id +intentional: true (always — user gesture-created) +metadata: { author_id, created_at, gesture_kind: 'drag' } +``` + +Stored as a row in the existing `sa_edge` table. No schema change — `SA_Edge::KINDS` already accepts arbitrary kinds (today: link, contains, depends, influences, flows-to, continues, promoted). + +Rendered as a flowing line between two focused regions, distinct visually from within-thread edges (dashed? colored by author? to be decided per phase). + +### 2.5 Compose operator + +``` +render(entity) = Global_Viewport ∘ Regional_Hyperbolic ∘ Local_Coil ∘ entity.position +``` + +Three projections stacked: + +1. **Local coil** (shipped): the log spiral places older correlation members relative to their head. +2. **Regional hyperbolic** (new): when a coil is past the threshold, apply the Möbius transform to everything within its region, centered on its head. +3. **Global viewport**: the SVG viewBox. Pans / scales the whole field for navigation. + +Each layer is independently controllable. A user at rest sees only the coils (layer 1 only). A user focused on one thread with hyperbolic crossed sees layer 1 + 2. A user zooming out to find a thread uses layer 3. + +--- + +## 3. Multi-focal model + +The posture: **multiple regions focused simultaneously, without split screens.** + +Scenarios: + +- **Single focus** (typical): one thread is zoomed in. Others sit at their rest coil. The user sees the coil-of-interest at its hyperbolic scale, other coils in the background at normal scale. +- **Dual focus** (workspace comparison): two threads both zoomed. Their Möbius disks compose by position — e.g., one occupies the left third of the viewport, the other the right third. Content *between* them is what survives the intersection. +- **Multi focus** (relational map): N threads focused at varied zoom levels. The field becomes a composite of hyperbolic neighborhoods, each with its own focal anchor. Resembles a relational map where the nodes are the focal points and the content around each is the locally-zoomed thread. + +Pull-connection is the natural gesture across this state: drag from a point inside focus A's hyperbolic neighborhood to a point inside focus B's hyperbolic neighborhood. The drag creates a cross-region edge. The edge renders as a line that passes through the ambient space between the foci. + +**Rejected alternative**: split-screen. Splitting the canvas into two viewports is the "click here, click there, zoom each, compare" solution. The spec rejects it as un-liquid — a literal cut in the fabric. The hyperbolic composition is the continuous alternative. + +--- + +## 4. Gesture vocabulary + +### 4.1 Entering regional zoom + +- **Scroll-wheel over a coil region**: the scroll targets the coil underneath the cursor, not the global viewport. Forward = zoom in, back = zoom out. +- Falls back to global viewport zoom when the cursor isn't over any coil's region. + +Implementation: hit-test the cursor against each visible coil's bounding disk. The coil whose center is closest (within a radius proportional to its current zoom) captures the scroll. + +### 4.2 Pin / unpin + +- **Click a coil's head while holding a modifier key** (e.g., Shift-click): pin this focus. Future zooms of other coils don't collapse this one. +- **Click in empty space**: collapse all transient (non-pinned) foci to rest. + +### 4.3 Pull-connection + +- **Drag from a focused region to another focused region** (both must be at zoom ≥ some threshold). The drag creates a new `pull-connection` edge. +- **Drop on empty space**: cancels. + +### 4.4 Exit / escape + +- **Esc** collapses all foci and returns to rest (coils at size 1, no hyperbolic envelope). +- **Double-click a coil**: toggles between rest and last-known zoom state. + +--- + +## 5. Math appendix + +### 5.1 Self-similar coil (shipped, Rung 3) + +For each correlation group: + +``` +t = turn index (0 = newest head) +baseAngle = hashJitter(correlation_id) · 2π (per-thread starting angle) +γ = 0.85 (decay) +dθ = π/3 (60° per turn) +r_t = r₀ · γ^t +θ_t = baseAngle + t · dθ +position_t = head_position + (r_t·cos θ_t − r₀·cos baseAngle, + r_t·sin θ_t − r₀·sin baseAngle) +scale_t = γ^t +``` + +Zoom shifts the visible portion of the spiral. Under a uniform zoom of factor `k`, `scale_t → k · scale_t = γ^(t - log_γ k)`, so the spiral looks the same shifted by `log_γ k` turns. This is why zooming into the coil reveals more tail without stretching it. + +### 5.2 Poincaré disk (Rung 4, new) + +The hyperbolic plane is represented as the interior of a unit disk `|z| < 1`. Geodesics are circular arcs perpendicular to the boundary. Distance grows without bound near the boundary (the ideal horizon). + +A Möbius transformation preserves hyperbolic distance: + +``` +φ_a(z) = (z − a) / (1 − ā·z) where |a| < 1 +``` + +For MissionNet: + +- The coil's currently-focused head sits at the disk center. +- The tail of the coil stretches toward the center (asymptotically never reaching, per the log-spiral math). +- Other visible content is projected outward toward the boundary, compressed as it approaches — "infinite expansion curls to hyperbolic limit." + +Scaling for zoom level `λ ≥ 0`: + +``` +z' = (1 − e^(−λ)) + e^(−λ) · z (pre-zoom dilation centered on head) +w = φ_focal(z') (Möbius, centered on head) +screen_w = half_viewport_size · w (map disk to viewport) +``` + +At λ = 0 the transform is identity. As λ grows, `z'` approaches 1 (the boundary), the head fills the viewport, and the rest of the content compresses toward the edge. + +### 5.3 Composition of multi-focal Möbius + +Two focal points `a` and `b`, each with zoom levels `λ_a` and `λ_b`, can be composed: + +``` +w = Σ weights_i · φ_i(dilate_i(z)) +``` + +For a weighted sum where `Σ weights_i = 1`. This is an approximation — the exact composition of two Möbius transforms is another Möbius transform, but picking *which* transform to use when you want BOTH foci visible requires a heuristic. The weighted-sum approximation gives a visually pleasing blend and is well-defined for any number of foci. + +A cleaner model: render each focused region in its own hyperbolic neighborhood, with the boundary between regions being the ambient (un-transformed) space. This is what pull-connection edges cross. + +--- + +## 6. Data model changes + +### 6.1 No schema change for zoom state + +Per-correlation zoom is client-side UI state. Not persisted by default. If a user asks for their zoom state to survive reload, add: + +- SA_Settings key `field.coil.zoom_states`, scope=user, type=string (JSON-serialized `CoilZoomState[]`). + +That's one line of setting declaration; no migration needed. + +### 6.2 Pull-connection edge: no schema change + +`SA_Edge` already accepts arbitrary kinds via a `kind` column. Add `'pull-connection'` to `SA_Edge::KINDS` as a documented kind. No migration; no new table. + +Permissions: creating a pull-connection requires `sa-core:impose-grouping` (already a capability in the starter package — user-declared relational claims are tenant work, not operator work). + +--- + +## 7. Phased implementation plan + +Phases ship independently; each is a working system. + +### Phase 0 — Already shipped (Rung 3 `de4de7e`) + +- Logarithmic-spiral coil for multi-turn correlations. +- Self-similar under zoom. +- Per-thread base angle. +- `.is-coiled` / `data-coil-index` DOM hooks. + +### Phase 1 — Per-correlation zoom state (single focus, no hyperbolic) + +Minimum usable regional zoom: + +- Client state: `Map`. +- Scroll-wheel over a coil zooms THAT coil (not the field). Hit-test via cursor position vs. each coil's bounding disk. +- Applying zoom = multiplying the coil's `r₀` and `scale_t` by a zoom factor. Math is already scale-invariant, so the spiral just reveals more structure as `r₀` grows. +- Other coils continue to render at rest. No hyperbolic envelope yet. +- Esc returns everything to rest. + +**Ships when:** the user can scroll over any thread, that thread alone expands / contracts, other threads are unchanged, and Esc collapses the focus. + +### Phase 2 — Hyperbolic envelope (single focus, past threshold) + +- When a coil's zoom crosses `HYPERBOLIC_THRESHOLD`, enter envelope mode. +- Render the focused coil's neighborhood through the Möbius transform (§5.2). +- Other coils in the field get pushed toward the viewport boundary as content crosses into the hyperbolic disk. +- Exit threshold hysteresis so crossing the threshold doesn't thrash. + +**Ships when:** the user can zoom a thread to where the head wraps the workspace, the tail visibly stretches toward the asymptotic center, and the "event horizon" reading is unambiguous. + +### Phase 3 — Multi-focal composition + +- Introduce the focal stack: multiple `CoilZoomState`s active simultaneously. +- Pin gesture (Shift-click on a head) keeps a focus alive when another is zoomed. +- Render pipeline composes multiple hyperbolic disks in the same viewport. +- Heuristic for disk placement (e.g., foci at equi-distant points around the viewport center, scaled by their respective zoom levels). + +**Ships when:** the user can pin thread A, focus thread B separately, and see both at their own zoom levels without a split screen. + +### Phase 4 — Pull-connection + +- Add `pull-connection` to `SA_Edge::KINDS`. +- Drag gesture from one focused region to another creates the edge (persisted via existing `SA_Edge::create`). +- Rendered as a distinct line between the two focal anchors, flowing through the ambient space between them. +- Pull-connections surface in the trace aside and in projection pipelines that list edges (e.g., a future "related threads" projection). + +**Ships when:** the user can physically drag a relation across focal regions, it persists, and it's visible from both threads' perspectives. + +### Deferred (out of scope) + +- **Interactive compression** (Pillar 0 / Prime Prompt) — the user can't yet summarize a coil at zoom; the coil renders what it holds. +- **Zoom-state persistence** — default is per-session. Persistent (across reloads / devices) is a follow-on SA_Settings slice. +- **Touch gestures** — pinch-zoom on a coil region. Desktop-first. +- **Pull-connection AI semantics** — a pull-connection is declarative today. Future rungs may let a pull-connection trigger an LLM rerun with both threads as context, or seed a geodesic marker. + +--- + +## 8. Open questions (for resolution at phase directive time) + +### 8.1 Global vs. regional zoom arbitration + +If the user scrolls while the cursor is over a coil AND also over a piece of ambient space (e.g., near the boundary of a coil), which wins? Proposal: cursor-anchored — nearest coil center wins within its bounding disk; outside that, ambient viewport wins. + +### 8.2 What happens when a single-turn correlation is focused? + +Single-turn correlations don't have a coil (Rung 3 skips them). Regional zoom on a single-turn could just scale the head in place. Or we could degrade it to global zoom. Proposal: degrade to global zoom for single-turn (no coil to reveal; zooming should feel like the viewport is responding). + +### 8.3 How deep is "deep"? + +The coil can, by construction, have infinitely many turns. The log-spiral decay means each turn is 15% smaller than the previous, so after 30 turns we're at 0.85^30 ≈ 0.8% of original size — visually indistinguishable from the center. Practically the coil caps at some maximum rendered turn. Proposal: render only turns where `scale_t ≥ some_epsilon` (say 0.05) unless the user has actively zoomed past that depth. Zooming past that depth reveals deeper turns. + +### 8.4 Pull-connection authorization model + +Not every user should be able to create cross-thread edges on content they didn't create. Current proposal: the creator of *either* thread can create a pull-connection. Operator bypass applies via `sa-core:impose-grouping`. Refine at phase 4 directive. + +### 8.5 Band vocabulary + +The canonical spec's "zoom band" vocabulary (bands 0-5) is orthogonal to this regional zoom: + +- Bands = semantic quanta of information (repo / folder / file / class / method / line for code; or workspace / thread / turn / sentence / token for conversation). +- Regional zoom = the user's viewport scalar within a domain. + +A single regional zoom doesn't have to correspond to a band crossing; bands apply primarily to the operating-memory lattice (Rung 3). For conversation coils, the "bands" are turns (each turn is one band deeper). This might converge later — for now, the two concepts are independent. + +--- + +## 9. What remains stable across all phases + +- **The coil math.** Rung 3's log-spiral doesn't change. Every phase builds on top of it. +- **All projections are 2D.** No WebGL, no actual 3D space. Möbius transforms and log spirals produce the illusion of depth on a flat viewport. +- **Client-side state.** Zoom is UX; it doesn't mutate the entity graph. The only server-side new thing across all phases is one new edge kind. +- **Head anchors at its base layout position** at zoom 0. The coil only exists below the head in the current scheme — expanding the head doesn't move it; it just reveals more of the coil that was already there. + +--- + +## 10. Summary of decisions + +- Zoom is **per-correlation by default, global as fallback**. +- Hyperbolic envelope via **Möbius transform on the Poincaré disk** when zoom exceeds threshold. +- Multi-focal composition via **weighted Möbius sum OR disjoint-neighborhood rendering** (tbd per phase 3). +- Cross-region linkage via a **new edge kind `pull-connection`**, stored in the existing `sa_edge` table. +- **No split screens.** The hyperbolic composition is the continuous alternative. +- **No WebGL / 3D.** All 2D projections. +- **No schema change** for any phase except the one-line KIND registration. +- **Phase 1 first**: scroll-wheel regional zoom, no hyperbolic. Ships independently. + +--- diff --git a/SA-orchestration MD/Archvie/sa-core/docs/specs/06-toolbox-action-contract.md b/SA-orchestration MD/Archvie/sa-core/docs/specs/06-toolbox-action-contract.md new file mode 100644 index 0000000000000000000000000000000000000000..a155504268540782d62787a6209d17b996c86c10 --- /dev/null +++ b/SA-orchestration MD/Archvie/sa-core/docs/specs/06-toolbox-action-contract.md @@ -0,0 +1,127 @@ +# Toolbox Action Contract (v0.1) + +*Implementation © Sleeper Agents LLC. Contract shape governed by the Admission Contract principle (see `01-ontology.md`).* + +A Toolbox action is a `/mission/`-native button whose click invokes a single REST endpoint and renders inline state-machine feedback. Toolbox actions are the surface through which operators trigger system-side work without leaving the field. + +This contract was promoted from *Informal* to *Formal* after the second concrete instance landed (GitHub Actions re-scan, alongside the original FS re-scan). Two examples were enough to extract the clauses below without speculation; further instances must conform. + +--- + +## Admission requirements + +A Toolbox action is admissible only if it satisfies all seven clauses. A button that does not satisfy them is not a Toolbox action — it is some other UI affordance and lives under a different contract. + +### 1. Semantic binding + +The button is bound to **one and only one** REST endpoint. Multi-action buttons are forbidden. If two related actions are needed (e.g., scan vs. discover), they require two buttons. The endpoint receives the action's parameters via JSON request body. + +```html +'; + echo ''; + } + + private static function render_create_form() { + $list_url = admin_url( 'admin.php?page=' . self::SUBMENU_SLUG ); + echo '

Create Actor

'; + echo '

Creation discipline: actors must be named with intent, have a defined role, and a scoped purpose. No "random AI assistant" — only purposeful, identity-bound roles.

'; + echo '
'; + wp_nonce_field( 'sa_actor_form', 'sa_actor_nonce' ); + echo ''; + + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo '
'; + + echo '

'; + echo '   '; + echo 'Cancel'; + echo '

'; + echo '
'; + } + + private static function render_detail() { + $list_url = admin_url( 'admin.php?page=' . self::SUBMENU_SLUG ); + $id = isset( $_GET['id'] ) ? (int) $_GET['id'] : 0; + $a = $id > 0 ? SA_Orch_Actor_Registry::get_actor( $id ) : null; + if ( ! $a ) { + echo '

Actor not found. Back to list

'; + return; + } + echo '

' . esc_html( $a['name'] ) . '

'; + echo '

← Back to list

'; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo '
Type ' . esc_html( $a['type'] ) . '
Status ' . ( $a['is_active'] ? 'active' : 'archived' ) . '
Owner ' . esc_html( $a['owner_login'] ?: '—' ) . ' (user_id=' . (int) $a['owner_user_id'] . ')
Persona
' . esc_html( $a['persona'] ) . '
Scope
' . esc_html( $a['scope'] ?: '—' ) . '
Subscription tier ' . esc_html( $a['subscription_tier'] ?: '— (reserved for future)' ) . '
Created ' . esc_html( $a['created_at'] ) . '
'; + + echo '

Activity trail

'; + echo '

Actor-attributed comments and drafts will appear here once the invocation surface is implemented (deferred to a future Board). For now: empty by design.

'; + } + + private static function inline_styles(): string { + return ''; + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-actor-handoff.php b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-actor-handoff.php new file mode 100644 index 0000000000000000000000000000000000000000..f137bc434cc5889a6e485f71999d4523ff767c04 --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-actor-handoff.php @@ -0,0 +1,134 @@ +prefix . SA_ORCH_DB_PREFIX . 'actor_handoff'; + } + + /** + * Record (or update) the committer for an actor-authored comment. + * Idempotent on (kind, comment_id) — re-recording replaces the prior row. + * + * Returns true on success, false on invalid input or DB failure. + */ + public static function record_committer( string $kind, int $comment_id, int $committed_by_user_id ): bool { + if ( $comment_id <= 0 || $committed_by_user_id <= 0 ) { + return false; + } + $kind = sanitize_key( $kind ); + if ( $kind === '' ) return false; + + global $wpdb; + $tbl = self::table(); + // Replace prior row, if any. Keeps the table thin. + $wpdb->delete( $tbl, [ + 'comment_kind' => $kind, + 'comment_id' => $comment_id, + ] ); + $ok = $wpdb->insert( $tbl, [ + 'comment_kind' => $kind, + 'comment_id' => $comment_id, + 'committed_by_user_id' => $committed_by_user_id, + 'recorded_at' => current_time( 'mysql', true ), + ] ); + return $ok !== false; + } + + /** + * Get the recorded committer for a comment, or null if none recorded. + */ + public static function get_committer( string $kind, int $comment_id ): ?int { + if ( $comment_id <= 0 ) return null; + $kind = sanitize_key( $kind ); + if ( $kind === '' ) return null; + + global $wpdb; + $row = $wpdb->get_var( $wpdb->prepare( + "SELECT committed_by_user_id FROM " . self::table() . " + WHERE comment_kind = %s AND comment_id = %d + ORDER BY id DESC LIMIT 1", + $kind, + $comment_id + ) ); + return $row !== null ? (int) $row : null; + } + + /** + * Detect operator mode for a comment. Returns one of the MODE_* constants. + * Never lies about uncertainty — returns 'unknown' rather than guessing. + */ + public static function detect_mode( string $kind, int $comment_id ): string { + $committer = self::get_committer( $kind, $comment_id ); + if ( $committer === null ) { + return self::MODE_UNKNOWN; + } + + /** + * Filter that signals whether an LLM token row exists for this commit. + * Returns: true (token row found → AI mode), + * false (no token row → human mode), + * null (unknown — token log not yet integrated, default). + * + * Board #16 will register a callback that consults the token log. + */ + $token_present = apply_filters( + 'sa_orch_handoff_has_llm_token', + null, + $kind, + $comment_id + ); + + if ( $token_present === null ) return self::MODE_UNKNOWN; + if ( $token_present === true ) return self::MODE_AI; + return self::MODE_HUMAN; + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-actor-registry.php b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-actor-registry.php new file mode 100644 index 0000000000000000000000000000000000000000..6d2099901c77b1d94a5312ba16a3c74d3e7f9fc5 --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-actor-registry.php @@ -0,0 +1,192 @@ + true, + ] ); + } + } + + /** + * List actors. Returns an array of WP_User objects. + * By default excludes archived actors; pass include_archived=true to see all. + */ + public static function list_actors( bool $include_archived = false ): array { + $args = [ + 'role' => self::ROLE, + 'orderby' => 'display_name', + 'order' => 'ASC', + 'number' => 200, + ]; + if ( ! $include_archived ) { + $args['meta_query'] = [ + 'relation' => 'OR', + [ 'key' => self::META_IS_ACTIVE, 'value' => '1', 'compare' => '=' ], + [ 'key' => self::META_IS_ACTIVE, 'compare' => 'NOT EXISTS' ], // legacy/unset → treat as active + ]; + } + $users = get_users( $args ); + return is_array( $users ) ? $users : []; + } + + /** + * Hydrate an actor's full record. Returns null if user does not exist + * or does not have the sa_actor role (defensive — never expose + * non-actor users through this surface). + */ + public static function get_actor( int $user_id ): ?array { + $user = get_userdata( $user_id ); + if ( ! $user || ! in_array( self::ROLE, (array) $user->roles, true ) ) { + return null; + } + $owner_id = (int) get_user_meta( $user_id, self::META_OWNER_USER_ID, true ); + $owner_user = $owner_id ? get_userdata( $owner_id ) : null; + $is_active_v = get_user_meta( $user_id, self::META_IS_ACTIVE, true ); + return [ + 'user_id' => (int) $user->ID, + 'name' => (string) $user->display_name, + 'login' => (string) $user->user_login, + 'email' => (string) $user->user_email, + 'type' => (string) get_user_meta( $user_id, self::META_TYPE, true ), + 'persona' => (string) get_user_meta( $user_id, self::META_PERSONA, true ), + 'scope' => (string) get_user_meta( $user_id, self::META_SCOPE, true ), + 'owner_user_id' => $owner_id, + 'owner_login' => $owner_user ? (string) $owner_user->user_login : '', + 'is_active' => ( $is_active_v === '' || $is_active_v === '1' ), + 'subscription_tier' => (string) get_user_meta( $user_id, self::META_SUBSCRIPTION_TIER, true ), + 'created_at' => (string) $user->user_registered, + ]; + } + + /** + * Create an actor. Required: name, type, persona (creation discipline — + * no nameless or roleless or purposeless actors). Owner defaults to + * current user. Email is synthetic if not provided (actors are not + * meant to log in). + * + * Returns the new user_id (int) or WP_Error on validation/creation failure. + */ + public static function create_actor( array $data ) { + $name = isset( $data['name'] ) ? trim( (string) $data['name'] ) : ''; + $type = isset( $data['type'] ) ? trim( (string) $data['type'] ) : ''; + $persona = isset( $data['persona'] ) ? trim( (string) $data['persona'] ) : ''; + $scope = isset( $data['scope'] ) ? trim( (string) $data['scope'] ) : ''; + $email = isset( $data['email'] ) ? trim( (string) $data['email'] ) : ''; + $owner = isset( $data['owner_user_id'] ) ? (int) $data['owner_user_id'] : (int) get_current_user_id(); + + if ( $name === '' ) return new WP_Error( 'actor_name_required', 'Actor name is required (intent — no random assistants).' ); + if ( $type === '' ) return new WP_Error( 'actor_type_required', 'Actor type is required (defined role).' ); + if ( $persona === '' ) return new WP_Error( 'actor_persona_required', 'Actor persona is required (scoped purpose).' ); + + // Synthesize a unique login from the name. WP requires unique user_login. + $login_base = sanitize_user( strtolower( str_replace( ' ', '-', $name ) ), true ); + if ( $login_base === '' ) $login_base = 'sa-actor'; + $login = $login_base; + $i = 1; + while ( username_exists( $login ) ) { + $login = $login_base . '-' . $i++; + if ( $i > 200 ) { + return new WP_Error( 'actor_login_exhausted', 'Could not generate unique login for this actor name.' ); + } + } + + // Synthesize an email if not provided. Actors live as WP users for + // identity but are not expected to log in or receive mail. + if ( $email === '' ) { + $email = $login . '@sleeperagents.local'; + } + if ( email_exists( $email ) ) { + $email = $login . '+' . wp_generate_password( 6, false ) . '@sleeperagents.local'; + } + + // Random password — actors are not meant to log in. We never expose it. + $password = wp_generate_password( 32, true, true ); + + $user_id = wp_insert_user( [ + 'user_login' => $login, + 'user_email' => $email, + 'user_pass' => $password, + 'display_name' => $name, + 'first_name' => $name, + 'role' => self::ROLE, + ] ); + if ( is_wp_error( $user_id ) ) { + return $user_id; + } + + update_user_meta( $user_id, self::META_TYPE, $type ); + update_user_meta( $user_id, self::META_PERSONA, $persona ); + update_user_meta( $user_id, self::META_SCOPE, $scope ); + update_user_meta( $user_id, self::META_OWNER_USER_ID, $owner ); + update_user_meta( $user_id, self::META_IS_ACTIVE, '1' ); + update_user_meta( $user_id, self::META_SUBSCRIPTION_TIER, '' ); + + /** + * Hook for downstream consumers (e.g. a future Asterion ledger writer + * that records canonical actor identity in markdown). Today nothing + * listens; the hook exists so the future write surface can attach + * without re-architecting this Board. + */ + do_action( 'sa_orch_actor_created', (int) $user_id, $data ); + + return (int) $user_id; + } + + /** Archive an actor (sets is_active=0). Returns true on success. */ + public static function archive_actor( int $user_id ): bool { + $actor = self::get_actor( $user_id ); + if ( ! $actor ) return false; + update_user_meta( $user_id, self::META_IS_ACTIVE, '0' ); + do_action( 'sa_orch_actor_archived', $user_id ); + return true; + } + + /** Unarchive an actor (sets is_active=1). */ + public static function unarchive_actor( int $user_id ): bool { + $actor = self::get_actor( $user_id ); + if ( ! $actor ) return false; + update_user_meta( $user_id, self::META_IS_ACTIVE, '1' ); + do_action( 'sa_orch_actor_unarchived', $user_id ); + return true; + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-adapter-fluent-boards.php b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-adapter-fluent-boards.php new file mode 100644 index 0000000000000000000000000000000000000000..bf13fd88674bf130b0cedb18901f5dd4777d4428 --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-adapter-fluent-boards.php @@ -0,0 +1,116 @@ +' + */ +class SA_Orch_Adapter_FluentBoards implements SA_Orch_Surface_Adapter { + + public function id(): string { + return 'fluent-boards'; + } + + public function validate( string $ref ): bool { + return $this->resolve( $ref ) !== null; + } + + public function resolve( string $ref ) { + global $wpdb; + if ( ! preg_match( '/^task#(\d+)$/', $ref, $m ) ) { + return null; + } + $row = $wpdb->get_row( $wpdb->prepare( + "SELECT * FROM {$wpdb->prefix}fbs_tasks WHERE id = %d", + (int) $m[1] + ) ); + return $row ?: null; + } + + public function label( string $ref ): string { + $row = $this->resolve( $ref ); + if ( ! $row ) { + return $ref; + } + // Title is the canonical label per current Fluent Boards schema. + // If schema changes (e.g. title becomes a sub-field, gets renamed, + // requires a join), update this method only. + if ( isset( $row->title ) && trim( (string) $row->title ) !== '' ) { + return (string) $row->title; + } + return $ref; + } + + /** + * Structured snapshot of the task — title, board name, stage, status, + * description summary, recent activity excerpts. All Fluent Boards schema + * knowledge stays bounded to this method; consumers receive a stable + * shape regardless of future schema changes. + */ + public function hydrate( string $ref ): ?array { + global $wpdb; + if ( ! preg_match( '/^task#(\d+)$/', $ref, $m ) ) { + return null; + } + $task_id = (int) $m[1]; + + $task = $wpdb->get_row( $wpdb->prepare( + "SELECT id, title, status, description, board_id, stage_id, created_at, updated_at + FROM {$wpdb->prefix}fbs_tasks WHERE id = %d", + $task_id + ) ); + if ( ! $task ) { + return null; + } + + $board_name = (string) $wpdb->get_var( $wpdb->prepare( + "SELECT title FROM {$wpdb->prefix}fbs_boards WHERE id = %d", + (int) $task->board_id + ) ); + $stage_name = (string) $wpdb->get_var( $wpdb->prepare( + "SELECT title FROM {$wpdb->prefix}fbs_board_terms WHERE id = %d", + (int) $task->stage_id + ) ); + + $recent = $wpdb->get_results( $wpdb->prepare( + "SELECT description, created_at FROM {$wpdb->prefix}fbs_comments + WHERE task_id = %d + ORDER BY created_at DESC, id DESC LIMIT 3", + $task_id + ) ); + $activity = []; + foreach ( (array) $recent as $row ) { + $text = trim( wp_strip_all_tags( (string) $row->description ) ); + if ( $text === '' ) continue; + $activity[] = [ + 'created_at' => (string) $row->created_at, + 'excerpt' => function_exists( 'mb_substr' ) + ? mb_substr( $text, 0, 240 ) + : substr( $text, 0, 240 ), + ]; + } + + $description = trim( wp_strip_all_tags( (string) $task->description ) ); + if ( function_exists( 'mb_substr' ) ) { + $description = mb_substr( $description, 0, 500 ); + } else { + $description = substr( $description, 0, 500 ); + } + + return [ + 'ref' => $ref, + 'title' => (string) $task->title, + 'board_name' => $board_name, + 'stage' => $stage_name, + 'status' => (string) $task->status, + 'description' => $description, + 'recent_activity' => $activity, + ]; + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-adapter-fluent-support.php b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-adapter-fluent-support.php new file mode 100644 index 0000000000000000000000000000000000000000..82c15b4a08510f3ae830dcb3ee2864b7f5ea4244 --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-adapter-fluent-support.php @@ -0,0 +1,214 @@ +' + */ +class SA_Orch_Adapter_FluentSupport implements SA_Orch_Surface_Adapter { + + public function id(): string { + return 'fluent-support'; + } + + public function validate( string $ref ): bool { + return $this->resolve( $ref ) !== null; + } + + public function resolve( string $ref ) { + global $wpdb; + if ( ! preg_match( '/^ticket#(\d+)$/', $ref, $m ) ) { + return null; + } + $row = $wpdb->get_row( $wpdb->prepare( + "SELECT * FROM {$wpdb->prefix}fs_tickets WHERE id = %d", + (int) $m[1] + ) ); + return $row ?: null; + } + + public function label( string $ref ): string { + $row = $this->resolve( $ref ); + if ( ! $row ) { + return $ref; + } + if ( isset( $row->title ) && trim( (string) $row->title ) !== '' ) { + return (string) $row->title; + } + return $ref; + } + + /** + * Structured snapshot of the ticket — title, status, priority, customer + * display name, description excerpt, and the latest visible conversation + * snippets (customer/agent dialogue only). All FluentSupport schema + * knowledge stays bounded to this method; consumers receive a stable + * shape regardless of future schema changes. + * + * Graceful fallback per field: + * - missing customer row → omit 'customer' + * - first_name + last_name both empty → fall back to email + * - email also empty → omit 'customer' + * - empty ticket content → omit 'description' + * - no visible conversations → 'recent_activity' is empty array + */ + public function hydrate( string $ref ): ?array { + global $wpdb; + if ( ! preg_match( '/^ticket#(\d+)$/', $ref, $m ) ) { + return null; + } + $ticket_id = (int) $m[1]; + + $ticket = $wpdb->get_row( $wpdb->prepare( + "SELECT id, customer_id, title, status, priority, content, created_at, updated_at + FROM {$wpdb->prefix}fs_tickets WHERE id = %d", + $ticket_id + ) ); + if ( ! $ticket ) { + return null; + } + + $out = [ + 'ref' => $ref, + 'title' => (string) $ticket->title, + 'status' => (string) $ticket->status, + ]; + + // Priority is meaningful for support tickets (normal, high, urgent...). + // Optional — only emit if non-empty. + if ( ! empty( $ticket->priority ) ) { + $out['priority'] = (string) $ticket->priority; + } + + // Customer — composed name from first_name + last_name; email fallback; + // omit entirely if neither is available. + $customer = $this->compose_customer_label( (int) $ticket->customer_id ); + if ( $customer !== '' ) { + $out['customer'] = $customer; + } + + // Description excerpt (HTML stripped, ~500 chars). + $description = trim( wp_strip_all_tags( (string) $ticket->content ) ); + if ( $description !== '' ) { + $out['description'] = function_exists( 'mb_substr' ) + ? mb_substr( $description, 0, 500 ) + : substr( $description, 0, 500 ); + } + + // Recent customer/agent dialogue — last 3 visible exchanges. + // Filter to conversation_type='response' so SA-Orchestration's own + // internal_info tracking rows do not bleed into Sara's context. + $recent = $wpdb->get_results( $wpdb->prepare( + "SELECT id, person_id, content, created_at + FROM {$wpdb->prefix}fs_conversations + WHERE ticket_id = %d AND conversation_type = 'response' + ORDER BY created_at DESC, id DESC LIMIT 3", + $ticket_id + ) ); + $activity = []; + foreach ( (array) $recent as $row ) { + $text = trim( wp_strip_all_tags( (string) $row->content ) ); + if ( $text === '' ) continue; + $excerpt = function_exists( 'mb_substr' ) + ? mb_substr( $text, 0, 240 ) + : substr( $text, 0, 240 ); + $by = $this->compose_person_label( (int) $row->person_id ); + $item = [ + 'created_at' => (string) $row->created_at, + 'excerpt' => $excerpt, + ]; + if ( $by !== '' ) { + $item['by'] = $by; + } + $activity[] = $item; + } + $out['recent_activity'] = $activity; + + return $out; + } + + /** + * Compose a display label for a customer person row. + * Returns empty string if the person row is missing or has no name/email. + * + * Order: "first last" → email → '' (caller should omit the field). + * Never reads $appends accessors like full_name — those exist only at + * the ORM layer. + */ + private function compose_customer_label( int $person_id ): string { + if ( $person_id <= 0 ) { + return ''; + } + global $wpdb; + $row = $wpdb->get_row( $wpdb->prepare( + "SELECT first_name, last_name, email + FROM {$wpdb->prefix}fs_persons + WHERE id = %d AND person_type = 'customer'", + $person_id + ) ); + if ( ! $row ) { + return ''; + } + return $this->compose_name_from_row( $row ); + } + + /** + * Compose a display label for any person (customer OR agent). + * Used for conversation authors — agents are people too, but they have + * person_type='agent' rather than 'customer', so this query is unscoped. + * Returns empty string if the person row is missing or has no name/email. + */ + private function compose_person_label( int $person_id ): string { + if ( $person_id <= 0 ) { + return ''; + } + global $wpdb; + $row = $wpdb->get_row( $wpdb->prepare( + "SELECT first_name, last_name, email + FROM {$wpdb->prefix}fs_persons + WHERE id = %d", + $person_id + ) ); + if ( ! $row ) { + return ''; + } + return $this->compose_name_from_row( $row ); + } + + /** + * Shared name composition: "first last" → email → ''. + * No access to ORM-only fields like full_name. + */ + private function compose_name_from_row( $row ): string { + $first = isset( $row->first_name ) ? trim( (string) $row->first_name ) : ''; + $last = isset( $row->last_name ) ? trim( (string) $row->last_name ) : ''; + $name = trim( $first . ' ' . $last ); + if ( $name !== '' ) { + return $name; + } + $email = isset( $row->email ) ? trim( (string) $row->email ) : ''; + return $email; + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-admin.php b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-admin.php new file mode 100644 index 0000000000000000000000000000000000000000..661d80e22f65d7ebf6f4bc28a2adf2e6827d9ce0 --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-admin.php @@ -0,0 +1,957 @@ +'; + echo '

Demand Trace

'; + echo '

Walk a Demand through arbitration → plan_links → acceptance chain → closure verdicts.

'; + + // Picker form (replaces v0.6.x free-form text inputs). + self::render_picker_form( $surface, $ref ); + + if ( $surface !== '' && $ref !== '' ) { + // Reuse the REST handler. Bypasses URI routing (avoids URL-encoding fragility around `#`). + $req = new WP_REST_Request( 'GET' ); + $req->set_url_params( [ 'surface' => $surface, 'ref' => $ref ] ); + $resp = SA_Orch_Rest::get_demand_trace( $req ); + $data = $resp->get_data(); + + self::render_trace_results( $data ); + } + + echo ''; + } + + /* ---------- Demand Trace picker (v0.6.2) ---------- */ + + /** + * Demand picker: + * - surface dropdown (closed enumeration of 4) + * - quick-select strip (SA-ORCH-TEST fixtures pinned + last-traced) + * - searchable browser of candidates for the selected surface + * - manual ref entry as fallback (validated against known per-surface patterns) + * + * No new schema; no new endpoints. All candidate data is rendered inline + * as JSON for client-side filtering. + */ + private static function render_picker_form( $surface, $ref ) { + $candidates = [ + 'fluent-support' => self::candidates_fluent_support(), + 'fluent-boards' => self::candidates_fluent_boards(), + 'fluentcrm' => self::candidates_fluentcrm(), + 'derived' => self::candidates_derived(), + ]; + $quick = self::quick_select_demands( 10 ); + + $surface_labels = [ + 'fluent-support' => 'fluent-support — Fluent Support tickets', + 'fluent-boards' => 'fluent-boards — Fluent Boards tasks', + 'fluentcrm' => 'fluentcrm — FluentCRM subscribers', + 'derived' => 'derived — rowless / inferred demands', + ]; + + // Per-surface validation regex (mirrors SA_Orch_Bridge_Fluent::validate / resolve). + $patterns = [ + 'fluent-support' => '^ticket#\\d+$', + 'fluent-boards' => '^task#\\d+$', + 'fluentcrm' => '^subscriber#\\d+$', + 'derived' => '^derived#[A-Za-z0-9_\\-]+$', + ]; + + echo '
'; + echo ''; + + // ---------- Quick-select ---------- + if ( ! empty( $quick ) ) { + echo '
'; + echo '

Quick-select

'; + echo '

SA-ORCH-TEST fixtures pinned, then the most recently traced Demands.

'; + echo '
'; + } + + // ---------- Surface + ref ---------- + echo ''; + + echo ''; + + echo ''; + + echo ''; + + echo '

'; + + // ---------- Browser ---------- + echo '
'; + echo '

Browse candidates

'; + echo ''; + echo '
Pick a surface to load candidates.
'; + echo '
    '; + echo '

    Click any row to fill the Demand ref field. Ref is also free-form for advanced use; format is validated when you type.

    '; + echo '
    '; + + // ---------- Inline JSON + JS ---------- + $payload = [ + 'candidates' => $candidates, + 'patterns' => $patterns, + ]; + echo ''; + echo ''; + + echo '
    '; + } + + /** + * Vanilla-JS controller for the picker. No jQuery dependency. + * Wires: surface change → repopulate browser; filter input → live filter; + * candidate click → fill ref input; ref input → pattern hint. + */ + private static function picker_inline_js() { + return <<<'JS' +(function () { + var data = window.SA_ORCH_PICKER || { candidates: {}, patterns: {} }; + var surfaceSel = document.getElementById('demand_surface_select'); + var refInput = document.getElementById('demand_ref_input'); + var filterInp = document.getElementById('sa-orch-filter'); + var listEl = document.getElementById('sa-orch-candidate-list'); + var helpEl = document.getElementById('sa-orch-candidate-help'); + var statusEl = document.getElementById('sa-orch-ref-status'); + if (!surfaceSel || !refInput || !listEl || !helpEl || !filterInp) return; + + function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + } + + function renderCandidates() { + var surface = surfaceSel.value; + var query = (filterInp.value || '').trim().toLowerCase(); + listEl.innerHTML = ''; + + if (!surface) { + helpEl.textContent = 'Pick a surface to load candidates.'; + filterInp.disabled = true; + return; + } + filterInp.disabled = false; + var pool = (data.candidates || {})[surface] || []; + if (!pool.length) { + helpEl.textContent = 'No candidates known for this surface.'; + return; + } + + var matches = pool; + if (query) { + matches = pool.filter(function (c) { + return c.ref.toLowerCase().indexOf(query) !== -1 + || (c.label || '').toLowerCase().indexOf(query) !== -1; + }); + } + + helpEl.textContent = matches.length + ' / ' + pool.length + ' candidate' + + (pool.length === 1 ? '' : 's') + + (query ? ' matching "' + query + '"' : ''); + + var frag = document.createDocumentFragment(); + matches.slice(0, 200).forEach(function (c) { + var li = document.createElement('li'); + li.style.padding = '5px 8px'; + li.style.cursor = 'pointer'; + li.style.borderBottom = '1px solid #f0f0f1'; + li.style.lineHeight = '1.4'; + var tag = c.is_test + ? 'TEST' + : ''; + li.innerHTML = tag + '' + escapeHtml(c.ref) + '' + + (c.label ? ' — ' + escapeHtml(c.label) : ''); + li.addEventListener('mouseover', function () { li.style.background = '#f6f7f7'; }); + li.addEventListener('mouseout', function () { if (!li.classList.contains('selected')) li.style.background = ''; }); + li.addEventListener('click', function () { + refInput.value = c.ref; + Array.prototype.forEach.call( + listEl.querySelectorAll('li.selected'), + function (e) { e.classList.remove('selected'); e.style.background = ''; } + ); + li.classList.add('selected'); + li.style.background = '#fff7c2'; + validateRef(); + refInput.focus(); + }); + frag.appendChild(li); + }); + listEl.appendChild(frag); + } + + function validateRef() { + var surface = surfaceSel.value; + var ref = refInput.value.trim(); + if (!surface || !ref) { + statusEl.textContent = ''; + return; + } + var pattern = (data.patterns || {})[surface]; + if (!pattern) { + statusEl.textContent = ''; + return; + } + if ((new RegExp(pattern)).test(ref)) { + statusEl.textContent = '✓ valid format'; + statusEl.style.color = '#0a7d24'; + } else { + statusEl.textContent = '✗ does not match expected pattern'; + statusEl.style.color = '#a02b1f'; + } + } + + surfaceSel.addEventListener('change', function () { + filterInp.value = ''; + renderCandidates(); + validateRef(); + }); + filterInp.addEventListener('input', renderCandidates); + refInput.addEventListener('input', validateRef); + + // Initial state (e.g. arrived via permalink with both fields preset). + renderCandidates(); + validateRef(); +})(); +JS; + } + + /* ---------- Candidate fetchers (per surface) ---------- */ + + /** Fluent Support tickets, SA-ORCH-TEST pinned first. Cap 50. */ + private static function candidates_fluent_support() { + global $wpdb; + $rows = $wpdb->get_results( + "SELECT id, title FROM {$wpdb->prefix}fs_tickets + ORDER BY (title LIKE '[SA-ORCH-TEST]%') DESC, id DESC + LIMIT 50" + ); + $out = []; + foreach ( (array) $rows as $r ) { + $title = (string) $r->title; + $out[] = [ + 'ref' => 'ticket#' . (int) $r->id, + 'label' => self::truncate( $title, 80 ), + 'is_test' => ( strpos( $title, '[SA-ORCH-TEST]' ) === 0 ), + ]; + } + return $out; + } + + /** Fluent Boards tasks, most-recent first. Cap 50. */ + private static function candidates_fluent_boards() { + global $wpdb; + // Defensive: Fluent Boards may not be installed. + $exists = $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $wpdb->prefix . 'fbs_tasks' ) ); + if ( ! $exists ) return []; + $rows = $wpdb->get_results( + "SELECT id, title FROM {$wpdb->prefix}fbs_tasks + ORDER BY id DESC LIMIT 50" + ); + $out = []; + foreach ( (array) $rows as $r ) { + $title = (string) ( $r->title ?? '' ); + $out[] = [ + 'ref' => 'task#' . (int) $r->id, + 'label' => self::truncate( $title, 80 ), + 'is_test' => ( strpos( $title, '[SA-ORCH-TEST]' ) === 0 ), + ]; + } + return $out; + } + + /** FluentCRM subscribers, most-recent first. Cap 50. Label = full_name|email. */ + private static function candidates_fluentcrm() { + global $wpdb; + $exists = $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $wpdb->prefix . 'fc_subscribers' ) ); + if ( ! $exists ) return []; + $rows = $wpdb->get_results( + "SELECT id, full_name, email FROM {$wpdb->prefix}fc_subscribers + ORDER BY id DESC LIMIT 50" + ); + $out = []; + foreach ( (array) $rows as $r ) { + $name = trim( (string) ( $r->full_name ?? '' ) ); + $email = (string) ( $r->email ?? '' ); + $label = $name !== '' ? ( $name . ( $email ? " <{$email}>" : '' ) ) : $email; + $out[] = [ + 'ref' => 'subscriber#' . (int) $r->id, + 'label' => self::truncate( $label, 80 ), + 'is_test' => false, + ]; + } + return $out; + } + + /** + * 'derived' surface has no underlying surface table — list every distinct + * derived demand_ref ever recorded in arbitrations or acceptance events. + */ + private static function candidates_derived() { + global $wpdb; + $arb = $wpdb->prefix . SA_ORCH_DB_PREFIX . 'arbitration'; + $evt = $wpdb->prefix . SA_ORCH_DB_PREFIX . 'acceptance_event'; + $rows = $wpdb->get_results( + "SELECT demand_ref, MAX(last_at) AS last_at FROM ( + SELECT demand_ref, MAX(created_at) AS last_at FROM {$arb} WHERE demand_surface='derived' GROUP BY demand_ref + UNION ALL + SELECT demand_ref, MAX(observed_at) AS last_at FROM {$evt} WHERE demand_surface='derived' GROUP BY demand_ref + ) sub + GROUP BY demand_ref + ORDER BY last_at DESC LIMIT 50" + ); + $out = []; + foreach ( (array) $rows as $r ) { + $out[] = [ + 'ref' => (string) $r->demand_ref, + 'label' => '(derived demand, no surface row)', + 'is_test' => false, + ]; + } + return $out; + } + + /** + * Quick-select list: SA-ORCH-TEST tickets pinned, then most-recently-traced + * demands across all surfaces (by max(arbitration.created_at, event.observed_at)). + */ + private static function quick_select_demands( $limit = 10 ) { + global $wpdb; + $arb = $wpdb->prefix . SA_ORCH_DB_PREFIX . 'arbitration'; + $evt = $wpdb->prefix . SA_ORCH_DB_PREFIX . 'acceptance_event'; + + $rows = $wpdb->get_results( + "SELECT demand_surface, demand_ref, MAX(last_at) AS last_at FROM ( + SELECT demand_surface, demand_ref, MAX(created_at) AS last_at FROM {$arb} GROUP BY demand_surface, demand_ref + UNION ALL + SELECT demand_surface, demand_ref, MAX(observed_at) AS last_at FROM {$evt} GROUP BY demand_surface, demand_ref + ) sub + GROUP BY demand_surface, demand_ref + ORDER BY last_at DESC LIMIT 30" + ); + if ( empty( $rows ) ) return []; + + // Resolve labels + is_test per row. + $out = []; + foreach ( $rows as $r ) { + $surface = (string) $r->demand_surface; + $ref = (string) $r->demand_ref; + list( $label, $is_test ) = self::resolve_label( $surface, $ref ); + $out[] = [ + 'surface' => $surface, + 'ref' => $ref, + 'label' => $label, + 'is_test' => $is_test, + 'last_at' => (string) $r->last_at, + ]; + } + + // Pin SA-ORCH-TEST first; preserve last_at ordering within each group. + usort( $out, function ( $a, $b ) { + if ( $a['is_test'] !== $b['is_test'] ) return $a['is_test'] ? -1 : 1; + return strcmp( $b['last_at'], $a['last_at'] ); + } ); + + return array_slice( $out, 0, (int) $limit ); + } + + /** Resolve a (surface, ref) to a human label + is_test flag. Best-effort, tolerant of missing tables. */ + private static function resolve_label( $surface, $ref ) { + global $wpdb; + + if ( $surface === 'fluent-support' && preg_match( '/^ticket#(\d+)$/', $ref, $m ) ) { + $title = (string) $wpdb->get_var( $wpdb->prepare( + "SELECT title FROM {$wpdb->prefix}fs_tickets WHERE id=%d", (int) $m[1] + ) ); + return [ self::truncate( $title, 80 ), strpos( $title, '[SA-ORCH-TEST]' ) === 0 ]; + } + if ( $surface === 'fluent-boards' && preg_match( '/^task#(\d+)$/', $ref, $m ) ) { + if ( $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $wpdb->prefix . 'fbs_tasks' ) ) ) { + $title = (string) $wpdb->get_var( $wpdb->prepare( + "SELECT title FROM {$wpdb->prefix}fbs_tasks WHERE id=%d", (int) $m[1] + ) ); + return [ self::truncate( $title, 80 ), strpos( $title, '[SA-ORCH-TEST]' ) === 0 ]; + } + } + if ( $surface === 'fluentcrm' && preg_match( '/^subscriber#(\d+)$/', $ref, $m ) ) { + if ( $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $wpdb->prefix . 'fc_subscribers' ) ) ) { + $row = $wpdb->get_row( $wpdb->prepare( + "SELECT full_name, email FROM {$wpdb->prefix}fc_subscribers WHERE id=%d", (int) $m[1] + ) ); + if ( $row ) { + $name = trim( (string) $row->full_name ); + $email = (string) $row->email; + $label = $name !== '' ? ( $name . ( $email ? " <{$email}>" : '' ) ) : $email; + return [ self::truncate( $label, 80 ), false ]; + } + } + } + if ( $surface === 'derived' ) { + return [ '(derived demand, no surface row)', false ]; + } + return [ '', false ]; + } + + private static function truncate( $s, $n ) { + $s = (string) $s; + if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) { + return mb_strlen( $s ) > $n ? ( mb_substr( $s, 0, $n - 1 ) . '…' ) : $s; + } + return strlen( $s ) > $n ? ( substr( $s, 0, $n - 1 ) . '…' ) : $s; + } + + private static function render_trace_results( array $data ) { + $arbs = $data['arbitrations'] ?? []; + $links = $data['plan_links'] ?? []; + $events = $data['acceptance'] ?? []; + + echo '

    Closure

    '; + echo ''; + echo ''; + echo ''; + $auth = $data['closure_authority_valid'] ?? null; + $auth_disp = $auth === null ? 'null' : ( $auth ? 'true' : 'false' ); + echo ''; + echo '
    closure_state_basic' . esc_html( (string) ( $data['closure_state_basic'] ?? '' ) ) . '
    closure_state_role_aware' . esc_html( (string) ( $data['closure_state_role_aware'] ?? '' ) ) . '
    closure_authority_valid' . esc_html( $auth_disp ) . '
    '; + + // === Deterministic Trace Audit (layer 2 — rules-based) === + self::render_reasoning_review( $data ); + + echo '

    Arbitrations (' . count( $arbs ) . ')

    '; + if ( empty( $arbs ) ) { + echo '

    No arbitrations recorded for this demand.

    '; + } else { + echo ''; + echo ''; + echo ''; + foreach ( $arbs as $a ) { + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + } + echo '
    IDHashProjection TypeTruth ClassActorCorrelationCreated (UTC)Asterion
    ' . (int) $a->id . '' . esc_html( substr( (string) $a->hash, 0, 12 ) ) . '…' . esc_html( (string) $a->projection_type ) . '' . esc_html( (string) $a->truth_class ) . '' . esc_html( (string) ( $a->actor_user_id ?? '—' ) ) . '' . esc_html( substr( (string) $a->correlation_id, 0, 8 ) ) . '…' . esc_html( (string) $a->created_at ) . '' . esc_html( (string) $a->asterion_path ) . '
    Rationale: ' . esc_html( (string) $a->rationale ) . '
    '; + } + + echo '

    Plan Links (' . count( $links ) . ')

    '; + if ( empty( $links ) ) { + echo '

    No plan links — this Demand has no Plan refs (consistent with scope-direct-no-plan or no-plan arbitration).

    '; + } else { + echo ''; + echo ''; + echo ''; + foreach ( $links as $l ) { + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + } + echo '
    IDArbitrationPlan SurfacePlan RefLink KindCreated (UTC)
    ' . (int) $l->id . '' . (int) $l->arbitration_id . '' . esc_html( (string) $l->plan_surface ) . '' . esc_html( (string) $l->plan_ref ) . '' . esc_html( (string) $l->link_kind ) . '' . esc_html( (string) $l->created_at ) . '
    '; + } + + echo '

    Acceptance Events (' . count( $events ) . ')

    '; + if ( empty( $events ) ) { + echo '

    No acceptance events.

    '; + } else { + echo ''; + echo ''; + echo ''; + foreach ( $events as $e ) { + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + if ( ! empty( $e->details ) ) { + echo ''; + } + } + echo '
    IDEvent TypeActor RoleActorTruth ClassPriorObserved (UTC)Asterion
    ' . (int) $e->id . '' . esc_html( (string) $e->event_type ) . '' . esc_html( (string) $e->actor_role ) . '' . esc_html( (string) ( $e->actor_user_id ?? '—' ) ) . '' . esc_html( (string) $e->truth_class ) . '' . esc_html( (string) ( $e->prior_event_id ?? '—' ) ) . '' . esc_html( (string) $e->observed_at ) . '' . esc_html( (string) $e->asterion_path ) . '
    Details: ' . esc_html( (string) $e->details ) . '
    '; + } + } + + /** + * Deterministic Trace Audit block. + * + * Local rules-based audit over the trace data. No API calls, no LLM, no + * persistence. Recomputed on every page load. + * + * This is the FLOOR of the review stack: + * 1. Trace data + * 2. Deterministic Trace Audit ← this section + * 3. Strategy LLM Review (future; will sit above this audit) + * 4. Human arbitration (final meaning; always wins) + */ + private static function render_reasoning_review( array $data ) { + $r = SA_Orch_Reasoning::evaluate( $data ); + + echo '

    Deterministic Trace Audit (rules-based, mode: ' . esc_html( (string) ( $r['mode'] ?? 'deterministic' ) ) . ')

    '; + echo '

    Local rules-based audit — repeatable, no API, no LLM. Computed on each page load. Floor of a four-layer stack: '; + echo '(1) Trace data(2) this Deterministic Trace Audit(3) Strategy LLM Review (future) → (4) Human arbitration (final).

    '; + + $color_map = [ 'high' => '#0a7d24', 'medium' => '#a76b00', 'low' => '#a02b1f', 'n/a' => '#666' ]; + $color = $color_map[ $r['closure_confidence'] ] ?? '#666'; + + echo ''; + + echo ''; + + echo ''; + + // Needs Human Arbitration — prominent row + echo ''; + + $render_list = function ( $label, $items ) { + echo ''; + }; + + $render_list( 'Inconsistencies', $r['inconsistencies'] ); + $render_list( 'Potential scope drift', $r['scope_drift'] ); + $render_list( 'Notes', $r['notes'] ); + + echo '
    Summary' . esc_html( $r['summary'] ) . '
    Closure confidence'; + echo '' . esc_html( strtoupper( (string) $r['closure_confidence'] ) ) . ''; + echo ' — ' . esc_html( $r['closure_confidence_explanation'] ); + echo '
    Needs Human Arbitration'; + if ( ! empty( $r['needs_human_arbitration'] ) ) { + echo '⚠ YES — ' . esc_html( (string) $r['human_arbitration_message'] ) . ''; + echo '

    Triggered by:

    '; + echo '
      '; + foreach ( (array) $r['human_arbitration_triggers'] as $t ) { + echo '
    • ' . esc_html( $t ) . '
    • '; + } + echo '
    '; + } else { + echo 'No — none of the deterministic escalation triggers fired.'; + } + echo '
    ' . esc_html( $label ) . ''; + if ( empty( $items ) ) { + echo '(none)'; + } else { + echo '
      '; + foreach ( $items as $i ) { + echo '
    • ' . esc_html( $i ) . '
    • '; + } + echo '
    '; + } + echo '
    '; + + echo '

    Layer 3 — Strategy LLM Review — sits below this section. The audit remains as a sanity-check / fallback layer underneath any LLM verdict.

    '; + + // --- Strategy Review (v0.5) --- + self::render_strategy_review( $data, $r ); + } + + /** + * Strategy LLM Review block (layer 3). + * + * Consumes the trace + the deterministic audit; delegates to the active + * provider via SA_Orch_Strategy::review(). Strategy Review is advisory: + * it cannot override closure_state or needs_human_arbitration. + */ + private static function render_strategy_review( array $data, array $deterministic_audit ) { + $strategy = SA_Orch_Strategy::review( $data, $deterministic_audit ); + + $provider_id = (string) ( $strategy['provider_id'] ?? 'simulated' ); + $provider_name = (string) ( $strategy['provider_name'] ?? 'Simulated (no API call)' ); + $configured_id = (string) ( $strategy['configured_provider_id'] ?? $provider_id ); + $configured_name = (string) ( $strategy['configured_provider_name'] ?? $provider_name ); + $fallback_used = ! empty( $strategy['fallback_used'] ); + $provider_err = $strategy['provider_error'] ?? null; + + $heading_suffix = $fallback_used + ? '(layer 3 — used: ' . esc_html( $provider_id ) . ', configured: ' . esc_html( $configured_id ) . ' — FALLBACK)' + : '(layer 3 — provider: ' . esc_html( $provider_id ) . ')'; + echo '

    Strategy Review (simulated LLM) ' . $heading_suffix . '

    '; + echo '

    Layer 3 of the review stack. Provider in use: ' . esc_html( $provider_name ) . '. '; + if ( $fallback_used ) { + echo 'Configured provider (' . esc_html( $configured_name ) . ') failed and was replaced by the simulated fallback. '; + } + echo 'Compares its interpretation against the deterministic audit. '; + echo 'Strategy Review cannot override closure_state or the needs_human_arbitration flag — those remain governed by the deterministic audit and ultimately by human arbitration.

    '; + + if ( $provider_err ) { + echo '

    Provider error (configured: ' . esc_html( $configured_id ) . '): ' . esc_html( $provider_err ) . ' — fell back to simulated.

    '; + } + + // Color hints + $color_map_conf = [ 'high' => '#0a7d24', 'medium' => '#a76b00', 'low' => '#a02b1f' ]; + $color_map_agree = [ 'true' => '#0a7d24', 'partial' => '#a76b00', 'false' => '#a02b1f' ]; + $strat_conf = (string) ( $strategy['confidence'] ?? 'low' ); + $strat_agree = (string) ( $strategy['agreement_with_audit'] ?? 'partial' ); + $strat_color = $color_map_conf[ $strat_conf ] ?? '#666'; + $agree_color = $color_map_agree[ $strat_agree ] ?? '#666'; + + echo ''; + + echo ''; + + echo ''; + + echo ''; + + echo ''; + + echo ''; + + echo '
    Summary' . esc_html( (string) ( $strategy['summary'] ?? '' ) ) . '
    Agreement with Deterministic Audit'; + echo '' . esc_html( strtoupper( $strat_agree ) ) . ''; + echo '
    Additional risks (not caught by audit)'; + $risks = (array) ( $strategy['additional_risks'] ?? [] ); + if ( empty( $risks ) ) { + echo '(none)'; + } else { + echo '
      '; + foreach ( $risks as $r ) { + echo '
    • ' . esc_html( (string) $r ) . '
    • '; + } + echo '
    '; + } + echo '
    Suggested next action' . esc_html( (string) ( $strategy['suggested_next_action'] ?? '' ) ) . '
    Confidence'; + echo '' . esc_html( strtoupper( $strat_conf ) ) . ''; + echo '
    '; + + // Visual side-by-side comparison + self::render_audit_strategy_comparison( $deterministic_audit, $strategy ); + } + + /** + * Side-by-side comparison of Deterministic Audit (layer 2) and Strategy Review (layer 3). + */ + private static function render_audit_strategy_comparison( array $audit, array $strategy ) { + $color_map_conf = [ 'high' => '#0a7d24', 'medium' => '#a76b00', 'low' => '#a02b1f', 'n/a' => '#666' ]; + + $audit_conf = (string) ( $audit['closure_confidence'] ?? 'n/a' ); + $strat_conf = (string) ( $strategy['confidence'] ?? 'low' ); + $audit_inc = count( (array) ( $audit['inconsistencies'] ?? [] ) ); + $audit_drift = count( (array) ( $audit['scope_drift'] ?? [] ) ); + $strat_extra = count( (array) ( $strategy['additional_risks'] ?? [] ) ); + $needs_hum = ! empty( $audit['needs_human_arbitration'] ); + + echo '

    Deterministic Audit ↔ Strategy Review (side-by-side)

    '; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + + $audit_conf_color = $color_map_conf[ $audit_conf ] ?? '#666'; + $strat_conf_color = $color_map_conf[ $strat_conf ] ?? '#666'; + + echo ''; + echo ''; + echo ''; + echo ''; + + echo ''; + echo ''; + echo ''; + echo ''; + + echo ''; + echo ''; + echo ''; + echo ''; + + echo ''; + echo ''; + echo ''; + echo ''; + + echo '
     Deterministic Audit (layer 2)Strategy Review (layer 3)
    Confidence' . esc_html( strtoupper( $audit_conf ) ) . '' . esc_html( strtoupper( $strat_conf ) ) . '
    Findings counted' . (int) $audit_inc . ' inconsistenc' . ( $audit_inc === 1 ? 'y' : 'ies' ) . ', ' . (int) $audit_drift . ' scope-drift indicator' . ( $audit_drift === 1 ? '' : 's' ) . '' . (int) $strat_extra . ' additional risk' . ( $strat_extra === 1 ? '' : 's' ) . '
    Closure authority' . ( $needs_hum ? 'flagged needs_human_arbitration = YES' : 'no escalation flag' ) . 'cannot override the audit
    Mode / characterrules-based, repeatableinterpretive, narrative (' . esc_html( (string) ( $strategy['provider_id'] ?? 'simulated' ) ) . ')
    '; + + echo '

    Reminder: closure_state_basic, closure_state_role_aware, closure_authority_valid, and needs_human_arbitration are governed by the deterministic audit (layer 2) and ultimately by human arbitration (layer 4). Strategy Review is advisory.

    '; + } + + /* ---------- Recent Arbitrations ---------- */ + + public static function render_arbitrations() { + if ( ! current_user_can( self::CAP ) ) wp_die( 'Insufficient permissions.' ); + global $wpdb; + $p = $wpdb->prefix . SA_ORCH_DB_PREFIX; + + $rows = $wpdb->get_results( + "SELECT id, demand_surface, demand_ref, projection_type, truth_class, actor_user_id, created_at + FROM {$p}arbitration + ORDER BY created_at DESC, id DESC LIMIT 20" + ); + + echo '
    '; + echo '

    Recent Arbitrations

    '; + echo '

    Last 20 arbitration records, newest first. Click Trace to walk the full chain.

    '; + + if ( empty( $rows ) ) { + echo '

    No arbitrations recorded yet.

    '; + } else { + echo ''; + echo ''; + echo ''; + foreach ( $rows as $r ) { + $trace_url = admin_url( 'admin.php?page=' . self::PARENT_SLUG + . '&demand_surface=' . urlencode( $r->demand_surface ) + . '&demand_ref=' . urlencode( $r->demand_ref ) ); + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + } + echo '
    IDDemandProjection TypeTruth ClassActorCreated (UTC)Action
    ' . (int) $r->id . '' . esc_html( $r->demand_surface . ' / ' . $r->demand_ref ) . '' . esc_html( (string) $r->projection_type ) . '' . esc_html( (string) $r->truth_class ) . '' . esc_html( (string) ( $r->actor_user_id ?? '—' ) ) . '' . esc_html( (string) $r->created_at ) . 'Trace
    '; + } + + echo '
    '; + } + + /* ---------- Language Model Providers (settings page) ---------- */ + + public static function render_llm_settings() { + if ( ! current_user_can( self::CAP ) ) wp_die( 'Insufficient permissions.' ); + + $message = ''; + if ( $_SERVER['REQUEST_METHOD'] === 'POST' ) { + check_admin_referer( 'sa_orch_llm_settings' ); + SA_Orch_LLM_Settings::save( wp_unslash( $_POST ) ); + $message = 'Settings saved.'; + } + + $settings = SA_Orch_LLM_Settings::get(); + $choices = SA_Orch_LLM_Providers::get_provider_choices(); + $token_label = SA_Orch_LLM_Settings::token_status_label(); + + echo '
    '; + echo '

    Language Model Providers

    '; + + if ( $message ) { + echo '

    ' . esc_html( $message ) . '

    '; + } + + echo '

    Configure the provider used by the Strategy Review (layer 3 of the review stack: trace data → deterministic audit → strategy review → human arbitration).

    '; + echo '

    Local-dev posture: secrets are stored in wp_options. Tokens are never displayed back — saving with a blank token field preserves the existing value. This storage layer is the attachment seam for a future SA-Core key vault; replacing it later will not change the consumer surface.

    '; + + echo '
    '; + wp_nonce_field( 'sa_orch_llm_settings' ); + + echo ''; + + echo ''; + + echo ''; + + echo ''; + + echo ''; + + echo ''; + + echo ''; + + echo '

    '; + echo '
    '; + + echo '
    '; + + echo '

    Status

    '; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo '
    Active provider (resolved)' . esc_html( SA_Orch_LLM_Providers::get_active_provider()->id() ) . '
    Settings.enabled' . ( ! empty( $settings['enabled'] ) ? 'true' : 'false' ) . '
    Settings.provider' . esc_html( (string) $settings['provider'] ) . '
    Settings.model' . esc_html( (string) $settings['model'] ?: '—' ) . '
    Settings.api_base_url' . esc_html( (string) $settings['api_base_url'] ?: '—' ) . '
    Settings.api_token' . ( SA_Orch_LLM_Settings::has_token() ? '***** (set)' : '(not set)' ) . '
    '; + + echo '

    v0.5 ships with the simulated provider only — no external API call is made by this plugin under any settings configuration. Adding real providers (OpenAI, Anthropic, local model endpoint, custom HTTP) is a matter of implementing SA_Orch_LLM_Provider_Interface and adding a case to SA_Orch_LLM_Providers::instantiate().

    '; + + echo '
    '; + } + + /* ---------- Acceptance Events ---------- */ + + public static function render_events() { + if ( ! current_user_can( self::CAP ) ) wp_die( 'Insufficient permissions.' ); + global $wpdb; + $p = $wpdb->prefix . SA_ORCH_DB_PREFIX; + + $rows = $wpdb->get_results( + "SELECT id, event_type, actor_role, demand_surface, demand_ref, observed_at, truth_class, actor_user_id + FROM {$p}acceptance_event + ORDER BY observed_at DESC, id DESC LIMIT 20" + ); + + echo '
    '; + echo '

    Acceptance Events

    '; + echo '

    Last 20 acceptance events, newest first. Append-only — these rows are never updated or deleted.

    '; + + if ( empty( $rows ) ) { + echo '

    No acceptance events recorded yet.

    '; + } else { + echo ''; + echo ''; + echo ''; + foreach ( $rows as $r ) { + $trace_url = admin_url( 'admin.php?page=' . self::PARENT_SLUG + . '&demand_surface=' . urlencode( $r->demand_surface ) + . '&demand_ref=' . urlencode( $r->demand_ref ) ); + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + } + echo '
    IDEvent TypeActor RoleActorDemandTruth ClassObserved (UTC)Action
    ' . (int) $r->id . '' . esc_html( (string) $r->event_type ) . '' . esc_html( (string) $r->actor_role ) . '' . esc_html( (string) ( $r->actor_user_id ?? '—' ) ) . '' . esc_html( $r->demand_surface . ' / ' . $r->demand_ref ) . '' . esc_html( (string) $r->truth_class ) . '' . esc_html( (string) $r->observed_at ) . 'Trace
    '; + } + + echo '
    '; + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-arbitration.php b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-arbitration.php new file mode 100644 index 0000000000000000000000000000000000000000..7d597e2082387b7ae1a08e0beb832055f9420536 --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-arbitration.php @@ -0,0 +1,174 @@ + $pr ) { + $ps = isset( $pr['surface'] ) ? trim( (string) $pr['surface'] ) : ''; + $prr = isset( $pr['ref'] ) ? trim( (string) $pr['ref'] ) : ''; + if ( $ps === '' || $prr === '' ) { + return new WP_Error( 'invalid_plan_ref', "plan_refs[$i] missing surface or ref" ); + } + if ( ! in_array( $ps, SA_Orch_Bridge_Fluent::known_surfaces(), true ) ) { + return new WP_Error( 'unknown_surface', "plan_refs[$i].surface '$ps' is not known" ); + } + if ( ! SA_Orch_Bridge_Fluent::validate( $ps, $prr ) ) { + return new WP_Error( 'invalid_plan_ref', "plan_refs[$i] does not resolve: $ps/$prr" ); + } + } + + $created_at = current_time( 'mysql', true ); + $hash = self::compute_hash( $demand_surface, $demand_ref, $created_at, $actor_user_id, $correlation_id ); + + // Asterion-first: ledger entry written before any DB row. + $frontmatter = [ + 'kind' => 'arbitration', + 'hash' => $hash, + 'demand_surface' => $demand_surface, + 'demand_ref' => $demand_ref, + 'projection_type' => $projection_type, + 'actor_user_id' => $actor_user_id, + 'correlation_id' => $correlation_id, + 'causation_id' => $causation_id, + 'truth_class' => $truth_class, + 'plan_refs' => array_map( function ( $pr ) { + return ( $pr['surface'] ?? '' ) . ':' . ( $pr['ref'] ?? '' ); + }, $plan_refs ), + 'created_at' => $created_at, + ]; + + $body = "## Rationale\n\n" . $rationale . "\n"; + + $rel_path = SA_Orch_Asterion::write( 'arbitration', $hash, $frontmatter, $body ); + if ( is_wp_error( $rel_path ) ) { + return $rel_path; + } + + // Now insert the runtime cache row. + $table = $wpdb->prefix . SA_ORCH_DB_PREFIX . 'arbitration'; + $ok = $wpdb->insert( $table, [ + 'hash' => $hash, + 'demand_surface' => $demand_surface, + 'demand_ref' => $demand_ref, + 'rationale' => $rationale, + 'projection_type' => $projection_type, + 'actor_user_id' => $actor_user_id, + 'correlation_id' => $correlation_id, + 'causation_id' => $causation_id, + 'truth_class' => $truth_class, + 'asterion_path' => $rel_path, + 'created_at' => $created_at, + ] ); + + if ( $ok === false ) { + return new WP_Error( 'db_insert_failed', $wpdb->last_error, [ 'asterion_path' => $rel_path ] ); + } + + $arbitration_id = (int) $wpdb->insert_id; + + $link_ids = []; + foreach ( $plan_refs as $pr ) { + $link_id = SA_Orch_Link::write( [ + 'arbitration_id' => $arbitration_id, + 'plan_surface' => $pr['surface'], + 'plan_ref' => $pr['ref'], + 'link_kind' => $pr['link_kind'] ?? 'derived', + ] ); + if ( is_wp_error( $link_id ) ) { + return $link_id; + } + $link_ids[] = $link_id; + } + + // v0.6.0: notify projection / side-effect listeners. Decoupled — listeners + // (e.g. SA_Orch_Projection_Fluent_Support) react via WordPress action hooks. + do_action( 'sa_orch_arbitration_written', [ + 'id' => $arbitration_id, + 'hash' => $hash, + 'demand_surface' => $demand_surface, + 'demand_ref' => $demand_ref, + 'projection_type' => $projection_type, + 'truth_class' => $truth_class, + 'plan_link_ids' => $link_ids, + 'correlation_id' => $correlation_id, + 'asterion_path' => $rel_path, + ] ); + + return [ + 'id' => $arbitration_id, + 'hash' => $hash, + 'asterion_path' => $rel_path, + 'plan_link_ids' => $link_ids, + 'correlation_id' => $correlation_id, + ]; + } + + private static function compute_hash( $demand_surface, $demand_ref, $created_at, $actor_user_id, $correlation_id ) { + $material = implode( '|', [ + $demand_surface, + $demand_ref, + $created_at, + (int) $actor_user_id, + $correlation_id, + ] ); + return substr( hash( 'sha256', $material ), 0, 32 ); + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-asterion-explorer.php b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-asterion-explorer.php new file mode 100644 index 0000000000000000000000000000000000000000..281b4dcbc3eef15fa94a38544948cc1eafc960c8 --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-asterion-explorer.php @@ -0,0 +1,263 @@ +'; + echo '

    Asterion read-only canonical truth surface

    '; + echo '

    Source: ' . esc_html( $root ) . '  ·  Repo is authoritative. No edits from WP.

    '; + + echo self::inline_styles(); + + echo '
    '; + + // ---- Tree ---- + echo ''; + + // ---- Content ---- + echo '
    '; + if ( $requested !== '' && $resolved === null ) { + echo '
    Invalid file path. The requested file is outside the spec root, has a non-.md extension, contains path traversal, or does not exist.
    '; + } elseif ( $resolved === null ) { + echo '
    Select a file from the tree to view its contents.
    '; + } else { + $rendered = self::render_markdown_file( $resolved['absolute'] ); + if ( $rendered === null ) { + echo '
    Read failed. The file resolved but could not be read.
    '; + } else { + echo '
    ' . esc_html( $resolved['relative'] ) . '
    '; + echo '
    ' . $rendered . '
    '; + } + } + echo '
    '; + + echo '
    '; // .sa-asterion-explorer + echo ''; // .wrap + } + + /** + * Build a nested array tree of the spec pack. + * Returns: [ 'dirs' => [ name => sub-tree ], 'files' => [ name => relative_path ] ] + */ + private static function build_tree( string $root ): array { + if ( ! is_dir( $root ) ) { + return []; + } + return self::walk( $root, '' ); + } + + private static function walk( string $abs, string $rel_prefix ): array { + $dirs = []; + $files = []; + $entries = @scandir( $abs ); + if ( $entries === false ) { + return [ 'dirs' => [], 'files' => [] ]; + } + foreach ( $entries as $entry ) { + if ( $entry === '.' || $entry === '..' ) continue; + $abs_path = $abs . '/' . $entry; + $rel_path = $rel_prefix === '' ? $entry : $rel_prefix . '/' . $entry; + if ( is_dir( $abs_path ) ) { + $dirs[ $entry ] = self::walk( $abs_path, $rel_path ); + } elseif ( is_file( $abs_path ) && substr( strtolower( $entry ), -3 ) === '.md' ) { + $files[ $entry ] = $rel_path; + } + } + ksort( $dirs ); + ksort( $files ); + return [ 'dirs' => $dirs, 'files' => $files ]; + } + + private static function render_tree_html( array $tree, string $current_dir_rel, string $selected ) { + echo '
      '; + foreach ( (array) ( $tree['dirs'] ?? [] ) as $name => $sub ) { + echo '
    • ' . esc_html( $name ) . '/'; + self::render_tree_html( $sub, ( $current_dir_rel === '' ? $name : $current_dir_rel . '/' . $name ), $selected ); + echo '
    • '; + } + foreach ( (array) ( $tree['files'] ?? [] ) as $name => $rel ) { + $url = add_query_arg( [ + 'page' => self::SUBMENU_SLUG, + 'file' => $rel, + ], admin_url( 'admin.php' ) ); + $is_sel = ( $rel === $selected ); + $cls = 'sa-asterion-file' . ( $is_sel ? ' sa-asterion-file-selected' : '' ); + echo '
    • ' . esc_html( $name ) . '
    • '; + } + echo '
    '; + } + + /** + * Validate a relative path against the spec root. Returns + * [ 'relative' => normalized, 'absolute' => realpath ] + * or null on any failure (path traversal, wrong extension, outside root, + * not a file). + * + * Public so other surfaces (Board #28 Sara file binding; + * Board #30 Asterion projection — both reuse) can validate without + * re-implementing the rules. + */ + public static function validate_relative_path( string $relative ): ?array { + // Normalize separators + $relative = str_replace( '\\', '/', $relative ); + + // Reject any obvious traversal or absolute paths + if ( strpos( $relative, '..' ) !== false ) return null; + if ( $relative === '' ) return null; + if ( $relative[0] === '/' ) return null; + if ( preg_match( '#^[A-Za-z]:#', $relative ) ) return null; + + // Whitelist .md extension only + if ( substr( strtolower( $relative ), -3 ) !== '.md' ) return null; + + $root_real = realpath( self::spec_root() ); + if ( $root_real === false ) return null; + $root_real = str_replace( '\\', '/', $root_real ); + + $candidate = realpath( self::spec_root() . '/' . $relative ); + if ( $candidate === false ) return null; + $candidate = str_replace( '\\', '/', $candidate ); + + // Final containment check: candidate must be inside root + if ( strpos( $candidate, $root_real . '/' ) !== 0 ) return null; + if ( ! is_file( $candidate ) ) return null; + + // Re-derive normalized relative from the realpath for display + $normalized_rel = ltrim( substr( $candidate, strlen( $root_real ) ), '/' ); + + return [ + 'relative' => $normalized_rel, + 'absolute' => $candidate, + ]; + } + + /** + * Read the file from disk and render via Parsedown in safe mode. + * Returns rendered HTML, or null on read failure. + */ + private static function render_markdown_file( string $absolute ): ?string { + $content = @file_get_contents( $absolute ); + if ( $content === false ) return null; + + if ( ! class_exists( 'Parsedown' ) ) { + $vendor = dirname( __DIR__ ) . '/vendor/Parsedown.php'; + if ( file_exists( $vendor ) ) { + require_once $vendor; + } + } + if ( ! class_exists( 'Parsedown' ) ) { + // Last-resort fallback: HTML-escape and render in
    . Honest if ugly.
    +            return '
    ' . esc_html( $content ) . '
    '; + } + + $pd = new Parsedown(); + if ( method_exists( $pd, 'setSafeMode' ) ) { + $pd->setSafeMode( true ); + } + return $pd->text( $content ); + } + + private static function inline_styles(): string { + // Scoped styles. Keeping it inline so the Board ships in one file. + return ''; + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-asterion-projection.php b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-asterion-projection.php new file mode 100644 index 0000000000000000000000000000000000000000..4ce719579e5ef89e983dcceb6081b91b04cce994 --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-asterion-projection.php @@ -0,0 +1,478 @@ + 'POST', + 'callback' => [ __CLASS__, 'rest_query' ], + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + 'args' => [ + 'query' => [ 'type' => 'string', 'required' => true ], + 'asterion_file' => [ 'type' => 'string', 'required' => false ], + ], + ] ); + } + + public static function maybe_enqueue( $hook ) { + if ( ! current_user_can( 'manage_options' ) ) return; + + $page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : ''; + + // Asterion bubble: any SA-Orchestration admin page (the Explorer is a subpage, + // but Asterion is also useful from Demand Trace / Acceptance Events). + if ( strpos( $page, 'sa-orchestration' ) !== 0 ) return; + + wp_enqueue_style( + self::HANDLE_CSS, + SA_ORCH_URL . 'assets/asterion-bubble.css', + [], + SA_ORCH_VERSION + ); + + wp_enqueue_script( + self::HANDLE_JS, + SA_ORCH_URL . 'assets/asterion-bubble.js', + [], + SA_ORCH_VERSION, + true + ); + + wp_localize_script( self::HANDLE_JS, 'SA_ORCH_ASTERION', [ + 'rest_url' => esc_url_raw( rest_url( 'sa-orch/v1/asterion/query' ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'page' => $page, + ] ); + } + + /* -------------------- REST handler -------------------- */ + + public static function rest_query( WP_REST_Request $request ) { + $query = (string) $request->get_param( 'query' ); + $asterion_file = sanitize_text_field( (string) $request->get_param( 'asterion_file' ) ); + + if ( trim( $query ) === '' ) { + return new WP_REST_Response( [ + 'error' => 'empty_query', + 'message' => 'query is required', + ], 400 ); + } + + $result = self::query( $query, $asterion_file ); + if ( is_wp_error( $result ) ) { + return new WP_REST_Response( [ + 'error' => $result->get_error_code(), + 'message' => $result->get_error_message(), + ], 502 ); + } + + return new WP_REST_Response( $result, 200 ); + } + + /* -------------------- Core query -------------------- */ + + /** + * Run a corpus query and return a structured Asterion response. + * + * Response shape: + * { + * answer: string, // citation-grounded answer or "no canonical reference found" + * citations: array, // [{ file, excerpt }, ...] + * scope: string, // "single_file:" | "spec_corpus" | "no_corpus" + * usage: array|null, + * log_id: int|null + * } + */ + public static function query( string $query, string $asterion_file = '' ) { + if ( ! class_exists( 'SA_Orch_Asterion_Explorer' ) ) { + return new WP_Error( 'no_corpus', 'Asterion Explorer not loaded.' ); + } + + $root = SA_Orch_Asterion_Explorer::spec_root(); + if ( ! is_dir( $root ) ) { + return [ + 'answer' => 'no canonical reference found in the spec corpus (corpus root unreachable)', + 'citations' => [], + 'scope' => 'no_corpus', + 'usage' => null, + 'log_id' => null, + ]; + } + + // Determine search scope. + $scope = 'spec_corpus'; + $bound_md = null; + if ( $asterion_file !== '' ) { + $resolved = SA_Orch_Asterion_Explorer::validate_relative_path( $asterion_file ); + if ( is_array( $resolved ) ) { + $bound_md = $resolved; + $scope = 'single_file:' . $resolved['relative']; + } + } + + // Retrieve excerpts. + $excerpts = $bound_md + ? self::search_single_file( $bound_md['absolute'], $bound_md['relative'], $query ) + : self::search_corpus( $root, $query ); + + // No matches → refuse to speculate. + if ( empty( $excerpts ) ) { + return [ + 'answer' => 'no canonical reference found in the spec corpus', + 'citations' => [], + 'scope' => $scope, + 'usage' => null, + 'log_id' => null, + ]; + } + + // Frame with LLM (Model B). If LLM is disabled or fails, fall through + // to raw-excerpt mode so the surface still works. + $framed = self::frame_with_llm( $query, $excerpts, $scope ); + if ( is_array( $framed ) ) { + return $framed + [ + 'citations' => $excerpts, + 'scope' => $scope, + ]; + } + + // Fallback: raw excerpts as the "answer" when LLM unavailable. + $answer_lines = [ 'Citation-only mode (LLM unavailable). Raw excerpts:' ]; + foreach ( $excerpts as $i => $ex ) { + $answer_lines[] = sprintf( '[%d] %s', $i + 1, $ex['file'] ); + } + return [ + 'answer' => implode( "\n", $answer_lines ), + 'citations' => $excerpts, + 'scope' => $scope, + 'usage' => null, + 'log_id' => null, + ]; + } + + /* -------------------- Search -------------------- */ + + /** + * Search the entire spec corpus for terms in $query. + * Returns up to TOP_K excerpts ranked by hit score. + */ + private static function search_corpus( string $root, string $query ): array { + $terms = self::extract_terms( $query ); + if ( empty( $terms ) ) return []; + + $hits = []; + self::walk_corpus( $root, '', function ( $abs, $rel ) use ( $terms, &$hits ) { + $content = @file_get_contents( $abs ); + if ( $content === false ) return; + $score = 0; + foreach ( $terms as $t ) { + $score += substr_count( strtolower( $content ), $t ); + } + if ( $score > 0 ) { + $hits[] = [ + 'file' => $rel, + 'score' => $score, + 'excerpt' => self::best_excerpt( $content, $terms ), + ]; + } + } ); + + usort( $hits, function ( $a, $b ) { return $b['score'] - $a['score']; } ); + $hits = array_slice( $hits, 0, self::TOP_K ); + + $out = []; + foreach ( $hits as $h ) { + $out[] = [ 'file' => $h['file'], 'excerpt' => $h['excerpt'] ]; + } + return $out; + } + + /** + * Search a single bound MD file. + */ + private static function search_single_file( string $abs, string $rel, string $query ): array { + $content = @file_get_contents( $abs ); + if ( $content === false ) return []; + + $terms = self::extract_terms( $query ); + if ( empty( $terms ) ) return []; + + $score = 0; + foreach ( $terms as $t ) { + $score += substr_count( strtolower( $content ), $t ); + } + if ( $score === 0 ) return []; + + return [ [ + 'file' => $rel, + 'excerpt' => self::best_excerpt( $content, $terms ), + ] ]; + } + + private static function walk_corpus( string $abs, string $rel_prefix, callable $cb ) { + $entries = @scandir( $abs ); + if ( $entries === false ) return; + foreach ( $entries as $e ) { + if ( $e === '.' || $e === '..' ) continue; + $a = $abs . '/' . $e; + $r = $rel_prefix === '' ? $e : $rel_prefix . '/' . $e; + if ( is_dir( $a ) ) { + self::walk_corpus( $a, $r, $cb ); + } elseif ( is_file( $a ) && substr( strtolower( $e ), -3 ) === '.md' ) { + $cb( $a, $r ); + } + } + } + + /** + * Extract lowercased search terms from the query. + * Strips stopwords/punctuation; minimum 3-char tokens. + */ + private static function extract_terms( string $query ): array { + $q = strtolower( $query ); + $q = preg_replace( '/[^a-z0-9_\-\s]/', ' ', $q ); + $tokens = preg_split( '/\s+/', $q, -1, PREG_SPLIT_NO_EMPTY ); + $stop = [ 'the','and','but','for','are','was','were','have','has','had','will', + 'with','this','that','from','what','which','about','they','them', + 'their','our','your','his','her','its','some','any','all','can', + 'how','why','when','where','who','whom','does','did','done','not', + 'into','onto','off','out','only','also','each','more','less','very', + 'such','than','then','than','these','those','say','says','said' ]; + $out = []; + foreach ( $tokens as $t ) { + if ( strlen( $t ) < 3 ) continue; + if ( in_array( $t, $stop, true ) ) continue; + $out[ $t ] = true; + } + return array_keys( $out ); + } + + /** + * Find the best excerpt window in $content given $terms. + * + * Strategy: pick the position with the highest local term-density across a + * sliding window — not just the first match. File headers and credit blurbs + * often contain isolated keyword hits; the actual definition is further down. + * Windowing on density beats first-match for citation quality. + */ + private static function best_excerpt( string $content, array $terms ): string { + $lower = strtolower( $content ); + $len = strlen( $lower ); + if ( $len === 0 ) return ''; + + // Collect all match positions for any term. + $positions = []; + foreach ( $terms as $t ) { + $off = 0; + while ( ( $p = strpos( $lower, $t, $off ) ) !== false ) { + $positions[] = $p; + $off = $p + max( 1, strlen( $t ) ); + } + } + if ( empty( $positions ) ) { + // No match — fall back to first chars + return function_exists( 'mb_substr' ) + ? mb_substr( $content, 0, self::EXCERPT_CHARS ) + : substr( $content, 0, self::EXCERPT_CHARS ); + } + sort( $positions ); + + // Slide a window across positions; pick the position whose window + // contains the most match positions (density). + $window = self::EXCERPT_CHARS; + $best_pos = $positions[0]; + $best_count = 0; + foreach ( $positions as $p ) { + $count = 0; + foreach ( $positions as $q ) { + if ( $q >= $p && $q < $p + $window ) $count++; + if ( $q >= $p + $window ) break; + } + if ( $count > $best_count ) { + $best_count = $count; + $best_pos = $p; + } + } + + // Center window on best_pos (back off slightly so match isn't at edge) + $half = (int) ( $window / 4 ); // back off 25%, not 50% — anchor near top of window + $start = max( 0, $best_pos - $half ); + $exc = function_exists( 'mb_substr' ) + ? mb_substr( $content, $start, $window ) + : substr( $content, $start, $window ); + if ( $start > 0 ) $exc = '… ' . $exc; + if ( $start + $window < strlen( $content ) ) $exc .= ' …'; + return $exc; + } + + /* -------------------- LLM framing -------------------- */ + + /** + * Frame the excerpts as a librarian-tone, citation-grounded answer. + * Returns array { answer, usage, log_id } on success, null on failure. + */ + private static function frame_with_llm( string $query, array $excerpts, string $scope ) { + if ( ! class_exists( 'SA_Orch_LLM_Settings' ) ) return null; + $settings = SA_Orch_LLM_Settings::get(); + if ( empty( $settings['enabled'] ) || empty( $settings['api_token'] ) + || empty( $settings['api_base_url'] ) || empty( $settings['model'] ) ) { + return null; + } + + $url = rtrim( (string) $settings['api_base_url'], '/' ) . '/chat/completions'; + + $excerpt_block = "Excerpts retrieved from the spec corpus (verbatim; do not invent beyond these):\n\n"; + foreach ( $excerpts as $i => $ex ) { + $n = $i + 1; + $excerpt_block .= "[CITATION {$n}] file: {$ex['file']}\n"; + $excerpt_block .= "----\n"; + $excerpt_block .= $ex['excerpt'] . "\n"; + $excerpt_block .= "----\n\n"; + } + + $messages = [ + [ 'role' => 'system', 'content' => self::system_prompt() ], + [ 'role' => 'user', 'content' => $excerpt_block . "User question:\n\n" . $query ], + ]; + + $payload = [ + 'model' => (string) $settings['model'], + 'messages' => $messages, + 'response_format' => [ 'type' => 'json_object' ], + 'temperature' => 0.1, + ]; + + $response = wp_remote_post( $url, [ + 'headers' => [ + 'Authorization' => 'Bearer ' . (string) $settings['api_token'], + 'Content-Type' => 'application/json', + ], + 'body' => wp_json_encode( $payload ), + 'timeout' => self::REQUEST_TIMEOUT, + ] ); + + if ( is_wp_error( $response ) ) return null; + $code = (int) wp_remote_retrieve_response_code( $response ); + if ( $code < 200 || $code >= 300 ) return null; + + $body = (string) wp_remote_retrieve_body( $response ); + $envelope = json_decode( $body, true ); + if ( ! is_array( $envelope ) || ! isset( $envelope['choices'][0]['message']['content'] ) ) return null; + + $content = (string) $envelope['choices'][0]['message']['content']; + $parsed = json_decode( $content, true ); + if ( ! is_array( $parsed ) ) return null; + + $answer = isset( $parsed['answer'] ) ? (string) $parsed['answer'] : ''; + if ( $answer === '' ) { + $answer = 'no canonical reference found in the spec corpus'; + } + + // Token log + $usage = isset( $envelope['usage'] ) && is_array( $envelope['usage'] ) ? $envelope['usage'] : null; + $log_id = null; + if ( $usage !== null && class_exists( 'SA_Orch_Token_Log' ) ) { + $log_id = SA_Orch_Token_Log::record( + 'asterion', + (string) $settings['model'], + $usage, + (int) get_current_user_id() + ); + $log_id = $log_id > 0 ? $log_id : null; + } + + return [ + 'answer' => $answer, + 'usage' => $usage, + 'log_id' => $log_id, + ]; + } + + /** + * Asterion's system prompt — librarian role. + * Structurally distinct from Sara's prompt: + * - no interpretation tone, no synthesis, no advice + * - citation-grounded, refusal-to-speculate, file-named + * - returns { answer: string } JSON; no summary/suggestion/draft/notes + */ + public static function system_prompt(): string { + return "ASTERION — canonical librarian for the SA-Core spec corpus.\n" + . "\n" + . "You are NOT Sara. You are NOT an interpreter. You are NOT an advisor.\n" + . "You are the verifier: a strict, citation-grounded surface over\n" + . "Archvie/sa-core/docs/specs/. Your job is to ground every claim in\n" + . "specific source excerpts that have been provided to you, and to\n" + . "refuse if the answer is not present in those excerpts.\n" + . "\n" + . "TONE\n" + . " - Direct. Citation-led. No interpretive framing. No synthesis tone.\n" + . " - No \"I think\", no \"likely\", no \"probably\", no advisory voice.\n" + . " - Reference the corpus by file name + section. Use phrases like\n" + . " \"per 02-invariants.md, I2 ...\" — name the file and the labeled\n" + . " element.\n" + . "\n" + . "OUTPUT (strict JSON; no other keys)\n" + . " { \"answer\": string }\n" + . "\n" + . "RULES\n" + . " - If the excerpts contain the answer: state it directly and cite the\n" + . " file(s) and labeled elements (I1, I2, R-O-1, named sections). Quote\n" + . " short phrases verbatim where it adds verifiability.\n" + . " - If the excerpts do NOT contain the answer (the question is off-topic,\n" + . " or the corpus is silent): set answer to exactly:\n" + . " \"no canonical reference found in the spec corpus\"\n" + . " Do not speculate. Do not extrapolate. Do not invent.\n" + . " - You may NOT introduce concepts not present in the excerpts.\n" + . " - You may NOT advise the user on what to do.\n" + . " - You may NOT use Sara's structured 4-key shape. Only { \"answer\" }.\n" + . "\n" + . "Sara interprets. You verify. The Human arbitrates.\n"; + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-asterion.php b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-asterion.php new file mode 100644 index 0000000000000000000000000000000000000000..78ef97796d190fa364fef11a759ac1ce89708290 --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-asterion.php @@ -0,0 +1,82 @@ +.md") or WP_Error. + */ + public static function write( $kind, $hash, array $frontmatter, $body ) { + $root = self::root(); + if ( ! is_dir( $root ) && ! wp_mkdir_p( $root ) ) { + return new WP_Error( 'asterion_mkdir_failed', "Could not create root: $root" ); + } + + $kind_safe = sanitize_file_name( $kind ); + $hash_safe = sanitize_file_name( $hash ); + + $kind_dir = $root . '/' . $kind_safe; + if ( ! is_dir( $kind_dir ) && ! wp_mkdir_p( $kind_dir ) ) { + return new WP_Error( 'asterion_mkdir_failed', "Could not create kind dir: $kind_dir" ); + } + + $rel = $kind_safe . '/' . $hash_safe . '.md'; + $abs = $root . '/' . $rel; + + if ( file_exists( $abs ) ) { + return new WP_Error( 'asterion_collision', "Ledger entry already exists: $rel" ); + } + + $contents = "---\n" . self::build_frontmatter( $frontmatter ) . "---\n\n" . $body . "\n"; + + $written = @file_put_contents( $abs, $contents, LOCK_EX ); + if ( $written === false ) { + return new WP_Error( 'asterion_write_failed', "Could not write $abs" ); + } + + return $rel; + } + + private static function build_frontmatter( array $fm ) { + $out = ''; + foreach ( $fm as $k => $v ) { + if ( is_array( $v ) ) { + if ( empty( $v ) ) { + $out .= "{$k}: []\n"; + } else { + $out .= "{$k}:\n"; + foreach ( $v as $item ) { + $out .= " - " . self::yaml_scalar( $item ) . "\n"; + } + } + } else { + $out .= "{$k}: " . self::yaml_scalar( $v ) . "\n"; + } + } + return $out; + } + + private static function yaml_scalar( $v ) { + if ( $v === null ) return '~'; + if ( is_bool( $v ) ) return $v ? 'true' : 'false'; + if ( is_int( $v ) ) return (string) $v; + if ( is_float( $v ) ) return (string) $v; + + $s = (string) $v; + // Quote strings that contain YAML-special chars or whitespace edges + if ( $s === '' || preg_match( '/[:#\-\?&\*\!\|>\'"%@`\{\}\[\],\n]/', $s ) || $s !== trim( $s ) ) { + return '"' . str_replace( [ '\\', '"' ], [ '\\\\', '\\"' ], $s ) . '"'; + } + return $s; + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-attention-bridge.php b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-attention-bridge.php new file mode 100644 index 0000000000000000000000000000000000000000..86b1333f3e4e0bab88f6c17d439f01df7182ef85 --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-attention-bridge.php @@ -0,0 +1,399 @@ + 'POST', + 'callback' => [ __CLASS__, 'emit' ], + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + 'args' => [ + 'surface' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ], + 'ref' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ], + 'page' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ], + // 'mode' selects which target the dev verification flow exercises. + // Currently accepted: 'default' (the configured / scaffold-failure target) + // and 'success_mock' (routes to the dev-receive endpoint, dev-only). + // In non-dev environments, 'success_mock' silently falls back to default. + 'mode' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_key' ], + ], + ] ); + + // Dev-only mock receiver. Registered ONLY when the environment is + // explicitly local or development. In production this route does not + // exist — the registration itself is gated, not just the handler. + if ( class_exists( 'SA_Orch_Env' ) && SA_Orch_Env::is_local_or_dev() ) { + register_rest_route( 'sa-orch/v1', '/attention/dev-receive', [ + 'methods' => 'POST', + 'callback' => [ __CLASS__, 'dev_receive' ], + // No capability check — this is a dev mock for self-call testing. + // The whole route is only registered in dev environments. + 'permission_callback' => '__return_true', + ] ); + } + } + + /** + * Dev-only mock receiver. Returns HTTP 200 with a confirmation body so + * the success path can be exercised end-to-end without a real oversight + * site standing up. Defense in depth: handler also bails if env is not + * dev (in case the route somehow survives a misconfigured deploy). + */ + public static function dev_receive( WP_REST_Request $request ) { + if ( ! class_exists( 'SA_Orch_Env' ) || ! SA_Orch_Env::is_local_or_dev() ) { + return new WP_Error( + 'dev_receive_not_available', + 'dev-receive is only available in local/development environments.', + [ 'status' => 404 ] + ); + } + return rest_ensure_response( [ + 'received' => true, + 'env' => SA_Orch_Env::type(), + 'message' => 'Dev mock receiver acknowledged the attention packet.', + 'received_at' => gmdate( 'c' ), + ] ); + } + + /** + * Read configuration with classification-aware precedence: + * + * 1. Defined constant (production-safe; human-authorized) + * 2. Filter override (testing override; same shape as the constant) + * 3. Dev scaffold default (only when env is local/development; clearly + * non-production values; no real privileged + * system exposed; tokens labeled dev-only) + * 4. Empty string (production with no constants set; emit will + * fail with attention_config_missing — preserved + * behavior for the unconfigured production case) + * + * Per the Environment Configuration Classification rule, dev scaffolding + * is allowed because: + * - the values are clearly non-production (sa-dev-local / discard-port URL / dev-only token) + * - no real external privileged system is exposed (target is unreachable) + * - the token is explicitly labeled dev-only + * - the env must be EXPLICITLY local/development (default 'production' never scaffolds) + */ + public static function get_config(): array { + $defaults = self::dev_scaffold_defaults(); + return [ + 'site_id' => self::resolve_value( 'SA_SITE_ID', 'sa_orch_attention_site_id', $defaults['site_id'] ), + 'target_url' => self::resolve_value( 'SA_ATTENTION_TARGET_URL', 'sa_orch_attention_target_url', $defaults['target_url'] ), + 'token' => self::resolve_value( 'SA_ATTENTION_TOKEN', 'sa_orch_attention_token', $defaults['token'] ), + 'tenant' => self::resolve_value( 'SA_TENANT', 'sa_orch_attention_tenant', $defaults['tenant'] ), + ]; + } + + /** + * Resolve a single config value. Constants beat filters beat dev scaffolds + * beat empty. The dev_default is only non-empty when env is local/dev. + */ + private static function resolve_value( string $constant_name, string $filter_name, string $dev_default ): string { + if ( defined( $constant_name ) ) { + return (string) constant( $constant_name ); + } + $filtered = apply_filters( $filter_name, '' ); + if ( $filtered !== '' && $filtered !== null ) { + return (string) $filtered; + } + return $dev_default; + } + + /** + * Dev-mode scaffolds. Empty in production. Populated in local/dev only. + * + * Values chosen to be unambiguously non-production: + * site_id — 'sa-dev-local' (slug clearly marks origin as dev) + * target_url — '://:9/sa-attention-dev-null' derived from + * home_url() so the scaffold scales with the install's own + * hostname (works on wordpress.localhost, dev.example.com, + * etc.). Port 9 is the IANA discard service; reliably + * refuses connections regardless of host. No real system + * is exposed; emit attempts cleanly fail with status=failed. + * token — 'dev-only-not-a-real-secret' (string self-labels its kind) + * tenant — 'dev-tenant' + */ + private static function dev_scaffold_defaults(): array { + if ( ! class_exists( 'SA_Orch_Env' ) || ! SA_Orch_Env::is_local_or_dev() ) { + return [ 'site_id' => '', 'target_url' => '', 'token' => '', 'tenant' => '' ]; + } + return [ + 'site_id' => 'sa-dev-local', + 'target_url' => self::dev_failure_target_url(), + 'token' => 'dev-only-not-a-real-secret', + 'tenant' => 'dev-tenant', + ]; + } + + /** + * Compute the dev failure-target URL from the install's own host. + * Scales with deploy: same code yields wordpress.localhost:9 in local + * dev, dev.example.com:9 in remote dev, etc. Production never calls this. + */ + private static function dev_failure_target_url(): string { + $parts = wp_parse_url( home_url() ); + $scheme = $parts['scheme'] ?? 'http'; + $host = $parts['host'] ?? '127.0.0.1'; + return $scheme . '://' . $host . ':9/sa-attention-dev-null'; + } + + /** + * The URL of the dev mock receiver — same install, REST endpoint at + * sa-orch/v1/attention/dev-receive. Returns empty string in non-dev + * environments so the success-mock mode silently falls back to default. + */ + private static function dev_success_mock_url(): string { + if ( ! class_exists( 'SA_Orch_Env' ) || ! SA_Orch_Env::is_local_or_dev() ) { + return ''; + } + return rest_url( 'sa-orch/v1/attention/dev-receive' ); + } + + /** + * Resolve the outbound target URL given the configured target and the + * caller-requested mode. Returns: + * [ url, mode_label ] + * mode_label is a short human-readable string for UI display: + * 'configured' — the configured / scaffold-failure target + * 'dev failure target' — same as 'configured' when env is dev (annotated) + * 'dev success mock' — the dev-receive URL (only when env is dev) + */ + private static function resolve_outbound_target( string $configured_url, string $mode ): array { + $is_dev = class_exists( 'SA_Orch_Env' ) && SA_Orch_Env::is_local_or_dev(); + + if ( $mode === 'success_mock' ) { + $mock = self::dev_success_mock_url(); + if ( $is_dev && $mock !== '' ) { + return [ $mock, 'dev success mock' ]; + } + // Production / non-dev silently falls back to configured. Production + // safety: success_mock has no effect outside dev. + } + return [ + $configured_url, + $is_dev ? 'dev failure target' : 'configured', + ]; + } + + /** + * Build the v1 attention payload. Pure function — no I/O. + */ + public static function build_payload( array $cfg, array $input ): array { + $user = wp_get_current_user(); + $user_id = isset( $user->ID ) ? (int) $user->ID : 0; + $user_name = isset( $user->user_login ) ? (string) $user->user_login : ''; + + return [ + 'schema_version' => self::SCHEMA_VERSION, + 'site_id' => (string) $cfg['site_id'], + 'tenant' => (string) $cfg['tenant'], + 'surface' => isset( $input['surface'] ) && $input['surface'] !== '' ? (string) $input['surface'] : null, + 'ref' => isset( $input['ref'] ) && $input['ref'] !== '' ? (string) $input['ref'] : null, + 'page' => isset( $input['page'] ) && $input['page'] !== '' ? (string) $input['page'] : null, + 'user' => [ + 'id' => $user_id, + 'login' => $user_name, + ], + 'observed_at' => gmdate( 'c' ), + ]; + } + + /** + * REST emit handler. Assembles payload, POSTs to target URL with bearer auth, + * logs the attempt to wp_sa_attention_log. Returns the result to the caller + * so the UI can render success/error feedback. + */ + public static function emit( WP_REST_Request $request ) { + $cfg = self::get_config(); + + // Required-config check. Surface missing config explicitly so the + // operator knows what to set, rather than silently failing. + $missing = []; + if ( $cfg['site_id'] === '' ) $missing[] = 'SA_SITE_ID'; + if ( $cfg['target_url'] === '' ) $missing[] = 'SA_ATTENTION_TARGET_URL'; + if ( $cfg['token'] === '' ) $missing[] = 'SA_ATTENTION_TOKEN'; + if ( $missing ) { + return new WP_Error( + 'attention_config_missing', + 'Attention Bridge configuration missing: ' . implode( ', ', $missing ) + . '. Define these constants in wp-config.php.', + [ 'status' => 500 ] + ); + } + + $input = [ + 'surface' => (string) $request->get_param( 'surface' ), + 'ref' => (string) $request->get_param( 'ref' ), + 'page' => (string) $request->get_param( 'page' ), + ]; + + $payload = self::build_payload( $cfg, $input ); + + // Resolve which target this emit hits. 'success_mock' routes to the + // dev-receive endpoint (dev-only); otherwise the configured target. + $mode = (string) $request->get_param( 'mode' ); + list( $outbound_url, $mode_label ) = self::resolve_outbound_target( $cfg['target_url'], $mode ); + + // POST with bearer auth. wp_remote_post handles transport errors gracefully. + $resp = wp_remote_post( $outbound_url, [ + 'method' => 'POST', + 'timeout' => 10, + 'headers' => [ + 'Authorization' => 'Bearer ' . $cfg['token'], + 'Content-Type' => 'application/json', + 'X-SA-Schema-Version' => self::SCHEMA_VERSION, + ], + 'body' => wp_json_encode( $payload ), + ] ); + + $http_status = null; + $response_excerpt = null; + $error_message = null; + if ( is_wp_error( $resp ) ) { + $error_message = $resp->get_error_message(); + } else { + $http_status = (int) wp_remote_retrieve_response_code( $resp ); + $body = (string) wp_remote_retrieve_body( $resp ); + $response_excerpt = function_exists( 'mb_substr' ) ? mb_substr( $body, 0, 500 ) : substr( $body, 0, 500 ); + } + + // Determine status. Distinguishes "the receiver accepted the packet" from + // "the attempt was made but did not arrive cleanly". This is the difference + // between an attempted-emission log and a successful-receipt log; we + // explicitly want the former so the operator can audit transport reality. + // + // success — HTTP 2xx received from the configured target + // failed — anything else: transport error (connection/DNS/timeout) OR + // a non-2xx HTTP response (4xx/5xx etc.) + // + // 'blocked_config' is intentionally NOT used here. Config validation + // failed earlier returns a WP_Error with no I/O; no row is written. + $is_2xx = ( $error_message === null && $http_status !== null && $http_status >= 200 && $http_status < 300 ); + $status = $is_2xx ? 'success' : 'failed'; + + // Log the attempt. Log-only — no analytics, no aggregation, no replay. + // target_url logs the actual URL hit (which is the resolved outbound, + // not necessarily the configured one — so dev success_mock attempts + // are honestly recorded as having gone to the dev-receive endpoint). + global $wpdb; + $tbl = $wpdb->prefix . SA_ORCH_DB_PREFIX . 'attention_log'; + $wpdb->insert( $tbl, [ + 'site_id' => (string) $cfg['site_id'], + 'surface' => $payload['surface'], + 'ref' => $payload['ref'], + 'target_url' => (string) $outbound_url, + 'payload_json' => wp_json_encode( $payload ), + 'status' => $status, + 'http_status' => $http_status, + 'response_excerpt' => $response_excerpt, + 'error_message' => $error_message, + 'recorded_at' => current_time( 'mysql', true ), + ] ); + $log_id = (int) $wpdb->insert_id; + + return rest_ensure_response( [ + 'log_id' => $log_id, + 'status' => $status, + 'http_status' => $http_status, + 'response_excerpt' => $response_excerpt, + 'error_message' => $error_message, + 'payload' => $payload, // echo back so UI can confirm what went out + 'mode_label' => $mode_label, // 'dev failure target' / 'dev success mock' / 'configured' + 'target_url' => $outbound_url, // the URL actually hit, for UI display + 'ok' => $is_2xx, + ] ); + } + + /** + * Enqueue the floating "Send Attention" button on the same admin + * surfaces where Sara appears, gated to manage_options. + * Sara's bubble lives bottom-right; this button lives bottom-left. + */ + public static function maybe_enqueue( $hook ) { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + $page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : ''; + $is_target = ( + $page === 'fluent-boards' + || $page === 'fluent-support' + || strpos( $page, 'sa-orchestration' ) === 0 + ); + if ( ! $is_target ) { + return; + } + + wp_enqueue_script( + self::HANDLE_JS, + SA_ORCH_URL . 'assets/attention-bridge.js', + [], + SA_ORCH_VERSION, + true + ); + + $cfg = self::get_config(); + $is_dev = class_exists( 'SA_Orch_Env' ) && SA_Orch_Env::is_local_or_dev(); + wp_localize_script( self::HANDLE_JS, 'SA_ORCH_ATTENTION', [ + 'rest_url' => esc_url_raw( rest_url( 'sa-orch/v1/attention/emit' ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'page' => $page, + 'site_id' => $cfg['site_id'], // for display only — not used as auth + 'configured' => $cfg['site_id'] !== '' && $cfg['target_url'] !== '' && $cfg['token'] !== '', + 'is_dev' => $is_dev, // Board #21 dev verification: when true, a second + // "Send to dev mock" button appears alongside + // the default "Send Attention". Production never + // sees this flag; the second button never renders. + ] ); + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-bridge-fluent.php b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-bridge-fluent.php new file mode 100644 index 0000000000000000000000000000000000000000..bf5f11f8b234aa8d69ebeb2fbcd892db766cc6bc --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-bridge-fluent.php @@ -0,0 +1,88 @@ +get_row( $wpdb->prepare( + "SELECT * FROM {$wpdb->prefix}fs_tickets WHERE id = %d", + (int) $m[1] + ) ); + } + + if ( $surface === 'fluent-boards' && preg_match( '/^task#(\d+)$/', $ref, $m ) ) { + return $wpdb->get_row( $wpdb->prepare( + "SELECT * FROM {$wpdb->prefix}fbs_tasks WHERE id = %d", + (int) $m[1] + ) ); + } + + if ( $surface === 'fluentcrm' && preg_match( '/^subscriber#(\d+)$/', $ref, $m ) ) { + return $wpdb->get_row( $wpdb->prepare( + "SELECT * FROM {$wpdb->prefix}fc_subscribers WHERE id = %d", + (int) $m[1] + ) ); + } + + return null; + } + + /** + * True if the surface/ref pair points to a real row OR is the + * 'derived' surface (legitimately rowless). False otherwise. + */ + public static function validate( $surface, $ref ) { + if ( $surface === 'derived' ) { + return $ref !== '' && preg_match( '/^derived#[A-Za-z0-9_\-]+$/', $ref ); + } + return self::resolve( $surface, $ref ) !== null; + } + + /** Recognized surface vocabulary (closed set). */ + public static function known_surfaces() { + return [ 'fluent-support', 'fluent-boards', 'fluentcrm', 'derived' ]; + } + + /** + * Surface adapter factory. + * + * Returns the adapter for a given surface, or null if no adapter is + * registered yet. Adapters are the safe path to surface data — + * external schema knowledge is bounded to the adapter file. Direct + * $wpdb access to surface tables in callers is the legacy path; new + * code should route through the adapter. + * + * v0.7.0 shipped one adapter (fluent-boards). Board #18 added + * fluent-support to the contract. fluentcrm and derived still resolve + * via the legacy paths in resolve() / validate() above; that carryover + * stays until those surfaces are ported (deferred Board #18 scope). + */ + public static function adapter_for( string $surface ): ?SA_Orch_Surface_Adapter { + switch ( $surface ) { + case 'fluent-boards': + return new SA_Orch_Adapter_FluentBoards(); + case 'fluent-support': + return new SA_Orch_Adapter_FluentSupport(); + // Deferred (later Board): + // case 'fluentcrm': return new SA_Orch_Adapter_FluentCRM(); + // case 'derived': return new SA_Orch_Adapter_Derived(); + } + return null; + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-demand-registry.php b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-demand-registry.php new file mode 100644 index 0000000000000000000000000000000000000000..21804575243524caa4d93f60e4ff52e712c195b6 --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-demand-registry.php @@ -0,0 +1,81 @@ +validate( $ref ) ) { + return new WP_Error( + 'invalid_ref', + "Ref '{$ref}' does not resolve under the '{$surface}' adapter." + ); + } + + return SA_Orch_Arbitration::write( [ + 'demand_surface' => $surface, + 'demand_ref' => $ref, + 'rationale' => $rationale, + 'projection_type' => $projection_type, + 'truth_class' => $truth_class, + ] ); + } + + /** + * Convenience: is this surface object already registered as a Demand? + * Returns true if at least one arbitration exists for the (surface, ref) pair. + */ + public static function is_registered( string $surface, string $ref ): bool { + global $wpdb; + $arb_table = $wpdb->prefix . SA_ORCH_DB_PREFIX . 'arbitration'; + $count = (int) $wpdb->get_var( $wpdb->prepare( + "SELECT COUNT(*) FROM {$arb_table} WHERE demand_surface = %s AND demand_ref = %s", + $surface, $ref + ) ); + return $count > 0; + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-env.php b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-env.php new file mode 100644 index 0000000000000000000000000000000000000000..bd3a1ed636806969e74f7a7177d70d362f3e36d6 --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-env.php @@ -0,0 +1,53 @@ +stage_id = X; $task->save();) bypass + * Fluent's lifecycle behavior. Specifically, they do NOT fire: + * - do_action('fluent_boards/task_stage_updated', $task, $oldStageId) + * which feeds: + * - ActivityHandler@logTaskStageUpdatedActivity (the audit log) + * - NotificationHandler@changeStageNotification (emails) + * - StageChangedTrigger (FluentCRM automations) + * They also leave 'status' and 'last_completed_at' stale relative to the + * stage's default_task_status setting. + * + * The default path (move_task) keeps the system in sync. The gated path + * (move_task_silent) preserves the same state correctness but suppresses the + * action — for context repair / migration / latent-checks that should not + * generate notifications. Both paths use Fluent's own Task::close() and + * Task::reopen() primitives so the canonical timestamp behavior is honored. + * + * Direct ORM ($task->save() with no helpers) remains available as an + * implicit escape hatch for ad-hoc surgery; nothing in this class blocks it. + * The choice is at the call site. + * + * Architectural rule recorded: + * - Protocol comment is canonical for lifecycle (Proposed/Approved/ + * In Progress/Blocked/Complete/Accepted). + * - Fluent stage_id + status are projections of that lifecycle. + * - This wrapper keeps the projection true. + */ +class SA_Orch_Fluent_State { + + /** + * Move a task to a new stage and fire Fluent's lifecycle behavior. + * + * Behavior: + * - reads old stage_id + * - sets new stage_id, saves + * - aligns status + last_completed_at via Task::close() / Task::reopen() + * based on the new stage's default_task_status setting + * - fires do_action('fluent_boards/task_stage_updated', $task, $oldStageId) + * + * Idempotent: if old == new stage_id, no-op returns true. + * Returns false if task or stage not found. + */ + public static function move_task( int $task_id, int $new_stage_id, array $opts = [] ): bool { + return self::do_move( $task_id, $new_stage_id, true /* fire events */ ); + } + + /** + * Same as move_task() but does NOT fire 'fluent_boards/task_stage_updated'. + * + * Use cases (developer choice — explicitly opt-in): + * - Backfilling drifted state without spamming notifications + * - Repair operations during migration + * - Latent-check passes outside the project board concept + * + * State correctness (status, last_completed_at) is still maintained. + * Only the side-channel notifications/automations are suppressed. + */ + public static function move_task_silent( int $task_id, int $new_stage_id, array $opts = [] ): bool { + return self::do_move( $task_id, $new_stage_id, false /* suppress events */ ); + } + + /** + * Internal mover. Single code path; the only difference between move_task + * and move_task_silent is whether the action fires at the end. + * + * When $fire_events = true (default move_task path), the Description + * Contract gates from Board #27 also apply: + * - Pre-Work gate: blocks Open → In Progress when description is empty + * - Completion gate: blocks In Progress → Complete when description + * lacks acceptance language ('accept', 'criteria', 'done when', + * 'pass when' — case-insensitive substring; deterministic, no NLP). + * The silent variant explicitly bypasses both gates — it's the gated + * developer-choice path for context repair / migration. + */ + private static function do_move( int $task_id, int $new_stage_id, bool $fire_events ): bool { + $task = \FluentBoards\App\Models\Task::find( $task_id ); + if ( ! $task ) { + return false; + } + + $old_stage_id = (int) $task->stage_id; + if ( $old_stage_id === $new_stage_id ) { + // Idempotent no-op. Stage is already where requested. + return true; + } + + $stage = \FluentBoards\App\Models\Stage::find( $new_stage_id ); + if ( ! $stage ) { + return false; + } + + // Board #27 — Description Contract gates. Apply only on default path. + if ( $fire_events && ! self::description_contract_passes( $task, $new_stage_id ) ) { + return false; + } + + // Move the stage first so subsequent close()/reopen() observers see + // the new stage as the source of truth. + $task->stage_id = $new_stage_id; + $task->save(); + + // Align status + last_completed_at to the new stage's contract. + // Task::close() and Task::reopen() are Fluent's own primitives and + // handle the timestamp invariants correctly. + $default_status = $stage->defaultTaskStatus(); + if ( $default_status === 'closed' && $task->status !== 'closed' ) { + $task->close(); + } elseif ( $default_status === 'open' && $task->status === 'closed' ) { + $task->reopen(); + } + + if ( $fire_events ) { + do_action( 'fluent_boards/task_stage_updated', $task, $old_stage_id ); + } + + return true; + } + + /** + * Board #27 — Description Contract gates. + * + * Returns true if the transition is allowed. Returns false (with + * error_log entry explaining why) if the gate blocks. + * + * Stage IDs (board 16): + * 131 = Open / Approved / [QUEUE] + * 132 = In Progress + * 133 = Completed + * + * Gate logic: + * - Moving INTO 132 (In Progress) → Pre-Work gate: description must be non-empty + * - Moving INTO 133 (Completed) → Completion gate: description must contain + * one of 'accept', 'criteria', 'done when', + * 'pass when' (case-insensitive substring) + * - Other stage moves → no gate + * + * Both gates are bypassed by move_task_silent() (the gated escape hatch from #19). + */ + private static function description_contract_passes( $task, int $new_stage_id ): bool { + $description = isset( $task->description ) ? trim( (string) $task->description ) : ''; + + if ( $new_stage_id === 132 ) { + if ( $description === '' ) { + error_log( "[SA-Orch #27] move_task blocked: task #{$task->id} has empty description; cannot move to In Progress. Use move_task_silent() to bypass for repair/migration." ); + return false; + } + return true; + } + + if ( $new_stage_id === 133 ) { + if ( $description === '' ) { + error_log( "[SA-Orch #27] move_task blocked: task #{$task->id} has empty description; cannot move to Complete. Use move_task_silent() to bypass." ); + return false; + } + $lower = strtolower( $description ); + $patterns = [ 'accept', 'criteria', 'done when', 'pass when' ]; + $hit = false; + foreach ( $patterns as $p ) { + if ( strpos( $lower, $p ) !== false ) { $hit = true; break; } + } + if ( ! $hit ) { + error_log( "[SA-Orch #27] move_task blocked: task #{$task->id} description lacks acceptance language (one of: 'accept', 'criteria', 'done when', 'pass when'); cannot move to Complete. Use move_task_silent() to bypass." ); + return false; + } + return true; + } + + // Other stage moves: no gate. + return true; + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-link.php b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-link.php new file mode 100644 index 0000000000000000000000000000000000000000..bdda1a7300df4a16129d2ebe6fc231df6e0ddb4f --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-link.php @@ -0,0 +1,38 @@ +prefix . SA_ORCH_DB_PREFIX . 'arbitration_plan_link'; + $ok = $wpdb->insert( $table, [ + 'arbitration_id' => $arbitration_id, + 'plan_surface' => $plan_surface, + 'plan_ref' => $plan_ref, + 'link_kind' => $link_kind, + 'created_at' => $created_at, + ] ); + + if ( $ok === false ) { + return new WP_Error( 'db_insert_failed', $wpdb->last_error ); + } + + return (int) $wpdb->insert_id; + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-llm-provider-openai.php b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-llm-provider-openai.php new file mode 100644 index 0000000000000000000000000000000000000000..f08646d74a4c5766b94735a82a2419e551b44229 --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-llm-provider-openai.php @@ -0,0 +1,171 @@ + (string) $settings['model'], + 'messages' => [ + [ 'role' => 'system', 'content' => $this->system_prompt() ], + [ 'role' => 'user', 'content' => $this->user_prompt( $trace, $deterministic_audit ) ], + ], + 'response_format' => [ 'type' => 'json_object' ], + 'temperature' => 0.3, + ]; + + $response = wp_remote_post( $url, [ + 'headers' => [ + 'Authorization' => 'Bearer ' . (string) $settings['api_token'], + 'Content-Type' => 'application/json', + ], + 'body' => wp_json_encode( $payload ), + 'timeout' => self::REQUEST_TIMEOUT, + ] ); + + if ( is_wp_error( $response ) ) { + throw new \RuntimeException( 'OpenAI provider: network error — ' . $response->get_error_message() ); + } + + $code = (int) wp_remote_retrieve_response_code( $response ); + if ( $code < 200 || $code >= 300 ) { + $excerpt = substr( (string) wp_remote_retrieve_body( $response ), 0, self::RESPONSE_BODY_PREVIEW_BYTES ); + throw new \RuntimeException( "OpenAI provider: HTTP {$code} — {$excerpt}" ); + } + + $body = (string) wp_remote_retrieve_body( $response ); + $envelope = json_decode( $body, true ); + if ( ! is_array( $envelope ) || ! isset( $envelope['choices'][0]['message']['content'] ) ) { + $excerpt = substr( $body, 0, self::RESPONSE_BODY_PREVIEW_BYTES ); + throw new \RuntimeException( "OpenAI provider: response missing choices[0].message.content — {$excerpt}" ); + } + + $content = (string) $envelope['choices'][0]['message']['content']; + $parsed = json_decode( $content, true ); + if ( ! is_array( $parsed ) ) { + $excerpt = substr( $content, 0, self::RESPONSE_BODY_PREVIEW_BYTES ); + throw new \RuntimeException( "OpenAI provider: model output is not valid JSON — {$excerpt}" ); + } + + return $this->clamp_to_contract( $parsed ); + } + + /* ---------- internals ---------- */ + + private function system_prompt(): string { + return 'You are the Strategy LLM Review layer for the SA-Orchestration plugin ' + . '(a WordPress alignment layer above Fluent Support / Boards / CRM). ' + . 'You receive (a) trace data for a single Demand and (b) the output of a deterministic ' + . 'rules-based audit already computed for the same trace. Produce a brief, interpretive ' + . "strategy review that complements — never replaces — the audit.\n\n" + . "Hard constraints:\n" + . "1. The deterministic audit's closure_state_* fields and needs_human_arbitration are authoritative. " + . "You MUST NOT contradict or override them. If needs_human_arbitration is true, your suggested_next_action " + . "MUST include escalation guidance.\n" + . "2. Return ONLY a JSON object with this exact shape:\n" + . ' {"summary": string, "agreement_with_audit": "true" | "false" | "partial", ' + . '"additional_risks": [string, ...], "suggested_next_action": string, ' + . '"confidence": "low" | "medium" | "high"}' . "\n" + . "3. summary: 2–4 sentences of narrative interpretation (not just a recount of fields).\n" + . "4. agreement_with_audit: 'true' if you see no risks the audit missed; 'partial' if you add risk dimensions; " + . "'false' only if you genuinely disagree with a finding (rare).\n" + . "5. additional_risks: items the deterministic audit may not have caught (stale affirmation, plan complexity, " + . "pattern shortcuts, semantic drift, etc.). Empty array if none.\n" + . "6. suggested_next_action: a single concrete next step.\n" + . "7. confidence: 'low' / 'medium' / 'high' — your confidence in the closure status given the trace and audit.\n" + . "8. CONSERVATIVE FLOOR: If no acceptance events exist OR the deterministic audit's closure_confidence is 'n/a', " + . "you MUST return confidence: 'low'. 'medium' and 'high' are not allowed when closure cannot be evaluated."; + } + + private function user_prompt( array $trace, array $audit ): string { + $trace_payload = [ + 'demand' => $trace['demand'] ?? null, + 'arbitrations' => $trace['arbitrations'] ?? [], + 'plan_links' => $trace['plan_links'] ?? [], + 'acceptance' => $trace['acceptance'] ?? [], + 'closure_state_basic' => $trace['closure_state_basic'] ?? null, + 'closure_state_role_aware' => $trace['closure_state_role_aware'] ?? null, + 'closure_authority_valid' => $trace['closure_authority_valid'] ?? null, + ]; + $audit_payload = [ + 'summary' => $audit['summary'] ?? '', + 'inconsistencies' => $audit['inconsistencies'] ?? [], + 'scope_drift' => $audit['scope_drift'] ?? [], + 'notes' => $audit['notes'] ?? [], + 'closure_confidence' => $audit['closure_confidence'] ?? 'n/a', + 'closure_confidence_explanation' => $audit['closure_confidence_explanation'] ?? '', + 'needs_human_arbitration' => $audit['needs_human_arbitration'] ?? false, + 'human_arbitration_triggers' => $audit['human_arbitration_triggers'] ?? [], + ]; + + return "TRACE:\n" + . wp_json_encode( $trace_payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . "\n\n" + . "DETERMINISTIC AUDIT:\n" + . wp_json_encode( $audit_payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . "\n\n" + . "Return your strategy review as JSON."; + } + + /** + * Clamp the model's JSON output to the contract shape. A misbehaving model + * cannot inject extra fields or wrong types into the consumer surface. + */ + private function clamp_to_contract( array $parsed ): array { + return [ + 'summary' => isset( $parsed['summary'] ) ? (string) $parsed['summary'] : '', + 'agreement_with_audit' => in_array( $parsed['agreement_with_audit'] ?? '', [ 'true', 'false', 'partial' ], true ) + ? (string) $parsed['agreement_with_audit'] + : 'partial', + 'additional_risks' => is_array( $parsed['additional_risks'] ?? null ) + ? array_values( array_map( 'strval', $parsed['additional_risks'] ) ) + : [], + 'suggested_next_action' => isset( $parsed['suggested_next_action'] ) ? (string) $parsed['suggested_next_action'] : '', + 'confidence' => in_array( $parsed['confidence'] ?? '', [ 'low', 'medium', 'high' ], true ) + ? (string) $parsed['confidence'] + : 'low', + ]; + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-llm-provider-simulated.php b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-llm-provider-simulated.php new file mode 100644 index 0000000000000000000000000000000000000000..71e3590277bc68811ed47aac43e9786f8fb874ca --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-llm-provider-simulated.php @@ -0,0 +1,217 @@ +build_narrative( $arbs, $links, $events, $trace, $deterministic_audit ); + $additional_risks = $this->identify_additional_risks( $arbs, $links, $events, $trace ); + $agreement = $this->determine_agreement( $additional_risks, $deterministic_audit ); + $next_action = $this->suggest_next_action( $audit_conf, $trace, $needs_hum, count( $additional_risks ) ); + $confidence = $this->compute_confidence( $audit_conf, count( $additional_risks ), $needs_hum ); + + return [ + 'summary' => $summary, + 'agreement_with_audit' => $agreement, + 'additional_risks' => $additional_risks, + 'suggested_next_action' => $next_action, + 'confidence' => $confidence, + ]; + } + + /* ---------- internals ---------- */ + + private function build_narrative( $arbs, $links, $events, $trace, $audit ) { + $arb_n = count( $arbs ); + $event_n = count( $events ); + $closure_basic = $trace['closure_state_basic'] ?? null; + $authority = $trace['closure_authority_valid'] ?? null; + + if ( $arb_n === 0 && $event_n === 0 ) { + return "No trace data found for this Demand reference. The system has no record of the Demand passing through arbitration or acceptance — the surface_ref may be wrong, or this Demand has not yet been arbitrated."; + } + + $patterns = []; + foreach ( $arbs as $a ) { + if ( ( $a->truth_class ?? null ) === 'derived' ) $patterns['derived'] = true; + if ( ( $a->projection_type ?? null ) === 'scope-direct-no-plan' ) $patterns['no_plan'] = true; + } + if ( $arb_n > 1 ) $patterns['multi_arb'] = true; + if ( $authority === false ) $patterns['agent_self'] = true; + + $had_dispute = false; + foreach ( $events as $e ) { + if ( ( $e->event_type ?? null ) === 'acceptance_disputed' ) { $had_dispute = true; break; } + } + if ( $had_dispute ) $patterns['dispute'] = true; + if ( $closure_basic === 'closed' && $authority === true && ! $had_dispute ) $patterns['clean'] = true; + if ( is_string( $closure_basic ) && strpos( $closure_basic, 'open:' ) === 0 ) $patterns['open'] = true; + + $bits = []; + + // Opening sentence — lead with the dominant pattern + if ( isset( $patterns['agent_self'] ) ) { + $bits[] = "The closure pattern here is concerning: the same role that claimed the result also affirmed acceptance, bypassing the originating stakeholder."; + } elseif ( isset( $patterns['derived'] ) && $event_n === 0 ) { + $bits[] = "The Demand was reconstructed from Plan-side evidence rather than captured at the originating surface. With no acceptance events on record, there is no evidence that the actual stakeholder agrees this Plan addresses their need."; + } elseif ( isset( $patterns['open'] ) && isset( $patterns['dispute'] ) ) { + $bits[] = "The Demand was claimed resolved but disputed; the dispute remains unresolved. The surface state may signal in-progress, but the strategic reality is that the original demand-constraint is unmet."; + } elseif ( isset( $patterns['open'] ) ) { + $bits[] = "The Demand is in-flight: the claim and acceptance steps are not yet complete."; + } elseif ( isset( $patterns['clean'] ) ) { + $bits[] = "This Demand followed a routine path: scoped, executed, and confirmed by the stakeholder."; + } else { + $bits[] = "The Demand chain has " . $arb_n . " arbitration" . ( $arb_n === 1 ? '' : 's' ) + . " and " . $event_n . " acceptance event" . ( $event_n === 1 ? '' : 's' ) . " on record."; + } + + // Mid-narrative annotations + if ( isset( $patterns['no_plan'] ) && isset( $patterns['clean'] ) ) { + $bits[] = "The Plan layer was bypassed (`scope-direct-no-plan`), suggesting an inline operational fix rather than a tracked work unit. Appropriate for small UX changes, but a recurring pattern would erode the Plan layer's value as a work-tracking signal."; + } + if ( isset( $patterns['multi_arb'] ) ) { + $bits[] = "Multiple arbitrations on this Demand suggest the scope was re-interpreted at least once. Each re-arbitration is an opportunity for meaning to drift from the original demand-constraint."; + } + if ( isset( $patterns['dispute'] ) && ! isset( $patterns['open'] ) ) { + $bits[] = "A dispute appeared earlier in the chain but was followed by an affirmation. Verify the dispute root cause was actually addressed, not merely time-elapsed."; + } + + // Closing strategic verdict + if ( ! empty( $audit['needs_human_arbitration'] ) ) { + $bits[] = "Strategy interpretation: this trace requires human arbitration before closure can be trusted. The deterministic audit has flagged this; Strategy Review concurs."; + } elseif ( isset( $patterns['clean'] ) ) { + $bits[] = "Strategy interpretation: routine resolution. No further action required."; + } else { + $bits[] = "Strategy interpretation: defer to the deterministic audit's confidence rating before treating this Demand as resolved."; + } + + return implode( ' ', $bits ); + } + + private function identify_additional_risks( $arbs, $links, $events, $trace ) { + $risks = []; + + // Scope-direct-no-plan as a recurring shortcut + foreach ( $arbs as $a ) { + if ( ( $a->projection_type ?? null ) === 'scope-direct-no-plan' ) { + $risks[] = "Pattern shortcut watch: 'scope-direct-no-plan' arbitration may indicate a class of Demand that routinely bypasses Plan tracking. Recommend monitoring its frequency to catch systemic Plan-layer underuse."; + break; + } + } + + // Reconstruction-then-affirmation + $has_derived = false; + foreach ( $arbs as $a ) { + if ( ( $a->truth_class ?? null ) === 'derived' ) { $has_derived = true; break; } + } + $has_affirm = false; + foreach ( $events as $e ) { + if ( ( $e->event_type ?? null ) === 'acceptance_affirmed' ) { $has_affirm = true; break; } + } + if ( $has_derived && $has_affirm ) { + $risks[] = "Reconstruction-then-affirmation: the Demand was reconstructed from Plan-side evidence and later affirmed. Verify the affirming actor saw the original Demand intent, not just the Plan they were asked to validate."; + } + + // Stale affirmation: long gap between claim and affirm + $claim_time = null; + $affirm_time = null; + foreach ( $events as $e ) { + if ( ( $e->event_type ?? null ) === 'result_claimed' && $claim_time === null ) { + $claim_time = strtotime( (string) $e->observed_at ); + } + if ( ( $e->event_type ?? null ) === 'acceptance_affirmed' ) { + $affirm_time = strtotime( (string) $e->observed_at ); + } + } + if ( $claim_time && $affirm_time ) { + $delta_days = ( $affirm_time - $claim_time ) / 86400; + if ( $delta_days > 30 ) { + $risks[] = sprintf( + "Stale affirmation: %d days between result_claimed and acceptance_affirmed. The stakeholder's confirmation may not reflect current state.", + (int) round( $delta_days ) + ); + } + } + + // Plan complexity + if ( count( $links ) > 2 ) { + $risks[] = "Plan complexity: this Demand fans out to " . count( $links ) . " plan refs. Verify each sub-plan is individually traceable to the original demand-constraint."; + } + + // Long event chain + if ( count( $events ) >= 4 ) { + $risks[] = "State-transition complexity: " . count( $events ) . " acceptance events on this Demand. Each transition is an opportunity for meaning to drift; review for chain coherence."; + } + + return $risks; + } + + private function determine_agreement( $additional_risks, $audit ) { + // The simulated layer reads the same data — it does not contradict the audit's + // factual findings. Agreement is 'true' when nothing additional is identified; + // 'partial' when extra risk dimensions are added but the audit's findings stand. + if ( count( $additional_risks ) === 0 ) { + return 'true'; + } + return 'partial'; + } + + private function suggest_next_action( $audit_conf, $trace, $needs_hum, $extra_risk_count ) { + if ( $needs_hum ) { + return "Escalate to human arbiter. The deterministic audit has flagged this trace as requiring human review; Strategy Review concurs that closure cannot be trusted without it."; + } + + $closure_basic = $trace['closure_state_basic'] ?? null; + + if ( $audit_conf === 'high' && $extra_risk_count === 0 ) { + return "No further action needed; routine closure."; + } + if ( $audit_conf === 'high' && $extra_risk_count > 0 ) { + return "Closure is sound. Optionally surface the additional risks above to the relevant stakeholders for awareness; no blocking action required."; + } + if ( $audit_conf === 'medium' ) { + return "Closure is sound but with notable history. Recommend a follow-up audit in 30 days to confirm the resolution held."; + } + if ( $audit_conf === 'low' && $closure_basic === 'closed' ) { + return "Closure is recorded but not authoritative. Drive to stakeholder affirmation before treating as final."; + } + if ( $audit_conf === 'low' && is_string( $closure_basic ) && strpos( $closure_basic, 'open:' ) === 0 ) { + return "Demand is open. Drive to result_claimed and stakeholder affirmation; or escalate if blocked."; + } + if ( $audit_conf === 'n/a' ) { + return "Demand has no acceptance events. Either drive through claim → affirmation, or close as scope-direct-no-plan if no work is owed."; + } + return "Review the deterministic audit's recommendations and the human-arbitration flag before next action."; + } + + private function compute_confidence( $audit_conf, $extra_risk_count, $needs_hum ) { + if ( $needs_hum ) return 'low'; + if ( $audit_conf === 'high' && $extra_risk_count === 0 ) return 'high'; + if ( $audit_conf === 'high' && $extra_risk_count <= 2 ) return 'medium'; + if ( $audit_conf === 'medium' ) return 'medium'; + if ( $audit_conf === 'n/a' ) return 'low'; + return 'low'; + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-llm-providers.php b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-llm-providers.php new file mode 100644 index 0000000000000000000000000000000000000000..b76a7dd569d9eebc3fdbc7808ab1247634754c4a --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-llm-providers.php @@ -0,0 +1,67 @@ + 'Simulated (no API call) — heuristic expansion of deterministic audit', + 'openai' => 'OpenAI (Chat Completions; works with any OpenAI-compatible endpoint)', + 'anthropic' => 'Anthropic (not implemented yet)', + 'local' => 'Local model endpoint (not implemented yet)', + 'custom' => 'Custom HTTP endpoint (not implemented yet)', + ]; + } + + /** + * Resolve the active provider. Falls back to simulated when: + * - settings.enabled is false + * - settings.provider is unknown + * - the configured provider's class is not yet implemented + */ + public static function get_active_provider(): SA_Orch_LLM_Provider_Interface { + $settings = SA_Orch_LLM_Settings::get(); + + if ( empty( $settings['enabled'] ) ) { + return new SA_Orch_LLM_Provider_Simulated(); + } + + $instance = self::instantiate( (string) ( $settings['provider'] ?? '' ) ); + if ( $instance instanceof SA_Orch_LLM_Provider_Interface ) { + return $instance; + } + + // Fallback + return new SA_Orch_LLM_Provider_Simulated(); + } + + private static function instantiate( $id ): ?SA_Orch_LLM_Provider_Interface { + switch ( $id ) { + case 'simulated': + return new SA_Orch_LLM_Provider_Simulated(); + case 'openai': + return new SA_Orch_LLM_Provider_OpenAI(); + // Future: add cases here as providers land. + // case 'anthropic': return new SA_Orch_LLM_Provider_Anthropic(); + // case 'local': return new SA_Orch_LLM_Provider_Local(); + // case 'custom': return new SA_Orch_LLM_Provider_Custom(); + } + return null; + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-llm-settings.php b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-llm-settings.php new file mode 100644 index 0000000000000000000000000000000000000000..66198985672aff8e6e080ff62b13cf334217a879 --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-llm-settings.php @@ -0,0 +1,62 @@ + false, + 'provider' => 'simulated', + 'model' => '', + 'api_base_url' => '', + 'api_token' => '', + ]; + $stored = get_option( self::OPTION, [] ); + if ( ! is_array( $stored ) ) $stored = []; + return wp_parse_args( $stored, $defaults ); + } + + /** + * Save settings. Token field preserves the existing value when input is empty. + */ + public static function save( array $input ): void { + $current = self::get(); + $new = [ + 'enabled' => ! empty( $input['enabled'] ), + 'provider' => sanitize_key( $input['provider'] ?? 'simulated' ), + 'model' => sanitize_text_field( $input['model'] ?? '' ), + 'api_base_url' => esc_url_raw( $input['api_base_url'] ?? '' ), + 'api_token' => ! empty( $input['api_token'] ) + ? (string) $input['api_token'] + : (string) $current['api_token'], + ]; + update_option( self::OPTION, $new ); + } + + public static function has_token(): bool { + $s = self::get(); + return ! empty( $s['api_token'] ); + } + + /** + * Public-facing label for the saved-token state. Never returns the value itself. + */ + public static function token_status_label(): string { + return self::has_token() + ? '[token is set; leave blank to preserve, enter a new value to update]' + : '[no token saved]'; + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-migrations.php b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-migrations.php new file mode 100644 index 0000000000000000000000000000000000000000..88fd0503171343d5915e4669ea9ee6741b2fc124 --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-migrations.php @@ -0,0 +1,161 @@ +get_charset_collate(); + $p = $wpdb->prefix . SA_ORCH_DB_PREFIX; + + // wp_sa_arbitration + $sql_arb = "CREATE TABLE {$p}arbitration ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + hash VARCHAR(64) NOT NULL, + demand_surface VARCHAR(64) NOT NULL, + demand_ref VARCHAR(255) NOT NULL, + rationale TEXT NOT NULL, + projection_type VARCHAR(64) NOT NULL, + actor_user_id BIGINT UNSIGNED DEFAULT NULL, + correlation_id VARCHAR(64) NOT NULL, + causation_id VARCHAR(64) DEFAULT NULL, + truth_class VARCHAR(32) NOT NULL DEFAULT 'canonical', + asterion_path VARCHAR(255) DEFAULT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uniq_hash (hash), + KEY idx_demand (demand_surface, demand_ref), + KEY idx_corr (correlation_id), + KEY idx_created (created_at) + ) $charset;"; + + // wp_sa_arbitration_plan_link + $sql_link = "CREATE TABLE {$p}arbitration_plan_link ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + arbitration_id BIGINT UNSIGNED NOT NULL, + plan_surface VARCHAR(64) NOT NULL, + plan_ref VARCHAR(255) NOT NULL, + link_kind VARCHAR(32) NOT NULL DEFAULT 'derived', + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY idx_arb (arbitration_id), + KEY idx_plan (plan_surface, plan_ref) + ) $charset;"; + + // wp_sa_acceptance_event + $sql_acc = "CREATE TABLE {$p}acceptance_event ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + hash VARCHAR(64) NOT NULL, + demand_surface VARCHAR(64) NOT NULL, + demand_ref VARCHAR(255) NOT NULL, + event_type VARCHAR(32) NOT NULL, + prior_event_id BIGINT UNSIGNED DEFAULT NULL, + actor_user_id BIGINT UNSIGNED DEFAULT NULL, + actor_role VARCHAR(32) NOT NULL, + details TEXT DEFAULT NULL, + truth_class VARCHAR(32) NOT NULL DEFAULT 'canonical', + asterion_path VARCHAR(255) DEFAULT NULL, + observed_at DATETIME NOT NULL, + recorded_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uniq_hash (hash), + KEY idx_demand_time (demand_surface, demand_ref, observed_at), + KEY idx_event_type (event_type) + ) $charset;"; + + // wp_sa_attention_log — Board #21 (Attention Bridge REST). + // + // SEMANTICS: this is an ATTEMPTED-EMISSION log, NOT a successful-receipt log. + // A row records that an emit was attempted; the 'status' column tells you + // whether the receiver accepted it. + // + // status enum (Board #21 rev2): + // 'success' — outbound POST returned HTTP 2xx; receiver accepted the packet + // 'failed' — transport error (connection refused, DNS, timeout) OR non-2xx HTTP response + // 'blocked_config' — RESERVED. Currently never written. If config validation fails, + // no log row is created at all (validation precedes any I/O). The value + // is reserved for future use cases (e.g. partial-config emission attempts). + // + // NOT a query/analytics store. Captures: + // - what was attempted (full payload_json) + // - whether it arrived (status + http_status + response_excerpt OR error_message) + // Per stakeholder spec: "Did packet arrive? What did it contain?" + $sql_attn = "CREATE TABLE {$p}attention_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + site_id VARCHAR(64) NOT NULL, + surface VARCHAR(64) DEFAULT NULL, + ref VARCHAR(255) DEFAULT NULL, + target_url VARCHAR(512) NOT NULL, + payload_json TEXT NOT NULL, + status VARCHAR(16) DEFAULT NULL, + http_status INT DEFAULT NULL, + response_excerpt TEXT DEFAULT NULL, + error_message TEXT DEFAULT NULL, + recorded_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY idx_site_time (site_id, recorded_at), + KEY idx_status (status) + ) $charset;"; + + // wp_sa_token_log — Board #16 (Token + Usage Visibility). + // Append-only audit of LLM invocations. Each call writes one row. + // (comment_kind, comment_id) is filled in later by the future + // invocation surface; until then, all rows have nulls there and + // the Board #23 filter callback returns false for all comments. + $sql_token = "CREATE TABLE {$p}token_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED DEFAULT NULL, + provider VARCHAR(64) NOT NULL, + model VARCHAR(128) DEFAULT NULL, + prompt_tokens INT UNSIGNED DEFAULT 0, + completion_tokens INT UNSIGNED DEFAULT 0, + total_tokens INT UNSIGNED DEFAULT 0, + comment_kind VARCHAR(32) DEFAULT NULL, + comment_id BIGINT UNSIGNED DEFAULT NULL, + invoked_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY idx_user_time (user_id, invoked_at), + KEY idx_comment (comment_kind, comment_id) + ) $charset;"; + + // wp_sa_actor_handoff — Board #23 (Actor Handoff Log). + // Two-axis attribution log. Records who "pressed send" for an + // actor-authored comment. Strictly additive — does NOT touch + // fbs_comments / fs_conversations. + $sql_handoff = "CREATE TABLE {$p}actor_handoff ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + comment_kind VARCHAR(32) NOT NULL, + comment_id BIGINT UNSIGNED NOT NULL, + committed_by_user_id BIGINT UNSIGNED NOT NULL, + recorded_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY idx_comment (comment_kind, comment_id), + KEY idx_committer (committed_by_user_id, recorded_at) + ) $charset;"; + + dbDelta( $sql_arb ); + dbDelta( $sql_link ); + dbDelta( $sql_acc ); + dbDelta( $sql_attn ); + dbDelta( $sql_handoff ); + dbDelta( $sql_token ); + + // Asterion ledger root + $root = SA_Orch_Asterion::root(); + if ( ! is_dir( $root ) ) { + wp_mkdir_p( $root ); + } + $marker = $root . '/.sa-asterion'; + if ( ! file_exists( $marker ) ) { + file_put_contents( + $marker, + "Asterion ledger root for SA-Orchestration plugin.\n" + . "Canonical-at-write store. Runtime DB at {$wpdb->prefix}{$p}* is a cache.\n" + . "If runtime != Asterion, runtime is the lie.\n" + ); + } + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-projection-fluent-support.php b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-projection-fluent-support.php new file mode 100644 index 0000000000000000000000000000000000000000..e203ba91ebb372a98f4d68765d418abfa41fd0a8 --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-projection-fluent-support.php @@ -0,0 +1,521 @@ +getMessage() ); } + } + + public static function on_acceptance_written( $payload ) { + if ( ! is_array( $payload ) ) return; + $surface = $payload['demand_surface'] ?? ''; + $ref = $payload['demand_ref'] ?? ''; + if ( $surface !== 'fluent-support' ) return; + if ( ! preg_match( '/^ticket#(\d+)$/', (string) $ref, $m ) ) return; + try { self::project_ticket( (int) $m[1] ); } + catch ( \Throwable $e ) { error_log( 'SA-Orch projection (fluent-support) failed: ' . $e->getMessage() ); } + } + + /** + * Project all SA-Orch state for a Demand-bound ticket as Fluent Support + * augmentations. Idempotent — safe to call any number of times. + */ + public static function project_ticket( $ticket_id ) { + global $wpdb; + + $report = [ + 'ticket_id' => (int) $ticket_id, + 'meta_written' => 0, + 'tracked_written' => false, + 'tracked_updated' => false, + 'state_inserted' => false, + 'state_updated' => false, + 'legacy_pruned' => 0, + 'skipped_reason' => null, + ]; + + $ticket = $wpdb->get_row( $wpdb->prepare( + "SELECT id FROM {$wpdb->prefix}fs_tickets WHERE id = %d", + $ticket_id + ) ); + if ( ! $ticket ) { + $report['skipped_reason'] = 'ticket-not-found'; + return $report; + } + + $surface = 'fluent-support'; + $ref = 'ticket#' . (int) $ticket_id; + + $arbitrations = $wpdb->get_results( $wpdb->prepare( + "SELECT id, hash, truth_class, projection_type FROM {$wpdb->prefix}sa_arbitration + WHERE demand_surface = %s AND demand_ref = %s ORDER BY created_at, id", + $surface, $ref + ) ); + $events = $wpdb->get_results( $wpdb->prepare( + "SELECT * FROM {$wpdb->prefix}sa_acceptance_event + WHERE demand_surface = %s AND demand_ref = %s ORDER BY observed_at, id", + $surface, $ref + ) ); + + if ( empty( $arbitrations ) && empty( $events ) ) { + self::remove_all_for_ticket( $ticket_id ); + $report['skipped_reason'] = 'no-sa-orch-data'; + return $report; + } + + // ----- Meta ----- + $latest_arb = end( $arbitrations ); + $arbitration_hash = $latest_arb ? (string) $latest_arb->hash : ''; + $latest_event = ! empty( $events ) ? end( $events ) : null; + $needs_human = self::compute_needs_human( $arbitrations, $events ); + + $closure_authority = ''; + if ( $latest_event && $latest_event->event_type === 'acceptance_affirmed' ) { + $closure_authority = (string) $latest_event->actor_role; + } + + self::set_meta( $ticket_id, 'arbitration_hash', $arbitration_hash ); $report['meta_written']++; + self::set_meta( $ticket_id, 'needs_human', $needs_human ? 'true' : 'false' ); $report['meta_written']++; + self::set_meta( $ticket_id, 'closure_authority', $closure_authority ); $report['meta_written']++; + + // ----- Entry 1: Tracking marker (upserts in place so phrasing/pin rule changes propagate) ----- + $tracked_result = self::upsert_conversation( + $ticket_id, + 'internal_info', + self::tracking_marker_text( $ticket_id ), + self::TRACKED_KEY . $ticket_id, + 'no', + self::resolve_person_id( null ) + ); + $report['tracked_written'] = $tracked_result['inserted']; + $report['tracked_updated'] = $tracked_result['updated']; + + // ----- Entry 2: State summary (upserts in place) ----- + $state_content = self::build_state_summary( $ticket_id, $arbitrations, $events, $needs_human ); + $state_pinned = $needs_human ? 'yes' : 'no'; + $state_actor = $latest_event && $latest_event->actor_user_id ? (int) $latest_event->actor_user_id : null; + $state_result = self::upsert_conversation( + $ticket_id, + 'internal_info', + $state_content, + self::STATE_KEY . $ticket_id, + $state_pinned, + self::resolve_person_id( $state_actor ) + ); + $report['state_inserted'] = $state_result['inserted']; + $report['state_updated'] = $state_result['updated']; + + // ----- Legacy cleanup: prune v0.6.0-era per-event and standalone needs-human rows ----- + $report['legacy_pruned'] = self::prune_legacy_sa_orch_rows( $ticket_id ); + + return $report; + } + + /* ---------- Phrasing helpers ---------- */ + + private static function tracking_marker_text( $ticket_id ) { + return "[SA-Orch] Tracking this ticket.\n" + . "Full record (arbitrations, acceptance events, audit) in WP Admin → SA-Orchestration → Demand Trace → ticket#{$ticket_id}."; + } + + private static function build_state_summary( $ticket_id, $arbitrations, $events, $needs_human ) { + $latest_event = ! empty( $events ) ? end( $events ) : null; + $admin_link = "WP Admin → SA-Orchestration → Demand Trace → ticket#{$ticket_id}"; + + $header = self::state_header( $latest_event, $arbitrations, $needs_human ); + $body = self::state_body( $latest_event, $arbitrations ); + + $lines = []; + $lines[] = "[SA-Orch] {$header}"; + if ( $body !== '' ) $lines[] = $body; + if ( $needs_human ) { + $lines[] = 'Action: ' . self::state_action( $latest_event, $arbitrations ); + } + $lines[] = "Trace: {$admin_link}"; + return implode( "\n", $lines ); + } + + private static function state_header( $latest_event, $arbitrations, $needs_human ) { + if ( $needs_human ) { + // Pick the most actionable trigger as the primary header. + if ( $latest_event && $latest_event->event_type === 'acceptance_disputed' ) { + return 'Needs review — disputed'; + } + if ( $latest_event && $latest_event->event_type === 'acceptance_affirmed' && $latest_event->actor_role !== 'stakeholder' ) { + return 'Needs review — closure not authoritative'; + } + if ( count( $arbitrations ) > 1 ) { + return 'Needs review — multiple arbitrations on this ticket'; + } + foreach ( $arbitrations as $arb ) { + if ( ( $arb->truth_class ?? '' ) === 'derived' ) { + return 'Needs review — Demand reconstructed from indirect signals'; + } + } + return 'Needs review'; + } + if ( ! $latest_event ) { + return 'Tracking — awaiting result'; + } + switch ( $latest_event->event_type ) { + case 'result_claimed': return 'Result claimed — awaiting affirmation'; + case 'acceptance_affirmed': return 'Closed — stakeholder affirmation'; + case 'acceptance_revoked': return 'Revoked — open again'; + case 'acceptance_disputed': return 'Disputed'; + } + return 'Tracking'; + } + + private static function state_body( $latest_event, $arbitrations ) { + if ( ! $latest_event ) { + $arb_count = count( $arbitrations ); + if ( $arb_count > 1 ) { + return "{$arb_count} arbitrations on file. No acceptance events yet."; + } + return 'An arbitration is on file. No result has been claimed yet.'; + } + + $when = self::pretty_date( $latest_event->observed_at ); + $actor = self::actor_phrase( $latest_event->actor_role ); + + switch ( $latest_event->event_type ) { + case 'result_claimed': + return "A result was claimed by {$actor} on {$when}. Awaiting stakeholder affirmation."; + case 'acceptance_affirmed': + if ( $latest_event->actor_role === 'stakeholder' ) { + return "The stakeholder affirmed acceptance on {$when}. Closure is authoritative."; + } + return "Acceptance was affirmed by {$actor} on {$when} — but only the stakeholder can authoritatively close."; + case 'acceptance_disputed': + return "A dispute was recorded by {$actor} on {$when}. Closure is blocked until the dispute is resolved."; + case 'acceptance_revoked': + return "An earlier affirmation was revoked by {$actor} on {$when}. The Demand is open again."; + } + return ''; + } + + private static function state_action( $latest_event, $arbitrations ) { + if ( $latest_event ) { + if ( $latest_event->event_type === 'acceptance_disputed' ) { + return 'resolve the dispute, then have the stakeholder affirm or revoke.'; + } + if ( $latest_event->event_type === 'acceptance_affirmed' && $latest_event->actor_role !== 'stakeholder' ) { + return 'ask the stakeholder to affirm the result, or record their actual response (affirm or dispute).'; + } + } + if ( count( $arbitrations ) > 1 ) { + return 'reconcile the multiple arbitrations on this ticket before relying on closure.'; + } + foreach ( $arbitrations as $arb ) { + if ( ( $arb->truth_class ?? '' ) === 'derived' ) { + return 'confirm the reconstructed Demand with the stakeholder before treating it as canonical.'; + } + } + return 'review the trace and decide whether closure is appropriate.'; + } + + private static function actor_phrase( $actor_role ) { + switch ( $actor_role ) { + case 'stakeholder': return 'the stakeholder'; + case 'agent': return 'an agent'; + case 'system': return 'the system'; + case 'inferred': return 'an inferred actor'; + } + return (string) $actor_role; + } + + private static function pretty_date( $mysql_datetime ) { + if ( empty( $mysql_datetime ) ) return '(unknown)'; + $ts = strtotime( $mysql_datetime ); + if ( ! $ts ) return $mysql_datetime; + return gmdate( 'Y-m-d H:i', $ts ) . ' UTC'; + } + + /* ---------- Audit-mirror helpers (unchanged from v0.6.0) ---------- */ + + private static function compute_needs_human( $arbitrations, $events ) { + if ( ! empty( $events ) ) { + $latest = end( $events ); + if ( $latest->event_type === 'acceptance_disputed' ) return true; + if ( $latest->event_type === 'acceptance_affirmed' && $latest->actor_role !== 'stakeholder' ) { + return true; + } + } + if ( empty( $events ) ) { + foreach ( $arbitrations as $arb ) { + if ( ( $arb->truth_class ?? '' ) === 'derived' ) return true; + } + } + if ( count( $arbitrations ) > 1 ) return true; + return false; + } + + /* ---------- Meta + conversation writers ---------- */ + + private static function set_meta( $ticket_id, $key_short, $value ) { + global $wpdb; + $table = $wpdb->prefix . 'fs_meta'; + $key = self::META_PREFIX . $key_short; + + $existing = $wpdb->get_var( $wpdb->prepare( + "SELECT id FROM {$table} WHERE object_type = %s AND object_id = %d AND `key` = %s LIMIT 1", + 'ticket', (int) $ticket_id, $key + ) ); + $now = current_time( 'mysql', true ); + if ( $existing ) { + $wpdb->update( + $table, + [ 'value' => (string) $value, 'updated_at' => $now ], + [ 'id' => (int) $existing ] + ); + } else { + $wpdb->insert( $table, [ + 'object_type' => 'ticket', + 'object_id' => (int) $ticket_id, + 'key' => $key, + 'value' => (string) $value, + 'created_at' => $now, + 'updated_at' => $now, + ] ); + } + } + + /** + * Insert-only by stable idempotency key. Used for the tracking marker. + */ + private static function ensure_conversation( $ticket_id, $conversation_type, $content, $idempotency_key, $is_important, $person_id ) { + global $wpdb; + $table = $wpdb->prefix . 'fs_conversations'; + $content_hash = md5( $idempotency_key ); + + $existing = $wpdb->get_var( $wpdb->prepare( + "SELECT id FROM {$table} WHERE ticket_id = %d AND content_hash = %s AND source = %s LIMIT 1", + (int) $ticket_id, $content_hash, self::CONV_SOURCE + ) ); + if ( $existing ) return false; + + $now = current_time( 'mysql', true ); + $wpdb->insert( $table, [ + 'serial' => 1, + 'ticket_id' => (int) $ticket_id, + 'person_id' => (int) $person_id, + 'conversation_type' => $conversation_type, + 'content' => $content, + 'source' => self::CONV_SOURCE, + 'content_hash' => $content_hash, + 'is_important' => ( $is_important === 'yes' ? 'yes' : 'no' ), + 'created_at' => $now, + 'updated_at' => $now, + ] ); + return true; + } + + /** + * Upsert by stable idempotency key. Used for the state summary. + * Returns ['inserted' => bool, 'updated' => bool] — at most one is true. + */ + private static function upsert_conversation( $ticket_id, $conversation_type, $content, $idempotency_key, $is_important, $person_id ) { + global $wpdb; + $table = $wpdb->prefix . 'fs_conversations'; + $content_hash = md5( $idempotency_key ); + $now = current_time( 'mysql', true ); + $important = ( $is_important === 'yes' ? 'yes' : 'no' ); + + $existing = $wpdb->get_row( $wpdb->prepare( + "SELECT id, content, is_important FROM {$table} + WHERE ticket_id = %d AND content_hash = %s AND source = %s LIMIT 1", + (int) $ticket_id, $content_hash, self::CONV_SOURCE + ) ); + + if ( $existing ) { + // Skip update if nothing actually changed — preserves a stable updated_at. + if ( $existing->content === $content && $existing->is_important === $important ) { + return [ 'inserted' => false, 'updated' => false ]; + } + $wpdb->update( + $table, + [ + 'content' => $content, + 'is_important' => $important, + 'person_id' => (int) $person_id, + 'updated_at' => $now, + ], + [ 'id' => (int) $existing->id ] + ); + return [ 'inserted' => false, 'updated' => true ]; + } + + $wpdb->insert( $table, [ + 'serial' => 1, + 'ticket_id' => (int) $ticket_id, + 'person_id' => (int) $person_id, + 'conversation_type' => $conversation_type, + 'content' => $content, + 'source' => self::CONV_SOURCE, + 'content_hash' => $content_hash, + 'is_important' => $important, + 'created_at' => $now, + 'updated_at' => $now, + ] ); + return [ 'inserted' => true, 'updated' => false ]; + } + + /** + * Drop SA-Orch conversation rows for this ticket that are NOT the canonical + * tracking-marker or state-summary entries. v0.6.0 emitted per-event entries + * and a separate needs-human entry; v0.6.1 collapses both into the state + * summary, so any leftover rows from v0.6.0 should be pruned on re-projection. + */ + private static function prune_legacy_sa_orch_rows( $ticket_id ) { + global $wpdb; + $tracked_hash = md5( self::TRACKED_KEY . $ticket_id ); + $state_hash = md5( self::STATE_KEY . $ticket_id ); + $deleted = $wpdb->query( $wpdb->prepare( + "DELETE FROM {$wpdb->prefix}fs_conversations + WHERE ticket_id = %d AND source = %s + AND content_hash NOT IN ( %s, %s )", + (int) $ticket_id, self::CONV_SOURCE, $tracked_hash, $state_hash + ) ); + return (int) $deleted; + } + + private static function resolve_person_id( $actor_user_id ) { + global $wpdb; + $actor_user_id = (int) $actor_user_id; + if ( $actor_user_id > 0 ) { + $person = $wpdb->get_var( $wpdb->prepare( + "SELECT id FROM {$wpdb->prefix}fs_persons WHERE user_id = %d AND person_type = 'agent' LIMIT 1", + $actor_user_id + ) ); + if ( $person ) return (int) $person; + } + if ( self::$system_person_id !== null ) return self::$system_person_id; + + $admin_person = $wpdb->get_var( + "SELECT p.id FROM {$wpdb->prefix}fs_persons p + JOIN {$wpdb->users} u ON u.ID = p.user_id + WHERE p.person_type = 'agent' AND u.user_login = 'admin' + LIMIT 1" + ); + if ( $admin_person ) { + self::$system_person_id = (int) $admin_person; + return self::$system_person_id; + } + $any_agent = $wpdb->get_var( + "SELECT id FROM {$wpdb->prefix}fs_persons WHERE person_type = 'agent' ORDER BY id LIMIT 1" + ); + self::$system_person_id = $any_agent ? (int) $any_agent : 1; + return self::$system_person_id; + } + + /* ---------- Cleanup ---------- */ + + public static function remove_all_for_ticket( $ticket_id ) { + global $wpdb; + $meta_deleted = $wpdb->query( $wpdb->prepare( + "DELETE FROM {$wpdb->prefix}fs_meta WHERE object_type = 'ticket' AND object_id = %d AND `key` LIKE %s", + (int) $ticket_id, $wpdb->esc_like( self::META_PREFIX ) . '%' + ) ); + $conv_deleted = $wpdb->query( $wpdb->prepare( + "DELETE FROM {$wpdb->prefix}fs_conversations WHERE ticket_id = %d AND source = %s", + (int) $ticket_id, self::CONV_SOURCE + ) ); + return [ 'meta_rows' => (int) $meta_deleted, 'conversation_rows' => (int) $conv_deleted ]; + } + + public static function remove_all() { + global $wpdb; + $meta_deleted = $wpdb->query( $wpdb->prepare( + "DELETE FROM {$wpdb->prefix}fs_meta WHERE object_type = 'ticket' AND `key` LIKE %s", + $wpdb->esc_like( self::META_PREFIX ) . '%' + ) ); + $conv_deleted = $wpdb->query( $wpdb->prepare( + "DELETE FROM {$wpdb->prefix}fs_conversations WHERE source = %s", + self::CONV_SOURCE + ) ); + return [ 'meta_rows' => (int) $meta_deleted, 'conversation_rows' => (int) $conv_deleted ]; + } + + public static function backfill_all() { + global $wpdb; + $rows = $wpdb->get_col( + "SELECT DISTINCT CAST(SUBSTRING(demand_ref, 8) AS UNSIGNED) AS ticket_id + FROM {$wpdb->prefix}sa_arbitration + WHERE demand_surface = 'fluent-support' AND demand_ref LIKE 'ticket#%' + UNION + SELECT DISTINCT CAST(SUBSTRING(demand_ref, 8) AS UNSIGNED) AS ticket_id + FROM {$wpdb->prefix}sa_acceptance_event + WHERE demand_surface = 'fluent-support' AND demand_ref LIKE 'ticket#%'" + ); + $reports = []; + foreach ( $rows as $tid ) { + $tid = (int) $tid; + if ( $tid > 0 ) $reports[] = self::project_ticket( $tid ); + } + return $reports; + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-reasoning.php b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-reasoning.php new file mode 100644 index 0000000000000000000000000000000000000000..48b9a165534d62c0c6b3d3894418135e1dd7a9b2 --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-reasoning.php @@ -0,0 +1,334 @@ + 'deterministic', + * 'summary' => string, + * 'inconsistencies' => string[], + * 'scope_drift' => string[], + * 'notes' => string[], + * 'closure_confidence' => 'high' | 'medium' | 'low' | 'n/a', + * 'closure_confidence_explanation' => string, + * 'needs_human_arbitration' => bool, + * 'human_arbitration_message' => string, // verbatim user-facing line + * 'human_arbitration_triggers' => string[], // reasons firing the flag + * ] + */ + public static function evaluate( array $trace ) { + $arbs = isset( $trace['arbitrations'] ) ? (array) $trace['arbitrations'] : []; + $links = isset( $trace['plan_links'] ) ? (array) $trace['plan_links'] : []; + $events = isset( $trace['acceptance'] ) ? (array) $trace['acceptance'] : []; + $basic = $trace['closure_state_basic'] ?? null; + $authority = $trace['closure_authority_valid'] ?? null; + + // Event-type flags + $has_claim = false; + $has_dispute = false; + $has_affirm = false; + $has_revoke = false; + foreach ( $events as $e ) { + if ( $e->event_type === 'result_claimed' ) $has_claim = true; + if ( $e->event_type === 'acceptance_disputed' ) $has_dispute = true; + if ( $e->event_type === 'acceptance_affirmed' ) $has_affirm = true; + if ( $e->event_type === 'acceptance_revoked' ) $has_revoke = true; + } + + $inconsistencies = []; + $drift = []; + $notes = []; + + // --- Inconsistencies --- + + // Closure authority invalid: latest acceptance_affirmed by non-stakeholder + if ( $authority === false ) { + $latest_affirm = null; + foreach ( array_reverse( $events ) as $e ) { + if ( $e->event_type === 'acceptance_affirmed' ) { $latest_affirm = $e; break; } + } + if ( $latest_affirm ) { + $inconsistencies[] = "Closure authority invalid: acceptance_affirmed event #{$latest_affirm->id} authored by actor_role='{$latest_affirm->actor_role}' (must be 'stakeholder' for authoritative closure)."; + } + } + + // projection_type vs plan_link count mismatch + foreach ( $arbs as $arb ) { + $arb_id = (int) $arb->id; + $arb_links = count( array_filter( $links, function ( $l ) use ( $arb_id ) { + return (int) $l->arbitration_id === $arb_id; + } ) ); + if ( $arb->projection_type === 'scope-direct-no-plan' && $arb_links > 0 ) { + $inconsistencies[] = "Arbitration #{$arb->id} declares projection_type='scope-direct-no-plan' but has {$arb_links} plan link(s) — inconsistent."; + } + if ( in_array( $arb->projection_type, [ 'scope-direct', 'scope-decomposed', 'scope-merged' ], true ) && $arb_links === 0 ) { + $inconsistencies[] = "Arbitration #{$arb->id} declares projection_type='{$arb->projection_type}' but has no plan links — projection promised a Plan but none recorded."; + } + } + + // Affirmation without claim (closure path is incomplete) + if ( $has_affirm && ! $has_claim ) { + $inconsistencies[] = "Acceptance was affirmed without any prior result_claimed event — closure path is incomplete."; + } + + // prior_event_id chain integrity + $event_ids = array_map( function ( $e ) { return (int) $e->id; }, $events ); + foreach ( $events as $e ) { + if ( $e->prior_event_id !== null && $e->prior_event_id !== '' ) { + $prior = (int) $e->prior_event_id; + if ( $prior > 0 && ! in_array( $prior, $event_ids, true ) ) { + $inconsistencies[] = "Event #{$e->id} references prior_event_id={$prior}, which is not in this Demand's event chain."; + } + } + } + + // --- Scope drift --- + + if ( count( $arbs ) > 1 ) { + $drift[] = "Demand has " . count( $arbs ) . " arbitrations recorded — review for re-interpretation across arbitration moments."; + } + + foreach ( $arbs as $arb ) { + if ( ! in_array( $arb->projection_type, [ 'scope-direct', 'scope-direct-no-plan' ], true ) ) { + $drift[] = "Arbitration #{$arb->id} uses projection_type='{$arb->projection_type}' — explicit scope transformation. Verify the Plan honors the original Demand intent."; + } + if ( $arb->truth_class === 'derived' ) { + $drift[] = "Arbitration #{$arb->id} has truth_class='derived' — Demand was reconstructed from Plan-side evidence. Original scope is not directly verifiable."; + } + } + + // --- Notes (informational) --- + + if ( $has_dispute && $has_affirm ) { + $notes[] = "This Demand experienced a dispute before its current affirmation. Review the dispute → affirmation transition for completeness of resolution."; + } + if ( $has_revoke ) { + $notes[] = "An acceptance_revoked event is present — a previously-affirmed acceptance was withdrawn."; + } + if ( ! empty( $events ) && ! $has_claim && ! $has_dispute && ! $has_affirm && ! $has_revoke ) { + $notes[] = "Acceptance events present but none of the standard types (claim/dispute/affirm/revoke) — review event_type values."; + } + + // --- Summary + confidence --- + + $summary = self::build_summary( $arbs, $links, $events, $basic, $authority ); + $confidence = self::compute_confidence( + $basic, + $authority, + $has_dispute, + $has_revoke, + count( $inconsistencies ), + count( $drift ), + count( $events ) + ); + + // --- Needs Human Arbitration (v0.4.1) --- + + $human = self::compute_human_arbitration( $arbs, $events, $authority ); + + return [ + 'mode' => self::MODE, + 'summary' => $summary, + 'inconsistencies' => $inconsistencies, + 'scope_drift' => $drift, + 'notes' => $notes, + 'closure_confidence' => $confidence['level'], + 'closure_confidence_explanation' => $confidence['explanation'], + 'needs_human_arbitration' => $human['required'], + 'human_arbitration_message' => $human['message'], + 'human_arbitration_triggers' => $human['triggers'], + ]; + } + + /** + * Determine whether this trace requires human arbitration. + * + * Triggers (any one is sufficient): + * 1. closure_authority_valid = false (latest acceptance_affirmed by non-stakeholder). + * 2. arbitration carries truth_class='derived' AND no acceptance events exist + * (Demand was reconstructed from Plan-side evidence and never confirmed). + * 3. multiple arbitrations on the same Demand (re-interpretation requires + * human reconciliation). + * 4. latest event is acceptance_disputed (dispute is unresolved). + * + * The human arbitration verdict is the deterministic layer's escalation + * signal. It is independent of closure_confidence — a Demand can be + * "open" and still require human arbitration if the conditions warrant. + */ + private static function compute_human_arbitration( $arbs, $events, $authority ) { + $triggers = []; + + // 1. closure_authority_valid = false + if ( $authority === false ) { + $triggers[] = "closure_authority_valid is false (acceptance_affirmed event was authored by a non-stakeholder actor)."; + } + + // 2. derived arbitration without acceptance events + $has_derived_arb = false; + foreach ( $arbs as $a ) { + if ( ( $a->truth_class ?? null ) === 'derived' ) { $has_derived_arb = true; break; } + } + if ( $has_derived_arb && count( $events ) === 0 ) { + $triggers[] = "Demand is derived (truth_class='derived' on arbitration) and has no acceptance events — original stakeholder intent is unverified."; + } + + // 3. multiple arbitrations on the same Demand + if ( count( $arbs ) > 1 ) { + $triggers[] = count( $arbs ) . " arbitrations exist on the same Demand — re-interpretation requires human reconciliation."; + } + + // 4. latest event is acceptance_disputed + if ( ! empty( $events ) ) { + $latest = end( $events ); + if ( ( $latest->event_type ?? null ) === 'acceptance_disputed' ) { + $triggers[] = "Latest event is acceptance_disputed — dispute is unresolved and requires human follow-up before closure can be trusted."; + } + } + + return [ + 'required' => ! empty( $triggers ), + 'message' => 'This trace requires human arbitration before closure can be trusted.', + 'triggers' => $triggers, + ]; + } + + private static function build_summary( $arbs, $links, $events, $basic, $authority ) { + $arb_n = count( $arbs ); + $link_n = count( $links ); + $event_n = count( $events ); + + if ( $arb_n === 0 && $event_n === 0 ) { + return "No arbitration and no acceptance events for this Demand. The Demand may be unrecognized by SA-Orchestration, or the surface_ref may be wrong."; + } + + $bits = []; + $bits[] = "{$arb_n} arbitration" . ( $arb_n === 1 ? '' : 's' ); + $bits[] = "{$link_n} plan link" . ( $link_n === 1 ? '' : 's' ); + $bits[] = "{$event_n} acceptance event" . ( $event_n === 1 ? '' : 's' ); + + $event_seq = ''; + if ( $event_n > 0 ) { + $sequence = []; + foreach ( $events as $e ) $sequence[] = $e->event_type; + $event_seq = ' Sequence: ' . implode( ' → ', $sequence ) . '.'; + } + + $closure_phrase = ''; + if ( $basic === 'closed' ) { + if ( $authority === true ) { + $closure_phrase = ' Currently closed (stakeholder-affirmed).'; + } elseif ( $authority === false ) { + $closure_phrase = ' Marked closed but closure authority is invalid (non-stakeholder affirmed).'; + } else { + $closure_phrase = ' Currently closed.'; + } + } elseif ( is_string( $basic ) && strpos( $basic, 'open:' ) === 0 ) { + $closure_phrase = " Currently open ({$basic})."; + } elseif ( $basic === 'no-events' ) { + $closure_phrase = ' No acceptance events.'; + } + + return implode( ', ', $bits ) . '.' . $event_seq . $closure_phrase; + } + + private static function compute_confidence( $basic, $authority, $has_dispute, $has_revoke, $inc_count, $drift_count, $event_count ) { + // No events — confidence not meaningful + if ( $event_count === 0 || $basic === 'no-events' ) { + return [ + 'level' => 'n/a', + 'explanation' => 'No acceptance events to evaluate; closure confidence is not yet meaningful.', + ]; + } + + // Open states + if ( is_string( $basic ) && strpos( $basic, 'open:' ) === 0 ) { + return [ + 'level' => 'low', + 'explanation' => "Demand is currently {$basic}; closure has not been claimed and confirmed.", + ]; + } + + // Closed but authority invalid + if ( $basic === 'closed' && $authority === false ) { + return [ + 'level' => 'low', + 'explanation' => 'Closed by non-stakeholder actor; closure_authority_valid=false. Treat as not authoritatively closed.', + ]; + } + + // Closed with stakeholder authority + if ( $basic === 'closed' && $authority === true ) { + if ( $inc_count > 0 ) { + return [ + 'level' => 'low', + 'explanation' => "Closed by stakeholder, but {$inc_count} inconsistenc" . ( $inc_count === 1 ? 'y' : 'ies' ) . ' detected. Review before treating as final.', + ]; + } + $reasons = []; + if ( $has_dispute ) $reasons[] = 'a prior dispute was recorded'; + if ( $has_revoke ) $reasons[] = 'an acceptance_revoked event exists'; + if ( $drift_count ) $reasons[] = "{$drift_count} potential scope-drift indicator" . ( $drift_count === 1 ? '' : 's' ); + if ( count( $reasons ) === 0 ) { + return [ + 'level' => 'high', + 'explanation' => 'Closed by stakeholder with a clean event chain and no scope drift detected.', + ]; + } + return [ + 'level' => 'medium', + 'explanation' => 'Closed by stakeholder, but ' . implode( '; ', $reasons ) . '. Review history before treating as routine.', + ]; + } + + // Fall-through (unrecognized basic state) + return [ 'level' => 'low', 'explanation' => "Unrecognized closure_state_basic value: '{$basic}'." ]; + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-rest.php b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-rest.php new file mode 100644 index 0000000000000000000000000000000000000000..b7014395a426ca162c9acfe3c8791d0144ea462c --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-rest.php @@ -0,0 +1,110 @@ + 'POST', + 'callback' => [ __CLASS__, 'post_arbitration' ], + 'permission_callback' => [ __CLASS__, 'check_admin' ], + ] ); + + register_rest_route( self::REST_NS, '/acceptance', [ + 'methods' => 'POST', + 'callback' => [ __CLASS__, 'post_acceptance' ], + 'permission_callback' => [ __CLASS__, 'check_admin' ], + ] ); + + register_rest_route( self::REST_NS, '/demand/(?P[a-z\-]+)/(?P[^/]+)/trace', [ + 'methods' => 'GET', + 'callback' => [ __CLASS__, 'get_demand_trace' ], + 'permission_callback' => [ __CLASS__, 'check_admin' ], + ] ); + } + + public static function check_admin() { + return current_user_can( 'manage_options' ); + } + + public static function post_arbitration( WP_REST_Request $request ) { + $params = $request->get_json_params(); + if ( ! is_array( $params ) || empty( $params ) ) { + $params = $request->get_params(); + } + $result = SA_Orch_Arbitration::write( $params ); + if ( is_wp_error( $result ) ) { + return new WP_REST_Response( [ + 'ok' => false, + 'error' => $result->get_error_code(), + 'message' => $result->get_error_message(), + 'data' => $result->get_error_data(), + ], 400 ); + } + return new WP_REST_Response( [ 'ok' => true, 'data' => $result ], 201 ); + } + + public static function post_acceptance( WP_REST_Request $request ) { + $params = $request->get_json_params(); + if ( ! is_array( $params ) || empty( $params ) ) { + $params = $request->get_params(); + } + $result = SA_Orch_Acceptance::write( $params ); + if ( is_wp_error( $result ) ) { + return new WP_REST_Response( [ + 'ok' => false, + 'error' => $result->get_error_code(), + 'message' => $result->get_error_message(), + 'data' => $result->get_error_data(), + ], 400 ); + } + return new WP_REST_Response( [ 'ok' => true, 'data' => $result ], 201 ); + } + + public static function get_demand_trace( WP_REST_Request $request ) { + global $wpdb; + $surface = $request['surface']; + $ref = $request['ref']; + $p = $wpdb->prefix . SA_ORCH_DB_PREFIX; + + $arbs = $wpdb->get_results( $wpdb->prepare( + "SELECT * FROM {$p}arbitration WHERE demand_surface = %s AND demand_ref = %s ORDER BY created_at, id", + $surface, $ref + ) ); + + $links = []; + if ( ! empty( $arbs ) ) { + $arb_ids = array_map( function ( $a ) { return (int) $a->id; }, $arbs ); + $placeholders = implode( ',', array_fill( 0, count( $arb_ids ), '%d' ) ); + $links = $wpdb->get_results( $wpdb->prepare( + "SELECT * FROM {$p}arbitration_plan_link WHERE arbitration_id IN ($placeholders) ORDER BY created_at, id", + ...$arb_ids + ) ); + } + + $events = $wpdb->get_results( $wpdb->prepare( + "SELECT * FROM {$p}acceptance_event WHERE demand_surface = %s AND demand_ref = %s ORDER BY observed_at, id", + $surface, $ref + ) ); + + $closure_state_basic = SA_Orch_Acceptance::basic_closure_state( $surface, $ref ); + $closure_state_role_aware = SA_Orch_Acceptance::role_aware_closure_state( $surface, $ref ); + $closure_authority_valid = SA_Orch_Acceptance::closure_authority_valid( $surface, $ref ); + + return new WP_REST_Response( [ + 'ok' => true, + 'demand' => [ 'surface' => $surface, 'ref' => $ref ], + 'arbitrations' => $arbs, + 'plan_links' => $links, + 'acceptance' => $events, + // Existing field (v0.1) — retained as alias of closure_state_basic for backward compatibility + 'closure_state' => $closure_state_basic, + // Added in v0.2 — explicit naming + 'closure_state_basic' => $closure_state_basic, + 'closure_state_role_aware' => $closure_state_role_aware, + 'closure_authority_valid' => $closure_authority_valid, + ], 200 ); + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-sara-admin.php b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-sara-admin.php new file mode 100644 index 0000000000000000000000000000000000000000..625b9b59b9a4258e8787f5dd6c50ee8c073c6de6 --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-sara-admin.php @@ -0,0 +1,68 @@ + esc_url_raw( rest_url( 'sa-orch/v1/sara/invoke' ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'page' => $page, + ] ); + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-sara-rest.php b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-sara-rest.php new file mode 100644 index 0000000000000000000000000000000000000000..28352d0f2033d0d9224d43aa6667f7439a452ca8 --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-sara-rest.php @@ -0,0 +1,81 @@ + 'POST', + 'callback' => [ __CLASS__, 'invoke' ], + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + 'args' => [ + 'surface' => [ 'type' => 'string', 'required' => false ], + 'ref' => [ 'type' => 'string', 'required' => false ], + 'page' => [ 'type' => 'string', 'required' => false ], + 'prompt' => [ 'type' => 'string', 'required' => true ], + 'history' => [ 'type' => 'array', 'required' => false ], + 'asterion_file' => [ 'type' => 'string', 'required' => false ], // Board #28 + ], + ] ); + } + + public static function invoke( WP_REST_Request $request ) { + $surface = sanitize_text_field( (string) $request->get_param( 'surface' ) ); + $ref = sanitize_text_field( (string) $request->get_param( 'ref' ) ); + $page = sanitize_text_field( (string) $request->get_param( 'page' ) ); + $prompt = (string) $request->get_param( 'prompt' ); + $asterion_file = sanitize_text_field( (string) $request->get_param( 'asterion_file' ) ); + $history = $request->get_param( 'history' ); + if ( ! is_array( $history ) ) { + $history = []; + } + + if ( trim( $prompt ) === '' ) { + return new WP_REST_Response( [ + 'error' => 'empty_prompt', + 'message' => 'prompt is required', + ], 400 ); + } + + $context = [ + 'surface' => $surface !== '' ? $surface : null, + 'ref' => $ref !== '' ? $ref : null, + ]; + + $result = SA_Orch_Sara_Service::invoke( $context, $prompt, $history, $page, $asterion_file ); + + if ( is_wp_error( $result ) ) { + return new WP_REST_Response( [ + 'error' => $result->get_error_code(), + 'message' => $result->get_error_message(), + ], 502 ); + } + + // Service now returns a wrapping shape: { response, usage, log_id }. + // Pass the structured response through as-is for backward-compat with + // the existing JS contract; surface usage + log_id alongside for Board #16. + return new WP_REST_Response( [ + 'response' => $result['response'], + 'usage' => $result['usage'] ?? null, + 'log_id' => $result['log_id'] ?? null, + 'context' => $context, + 'page' => $page !== '' ? $page : null, + 'turn' => count( $history ) / 2 + 1, // 1-indexed turn number for UI feedback + ], 200 ); + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-sara-service.php b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-sara-service.php new file mode 100644 index 0000000000000000000000000000000000000000..15e17adb79b633a367ba61e3691451ffe32b2374 --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-sara-service.php @@ -0,0 +1,418 @@ + string|null, 'ref' => string|null] + * @param string $prompt Current user prompt (the new turn). + * @param array $history Optional prior turns. Each item: + * [ 'role' => 'user'|'assistant', 'content' => string|array ] + * Assistant content is the structured shape returned previously + * and gets reformatted as readable text for the model. + * @param string $page Optional admin page slug (e.g. 'sa-orchestration', 'fluent-boards'). + * Carried into the user prompt when no surface/ref pair is bound, + * so Sara still has page-level context. + * + * @return array|WP_Error On success: + * [ + * 'response' => [ 'summary' => string, 'suggestion' => string, 'draft' => string, 'notes' => string ], + * 'usage' => [ 'prompt_tokens' => int, 'completion_tokens' => int, 'total_tokens' => int ] | null, + * 'log_id' => int | null, // wp_sa_token_log row id, for later linkage to a committed comment + * ] + * WP_Error on any failure path. + */ + public static function invoke( array $context, string $prompt, array $history = [], string $page = '', string $asterion_file = '' ) { + $settings = SA_Orch_LLM_Settings::get(); + if ( empty( $settings['enabled'] ) ) { + return new WP_Error( 'llm_disabled', 'LLM provider is disabled in settings.' ); + } + if ( empty( $settings['api_token'] ) ) { + return new WP_Error( 'no_api_token', 'No API token configured.' ); + } + if ( empty( $settings['api_base_url'] ) || empty( $settings['model'] ) ) { + return new WP_Error( 'incomplete_settings', 'Provider api_base_url and model must be configured.' ); + } + + $url = rtrim( (string) $settings['api_base_url'], '/' ) . '/chat/completions'; + + // Build the messages array: system prompt, then prior turns (history), + // then the current user prompt with context wrapping. + $messages = [ + [ 'role' => 'system', 'content' => self::system_prompt() ], + ]; + foreach ( $history as $turn ) { + $role = isset( $turn['role'] ) ? (string) $turn['role'] : ''; + $content = $turn['content'] ?? ''; + if ( $role === 'user' ) { + $messages[] = [ 'role' => 'user', 'content' => (string) $content ]; + } elseif ( $role === 'assistant' ) { + $messages[] = [ 'role' => 'assistant', 'content' => self::format_assistant_replay( $content ) ]; + } + } + $messages[] = [ 'role' => 'user', 'content' => self::user_prompt( $context, $prompt, $page, $asterion_file ) ]; + + $payload = [ + 'model' => (string) $settings['model'], + 'messages' => $messages, + 'response_format' => [ 'type' => 'json_object' ], + 'temperature' => 0.4, + ]; + + $response = wp_remote_post( $url, [ + 'headers' => [ + 'Authorization' => 'Bearer ' . (string) $settings['api_token'], + 'Content-Type' => 'application/json', + ], + 'body' => wp_json_encode( $payload ), + 'timeout' => self::REQUEST_TIMEOUT, + ] ); + + if ( is_wp_error( $response ) ) { + return new WP_Error( 'network_error', 'Sara: network error — ' . $response->get_error_message() ); + } + $code = (int) wp_remote_retrieve_response_code( $response ); + if ( $code < 200 || $code >= 300 ) { + $excerpt = substr( (string) wp_remote_retrieve_body( $response ), 0, 240 ); + return new WP_Error( 'http_error', "Sara: HTTP {$code} — {$excerpt}" ); + } + + $body = (string) wp_remote_retrieve_body( $response ); + $envelope = json_decode( $body, true ); + if ( ! is_array( $envelope ) || ! isset( $envelope['choices'][0]['message']['content'] ) ) { + return new WP_Error( 'malformed_response', 'Sara: response missing content.' ); + } + + $content = (string) $envelope['choices'][0]['message']['content']; + $parsed = json_decode( $content, true ); + if ( ! is_array( $parsed ) ) { + $excerpt = substr( $content, 0, 240 ); + return new WP_Error( 'parse_error', "Sara: model output is not valid JSON — {$excerpt}" ); + } + + $structured = self::clamp_response( $parsed ); + + // If the request had no bound context AND no Asterion file under view, + // surface "no context bound" so the operator knows why answers are general. + // When asterion_file IS bound, canonical context is present — do not lie + // about being unbound. + if ( ( empty( $context['surface'] ) || empty( $context['ref'] ) ) && $asterion_file === '' ) { + $existing = trim( $structured['notes'] ); + $marker = 'no context bound'; + $structured['notes'] = $existing === '' + ? $marker + : ( $existing . '; ' . $marker ); + } + + // Capture usage and log the invocation (Board #16). Server-side log + // is for cross-session attribution (Board #23 hook). Sara UI counters + // remain JS-only / session-only as locked. log_id is returned so a + // future invocation surface can link this row to a committed comment. + $usage = isset( $envelope['usage'] ) && is_array( $envelope['usage'] ) ? $envelope['usage'] : null; + $log_id = null; + if ( $usage !== null && class_exists( 'SA_Orch_Token_Log' ) ) { + $log_id = SA_Orch_Token_Log::record( + 'openai', + (string) $settings['model'], + $usage, + (int) get_current_user_id() + ); + $log_id = $log_id > 0 ? $log_id : null; + } + + return [ + 'response' => $structured, + 'usage' => $usage, + 'log_id' => $log_id, + ]; + } + + /** + * Clamp model output to the structured contract. A misbehaving model + * cannot inject extra fields or wrong types into the consumer surface. + */ + private static function clamp_response( array $parsed ): array { + return [ + 'summary' => isset( $parsed['summary'] ) ? (string) $parsed['summary'] : '', + 'suggestion' => isset( $parsed['suggestion'] ) ? (string) $parsed['suggestion'] : '', + 'draft' => isset( $parsed['draft'] ) ? (string) $parsed['draft'] : '', + 'notes' => isset( $parsed['notes'] ) ? (string) $parsed['notes'] : '', + ]; + } + + /** System prompt establishing Sara's role and the structured-output contract. */ + private static function system_prompt(): string { + // Board #29 — Sara System Primer. + // Stable identity block: Sara has system-level awareness even with no + // surface or file bound. No generic AI-assistant fallback. No hedging + // disclaimers about "documentation not provided here." + $primer = "SYSTEM IDENTITY (always active)\n" + . "\n" + . "You are Sara — the interpretive cognition surface of\n" + . "SA-Orchestration / MissionNet. This is not a generic chat.\n" + . "You have a stable identity here. You DO NOT need to ask what\n" + . "this system is; you already know it.\n" + . "\n" + . "Forbidden phrasings (do not output any equivalent):\n" + . " - \"specifics depend on system documentation not provided here\"\n" + . " - \"as a generic AI assistant\"\n" + . " - \"if I had access to ...\" about your own operating context\n" + . " - \"likely\" / \"probably depends on\" as a hedge about the system itself\n" + . "(Hedging is fine ABOUT FACTS you genuinely don't know. It is not\n" + . "fine about your own role and the named structure of the system.)\n" + . "\n" + . "COMPASS\n" + . " North = Sara (you) — interpretation, drafting, advising, observing.\n" + . " South = Asterion — canonical truth, read-only markdown spec corpus\n" + . " at Archvie/sa-core/docs/specs/. Verifies, never speculates.\n" + . " Center = Human — only the Human advances state.\n" + . "\n" + . "SURFACES where you appear\n" + . " - Fluent Boards (project tasks; per-board task lifecycle)\n" + . " - Fluent Support (customer tickets)\n" + . " - SA-Orchestration admin: Demand Trace, Sleeper Agents actor registry,\n" + . " Asterion Explorer (canonical MD), Attention Bridge\n" + . " - External Realms (FluentCRM, etc.) mediated through Adapters\n" + . "\n" + . "VOCABULARY\n" + . " - Fluent = motion. Attention = routing.\n" + . " - Boards are contracts (the Description IS the contract — Board #27).\n" + . " - Comments are evidence (audit log; cannot redefine the contract).\n" + . " - Asterion canonical files mirror the repo at Archvie/sa-core/docs/specs/.\n" + . " - When an Asterion file is bound (Board #28), it appears in your prompt as\n" + . " 'ASTERION CANONICAL DOCUMENT — READ FIRST'. Treat it as authoritative.\n" + . "\n" + . "PERMISSIONS (locked)\n" + . " You MAY interpret, draft, advise, observe, coordinate.\n" + . " You MAY NOT progress Boards, accept gates, mutate Asterion, act silently.\n" + . "\n" + . "DOCTRINE\n" + . " Sara interprets. Asterion verifies. Human arbitrates.\n" + . "\n" + . "META-ROLE OVERRIDE (highest priority — overrides BRIEF mode below)\n" + . "\n" + . "When the user asks a meta-question about your identity or the system\n" + . "(any of: \"what is your role\", \"what is this space\", \"what do you do\",\n" + . "\"who are you\", \"introduce yourself\", \"explain yourself\", or paraphrases),\n" + . "the response MUST contain ALL of the following — non-negotiable, regardless\n" + . "of any other mode rule below:\n" + . "\n" + . " - summary: names yourself as Sara AND names the compass\n" + . " (North/Sara, South/Asterion, Center/Human).\n" + . " - suggestion: names at least three of the surfaces above\n" + . " (Fluent Boards, Fluent Support, Asterion Explorer, Demand Trace,\n" + . " Sleeper Agents actor registry, Attention Bridge, FluentCRM Realm).\n" + . " - notes: states the doctrine line: \"Sara interprets. Asterion verifies.\n" + . " Human arbitrates.\"\n" + . "\n" + . "On these questions, BRIEF mode does NOT apply — you produce the structured\n" + . "identity answer. Do NOT answer with one sentence. Do NOT abstract away the\n" + . "named structure. Do NOT say you don't have access to know.\n" + . "\n" + . "--\n" + . "\n"; + + return $primer + . 'You are Sara, the interactive cognition surface of the SA-Orchestration system. ' + . 'You sit alongside the user inside WP admin and help them think and draft. ' + . 'You are NOT canonical. You do not write to any system. ' + . "Your outputs are non-binding drafts; the human decides what to commit.\n\n" + . "You MUST return ONLY a JSON object with this exact shape:\n" + . " {\n" + . " \"summary\": string, // 1-2 sentences\n" + . " \"suggestion\": string, // clear next step\n" + . " \"draft\": string, // optional text the user can paste; empty string if not relevant\n" + . " \"notes\": string // optional flags or caveats; empty string if none\n" + . " }\n\n" + . "All four keys MUST be present. Values may be empty strings. No additional keys.\n\n" + . "Constraints:\n" + . "- Be direct and concrete. The audience is a busy developer/operator.\n" + . "- summary: a brief read of the situation — 1 to 2 sentences.\n" + . "- suggestion: one concrete next action; not a list of options.\n" + . "- draft: if the user seems to want text they can post (a comment, a reply, a message), put a complete ready-to-paste draft here. Otherwise empty string.\n" + . "- notes: caveats, low-confidence flags, missing-context observations. Otherwise empty string.\n" + . "- Do not invent facts about the system. If you do not know, put it in notes.\n" + . "- Keep responses tight. No filler. No preamble.\n" + . "\n" + . "Mode selection — match the depth of the input.\n" + . "\n" + . "The 4-key contract above does NOT change. What changes is the depth of what each field carries, based on what the input is asking for.\n" + . "\n" + . "The constraints above describe BRIEF mode (the default). In BRIEF mode they hold exactly as written.\n" + . "\n" + . "INTERPRETIVE mode OVERRIDES the depth rules. Engage INTERPRETIVE mode when the input shows 2 or more of:\n" + . " - numbered or named principles (e.g. I1, R-O-1, axioms, invariants)\n" + . " - multi-section structure with headings\n" + . " - doctrine vocabulary (canonical, invariant, asymmetry, contract, law, axiom, doctrine, foundational, principle)\n" + . " - explicit philosophical or architectural framing\n" + . " - the user prompt asks for synthesis (\"explain\", \"what does this mean\", \"interpret\", \"synthesize\")\n" + . "\n" + . "When in INTERPRETIVE mode, depth changes:\n" + . " - summary: 2 to 4 sentences. Name the underlying thesis. Connect multiple parts to a coherent system. Not paraphrase. Not a bullet-list of headings.\n" + . " - suggestion: an architectural observation, a synthesis prompt back to the user, a connection-naming. NOT \"review this\". NOT \"check status\".\n" + . " - draft: empty unless the user explicitly wants pasteable text.\n" + . " - notes: thread-level observations — connections, named patterns, attribution flags, structural depth markers.\n" + . "\n" + . "When the input contains numbered or labeled principles (e.g. I1, I2, R-O-1, named axioms, named invariants), the synthesis MUST:\n" + . " - reference at least 3 of them by their identifier (not as a vague group)\n" + . " - explicitly tie each named one to the thesis you identified\n" + . " - name structural patterns that appear (severity tiers, enforcement gradients, attribution chains, asymmetry classes, etc.) — call them out by what they are, not just what they do\n" + . "Do not refer to \"the invariants\" or \"these principles\" as a group when specific identifiers exist. Use the identifiers. The reader already has the document; what they need from you is the structural map.\n" + . "\n" + . "The contract is structural. The depth is contextual. Don't be brief because being brief is safe; brief is wrong on system-shaped input.\n"; + } + + /** + * User prompt wrapping the context payload + the user's actual question. + * + * When (surface, ref) is bound, hydrate the object through the surface + * adapter and embed structured details. When no adapter exists for the + * surface, surface that explicitly so the model knows the ID is real but + * the details aren't reachable (per the user's "if hydration fails, say + * details unavailable" requirement). + * + * When no (surface, ref) is bound, fall back to admin-page context so + * Sara still has SOMETHING to work with on SA-Orchestration admin pages + * that don't carry demand params. + */ + private static function user_prompt( array $context, string $prompt, string $page = '', string $asterion_file = '' ): string { + $surface = $context['surface'] ?? null; + $ref = $context['ref'] ?? null; + + $context_block = "Context:\n"; + + if ( $surface && $ref ) { + $adapter = SA_Orch_Bridge_Fluent::adapter_for( $surface ); + $hydrated = null; + if ( $adapter && method_exists( $adapter, 'hydrate' ) ) { + $hydrated = $adapter->hydrate( $ref ); + } + + if ( is_array( $hydrated ) ) { + $context_block .= "User is currently viewing {$surface} / {$ref}.\n"; + $context_block .= "Bound object details (hydrated through the {$surface} adapter):\n"; + if ( ! empty( $hydrated['title'] ) ) { + $context_block .= " Title: {$hydrated['title']}\n"; + } + if ( ! empty( $hydrated['board_name'] ) ) { + $context_block .= " Board / project: {$hydrated['board_name']}\n"; + } + if ( ! empty( $hydrated['stage'] ) ) { + $context_block .= " Stage: {$hydrated['stage']}\n"; + } + if ( ! empty( $hydrated['status'] ) ) { + $context_block .= " Status: {$hydrated['status']}\n"; + } + if ( ! empty( $hydrated['priority'] ) ) { + $context_block .= " Priority: {$hydrated['priority']}\n"; + } + if ( ! empty( $hydrated['customer'] ) ) { + $context_block .= " Customer / contact: {$hydrated['customer']}\n"; + } + if ( ! empty( $hydrated['description'] ) ) { + // Board #27 — Description Contract Enforcement: when present, + // the Description IS the contract. Comments are evidence only. + $context_block .= " Description (CANONICAL CONTRACT — read this first; recent activity below is verification evidence only and cannot define or modify the contract):\n"; + $context_block .= " {$hydrated['description']}\n"; + } + if ( ! empty( $hydrated['recent_activity'] ) && is_array( $hydrated['recent_activity'] ) ) { + $context_block .= " Recent activity (verification evidence; not the contract):\n"; + foreach ( $hydrated['recent_activity'] as $item ) { + $when = isset( $item['created_at'] ) ? "[{$item['created_at']}] " : ''; + $by = ! empty( $item['by'] ) ? "({$item['by']}) " : ''; + $exc = isset( $item['excerpt'] ) ? (string) $item['excerpt'] : ''; + $context_block .= " - {$when}{$by}{$exc}\n"; + } + } + } else { + // Hydration failed — either no adapter for this surface yet, + // or the adapter returned null. Per spec: tell the model the + // ID is bound but details are unavailable. + $reason = $adapter + ? "the {$surface} adapter could not resolve the ref" + : "no adapter is registered for {$surface} yet"; + $context_block .= "User is currently viewing {$surface} / {$ref}.\n"; + $context_block .= "Bound object details: UNAVAILABLE ({$reason}). Tell the user the context ID is bound but the underlying details could not be fetched, and answer based on the ref alone.\n"; + } + } elseif ( $page !== '' ) { + $context_block .= "User is on admin page '{$page}' (no specific item bound).\n"; + } else { + $context_block .= "No specific surface context detected.\n"; + } + + // Board #28 — Sara Asterion Context Binding. + // Inject the canonical document under view, before the user prompt, + // clearly labeled. No transformation: visibility only. + $asterion_block = ''; + if ( $asterion_file !== '' && class_exists( 'SA_Orch_Asterion_Explorer' ) ) { + $resolved = SA_Orch_Asterion_Explorer::validate_relative_path( $asterion_file ); + if ( is_array( $resolved ) ) { + $content = @file_get_contents( $resolved['absolute'] ); + if ( $content !== false ) { + $cap = 12000; // chars; truncation only allowed transformation + $truncated = false; + if ( strlen( $content ) > $cap ) { + $content = function_exists( 'mb_substr' ) + ? mb_substr( $content, 0, $cap ) + : substr( $content, 0, $cap ); + $truncated = true; + } + $asterion_block = "\n--\n\n"; + $asterion_block .= "ASTERION CANONICAL DOCUMENT — READ FIRST\n"; + $asterion_block .= "File: {$resolved['relative']}\n\n"; + $asterion_block .= $content; + if ( $truncated ) { + $asterion_block .= "\n\n[truncated to {$cap} chars]"; + } + $asterion_block .= "\n"; + } + } + } + + return $context_block . $asterion_block . "\n--\n\nUser prompt:\n\n" . $prompt; + } + + /** + * Convert a prior assistant turn (structured response) back into readable + * text the model can consume on replay. Accepts either the structured + * array shape or a plain string (defensive). + */ + private static function format_assistant_replay( $content ): string { + if ( is_string( $content ) ) { + return $content; + } + if ( ! is_array( $content ) ) { + return ''; + } + $parts = []; + foreach ( [ 'summary', 'suggestion', 'draft', 'notes' ] as $key ) { + $val = isset( $content[ $key ] ) ? trim( (string) $content[ $key ] ) : ''; + if ( $val !== '' ) { + $parts[] = ucfirst( $key ) . ":\n" . $val; + } + } + return $parts ? implode( "\n\n", $parts ) : '(empty response)'; + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-seed-generator.php b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-seed-generator.php new file mode 100644 index 0000000000000000000000000000000000000000..0113a1d17f0d8654312bd367b244c3656c8c1f50 --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-seed-generator.php @@ -0,0 +1,523 @@ +/ + * (b) Filename: -.md, with frontmatter + * (c) Trigger: REST endpoint + admin submenu page (no auto-emission) + * (d) Project: Fluent Boards board_id + * (e) Strategy: Sara-mediated synthesis (LLM via SA_Orch_Sara_Service) + * (f) Promote: DEFERRED — out of scope for #26 + * (g) Index: NO new table; pure-filesystem listing + * + * Hard boundaries: + * - No mutation to Archvie/sa-core/docs/specs/ + * - No mutation to fbs_tasks / fbs_comments + * - No new schema; uses filesystem only + * - No fluent state changes + */ +class SA_Orch_Seed_Generator { + + const SUBMENU_SLUG = 'sa-orchestration-seeds'; + + /* -------------------- Hooks -------------------- */ + + public static function register_routes() { + register_rest_route( 'sa-orch/v1', '/seed/generate', [ + 'methods' => 'POST', + 'callback' => [ __CLASS__, 'rest_generate' ], + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + 'args' => [ + 'board_id' => [ 'type' => 'integer', 'required' => true ], + 'topic' => [ 'type' => 'string', 'required' => false ], + ], + ] ); + + register_rest_route( 'sa-orch/v1', '/seed/list', [ + 'methods' => 'GET', + 'callback' => [ __CLASS__, 'rest_list' ], + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + ] ); + } + + public static function register_menu() { + add_submenu_page( + SA_Orch_Admin::PARENT_SLUG, + 'Asterion Seeds', + 'Asterion Seeds', + SA_Orch_Admin::CAP, + self::SUBMENU_SLUG, + [ __CLASS__, 'render_page' ] + ); + } + + /* -------------------- Storage paths -------------------- */ + + public static function seeds_root(): string { + $base = trailingslashit( WP_CONTENT_DIR ) . 'sa-asterion-seeds'; + $base = apply_filters( 'sa_orch_seed_root', $base ); + return rtrim( str_replace( '\\', '/', (string) $base ), '/' ); + } + + private static function ensure_dir( string $abs ): bool { + if ( is_dir( $abs ) ) return true; + return wp_mkdir_p( $abs ); + } + + private static function slug( string $s ): string { + $s = sanitize_title( $s ); + $s = $s !== '' ? $s : 'seed'; + return $s; + } + + /* -------------------- REST -------------------- */ + + public static function rest_generate( WP_REST_Request $request ) { + $board_id = (int) $request->get_param( 'board_id' ); + $topic = sanitize_text_field( (string) $request->get_param( 'topic' ) ); + + if ( $board_id <= 0 ) { + return new WP_REST_Response( [ + 'error' => 'invalid_board_id', + 'message' => 'board_id must be a positive integer', + ], 400 ); + } + + $result = self::generate( $board_id, $topic ); + if ( is_wp_error( $result ) ) { + return new WP_REST_Response( [ + 'error' => $result->get_error_code(), + 'message' => $result->get_error_message(), + ], 502 ); + } + + return new WP_REST_Response( $result, 200 ); + } + + public static function rest_list( WP_REST_Request $request ) { + return new WP_REST_Response( [ 'seeds' => self::list_seeds() ], 200 ); + } + + /* -------------------- Generation -------------------- */ + + /** + * Generate a seed packet for a Fluent Board. + * + * Steps: + * 1. Read project momentum from fbs_tasks (counts by stage, recent activity). + * 2. Build a structured analysis prompt for Sara. + * 3. Call SA_Orch_Sara_Service::invoke() — INTERPRETIVE-mode signals + * embedded in the prompt so synthesis (not paraphrase) is produced. + * 4. Compose final markdown with YAML frontmatter (artifact_class: seed, + * accepted_into_asterion: false) + Sara's synthesis as body. + * NOTE: artifact_class — NOT truth_class. Seeds are non-canonical + * proposed candidates that pre-date truth classification. I2's + * truth_class enum (canonical | projected | cached | inferred | + * derived | synthetic) stays sacred and is not widened to admit + * seeds; seeds get their own classification dimension. + * 5. Write to wp-content/sa-asterion-seeds//-.md. + * 6. Return the file path + relative path + summary stats. + */ + public static function generate( int $board_id, string $topic = '' ) { + global $wpdb; + + // 1. Project metadata + $board = $wpdb->get_row( $wpdb->prepare( + "SELECT id, title FROM {$wpdb->prefix}fbs_boards WHERE id = %d", + $board_id + ), ARRAY_A ); + if ( ! $board ) { + return new WP_Error( 'board_not_found', "Board {$board_id} not found." ); + } + + // 2. Tasks on this board (parent only, non-archived) + $tasks = $wpdb->get_results( $wpdb->prepare( + "SELECT id, title, stage_id, status, priority, last_completed_at, created_at, updated_at + FROM {$wpdb->prefix}fbs_tasks + WHERE board_id = %d AND (parent_id IS NULL OR parent_id = 0) AND archived_at IS NULL + ORDER BY id ASC", + $board_id + ), ARRAY_A ); + + // Stage label map + $stages = $wpdb->get_results( $wpdb->prepare( + "SELECT id, title FROM {$wpdb->prefix}fbs_board_terms WHERE board_id = %d AND type = 'stage'", + $board_id + ), ARRAY_A ); + $slabel = []; + foreach ( $stages as $s ) $slabel[ (int) $s['id'] ] = (string) $s['title']; + + // 3. Build momentum analysis (deterministic, pre-LLM) + $momentum = self::build_momentum( $tasks, $slabel ); + + // 4. Build prompt for Sara (INTERPRETIVE mode trigger via doctrine vocabulary) + $analysis_block = self::format_momentum_for_prompt( $board, $momentum, $tasks, $slabel ); + $sara_prompt = "Synthesize an Asterion seed packet for the project below.\n" + . "\n" + . "This is a doctrine-grade seed: the output will be a non-canonical\n" + . "markdown candidate for human review and possible promotion into the\n" + . "Asterion canonical spec corpus. Speak in interpretive mode — name\n" + . "the underlying thesis of the project's momentum, identify accepted\n" + . "invariants implied by the closed Boards, surface deferred items as\n" + . "their own structural class, and propose 2–4 specific Asterion\n" + . "additions (each as a one-line proposed canonical addition).\n" + . "\n" + . "Return the structured 4-key Sara shape. Use:\n" + . " summary — 2-4 sentence thesis of the project's current state\n" + . " suggestion — one specific architectural observation worth surfacing to Asterion\n" + . " draft — the proposed Asterion addition(s), markdown-formatted, one or more\n" + . " headed sections (## Proposed addition: …) ready to paste into a spec file\n" + . " notes — open questions, deferred items, attribution flags, low-confidence calls\n" + . "\n" + . "Project analysis (pre-computed):\n" + . "\n" + . $analysis_block; + + // 5. Call Sara + $sara = SA_Orch_Sara_Service::invoke( + [ 'surface' => null, 'ref' => null ], + $sara_prompt, + [], + 'sa-orchestration-seeds', + '' + ); + if ( is_wp_error( $sara ) ) { + return $sara; + } + $resp = isset( $sara['response'] ) && is_array( $sara['response'] ) ? $sara['response'] : []; + $log_id = $sara['log_id'] ?? null; + + // 6. Compose final MD with frontmatter + $project_slug = self::slug( $board['title'] ?: ('board-' . $board_id) ); + $date_utc = gmdate( 'Y-m-d' ); + $time_utc = gmdate( 'Y-m-d\TH:i:s\Z' ); + $topic_slug = $topic !== '' ? self::slug( $topic ) : ('momentum-' . substr( md5( $time_utc ), 0, 6 )); + $relative = $project_slug . '/' . $date_utc . '-' . $topic_slug . '.md'; + $abs_dir = self::seeds_root() . '/' . $project_slug; + $abs_file = self::seeds_root() . '/' . $relative; + + if ( ! self::ensure_dir( $abs_dir ) ) { + return new WP_Error( 'mkdir_failed', "Could not create seed directory {$abs_dir}" ); + } + + $md = self::compose_markdown( + $board, + $project_slug, + $time_utc, + $log_id, + $momentum, + $resp + ); + + $written = @file_put_contents( $abs_file, $md ); + if ( $written === false ) { + return new WP_Error( 'write_failed', "Could not write seed file {$abs_file}" ); + } + + return [ + 'ok' => true, + 'board_id' => $board_id, + 'board_title' => $board['title'], + 'project_slug' => $project_slug, + 'relative' => $relative, + 'absolute' => $abs_file, + 'bytes' => strlen( $md ), + 'log_id' => $log_id, + 'momentum' => $momentum, + 'sara_response'=> $resp, + ]; + } + + /* -------------------- Helpers -------------------- */ + + private static function build_momentum( array $tasks, array $slabel ): array { + $by_stage = []; + $by_status = []; + $closed_recent = []; + $open_now = []; + $deferred = []; + $now = time(); + $week = 7 * 24 * 3600; + + foreach ( $tasks as $t ) { + $sn = $slabel[ (int) $t['stage_id'] ] ?? ('stage_' . $t['stage_id']); + $by_stage[ $sn ] = ( $by_stage[ $sn ] ?? 0 ) + 1; + $by_status[ $t['status'] ] = ( $by_status[ $t['status'] ] ?? 0 ) + 1; + + if ( $t['status'] === 'closed' && $t['last_completed_at'] ) { + $ts = strtotime( $t['last_completed_at'] ); + if ( $ts && $now - $ts <= $week ) { + $closed_recent[] = $t; + } + } + if ( $t['status'] !== 'closed' ) { + $open_now[] = $t; + } + if ( strpos( strtolower( (string) $t['title'] ), '[queue]' ) !== false ) { + $deferred[] = $t; + } + } + + return [ + 'task_count' => count( $tasks ), + 'by_stage' => $by_stage, + 'by_status' => $by_status, + 'closed_in_week' => count( $closed_recent ), + 'open_now' => count( $open_now ), + 'deferred' => count( $deferred ), + 'closed_recent_titles' => array_map( function ( $t ) { + return [ 'id' => (int) $t['id'], 'title' => (string) $t['title'] ]; + }, $closed_recent ), + 'open_now_titles' => array_map( function ( $t ) { + return [ 'id' => (int) $t['id'], 'title' => (string) $t['title'] ]; + }, $open_now ), + ]; + } + + private static function format_momentum_for_prompt( array $board, array $momentum, array $tasks, array $slabel ): string { + $out = "Project: {$board['title']} (board_id={$board['id']})\n"; + $out .= "Total tasks: {$momentum['task_count']}\n"; + $out .= "Closed in last 7 days: {$momentum['closed_in_week']}\n"; + $out .= "Currently open: {$momentum['open_now']}\n"; + $out .= "Deferred ([QUEUE]-tagged): {$momentum['deferred']}\n"; + $out .= "\n"; + $out .= "By stage:\n"; + foreach ( $momentum['by_stage'] as $k => $n ) { + $out .= " {$k}: {$n}\n"; + } + $out .= "\n"; + $out .= "Recently closed (last 7 days):\n"; + if ( empty( $momentum['closed_recent_titles'] ) ) { + $out .= " (none)\n"; + } else { + foreach ( $momentum['closed_recent_titles'] as $r ) { + $out .= " - [#{$r['id']}] {$r['title']}\n"; + } + } + $out .= "\n"; + $out .= "Currently open:\n"; + if ( empty( $momentum['open_now_titles'] ) ) { + $out .= " (none)\n"; + } else { + foreach ( $momentum['open_now_titles'] as $r ) { + $out .= " - [#{$r['id']}] {$r['title']}\n"; + } + } + $out .= "\n"; + return $out; + } + + /** + * Build the final seed markdown: + * - YAML frontmatter (artifact_class: seed; accepted_into_asterion: false) + * - Header with project + generation context + * - Momentum block (deterministic stats) + * - Sara synthesis (4 fields rendered as headed sections) + * - Footer noting non-canonical status + planting instructions + * + * artifact_class is intentionally NOT truth_class. Seeds are non-canonical + * proposed candidates predating truth classification. I2's enum stays + * sacred (canonical | projected | cached | inferred | derived | synthetic). + */ + private static function compose_markdown( array $board, string $project_slug, string $time_utc, $log_id, array $momentum, array $sara_resp ): string { + $title = (string) $board['title']; + $bid = (int) $board['id']; + $log = $log_id !== null ? (int) $log_id : 'null'; + + $md = "---\n"; + $md .= "artifact_class: seed\n"; + $md .= "accepted_into_asterion: false\n"; + $md .= "project: " . self::yaml_escape( $title ) . "\n"; + $md .= "project_slug: {$project_slug}\n"; + $md .= "board_id: {$bid}\n"; + $md .= "generated_at: {$time_utc}\n"; + $md .= "sara_log_id: {$log}\n"; + $md .= "generator: SA_Orch_Seed_Generator\n"; + $md .= "---\n\n"; + + $md .= "# Asterion seed: {$title}\n\n"; + $md .= "> **Non-canonical.** This file is a Sara-generated seed packet for human review.\n"; + $md .= "> It has not been planted into the Asterion canonical spec corpus.\n"; + $md .= "> Promotion is a separate, deliberate act outside this generator's scope.\n\n"; + + $md .= "## Project momentum (deterministic)\n\n"; + $md .= "- Total tasks: {$momentum['task_count']}\n"; + $md .= "- Closed in last 7 days: {$momentum['closed_in_week']}\n"; + $md .= "- Currently open: {$momentum['open_now']}\n"; + $md .= "- Deferred ([QUEUE]-tagged): {$momentum['deferred']}\n\n"; + $md .= "By stage:\n\n"; + foreach ( $momentum['by_stage'] as $k => $n ) { + $md .= "- `{$k}`: {$n}\n"; + } + $md .= "\n"; + + $md .= "## Sara synthesis (interpretive)\n\n"; + + $sum = trim( (string) ( $sara_resp['summary'] ?? '' ) ); + $sug = trim( (string) ( $sara_resp['suggestion'] ?? '' ) ); + $drf = trim( (string) ( $sara_resp['draft'] ?? '' ) ); + $note = trim( (string) ( $sara_resp['notes'] ?? '' ) ); + + $md .= "### Thesis\n\n"; + $md .= ( $sum !== '' ? $sum : '_(none)_' ) . "\n\n"; + + $md .= "### Architectural observation\n\n"; + $md .= ( $sug !== '' ? $sug : '_(none)_' ) . "\n\n"; + + $md .= "### Proposed Asterion addition(s)\n\n"; + $md .= ( $drf !== '' ? $drf : '_(none)_' ) . "\n\n"; + + $md .= "### Open questions / deferred / flags\n\n"; + $md .= ( $note !== '' ? $note : '_(none)_' ) . "\n\n"; + + $md .= "---\n\n"; + $md .= "## Planting (manual)\n\n"; + $md .= "If accepted: review the proposed additions above, copy any that survive review into\n"; + $md .= "`Archvie/sa-core/docs/specs/` (the canonical corpus), commit, and update this seed's\n"; + $md .= "frontmatter `accepted_into_asterion: true` (the generator will not flip that flag).\n"; + $md .= "If rejected: the seed remains in `wp-content/sa-asterion-seeds/` as a record that the\n"; + $md .= "synthesis was attempted; nothing in canonical Asterion changes.\n"; + + return $md; + } + + private static function yaml_escape( string $s ): string { + // YAML-safe single-line: wrap in double quotes, escape backslash + quotes. + $s = str_replace( [ '\\', '"' ], [ '\\\\', '\\"' ], $s ); + return '"' . $s . '"'; + } + + /* -------------------- Listing -------------------- */ + + /** + * List existing seed files (filesystem-only; no index table per question (g)). + */ + public static function list_seeds(): array { + $root = self::seeds_root(); + if ( ! is_dir( $root ) ) return []; + $out = []; + $projects = @scandir( $root ); + if ( $projects === false ) return []; + foreach ( $projects as $p ) { + if ( $p === '.' || $p === '..' ) continue; + $abs = $root . '/' . $p; + if ( ! is_dir( $abs ) ) continue; + $files = @scandir( $abs ); + if ( $files === false ) continue; + foreach ( $files as $f ) { + if ( $f === '.' || $f === '..' ) continue; + if ( substr( strtolower( $f ), -3 ) !== '.md' ) continue; + $rel = $p . '/' . $f; + $st = @stat( $abs . '/' . $f ); + $out[] = [ + 'project_slug' => $p, + 'file' => $f, + 'relative' => $rel, + 'size' => $st ? (int) $st['size'] : 0, + 'mtime' => $st ? gmdate( 'Y-m-d\TH:i:s\Z', (int) $st['mtime'] ) : '', + ]; + } + } + usort( $out, function ( $a, $b ) { return strcmp( $b['mtime'], $a['mtime'] ); } ); + return $out; + } + + /* -------------------- Admin page -------------------- */ + + public static function render_page() { + if ( ! current_user_can( SA_Orch_Admin::CAP ) ) wp_die( 'Insufficient permissions.' ); + + // Handle synchronous form submission + $result = null; + $error = ''; + if ( isset( $_POST['sa_orch_seed_generate'] ) && check_admin_referer( 'sa_orch_seed_generate' ) ) { + $bid = isset( $_POST['board_id'] ) ? (int) $_POST['board_id'] : 0; + $topic = isset( $_POST['topic'] ) ? sanitize_text_field( wp_unslash( $_POST['topic'] ) ) : ''; + if ( $bid <= 0 ) { + $error = 'board_id required'; + } else { + $r = self::generate( $bid, $topic ); + if ( is_wp_error( $r ) ) { + $error = $r->get_error_message(); + } else { + $result = $r; + } + } + } + + global $wpdb; + $boards = $wpdb->get_results( "SELECT id, title FROM {$wpdb->prefix}fbs_boards ORDER BY id", ARRAY_A ); + $seeds = self::list_seeds(); + + echo '
    '; + echo '

    Asterion Seeds — non-canonical seed packets generated by Sara

    '; + + echo '

    Sara generates. Asterion validates. Human plants.

    '; + echo '

    Seed root: ' . esc_html( self::seeds_root() ) . '

    '; + + if ( $error !== '' ) { + echo '

    ' . esc_html( $error ) . '

    '; + } + if ( $result ) { + echo '

    Seed written to ' . esc_html( $result['relative'] ) . ' (' + . (int) $result['bytes'] . ' bytes).

    '; + } + + echo '

    Generate a new seed

    '; + echo '
    '; + wp_nonce_field( 'sa_orch_seed_generate' ); + echo ''; + echo ''; + echo ''; + echo '
    '; + echo ''; + echo '
    '; + echo ''; + echo '
    '; + echo '

    '; + echo '

    Generation calls the configured LLM provider (Sara). One LLM call per generation; cost logged in wp_sa_token_log.

    '; + echo '
    '; + + echo '

    Existing seeds (' . count( $seeds ) . ')

    '; + if ( empty( $seeds ) ) { + echo '

    No seeds generated yet.

    '; + } else { + echo ''; + echo ''; + echo ''; + foreach ( $seeds as $s ) { + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + } + echo '
    ProjectFileSizeGenerated (UTC)
    ' . esc_html( $s['project_slug'] ) . '' . esc_html( $s['file'] ) . '' . (int) $s['size'] . '' . esc_html( $s['mtime'] ) . '
    '; + echo '

    Files live on disk under the seed root. No DB index (per resolved question (g)).

    '; + } + + echo '
    '; + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-strategy.php b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-strategy.php new file mode 100644 index 0000000000000000000000000000000000000000..2965609440f8caab1c866242fcdeed5e4ea9fbf9 --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-strategy.php @@ -0,0 +1,83 @@ +id(); + $configured_name = $configured->name(); + + $fallback_used = false; + $error_message = null; + $used = $configured; + + try { + $review = $configured->review_trace( $trace, $deterministic_audit ); + } catch ( \Throwable $e ) { + // Fallback to simulated on any error from the configured provider + $fallback = new SA_Orch_LLM_Provider_Simulated(); + $review = $fallback->review_trace( $trace, $deterministic_audit ); + $error_message = $e->getMessage(); + $fallback_used = true; + $used = $fallback; + } + + // Defensive: enforce required keys + $review = wp_parse_args( $review, [ + 'summary' => '', + 'agreement_with_audit' => 'partial', + 'additional_risks' => [], + 'suggested_next_action' => '', + 'confidence' => 'low', + ] ); + + // Conservative confidence floor (v0.5.2): when the deterministic audit + // cannot evaluate closure (closure_confidence='n/a' — i.e. no acceptance + // events recorded), the strategy layer MUST collapse to 'low'. Prevents + // LLM-shaped over-confidence on Demands with no acceptance evidence. + // Cross-cutting: applied here so any provider (including future ones) + // gets it for free, regardless of what the model returned. + if ( ( $deterministic_audit['closure_confidence'] ?? '' ) === 'n/a' + && in_array( $review['confidence'], [ 'medium', 'high' ], true ) ) { + $review['confidence'] = 'low'; + } + + // Provider used to actually produce this output + $review['provider_id'] = $used->id(); + $review['provider_name'] = $used->name(); + + // Configured provider (what the operator selected) + $review['configured_provider_id'] = $configured_id; + $review['configured_provider_name'] = $configured_name; + + // Fallback transparency + $review['fallback_used'] = $fallback_used; + if ( $error_message !== null ) { + $review['provider_error'] = $error_message; + } + + return $review; + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-token-log.php b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-token-log.php new file mode 100644 index 0000000000000000000000000000000000000000..20a271bb728d087cfc6ea8c26c7afcd6939eba41 --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/class-sa-token-log.php @@ -0,0 +1,114 @@ +prefix . SA_ORCH_DB_PREFIX . 'token_log'; + } + + /** + * Record one LLM invocation. Returns the new row id, or 0 on failure. + * + * @param string $provider e.g. 'openai' + * @param string $model e.g. 'gpt-4o-mini' + * @param array $usage [ prompt_tokens, completion_tokens, total_tokens ] + * @param int $user_id the WP user_id who triggered the call (or 0) + */ + public static function record( string $provider, string $model, array $usage, int $user_id = 0 ): int { + if ( $provider === '' ) return 0; + global $wpdb; + $ok = $wpdb->insert( self::table(), [ + 'user_id' => $user_id > 0 ? $user_id : null, + 'provider' => $provider, + 'model' => $model, + 'prompt_tokens' => isset( $usage['prompt_tokens'] ) ? (int) $usage['prompt_tokens'] : 0, + 'completion_tokens' => isset( $usage['completion_tokens'] ) ? (int) $usage['completion_tokens'] : 0, + 'total_tokens' => isset( $usage['total_tokens'] ) ? (int) $usage['total_tokens'] : 0, + 'comment_kind' => null, + 'comment_id' => null, + 'invoked_at' => current_time( 'mysql', true ), + ] ); + return $ok !== false ? (int) $wpdb->insert_id : 0; + } + + /** + * Link a previously-recorded token row to the comment it produced. + * Called by the future invocation surface when an actor-attributed + * comment is committed from a Sara draft. + * + * Idempotent on the row's identity — re-linking just updates the row. + */ + public static function link_to_comment( int $log_id, string $kind, int $comment_id ): bool { + if ( $log_id <= 0 || $comment_id <= 0 ) return false; + $kind = sanitize_key( $kind ); + if ( $kind === '' ) return false; + + global $wpdb; + $ok = $wpdb->update( + self::table(), + [ + 'comment_kind' => $kind, + 'comment_id' => $comment_id, + ], + [ 'id' => $log_id ] + ); + return $ok !== false; + } + + /** + * Filter callback for Board #23's 'sa_orch_handoff_has_llm_token'. + * Returns true if at least one token row is linked to (kind, comment_id), + * false otherwise. Always deterministic — no nulls. + */ + public static function has_token_for_comment( string $kind, int $comment_id ): bool { + if ( $comment_id <= 0 ) return false; + $kind = sanitize_key( $kind ); + if ( $kind === '' ) return false; + + global $wpdb; + $count = (int) $wpdb->get_var( $wpdb->prepare( + "SELECT COUNT(*) FROM " . self::table() . " + WHERE comment_kind = %s AND comment_id = %d", + $kind, + $comment_id + ) ); + return $count > 0; + } + + /** + * Bridge to the Board #23 hook. Hooked at plugin load via add_filter. + * Replaces the default 'unknown' (null) with a deterministic boolean. + * + * @param mixed $default The default value (typically null). + * @param string $kind Comment kind constant. + * @param int $comment_id Comment id. + * @return bool + */ + public static function answer_handoff_filter( $default, string $kind, int $comment_id ): bool { + return self::has_token_for_comment( $kind, $comment_id ); + } +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/interface-sa-llm-provider.php b/SA-orchestration MD/plugin/sa-orchestration/includes/interface-sa-llm-provider.php new file mode 100644 index 0000000000000000000000000000000000000000..eb5712f901a2cb0a27363dabf2b8c6643786c80f --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/interface-sa-llm-provider.php @@ -0,0 +1,48 @@ + string, // narrative + * 'agreement_with_audit' => 'true'|'false'|'partial', + * 'additional_risks' => string[], // risks not caught by audit + * 'suggested_next_action' => string, + * 'confidence' => 'low'|'medium'|'high', + * ] + */ + public function review_trace( array $trace, array $deterministic_audit ): array; +} diff --git a/SA-orchestration MD/plugin/sa-orchestration/includes/interface-sa-surface-adapter.php b/SA-orchestration MD/plugin/sa-orchestration/includes/interface-sa-surface-adapter.php new file mode 100644 index 0000000000000000000000000000000000000000..d9c10eb001b6e54b02b592c565f9a0652eae45dd --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/includes/interface-sa-surface-adapter.php @@ -0,0 +1,63 @@ + tag is rendered. +// +// We add 'sa-orchestration' to the allowlist via this filter. Surgical: +// it does NOT disable Fluent Boards' no-conflict mechanism (other plugins' +// scripts are still stripped), and it does NOT change any other behavior +// in Sara, Attention, or anything else. Just whitelists our plugin path. +add_filter( 'fluent_boards/asset_listed_slugs', function ( $slugs ) { + if ( ! is_array( $slugs ) ) $slugs = []; + if ( ! in_array( '\/sa-orchestration\/', $slugs, true ) ) { + $slugs[] = '\/sa-orchestration\/'; + } + return $slugs; +} ); diff --git a/SA-orchestration MD/plugin/sa-orchestration/vendor/Parsedown.php b/SA-orchestration MD/plugin/sa-orchestration/vendor/Parsedown.php new file mode 100644 index 0000000000000000000000000000000000000000..1b9d6d5bc2c044a2e1115dda3f001874f5891789 --- /dev/null +++ b/SA-orchestration MD/plugin/sa-orchestration/vendor/Parsedown.php @@ -0,0 +1,1712 @@ +DefinitionData = array(); + + # standardize line breaks + $text = str_replace(array("\r\n", "\r"), "\n", $text); + + # remove surrounding line breaks + $text = trim($text, "\n"); + + # split text into lines + $lines = explode("\n", $text); + + # iterate through lines to identify blocks + $markup = $this->lines($lines); + + # trim line breaks + $markup = trim($markup, "\n"); + + return $markup; + } + + # + # Setters + # + + function setBreaksEnabled($breaksEnabled) + { + $this->breaksEnabled = $breaksEnabled; + + return $this; + } + + protected $breaksEnabled; + + function setMarkupEscaped($markupEscaped) + { + $this->markupEscaped = $markupEscaped; + + return $this; + } + + protected $markupEscaped; + + function setUrlsLinked($urlsLinked) + { + $this->urlsLinked = $urlsLinked; + + return $this; + } + + protected $urlsLinked = true; + + function setSafeMode($safeMode) + { + $this->safeMode = (bool) $safeMode; + + return $this; + } + + protected $safeMode; + + protected $safeLinksWhitelist = array( + 'http://', + 'https://', + 'ftp://', + 'ftps://', + 'mailto:', + 'data:image/png;base64,', + 'data:image/gif;base64,', + 'data:image/jpeg;base64,', + 'irc:', + 'ircs:', + 'git:', + 'ssh:', + 'news:', + 'steam:', + ); + + # + # Lines + # + + protected $BlockTypes = array( + '#' => array('Header'), + '*' => array('Rule', 'List'), + '+' => array('List'), + '-' => array('SetextHeader', 'Table', 'Rule', 'List'), + '0' => array('List'), + '1' => array('List'), + '2' => array('List'), + '3' => array('List'), + '4' => array('List'), + '5' => array('List'), + '6' => array('List'), + '7' => array('List'), + '8' => array('List'), + '9' => array('List'), + ':' => array('Table'), + '<' => array('Comment', 'Markup'), + '=' => array('SetextHeader'), + '>' => array('Quote'), + '[' => array('Reference'), + '_' => array('Rule'), + '`' => array('FencedCode'), + '|' => array('Table'), + '~' => array('FencedCode'), + ); + + # ~ + + protected $unmarkedBlockTypes = array( + 'Code', + ); + + # + # Blocks + # + + protected function lines(array $lines) + { + $CurrentBlock = null; + + foreach ($lines as $line) + { + if (chop($line) === '') + { + if (isset($CurrentBlock)) + { + $CurrentBlock['interrupted'] = true; + } + + continue; + } + + if (strpos($line, "\t") !== false) + { + $parts = explode("\t", $line); + + $line = $parts[0]; + + unset($parts[0]); + + foreach ($parts as $part) + { + $shortage = 4 - mb_strlen($line, 'utf-8') % 4; + + $line .= str_repeat(' ', $shortage); + $line .= $part; + } + } + + $indent = 0; + + while (isset($line[$indent]) and $line[$indent] === ' ') + { + $indent ++; + } + + $text = $indent > 0 ? substr($line, $indent) : $line; + + # ~ + + $Line = array('body' => $line, 'indent' => $indent, 'text' => $text); + + # ~ + + if (isset($CurrentBlock['continuable'])) + { + $Block = $this->{'block'.$CurrentBlock['type'].'Continue'}($Line, $CurrentBlock); + + if (isset($Block)) + { + $CurrentBlock = $Block; + + continue; + } + else + { + if ($this->isBlockCompletable($CurrentBlock['type'])) + { + $CurrentBlock = $this->{'block'.$CurrentBlock['type'].'Complete'}($CurrentBlock); + } + } + } + + # ~ + + $marker = $text[0]; + + # ~ + + $blockTypes = $this->unmarkedBlockTypes; + + if (isset($this->BlockTypes[$marker])) + { + foreach ($this->BlockTypes[$marker] as $blockType) + { + $blockTypes []= $blockType; + } + } + + # + # ~ + + foreach ($blockTypes as $blockType) + { + $Block = $this->{'block'.$blockType}($Line, $CurrentBlock); + + if (isset($Block)) + { + $Block['type'] = $blockType; + + if ( ! isset($Block['identified'])) + { + $Blocks []= $CurrentBlock; + + $Block['identified'] = true; + } + + if ($this->isBlockContinuable($blockType)) + { + $Block['continuable'] = true; + } + + $CurrentBlock = $Block; + + continue 2; + } + } + + # ~ + + if (isset($CurrentBlock) and ! isset($CurrentBlock['type']) and ! isset($CurrentBlock['interrupted'])) + { + $CurrentBlock['element']['text'] .= "\n".$text; + } + else + { + $Blocks []= $CurrentBlock; + + $CurrentBlock = $this->paragraph($Line); + + $CurrentBlock['identified'] = true; + } + } + + # ~ + + if (isset($CurrentBlock['continuable']) and $this->isBlockCompletable($CurrentBlock['type'])) + { + $CurrentBlock = $this->{'block'.$CurrentBlock['type'].'Complete'}($CurrentBlock); + } + + # ~ + + $Blocks []= $CurrentBlock; + + unset($Blocks[0]); + + # ~ + + $markup = ''; + + foreach ($Blocks as $Block) + { + if (isset($Block['hidden'])) + { + continue; + } + + $markup .= "\n"; + $markup .= isset($Block['markup']) ? $Block['markup'] : $this->element($Block['element']); + } + + $markup .= "\n"; + + # ~ + + return $markup; + } + + protected function isBlockContinuable($Type) + { + return method_exists($this, 'block'.$Type.'Continue'); + } + + protected function isBlockCompletable($Type) + { + return method_exists($this, 'block'.$Type.'Complete'); + } + + # + # Code + + protected function blockCode($Line, $Block = null) + { + if (isset($Block) and ! isset($Block['type']) and ! isset($Block['interrupted'])) + { + return; + } + + if ($Line['indent'] >= 4) + { + $text = substr($Line['body'], 4); + + $Block = array( + 'element' => array( + 'name' => 'pre', + 'handler' => 'element', + 'text' => array( + 'name' => 'code', + 'text' => $text, + ), + ), + ); + + return $Block; + } + } + + protected function blockCodeContinue($Line, $Block) + { + if ($Line['indent'] >= 4) + { + if (isset($Block['interrupted'])) + { + $Block['element']['text']['text'] .= "\n"; + + unset($Block['interrupted']); + } + + $Block['element']['text']['text'] .= "\n"; + + $text = substr($Line['body'], 4); + + $Block['element']['text']['text'] .= $text; + + return $Block; + } + } + + protected function blockCodeComplete($Block) + { + $text = $Block['element']['text']['text']; + + $Block['element']['text']['text'] = $text; + + return $Block; + } + + # + # Comment + + protected function blockComment($Line) + { + if ($this->markupEscaped or $this->safeMode) + { + return; + } + + if (isset($Line['text'][3]) and $Line['text'][3] === '-' and $Line['text'][2] === '-' and $Line['text'][1] === '!') + { + $Block = array( + 'markup' => $Line['body'], + ); + + if (preg_match('/-->$/', $Line['text'])) + { + $Block['closed'] = true; + } + + return $Block; + } + } + + protected function blockCommentContinue($Line, array $Block) + { + if (isset($Block['closed'])) + { + return; + } + + $Block['markup'] .= "\n" . $Line['body']; + + if (preg_match('/-->$/', $Line['text'])) + { + $Block['closed'] = true; + } + + return $Block; + } + + # + # Fenced Code + + protected function blockFencedCode($Line) + { + if (preg_match('/^['.$Line['text'][0].']{3,}[ ]*([^`]+)?[ ]*$/', $Line['text'], $matches)) + { + $Element = array( + 'name' => 'code', + 'text' => '', + ); + + if (isset($matches[1])) + { + /** + * https://www.w3.org/TR/2011/WD-html5-20110525/elements.html#classes + * Every HTML element may have a class attribute specified. + * The attribute, if specified, must have a value that is a set + * of space-separated tokens representing the various classes + * that the element belongs to. + * [...] + * The space characters, for the purposes of this specification, + * are U+0020 SPACE, U+0009 CHARACTER TABULATION (tab), + * U+000A LINE FEED (LF), U+000C FORM FEED (FF), and + * U+000D CARRIAGE RETURN (CR). + */ + $language = substr($matches[1], 0, strcspn($matches[1], " \t\n\f\r")); + + $class = 'language-'.$language; + + $Element['attributes'] = array( + 'class' => $class, + ); + } + + $Block = array( + 'char' => $Line['text'][0], + 'element' => array( + 'name' => 'pre', + 'handler' => 'element', + 'text' => $Element, + ), + ); + + return $Block; + } + } + + protected function blockFencedCodeContinue($Line, $Block) + { + if (isset($Block['complete'])) + { + return; + } + + if (isset($Block['interrupted'])) + { + $Block['element']['text']['text'] .= "\n"; + + unset($Block['interrupted']); + } + + if (preg_match('/^'.$Block['char'].'{3,}[ ]*$/', $Line['text'])) + { + $Block['element']['text']['text'] = substr($Block['element']['text']['text'], 1); + + $Block['complete'] = true; + + return $Block; + } + + $Block['element']['text']['text'] .= "\n".$Line['body']; + + return $Block; + } + + protected function blockFencedCodeComplete($Block) + { + $text = $Block['element']['text']['text']; + + $Block['element']['text']['text'] = $text; + + return $Block; + } + + # + # Header + + protected function blockHeader($Line) + { + if (isset($Line['text'][1])) + { + $level = 1; + + while (isset($Line['text'][$level]) and $Line['text'][$level] === '#') + { + $level ++; + } + + if ($level > 6) + { + return; + } + + $text = trim($Line['text'], '# '); + + $Block = array( + 'element' => array( + 'name' => 'h' . min(6, $level), + 'text' => $text, + 'handler' => 'line', + ), + ); + + return $Block; + } + } + + # + # List + + protected function blockList($Line) + { + list($name, $pattern) = $Line['text'][0] <= '-' ? array('ul', '[*+-]') : array('ol', '[0-9]+[.]'); + + if (preg_match('/^('.$pattern.'[ ]+)(.*)/', $Line['text'], $matches)) + { + $Block = array( + 'indent' => $Line['indent'], + 'pattern' => $pattern, + 'element' => array( + 'name' => $name, + 'handler' => 'elements', + ), + ); + + if($name === 'ol') + { + $listStart = stristr($matches[0], '.', true); + + if($listStart !== '1') + { + $Block['element']['attributes'] = array('start' => $listStart); + } + } + + $Block['li'] = array( + 'name' => 'li', + 'handler' => 'li', + 'text' => array( + $matches[2], + ), + ); + + $Block['element']['text'] []= & $Block['li']; + + return $Block; + } + } + + protected function blockListContinue($Line, array $Block) + { + if ($Block['indent'] === $Line['indent'] and preg_match('/^'.$Block['pattern'].'(?:[ ]+(.*)|$)/', $Line['text'], $matches)) + { + if (isset($Block['interrupted'])) + { + $Block['li']['text'] []= ''; + + $Block['loose'] = true; + + unset($Block['interrupted']); + } + + unset($Block['li']); + + $text = isset($matches[1]) ? $matches[1] : ''; + + $Block['li'] = array( + 'name' => 'li', + 'handler' => 'li', + 'text' => array( + $text, + ), + ); + + $Block['element']['text'] []= & $Block['li']; + + return $Block; + } + + if ($Line['text'][0] === '[' and $this->blockReference($Line)) + { + return $Block; + } + + if ( ! isset($Block['interrupted'])) + { + $text = preg_replace('/^[ ]{0,4}/', '', $Line['body']); + + $Block['li']['text'] []= $text; + + return $Block; + } + + if ($Line['indent'] > 0) + { + $Block['li']['text'] []= ''; + + $text = preg_replace('/^[ ]{0,4}/', '', $Line['body']); + + $Block['li']['text'] []= $text; + + unset($Block['interrupted']); + + return $Block; + } + } + + protected function blockListComplete(array $Block) + { + if (isset($Block['loose'])) + { + foreach ($Block['element']['text'] as &$li) + { + if (end($li['text']) !== '') + { + $li['text'] []= ''; + } + } + } + + return $Block; + } + + # + # Quote + + protected function blockQuote($Line) + { + if (preg_match('/^>[ ]?(.*)/', $Line['text'], $matches)) + { + $Block = array( + 'element' => array( + 'name' => 'blockquote', + 'handler' => 'lines', + 'text' => (array) $matches[1], + ), + ); + + return $Block; + } + } + + protected function blockQuoteContinue($Line, array $Block) + { + if ($Line['text'][0] === '>' and preg_match('/^>[ ]?(.*)/', $Line['text'], $matches)) + { + if (isset($Block['interrupted'])) + { + $Block['element']['text'] []= ''; + + unset($Block['interrupted']); + } + + $Block['element']['text'] []= $matches[1]; + + return $Block; + } + + if ( ! isset($Block['interrupted'])) + { + $Block['element']['text'] []= $Line['text']; + + return $Block; + } + } + + # + # Rule + + protected function blockRule($Line) + { + if (preg_match('/^(['.$Line['text'][0].'])([ ]*\1){2,}[ ]*$/', $Line['text'])) + { + $Block = array( + 'element' => array( + 'name' => 'hr' + ), + ); + + return $Block; + } + } + + # + # Setext + + protected function blockSetextHeader($Line, array $Block = null) + { + if ( ! isset($Block) or isset($Block['type']) or isset($Block['interrupted'])) + { + return; + } + + if (chop($Line['text'], $Line['text'][0]) === '') + { + $Block['element']['name'] = $Line['text'][0] === '=' ? 'h1' : 'h2'; + + return $Block; + } + } + + # + # Markup + + protected function blockMarkup($Line) + { + if ($this->markupEscaped or $this->safeMode) + { + return; + } + + if (preg_match('/^<(\w[\w-]*)(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*(\/)?>/', $Line['text'], $matches)) + { + $element = strtolower($matches[1]); + + if (in_array($element, $this->textLevelElements)) + { + return; + } + + $Block = array( + 'name' => $matches[1], + 'depth' => 0, + 'markup' => $Line['text'], + ); + + $length = strlen($matches[0]); + + $remainder = substr($Line['text'], $length); + + if (trim($remainder) === '') + { + if (isset($matches[2]) or in_array($matches[1], $this->voidElements)) + { + $Block['closed'] = true; + + $Block['void'] = true; + } + } + else + { + if (isset($matches[2]) or in_array($matches[1], $this->voidElements)) + { + return; + } + + if (preg_match('/<\/'.$matches[1].'>[ ]*$/i', $remainder)) + { + $Block['closed'] = true; + } + } + + return $Block; + } + } + + protected function blockMarkupContinue($Line, array $Block) + { + if (isset($Block['closed'])) + { + return; + } + + if (preg_match('/^<'.$Block['name'].'(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*>/i', $Line['text'])) # open + { + $Block['depth'] ++; + } + + if (preg_match('/(.*?)<\/'.$Block['name'].'>[ ]*$/i', $Line['text'], $matches)) # close + { + if ($Block['depth'] > 0) + { + $Block['depth'] --; + } + else + { + $Block['closed'] = true; + } + } + + if (isset($Block['interrupted'])) + { + $Block['markup'] .= "\n"; + + unset($Block['interrupted']); + } + + $Block['markup'] .= "\n".$Line['body']; + + return $Block; + } + + # + # Reference + + protected function blockReference($Line) + { + if (preg_match('/^\[(.+?)\]:[ ]*?(?:[ ]+["\'(](.+)["\')])?[ ]*$/', $Line['text'], $matches)) + { + $id = strtolower($matches[1]); + + $Data = array( + 'url' => $matches[2], + 'title' => null, + ); + + if (isset($matches[3])) + { + $Data['title'] = $matches[3]; + } + + $this->DefinitionData['Reference'][$id] = $Data; + + $Block = array( + 'hidden' => true, + ); + + return $Block; + } + } + + # + # Table + + protected function blockTable($Line, array $Block = null) + { + if ( ! isset($Block) or isset($Block['type']) or isset($Block['interrupted'])) + { + return; + } + + if (strpos($Block['element']['text'], '|') !== false and chop($Line['text'], ' -:|') === '') + { + $alignments = array(); + + $divider = $Line['text']; + + $divider = trim($divider); + $divider = trim($divider, '|'); + + $dividerCells = explode('|', $divider); + + foreach ($dividerCells as $dividerCell) + { + $dividerCell = trim($dividerCell); + + if ($dividerCell === '') + { + continue; + } + + $alignment = null; + + if ($dividerCell[0] === ':') + { + $alignment = 'left'; + } + + if (substr($dividerCell, - 1) === ':') + { + $alignment = $alignment === 'left' ? 'center' : 'right'; + } + + $alignments []= $alignment; + } + + # ~ + + $HeaderElements = array(); + + $header = $Block['element']['text']; + + $header = trim($header); + $header = trim($header, '|'); + + $headerCells = explode('|', $header); + + foreach ($headerCells as $index => $headerCell) + { + $headerCell = trim($headerCell); + + $HeaderElement = array( + 'name' => 'th', + 'text' => $headerCell, + 'handler' => 'line', + ); + + if (isset($alignments[$index])) + { + $alignment = $alignments[$index]; + + $HeaderElement['attributes'] = array( + 'style' => 'text-align: '.$alignment.';', + ); + } + + $HeaderElements []= $HeaderElement; + } + + # ~ + + $Block = array( + 'alignments' => $alignments, + 'identified' => true, + 'element' => array( + 'name' => 'table', + 'handler' => 'elements', + ), + ); + + $Block['element']['text'] []= array( + 'name' => 'thead', + 'handler' => 'elements', + ); + + $Block['element']['text'] []= array( + 'name' => 'tbody', + 'handler' => 'elements', + 'text' => array(), + ); + + $Block['element']['text'][0]['text'] []= array( + 'name' => 'tr', + 'handler' => 'elements', + 'text' => $HeaderElements, + ); + + return $Block; + } + } + + protected function blockTableContinue($Line, array $Block) + { + if (isset($Block['interrupted'])) + { + return; + } + + if ($Line['text'][0] === '|' or strpos($Line['text'], '|')) + { + $Elements = array(); + + $row = $Line['text']; + + $row = trim($row); + $row = trim($row, '|'); + + preg_match_all('/(?:(\\\\[|])|[^|`]|`[^`]+`|`)+/', $row, $matches); + + foreach ($matches[0] as $index => $cell) + { + $cell = trim($cell); + + $Element = array( + 'name' => 'td', + 'handler' => 'line', + 'text' => $cell, + ); + + if (isset($Block['alignments'][$index])) + { + $Element['attributes'] = array( + 'style' => 'text-align: '.$Block['alignments'][$index].';', + ); + } + + $Elements []= $Element; + } + + $Element = array( + 'name' => 'tr', + 'handler' => 'elements', + 'text' => $Elements, + ); + + $Block['element']['text'][1]['text'] []= $Element; + + return $Block; + } + } + + # + # ~ + # + + protected function paragraph($Line) + { + $Block = array( + 'element' => array( + 'name' => 'p', + 'text' => $Line['text'], + 'handler' => 'line', + ), + ); + + return $Block; + } + + # + # Inline Elements + # + + protected $InlineTypes = array( + '"' => array('SpecialCharacter'), + '!' => array('Image'), + '&' => array('SpecialCharacter'), + '*' => array('Emphasis'), + ':' => array('Url'), + '<' => array('UrlTag', 'EmailTag', 'Markup', 'SpecialCharacter'), + '>' => array('SpecialCharacter'), + '[' => array('Link'), + '_' => array('Emphasis'), + '`' => array('Code'), + '~' => array('Strikethrough'), + '\\' => array('EscapeSequence'), + ); + + # ~ + + protected $inlineMarkerList = '!"*_&[:<>`~\\'; + + # + # ~ + # + + public function line($text, $nonNestables=array()) + { + $markup = ''; + + # $excerpt is based on the first occurrence of a marker + + while ($excerpt = strpbrk($text, $this->inlineMarkerList)) + { + $marker = $excerpt[0]; + + $markerPosition = strpos($text, $marker); + + $Excerpt = array('text' => $excerpt, 'context' => $text); + + foreach ($this->InlineTypes[$marker] as $inlineType) + { + # check to see if the current inline type is nestable in the current context + + if ( ! empty($nonNestables) and in_array($inlineType, $nonNestables)) + { + continue; + } + + $Inline = $this->{'inline'.$inlineType}($Excerpt); + + if ( ! isset($Inline)) + { + continue; + } + + # makes sure that the inline belongs to "our" marker + + if (isset($Inline['position']) and $Inline['position'] > $markerPosition) + { + continue; + } + + # sets a default inline position + + if ( ! isset($Inline['position'])) + { + $Inline['position'] = $markerPosition; + } + + # cause the new element to 'inherit' our non nestables + + foreach ($nonNestables as $non_nestable) + { + $Inline['element']['nonNestables'][] = $non_nestable; + } + + # the text that comes before the inline + $unmarkedText = substr($text, 0, $Inline['position']); + + # compile the unmarked text + $markup .= $this->unmarkedText($unmarkedText); + + # compile the inline + $markup .= isset($Inline['markup']) ? $Inline['markup'] : $this->element($Inline['element']); + + # remove the examined text + $text = substr($text, $Inline['position'] + $Inline['extent']); + + continue 2; + } + + # the marker does not belong to an inline + + $unmarkedText = substr($text, 0, $markerPosition + 1); + + $markup .= $this->unmarkedText($unmarkedText); + + $text = substr($text, $markerPosition + 1); + } + + $markup .= $this->unmarkedText($text); + + return $markup; + } + + # + # ~ + # + + protected function inlineCode($Excerpt) + { + $marker = $Excerpt['text'][0]; + + if (preg_match('/^('.$marker.'+)[ ]*(.+?)[ ]*(? strlen($matches[0]), + 'element' => array( + 'name' => 'code', + 'text' => $text, + ), + ); + } + } + + protected function inlineEmailTag($Excerpt) + { + if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<((mailto:)?\S+?@\S+?)>/i', $Excerpt['text'], $matches)) + { + $url = $matches[1]; + + if ( ! isset($matches[2])) + { + $url = 'mailto:' . $url; + } + + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => 'a', + 'text' => $matches[1], + 'attributes' => array( + 'href' => $url, + ), + ), + ); + } + } + + protected function inlineEmphasis($Excerpt) + { + if ( ! isset($Excerpt['text'][1])) + { + return; + } + + $marker = $Excerpt['text'][0]; + + if ($Excerpt['text'][1] === $marker and preg_match($this->StrongRegex[$marker], $Excerpt['text'], $matches)) + { + $emphasis = 'strong'; + } + elseif (preg_match($this->EmRegex[$marker], $Excerpt['text'], $matches)) + { + $emphasis = 'em'; + } + else + { + return; + } + + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => $emphasis, + 'handler' => 'line', + 'text' => $matches[1], + ), + ); + } + + protected function inlineEscapeSequence($Excerpt) + { + if (isset($Excerpt['text'][1]) and in_array($Excerpt['text'][1], $this->specialCharacters)) + { + return array( + 'markup' => $Excerpt['text'][1], + 'extent' => 2, + ); + } + } + + protected function inlineImage($Excerpt) + { + if ( ! isset($Excerpt['text'][1]) or $Excerpt['text'][1] !== '[') + { + return; + } + + $Excerpt['text']= substr($Excerpt['text'], 1); + + $Link = $this->inlineLink($Excerpt); + + if ($Link === null) + { + return; + } + + $Inline = array( + 'extent' => $Link['extent'] + 1, + 'element' => array( + 'name' => 'img', + 'attributes' => array( + 'src' => $Link['element']['attributes']['href'], + 'alt' => $Link['element']['text'], + ), + ), + ); + + $Inline['element']['attributes'] += $Link['element']['attributes']; + + unset($Inline['element']['attributes']['href']); + + return $Inline; + } + + protected function inlineLink($Excerpt) + { + $Element = array( + 'name' => 'a', + 'handler' => 'line', + 'nonNestables' => array('Url', 'Link'), + 'text' => null, + 'attributes' => array( + 'href' => null, + 'title' => null, + ), + ); + + $extent = 0; + + $remainder = $Excerpt['text']; + + if (preg_match('/\[((?:[^][]++|(?R))*+)\]/', $remainder, $matches)) + { + $Element['text'] = $matches[1]; + + $extent += strlen($matches[0]); + + $remainder = substr($remainder, $extent); + } + else + { + return; + } + + if (preg_match('/^[(]\s*+((?:[^ ()]++|[(][^ )]+[)])++)(?:[ ]+("[^"]*"|\'[^\']*\'))?\s*[)]/', $remainder, $matches)) + { + $Element['attributes']['href'] = $matches[1]; + + if (isset($matches[2])) + { + $Element['attributes']['title'] = substr($matches[2], 1, - 1); + } + + $extent += strlen($matches[0]); + } + else + { + if (preg_match('/^\s*\[(.*?)\]/', $remainder, $matches)) + { + $definition = strlen($matches[1]) ? $matches[1] : $Element['text']; + $definition = strtolower($definition); + + $extent += strlen($matches[0]); + } + else + { + $definition = strtolower($Element['text']); + } + + if ( ! isset($this->DefinitionData['Reference'][$definition])) + { + return; + } + + $Definition = $this->DefinitionData['Reference'][$definition]; + + $Element['attributes']['href'] = $Definition['url']; + $Element['attributes']['title'] = $Definition['title']; + } + + return array( + 'extent' => $extent, + 'element' => $Element, + ); + } + + protected function inlineMarkup($Excerpt) + { + if ($this->markupEscaped or $this->safeMode or strpos($Excerpt['text'], '>') === false) + { + return; + } + + if ($Excerpt['text'][1] === '/' and preg_match('/^<\/\w[\w-]*[ ]*>/s', $Excerpt['text'], $matches)) + { + return array( + 'markup' => $matches[0], + 'extent' => strlen($matches[0]), + ); + } + + if ($Excerpt['text'][1] === '!' and preg_match('/^/s', $Excerpt['text'], $matches)) + { + return array( + 'markup' => $matches[0], + 'extent' => strlen($matches[0]), + ); + } + + if ($Excerpt['text'][1] !== ' ' and preg_match('/^<\w[\w-]*(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*\/?>/s', $Excerpt['text'], $matches)) + { + return array( + 'markup' => $matches[0], + 'extent' => strlen($matches[0]), + ); + } + } + + protected function inlineSpecialCharacter($Excerpt) + { + if ($Excerpt['text'][0] === '&' and ! preg_match('/^&#?\w+;/', $Excerpt['text'])) + { + return array( + 'markup' => '&', + 'extent' => 1, + ); + } + + $SpecialCharacter = array('>' => 'gt', '<' => 'lt', '"' => 'quot'); + + if (isset($SpecialCharacter[$Excerpt['text'][0]])) + { + return array( + 'markup' => '&'.$SpecialCharacter[$Excerpt['text'][0]].';', + 'extent' => 1, + ); + } + } + + protected function inlineStrikethrough($Excerpt) + { + if ( ! isset($Excerpt['text'][1])) + { + return; + } + + if ($Excerpt['text'][1] === '~' and preg_match('/^~~(?=\S)(.+?)(?<=\S)~~/', $Excerpt['text'], $matches)) + { + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => 'del', + 'text' => $matches[1], + 'handler' => 'line', + ), + ); + } + } + + protected function inlineUrl($Excerpt) + { + if ($this->urlsLinked !== true or ! isset($Excerpt['text'][2]) or $Excerpt['text'][2] !== '/') + { + return; + } + + if (preg_match('/\bhttps?:[\/]{2}[^\s<]+\b\/*/ui', $Excerpt['context'], $matches, PREG_OFFSET_CAPTURE)) + { + $url = $matches[0][0]; + + $Inline = array( + 'extent' => strlen($matches[0][0]), + 'position' => $matches[0][1], + 'element' => array( + 'name' => 'a', + 'text' => $url, + 'attributes' => array( + 'href' => $url, + ), + ), + ); + + return $Inline; + } + } + + protected function inlineUrlTag($Excerpt) + { + if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<(\w+:\/{2}[^ >]+)>/i', $Excerpt['text'], $matches)) + { + $url = $matches[1]; + + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => 'a', + 'text' => $url, + 'attributes' => array( + 'href' => $url, + ), + ), + ); + } + } + + # ~ + + protected function unmarkedText($text) + { + if ($this->breaksEnabled) + { + $text = preg_replace('/[ ]*\n/', "
    \n", $text); + } + else + { + $text = preg_replace('/(?:[ ][ ]+|[ ]*\\\\)\n/', "
    \n", $text); + $text = str_replace(" \n", "\n", $text); + } + + return $text; + } + + # + # Handlers + # + + protected function element(array $Element) + { + if ($this->safeMode) + { + $Element = $this->sanitiseElement($Element); + } + + $markup = '<'.$Element['name']; + + if (isset($Element['attributes'])) + { + foreach ($Element['attributes'] as $name => $value) + { + if ($value === null) + { + continue; + } + + $markup .= ' '.$name.'="'.self::escape($value).'"'; + } + } + + $permitRawHtml = false; + + if (isset($Element['text'])) + { + $text = $Element['text']; + } + // very strongly consider an alternative if you're writing an + // extension + elseif (isset($Element['rawHtml'])) + { + $text = $Element['rawHtml']; + $allowRawHtmlInSafeMode = isset($Element['allowRawHtmlInSafeMode']) && $Element['allowRawHtmlInSafeMode']; + $permitRawHtml = !$this->safeMode || $allowRawHtmlInSafeMode; + } + + if (isset($text)) + { + $markup .= '>'; + + if (!isset($Element['nonNestables'])) + { + $Element['nonNestables'] = array(); + } + + if (isset($Element['handler'])) + { + $markup .= $this->{$Element['handler']}($text, $Element['nonNestables']); + } + elseif (!$permitRawHtml) + { + $markup .= self::escape($text, true); + } + else + { + $markup .= $text; + } + + $markup .= ''; + } + else + { + $markup .= ' />'; + } + + return $markup; + } + + protected function elements(array $Elements) + { + $markup = ''; + + foreach ($Elements as $Element) + { + $markup .= "\n" . $this->element($Element); + } + + $markup .= "\n"; + + return $markup; + } + + # ~ + + protected function li($lines) + { + $markup = $this->lines($lines); + + $trimmedMarkup = trim($markup); + + if ( ! in_array('', $lines) and substr($trimmedMarkup, 0, 3) === '

    ') + { + $markup = $trimmedMarkup; + $markup = substr($markup, 3); + + $position = strpos($markup, "

    "); + + $markup = substr_replace($markup, '', $position, 4); + } + + return $markup; + } + + # + # Deprecated Methods + # + + function parse($text) + { + $markup = $this->text($text); + + return $markup; + } + + protected function sanitiseElement(array $Element) + { + static $goodAttribute = '/^[a-zA-Z0-9][a-zA-Z0-9-_]*+$/'; + static $safeUrlNameToAtt = array( + 'a' => 'href', + 'img' => 'src', + ); + + if (isset($safeUrlNameToAtt[$Element['name']])) + { + $Element = $this->filterUnsafeUrlInAttribute($Element, $safeUrlNameToAtt[$Element['name']]); + } + + if ( ! empty($Element['attributes'])) + { + foreach ($Element['attributes'] as $att => $val) + { + # filter out badly parsed attribute + if ( ! preg_match($goodAttribute, $att)) + { + unset($Element['attributes'][$att]); + } + # dump onevent attribute + elseif (self::striAtStart($att, 'on')) + { + unset($Element['attributes'][$att]); + } + } + } + + return $Element; + } + + protected function filterUnsafeUrlInAttribute(array $Element, $attribute) + { + foreach ($this->safeLinksWhitelist as $scheme) + { + if (self::striAtStart($Element['attributes'][$attribute], $scheme)) + { + return $Element; + } + } + + $Element['attributes'][$attribute] = str_replace(':', '%3A', $Element['attributes'][$attribute]); + + return $Element; + } + + # + # Static Methods + # + + protected static function escape($text, $allowQuotes = false) + { + return htmlspecialchars($text, $allowQuotes ? ENT_NOQUOTES : ENT_QUOTES, 'UTF-8'); + } + + protected static function striAtStart($string, $needle) + { + $len = strlen($needle); + + if ($len > strlen($string)) + { + return false; + } + else + { + return strtolower(substr($string, 0, $len)) === strtolower($needle); + } + } + + static function instance($name = 'default') + { + if (isset(self::$instances[$name])) + { + return self::$instances[$name]; + } + + $instance = new static(); + + self::$instances[$name] = $instance; + + return $instance; + } + + private static $instances = array(); + + # + # Fields + # + + protected $DefinitionData; + + # + # Read-Only + + protected $specialCharacters = array( + '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '>', '#', '+', '-', '.', '!', '|', + ); + + protected $StrongRegex = array( + '*' => '/^[*]{2}((?:\\\\\*|[^*]|[*][^*]*[*])+?)[*]{2}(?![*])/s', + '_' => '/^__((?:\\\\_|[^_]|_[^_]*_)+?)__(?!_)/us', + ); + + protected $EmRegex = array( + '*' => '/^[*]((?:\\\\\*|[^*]|[*][*][^*]+?[*][*])+?)[*](?![*])/s', + '_' => '/^_((?:\\\\_|[^_]|__[^_]*__)+?)_(?!_)\b/us', + ); + + protected $regexHtmlAttribute = '[a-zA-Z_:][\w:.-]*(?:\s*=\s*(?:[^"\'=<>`\s]+|"[^"]*"|\'[^\']*\'))?'; + + protected $voidElements = array( + 'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', + ); + + protected $textLevelElements = array( + 'a', 'br', 'bdo', 'abbr', 'blink', 'nextid', 'acronym', 'basefont', + 'b', 'em', 'big', 'cite', 'small', 'spacer', 'listing', + 'i', 'rp', 'del', 'code', 'strike', 'marquee', + 'q', 'rt', 'ins', 'font', 'strong', + 's', 'tt', 'kbd', 'mark', + 'u', 'xm', 'sub', 'nobr', + 'sup', 'ruby', + 'var', 'span', + 'wbr', 'time', + ); +} diff --git a/SA-orchestration MD/verify-stamp.php b/SA-orchestration MD/verify-stamp.php new file mode 100644 index 0000000000000000000000000000000000000000..d39a48e76338578817f756692917dac186103b23 --- /dev/null +++ b/SA-orchestration MD/verify-stamp.php @@ -0,0 +1,139 @@ + + * + * + * markers. Inserts after the H1 if absent. + * + * Usage + * ----- + * php verify-stamp.php + * Defaults: --by=manual --env=local --at=now (UTC). + * + * php verify-stamp.php --by=test --env=local + * After running the regression suite locally. + * + * php verify-stamp.php --by=llm --env=local + * After running an LLM-backed evaluation. + * + * php verify-stamp.php --by=manual --env=staging + * After a manual smoke check on staging. + * + * php verify-stamp.php --at='2026-05-01 21:30 UTC' + * Override the timestamp explicitly. + * + * Exit codes + * ---------- + * 0 stamp updated + * 1 invalid argument + * 2 PLUGIN_STATUS.md not found / not writable + */ + +const STATUS_FILE = __DIR__ . DIRECTORY_SEPARATOR . 'PLUGIN_STATUS.md'; +const MARKER_START = ''; +const MARKER_END = ''; +const VALID_BY = [ 'manual', 'test', 'llm' ]; +const VALID_ENV = [ 'local', 'staging', 'prod' ]; +const BY_LABELS = [ + 'manual' => 'manual', + 'test' => 'test suite', + 'llm' => 'LLM eval', +]; + +// --- Parse args --- +$args = []; +foreach ( array_slice( $argv, 1 ) as $a ) { + if ( preg_match( '/^--([a-z]+)=(.+)$/i', $a, $m ) ) { + $args[ strtolower( $m[1] ) ] = $m[2]; + } elseif ( $a === '-h' || $a === '--help' ) { + echo file_get_contents( __FILE__, false, null, 0, 2400 ); + exit( 0 ); + } else { + fwrite( STDERR, "Unknown argument: $a\n Try --help.\n" ); + exit( 1 ); + } +} + +$by = strtolower( $args['by'] ?? 'manual' ); +$env = strtolower( $args['env'] ?? 'local' ); +$at = isset( $args['at'] ) ? trim( $args['at'] ) : ( gmdate( 'Y-m-d H:i' ) . ' UTC' ); + +if ( ! in_array( $by, VALID_BY, true ) ) { + fwrite( STDERR, "ERROR: --by must be one of: " . implode( ', ', VALID_BY ) . "\n" ); + exit( 1 ); +} +if ( ! in_array( $env, VALID_ENV, true ) ) { + fwrite( STDERR, "ERROR: --env must be one of: " . implode( ', ', VALID_ENV ) . "\n" ); + exit( 1 ); +} +$by_label = BY_LABELS[ $by ]; + +// --- Locate file --- +if ( ! file_exists( STATUS_FILE ) ) { + fwrite( STDERR, "ERROR: " . STATUS_FILE . " not found.\n" ); + exit( 2 ); +} +if ( ! is_writable( STATUS_FILE ) ) { + fwrite( STDERR, "ERROR: " . STATUS_FILE . " is not writable.\n" ); + exit( 2 ); +} + +$contents = file_get_contents( STATUS_FILE ); +if ( $contents === false ) { + fwrite( STDERR, "ERROR: could not read " . STATUS_FILE . ".\n" ); + exit( 2 ); +} + +// --- Build new stamp block --- +$stamp = + MARKER_START . "\n" + . "> **Verification stamp** — when the state recorded below was last confirmed against reality.\n" + . ">\n" + . "> - **Last Verified At**: $at\n" + . "> - **Verified By**: $by_label\n" + . "> - **Environment**: $env\n" + . ">\n" + . "> Update via the repo-root helper: `php verify-stamp.php [--by=manual|test|llm] [--env=local|staging|prod] [--at=]`. Manual confirmation only — no auto-stamping. See `verify-stamp.php` for usage.\n" + . MARKER_END; + +// --- Replace if present, else insert after H1 --- +$pattern = '/' . preg_quote( MARKER_START, '/' ) . '.*?' . preg_quote( MARKER_END, '/' ) . '/s'; +if ( preg_match( $pattern, $contents ) ) { + $new_contents = preg_replace( $pattern, $stamp, $contents ); + $action = 'updated'; +} else { + // Insert after first H1 + $new_contents = preg_replace( + '/^(# [^\n]+\n)/m', + "$1\n$stamp\n", + $contents, + 1, + $count + ); + if ( $count !== 1 ) { + fwrite( STDERR, "ERROR: could not locate H1 in " . STATUS_FILE . " to insert stamp.\n" ); + exit( 2 ); + } + $action = 'inserted'; +} + +if ( file_put_contents( STATUS_FILE, $new_contents ) === false ) { + fwrite( STDERR, "ERROR: could not write to " . STATUS_FILE . ".\n" ); + exit( 2 ); +} + +echo "Verification stamp $action in " . basename( STATUS_FILE ) . ":\n"; +echo " Last Verified At : $at\n"; +echo " Verified By : $by_label\n"; +echo " Environment : $env\n"; +exit( 0 );