Spaces:
Sleeping
Sleeping
Commit ·
f51c224
1
Parent(s): 0f71b56
reseed
Browse files- CLAUDE.md +3 -1
- ONBOARDING.md +15 -9
- README.md +14 -1
- sa-orchestration/README.md +33 -0
- sa-orchestration/assets/asterion-bubble.css +245 -0
- sa-orchestration/assets/asterion-bubble.js +237 -0
- sa-orchestration/assets/attention-bridge.js +263 -0
- sa-orchestration/assets/sara-bubble.css +304 -0
- sa-orchestration/assets/sara-bubble.js +560 -0
- sa-orchestration/includes/class-sa-acceptance.php +216 -0
- sa-orchestration/includes/class-sa-actor-admin.php +235 -0
- sa-orchestration/includes/class-sa-actor-handoff.php +134 -0
- sa-orchestration/includes/class-sa-actor-registry.php +192 -0
- sa-orchestration/includes/class-sa-adapter-fluent-boards.php +116 -0
- sa-orchestration/includes/class-sa-adapter-fluent-support.php +214 -0
- sa-orchestration/includes/class-sa-admin.php +957 -0
- sa-orchestration/includes/class-sa-arbitration.php +174 -0
- sa-orchestration/includes/class-sa-asterion-explorer.php +263 -0
- sa-orchestration/includes/class-sa-asterion-projection.php +478 -0
- sa-orchestration/includes/class-sa-asterion.php +82 -0
- sa-orchestration/includes/class-sa-attention-bridge.php +399 -0
- sa-orchestration/includes/class-sa-bridge-fluent.php +88 -0
- sa-orchestration/includes/class-sa-demand-registry.php +81 -0
- sa-orchestration/includes/class-sa-env.php +53 -0
- sa-orchestration/includes/class-sa-fluent-state.php +178 -0
- sa-orchestration/includes/class-sa-link.php +38 -0
- sa-orchestration/includes/class-sa-llm-provider-openai.php +171 -0
- sa-orchestration/includes/class-sa-llm-provider-simulated.php +217 -0
- sa-orchestration/includes/class-sa-llm-providers.php +67 -0
- sa-orchestration/includes/class-sa-llm-settings.php +62 -0
- sa-orchestration/includes/class-sa-migrations.php +161 -0
- sa-orchestration/includes/class-sa-projection-fluent-support.php +521 -0
- sa-orchestration/includes/class-sa-reasoning.php +334 -0
- sa-orchestration/includes/class-sa-rest.php +110 -0
- sa-orchestration/includes/class-sa-sara-admin.php +68 -0
- sa-orchestration/includes/class-sa-sara-rest.php +81 -0
- sa-orchestration/includes/class-sa-sara-service.php +418 -0
- sa-orchestration/includes/class-sa-seed-generator.php +523 -0
- sa-orchestration/includes/class-sa-strategy.php +83 -0
- sa-orchestration/includes/class-sa-token-log.php +114 -0
- sa-orchestration/includes/interface-sa-llm-provider.php +48 -0
- sa-orchestration/includes/interface-sa-surface-adapter.php +63 -0
- sa-orchestration/sa-orchestration.php +97 -0
- sa-orchestration/vendor/Parsedown.php +1712 -0
CLAUDE.md
CHANGED
|
@@ -44,8 +44,10 @@ This SleeperAgents-internal project (RcmEmailAutomation) sits at the SA-Orchestr
|
|
| 44 |
|
| 45 |
## ── REPO LAYOUT (root level) ────────────────────────────────────────────────
|
| 46 |
|
|
|
|
|
|
|
| 47 |
```
|
| 48 |
-
|
| 49 |
├── app/ ← Python FastAPI service (production target)
|
| 50 |
│ ├── main.py
|
| 51 |
│ ├── auth.py, config.py, database.py, email_processor.py
|
|
|
|
| 44 |
|
| 45 |
## ── REPO LAYOUT (root level) ────────────────────────────────────────────────
|
| 46 |
|
| 47 |
+
> Paths below are **relative to this repo root** — wherever the developer cloned it. Drive letters and absolute paths from the seeding machine do not apply. See `README.md` → "Path discipline" before assuming any path.
|
| 48 |
+
|
| 49 |
```
|
| 50 |
+
<repo-root>/ ← wherever the developer cloned RcmEmailAutomation
|
| 51 |
├── app/ ← Python FastAPI service (production target)
|
| 52 |
│ ├── main.py
|
| 53 |
│ ├── auth.py, config.py, database.py, email_processor.py
|
ONBOARDING.md
CHANGED
|
@@ -64,13 +64,14 @@ Run this audit before installing or running anything. Surface findings to the hu
|
|
| 64 |
|
| 65 |
### Audit checklist
|
| 66 |
|
| 67 |
-
1. **Local stack capability** — does this machine already have a local PHP + MySQL + WordPress capability?
|
| 68 |
-
- XAMPP
|
| 69 |
-
- Local by Flywheel
|
| 70 |
-
- Laragon
|
| 71 |
-
- MAMP
|
| 72 |
-
- Docker Desktop with WordPress containers
|
| 73 |
- Any custom Apache/Nginx + PHP-FPM + MySQL combination
|
|
|
|
| 74 |
|
| 75 |
2. **PHP version available** — `php --version` (target: 7.4+, 8.1+ preferred)
|
| 76 |
|
|
@@ -177,16 +178,21 @@ cp -r <this-repo>/asterion/* <wp-install>/wp-content/sa-asterion/
|
|
| 177 |
|
| 178 |
**B. Point the spec_root filter at this repo's `asterion/` directory directly** (no copy):
|
| 179 |
|
| 180 |
-
Drop a mu-plugin at `<wp-install>/wp-content/mu-plugins/sa-orch-spec-root.php`:
|
| 181 |
|
| 182 |
```php
|
| 183 |
<?php
|
| 184 |
add_filter( 'sa_orch_asterion_spec_root', function () {
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
| 186 |
}, 5 );
|
| 187 |
```
|
| 188 |
|
| 189 |
-
|
|
|
|
|
|
|
| 190 |
|
| 191 |
**Verify**: filesystem readable by the web user; `01-ontology.md` and `02-invariants.md` exist at the chosen location.
|
| 192 |
|
|
|
|
| 64 |
|
| 65 |
### Audit checklist
|
| 66 |
|
| 67 |
+
1. **Local stack capability** — does this machine already have a local PHP + MySQL + WordPress capability? **Where the developer's stack lives is a Phase 0 question** (see Phase 0 above) — do NOT assume a default install location. Common patterns the audit may discover:
|
| 68 |
+
- XAMPP (Windows: typically `C:\xampp`; sometimes installed on a different drive — confirm with the developer, never assume)
|
| 69 |
+
- Local by Flywheel (Windows: `C:\Users\<user>\Local Sites`; macOS: `~/Local Sites`)
|
| 70 |
+
- Laragon (Windows: typically `C:\laragon` — confirm)
|
| 71 |
+
- MAMP (macOS: `/Applications/MAMP`; Windows: `C:\MAMP`)
|
| 72 |
+
- Docker Desktop with WordPress containers (any path; ask)
|
| 73 |
- Any custom Apache/Nginx + PHP-FPM + MySQL combination
|
| 74 |
+
- **The developer may have none of the above** — JNO must respect that and use what they have or what they explicitly approve installing. Do not install on a drive or location the developer has not authorized.
|
| 75 |
|
| 76 |
2. **PHP version available** — `php --version` (target: 7.4+, 8.1+ preferred)
|
| 77 |
|
|
|
|
| 178 |
|
| 179 |
**B. Point the spec_root filter at this repo's `asterion/` directory directly** (no copy):
|
| 180 |
|
| 181 |
+
Drop a mu-plugin at `<wp-install>/wp-content/mu-plugins/sa-orch-spec-root.php`. The filter must return the **absolute path to this repo's `asterion/` directory on the developer's machine** — substitute `<repo-root>` with whatever the developer's actual clone path is (Phase 0 should have surfaced this; if not, ask):
|
| 182 |
|
| 183 |
```php
|
| 184 |
<?php
|
| 185 |
add_filter( 'sa_orch_asterion_spec_root', function () {
|
| 186 |
+
// Replace <repo-root> with the absolute path to where this repo lives
|
| 187 |
+
// on the developer's machine. DO NOT hardcode a drive letter or path
|
| 188 |
+
// that has not been confirmed by the developer.
|
| 189 |
+
return '<repo-root>/asterion';
|
| 190 |
}, 5 );
|
| 191 |
```
|
| 192 |
|
| 193 |
+
If the developer prefers POSIX-style forward slashes vs. Windows backslashes, follow their convention — WordPress accepts either, but consistency aids debugging. **Verify the path resolves** (`is_dir()` returns true) before trusting it; otherwise the Asterion Explorer will silently render an empty tree.
|
| 194 |
+
|
| 195 |
+
Choose A for cleanliness (corpus lives inside the WP install, no path-resolution surprises). Choose B for "single source of truth" (corpus stays in this repo, the WP install reads through). Either is acceptable for JNO. **Discuss the trade-off with the developer; they pick.**
|
| 196 |
|
| 197 |
**Verify**: filesystem readable by the web user; `01-ontology.md` and `02-invariants.md` exist at the chosen location.
|
| 198 |
|
README.md
CHANGED
|
@@ -32,7 +32,20 @@ CR ⊇ CA ⊇ JourneySeeker ⊇ MissionNet/MeshNet ⊇ SA-Orchestration ⊇ Bran
|
|
| 32 |
|
| 33 |
Causal Relativity, Causal Agentics, JourneySeeker, the Compass Doctrine, the Story Seed pattern, and the Quest-vs-Job distinction are pre-existing intellectual property authored by **Mark Holak**. They are preserved under Invariant **I9** of the SA-Orchestration spec pack. **You may reference these concepts. You may not re-author them as if they originated at the implementation layer or at the agent layer.** When you cite them, cite them.
|
| 34 |
|
| 35 |
-
The full canonical corpus lives at `asterion/` in this repo (copied from the SA-Orchestration source of truth
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
### Doctrine carry-forward (the operating discipline)
|
| 38 |
|
|
|
|
| 32 |
|
| 33 |
Causal Relativity, Causal Agentics, JourneySeeker, the Compass Doctrine, the Story Seed pattern, and the Quest-vs-Job distinction are pre-existing intellectual property authored by **Mark Holak**. They are preserved under Invariant **I9** of the SA-Orchestration spec pack. **You may reference these concepts. You may not re-author them as if they originated at the implementation layer or at the agent layer.** When you cite them, cite them.
|
| 34 |
|
| 35 |
+
The full canonical corpus lives at **`asterion/` in this repo** (copied here at seed time from the SA-Orchestration source of truth that lives in a separate SleeperAgents-internal repository — the absolute path on the seeding machine is not relevant to the developer's machine). Read it when you encounter a term you are uncertain about.
|
| 36 |
+
|
| 37 |
+
### Path discipline (read once; never violate)
|
| 38 |
+
|
| 39 |
+
This seed was authored on a different machine than the developer's. **Any absolute path that may appear in `ONBOARDING.md`, in this README, or in code excerpts is illustrative — drawn from the seeding machine — not canonical for the developer's environment.** Drive letters, root paths, and OS conventions on the seeding machine do not transfer.
|
| 40 |
+
|
| 41 |
+
Specifically:
|
| 42 |
+
|
| 43 |
+
- **Do not** install anything onto a drive letter, mount point, or path the developer has not declared.
|
| 44 |
+
- **Do not** assume any directory exists outside this repo's tree without verifying or asking.
|
| 45 |
+
- **Always** map paths through the developer's stated layout (Phase 0 surfaces this).
|
| 46 |
+
- **When in doubt about where something goes, ask.** The developer is the arbiter of their domain (see Phase 0 in `ONBOARDING.md`). Asking is cheaper than installing into the wrong place.
|
| 47 |
+
|
| 48 |
+
If the developer has not declared a drive layout in Phase 0, **ask explicitly before any install action**. Path-related questions are the kind that quietly pass when the agent assumes — and quietly destroy when the assumption is wrong.
|
| 49 |
|
| 50 |
### Doctrine carry-forward (the operating discipline)
|
| 51 |
|
sa-orchestration/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SA-Orchestration plugin (v0.1.0)
|
| 2 |
+
|
| 3 |
+
First slice of the SA-Core / MissionNet alignment layer above the Fluent stack.
|
| 4 |
+
|
| 5 |
+
## What it does
|
| 6 |
+
|
| 7 |
+
Records three kinds of canonical artifacts that Fluent does not natively carry:
|
| 8 |
+
|
| 9 |
+
| Artifact | Table | Concept |
|
| 10 |
+
|---|---|---|
|
| 11 |
+
| Arbitration record | `wp_sa_arbitration` | Manager-as-arbiter outcome (rationale, projection_type, demand_ref) |
|
| 12 |
+
| Demand → Plan linkage | `wp_sa_arbitration_plan_link` | Edges from an arbitration to its resulting Plan refs |
|
| 13 |
+
| Acceptance event chain | `wp_sa_acceptance_event` | Append-only events: result_claimed → acceptance_disputed → acceptance_affirmed |
|
| 14 |
+
|
| 15 |
+
Each row has a corresponding markdown file in the **Asterion ledger** at `wp-content/sa-asterion/`. **Asterion is canonical-at-write; the DB is cache.** If the file write fails, no DB row is created.
|
| 16 |
+
|
| 17 |
+
## REST endpoints (admin-only)
|
| 18 |
+
|
| 19 |
+
```
|
| 20 |
+
POST /wp-json/sa-orch/v1/arbitration
|
| 21 |
+
POST /wp-json/sa-orch/v1/acceptance
|
| 22 |
+
GET /wp-json/sa-orch/v1/demand/{surface}/{ref}/trace
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
All require `current_user_can('manage_options')`.
|
| 26 |
+
|
| 27 |
+
## Schema reference
|
| 28 |
+
|
| 29 |
+
See [SA-orchestration repo](../../../../../SleeperAgents/SA-orchestration) — `FLUENT_DATA_MAPPING.md` and `CORE_MODEL.md` for vocabulary.
|
| 30 |
+
|
| 31 |
+
## Status
|
| 32 |
+
|
| 33 |
+
v0.1.0 — minimum viable. No admin UI. No advanced features. Plugin code currently lives at `wp-content/plugins/sa-orchestration/`; canonical home should move to the SA-orchestration repo at a future milestone.
|
sa-orchestration/assets/asterion-bubble.css
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Asterion Projection — floating canonical-query button (Board #30) */
|
| 2 |
+
|
| 3 |
+
#sa-orch-asterion-root {
|
| 4 |
+
position: fixed;
|
| 5 |
+
top: 60px;
|
| 6 |
+
right: 24px;
|
| 7 |
+
z-index: 99998;
|
| 8 |
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
#sa-orch-asterion-button {
|
| 12 |
+
width: 48px;
|
| 13 |
+
height: 48px;
|
| 14 |
+
border-radius: 50%;
|
| 15 |
+
background: #1a1a24;
|
| 16 |
+
color: #e8e8ea;
|
| 17 |
+
border: 2px solid #4a4a55;
|
| 18 |
+
cursor: pointer;
|
| 19 |
+
display: flex;
|
| 20 |
+
align-items: center;
|
| 21 |
+
justify-content: center;
|
| 22 |
+
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
| 23 |
+
transition: all 0.15s ease;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
#sa-orch-asterion-button:hover {
|
| 27 |
+
background: #25252e;
|
| 28 |
+
border-color: #6a6a75;
|
| 29 |
+
transform: scale(1.05);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
.sa-orch-asterion-glyph {
|
| 33 |
+
font-size: 22px;
|
| 34 |
+
line-height: 1;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
#sa-orch-asterion-panel {
|
| 38 |
+
position: fixed;
|
| 39 |
+
top: 60px;
|
| 40 |
+
right: 80px;
|
| 41 |
+
width: 420px;
|
| 42 |
+
max-height: 70vh;
|
| 43 |
+
background: #15151c;
|
| 44 |
+
color: #e8e8ea;
|
| 45 |
+
border: 1px solid #3a3a45;
|
| 46 |
+
border-radius: 8px;
|
| 47 |
+
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
| 48 |
+
display: none;
|
| 49 |
+
flex-direction: column;
|
| 50 |
+
overflow: hidden;
|
| 51 |
+
font-size: 13px;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
#sa-orch-asterion-panel.open {
|
| 55 |
+
display: flex;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.sa-orch-asterion-header {
|
| 59 |
+
padding: 10px 14px;
|
| 60 |
+
background: #1f1f28;
|
| 61 |
+
border-bottom: 1px solid #3a3a45;
|
| 62 |
+
position: relative;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.sa-orch-asterion-title {
|
| 66 |
+
font-weight: 700;
|
| 67 |
+
font-size: 14px;
|
| 68 |
+
letter-spacing: 0.4px;
|
| 69 |
+
color: #e8e8ea;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.sa-orch-asterion-subtitle {
|
| 73 |
+
font-size: 11px;
|
| 74 |
+
color: #8a8a95;
|
| 75 |
+
margin-top: 2px;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
.sa-orch-asterion-close {
|
| 79 |
+
position: absolute;
|
| 80 |
+
top: 6px;
|
| 81 |
+
right: 8px;
|
| 82 |
+
background: transparent;
|
| 83 |
+
color: #8a8a95;
|
| 84 |
+
border: 0;
|
| 85 |
+
font-size: 20px;
|
| 86 |
+
line-height: 1;
|
| 87 |
+
cursor: pointer;
|
| 88 |
+
padding: 4px 8px;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.sa-orch-asterion-close:hover {
|
| 92 |
+
color: #e8e8ea;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
.sa-orch-asterion-context {
|
| 96 |
+
padding: 6px 14px;
|
| 97 |
+
background: #14141a;
|
| 98 |
+
border-bottom: 1px solid #2a2a33;
|
| 99 |
+
font-size: 11px;
|
| 100 |
+
color: #a0a0ad;
|
| 101 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.sa-orch-asterion-conv {
|
| 105 |
+
flex: 1;
|
| 106 |
+
overflow-y: auto;
|
| 107 |
+
padding: 10px 14px;
|
| 108 |
+
min-height: 80px;
|
| 109 |
+
max-height: 400px;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.sa-orch-asterion-hint {
|
| 113 |
+
color: #777783;
|
| 114 |
+
font-size: 12px;
|
| 115 |
+
font-style: italic;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.sa-orch-asterion-qa {
|
| 119 |
+
margin-bottom: 16px;
|
| 120 |
+
padding-bottom: 12px;
|
| 121 |
+
border-bottom: 1px dashed #2e2e38;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
.sa-orch-asterion-qa:last-child {
|
| 125 |
+
border-bottom: 0;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.sa-orch-asterion-q {
|
| 129 |
+
color: #c8c8d2;
|
| 130 |
+
font-weight: 600;
|
| 131 |
+
margin-bottom: 6px;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
.sa-orch-asterion-q::before {
|
| 135 |
+
content: "▸ ";
|
| 136 |
+
color: #6a6a75;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.sa-orch-asterion-a {
|
| 140 |
+
color: #e8e8ea;
|
| 141 |
+
line-height: 1.5;
|
| 142 |
+
white-space: pre-wrap;
|
| 143 |
+
margin-bottom: 8px;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.sa-orch-asterion-cites {
|
| 147 |
+
margin-top: 8px;
|
| 148 |
+
padding: 8px 10px;
|
| 149 |
+
background: #11111a;
|
| 150 |
+
border-left: 3px solid #4a4a55;
|
| 151 |
+
border-radius: 0 4px 4px 0;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.sa-orch-asterion-cites-label {
|
| 155 |
+
font-size: 10px;
|
| 156 |
+
color: #8a8a95;
|
| 157 |
+
letter-spacing: 1.2px;
|
| 158 |
+
margin-bottom: 6px;
|
| 159 |
+
font-weight: 700;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.sa-orch-asterion-cite {
|
| 163 |
+
margin-bottom: 8px;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
.sa-orch-asterion-cite:last-child {
|
| 167 |
+
margin-bottom: 0;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
.sa-orch-asterion-cite-file {
|
| 171 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
| 172 |
+
font-size: 11px;
|
| 173 |
+
color: #c8c8d2;
|
| 174 |
+
font-weight: 600;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.sa-orch-asterion-cite-excerpt {
|
| 178 |
+
font-size: 11.5px;
|
| 179 |
+
color: #a0a0ad;
|
| 180 |
+
margin-top: 2px;
|
| 181 |
+
padding-left: 12px;
|
| 182 |
+
line-height: 1.45;
|
| 183 |
+
white-space: pre-wrap;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.sa-orch-asterion-scope {
|
| 187 |
+
font-size: 10px;
|
| 188 |
+
color: #6a6a75;
|
| 189 |
+
margin-top: 6px;
|
| 190 |
+
text-align: right;
|
| 191 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
.sa-orch-asterion-input-row {
|
| 195 |
+
display: flex;
|
| 196 |
+
gap: 8px;
|
| 197 |
+
padding: 10px 14px;
|
| 198 |
+
border-top: 1px solid #2a2a33;
|
| 199 |
+
background: #1a1a22;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
.sa-orch-asterion-input {
|
| 203 |
+
flex: 1;
|
| 204 |
+
background: #0e0e14;
|
| 205 |
+
color: #e8e8ea;
|
| 206 |
+
border: 1px solid #3a3a45;
|
| 207 |
+
border-radius: 4px;
|
| 208 |
+
padding: 6px 8px;
|
| 209 |
+
font-family: inherit;
|
| 210 |
+
font-size: 12.5px;
|
| 211 |
+
resize: vertical;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
.sa-orch-asterion-input:focus {
|
| 215 |
+
outline: none;
|
| 216 |
+
border-color: #6a6a75;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
.sa-orch-asterion-send {
|
| 220 |
+
background: #2a2a35;
|
| 221 |
+
color: #e8e8ea;
|
| 222 |
+
border: 1px solid #4a4a55;
|
| 223 |
+
border-radius: 4px;
|
| 224 |
+
padding: 0 14px;
|
| 225 |
+
cursor: pointer;
|
| 226 |
+
font-weight: 600;
|
| 227 |
+
font-size: 12px;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
.sa-orch-asterion-send:hover:not(:disabled) {
|
| 231 |
+
background: #353541;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
.sa-orch-asterion-send:disabled {
|
| 235 |
+
opacity: 0.5;
|
| 236 |
+
cursor: not-allowed;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
.sa-orch-asterion-status {
|
| 240 |
+
padding: 4px 14px;
|
| 241 |
+
font-size: 11px;
|
| 242 |
+
color: #8a8a95;
|
| 243 |
+
min-height: 16px;
|
| 244 |
+
background: #15151c;
|
| 245 |
+
}
|
sa-orchestration/assets/asterion-bubble.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Asterion Projection — floating canonical-query button.
|
| 3 |
+
* Board #30.
|
| 4 |
+
*
|
| 5 |
+
* Sister surface to sara-bubble.js. Distinct identity:
|
| 6 |
+
* - top-right corner (Sara is bottom-right; Attention is bottom-left)
|
| 7 |
+
* - stone glyph (◾) for "south pole / foundation"
|
| 8 |
+
* - librarian tone, citation-grounded responses
|
| 9 |
+
* - reads URL ?file= when on Asterion explorer page (single-file scope)
|
| 10 |
+
* - no draft / no insert / no advice — only canonical answers
|
| 11 |
+
*
|
| 12 |
+
* No DB writes. No mutation. POST /sa-orch/v1/asterion/query round-trip only.
|
| 13 |
+
*/
|
| 14 |
+
(function () {
|
| 15 |
+
'use strict';
|
| 16 |
+
|
| 17 |
+
if (typeof window.SA_ORCH_ASTERION === 'undefined') return;
|
| 18 |
+
var cfg = window.SA_ORCH_ASTERION;
|
| 19 |
+
|
| 20 |
+
var root, button, panel, input, sendBtn, contextLabel, statusLine, answerEl, citationsEl;
|
| 21 |
+
var conversationContainer;
|
| 22 |
+
var isOpen = false;
|
| 23 |
+
var inFlight = false;
|
| 24 |
+
|
| 25 |
+
function createEl(tag, attrs, text) {
|
| 26 |
+
var el = document.createElement(tag);
|
| 27 |
+
if (attrs) {
|
| 28 |
+
for (var k in attrs) {
|
| 29 |
+
if (k === 'style') {
|
| 30 |
+
for (var s in attrs.style) el.style[s] = attrs.style[s];
|
| 31 |
+
} else if (k === 'class') {
|
| 32 |
+
el.className = attrs[k];
|
| 33 |
+
} else {
|
| 34 |
+
el.setAttribute(k, attrs[k]);
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
if (text !== undefined) el.textContent = text;
|
| 39 |
+
return el;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function getBoundFile() {
|
| 43 |
+
if (cfg.page !== 'sa-orchestration-asterion') return '';
|
| 44 |
+
if (typeof URLSearchParams === 'undefined') return '';
|
| 45 |
+
return new URLSearchParams(window.location.search).get('file') || '';
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function contextLabelText() {
|
| 49 |
+
var f = getBoundFile();
|
| 50 |
+
if (f) return 'canonical: ' + f;
|
| 51 |
+
if (cfg.page === 'sa-orchestration-asterion') return 'canonical: spec corpus (no file bound)';
|
| 52 |
+
return 'canonical: spec corpus';
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function buildUI() {
|
| 56 |
+
root = createEl('div', { id: 'sa-orch-asterion-root' });
|
| 57 |
+
|
| 58 |
+
button = createEl('button', {
|
| 59 |
+
id: 'sa-orch-asterion-button',
|
| 60 |
+
type: 'button',
|
| 61 |
+
'aria-label': 'Ask Asterion (canonical librarian)',
|
| 62 |
+
title: 'Ask Asterion'
|
| 63 |
+
});
|
| 64 |
+
button.innerHTML = '<span class="sa-orch-asterion-glyph">◾</span>';
|
| 65 |
+
button.addEventListener('click', toggle);
|
| 66 |
+
|
| 67 |
+
panel = createEl('div', { id: 'sa-orch-asterion-panel', 'aria-hidden': 'true' });
|
| 68 |
+
|
| 69 |
+
var header = createEl('div', { class: 'sa-orch-asterion-header' });
|
| 70 |
+
var title = createEl('div', { class: 'sa-orch-asterion-title' }, 'Asterion');
|
| 71 |
+
var subtitle = createEl('div', { class: 'sa-orch-asterion-subtitle' },
|
| 72 |
+
'canonical librarian · cite-only · no speculation');
|
| 73 |
+
var closeBtn = createEl('button', {
|
| 74 |
+
class: 'sa-orch-asterion-close',
|
| 75 |
+
type: 'button',
|
| 76 |
+
'aria-label': 'Close'
|
| 77 |
+
}, '×');
|
| 78 |
+
closeBtn.addEventListener('click', toggle);
|
| 79 |
+
header.appendChild(title);
|
| 80 |
+
header.appendChild(subtitle);
|
| 81 |
+
header.appendChild(closeBtn);
|
| 82 |
+
|
| 83 |
+
contextLabel = createEl('div', { class: 'sa-orch-asterion-context' }, contextLabelText());
|
| 84 |
+
|
| 85 |
+
conversationContainer = createEl('div', { class: 'sa-orch-asterion-conv' });
|
| 86 |
+
var hint = createEl('div', { class: 'sa-orch-asterion-hint' },
|
| 87 |
+
'Ask a question about the spec corpus. If the answer is not in the corpus, Asterion will say so.');
|
| 88 |
+
conversationContainer.appendChild(hint);
|
| 89 |
+
|
| 90 |
+
var inputRow = createEl('div', { class: 'sa-orch-asterion-input-row' });
|
| 91 |
+
input = createEl('textarea', {
|
| 92 |
+
class: 'sa-orch-asterion-input',
|
| 93 |
+
placeholder: 'Ask Asterion (e.g. "what is Truth Class?")',
|
| 94 |
+
rows: '2'
|
| 95 |
+
});
|
| 96 |
+
input.addEventListener('keydown', function (e) {
|
| 97 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 98 |
+
e.preventDefault();
|
| 99 |
+
send();
|
| 100 |
+
}
|
| 101 |
+
});
|
| 102 |
+
sendBtn = createEl('button', {
|
| 103 |
+
class: 'sa-orch-asterion-send',
|
| 104 |
+
type: 'button'
|
| 105 |
+
}, 'Ask');
|
| 106 |
+
sendBtn.addEventListener('click', send);
|
| 107 |
+
inputRow.appendChild(input);
|
| 108 |
+
inputRow.appendChild(sendBtn);
|
| 109 |
+
|
| 110 |
+
statusLine = createEl('div', { class: 'sa-orch-asterion-status' }, '');
|
| 111 |
+
|
| 112 |
+
panel.appendChild(header);
|
| 113 |
+
panel.appendChild(contextLabel);
|
| 114 |
+
panel.appendChild(conversationContainer);
|
| 115 |
+
panel.appendChild(inputRow);
|
| 116 |
+
panel.appendChild(statusLine);
|
| 117 |
+
|
| 118 |
+
root.appendChild(button);
|
| 119 |
+
root.appendChild(panel);
|
| 120 |
+
document.body.appendChild(root);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
function toggle() {
|
| 124 |
+
isOpen = !isOpen;
|
| 125 |
+
if (isOpen) {
|
| 126 |
+
panel.classList.add('open');
|
| 127 |
+
panel.setAttribute('aria-hidden', 'false');
|
| 128 |
+
contextLabel.textContent = contextLabelText();
|
| 129 |
+
setTimeout(function () { input.focus(); }, 50);
|
| 130 |
+
} else {
|
| 131 |
+
panel.classList.remove('open');
|
| 132 |
+
panel.setAttribute('aria-hidden', 'true');
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
function appendQA(question, result) {
|
| 137 |
+
var block = createEl('div', { class: 'sa-orch-asterion-qa' });
|
| 138 |
+
|
| 139 |
+
var q = createEl('div', { class: 'sa-orch-asterion-q' }, question);
|
| 140 |
+
block.appendChild(q);
|
| 141 |
+
|
| 142 |
+
var a = createEl('div', { class: 'sa-orch-asterion-a' });
|
| 143 |
+
a.textContent = (result && result.answer) ? result.answer : '(empty)';
|
| 144 |
+
block.appendChild(a);
|
| 145 |
+
|
| 146 |
+
if (result && Array.isArray(result.citations) && result.citations.length > 0) {
|
| 147 |
+
var cites = createEl('div', { class: 'sa-orch-asterion-cites' });
|
| 148 |
+
var lbl = createEl('div', { class: 'sa-orch-asterion-cites-label' }, 'CITATIONS');
|
| 149 |
+
cites.appendChild(lbl);
|
| 150 |
+
result.citations.forEach(function (c, i) {
|
| 151 |
+
var row = createEl('div', { class: 'sa-orch-asterion-cite' });
|
| 152 |
+
var fileEl = createEl('div', { class: 'sa-orch-asterion-cite-file' },
|
| 153 |
+
'[' + (i + 1) + '] ' + (c.file || ''));
|
| 154 |
+
var excEl = createEl('div', { class: 'sa-orch-asterion-cite-excerpt' },
|
| 155 |
+
c.excerpt || '');
|
| 156 |
+
row.appendChild(fileEl);
|
| 157 |
+
row.appendChild(excEl);
|
| 158 |
+
cites.appendChild(row);
|
| 159 |
+
});
|
| 160 |
+
block.appendChild(cites);
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
if (result && result.scope) {
|
| 164 |
+
var scope = createEl('div', { class: 'sa-orch-asterion-scope' },
|
| 165 |
+
'scope: ' + result.scope);
|
| 166 |
+
block.appendChild(scope);
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
// Clear hint on first response
|
| 170 |
+
var hint = conversationContainer.querySelector('.sa-orch-asterion-hint');
|
| 171 |
+
if (hint) hint.remove();
|
| 172 |
+
|
| 173 |
+
conversationContainer.appendChild(block);
|
| 174 |
+
conversationContainer.scrollTop = conversationContainer.scrollHeight;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
function send() {
|
| 178 |
+
if (inFlight) return;
|
| 179 |
+
var q = (input.value || '').trim();
|
| 180 |
+
if (!q) return;
|
| 181 |
+
|
| 182 |
+
inFlight = true;
|
| 183 |
+
sendBtn.disabled = true;
|
| 184 |
+
statusLine.textContent = 'querying canonical corpus…';
|
| 185 |
+
input.value = '';
|
| 186 |
+
|
| 187 |
+
var body = {
|
| 188 |
+
query: q,
|
| 189 |
+
asterion_file: getBoundFile()
|
| 190 |
+
};
|
| 191 |
+
|
| 192 |
+
fetch(cfg.rest_url, {
|
| 193 |
+
method: 'POST',
|
| 194 |
+
credentials: 'same-origin',
|
| 195 |
+
headers: {
|
| 196 |
+
'Content-Type': 'application/json',
|
| 197 |
+
'X-WP-Nonce': cfg.nonce
|
| 198 |
+
},
|
| 199 |
+
body: JSON.stringify(body)
|
| 200 |
+
})
|
| 201 |
+
.then(function (resp) {
|
| 202 |
+
return resp.json().then(function (data) {
|
| 203 |
+
return { ok: resp.ok, status: resp.status, data: data };
|
| 204 |
+
});
|
| 205 |
+
})
|
| 206 |
+
.then(function (r) {
|
| 207 |
+
if (!r.ok) {
|
| 208 |
+
appendQA(q, {
|
| 209 |
+
answer: 'Asterion error: ' + (r.data && r.data.message ? r.data.message : ('HTTP ' + r.status)),
|
| 210 |
+
citations: [],
|
| 211 |
+
scope: 'error'
|
| 212 |
+
});
|
| 213 |
+
} else {
|
| 214 |
+
appendQA(q, r.data);
|
| 215 |
+
}
|
| 216 |
+
})
|
| 217 |
+
.catch(function (err) {
|
| 218 |
+
appendQA(q, {
|
| 219 |
+
answer: 'Asterion network error: ' + (err && err.message ? err.message : err),
|
| 220 |
+
citations: [],
|
| 221 |
+
scope: 'error'
|
| 222 |
+
});
|
| 223 |
+
})
|
| 224 |
+
.then(function () {
|
| 225 |
+
inFlight = false;
|
| 226 |
+
sendBtn.disabled = false;
|
| 227 |
+
statusLine.textContent = '';
|
| 228 |
+
input.focus();
|
| 229 |
+
});
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
if (document.readyState === 'loading') {
|
| 233 |
+
document.addEventListener('DOMContentLoaded', buildUI);
|
| 234 |
+
} else {
|
| 235 |
+
buildUI();
|
| 236 |
+
}
|
| 237 |
+
})();
|
sa-orchestration/assets/attention-bridge.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Attention Bridge — emit-only frontend (Board #21)
|
| 3 |
+
*
|
| 4 |
+
* Floating button (bottom-left, distinct from Sara's bottom-right) that lets
|
| 5 |
+
* the admin user emit a structured attention packet to the configured
|
| 6 |
+
* oversight URL. User-triggered ONLY — no auto-emission. Confirms the
|
| 7 |
+
* payload before POSTing.
|
| 8 |
+
*
|
| 9 |
+
* Per locked doctrine:
|
| 10 |
+
* - Sara does NOT emit. This is its own surface.
|
| 11 |
+
* - User decides. Always.
|
| 12 |
+
* - The button label shows what would be emitted, so the user knows
|
| 13 |
+
* before they click.
|
| 14 |
+
*/
|
| 15 |
+
(function () {
|
| 16 |
+
'use strict';
|
| 17 |
+
|
| 18 |
+
if (typeof window.SA_ORCH_ATTENTION === 'undefined') return;
|
| 19 |
+
var cfg = window.SA_ORCH_ATTENTION;
|
| 20 |
+
|
| 21 |
+
// ----- Context resolver (parallel to Sara's, kept independent) -----
|
| 22 |
+
var REF_PATTERNS = {
|
| 23 |
+
'fluent-support': /^ticket#\d+$/,
|
| 24 |
+
'fluent-boards': /^task#\d+$/,
|
| 25 |
+
'fluentcrm': /^subscriber#\d+$/,
|
| 26 |
+
'derived': /^derived#[A-Za-z0-9_\-]+$/
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
function isValidSurfaceRef(surface, ref) {
|
| 30 |
+
if (!surface || !ref) return false;
|
| 31 |
+
if (!REF_PATTERNS.hasOwnProperty(surface)) return false;
|
| 32 |
+
return REF_PATTERNS[surface].test(ref);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
function resolveContext() {
|
| 36 |
+
var page = cfg.page || '';
|
| 37 |
+
var hash = window.location.hash || '';
|
| 38 |
+
|
| 39 |
+
if (page === 'fluent-boards') {
|
| 40 |
+
var m = hash.match(/^#\/boards\/\d+\/tasks\/(\d+)/);
|
| 41 |
+
if (m) return { surface: 'fluent-boards', ref: 'task#' + m[1] };
|
| 42 |
+
}
|
| 43 |
+
if (page === 'fluent-support') {
|
| 44 |
+
var m2 = hash.match(/^#\/tickets\/(\d+)/);
|
| 45 |
+
if (m2) return { surface: 'fluent-support', ref: 'ticket#' + m2[1] };
|
| 46 |
+
}
|
| 47 |
+
if (page && page.indexOf('sa-orchestration') === 0 && typeof URLSearchParams !== 'undefined') {
|
| 48 |
+
var params = new URLSearchParams(window.location.search);
|
| 49 |
+
var s = params.get('demand_surface');
|
| 50 |
+
var r = params.get('demand_ref');
|
| 51 |
+
if (s && r && isValidSurfaceRef(s, r)) return { surface: s, ref: r };
|
| 52 |
+
}
|
| 53 |
+
return null;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
// ----- DOM -----
|
| 57 |
+
var root, button, devButton, status;
|
| 58 |
+
|
| 59 |
+
function build() {
|
| 60 |
+
root = document.createElement('div');
|
| 61 |
+
root.id = 'sa-orch-attention-root';
|
| 62 |
+
root.setAttribute('style',
|
| 63 |
+
'position:fixed;bottom:24px;left:24px;z-index:99998;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;display:flex;flex-direction:column;align-items:flex-start;gap:6px;'
|
| 64 |
+
);
|
| 65 |
+
|
| 66 |
+
button = document.createElement('button');
|
| 67 |
+
button.id = 'sa-orch-attention-button';
|
| 68 |
+
button.type = 'button';
|
| 69 |
+
button.setAttribute('style',
|
| 70 |
+
'background:#2271b1;color:#fff;border:none;padding:10px 14px;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600;box-shadow:0 2px 8px rgba(0,0,0,0.18);display:flex;align-items:center;gap:8px;'
|
| 71 |
+
);
|
| 72 |
+
button.addEventListener('mouseenter', function () { button.style.background = '#135e96'; });
|
| 73 |
+
button.addEventListener('mouseleave', function () { button.style.background = '#2271b1'; });
|
| 74 |
+
button.addEventListener('click', function () { onSendClick('default'); });
|
| 75 |
+
|
| 76 |
+
// Board #21 dev verification — second button only renders when
|
| 77 |
+
// SA_Orch_Env::is_local_or_dev() returned true on the server side.
|
| 78 |
+
// Production never sees this flag, so this button never appears in prod.
|
| 79 |
+
if (cfg.is_dev) {
|
| 80 |
+
devButton = document.createElement('button');
|
| 81 |
+
devButton.id = 'sa-orch-attention-dev-mock-button';
|
| 82 |
+
devButton.type = 'button';
|
| 83 |
+
devButton.setAttribute('style',
|
| 84 |
+
'background:#fff;color:#2271b1;border:1px dashed #2271b1;padding:6px 10px;border-radius:6px;cursor:pointer;font-size:11px;font-weight:600;display:flex;align-items:center;gap:6px;opacity:0.95;'
|
| 85 |
+
);
|
| 86 |
+
devButton.textContent = '\u{1F9EA} Dev: Send to mock receiver (200)';
|
| 87 |
+
devButton.title = 'Dev-only — emits to a local mock REST endpoint that returns HTTP 200. Verifies the success path without a real oversight site.';
|
| 88 |
+
devButton.addEventListener('click', function () { onSendClick('success_mock'); });
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
status = document.createElement('div');
|
| 92 |
+
status.id = 'sa-orch-attention-status';
|
| 93 |
+
status.setAttribute('style',
|
| 94 |
+
'max-width:380px;padding:8px 12px;border-radius:4px;font-size:12px;display:none;'
|
| 95 |
+
);
|
| 96 |
+
|
| 97 |
+
root.appendChild(button);
|
| 98 |
+
if (devButton) root.appendChild(devButton);
|
| 99 |
+
root.appendChild(status);
|
| 100 |
+
document.body.appendChild(root);
|
| 101 |
+
|
| 102 |
+
updateLabel();
|
| 103 |
+
|
| 104 |
+
// Surface reliability — Board #24 parallel safety.
|
| 105 |
+
// Re-mount + label refresh on either SPA navigation event. Identical
|
| 106 |
+
// pattern to Sara's bubble; no Attention behavior change beyond
|
| 107 |
+
// ensuring the button stays present and its label tracks context.
|
| 108 |
+
window.addEventListener('hashchange', function () {
|
| 109 |
+
ensureMounted();
|
| 110 |
+
updateLabel();
|
| 111 |
+
});
|
| 112 |
+
window.addEventListener('popstate', function () {
|
| 113 |
+
ensureMounted();
|
| 114 |
+
updateLabel();
|
| 115 |
+
});
|
| 116 |
+
ensureMounted();
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
// Re-attach Attention's root to document.body if SPA navigation removed it.
|
| 120 |
+
function ensureMounted() {
|
| 121 |
+
if (root && !document.body.contains(root)) {
|
| 122 |
+
document.body.appendChild(root);
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
function updateLabel() {
|
| 127 |
+
var ctx = resolveContext();
|
| 128 |
+
var dot = '\u{1F4E1}'; // 📡
|
| 129 |
+
if (ctx) {
|
| 130 |
+
button.textContent = dot + ' Send Attention: ' + ctx.surface + ' / ' + ctx.ref;
|
| 131 |
+
} else {
|
| 132 |
+
var pageLabel = cfg.page ? cfg.page : 'page';
|
| 133 |
+
button.textContent = dot + ' Send Attention: ' + pageLabel + ' (no item bound)';
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
function showStatus(msg, kind) {
|
| 138 |
+
// kind: 'ok' | 'err' | 'info'
|
| 139 |
+
var bg = '#f0f0f1', color = '#1d2327', border = '#c3c4c7';
|
| 140 |
+
if (kind === 'ok') { bg = '#edf7ed'; color = '#1e4620'; border = '#5fb56f'; }
|
| 141 |
+
if (kind === 'err') { bg = '#fcf0f1'; color = '#7a1f23'; border = '#d63638'; }
|
| 142 |
+
status.style.background = bg;
|
| 143 |
+
status.style.color = color;
|
| 144 |
+
status.style.border = '1px solid ' + border;
|
| 145 |
+
status.textContent = msg;
|
| 146 |
+
status.style.display = 'block';
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
function clearStatus() {
|
| 150 |
+
status.style.display = 'none';
|
| 151 |
+
status.textContent = '';
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
function onSendClick(mode) {
|
| 155 |
+
// mode: 'default' (configured / dev failure scaffold) | 'success_mock' (dev-only)
|
| 156 |
+
mode = mode || 'default';
|
| 157 |
+
var modeLabel = mode === 'success_mock' ? 'dev success mock' : 'configured';
|
| 158 |
+
|
| 159 |
+
if (!cfg.configured) {
|
| 160 |
+
showStatus(
|
| 161 |
+
'Attention Bridge is not configured. Define SA_SITE_ID, SA_ATTENTION_TARGET_URL, and SA_ATTENTION_TOKEN in wp-config.php.',
|
| 162 |
+
'err'
|
| 163 |
+
);
|
| 164 |
+
return;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
var ctx = resolveContext();
|
| 168 |
+
var payload = {
|
| 169 |
+
site_id: cfg.site_id || '(unset)',
|
| 170 |
+
page: cfg.page || null,
|
| 171 |
+
surface: ctx ? ctx.surface : null,
|
| 172 |
+
ref: ctx ? ctx.ref : null
|
| 173 |
+
};
|
| 174 |
+
|
| 175 |
+
var modeLine = mode === 'success_mock'
|
| 176 |
+
? 'Mode: dev success mock (HTTP 200 expected — local mock receiver)\n\n'
|
| 177 |
+
: '';
|
| 178 |
+
var preview = modeLine + 'Emit this attention packet?\n\n' + JSON.stringify(payload, null, 2);
|
| 179 |
+
if (!window.confirm(preview)) {
|
| 180 |
+
return;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
var clickedButton = mode === 'success_mock' && devButton ? devButton : button;
|
| 184 |
+
var originalText = clickedButton.textContent;
|
| 185 |
+
button.disabled = true;
|
| 186 |
+
if (devButton) devButton.disabled = true;
|
| 187 |
+
clickedButton.textContent = '\u{1F4E1} Sending…';
|
| 188 |
+
clearStatus();
|
| 189 |
+
|
| 190 |
+
var body = {
|
| 191 |
+
page: cfg.page || '',
|
| 192 |
+
mode: mode
|
| 193 |
+
};
|
| 194 |
+
if (ctx) {
|
| 195 |
+
body.surface = ctx.surface;
|
| 196 |
+
body.ref = ctx.ref;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
fetch(cfg.rest_url, {
|
| 200 |
+
method: 'POST',
|
| 201 |
+
credentials: 'same-origin',
|
| 202 |
+
headers: {
|
| 203 |
+
'Content-Type': 'application/json',
|
| 204 |
+
'X-WP-Nonce': cfg.nonce
|
| 205 |
+
},
|
| 206 |
+
body: JSON.stringify(body)
|
| 207 |
+
})
|
| 208 |
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, json: j }; }); })
|
| 209 |
+
.then(function (result) {
|
| 210 |
+
button.disabled = false;
|
| 211 |
+
if (devButton) devButton.disabled = false;
|
| 212 |
+
updateLabel();
|
| 213 |
+
if (devButton && clickedButton === devButton) {
|
| 214 |
+
devButton.textContent = '\u{1F9EA} Dev: Send to mock receiver (200)';
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
// Use the server-reported mode_label when present (server is the source
|
| 218 |
+
// of truth — production silently downgrades success_mock to configured).
|
| 219 |
+
var serverLabel = (result.json && result.json.mode_label) || modeLabel;
|
| 220 |
+
var modeAnnot = ' [' + serverLabel + ']';
|
| 221 |
+
|
| 222 |
+
// Distinguish "attempt was made" from "receiver accepted it".
|
| 223 |
+
// status semantics (Board #21 rev2):
|
| 224 |
+
// success — HTTP 2xx received; receiver accepted the packet
|
| 225 |
+
// failed — transport error OR non-2xx HTTP; packet did not arrive cleanly
|
| 226 |
+
// (no log row at all if config is missing — error returned before any I/O)
|
| 227 |
+
if (result.ok && result.json && result.json.ok && result.json.status === 'success') {
|
| 228 |
+
showStatus(
|
| 229 |
+
'status=success' + modeAnnot + ' — HTTP ' + result.json.http_status + '. Receiver accepted the packet. Log id #' + result.json.log_id + '.',
|
| 230 |
+
'ok'
|
| 231 |
+
);
|
| 232 |
+
} else if (result.json && result.json.code === 'attention_config_missing') {
|
| 233 |
+
// No log row created in this case (validation precedes emission).
|
| 234 |
+
showStatus(result.json.message || 'Configuration missing.', 'err');
|
| 235 |
+
} else if (result.json && result.json.status === 'failed' && result.json.error_message) {
|
| 236 |
+
showStatus(
|
| 237 |
+
'status=failed' + modeAnnot + ' — transport error: ' + result.json.error_message + ' (log id #' + result.json.log_id + '). The packet was NOT received.',
|
| 238 |
+
'err'
|
| 239 |
+
);
|
| 240 |
+
} else if (result.json && result.json.status === 'failed' && result.json.http_status) {
|
| 241 |
+
showStatus(
|
| 242 |
+
'status=failed' + modeAnnot + ' — receiver returned HTTP ' + result.json.http_status + ' (log id #' + result.json.log_id + '). The packet was NOT accepted.',
|
| 243 |
+
'err'
|
| 244 |
+
);
|
| 245 |
+
} else {
|
| 246 |
+
var msg = (result.json && (result.json.message || JSON.stringify(result.json))) || 'unknown error';
|
| 247 |
+
showStatus('Emit failed' + modeAnnot + ': ' + msg, 'err');
|
| 248 |
+
}
|
| 249 |
+
})
|
| 250 |
+
.catch(function (err) {
|
| 251 |
+
button.disabled = false;
|
| 252 |
+
if (devButton) devButton.disabled = false;
|
| 253 |
+
clickedButton.textContent = originalText;
|
| 254 |
+
showStatus('Network error: ' + (err && err.message ? err.message : String(err)), 'err');
|
| 255 |
+
});
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
if (document.readyState === 'loading') {
|
| 259 |
+
document.addEventListener('DOMContentLoaded', build);
|
| 260 |
+
} else {
|
| 261 |
+
build();
|
| 262 |
+
}
|
| 263 |
+
})();
|
sa-orchestration/assets/sara-bubble.css
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Sara — Invocation + Context Resolver + Response Layer
|
| 2 |
+
Conversation-style bubble: persistent panel size, scrolling thread,
|
| 3 |
+
never resets between prompts. Read-only against the system; outputs
|
| 4 |
+
are non-binding drafts.
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
#sa-orch-sara-root {
|
| 8 |
+
position: fixed;
|
| 9 |
+
bottom: 20px;
|
| 10 |
+
right: 20px;
|
| 11 |
+
z-index: 99999;
|
| 12 |
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
#sa-orch-sara-button {
|
| 16 |
+
background: #1d2327;
|
| 17 |
+
color: #fff;
|
| 18 |
+
border: none;
|
| 19 |
+
border-radius: 50px;
|
| 20 |
+
padding: 12px 20px;
|
| 21 |
+
cursor: pointer;
|
| 22 |
+
font-size: 14px;
|
| 23 |
+
font-weight: 600;
|
| 24 |
+
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
|
| 25 |
+
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
#sa-orch-sara-button:hover {
|
| 29 |
+
transform: translateY(-1px);
|
| 30 |
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
#sa-orch-sara-button:focus {
|
| 34 |
+
outline: 2px solid #2271b1;
|
| 35 |
+
outline-offset: 2px;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/* Panel: fixed dimensions; doesn't shrink/grow with content. Inner
|
| 39 |
+
conversation area is what scrolls. */
|
| 40 |
+
#sa-orch-sara-panel {
|
| 41 |
+
display: none;
|
| 42 |
+
position: absolute;
|
| 43 |
+
bottom: 56px;
|
| 44 |
+
right: 0;
|
| 45 |
+
width: 440px;
|
| 46 |
+
height: 600px;
|
| 47 |
+
background: #fff;
|
| 48 |
+
border: 1px solid #c3c4c7;
|
| 49 |
+
border-radius: 6px;
|
| 50 |
+
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
|
| 51 |
+
padding: 12px;
|
| 52 |
+
box-sizing: border-box;
|
| 53 |
+
flex-direction: column;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
#sa-orch-sara-panel.open {
|
| 57 |
+
display: flex;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.sa-orch-sara-header {
|
| 61 |
+
font-size: 14px;
|
| 62 |
+
margin-bottom: 6px;
|
| 63 |
+
color: #1d2327;
|
| 64 |
+
flex-shrink: 0;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
.sa-orch-sara-tag {
|
| 68 |
+
font-size: 11px;
|
| 69 |
+
color: #50575e;
|
| 70 |
+
background: #f0f0f1;
|
| 71 |
+
padding: 1px 6px;
|
| 72 |
+
border-radius: 3px;
|
| 73 |
+
margin-left: 6px;
|
| 74 |
+
font-weight: normal;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.sa-orch-sara-context {
|
| 78 |
+
font-size: 11px;
|
| 79 |
+
color: #50575e;
|
| 80 |
+
margin-bottom: 8px;
|
| 81 |
+
padding: 4px 8px;
|
| 82 |
+
background: #f6f7f7;
|
| 83 |
+
border-radius: 3px;
|
| 84 |
+
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
|
| 85 |
+
flex-shrink: 0;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
/* Conversation thread: takes remaining space, scrolls */
|
| 89 |
+
.sa-orch-sara-conversation {
|
| 90 |
+
flex: 1 1 auto;
|
| 91 |
+
overflow-y: auto;
|
| 92 |
+
border: 1px solid #f0f0f1;
|
| 93 |
+
border-radius: 3px;
|
| 94 |
+
padding: 8px;
|
| 95 |
+
background: #fafbfc;
|
| 96 |
+
margin-bottom: 8px;
|
| 97 |
+
min-height: 0; /* lets flex item shrink properly inside flex column */
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
.sa-orch-sara-conversation:empty::before {
|
| 101 |
+
content: 'No conversation yet. Type a prompt below to start.';
|
| 102 |
+
color: #8c8f94;
|
| 103 |
+
font-size: 12px;
|
| 104 |
+
font-style: italic;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
/* A turn = user message + Sara response */
|
| 108 |
+
.sa-orch-sara-turn {
|
| 109 |
+
margin-bottom: 14px;
|
| 110 |
+
padding-bottom: 14px;
|
| 111 |
+
border-bottom: 1px dashed #dcdcde;
|
| 112 |
+
}
|
| 113 |
+
.sa-orch-sara-turn:last-child {
|
| 114 |
+
border-bottom: none;
|
| 115 |
+
margin-bottom: 0;
|
| 116 |
+
padding-bottom: 0;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.sa-orch-sara-msg-label {
|
| 120 |
+
font-size: 10px;
|
| 121 |
+
font-weight: 600;
|
| 122 |
+
text-transform: uppercase;
|
| 123 |
+
letter-spacing: 0.06em;
|
| 124 |
+
color: #50575e;
|
| 125 |
+
margin-bottom: 3px;
|
| 126 |
+
display: flex;
|
| 127 |
+
align-items: center;
|
| 128 |
+
gap: 6px;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
.sa-orch-sara-not-saved {
|
| 132 |
+
background: #fff2c2;
|
| 133 |
+
color: #7a5b00;
|
| 134 |
+
border: 1px solid #c9a300;
|
| 135 |
+
padding: 1px 6px;
|
| 136 |
+
border-radius: 3px;
|
| 137 |
+
font-size: 9px;
|
| 138 |
+
font-weight: 600;
|
| 139 |
+
text-transform: uppercase;
|
| 140 |
+
letter-spacing: 0.04em;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.sa-orch-sara-user-msg {
|
| 144 |
+
margin-bottom: 8px;
|
| 145 |
+
padding: 6px 10px;
|
| 146 |
+
background: #eef4fa;
|
| 147 |
+
border-left: 3px solid #2271b1;
|
| 148 |
+
border-radius: 3px;
|
| 149 |
+
}
|
| 150 |
+
.sa-orch-sara-user-msg .sa-orch-sara-msg-body {
|
| 151 |
+
font-size: 13px;
|
| 152 |
+
color: #1d2327;
|
| 153 |
+
white-space: pre-wrap;
|
| 154 |
+
user-select: text;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.sa-orch-sara-assistant-msg {
|
| 158 |
+
padding: 0;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
.sa-orch-sara-section {
|
| 162 |
+
margin-bottom: 6px;
|
| 163 |
+
}
|
| 164 |
+
.sa-orch-sara-section:last-child {
|
| 165 |
+
margin-bottom: 0;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.sa-orch-sara-section-header {
|
| 169 |
+
font-size: 10px;
|
| 170 |
+
font-weight: 600;
|
| 171 |
+
text-transform: uppercase;
|
| 172 |
+
letter-spacing: 0.06em;
|
| 173 |
+
color: #50575e;
|
| 174 |
+
margin-bottom: 2px;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.sa-orch-sara-section-body {
|
| 178 |
+
padding: 6px 10px;
|
| 179 |
+
background: #f6f7f7;
|
| 180 |
+
border-left: 3px solid #1d2327;
|
| 181 |
+
border-radius: 3px;
|
| 182 |
+
font-size: 13px;
|
| 183 |
+
line-height: 1.45;
|
| 184 |
+
white-space: pre-wrap;
|
| 185 |
+
color: #1d2327;
|
| 186 |
+
user-select: text;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
/* Notes section (last in a Sara block) gets a different accent */
|
| 190 |
+
.sa-orch-sara-assistant-msg .sa-orch-sara-section:last-child .sa-orch-sara-section-body {
|
| 191 |
+
border-left-color: #c9a300;
|
| 192 |
+
background: #fcf9e8;
|
| 193 |
+
font-size: 12px;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
#sa-orch-sara-panel textarea {
|
| 197 |
+
width: 100%;
|
| 198 |
+
padding: 8px;
|
| 199 |
+
border: 1px solid #c3c4c7;
|
| 200 |
+
border-radius: 3px;
|
| 201 |
+
font-size: 13px;
|
| 202 |
+
font-family: inherit;
|
| 203 |
+
box-sizing: border-box;
|
| 204 |
+
resize: vertical;
|
| 205 |
+
flex-shrink: 0;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
.sa-orch-sara-send {
|
| 209 |
+
margin-top: 6px;
|
| 210 |
+
background: #2271b1;
|
| 211 |
+
color: #fff;
|
| 212 |
+
border: none;
|
| 213 |
+
border-radius: 3px;
|
| 214 |
+
padding: 6px 14px;
|
| 215 |
+
cursor: pointer;
|
| 216 |
+
font-size: 13px;
|
| 217 |
+
flex-shrink: 0;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
.sa-orch-sara-send:hover {
|
| 221 |
+
background: #135e96;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
.sa-orch-sara-send:disabled {
|
| 225 |
+
background: #8c8f94;
|
| 226 |
+
cursor: not-allowed;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
.sa-orch-sara-status {
|
| 230 |
+
margin-top: 6px;
|
| 231 |
+
padding: 6px 10px;
|
| 232 |
+
background: #fcf9e8;
|
| 233 |
+
border-left: 3px solid #c9a300;
|
| 234 |
+
border-radius: 3px;
|
| 235 |
+
font-size: 11px;
|
| 236 |
+
color: #50575e;
|
| 237 |
+
font-style: italic;
|
| 238 |
+
flex-shrink: 0;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.sa-orch-sara-actions {
|
| 242 |
+
margin-top: 6px;
|
| 243 |
+
display: flex;
|
| 244 |
+
gap: 6px;
|
| 245 |
+
flex-wrap: wrap;
|
| 246 |
+
flex-shrink: 0;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
.sa-orch-sara-actions button {
|
| 250 |
+
background: #f6f7f7;
|
| 251 |
+
color: #1d2327;
|
| 252 |
+
border: 1px solid #c3c4c7;
|
| 253 |
+
border-radius: 3px;
|
| 254 |
+
padding: 4px 10px;
|
| 255 |
+
cursor: pointer;
|
| 256 |
+
font-size: 12px;
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
.sa-orch-sara-actions button:hover {
|
| 260 |
+
background: #fff;
|
| 261 |
+
border-color: #8c8f94;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
/* Board #15 — Draft Assist: warning + inline insert message */
|
| 265 |
+
.sa-orch-sara-insert-warning {
|
| 266 |
+
margin-top: 6px;
|
| 267 |
+
padding: 5px 10px;
|
| 268 |
+
background: #fcf9e8;
|
| 269 |
+
border-left: 3px solid #c9a300;
|
| 270 |
+
border-radius: 3px;
|
| 271 |
+
font-size: 11px;
|
| 272 |
+
color: #7a5b00;
|
| 273 |
+
flex-shrink: 0;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
.sa-orch-sara-insert {
|
| 277 |
+
background: #1d2327 !important;
|
| 278 |
+
color: #fff !important;
|
| 279 |
+
border-color: #1d2327 !important;
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
.sa-orch-sara-insert:hover {
|
| 283 |
+
background: #2c3338 !important;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.sa-orch-sara-insert-message {
|
| 287 |
+
margin-top: 4px;
|
| 288 |
+
padding: 4px 10px;
|
| 289 |
+
background: #f6f7f7;
|
| 290 |
+
border-radius: 3px;
|
| 291 |
+
font-size: 11px;
|
| 292 |
+
color: #50575e;
|
| 293 |
+
font-style: italic;
|
| 294 |
+
flex-shrink: 0;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
.sa-orch-sara-usage {
|
| 298 |
+
margin-top: 8px;
|
| 299 |
+
padding-top: 6px;
|
| 300 |
+
border-top: 1px dashed #dcdcde;
|
| 301 |
+
font-size: 11px;
|
| 302 |
+
color: #646970;
|
| 303 |
+
font-family: Consolas, Monaco, monospace;
|
| 304 |
+
}
|
sa-orchestration/assets/sara-bubble.js
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Sara — Invocation + Context Resolver + Response Layer
|
| 3 |
+
* (Boards #12, #13, #14)
|
| 4 |
+
*
|
| 5 |
+
* Floating button + panel. Detects surface + ref from URL hash. Maintains
|
| 6 |
+
* a session-local conversation history (JS memory only — gone on page
|
| 7 |
+
* reload; never persisted to DB or Asterion). Renders responses as a
|
| 8 |
+
* scrolling conversation, not a single output that resets per turn.
|
| 9 |
+
*
|
| 10 |
+
* Per the locked Control Rule:
|
| 11 |
+
* Sara outputs are non-binding.
|
| 12 |
+
* Only human-initiated actions trigger writes.
|
| 13 |
+
*
|
| 14 |
+
* Per Acceptance Boundary Rule:
|
| 15 |
+
* No DB writes. No Asterion writes. No demand registration. No auto-post.
|
| 16 |
+
*
|
| 17 |
+
* Insert is intentionally NOT in this Board — it ships in Board #15
|
| 18 |
+
* (Draft Assist).
|
| 19 |
+
*/
|
| 20 |
+
(function () {
|
| 21 |
+
'use strict';
|
| 22 |
+
|
| 23 |
+
if (typeof window.SA_ORCH_SARA === 'undefined') return;
|
| 24 |
+
var cfg = window.SA_ORCH_SARA;
|
| 25 |
+
|
| 26 |
+
// ----- Session-local state -----
|
| 27 |
+
// Conversation history: closed turns only (each user prompt followed by
|
| 28 |
+
// an assistant response). Lives in module memory; resets on page reload.
|
| 29 |
+
// Not persisted anywhere.
|
| 30 |
+
var conversationHistory = [];
|
| 31 |
+
var lastDraft = '';
|
| 32 |
+
|
| 33 |
+
// Token usage counters (Board #16). Session-only; reset on page reload.
|
| 34 |
+
// Server-side audit is in wp_sa_token_log (separate concern: cross-session
|
| 35 |
+
// attribution for Board #23's hook). These counters are UI-only.
|
| 36 |
+
var sessionTokens = { prompt: 0, completion: 0, total: 0 };
|
| 37 |
+
|
| 38 |
+
function fmtNumber(n) {
|
| 39 |
+
try { return Number(n).toLocaleString(); } catch (e) { return String(n || 0); }
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// Last valid input target tracked from focusin events. Used by the Insert
|
| 43 |
+
// button (Board #15). Captured live; re-evaluated on every focus change.
|
| 44 |
+
// Excludes Sara's own panel inputs.
|
| 45 |
+
var lastValidField = null;
|
| 46 |
+
|
| 47 |
+
// ----- Strict Demand-binding context resolver (Board #13) -----
|
| 48 |
+
//
|
| 49 |
+
// Resolution order:
|
| 50 |
+
// 1. fluent-boards page + #/boards/N/tasks/M hash → fluent-boards / task#M
|
| 51 |
+
// 2. fluent-support page + #/tickets/N hash → fluent-support / ticket#N
|
| 52 |
+
// 3. sa-orchestration* page + ?demand_surface=...&demand_ref=...
|
| 53 |
+
// query params (closed-vocabulary surface + per-surface ref pattern
|
| 54 |
+
// validated) → that surface / ref
|
| 55 |
+
// 4. otherwise null (page-level fallback applies in backend user_prompt)
|
| 56 |
+
var REF_PATTERNS = {
|
| 57 |
+
'fluent-support': /^ticket#\d+$/,
|
| 58 |
+
'fluent-boards': /^task#\d+$/,
|
| 59 |
+
'fluentcrm': /^subscriber#\d+$/,
|
| 60 |
+
'derived': /^derived#[A-Za-z0-9_\-]+$/
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
function isValidSurfaceRef(surface, ref) {
|
| 64 |
+
if (!surface || !ref) return false;
|
| 65 |
+
if (!REF_PATTERNS.hasOwnProperty(surface)) return false;
|
| 66 |
+
return REF_PATTERNS[surface].test(ref);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
function resolveContext() {
|
| 70 |
+
var page = cfg.page || '';
|
| 71 |
+
var hash = window.location.hash || '';
|
| 72 |
+
|
| 73 |
+
if (page === 'fluent-boards') {
|
| 74 |
+
var m = hash.match(/^#\/boards\/\d+\/tasks\/(\d+)/);
|
| 75 |
+
if (m) {
|
| 76 |
+
return { surface: 'fluent-boards', ref: 'task#' + m[1] };
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
if (page === 'fluent-support') {
|
| 81 |
+
var m2 = hash.match(/^#\/tickets\/(\d+)/);
|
| 82 |
+
if (m2) {
|
| 83 |
+
return { surface: 'fluent-support', ref: 'ticket#' + m2[1] };
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
// SA-Orchestration Demand Trace pages carry demand_surface + demand_ref
|
| 88 |
+
// as URL query params (e.g. admin.php?page=sa-orchestration&demand_surface=fluent-boards&demand_ref=task%231019).
|
| 89 |
+
// URLSearchParams auto-decodes %23 → #.
|
| 90 |
+
if (page && page.indexOf('sa-orchestration') === 0 && typeof URLSearchParams !== 'undefined') {
|
| 91 |
+
var params = new URLSearchParams(window.location.search);
|
| 92 |
+
var s = params.get('demand_surface');
|
| 93 |
+
var r = params.get('demand_ref');
|
| 94 |
+
if (s && r && isValidSurfaceRef(s, r)) {
|
| 95 |
+
return { surface: s, ref: r };
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
return null;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// ----- DOM construction -----
|
| 103 |
+
var root, button, panel, input, sendBtn, contextLabel, statusLine;
|
| 104 |
+
var conversationContainer, copyBtn;
|
| 105 |
+
// Board #15 — Draft Assist additions
|
| 106 |
+
var insertBtn, insertWarning, insertMessage;
|
| 107 |
+
|
| 108 |
+
// ----- Focus tracking helpers (Board #15) -----
|
| 109 |
+
function isValidInsertTarget(el) {
|
| 110 |
+
if (!el) return false;
|
| 111 |
+
if (el === input) return false; // exclude Sara's own prompt textarea
|
| 112 |
+
if (el === insertBtn) return false; // exclude the insert button itself
|
| 113 |
+
if (el.tagName === 'TEXTAREA') return true;
|
| 114 |
+
if (el.tagName === 'INPUT') {
|
| 115 |
+
var t = (el.getAttribute('type') || 'text').toLowerCase();
|
| 116 |
+
return t === 'text';
|
| 117 |
+
}
|
| 118 |
+
return false;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
function updateInsertVisibility() {
|
| 122 |
+
if (!insertBtn || !insertWarning) return;
|
| 123 |
+
var draftReady = lastDraft && String(lastDraft).trim() !== '';
|
| 124 |
+
var fieldFocused = isValidInsertTarget(document.activeElement);
|
| 125 |
+
var show = draftReady && fieldFocused;
|
| 126 |
+
|
| 127 |
+
insertBtn.style.display = show ? 'inline-block' : 'none';
|
| 128 |
+
insertWarning.style.display = show ? 'block' : 'none';
|
| 129 |
+
|
| 130 |
+
// If a valid field is currently focused, capture it for the click handler.
|
| 131 |
+
if (fieldFocused) {
|
| 132 |
+
lastValidField = document.activeElement;
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
// Insert text at the field's cursor position, without dispatching events.
|
| 137 |
+
// Returns true on success, false otherwise.
|
| 138 |
+
function insertAtCursor(field, text) {
|
| 139 |
+
if (!field || !text) return false;
|
| 140 |
+
if (field.tagName === 'TEXTAREA' || field.tagName === 'INPUT') {
|
| 141 |
+
var current = field.value || '';
|
| 142 |
+
var start = field.selectionStart;
|
| 143 |
+
var end = field.selectionEnd;
|
| 144 |
+
if (typeof start === 'number' && typeof end === 'number') {
|
| 145 |
+
field.value = current.substring(0, start) + text + current.substring(end);
|
| 146 |
+
var newPos = start + text.length;
|
| 147 |
+
try { field.setSelectionRange(newPos, newPos); } catch (e) { /* some inputs don't support */ }
|
| 148 |
+
} else {
|
| 149 |
+
field.value = current + text;
|
| 150 |
+
}
|
| 151 |
+
// Per spec: do NOT dispatch change/input/submit events. The host form's
|
| 152 |
+
// state will not auto-update; the user must interact with the field
|
| 153 |
+
// afterward to flush. This is a deliberate constraint.
|
| 154 |
+
return true;
|
| 155 |
+
}
|
| 156 |
+
return false;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
function build() {
|
| 160 |
+
root = document.createElement('div');
|
| 161 |
+
root.id = 'sa-orch-sara-root';
|
| 162 |
+
|
| 163 |
+
button = document.createElement('button');
|
| 164 |
+
button.id = 'sa-orch-sara-button';
|
| 165 |
+
button.setAttribute('aria-label', 'Open Sara');
|
| 166 |
+
button.textContent = 'Sara';
|
| 167 |
+
button.addEventListener('click', togglePanel);
|
| 168 |
+
|
| 169 |
+
panel = document.createElement('div');
|
| 170 |
+
panel.id = 'sa-orch-sara-panel';
|
| 171 |
+
panel.setAttribute('aria-hidden', 'true');
|
| 172 |
+
|
| 173 |
+
var header = document.createElement('div');
|
| 174 |
+
header.className = 'sa-orch-sara-header';
|
| 175 |
+
header.innerHTML = '<strong>Sara</strong> <span class="sa-orch-sara-tag">non-binding draft surface</span>';
|
| 176 |
+
|
| 177 |
+
contextLabel = document.createElement('div');
|
| 178 |
+
contextLabel.className = 'sa-orch-sara-context';
|
| 179 |
+
contextLabel.textContent = 'context: detecting...';
|
| 180 |
+
|
| 181 |
+
// Conversation thread: scrollable container that grows with each turn
|
| 182 |
+
conversationContainer = document.createElement('div');
|
| 183 |
+
conversationContainer.className = 'sa-orch-sara-conversation';
|
| 184 |
+
|
| 185 |
+
statusLine = document.createElement('div');
|
| 186 |
+
statusLine.className = 'sa-orch-sara-status';
|
| 187 |
+
statusLine.style.display = 'none';
|
| 188 |
+
|
| 189 |
+
input = document.createElement('textarea');
|
| 190 |
+
input.placeholder = 'What do you need?';
|
| 191 |
+
input.rows = 3;
|
| 192 |
+
input.addEventListener('keydown', function (e) {
|
| 193 |
+
// Ctrl/Cmd + Enter sends
|
| 194 |
+
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
| 195 |
+
e.preventDefault();
|
| 196 |
+
send();
|
| 197 |
+
}
|
| 198 |
+
});
|
| 199 |
+
|
| 200 |
+
sendBtn = document.createElement('button');
|
| 201 |
+
sendBtn.className = 'sa-orch-sara-send';
|
| 202 |
+
sendBtn.textContent = 'Send';
|
| 203 |
+
sendBtn.addEventListener('click', send);
|
| 204 |
+
|
| 205 |
+
copyBtn = document.createElement('button');
|
| 206 |
+
copyBtn.className = 'sa-orch-sara-copy';
|
| 207 |
+
copyBtn.textContent = 'Copy latest draft';
|
| 208 |
+
copyBtn.style.display = 'none';
|
| 209 |
+
copyBtn.addEventListener('click', copyDraft);
|
| 210 |
+
|
| 211 |
+
// Board #15 — Draft Assist UI
|
| 212 |
+
// Warning text appears directly above the Insert button. Both gated on
|
| 213 |
+
// (draft non-empty) AND (a valid input field currently focused).
|
| 214 |
+
insertWarning = document.createElement('div');
|
| 215 |
+
insertWarning.className = 'sa-orch-sara-insert-warning';
|
| 216 |
+
insertWarning.textContent = 'You are about to insert this into the current field';
|
| 217 |
+
insertWarning.style.display = 'none';
|
| 218 |
+
|
| 219 |
+
insertBtn = document.createElement('button');
|
| 220 |
+
insertBtn.className = 'sa-orch-sara-insert';
|
| 221 |
+
insertBtn.textContent = 'Insert into active field';
|
| 222 |
+
insertBtn.style.display = 'none';
|
| 223 |
+
|
| 224 |
+
// mousedown preventDefault keeps focus on the textarea so document.activeElement
|
| 225 |
+
// is still the user's target field at click-time. No event dispatch.
|
| 226 |
+
insertBtn.addEventListener('mousedown', function (e) { e.preventDefault(); });
|
| 227 |
+
insertBtn.addEventListener('click', insertDraft);
|
| 228 |
+
|
| 229 |
+
insertMessage = document.createElement('div');
|
| 230 |
+
insertMessage.className = 'sa-orch-sara-insert-message';
|
| 231 |
+
insertMessage.style.display = 'none';
|
| 232 |
+
|
| 233 |
+
var actions = document.createElement('div');
|
| 234 |
+
actions.className = 'sa-orch-sara-actions';
|
| 235 |
+
actions.appendChild(copyBtn);
|
| 236 |
+
actions.appendChild(insertBtn);
|
| 237 |
+
|
| 238 |
+
panel.appendChild(header);
|
| 239 |
+
panel.appendChild(contextLabel);
|
| 240 |
+
panel.appendChild(conversationContainer);
|
| 241 |
+
panel.appendChild(statusLine);
|
| 242 |
+
panel.appendChild(input);
|
| 243 |
+
panel.appendChild(sendBtn);
|
| 244 |
+
panel.appendChild(insertWarning);
|
| 245 |
+
panel.appendChild(actions);
|
| 246 |
+
panel.appendChild(insertMessage);
|
| 247 |
+
|
| 248 |
+
root.appendChild(button);
|
| 249 |
+
root.appendChild(panel);
|
| 250 |
+
document.body.appendChild(root);
|
| 251 |
+
|
| 252 |
+
updateContextLabel();
|
| 253 |
+
|
| 254 |
+
// Surface reliability — Board #24.
|
| 255 |
+
//
|
| 256 |
+
// Listen on BOTH hashchange (Fluent's hash-routed SPA) and popstate
|
| 257 |
+
// (history.pushState navigation, used by some Fluent flows). Either
|
| 258 |
+
// event indicates the user moved to a different task/ticket; we
|
| 259 |
+
// refresh the context label so the panel header reflects reality.
|
| 260 |
+
// resolveContext() is also re-called at send() time, so even if
|
| 261 |
+
// these listeners somehow miss an update, the actual outbound
|
| 262 |
+
// request still carries current context.
|
| 263 |
+
window.addEventListener('hashchange', function () {
|
| 264 |
+
ensureMounted();
|
| 265 |
+
updateContextLabel();
|
| 266 |
+
});
|
| 267 |
+
window.addEventListener('popstate', function () {
|
| 268 |
+
ensureMounted();
|
| 269 |
+
updateContextLabel();
|
| 270 |
+
});
|
| 271 |
+
|
| 272 |
+
// Defensive re-mount: if Fluent's SPA (or any other code) removes
|
| 273 |
+
// Sara's root from document.body during navigation, put it back.
|
| 274 |
+
// Lightweight check — runs only on hash/popstate, not on every DOM
|
| 275 |
+
// mutation. Idempotent: if root is still mounted, this is a no-op.
|
| 276 |
+
ensureMounted();
|
| 277 |
+
|
| 278 |
+
// Focus tracking — re-evaluate Insert visibility on every focus change.
|
| 279 |
+
// Captures the user's currently-focused valid field so the click handler
|
| 280 |
+
// can act on it.
|
| 281 |
+
document.addEventListener('focusin', updateInsertVisibility);
|
| 282 |
+
document.addEventListener('focusout', function () {
|
| 283 |
+
// Defer one tick so document.activeElement reflects the new target
|
| 284 |
+
setTimeout(updateInsertVisibility, 0);
|
| 285 |
+
});
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
// Re-attach Sara's root to document.body if SPA navigation removed it.
|
| 289 |
+
// Cheap, idempotent. document.body.contains(root) is a fast DOM query.
|
| 290 |
+
function ensureMounted() {
|
| 291 |
+
if (root && !document.body.contains(root)) {
|
| 292 |
+
document.body.appendChild(root);
|
| 293 |
+
}
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
function updateContextLabel() {
|
| 297 |
+
if (!contextLabel) return;
|
| 298 |
+
var ctx = resolveContext();
|
| 299 |
+
var turns = conversationHistory.length / 2;
|
| 300 |
+
var turnSuffix = turns === 0 ? '' : ' · ' + turns + ' turn' + (turns === 1 ? '' : 's');
|
| 301 |
+
|
| 302 |
+
// Session token total (Board #16). Reset on page reload.
|
| 303 |
+
var tokSuffix = sessionTokens.total > 0
|
| 304 |
+
? ' · session: ' + fmtNumber(sessionTokens.total) + ' tokens'
|
| 305 |
+
: '';
|
| 306 |
+
|
| 307 |
+
if (ctx) {
|
| 308 |
+
contextLabel.textContent = 'context: ' + ctx.surface + ' / ' + ctx.ref + turnSuffix + tokSuffix;
|
| 309 |
+
} else {
|
| 310 |
+
// No bound ticket/task — still show page if available so user can see
|
| 311 |
+
// what context Sara IS getting (page-level + conversation history).
|
| 312 |
+
var pageLabel = cfg.page ? "page='" + cfg.page + "'" : 'no page';
|
| 313 |
+
contextLabel.textContent = 'context: none (' + pageLabel + ')' + turnSuffix + tokSuffix;
|
| 314 |
+
}
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
function togglePanel() {
|
| 318 |
+
var open = panel.classList.toggle('open');
|
| 319 |
+
panel.setAttribute('aria-hidden', open ? 'false' : 'true');
|
| 320 |
+
if (open) {
|
| 321 |
+
updateContextLabel();
|
| 322 |
+
input.focus();
|
| 323 |
+
scrollConversationToBottom();
|
| 324 |
+
}
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
function setStatus(msg) {
|
| 328 |
+
if (msg) {
|
| 329 |
+
statusLine.textContent = msg;
|
| 330 |
+
statusLine.style.display = 'block';
|
| 331 |
+
} else {
|
| 332 |
+
statusLine.textContent = '';
|
| 333 |
+
statusLine.style.display = 'none';
|
| 334 |
+
}
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
function scrollConversationToBottom() {
|
| 338 |
+
if (!conversationContainer) return;
|
| 339 |
+
// Defer to next tick so DOM updates are flushed
|
| 340 |
+
setTimeout(function () {
|
| 341 |
+
conversationContainer.scrollTop = conversationContainer.scrollHeight;
|
| 342 |
+
}, 0);
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
// ----- Render a single turn (user message + Sara response) -----
|
| 346 |
+
function renderTurn(userText, assistantStruct, usage) {
|
| 347 |
+
var turn = document.createElement('div');
|
| 348 |
+
turn.className = 'sa-orch-sara-turn';
|
| 349 |
+
|
| 350 |
+
// User block
|
| 351 |
+
var userBlock = document.createElement('div');
|
| 352 |
+
userBlock.className = 'sa-orch-sara-user-msg';
|
| 353 |
+
var userLabel = document.createElement('div');
|
| 354 |
+
userLabel.className = 'sa-orch-sara-msg-label';
|
| 355 |
+
userLabel.textContent = 'You';
|
| 356 |
+
var userBody = document.createElement('div');
|
| 357 |
+
userBody.className = 'sa-orch-sara-msg-body';
|
| 358 |
+
userBody.textContent = userText;
|
| 359 |
+
userBlock.appendChild(userLabel);
|
| 360 |
+
userBlock.appendChild(userBody);
|
| 361 |
+
|
| 362 |
+
// Sara block
|
| 363 |
+
var saraBlock = document.createElement('div');
|
| 364 |
+
saraBlock.className = 'sa-orch-sara-assistant-msg';
|
| 365 |
+
|
| 366 |
+
var saraHeader = document.createElement('div');
|
| 367 |
+
saraHeader.className = 'sa-orch-sara-msg-label';
|
| 368 |
+
saraHeader.innerHTML = 'Sara <span class="sa-orch-sara-not-saved">Not saved</span>';
|
| 369 |
+
saraBlock.appendChild(saraHeader);
|
| 370 |
+
|
| 371 |
+
function appendSection(label, text) {
|
| 372 |
+
if (!text || String(text).trim() === '') return;
|
| 373 |
+
var section = document.createElement('div');
|
| 374 |
+
section.className = 'sa-orch-sara-section';
|
| 375 |
+
var h = document.createElement('div');
|
| 376 |
+
h.className = 'sa-orch-sara-section-header';
|
| 377 |
+
h.textContent = label;
|
| 378 |
+
var b = document.createElement('div');
|
| 379 |
+
b.className = 'sa-orch-sara-section-body';
|
| 380 |
+
b.textContent = String(text);
|
| 381 |
+
section.appendChild(h);
|
| 382 |
+
section.appendChild(b);
|
| 383 |
+
saraBlock.appendChild(section);
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
appendSection('Summary', assistantStruct.summary);
|
| 387 |
+
appendSection('Suggestion', assistantStruct.suggestion);
|
| 388 |
+
appendSection('Draft', assistantStruct.draft);
|
| 389 |
+
appendSection('Notes', assistantStruct.notes);
|
| 390 |
+
|
| 391 |
+
// Per-turn token footer (Board #16). Only shown when usage was returned.
|
| 392 |
+
if (usage && (usage.prompt_tokens || usage.completion_tokens || usage.total_tokens)) {
|
| 393 |
+
var footer = document.createElement('div');
|
| 394 |
+
footer.className = 'sa-orch-sara-usage';
|
| 395 |
+
footer.textContent = 'tokens: '
|
| 396 |
+
+ fmtNumber(usage.prompt_tokens || 0) + ' in / '
|
| 397 |
+
+ fmtNumber(usage.completion_tokens || 0) + ' out · '
|
| 398 |
+
+ fmtNumber(usage.total_tokens || 0) + ' total';
|
| 399 |
+
saraBlock.appendChild(footer);
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
turn.appendChild(userBlock);
|
| 403 |
+
turn.appendChild(saraBlock);
|
| 404 |
+
conversationContainer.appendChild(turn);
|
| 405 |
+
|
| 406 |
+
scrollConversationToBottom();
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
function send() {
|
| 410 |
+
var ctx = resolveContext();
|
| 411 |
+
var prompt = (input.value || '').trim();
|
| 412 |
+
if (!prompt) {
|
| 413 |
+
setStatus('(empty prompt)');
|
| 414 |
+
return;
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
sendBtn.disabled = true;
|
| 418 |
+
sendBtn.textContent = 'Thinking...';
|
| 419 |
+
setStatus('');
|
| 420 |
+
|
| 421 |
+
// Build request body. Always send page (always non-empty under our enqueue).
|
| 422 |
+
// History is the SLICE of closed prior turns — does NOT include this new prompt yet.
|
| 423 |
+
var body = {
|
| 424 |
+
prompt: prompt,
|
| 425 |
+
page: cfg.page || '',
|
| 426 |
+
history: conversationHistory.slice()
|
| 427 |
+
};
|
| 428 |
+
if (ctx) {
|
| 429 |
+
body.surface = ctx.surface;
|
| 430 |
+
body.ref = ctx.ref;
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
// Board #28 — Sara Asterion Context Binding.
|
| 434 |
+
// Visibility only: when on the explorer with a file in the URL,
|
| 435 |
+
// pass the file param through. No transformation client-side.
|
| 436 |
+
if (cfg.page === 'sa-orchestration-asterion' && typeof URLSearchParams !== 'undefined') {
|
| 437 |
+
var asterionFile = new URLSearchParams(window.location.search).get('file');
|
| 438 |
+
if (asterionFile) body.asterion_file = asterionFile;
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
fetch(cfg.rest_url, {
|
| 442 |
+
method: 'POST',
|
| 443 |
+
credentials: 'same-origin',
|
| 444 |
+
headers: {
|
| 445 |
+
'Content-Type': 'application/json',
|
| 446 |
+
'X-WP-Nonce': cfg.nonce
|
| 447 |
+
},
|
| 448 |
+
body: JSON.stringify(body)
|
| 449 |
+
})
|
| 450 |
+
.then(function (r) {
|
| 451 |
+
return r.json().then(function (json) { return { ok: r.ok, json: json }; });
|
| 452 |
+
})
|
| 453 |
+
.then(function (result) {
|
| 454 |
+
sendBtn.disabled = false;
|
| 455 |
+
sendBtn.textContent = 'Send';
|
| 456 |
+
|
| 457 |
+
if (result.ok && result.json && result.json.response && typeof result.json.response === 'object') {
|
| 458 |
+
var assistantStruct = result.json.response;
|
| 459 |
+
var usage = result.json.usage || null; // Board #16
|
| 460 |
+
|
| 461 |
+
// Update session-only token counters (Board #16). Reset on page reload.
|
| 462 |
+
if (usage) {
|
| 463 |
+
sessionTokens.prompt += parseInt(usage.prompt_tokens || 0, 10);
|
| 464 |
+
sessionTokens.completion += parseInt(usage.completion_tokens || 0, 10);
|
| 465 |
+
sessionTokens.total += parseInt(usage.total_tokens || 0, 10);
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
// Push BOTH turns to history — close the round-trip
|
| 469 |
+
conversationHistory.push({ role: 'user', content: prompt });
|
| 470 |
+
conversationHistory.push({ role: 'assistant', content: assistantStruct });
|
| 471 |
+
|
| 472 |
+
// Render in conversation panel
|
| 473 |
+
renderTurn(prompt, assistantStruct, usage);
|
| 474 |
+
|
| 475 |
+
// Track latest draft for Copy + Insert buttons
|
| 476 |
+
lastDraft = assistantStruct.draft || '';
|
| 477 |
+
copyBtn.style.display = lastDraft.trim() !== '' ? 'inline-block' : 'none';
|
| 478 |
+
|
| 479 |
+
// Insert button visibility re-evaluates on focus change AND
|
| 480 |
+
// whenever the draft changes; refresh now that lastDraft updated.
|
| 481 |
+
updateInsertVisibility();
|
| 482 |
+
|
| 483 |
+
// Clear input ready for next turn; keep panel open + scroll
|
| 484 |
+
input.value = '';
|
| 485 |
+
setStatus('');
|
| 486 |
+
updateContextLabel();
|
| 487 |
+
} else {
|
| 488 |
+
var msg = (result.json && (result.json.message || result.json.error)) || 'unexpected error';
|
| 489 |
+
setStatus('Error: ' + msg);
|
| 490 |
+
// Do NOT push to history on failure
|
| 491 |
+
}
|
| 492 |
+
})
|
| 493 |
+
.catch(function (err) {
|
| 494 |
+
sendBtn.disabled = false;
|
| 495 |
+
sendBtn.textContent = 'Send';
|
| 496 |
+
setStatus('Network error: ' + (err && err.message ? err.message : err));
|
| 497 |
+
});
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
// Board #15 — Insert handler. Operates ONLY on the user's currently
|
| 501 |
+
// focused valid field. No fallback targeting. No event dispatch.
|
| 502 |
+
function insertDraft() {
|
| 503 |
+
if (!lastDraft || String(lastDraft).trim() === '') {
|
| 504 |
+
flashInsertMessage('No draft to insert');
|
| 505 |
+
return;
|
| 506 |
+
}
|
| 507 |
+
// Use document.activeElement first (mousedown preventDefault kept focus
|
| 508 |
+
// on the textarea); fall back to lastValidField as defensive backup.
|
| 509 |
+
var target = isValidInsertTarget(document.activeElement) ? document.activeElement
|
| 510 |
+
: (isValidInsertTarget(lastValidField) ? lastValidField : null);
|
| 511 |
+
if (!target) {
|
| 512 |
+
flashInsertMessage('No active input field');
|
| 513 |
+
return;
|
| 514 |
+
}
|
| 515 |
+
var ok = insertAtCursor(target, lastDraft);
|
| 516 |
+
flashInsertMessage(ok ? 'Inserted' : 'Could not insert');
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
function flashInsertMessage(msg) {
|
| 520 |
+
if (!insertMessage) return;
|
| 521 |
+
insertMessage.textContent = msg;
|
| 522 |
+
insertMessage.style.display = 'block';
|
| 523 |
+
setTimeout(function () {
|
| 524 |
+
insertMessage.style.display = 'none';
|
| 525 |
+
insertMessage.textContent = '';
|
| 526 |
+
}, 1800);
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
function copyDraft() {
|
| 530 |
+
if (!lastDraft) return;
|
| 531 |
+
var text = String(lastDraft);
|
| 532 |
+
|
| 533 |
+
var done = function (label) {
|
| 534 |
+
var orig = copyBtn.textContent;
|
| 535 |
+
copyBtn.textContent = label;
|
| 536 |
+
setTimeout(function () { copyBtn.textContent = orig; }, 1500);
|
| 537 |
+
};
|
| 538 |
+
|
| 539 |
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
| 540 |
+
navigator.clipboard.writeText(text).then(
|
| 541 |
+
function () { done('Copied'); },
|
| 542 |
+
function () { done('Copy failed'); }
|
| 543 |
+
);
|
| 544 |
+
} else {
|
| 545 |
+
var ta = document.createElement('textarea');
|
| 546 |
+
ta.value = text;
|
| 547 |
+
document.body.appendChild(ta);
|
| 548 |
+
ta.select();
|
| 549 |
+
try { document.execCommand('copy'); done('Copied'); }
|
| 550 |
+
catch (e) { done('Copy failed'); }
|
| 551 |
+
document.body.removeChild(ta);
|
| 552 |
+
}
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
if (document.readyState === 'loading') {
|
| 556 |
+
document.addEventListener('DOMContentLoaded', build);
|
| 557 |
+
} else {
|
| 558 |
+
build();
|
| 559 |
+
}
|
| 560 |
+
})();
|
sa-orchestration/includes/class-sa-acceptance.php
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Append-only by design. Class exposes only write() and read methods.
|
| 6 |
+
* No update(), no delete().
|
| 7 |
+
*/
|
| 8 |
+
class SA_Orch_Acceptance {
|
| 9 |
+
|
| 10 |
+
const ALLOWED_EVENT_TYPES = [
|
| 11 |
+
'result_claimed',
|
| 12 |
+
'acceptance_disputed',
|
| 13 |
+
'acceptance_affirmed',
|
| 14 |
+
'acceptance_revoked',
|
| 15 |
+
];
|
| 16 |
+
|
| 17 |
+
const ALLOWED_ACTOR_ROLES = [
|
| 18 |
+
'agent',
|
| 19 |
+
'stakeholder',
|
| 20 |
+
'system',
|
| 21 |
+
'inferred',
|
| 22 |
+
];
|
| 23 |
+
|
| 24 |
+
public static function write( array $args ) {
|
| 25 |
+
global $wpdb;
|
| 26 |
+
|
| 27 |
+
$demand_surface = isset( $args['demand_surface'] ) ? trim( (string) $args['demand_surface'] ) : '';
|
| 28 |
+
$demand_ref = isset( $args['demand_ref'] ) ? trim( (string) $args['demand_ref'] ) : '';
|
| 29 |
+
$event_type = isset( $args['event_type'] ) ? trim( (string) $args['event_type'] ) : '';
|
| 30 |
+
$actor_role = isset( $args['actor_role'] ) ? trim( (string) $args['actor_role'] ) : '';
|
| 31 |
+
|
| 32 |
+
if ( $demand_surface === '' || $demand_ref === '' ) {
|
| 33 |
+
return new WP_Error( 'missing_demand', 'demand_surface and demand_ref are required' );
|
| 34 |
+
}
|
| 35 |
+
if ( ! in_array( $event_type, self::ALLOWED_EVENT_TYPES, true ) ) {
|
| 36 |
+
return new WP_Error( 'invalid_event_type', 'event_type must be one of: ' . implode( ', ', self::ALLOWED_EVENT_TYPES ) );
|
| 37 |
+
}
|
| 38 |
+
if ( ! in_array( $actor_role, self::ALLOWED_ACTOR_ROLES, true ) ) {
|
| 39 |
+
return new WP_Error( 'invalid_actor_role', 'actor_role must be one of: ' . implode( ', ', self::ALLOWED_ACTOR_ROLES ) );
|
| 40 |
+
}
|
| 41 |
+
if ( ! in_array( $demand_surface, SA_Orch_Bridge_Fluent::known_surfaces(), true ) ) {
|
| 42 |
+
return new WP_Error( 'unknown_surface', "demand_surface '$demand_surface' is not in known vocabulary" );
|
| 43 |
+
}
|
| 44 |
+
if ( ! SA_Orch_Bridge_Fluent::validate( $demand_surface, $demand_ref ) ) {
|
| 45 |
+
return new WP_Error( 'invalid_demand_ref', "demand_ref does not resolve: {$demand_surface}/{$demand_ref}" );
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
$prior_event_id = isset( $args['prior_event_id'] ) ? (int) $args['prior_event_id'] : null;
|
| 49 |
+
if ( $prior_event_id ) {
|
| 50 |
+
$exists = $wpdb->get_var( $wpdb->prepare(
|
| 51 |
+
"SELECT id FROM {$wpdb->prefix}" . SA_ORCH_DB_PREFIX . "acceptance_event WHERE id = %d",
|
| 52 |
+
$prior_event_id
|
| 53 |
+
) );
|
| 54 |
+
if ( ! $exists ) {
|
| 55 |
+
return new WP_Error( 'invalid_prior_event', "prior_event_id $prior_event_id not found" );
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
$actor_user_id = isset( $args['actor_user_id'] ) ? (int) $args['actor_user_id'] : get_current_user_id();
|
| 60 |
+
if ( $actor_user_id === 0 ) { $actor_user_id = null; }
|
| 61 |
+
|
| 62 |
+
$details = isset( $args['details'] ) ? (string) $args['details'] : null;
|
| 63 |
+
$truth_class = isset( $args['truth_class'] ) && $args['truth_class'] !== ''
|
| 64 |
+
? (string) $args['truth_class']
|
| 65 |
+
: 'canonical';
|
| 66 |
+
|
| 67 |
+
$observed_at = isset( $args['observed_at'] ) && $args['observed_at'] !== ''
|
| 68 |
+
? (string) $args['observed_at']
|
| 69 |
+
: current_time( 'mysql', true );
|
| 70 |
+
$recorded_at = current_time( 'mysql', true );
|
| 71 |
+
|
| 72 |
+
$hash = self::compute_hash( $demand_surface, $demand_ref, $event_type, $observed_at, $prior_event_id );
|
| 73 |
+
|
| 74 |
+
$frontmatter = [
|
| 75 |
+
'kind' => 'acceptance_event',
|
| 76 |
+
'hash' => $hash,
|
| 77 |
+
'demand_surface' => $demand_surface,
|
| 78 |
+
'demand_ref' => $demand_ref,
|
| 79 |
+
'event_type' => $event_type,
|
| 80 |
+
'prior_event_id' => $prior_event_id,
|
| 81 |
+
'actor_user_id' => $actor_user_id,
|
| 82 |
+
'actor_role' => $actor_role,
|
| 83 |
+
'truth_class' => $truth_class,
|
| 84 |
+
'observed_at' => $observed_at,
|
| 85 |
+
'recorded_at' => $recorded_at,
|
| 86 |
+
];
|
| 87 |
+
|
| 88 |
+
$body = "## Details\n\n" . ( $details !== null && $details !== '' ? $details : '(none)' ) . "\n";
|
| 89 |
+
|
| 90 |
+
$rel_path = SA_Orch_Asterion::write( 'acceptance_event', $hash, $frontmatter, $body );
|
| 91 |
+
if ( is_wp_error( $rel_path ) ) {
|
| 92 |
+
return $rel_path;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
$table = $wpdb->prefix . SA_ORCH_DB_PREFIX . 'acceptance_event';
|
| 96 |
+
$ok = $wpdb->insert( $table, [
|
| 97 |
+
'hash' => $hash,
|
| 98 |
+
'demand_surface' => $demand_surface,
|
| 99 |
+
'demand_ref' => $demand_ref,
|
| 100 |
+
'event_type' => $event_type,
|
| 101 |
+
'prior_event_id' => $prior_event_id,
|
| 102 |
+
'actor_user_id' => $actor_user_id,
|
| 103 |
+
'actor_role' => $actor_role,
|
| 104 |
+
'details' => $details,
|
| 105 |
+
'truth_class' => $truth_class,
|
| 106 |
+
'asterion_path' => $rel_path,
|
| 107 |
+
'observed_at' => $observed_at,
|
| 108 |
+
'recorded_at' => $recorded_at,
|
| 109 |
+
] );
|
| 110 |
+
|
| 111 |
+
if ( $ok === false ) {
|
| 112 |
+
return new WP_Error( 'db_insert_failed', $wpdb->last_error, [ 'asterion_path' => $rel_path ] );
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
$event_id = (int) $wpdb->insert_id;
|
| 116 |
+
|
| 117 |
+
// v0.6.0: notify projection / side-effect listeners.
|
| 118 |
+
do_action( 'sa_orch_acceptance_event_written', [
|
| 119 |
+
'id' => $event_id,
|
| 120 |
+
'hash' => $hash,
|
| 121 |
+
'demand_surface' => $demand_surface,
|
| 122 |
+
'demand_ref' => $demand_ref,
|
| 123 |
+
'event_type' => $event_type,
|
| 124 |
+
'actor_role' => $actor_role,
|
| 125 |
+
'actor_user_id' => $actor_user_id,
|
| 126 |
+
'truth_class' => $truth_class,
|
| 127 |
+
'asterion_path' => $rel_path,
|
| 128 |
+
] );
|
| 129 |
+
|
| 130 |
+
return [
|
| 131 |
+
'id' => $event_id,
|
| 132 |
+
'hash' => $hash,
|
| 133 |
+
'asterion_path' => $rel_path,
|
| 134 |
+
];
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
/**
|
| 138 |
+
* Latest event per Demand. Useful for closure-rule queries.
|
| 139 |
+
* Returns object|null.
|
| 140 |
+
*/
|
| 141 |
+
public static function latest_for_demand( $demand_surface, $demand_ref ) {
|
| 142 |
+
global $wpdb;
|
| 143 |
+
$table = $wpdb->prefix . SA_ORCH_DB_PREFIX . 'acceptance_event';
|
| 144 |
+
return $wpdb->get_row( $wpdb->prepare(
|
| 145 |
+
"SELECT * FROM {$table}
|
| 146 |
+
WHERE demand_surface = %s AND demand_ref = %s
|
| 147 |
+
ORDER BY observed_at DESC, id DESC LIMIT 1",
|
| 148 |
+
$demand_surface,
|
| 149 |
+
$demand_ref
|
| 150 |
+
) );
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
/**
|
| 154 |
+
* Basic closure state.
|
| 155 |
+
* Returns: 'no-events' | 'open:<event_type>' | 'closed'
|
| 156 |
+
*
|
| 157 |
+
* 'closed' iff the latest event is acceptance_affirmed (regardless of who affirmed it).
|
| 158 |
+
* Existing behavior since v0.1; preserved.
|
| 159 |
+
*/
|
| 160 |
+
public static function basic_closure_state( $demand_surface, $demand_ref ) {
|
| 161 |
+
$latest = self::latest_for_demand( $demand_surface, $demand_ref );
|
| 162 |
+
if ( ! $latest ) return 'no-events';
|
| 163 |
+
if ( $latest->event_type !== 'acceptance_affirmed' ) return 'open:' . $latest->event_type;
|
| 164 |
+
return 'closed';
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
/**
|
| 168 |
+
* Role-aware closure state.
|
| 169 |
+
* Returns: 'no-events' | 'open:<event_type>' | 'role-mismatch:<actor_role>' | 'closed'
|
| 170 |
+
*
|
| 171 |
+
* 'closed' iff the latest event is acceptance_affirmed AND its actor_role is 'stakeholder'.
|
| 172 |
+
* Otherwise reports the specific reason closure is not authoritative.
|
| 173 |
+
*
|
| 174 |
+
* Per SIGNAL_FLOW.md closure rule: only stakeholder affirmation closes a Demand.
|
| 175 |
+
* Agent-self-affirmation, system-affirmation, etc. write successfully (append-only is preserved)
|
| 176 |
+
* but do not constitute closure under this stricter rule.
|
| 177 |
+
*/
|
| 178 |
+
public static function role_aware_closure_state( $demand_surface, $demand_ref ) {
|
| 179 |
+
$latest = self::latest_for_demand( $demand_surface, $demand_ref );
|
| 180 |
+
if ( ! $latest ) return 'no-events';
|
| 181 |
+
if ( $latest->event_type !== 'acceptance_affirmed' ) return 'open:' . $latest->event_type;
|
| 182 |
+
if ( $latest->actor_role !== 'stakeholder' ) return 'role-mismatch:' . $latest->actor_role;
|
| 183 |
+
return 'closed';
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
/**
|
| 187 |
+
* Closure authority validity.
|
| 188 |
+
*
|
| 189 |
+
* Returns:
|
| 190 |
+
* null — no acceptance_affirmed has been recorded yet (latest is not affirmed, or no events at all).
|
| 191 |
+
* Authority is not yet relevant; closure is not being claimed.
|
| 192 |
+
* true — latest acceptance_affirmed event has actor_role = 'stakeholder'. Closure is authoritative.
|
| 193 |
+
* false — latest acceptance_affirmed event has actor_role != 'stakeholder'. Closure is claimed
|
| 194 |
+
* but the actor lacked closure authority. Recording is preserved (append-only); the
|
| 195 |
+
* closure_state_role_aware field will read 'role-mismatch:<role>'.
|
| 196 |
+
*/
|
| 197 |
+
public static function closure_authority_valid( $demand_surface, $demand_ref ) {
|
| 198 |
+
$latest = self::latest_for_demand( $demand_surface, $demand_ref );
|
| 199 |
+
if ( ! $latest ) return null;
|
| 200 |
+
if ( $latest->event_type !== 'acceptance_affirmed' ) return null;
|
| 201 |
+
return $latest->actor_role === 'stakeholder';
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
private static function compute_hash( $demand_surface, $demand_ref, $event_type, $observed_at, $prior_event_id ) {
|
| 205 |
+
// wp_rand suffix prevents collision when same demand+event_type+observed_at recorded twice
|
| 206 |
+
$material = implode( '|', [
|
| 207 |
+
$demand_surface,
|
| 208 |
+
$demand_ref,
|
| 209 |
+
$event_type,
|
| 210 |
+
$observed_at,
|
| 211 |
+
(int) $prior_event_id,
|
| 212 |
+
wp_rand(),
|
| 213 |
+
] );
|
| 214 |
+
return substr( hash( 'sha256', $material ), 0, 32 );
|
| 215 |
+
}
|
| 216 |
+
}
|
sa-orchestration/includes/class-sa-actor-admin.php
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Sleeper Agents admin submenu — list / create / archive actors.
|
| 6 |
+
*
|
| 7 |
+
* Single page with view modes:
|
| 8 |
+
* ?page=sa-orchestration-actors → list (default)
|
| 9 |
+
* ?page=sa-orchestration-actors&view=create → create form
|
| 10 |
+
* ?page=sa-orchestration-actors&view=detail&id=X → detail
|
| 11 |
+
*
|
| 12 |
+
* POST actions (admin-post-style, handled via admin_init when on this page):
|
| 13 |
+
* sa_actor_action=create → create new actor; redirect to list
|
| 14 |
+
* sa_actor_action=archive&id=X → archive actor; redirect to list
|
| 15 |
+
* sa_actor_action=unarchive&id=X → unarchive actor; redirect to list
|
| 16 |
+
*
|
| 17 |
+
* Per the locked doctrine: actors created here are non-binding identity
|
| 18 |
+
* records. They cannot progress Boards, accept gates, or write to
|
| 19 |
+
* Asterion. This admin surface is the deliberate human-arbitrated path
|
| 20 |
+
* for instantiating those identities.
|
| 21 |
+
*/
|
| 22 |
+
class SA_Orch_Actor_Admin {
|
| 23 |
+
|
| 24 |
+
const SUBMENU_SLUG = 'sa-orchestration-actors';
|
| 25 |
+
|
| 26 |
+
public static function register_menu() {
|
| 27 |
+
add_submenu_page(
|
| 28 |
+
SA_Orch_Admin::PARENT_SLUG,
|
| 29 |
+
'Sleeper Agents',
|
| 30 |
+
'Sleeper Agents',
|
| 31 |
+
SA_Orch_Admin::CAP,
|
| 32 |
+
self::SUBMENU_SLUG,
|
| 33 |
+
[ __CLASS__, 'render_page' ]
|
| 34 |
+
);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/** Catch POST actions on the submenu page before rendering. */
|
| 38 |
+
public static function maybe_handle_action() {
|
| 39 |
+
if ( ! is_admin() ) return;
|
| 40 |
+
if ( ! current_user_can( SA_Orch_Admin::CAP ) ) return;
|
| 41 |
+
$page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
|
| 42 |
+
if ( $page !== self::SUBMENU_SLUG ) return;
|
| 43 |
+
$action = isset( $_POST['sa_actor_action'] ) ? sanitize_text_field( wp_unslash( $_POST['sa_actor_action'] ) ) : '';
|
| 44 |
+
if ( $action === '' ) return;
|
| 45 |
+
|
| 46 |
+
check_admin_referer( 'sa_actor_form', 'sa_actor_nonce' );
|
| 47 |
+
|
| 48 |
+
if ( $action === 'create' ) {
|
| 49 |
+
$data = [
|
| 50 |
+
'name' => isset( $_POST['actor_name'] ) ? sanitize_text_field( wp_unslash( $_POST['actor_name'] ) ) : '',
|
| 51 |
+
'type' => isset( $_POST['actor_type'] ) ? sanitize_text_field( wp_unslash( $_POST['actor_type'] ) ) : '',
|
| 52 |
+
'persona' => isset( $_POST['actor_persona'] ) ? sanitize_textarea_field( wp_unslash( $_POST['actor_persona'] ) ) : '',
|
| 53 |
+
'scope' => isset( $_POST['actor_scope'] ) ? sanitize_textarea_field( wp_unslash( $_POST['actor_scope'] ) ) : '',
|
| 54 |
+
];
|
| 55 |
+
$result = SA_Orch_Actor_Registry::create_actor( $data );
|
| 56 |
+
if ( is_wp_error( $result ) ) {
|
| 57 |
+
$msg = 'error&msg=' . rawurlencode( $result->get_error_message() );
|
| 58 |
+
} else {
|
| 59 |
+
$msg = 'created&id=' . (int) $result;
|
| 60 |
+
}
|
| 61 |
+
wp_safe_redirect( admin_url( 'admin.php?page=' . self::SUBMENU_SLUG . '¬ice=' . $msg ) );
|
| 62 |
+
exit;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
if ( $action === 'archive' || $action === 'unarchive' ) {
|
| 66 |
+
$id = isset( $_POST['actor_id'] ) ? (int) $_POST['actor_id'] : 0;
|
| 67 |
+
if ( $id > 0 ) {
|
| 68 |
+
if ( $action === 'archive' ) SA_Orch_Actor_Registry::archive_actor( $id );
|
| 69 |
+
if ( $action === 'unarchive' ) SA_Orch_Actor_Registry::unarchive_actor( $id );
|
| 70 |
+
}
|
| 71 |
+
wp_safe_redirect( admin_url( 'admin.php?page=' . self::SUBMENU_SLUG . '¬ice=' . $action . 'd' ) );
|
| 72 |
+
exit;
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
/** Single-page render with view dispatching. */
|
| 77 |
+
public static function render_page() {
|
| 78 |
+
if ( ! current_user_can( SA_Orch_Admin::CAP ) ) {
|
| 79 |
+
wp_die( __( 'Insufficient permissions.', 'sa-orchestration' ) );
|
| 80 |
+
}
|
| 81 |
+
$view = isset( $_GET['view'] ) ? sanitize_text_field( wp_unslash( $_GET['view'] ) ) : 'list';
|
| 82 |
+
|
| 83 |
+
echo '<div class="wrap sa-actors-wrap">';
|
| 84 |
+
echo '<h1>Sleeper Agents <span class="sa-actors-tag">identity-bound roles · non-binding</span></h1>';
|
| 85 |
+
echo self::inline_styles();
|
| 86 |
+
self::render_notice();
|
| 87 |
+
|
| 88 |
+
if ( $view === 'create' ) {
|
| 89 |
+
self::render_create_form();
|
| 90 |
+
} elseif ( $view === 'detail' ) {
|
| 91 |
+
self::render_detail();
|
| 92 |
+
} else {
|
| 93 |
+
self::render_list();
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
echo '</div>'; // .wrap
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
private static function render_notice() {
|
| 100 |
+
if ( empty( $_GET['notice'] ) ) return;
|
| 101 |
+
$notice = sanitize_text_field( wp_unslash( $_GET['notice'] ) );
|
| 102 |
+
$msg = isset( $_GET['msg'] ) ? sanitize_text_field( wp_unslash( $_GET['msg'] ) ) : '';
|
| 103 |
+
$kind = 'success'; $body = '';
|
| 104 |
+
if ( $notice === 'created' ) {
|
| 105 |
+
$body = 'Actor created.';
|
| 106 |
+
} elseif ( $notice === 'archived' ) {
|
| 107 |
+
$body = 'Actor archived.';
|
| 108 |
+
} elseif ( $notice === 'unarchived' ) {
|
| 109 |
+
$body = 'Actor unarchived.';
|
| 110 |
+
} elseif ( $notice === 'error' ) {
|
| 111 |
+
$kind = 'error';
|
| 112 |
+
$body = $msg !== '' ? $msg : 'Action failed.';
|
| 113 |
+
}
|
| 114 |
+
if ( $body !== '' ) {
|
| 115 |
+
echo '<div class="notice notice-' . esc_attr( $kind ) . ' is-dismissible"><p>' . esc_html( $body ) . '</p></div>';
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
private static function render_list() {
|
| 120 |
+
$include_archived = ! empty( $_GET['show_archived'] );
|
| 121 |
+
$actors = SA_Orch_Actor_Registry::list_actors( $include_archived );
|
| 122 |
+
|
| 123 |
+
$create_url = admin_url( 'admin.php?page=' . self::SUBMENU_SLUG . '&view=create' );
|
| 124 |
+
$toggle_archived_url = add_query_arg( 'show_archived', $include_archived ? '0' : '1' );
|
| 125 |
+
|
| 126 |
+
echo '<p class="sa-actors-meta">Actors are persistent identity-bound roles. They may speak, draft, advise, observe, coordinate. They may not progress Boards, accept gates, mutate Asterion, or act silently.</p>';
|
| 127 |
+
|
| 128 |
+
echo '<p>';
|
| 129 |
+
echo '<a href="' . esc_url( $create_url ) . '" class="button button-primary">+ Create Actor</a> ';
|
| 130 |
+
echo '<a href="' . esc_url( $toggle_archived_url ) . '" class="button">' . ( $include_archived ? 'Hide archived' : 'Show archived' ) . '</a>';
|
| 131 |
+
echo '</p>';
|
| 132 |
+
|
| 133 |
+
echo '<table class="wp-list-table widefat fixed striped sa-actors-table">';
|
| 134 |
+
echo '<thead><tr>';
|
| 135 |
+
echo '<th>Name</th><th>Type</th><th>Owner</th><th>Status</th><th>Persona (excerpt)</th><th>Created</th><th>Actions</th>';
|
| 136 |
+
echo '</tr></thead><tbody>';
|
| 137 |
+
if ( empty( $actors ) ) {
|
| 138 |
+
echo '<tr><td colspan="7" class="sa-actors-empty">No actors yet. <a href="' . esc_url( $create_url ) . '">Create the first one</a> with intent.</td></tr>';
|
| 139 |
+
} else {
|
| 140 |
+
foreach ( $actors as $u ) {
|
| 141 |
+
$a = SA_Orch_Actor_Registry::get_actor( (int) $u->ID );
|
| 142 |
+
if ( ! $a ) continue;
|
| 143 |
+
$detail_url = admin_url( 'admin.php?page=' . self::SUBMENU_SLUG . '&view=detail&id=' . $a['user_id'] );
|
| 144 |
+
$persona_excerpt = function_exists( 'mb_substr' ) ? mb_substr( $a['persona'], 0, 80 ) : substr( $a['persona'], 0, 80 );
|
| 145 |
+
$status_label = $a['is_active'] ? '<span class="sa-actors-status-active">active</span>' : '<span class="sa-actors-status-archived">archived</span>';
|
| 146 |
+
|
| 147 |
+
echo '<tr>';
|
| 148 |
+
echo '<td><strong><a href="' . esc_url( $detail_url ) . '">' . esc_html( $a['name'] ) . '</a></strong><br><small class="sa-actors-login">' . esc_html( $a['login'] ) . '</small></td>';
|
| 149 |
+
echo '<td>' . esc_html( $a['type'] ) . '</td>';
|
| 150 |
+
echo '<td>' . esc_html( $a['owner_login'] ?: '—' ) . '</td>';
|
| 151 |
+
echo '<td>' . $status_label . '</td>'; // status_label is pre-built; safe
|
| 152 |
+
echo '<td>' . esc_html( $persona_excerpt ) . ( strlen( $a['persona'] ) > 80 ? '…' : '' ) . '</td>';
|
| 153 |
+
echo '<td>' . esc_html( $a['created_at'] ) . '</td>';
|
| 154 |
+
echo '<td>';
|
| 155 |
+
if ( $a['is_active'] ) {
|
| 156 |
+
self::render_post_button( 'archive', $a['user_id'], 'Archive' );
|
| 157 |
+
} else {
|
| 158 |
+
self::render_post_button( 'unarchive', $a['user_id'], 'Unarchive' );
|
| 159 |
+
}
|
| 160 |
+
echo '</td>';
|
| 161 |
+
echo '</tr>';
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
echo '</tbody></table>';
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
private static function render_post_button( string $action, int $actor_id, string $label ) {
|
| 168 |
+
$url = admin_url( 'admin.php?page=' . self::SUBMENU_SLUG );
|
| 169 |
+
echo '<form method="post" action="' . esc_url( $url ) . '" style="display:inline;">';
|
| 170 |
+
wp_nonce_field( 'sa_actor_form', 'sa_actor_nonce' );
|
| 171 |
+
echo '<input type="hidden" name="sa_actor_action" value="' . esc_attr( $action ) . '" />';
|
| 172 |
+
echo '<input type="hidden" name="actor_id" value="' . esc_attr( (string) $actor_id ) . '" />';
|
| 173 |
+
echo '<button type="submit" class="button button-small">' . esc_html( $label ) . '</button>';
|
| 174 |
+
echo '</form>';
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
private static function render_create_form() {
|
| 178 |
+
$list_url = admin_url( 'admin.php?page=' . self::SUBMENU_SLUG );
|
| 179 |
+
echo '<h2>Create Actor</h2>';
|
| 180 |
+
echo '<p class="sa-actors-meta">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.</p>';
|
| 181 |
+
echo '<form method="post" action="' . esc_url( $list_url ) . '" class="sa-actors-form">';
|
| 182 |
+
wp_nonce_field( 'sa_actor_form', 'sa_actor_nonce' );
|
| 183 |
+
echo '<input type="hidden" name="sa_actor_action" value="create" />';
|
| 184 |
+
|
| 185 |
+
echo '<table class="form-table"><tbody>';
|
| 186 |
+
echo '<tr><th><label for="actor_name">Name <span class="required">*</span></label></th><td><input id="actor_name" name="actor_name" type="text" class="regular-text" required placeholder="e.g. Bridget — Reconciliation" /></td></tr>';
|
| 187 |
+
echo '<tr><th><label for="actor_type">Type <span class="required">*</span></label></th><td><input id="actor_type" name="actor_type" type="text" class="regular-text" required placeholder="e.g. reconciliation, designer, lawyer, dev" /></td></tr>';
|
| 188 |
+
echo '<tr><th><label for="actor_persona">Persona <span class="required">*</span></label></th><td><textarea id="actor_persona" name="actor_persona" rows="4" class="large-text" required placeholder="Voice + behavior. What this actor is for. e.g. \'System-level coordination actor. Monitors shared objects, highlights divergence, suggests reconciliation.\'"></textarea></td></tr>';
|
| 189 |
+
echo '<tr><th><label for="actor_scope">Scope</label></th><td><textarea id="actor_scope" name="actor_scope" rows="3" class="large-text" placeholder="What surfaces / objects this actor may comment on. e.g. \'cross-board observations\', \'fluent-support tickets\', \'fluent-boards tasks tagged urgent\'"></textarea></td></tr>';
|
| 190 |
+
echo '</tbody></table>';
|
| 191 |
+
|
| 192 |
+
echo '<p>';
|
| 193 |
+
echo '<button type="submit" class="button button-primary">Create Actor</button> ';
|
| 194 |
+
echo '<a href="' . esc_url( $list_url ) . '" class="button">Cancel</a>';
|
| 195 |
+
echo '</p>';
|
| 196 |
+
echo '</form>';
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
private static function render_detail() {
|
| 200 |
+
$list_url = admin_url( 'admin.php?page=' . self::SUBMENU_SLUG );
|
| 201 |
+
$id = isset( $_GET['id'] ) ? (int) $_GET['id'] : 0;
|
| 202 |
+
$a = $id > 0 ? SA_Orch_Actor_Registry::get_actor( $id ) : null;
|
| 203 |
+
if ( ! $a ) {
|
| 204 |
+
echo '<p class="sa-actors-empty">Actor not found. <a href="' . esc_url( $list_url ) . '">Back to list</a></p>';
|
| 205 |
+
return;
|
| 206 |
+
}
|
| 207 |
+
echo '<h2>' . esc_html( $a['name'] ) . ' <small class="sa-actors-login">' . esc_html( $a['login'] ) . '</small></h2>';
|
| 208 |
+
echo '<p><a href="' . esc_url( $list_url ) . '">← Back to list</a></p>';
|
| 209 |
+
echo '<table class="form-table"><tbody>';
|
| 210 |
+
echo '<tr><th>Type</th> <td>' . esc_html( $a['type'] ) . '</td></tr>';
|
| 211 |
+
echo '<tr><th>Status</th> <td>' . ( $a['is_active'] ? 'active' : 'archived' ) . '</td></tr>';
|
| 212 |
+
echo '<tr><th>Owner</th> <td>' . esc_html( $a['owner_login'] ?: '—' ) . ' (user_id=' . (int) $a['owner_user_id'] . ')</td></tr>';
|
| 213 |
+
echo '<tr><th>Persona</th> <td><pre class="sa-actors-pre">' . esc_html( $a['persona'] ) . '</pre></td></tr>';
|
| 214 |
+
echo '<tr><th>Scope</th> <td><pre class="sa-actors-pre">' . esc_html( $a['scope'] ?: '—' ) . '</pre></td></tr>';
|
| 215 |
+
echo '<tr><th>Subscription tier</th> <td>' . esc_html( $a['subscription_tier'] ?: '— (reserved for future)' ) . '</td></tr>';
|
| 216 |
+
echo '<tr><th>Created</th> <td>' . esc_html( $a['created_at'] ) . '</td></tr>';
|
| 217 |
+
echo '</tbody></table>';
|
| 218 |
+
|
| 219 |
+
echo '<h3>Activity trail</h3>';
|
| 220 |
+
echo '<p class="sa-actors-meta">Actor-attributed comments and drafts will appear here once the invocation surface is implemented (deferred to a future Board). For now: empty by design.</p>';
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
private static function inline_styles(): string {
|
| 224 |
+
return '<style>
|
| 225 |
+
.sa-actors-wrap h1 .sa-actors-tag { font-size:11px; padding:2px 6px; background:#f0f0f1; color:#646970; border-radius:3px; vertical-align:middle; font-weight:normal; margin-left:8px; }
|
| 226 |
+
.sa-actors-meta { color:#646970; }
|
| 227 |
+
.sa-actors-table .sa-actors-login { color:#646970; font-family:Consolas,Monaco,monospace; font-size:11px; }
|
| 228 |
+
.sa-actors-status-active { color:#1e4620; background:#edf7ed; padding:2px 6px; border-radius:3px; font-size:11px; }
|
| 229 |
+
.sa-actors-status-archived { color:#646970; background:#f0f0f1; padding:2px 6px; border-radius:3px; font-size:11px; }
|
| 230 |
+
.sa-actors-empty { color:#646970; font-style:italic; }
|
| 231 |
+
.sa-actors-pre { background:#f6f7f7; padding:10px; border-radius:4px; white-space:pre-wrap; max-width:680px; }
|
| 232 |
+
.sa-actors-form .required { color:#d63638; }
|
| 233 |
+
</style>';
|
| 234 |
+
}
|
| 235 |
+
}
|
sa-orchestration/includes/class-sa-actor-handoff.php
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Actor Handoff Log — two-axis attribution (Board #23).
|
| 6 |
+
*
|
| 7 |
+
* Provides a clear, auditable boundary between AI-operated actor mode
|
| 8 |
+
* and human-operated actor mode WITHOUT mutating Fluent's own comment
|
| 9 |
+
* tables. Strictly additive: a small dedicated table records who
|
| 10 |
+
* "pressed send" for a given actor-authored comment.
|
| 11 |
+
*
|
| 12 |
+
* The two axes:
|
| 13 |
+
* created_by — the actor (Bridget, etc.). The displayed author. Unchanged.
|
| 14 |
+
* committed_by — the user who pressed send (a human user, or an agent
|
| 15 |
+
* service account user). Recorded here, queried via
|
| 16 |
+
* SA_Orch_Actor_Handoff::get_committer().
|
| 17 |
+
*
|
| 18 |
+
* Operator-mode detection — the full provenance chain works once Board #16
|
| 19 |
+
* (Token + Usage Visibility) ships its LLM token log. Until then, the
|
| 20 |
+
* token-presence check returns 'unknown' and detect_mode reflects that
|
| 21 |
+
* honestly:
|
| 22 |
+
*
|
| 23 |
+
* committer recorded + token row found → 'ai'
|
| 24 |
+
* committer recorded + token row NOT found → 'human'
|
| 25 |
+
* committer recorded + token state unknown → 'unknown' (pre-#16; honest)
|
| 26 |
+
* committer NOT recorded → 'unknown'
|
| 27 |
+
*
|
| 28 |
+
* The token-presence check is implemented as a filter:
|
| 29 |
+
*
|
| 30 |
+
* apply_filters( 'sa_orch_handoff_has_llm_token', null, $kind, $comment_id );
|
| 31 |
+
*
|
| 32 |
+
* Board #16 will register a callback that consults the token log. The
|
| 33 |
+
* default return of null means "unknown" — never a false positive.
|
| 34 |
+
*
|
| 35 |
+
* Doctrine constraints honored:
|
| 36 |
+
* - Actors remain non-binding; this layer is observational, not enforcing
|
| 37 |
+
* - No impact to Board progression rules
|
| 38 |
+
* - No schema refactors — strictly additive (one new table)
|
| 39 |
+
* - No Asterion writes
|
| 40 |
+
* - No automation
|
| 41 |
+
* - No UI surface beyond what already exists
|
| 42 |
+
*/
|
| 43 |
+
class SA_Orch_Actor_Handoff {
|
| 44 |
+
|
| 45 |
+
const KIND_FLUENT_BOARDS = 'fluent_boards';
|
| 46 |
+
const KIND_FLUENT_SUPPORT = 'fluent_support';
|
| 47 |
+
|
| 48 |
+
const MODE_AI = 'ai';
|
| 49 |
+
const MODE_HUMAN = 'human';
|
| 50 |
+
const MODE_UNKNOWN = 'unknown';
|
| 51 |
+
|
| 52 |
+
private static function table(): string {
|
| 53 |
+
global $wpdb;
|
| 54 |
+
return $wpdb->prefix . SA_ORCH_DB_PREFIX . 'actor_handoff';
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/**
|
| 58 |
+
* Record (or update) the committer for an actor-authored comment.
|
| 59 |
+
* Idempotent on (kind, comment_id) — re-recording replaces the prior row.
|
| 60 |
+
*
|
| 61 |
+
* Returns true on success, false on invalid input or DB failure.
|
| 62 |
+
*/
|
| 63 |
+
public static function record_committer( string $kind, int $comment_id, int $committed_by_user_id ): bool {
|
| 64 |
+
if ( $comment_id <= 0 || $committed_by_user_id <= 0 ) {
|
| 65 |
+
return false;
|
| 66 |
+
}
|
| 67 |
+
$kind = sanitize_key( $kind );
|
| 68 |
+
if ( $kind === '' ) return false;
|
| 69 |
+
|
| 70 |
+
global $wpdb;
|
| 71 |
+
$tbl = self::table();
|
| 72 |
+
// Replace prior row, if any. Keeps the table thin.
|
| 73 |
+
$wpdb->delete( $tbl, [
|
| 74 |
+
'comment_kind' => $kind,
|
| 75 |
+
'comment_id' => $comment_id,
|
| 76 |
+
] );
|
| 77 |
+
$ok = $wpdb->insert( $tbl, [
|
| 78 |
+
'comment_kind' => $kind,
|
| 79 |
+
'comment_id' => $comment_id,
|
| 80 |
+
'committed_by_user_id' => $committed_by_user_id,
|
| 81 |
+
'recorded_at' => current_time( 'mysql', true ),
|
| 82 |
+
] );
|
| 83 |
+
return $ok !== false;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
/**
|
| 87 |
+
* Get the recorded committer for a comment, or null if none recorded.
|
| 88 |
+
*/
|
| 89 |
+
public static function get_committer( string $kind, int $comment_id ): ?int {
|
| 90 |
+
if ( $comment_id <= 0 ) return null;
|
| 91 |
+
$kind = sanitize_key( $kind );
|
| 92 |
+
if ( $kind === '' ) return null;
|
| 93 |
+
|
| 94 |
+
global $wpdb;
|
| 95 |
+
$row = $wpdb->get_var( $wpdb->prepare(
|
| 96 |
+
"SELECT committed_by_user_id FROM " . self::table() . "
|
| 97 |
+
WHERE comment_kind = %s AND comment_id = %d
|
| 98 |
+
ORDER BY id DESC LIMIT 1",
|
| 99 |
+
$kind,
|
| 100 |
+
$comment_id
|
| 101 |
+
) );
|
| 102 |
+
return $row !== null ? (int) $row : null;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
/**
|
| 106 |
+
* Detect operator mode for a comment. Returns one of the MODE_* constants.
|
| 107 |
+
* Never lies about uncertainty — returns 'unknown' rather than guessing.
|
| 108 |
+
*/
|
| 109 |
+
public static function detect_mode( string $kind, int $comment_id ): string {
|
| 110 |
+
$committer = self::get_committer( $kind, $comment_id );
|
| 111 |
+
if ( $committer === null ) {
|
| 112 |
+
return self::MODE_UNKNOWN;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
/**
|
| 116 |
+
* Filter that signals whether an LLM token row exists for this commit.
|
| 117 |
+
* Returns: true (token row found → AI mode),
|
| 118 |
+
* false (no token row → human mode),
|
| 119 |
+
* null (unknown — token log not yet integrated, default).
|
| 120 |
+
*
|
| 121 |
+
* Board #16 will register a callback that consults the token log.
|
| 122 |
+
*/
|
| 123 |
+
$token_present = apply_filters(
|
| 124 |
+
'sa_orch_handoff_has_llm_token',
|
| 125 |
+
null,
|
| 126 |
+
$kind,
|
| 127 |
+
$comment_id
|
| 128 |
+
);
|
| 129 |
+
|
| 130 |
+
if ( $token_present === null ) return self::MODE_UNKNOWN;
|
| 131 |
+
if ( $token_present === true ) return self::MODE_AI;
|
| 132 |
+
return self::MODE_HUMAN;
|
| 133 |
+
}
|
| 134 |
+
}
|
sa-orchestration/includes/class-sa-actor-registry.php
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Actor Registry — WP-User-backed identity layer (Board #22).
|
| 6 |
+
*
|
| 7 |
+
* Actors are persistent identity-bound roles — NOT disposable NPCs. Each
|
| 8 |
+
* actor is a real WordPress user with the 'sa_actor' role and structured
|
| 9 |
+
* metadata recorded in user_meta. The wp_users row + meta together IS the
|
| 10 |
+
* actor's persistent identity for this Board.
|
| 11 |
+
*
|
| 12 |
+
* Doctrine constraints (locked, non-negotiable):
|
| 13 |
+
* Actors may: speak, draft, advise, observe, coordinate
|
| 14 |
+
* Actors may NOT: progress Boards, accept/close tasks, mutate Asterion,
|
| 15 |
+
* silently act
|
| 16 |
+
*
|
| 17 |
+
* Sara grants voice. Asterion grants permission. Human grants progress.
|
| 18 |
+
*
|
| 19 |
+
* Implementation notes:
|
| 20 |
+
* - 'sa_actor' role gets only 'read' capability — nothing else. Actors
|
| 21 |
+
* are not real operators; they exist to be attributed.
|
| 22 |
+
* - Asterion ledger MD writes for actor records are out of this Board's
|
| 23 |
+
* scope. The dormant SA_Orch_Asterion writer stays dormant. Future
|
| 24 |
+
* Board can add literal canonical writes when the write surface
|
| 25 |
+
* activates.
|
| 26 |
+
* - Archive (sa_actor_is_active='0'), never delete. Actors are retired,
|
| 27 |
+
* not destroyed. Continuity preserved.
|
| 28 |
+
*/
|
| 29 |
+
class SA_Orch_Actor_Registry {
|
| 30 |
+
|
| 31 |
+
const ROLE = 'sa_actor';
|
| 32 |
+
|
| 33 |
+
// user_meta keys — namespaced 'sa_actor_*' to avoid collision with other plugins
|
| 34 |
+
const META_TYPE = 'sa_actor_type';
|
| 35 |
+
const META_PERSONA = 'sa_actor_persona';
|
| 36 |
+
const META_SCOPE = 'sa_actor_scope';
|
| 37 |
+
const META_OWNER_USER_ID = 'sa_actor_owner_user_id';
|
| 38 |
+
const META_IS_ACTIVE = 'sa_actor_is_active';
|
| 39 |
+
const META_SUBSCRIPTION_TIER = 'sa_actor_subscription_tier';
|
| 40 |
+
|
| 41 |
+
/** Idempotent role registration. Hooked on init. */
|
| 42 |
+
public static function register_role() {
|
| 43 |
+
if ( ! get_role( self::ROLE ) ) {
|
| 44 |
+
add_role( self::ROLE, __( 'SA Actor', 'sa-orchestration' ), [
|
| 45 |
+
'read' => true,
|
| 46 |
+
] );
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* List actors. Returns an array of WP_User objects.
|
| 52 |
+
* By default excludes archived actors; pass include_archived=true to see all.
|
| 53 |
+
*/
|
| 54 |
+
public static function list_actors( bool $include_archived = false ): array {
|
| 55 |
+
$args = [
|
| 56 |
+
'role' => self::ROLE,
|
| 57 |
+
'orderby' => 'display_name',
|
| 58 |
+
'order' => 'ASC',
|
| 59 |
+
'number' => 200,
|
| 60 |
+
];
|
| 61 |
+
if ( ! $include_archived ) {
|
| 62 |
+
$args['meta_query'] = [
|
| 63 |
+
'relation' => 'OR',
|
| 64 |
+
[ 'key' => self::META_IS_ACTIVE, 'value' => '1', 'compare' => '=' ],
|
| 65 |
+
[ 'key' => self::META_IS_ACTIVE, 'compare' => 'NOT EXISTS' ], // legacy/unset → treat as active
|
| 66 |
+
];
|
| 67 |
+
}
|
| 68 |
+
$users = get_users( $args );
|
| 69 |
+
return is_array( $users ) ? $users : [];
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
/**
|
| 73 |
+
* Hydrate an actor's full record. Returns null if user does not exist
|
| 74 |
+
* or does not have the sa_actor role (defensive — never expose
|
| 75 |
+
* non-actor users through this surface).
|
| 76 |
+
*/
|
| 77 |
+
public static function get_actor( int $user_id ): ?array {
|
| 78 |
+
$user = get_userdata( $user_id );
|
| 79 |
+
if ( ! $user || ! in_array( self::ROLE, (array) $user->roles, true ) ) {
|
| 80 |
+
return null;
|
| 81 |
+
}
|
| 82 |
+
$owner_id = (int) get_user_meta( $user_id, self::META_OWNER_USER_ID, true );
|
| 83 |
+
$owner_user = $owner_id ? get_userdata( $owner_id ) : null;
|
| 84 |
+
$is_active_v = get_user_meta( $user_id, self::META_IS_ACTIVE, true );
|
| 85 |
+
return [
|
| 86 |
+
'user_id' => (int) $user->ID,
|
| 87 |
+
'name' => (string) $user->display_name,
|
| 88 |
+
'login' => (string) $user->user_login,
|
| 89 |
+
'email' => (string) $user->user_email,
|
| 90 |
+
'type' => (string) get_user_meta( $user_id, self::META_TYPE, true ),
|
| 91 |
+
'persona' => (string) get_user_meta( $user_id, self::META_PERSONA, true ),
|
| 92 |
+
'scope' => (string) get_user_meta( $user_id, self::META_SCOPE, true ),
|
| 93 |
+
'owner_user_id' => $owner_id,
|
| 94 |
+
'owner_login' => $owner_user ? (string) $owner_user->user_login : '',
|
| 95 |
+
'is_active' => ( $is_active_v === '' || $is_active_v === '1' ),
|
| 96 |
+
'subscription_tier' => (string) get_user_meta( $user_id, self::META_SUBSCRIPTION_TIER, true ),
|
| 97 |
+
'created_at' => (string) $user->user_registered,
|
| 98 |
+
];
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
/**
|
| 102 |
+
* Create an actor. Required: name, type, persona (creation discipline —
|
| 103 |
+
* no nameless or roleless or purposeless actors). Owner defaults to
|
| 104 |
+
* current user. Email is synthetic if not provided (actors are not
|
| 105 |
+
* meant to log in).
|
| 106 |
+
*
|
| 107 |
+
* Returns the new user_id (int) or WP_Error on validation/creation failure.
|
| 108 |
+
*/
|
| 109 |
+
public static function create_actor( array $data ) {
|
| 110 |
+
$name = isset( $data['name'] ) ? trim( (string) $data['name'] ) : '';
|
| 111 |
+
$type = isset( $data['type'] ) ? trim( (string) $data['type'] ) : '';
|
| 112 |
+
$persona = isset( $data['persona'] ) ? trim( (string) $data['persona'] ) : '';
|
| 113 |
+
$scope = isset( $data['scope'] ) ? trim( (string) $data['scope'] ) : '';
|
| 114 |
+
$email = isset( $data['email'] ) ? trim( (string) $data['email'] ) : '';
|
| 115 |
+
$owner = isset( $data['owner_user_id'] ) ? (int) $data['owner_user_id'] : (int) get_current_user_id();
|
| 116 |
+
|
| 117 |
+
if ( $name === '' ) return new WP_Error( 'actor_name_required', 'Actor name is required (intent — no random assistants).' );
|
| 118 |
+
if ( $type === '' ) return new WP_Error( 'actor_type_required', 'Actor type is required (defined role).' );
|
| 119 |
+
if ( $persona === '' ) return new WP_Error( 'actor_persona_required', 'Actor persona is required (scoped purpose).' );
|
| 120 |
+
|
| 121 |
+
// Synthesize a unique login from the name. WP requires unique user_login.
|
| 122 |
+
$login_base = sanitize_user( strtolower( str_replace( ' ', '-', $name ) ), true );
|
| 123 |
+
if ( $login_base === '' ) $login_base = 'sa-actor';
|
| 124 |
+
$login = $login_base;
|
| 125 |
+
$i = 1;
|
| 126 |
+
while ( username_exists( $login ) ) {
|
| 127 |
+
$login = $login_base . '-' . $i++;
|
| 128 |
+
if ( $i > 200 ) {
|
| 129 |
+
return new WP_Error( 'actor_login_exhausted', 'Could not generate unique login for this actor name.' );
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
// Synthesize an email if not provided. Actors live as WP users for
|
| 134 |
+
// identity but are not expected to log in or receive mail.
|
| 135 |
+
if ( $email === '' ) {
|
| 136 |
+
$email = $login . '@sleeperagents.local';
|
| 137 |
+
}
|
| 138 |
+
if ( email_exists( $email ) ) {
|
| 139 |
+
$email = $login . '+' . wp_generate_password( 6, false ) . '@sleeperagents.local';
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
// Random password — actors are not meant to log in. We never expose it.
|
| 143 |
+
$password = wp_generate_password( 32, true, true );
|
| 144 |
+
|
| 145 |
+
$user_id = wp_insert_user( [
|
| 146 |
+
'user_login' => $login,
|
| 147 |
+
'user_email' => $email,
|
| 148 |
+
'user_pass' => $password,
|
| 149 |
+
'display_name' => $name,
|
| 150 |
+
'first_name' => $name,
|
| 151 |
+
'role' => self::ROLE,
|
| 152 |
+
] );
|
| 153 |
+
if ( is_wp_error( $user_id ) ) {
|
| 154 |
+
return $user_id;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
update_user_meta( $user_id, self::META_TYPE, $type );
|
| 158 |
+
update_user_meta( $user_id, self::META_PERSONA, $persona );
|
| 159 |
+
update_user_meta( $user_id, self::META_SCOPE, $scope );
|
| 160 |
+
update_user_meta( $user_id, self::META_OWNER_USER_ID, $owner );
|
| 161 |
+
update_user_meta( $user_id, self::META_IS_ACTIVE, '1' );
|
| 162 |
+
update_user_meta( $user_id, self::META_SUBSCRIPTION_TIER, '' );
|
| 163 |
+
|
| 164 |
+
/**
|
| 165 |
+
* Hook for downstream consumers (e.g. a future Asterion ledger writer
|
| 166 |
+
* that records canonical actor identity in markdown). Today nothing
|
| 167 |
+
* listens; the hook exists so the future write surface can attach
|
| 168 |
+
* without re-architecting this Board.
|
| 169 |
+
*/
|
| 170 |
+
do_action( 'sa_orch_actor_created', (int) $user_id, $data );
|
| 171 |
+
|
| 172 |
+
return (int) $user_id;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
/** Archive an actor (sets is_active=0). Returns true on success. */
|
| 176 |
+
public static function archive_actor( int $user_id ): bool {
|
| 177 |
+
$actor = self::get_actor( $user_id );
|
| 178 |
+
if ( ! $actor ) return false;
|
| 179 |
+
update_user_meta( $user_id, self::META_IS_ACTIVE, '0' );
|
| 180 |
+
do_action( 'sa_orch_actor_archived', $user_id );
|
| 181 |
+
return true;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
/** Unarchive an actor (sets is_active=1). */
|
| 185 |
+
public static function unarchive_actor( int $user_id ): bool {
|
| 186 |
+
$actor = self::get_actor( $user_id );
|
| 187 |
+
if ( ! $actor ) return false;
|
| 188 |
+
update_user_meta( $user_id, self::META_IS_ACTIVE, '1' );
|
| 189 |
+
do_action( 'sa_orch_actor_unarchived', $user_id );
|
| 190 |
+
return true;
|
| 191 |
+
}
|
| 192 |
+
}
|
sa-orchestration/includes/class-sa-adapter-fluent-boards.php
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Fluent Boards surface adapter.
|
| 6 |
+
*
|
| 7 |
+
* Resolves task#NNN refs against wp_fbs_tasks. ALL Fluent Boards schema
|
| 8 |
+
* knowledge lives here. If Fluent Boards changes its schema (renames a
|
| 9 |
+
* column, drops a field, splits a record), the change lands in this
|
| 10 |
+
* file alone. Consumers of the adapter never see the schema directly.
|
| 11 |
+
*
|
| 12 |
+
* Ref shape: 'task#<positive integer>'
|
| 13 |
+
*/
|
| 14 |
+
class SA_Orch_Adapter_FluentBoards implements SA_Orch_Surface_Adapter {
|
| 15 |
+
|
| 16 |
+
public function id(): string {
|
| 17 |
+
return 'fluent-boards';
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
public function validate( string $ref ): bool {
|
| 21 |
+
return $this->resolve( $ref ) !== null;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
public function resolve( string $ref ) {
|
| 25 |
+
global $wpdb;
|
| 26 |
+
if ( ! preg_match( '/^task#(\d+)$/', $ref, $m ) ) {
|
| 27 |
+
return null;
|
| 28 |
+
}
|
| 29 |
+
$row = $wpdb->get_row( $wpdb->prepare(
|
| 30 |
+
"SELECT * FROM {$wpdb->prefix}fbs_tasks WHERE id = %d",
|
| 31 |
+
(int) $m[1]
|
| 32 |
+
) );
|
| 33 |
+
return $row ?: null;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
public function label( string $ref ): string {
|
| 37 |
+
$row = $this->resolve( $ref );
|
| 38 |
+
if ( ! $row ) {
|
| 39 |
+
return $ref;
|
| 40 |
+
}
|
| 41 |
+
// Title is the canonical label per current Fluent Boards schema.
|
| 42 |
+
// If schema changes (e.g. title becomes a sub-field, gets renamed,
|
| 43 |
+
// requires a join), update this method only.
|
| 44 |
+
if ( isset( $row->title ) && trim( (string) $row->title ) !== '' ) {
|
| 45 |
+
return (string) $row->title;
|
| 46 |
+
}
|
| 47 |
+
return $ref;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* Structured snapshot of the task — title, board name, stage, status,
|
| 52 |
+
* description summary, recent activity excerpts. All Fluent Boards schema
|
| 53 |
+
* knowledge stays bounded to this method; consumers receive a stable
|
| 54 |
+
* shape regardless of future schema changes.
|
| 55 |
+
*/
|
| 56 |
+
public function hydrate( string $ref ): ?array {
|
| 57 |
+
global $wpdb;
|
| 58 |
+
if ( ! preg_match( '/^task#(\d+)$/', $ref, $m ) ) {
|
| 59 |
+
return null;
|
| 60 |
+
}
|
| 61 |
+
$task_id = (int) $m[1];
|
| 62 |
+
|
| 63 |
+
$task = $wpdb->get_row( $wpdb->prepare(
|
| 64 |
+
"SELECT id, title, status, description, board_id, stage_id, created_at, updated_at
|
| 65 |
+
FROM {$wpdb->prefix}fbs_tasks WHERE id = %d",
|
| 66 |
+
$task_id
|
| 67 |
+
) );
|
| 68 |
+
if ( ! $task ) {
|
| 69 |
+
return null;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
$board_name = (string) $wpdb->get_var( $wpdb->prepare(
|
| 73 |
+
"SELECT title FROM {$wpdb->prefix}fbs_boards WHERE id = %d",
|
| 74 |
+
(int) $task->board_id
|
| 75 |
+
) );
|
| 76 |
+
$stage_name = (string) $wpdb->get_var( $wpdb->prepare(
|
| 77 |
+
"SELECT title FROM {$wpdb->prefix}fbs_board_terms WHERE id = %d",
|
| 78 |
+
(int) $task->stage_id
|
| 79 |
+
) );
|
| 80 |
+
|
| 81 |
+
$recent = $wpdb->get_results( $wpdb->prepare(
|
| 82 |
+
"SELECT description, created_at FROM {$wpdb->prefix}fbs_comments
|
| 83 |
+
WHERE task_id = %d
|
| 84 |
+
ORDER BY created_at DESC, id DESC LIMIT 3",
|
| 85 |
+
$task_id
|
| 86 |
+
) );
|
| 87 |
+
$activity = [];
|
| 88 |
+
foreach ( (array) $recent as $row ) {
|
| 89 |
+
$text = trim( wp_strip_all_tags( (string) $row->description ) );
|
| 90 |
+
if ( $text === '' ) continue;
|
| 91 |
+
$activity[] = [
|
| 92 |
+
'created_at' => (string) $row->created_at,
|
| 93 |
+
'excerpt' => function_exists( 'mb_substr' )
|
| 94 |
+
? mb_substr( $text, 0, 240 )
|
| 95 |
+
: substr( $text, 0, 240 ),
|
| 96 |
+
];
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
$description = trim( wp_strip_all_tags( (string) $task->description ) );
|
| 100 |
+
if ( function_exists( 'mb_substr' ) ) {
|
| 101 |
+
$description = mb_substr( $description, 0, 500 );
|
| 102 |
+
} else {
|
| 103 |
+
$description = substr( $description, 0, 500 );
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
return [
|
| 107 |
+
'ref' => $ref,
|
| 108 |
+
'title' => (string) $task->title,
|
| 109 |
+
'board_name' => $board_name,
|
| 110 |
+
'stage' => $stage_name,
|
| 111 |
+
'status' => (string) $task->status,
|
| 112 |
+
'description' => $description,
|
| 113 |
+
'recent_activity' => $activity,
|
| 114 |
+
];
|
| 115 |
+
}
|
| 116 |
+
}
|
sa-orchestration/includes/class-sa-adapter-fluent-support.php
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Fluent Support surface adapter.
|
| 6 |
+
*
|
| 7 |
+
* Resolves ticket#NNN refs against wp_fs_tickets. ALL Fluent Support
|
| 8 |
+
* schema knowledge lives here. If Fluent Support changes its schema
|
| 9 |
+
* (renames a column, drops a field, splits a record), the change lands
|
| 10 |
+
* in this file alone. Consumers of the adapter never see the schema
|
| 11 |
+
* directly.
|
| 12 |
+
*
|
| 13 |
+
* Schema bounded to this adapter:
|
| 14 |
+
* wp_fs_tickets (id, customer_id, title, status, priority, content, created_at)
|
| 15 |
+
* wp_fs_persons (first_name, last_name, email; person_type='customer')
|
| 16 |
+
* wp_fs_conversations (ticket_id, person_id, conversation_type, content, created_at)
|
| 17 |
+
*
|
| 18 |
+
* NOTE: The customer display name is composed in PHP from first_name +
|
| 19 |
+
* last_name with email fallback. There is intentionally NO use of
|
| 20 |
+
* `full_name` — that is an ORM-layer accessor on the FluentSupport Person
|
| 21 |
+
* model (declared via $appends), not a real database column. SELECTing
|
| 22 |
+
* full_name from wp_fs_persons would error.
|
| 23 |
+
*
|
| 24 |
+
* Conversation snippets are filtered to conversation_type='response',
|
| 25 |
+
* which excludes 'internal_info' rows (FluentSupport's own internal
|
| 26 |
+
* notes, including SA-Orchestration's own ticket-tracking comments).
|
| 27 |
+
*
|
| 28 |
+
* Ref shape: 'ticket#<positive integer>'
|
| 29 |
+
*/
|
| 30 |
+
class SA_Orch_Adapter_FluentSupport implements SA_Orch_Surface_Adapter {
|
| 31 |
+
|
| 32 |
+
public function id(): string {
|
| 33 |
+
return 'fluent-support';
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
public function validate( string $ref ): bool {
|
| 37 |
+
return $this->resolve( $ref ) !== null;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
public function resolve( string $ref ) {
|
| 41 |
+
global $wpdb;
|
| 42 |
+
if ( ! preg_match( '/^ticket#(\d+)$/', $ref, $m ) ) {
|
| 43 |
+
return null;
|
| 44 |
+
}
|
| 45 |
+
$row = $wpdb->get_row( $wpdb->prepare(
|
| 46 |
+
"SELECT * FROM {$wpdb->prefix}fs_tickets WHERE id = %d",
|
| 47 |
+
(int) $m[1]
|
| 48 |
+
) );
|
| 49 |
+
return $row ?: null;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
public function label( string $ref ): string {
|
| 53 |
+
$row = $this->resolve( $ref );
|
| 54 |
+
if ( ! $row ) {
|
| 55 |
+
return $ref;
|
| 56 |
+
}
|
| 57 |
+
if ( isset( $row->title ) && trim( (string) $row->title ) !== '' ) {
|
| 58 |
+
return (string) $row->title;
|
| 59 |
+
}
|
| 60 |
+
return $ref;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
/**
|
| 64 |
+
* Structured snapshot of the ticket — title, status, priority, customer
|
| 65 |
+
* display name, description excerpt, and the latest visible conversation
|
| 66 |
+
* snippets (customer/agent dialogue only). All FluentSupport schema
|
| 67 |
+
* knowledge stays bounded to this method; consumers receive a stable
|
| 68 |
+
* shape regardless of future schema changes.
|
| 69 |
+
*
|
| 70 |
+
* Graceful fallback per field:
|
| 71 |
+
* - missing customer row → omit 'customer'
|
| 72 |
+
* - first_name + last_name both empty → fall back to email
|
| 73 |
+
* - email also empty → omit 'customer'
|
| 74 |
+
* - empty ticket content → omit 'description'
|
| 75 |
+
* - no visible conversations → 'recent_activity' is empty array
|
| 76 |
+
*/
|
| 77 |
+
public function hydrate( string $ref ): ?array {
|
| 78 |
+
global $wpdb;
|
| 79 |
+
if ( ! preg_match( '/^ticket#(\d+)$/', $ref, $m ) ) {
|
| 80 |
+
return null;
|
| 81 |
+
}
|
| 82 |
+
$ticket_id = (int) $m[1];
|
| 83 |
+
|
| 84 |
+
$ticket = $wpdb->get_row( $wpdb->prepare(
|
| 85 |
+
"SELECT id, customer_id, title, status, priority, content, created_at, updated_at
|
| 86 |
+
FROM {$wpdb->prefix}fs_tickets WHERE id = %d",
|
| 87 |
+
$ticket_id
|
| 88 |
+
) );
|
| 89 |
+
if ( ! $ticket ) {
|
| 90 |
+
return null;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
$out = [
|
| 94 |
+
'ref' => $ref,
|
| 95 |
+
'title' => (string) $ticket->title,
|
| 96 |
+
'status' => (string) $ticket->status,
|
| 97 |
+
];
|
| 98 |
+
|
| 99 |
+
// Priority is meaningful for support tickets (normal, high, urgent...).
|
| 100 |
+
// Optional — only emit if non-empty.
|
| 101 |
+
if ( ! empty( $ticket->priority ) ) {
|
| 102 |
+
$out['priority'] = (string) $ticket->priority;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
// Customer — composed name from first_name + last_name; email fallback;
|
| 106 |
+
// omit entirely if neither is available.
|
| 107 |
+
$customer = $this->compose_customer_label( (int) $ticket->customer_id );
|
| 108 |
+
if ( $customer !== '' ) {
|
| 109 |
+
$out['customer'] = $customer;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// Description excerpt (HTML stripped, ~500 chars).
|
| 113 |
+
$description = trim( wp_strip_all_tags( (string) $ticket->content ) );
|
| 114 |
+
if ( $description !== '' ) {
|
| 115 |
+
$out['description'] = function_exists( 'mb_substr' )
|
| 116 |
+
? mb_substr( $description, 0, 500 )
|
| 117 |
+
: substr( $description, 0, 500 );
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
// Recent customer/agent dialogue — last 3 visible exchanges.
|
| 121 |
+
// Filter to conversation_type='response' so SA-Orchestration's own
|
| 122 |
+
// internal_info tracking rows do not bleed into Sara's context.
|
| 123 |
+
$recent = $wpdb->get_results( $wpdb->prepare(
|
| 124 |
+
"SELECT id, person_id, content, created_at
|
| 125 |
+
FROM {$wpdb->prefix}fs_conversations
|
| 126 |
+
WHERE ticket_id = %d AND conversation_type = 'response'
|
| 127 |
+
ORDER BY created_at DESC, id DESC LIMIT 3",
|
| 128 |
+
$ticket_id
|
| 129 |
+
) );
|
| 130 |
+
$activity = [];
|
| 131 |
+
foreach ( (array) $recent as $row ) {
|
| 132 |
+
$text = trim( wp_strip_all_tags( (string) $row->content ) );
|
| 133 |
+
if ( $text === '' ) continue;
|
| 134 |
+
$excerpt = function_exists( 'mb_substr' )
|
| 135 |
+
? mb_substr( $text, 0, 240 )
|
| 136 |
+
: substr( $text, 0, 240 );
|
| 137 |
+
$by = $this->compose_person_label( (int) $row->person_id );
|
| 138 |
+
$item = [
|
| 139 |
+
'created_at' => (string) $row->created_at,
|
| 140 |
+
'excerpt' => $excerpt,
|
| 141 |
+
];
|
| 142 |
+
if ( $by !== '' ) {
|
| 143 |
+
$item['by'] = $by;
|
| 144 |
+
}
|
| 145 |
+
$activity[] = $item;
|
| 146 |
+
}
|
| 147 |
+
$out['recent_activity'] = $activity;
|
| 148 |
+
|
| 149 |
+
return $out;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
/**
|
| 153 |
+
* Compose a display label for a customer person row.
|
| 154 |
+
* Returns empty string if the person row is missing or has no name/email.
|
| 155 |
+
*
|
| 156 |
+
* Order: "first last" → email → '' (caller should omit the field).
|
| 157 |
+
* Never reads $appends accessors like full_name — those exist only at
|
| 158 |
+
* the ORM layer.
|
| 159 |
+
*/
|
| 160 |
+
private function compose_customer_label( int $person_id ): string {
|
| 161 |
+
if ( $person_id <= 0 ) {
|
| 162 |
+
return '';
|
| 163 |
+
}
|
| 164 |
+
global $wpdb;
|
| 165 |
+
$row = $wpdb->get_row( $wpdb->prepare(
|
| 166 |
+
"SELECT first_name, last_name, email
|
| 167 |
+
FROM {$wpdb->prefix}fs_persons
|
| 168 |
+
WHERE id = %d AND person_type = 'customer'",
|
| 169 |
+
$person_id
|
| 170 |
+
) );
|
| 171 |
+
if ( ! $row ) {
|
| 172 |
+
return '';
|
| 173 |
+
}
|
| 174 |
+
return $this->compose_name_from_row( $row );
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
/**
|
| 178 |
+
* Compose a display label for any person (customer OR agent).
|
| 179 |
+
* Used for conversation authors — agents are people too, but they have
|
| 180 |
+
* person_type='agent' rather than 'customer', so this query is unscoped.
|
| 181 |
+
* Returns empty string if the person row is missing or has no name/email.
|
| 182 |
+
*/
|
| 183 |
+
private function compose_person_label( int $person_id ): string {
|
| 184 |
+
if ( $person_id <= 0 ) {
|
| 185 |
+
return '';
|
| 186 |
+
}
|
| 187 |
+
global $wpdb;
|
| 188 |
+
$row = $wpdb->get_row( $wpdb->prepare(
|
| 189 |
+
"SELECT first_name, last_name, email
|
| 190 |
+
FROM {$wpdb->prefix}fs_persons
|
| 191 |
+
WHERE id = %d",
|
| 192 |
+
$person_id
|
| 193 |
+
) );
|
| 194 |
+
if ( ! $row ) {
|
| 195 |
+
return '';
|
| 196 |
+
}
|
| 197 |
+
return $this->compose_name_from_row( $row );
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
/**
|
| 201 |
+
* Shared name composition: "first last" → email → ''.
|
| 202 |
+
* No access to ORM-only fields like full_name.
|
| 203 |
+
*/
|
| 204 |
+
private function compose_name_from_row( $row ): string {
|
| 205 |
+
$first = isset( $row->first_name ) ? trim( (string) $row->first_name ) : '';
|
| 206 |
+
$last = isset( $row->last_name ) ? trim( (string) $row->last_name ) : '';
|
| 207 |
+
$name = trim( $first . ' ' . $last );
|
| 208 |
+
if ( $name !== '' ) {
|
| 209 |
+
return $name;
|
| 210 |
+
}
|
| 211 |
+
$email = isset( $row->email ) ? trim( (string) $row->email ) : '';
|
| 212 |
+
return $email;
|
| 213 |
+
}
|
| 214 |
+
}
|
sa-orchestration/includes/class-sa-admin.php
ADDED
|
@@ -0,0 +1,957 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Read-only admin visibility layer.
|
| 6 |
+
*
|
| 7 |
+
* Top-level menu "SA-Orchestration" with three subpages:
|
| 8 |
+
* - Demand Trace (form + full trace including v0.2 closure fields)
|
| 9 |
+
* - Recent Arbitrations (last 20 arbitrations)
|
| 10 |
+
* - Acceptance Events (last 20 acceptance events)
|
| 11 |
+
*
|
| 12 |
+
* No editing. No new tables. Reuses SA_Orch_Rest::get_demand_trace() and
|
| 13 |
+
* SA_Orch_Acceptance helpers — no logic duplication.
|
| 14 |
+
*/
|
| 15 |
+
class SA_Orch_Admin {
|
| 16 |
+
|
| 17 |
+
const PARENT_SLUG = 'sa-orchestration';
|
| 18 |
+
const ARB_SLUG = 'sa-orchestration-arbitrations';
|
| 19 |
+
const EVENT_SLUG = 'sa-orchestration-events';
|
| 20 |
+
const LLM_SLUG = 'sa-orchestration-llm';
|
| 21 |
+
const CAP = 'manage_options';
|
| 22 |
+
|
| 23 |
+
public static function register_menu() {
|
| 24 |
+
add_menu_page(
|
| 25 |
+
'SA-Orchestration',
|
| 26 |
+
'SA-Orchestration',
|
| 27 |
+
self::CAP,
|
| 28 |
+
self::PARENT_SLUG,
|
| 29 |
+
[ __CLASS__, 'render_trace' ],
|
| 30 |
+
'dashicons-networking',
|
| 31 |
+
80
|
| 32 |
+
);
|
| 33 |
+
|
| 34 |
+
// First submenu — reusing the parent slug renames the auto-generated
|
| 35 |
+
// submenu from "SA-Orchestration" to "Demand Trace".
|
| 36 |
+
add_submenu_page(
|
| 37 |
+
self::PARENT_SLUG,
|
| 38 |
+
'Demand Trace',
|
| 39 |
+
'Demand Trace',
|
| 40 |
+
self::CAP,
|
| 41 |
+
self::PARENT_SLUG,
|
| 42 |
+
[ __CLASS__, 'render_trace' ]
|
| 43 |
+
);
|
| 44 |
+
|
| 45 |
+
add_submenu_page(
|
| 46 |
+
self::PARENT_SLUG,
|
| 47 |
+
'Recent Arbitrations',
|
| 48 |
+
'Recent Arbitrations',
|
| 49 |
+
self::CAP,
|
| 50 |
+
self::ARB_SLUG,
|
| 51 |
+
[ __CLASS__, 'render_arbitrations' ]
|
| 52 |
+
);
|
| 53 |
+
|
| 54 |
+
add_submenu_page(
|
| 55 |
+
self::PARENT_SLUG,
|
| 56 |
+
'Acceptance Events',
|
| 57 |
+
'Acceptance Events',
|
| 58 |
+
self::CAP,
|
| 59 |
+
self::EVENT_SLUG,
|
| 60 |
+
[ __CLASS__, 'render_events' ]
|
| 61 |
+
);
|
| 62 |
+
|
| 63 |
+
add_submenu_page(
|
| 64 |
+
self::PARENT_SLUG,
|
| 65 |
+
'Language Model Providers',
|
| 66 |
+
'Language Model Providers',
|
| 67 |
+
self::CAP,
|
| 68 |
+
self::LLM_SLUG,
|
| 69 |
+
[ __CLASS__, 'render_llm_settings' ]
|
| 70 |
+
);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
/* ---------- Demand Trace ---------- */
|
| 74 |
+
|
| 75 |
+
public static function render_trace() {
|
| 76 |
+
if ( ! current_user_can( self::CAP ) ) wp_die( 'Insufficient permissions.' );
|
| 77 |
+
|
| 78 |
+
$surface = isset( $_GET['demand_surface'] ) ? sanitize_text_field( wp_unslash( $_GET['demand_surface'] ) ) : '';
|
| 79 |
+
$ref = isset( $_GET['demand_ref'] ) ? sanitize_text_field( wp_unslash( $_GET['demand_ref'] ) ) : '';
|
| 80 |
+
|
| 81 |
+
echo '<div class="wrap">';
|
| 82 |
+
echo '<h1>Demand Trace</h1>';
|
| 83 |
+
echo '<p>Walk a Demand through arbitration → plan_links → acceptance chain → closure verdicts.</p>';
|
| 84 |
+
|
| 85 |
+
// Picker form (replaces v0.6.x free-form text inputs).
|
| 86 |
+
self::render_picker_form( $surface, $ref );
|
| 87 |
+
|
| 88 |
+
if ( $surface !== '' && $ref !== '' ) {
|
| 89 |
+
// Reuse the REST handler. Bypasses URI routing (avoids URL-encoding fragility around `#`).
|
| 90 |
+
$req = new WP_REST_Request( 'GET' );
|
| 91 |
+
$req->set_url_params( [ 'surface' => $surface, 'ref' => $ref ] );
|
| 92 |
+
$resp = SA_Orch_Rest::get_demand_trace( $req );
|
| 93 |
+
$data = $resp->get_data();
|
| 94 |
+
|
| 95 |
+
self::render_trace_results( $data );
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
echo '</div>';
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
/* ---------- Demand Trace picker (v0.6.2) ---------- */
|
| 102 |
+
|
| 103 |
+
/**
|
| 104 |
+
* Demand picker:
|
| 105 |
+
* - surface dropdown (closed enumeration of 4)
|
| 106 |
+
* - quick-select strip (SA-ORCH-TEST fixtures pinned + last-traced)
|
| 107 |
+
* - searchable browser of candidates for the selected surface
|
| 108 |
+
* - manual ref entry as fallback (validated against known per-surface patterns)
|
| 109 |
+
*
|
| 110 |
+
* No new schema; no new endpoints. All candidate data is rendered inline
|
| 111 |
+
* as JSON for client-side filtering.
|
| 112 |
+
*/
|
| 113 |
+
private static function render_picker_form( $surface, $ref ) {
|
| 114 |
+
$candidates = [
|
| 115 |
+
'fluent-support' => self::candidates_fluent_support(),
|
| 116 |
+
'fluent-boards' => self::candidates_fluent_boards(),
|
| 117 |
+
'fluentcrm' => self::candidates_fluentcrm(),
|
| 118 |
+
'derived' => self::candidates_derived(),
|
| 119 |
+
];
|
| 120 |
+
$quick = self::quick_select_demands( 10 );
|
| 121 |
+
|
| 122 |
+
$surface_labels = [
|
| 123 |
+
'fluent-support' => 'fluent-support — Fluent Support tickets',
|
| 124 |
+
'fluent-boards' => 'fluent-boards — Fluent Boards tasks',
|
| 125 |
+
'fluentcrm' => 'fluentcrm — FluentCRM subscribers',
|
| 126 |
+
'derived' => 'derived — rowless / inferred demands',
|
| 127 |
+
];
|
| 128 |
+
|
| 129 |
+
// Per-surface validation regex (mirrors SA_Orch_Bridge_Fluent::validate / resolve).
|
| 130 |
+
$patterns = [
|
| 131 |
+
'fluent-support' => '^ticket#\\d+$',
|
| 132 |
+
'fluent-boards' => '^task#\\d+$',
|
| 133 |
+
'fluentcrm' => '^subscriber#\\d+$',
|
| 134 |
+
'derived' => '^derived#[A-Za-z0-9_\\-]+$',
|
| 135 |
+
];
|
| 136 |
+
|
| 137 |
+
echo '<form method="get" action="' . esc_url( admin_url( 'admin.php' ) ) . '" id="sa-orch-trace-form">';
|
| 138 |
+
echo '<input type="hidden" name="page" value="' . esc_attr( self::PARENT_SLUG ) . '" />';
|
| 139 |
+
|
| 140 |
+
// ---------- Quick-select ----------
|
| 141 |
+
if ( ! empty( $quick ) ) {
|
| 142 |
+
echo '<div class="sa-orch-quick" style="margin:1em 0; padding:10px 14px; background:#f6f7f7; border:1px solid #dcdcde; max-width:900px">';
|
| 143 |
+
echo '<h3 style="margin:0 0 6px; font-size:13px; text-transform:uppercase; letter-spacing:0.05em; color:#50575e">Quick-select</h3>';
|
| 144 |
+
echo '<p class="description" style="margin:0 0 8px">SA-ORCH-TEST fixtures pinned, then the most recently traced Demands.</p>';
|
| 145 |
+
echo '<ul style="margin:0; padding:0; list-style:none">';
|
| 146 |
+
foreach ( $quick as $d ) {
|
| 147 |
+
$url = admin_url( 'admin.php?page=' . self::PARENT_SLUG
|
| 148 |
+
. '&demand_surface=' . urlencode( $d['surface'] )
|
| 149 |
+
. '&demand_ref=' . urlencode( $d['ref'] ) );
|
| 150 |
+
$tag = $d['is_test']
|
| 151 |
+
? '<span style="background:#fff2c2;border:1px solid #c9a300;border-radius:3px;padding:0 6px;font-size:11px;margin-right:6px;color:#7a5b00">TEST</span> '
|
| 152 |
+
: '';
|
| 153 |
+
echo '<li style="padding:3px 0">'
|
| 154 |
+
. $tag
|
| 155 |
+
. '<a href="' . esc_url( $url ) . '">'
|
| 156 |
+
. '<code>' . esc_html( $d['surface'] . ' / ' . $d['ref'] ) . '</code>'
|
| 157 |
+
. ( $d['label'] !== '' ? ' — ' . esc_html( $d['label'] ) : '' )
|
| 158 |
+
. '</a></li>';
|
| 159 |
+
}
|
| 160 |
+
echo '</ul></div>';
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
// ---------- Surface + ref ----------
|
| 164 |
+
echo '<table class="form-table" role="presentation" style="max-width:900px"><tbody>';
|
| 165 |
+
|
| 166 |
+
echo '<tr><th scope="row"><label for="demand_surface_select">Demand surface</label></th><td>';
|
| 167 |
+
echo '<select id="demand_surface_select" name="demand_surface" class="regular-text">';
|
| 168 |
+
echo '<option value="">— pick a surface —</option>';
|
| 169 |
+
foreach ( $surface_labels as $sid => $slabel ) {
|
| 170 |
+
echo '<option value="' . esc_attr( $sid ) . '"' . selected( $surface, $sid, false ) . '>'
|
| 171 |
+
. esc_html( $slabel ) . '</option>';
|
| 172 |
+
}
|
| 173 |
+
echo '</select>';
|
| 174 |
+
echo '<p class="description">Closed vocabulary; extending requires touching <code>SA_Orch_Bridge_Fluent</code>.</p>';
|
| 175 |
+
echo '</td></tr>';
|
| 176 |
+
|
| 177 |
+
echo '<tr><th scope="row"><label for="demand_ref_input">Demand ref</label></th><td>';
|
| 178 |
+
echo '<input type="text" id="demand_ref_input" name="demand_ref" value="' . esc_attr( $ref ) . '"';
|
| 179 |
+
echo ' class="regular-text" placeholder="pick from the list below, or type" autocomplete="off" />';
|
| 180 |
+
echo '<span id="sa-orch-ref-status" style="margin-left:10px; font-size:12px"></span>';
|
| 181 |
+
echo '<p class="description">Format depends on surface: <code>ticket#NNN</code> · <code>task#NNN</code> · <code>subscriber#NNN</code> · <code>derived#slug</code>. Type or pick.</p>';
|
| 182 |
+
echo '</td></tr>';
|
| 183 |
+
|
| 184 |
+
echo '</tbody></table>';
|
| 185 |
+
|
| 186 |
+
echo '<p class="submit" style="margin:6px 0 14px"><button type="submit" class="button button-primary">Trace</button></p>';
|
| 187 |
+
|
| 188 |
+
// ---------- Browser ----------
|
| 189 |
+
echo '<div class="sa-orch-browse" style="margin:0 0 1.5em; padding:10px 14px; background:#fff; border:1px solid #dcdcde; max-width:900px">';
|
| 190 |
+
echo '<h3 style="margin:0 0 8px; font-size:13px; text-transform:uppercase; letter-spacing:0.05em; color:#50575e">Browse candidates</h3>';
|
| 191 |
+
echo '<input type="search" id="sa-orch-filter" placeholder="filter by ID or label…" style="width:100%; max-width:560px" autocomplete="off" />';
|
| 192 |
+
echo '<div id="sa-orch-candidate-help" class="description" style="margin:6px 0">Pick a surface to load candidates.</div>';
|
| 193 |
+
echo '<ul id="sa-orch-candidate-list" style="margin:0; padding:0; list-style:none; max-height:360px; overflow:auto; border:1px solid #f0f0f1; border-radius:3px"></ul>';
|
| 194 |
+
echo '<p class="description" style="margin:8px 0 0"><em>Click any row to fill the Demand ref field. Ref is also free-form for advanced use; format is validated when you type.</em></p>';
|
| 195 |
+
echo '</div>';
|
| 196 |
+
|
| 197 |
+
// ---------- Inline JSON + JS ----------
|
| 198 |
+
$payload = [
|
| 199 |
+
'candidates' => $candidates,
|
| 200 |
+
'patterns' => $patterns,
|
| 201 |
+
];
|
| 202 |
+
echo '<script>window.SA_ORCH_PICKER = ' . wp_json_encode( $payload ) . ';</script>';
|
| 203 |
+
echo '<script>' . self::picker_inline_js() . '</script>';
|
| 204 |
+
|
| 205 |
+
echo '</form>';
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
/**
|
| 209 |
+
* Vanilla-JS controller for the picker. No jQuery dependency.
|
| 210 |
+
* Wires: surface change → repopulate browser; filter input → live filter;
|
| 211 |
+
* candidate click → fill ref input; ref input → pattern hint.
|
| 212 |
+
*/
|
| 213 |
+
private static function picker_inline_js() {
|
| 214 |
+
return <<<'JS'
|
| 215 |
+
(function () {
|
| 216 |
+
var data = window.SA_ORCH_PICKER || { candidates: {}, patterns: {} };
|
| 217 |
+
var surfaceSel = document.getElementById('demand_surface_select');
|
| 218 |
+
var refInput = document.getElementById('demand_ref_input');
|
| 219 |
+
var filterInp = document.getElementById('sa-orch-filter');
|
| 220 |
+
var listEl = document.getElementById('sa-orch-candidate-list');
|
| 221 |
+
var helpEl = document.getElementById('sa-orch-candidate-help');
|
| 222 |
+
var statusEl = document.getElementById('sa-orch-ref-status');
|
| 223 |
+
if (!surfaceSel || !refInput || !listEl || !helpEl || !filterInp) return;
|
| 224 |
+
|
| 225 |
+
function escapeHtml(s) {
|
| 226 |
+
return String(s).replace(/[&<>"']/g, function (c) {
|
| 227 |
+
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
| 228 |
+
});
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
function renderCandidates() {
|
| 232 |
+
var surface = surfaceSel.value;
|
| 233 |
+
var query = (filterInp.value || '').trim().toLowerCase();
|
| 234 |
+
listEl.innerHTML = '';
|
| 235 |
+
|
| 236 |
+
if (!surface) {
|
| 237 |
+
helpEl.textContent = 'Pick a surface to load candidates.';
|
| 238 |
+
filterInp.disabled = true;
|
| 239 |
+
return;
|
| 240 |
+
}
|
| 241 |
+
filterInp.disabled = false;
|
| 242 |
+
var pool = (data.candidates || {})[surface] || [];
|
| 243 |
+
if (!pool.length) {
|
| 244 |
+
helpEl.textContent = 'No candidates known for this surface.';
|
| 245 |
+
return;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
var matches = pool;
|
| 249 |
+
if (query) {
|
| 250 |
+
matches = pool.filter(function (c) {
|
| 251 |
+
return c.ref.toLowerCase().indexOf(query) !== -1
|
| 252 |
+
|| (c.label || '').toLowerCase().indexOf(query) !== -1;
|
| 253 |
+
});
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
helpEl.textContent = matches.length + ' / ' + pool.length + ' candidate'
|
| 257 |
+
+ (pool.length === 1 ? '' : 's')
|
| 258 |
+
+ (query ? ' matching "' + query + '"' : '');
|
| 259 |
+
|
| 260 |
+
var frag = document.createDocumentFragment();
|
| 261 |
+
matches.slice(0, 200).forEach(function (c) {
|
| 262 |
+
var li = document.createElement('li');
|
| 263 |
+
li.style.padding = '5px 8px';
|
| 264 |
+
li.style.cursor = 'pointer';
|
| 265 |
+
li.style.borderBottom = '1px solid #f0f0f1';
|
| 266 |
+
li.style.lineHeight = '1.4';
|
| 267 |
+
var tag = c.is_test
|
| 268 |
+
? '<span style="background:#fff2c2;border:1px solid #c9a300;border-radius:3px;padding:0 5px;font-size:11px;margin-right:6px;color:#7a5b00">TEST</span>'
|
| 269 |
+
: '';
|
| 270 |
+
li.innerHTML = tag + '<code>' + escapeHtml(c.ref) + '</code>'
|
| 271 |
+
+ (c.label ? ' — ' + escapeHtml(c.label) : '');
|
| 272 |
+
li.addEventListener('mouseover', function () { li.style.background = '#f6f7f7'; });
|
| 273 |
+
li.addEventListener('mouseout', function () { if (!li.classList.contains('selected')) li.style.background = ''; });
|
| 274 |
+
li.addEventListener('click', function () {
|
| 275 |
+
refInput.value = c.ref;
|
| 276 |
+
Array.prototype.forEach.call(
|
| 277 |
+
listEl.querySelectorAll('li.selected'),
|
| 278 |
+
function (e) { e.classList.remove('selected'); e.style.background = ''; }
|
| 279 |
+
);
|
| 280 |
+
li.classList.add('selected');
|
| 281 |
+
li.style.background = '#fff7c2';
|
| 282 |
+
validateRef();
|
| 283 |
+
refInput.focus();
|
| 284 |
+
});
|
| 285 |
+
frag.appendChild(li);
|
| 286 |
+
});
|
| 287 |
+
listEl.appendChild(frag);
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
function validateRef() {
|
| 291 |
+
var surface = surfaceSel.value;
|
| 292 |
+
var ref = refInput.value.trim();
|
| 293 |
+
if (!surface || !ref) {
|
| 294 |
+
statusEl.textContent = '';
|
| 295 |
+
return;
|
| 296 |
+
}
|
| 297 |
+
var pattern = (data.patterns || {})[surface];
|
| 298 |
+
if (!pattern) {
|
| 299 |
+
statusEl.textContent = '';
|
| 300 |
+
return;
|
| 301 |
+
}
|
| 302 |
+
if ((new RegExp(pattern)).test(ref)) {
|
| 303 |
+
statusEl.textContent = '✓ valid format';
|
| 304 |
+
statusEl.style.color = '#0a7d24';
|
| 305 |
+
} else {
|
| 306 |
+
statusEl.textContent = '✗ does not match expected pattern';
|
| 307 |
+
statusEl.style.color = '#a02b1f';
|
| 308 |
+
}
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
surfaceSel.addEventListener('change', function () {
|
| 312 |
+
filterInp.value = '';
|
| 313 |
+
renderCandidates();
|
| 314 |
+
validateRef();
|
| 315 |
+
});
|
| 316 |
+
filterInp.addEventListener('input', renderCandidates);
|
| 317 |
+
refInput.addEventListener('input', validateRef);
|
| 318 |
+
|
| 319 |
+
// Initial state (e.g. arrived via permalink with both fields preset).
|
| 320 |
+
renderCandidates();
|
| 321 |
+
validateRef();
|
| 322 |
+
})();
|
| 323 |
+
JS;
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
/* ---------- Candidate fetchers (per surface) ---------- */
|
| 327 |
+
|
| 328 |
+
/** Fluent Support tickets, SA-ORCH-TEST pinned first. Cap 50. */
|
| 329 |
+
private static function candidates_fluent_support() {
|
| 330 |
+
global $wpdb;
|
| 331 |
+
$rows = $wpdb->get_results(
|
| 332 |
+
"SELECT id, title FROM {$wpdb->prefix}fs_tickets
|
| 333 |
+
ORDER BY (title LIKE '[SA-ORCH-TEST]%') DESC, id DESC
|
| 334 |
+
LIMIT 50"
|
| 335 |
+
);
|
| 336 |
+
$out = [];
|
| 337 |
+
foreach ( (array) $rows as $r ) {
|
| 338 |
+
$title = (string) $r->title;
|
| 339 |
+
$out[] = [
|
| 340 |
+
'ref' => 'ticket#' . (int) $r->id,
|
| 341 |
+
'label' => self::truncate( $title, 80 ),
|
| 342 |
+
'is_test' => ( strpos( $title, '[SA-ORCH-TEST]' ) === 0 ),
|
| 343 |
+
];
|
| 344 |
+
}
|
| 345 |
+
return $out;
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
/** Fluent Boards tasks, most-recent first. Cap 50. */
|
| 349 |
+
private static function candidates_fluent_boards() {
|
| 350 |
+
global $wpdb;
|
| 351 |
+
// Defensive: Fluent Boards may not be installed.
|
| 352 |
+
$exists = $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $wpdb->prefix . 'fbs_tasks' ) );
|
| 353 |
+
if ( ! $exists ) return [];
|
| 354 |
+
$rows = $wpdb->get_results(
|
| 355 |
+
"SELECT id, title FROM {$wpdb->prefix}fbs_tasks
|
| 356 |
+
ORDER BY id DESC LIMIT 50"
|
| 357 |
+
);
|
| 358 |
+
$out = [];
|
| 359 |
+
foreach ( (array) $rows as $r ) {
|
| 360 |
+
$title = (string) ( $r->title ?? '' );
|
| 361 |
+
$out[] = [
|
| 362 |
+
'ref' => 'task#' . (int) $r->id,
|
| 363 |
+
'label' => self::truncate( $title, 80 ),
|
| 364 |
+
'is_test' => ( strpos( $title, '[SA-ORCH-TEST]' ) === 0 ),
|
| 365 |
+
];
|
| 366 |
+
}
|
| 367 |
+
return $out;
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
/** FluentCRM subscribers, most-recent first. Cap 50. Label = full_name|email. */
|
| 371 |
+
private static function candidates_fluentcrm() {
|
| 372 |
+
global $wpdb;
|
| 373 |
+
$exists = $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $wpdb->prefix . 'fc_subscribers' ) );
|
| 374 |
+
if ( ! $exists ) return [];
|
| 375 |
+
$rows = $wpdb->get_results(
|
| 376 |
+
"SELECT id, full_name, email FROM {$wpdb->prefix}fc_subscribers
|
| 377 |
+
ORDER BY id DESC LIMIT 50"
|
| 378 |
+
);
|
| 379 |
+
$out = [];
|
| 380 |
+
foreach ( (array) $rows as $r ) {
|
| 381 |
+
$name = trim( (string) ( $r->full_name ?? '' ) );
|
| 382 |
+
$email = (string) ( $r->email ?? '' );
|
| 383 |
+
$label = $name !== '' ? ( $name . ( $email ? " <{$email}>" : '' ) ) : $email;
|
| 384 |
+
$out[] = [
|
| 385 |
+
'ref' => 'subscriber#' . (int) $r->id,
|
| 386 |
+
'label' => self::truncate( $label, 80 ),
|
| 387 |
+
'is_test' => false,
|
| 388 |
+
];
|
| 389 |
+
}
|
| 390 |
+
return $out;
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
/**
|
| 394 |
+
* 'derived' surface has no underlying surface table — list every distinct
|
| 395 |
+
* derived demand_ref ever recorded in arbitrations or acceptance events.
|
| 396 |
+
*/
|
| 397 |
+
private static function candidates_derived() {
|
| 398 |
+
global $wpdb;
|
| 399 |
+
$arb = $wpdb->prefix . SA_ORCH_DB_PREFIX . 'arbitration';
|
| 400 |
+
$evt = $wpdb->prefix . SA_ORCH_DB_PREFIX . 'acceptance_event';
|
| 401 |
+
$rows = $wpdb->get_results(
|
| 402 |
+
"SELECT demand_ref, MAX(last_at) AS last_at FROM (
|
| 403 |
+
SELECT demand_ref, MAX(created_at) AS last_at FROM {$arb} WHERE demand_surface='derived' GROUP BY demand_ref
|
| 404 |
+
UNION ALL
|
| 405 |
+
SELECT demand_ref, MAX(observed_at) AS last_at FROM {$evt} WHERE demand_surface='derived' GROUP BY demand_ref
|
| 406 |
+
) sub
|
| 407 |
+
GROUP BY demand_ref
|
| 408 |
+
ORDER BY last_at DESC LIMIT 50"
|
| 409 |
+
);
|
| 410 |
+
$out = [];
|
| 411 |
+
foreach ( (array) $rows as $r ) {
|
| 412 |
+
$out[] = [
|
| 413 |
+
'ref' => (string) $r->demand_ref,
|
| 414 |
+
'label' => '(derived demand, no surface row)',
|
| 415 |
+
'is_test' => false,
|
| 416 |
+
];
|
| 417 |
+
}
|
| 418 |
+
return $out;
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
/**
|
| 422 |
+
* Quick-select list: SA-ORCH-TEST tickets pinned, then most-recently-traced
|
| 423 |
+
* demands across all surfaces (by max(arbitration.created_at, event.observed_at)).
|
| 424 |
+
*/
|
| 425 |
+
private static function quick_select_demands( $limit = 10 ) {
|
| 426 |
+
global $wpdb;
|
| 427 |
+
$arb = $wpdb->prefix . SA_ORCH_DB_PREFIX . 'arbitration';
|
| 428 |
+
$evt = $wpdb->prefix . SA_ORCH_DB_PREFIX . 'acceptance_event';
|
| 429 |
+
|
| 430 |
+
$rows = $wpdb->get_results(
|
| 431 |
+
"SELECT demand_surface, demand_ref, MAX(last_at) AS last_at FROM (
|
| 432 |
+
SELECT demand_surface, demand_ref, MAX(created_at) AS last_at FROM {$arb} GROUP BY demand_surface, demand_ref
|
| 433 |
+
UNION ALL
|
| 434 |
+
SELECT demand_surface, demand_ref, MAX(observed_at) AS last_at FROM {$evt} GROUP BY demand_surface, demand_ref
|
| 435 |
+
) sub
|
| 436 |
+
GROUP BY demand_surface, demand_ref
|
| 437 |
+
ORDER BY last_at DESC LIMIT 30"
|
| 438 |
+
);
|
| 439 |
+
if ( empty( $rows ) ) return [];
|
| 440 |
+
|
| 441 |
+
// Resolve labels + is_test per row.
|
| 442 |
+
$out = [];
|
| 443 |
+
foreach ( $rows as $r ) {
|
| 444 |
+
$surface = (string) $r->demand_surface;
|
| 445 |
+
$ref = (string) $r->demand_ref;
|
| 446 |
+
list( $label, $is_test ) = self::resolve_label( $surface, $ref );
|
| 447 |
+
$out[] = [
|
| 448 |
+
'surface' => $surface,
|
| 449 |
+
'ref' => $ref,
|
| 450 |
+
'label' => $label,
|
| 451 |
+
'is_test' => $is_test,
|
| 452 |
+
'last_at' => (string) $r->last_at,
|
| 453 |
+
];
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
// Pin SA-ORCH-TEST first; preserve last_at ordering within each group.
|
| 457 |
+
usort( $out, function ( $a, $b ) {
|
| 458 |
+
if ( $a['is_test'] !== $b['is_test'] ) return $a['is_test'] ? -1 : 1;
|
| 459 |
+
return strcmp( $b['last_at'], $a['last_at'] );
|
| 460 |
+
} );
|
| 461 |
+
|
| 462 |
+
return array_slice( $out, 0, (int) $limit );
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
/** Resolve a (surface, ref) to a human label + is_test flag. Best-effort, tolerant of missing tables. */
|
| 466 |
+
private static function resolve_label( $surface, $ref ) {
|
| 467 |
+
global $wpdb;
|
| 468 |
+
|
| 469 |
+
if ( $surface === 'fluent-support' && preg_match( '/^ticket#(\d+)$/', $ref, $m ) ) {
|
| 470 |
+
$title = (string) $wpdb->get_var( $wpdb->prepare(
|
| 471 |
+
"SELECT title FROM {$wpdb->prefix}fs_tickets WHERE id=%d", (int) $m[1]
|
| 472 |
+
) );
|
| 473 |
+
return [ self::truncate( $title, 80 ), strpos( $title, '[SA-ORCH-TEST]' ) === 0 ];
|
| 474 |
+
}
|
| 475 |
+
if ( $surface === 'fluent-boards' && preg_match( '/^task#(\d+)$/', $ref, $m ) ) {
|
| 476 |
+
if ( $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $wpdb->prefix . 'fbs_tasks' ) ) ) {
|
| 477 |
+
$title = (string) $wpdb->get_var( $wpdb->prepare(
|
| 478 |
+
"SELECT title FROM {$wpdb->prefix}fbs_tasks WHERE id=%d", (int) $m[1]
|
| 479 |
+
) );
|
| 480 |
+
return [ self::truncate( $title, 80 ), strpos( $title, '[SA-ORCH-TEST]' ) === 0 ];
|
| 481 |
+
}
|
| 482 |
+
}
|
| 483 |
+
if ( $surface === 'fluentcrm' && preg_match( '/^subscriber#(\d+)$/', $ref, $m ) ) {
|
| 484 |
+
if ( $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $wpdb->prefix . 'fc_subscribers' ) ) ) {
|
| 485 |
+
$row = $wpdb->get_row( $wpdb->prepare(
|
| 486 |
+
"SELECT full_name, email FROM {$wpdb->prefix}fc_subscribers WHERE id=%d", (int) $m[1]
|
| 487 |
+
) );
|
| 488 |
+
if ( $row ) {
|
| 489 |
+
$name = trim( (string) $row->full_name );
|
| 490 |
+
$email = (string) $row->email;
|
| 491 |
+
$label = $name !== '' ? ( $name . ( $email ? " <{$email}>" : '' ) ) : $email;
|
| 492 |
+
return [ self::truncate( $label, 80 ), false ];
|
| 493 |
+
}
|
| 494 |
+
}
|
| 495 |
+
}
|
| 496 |
+
if ( $surface === 'derived' ) {
|
| 497 |
+
return [ '(derived demand, no surface row)', false ];
|
| 498 |
+
}
|
| 499 |
+
return [ '', false ];
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
private static function truncate( $s, $n ) {
|
| 503 |
+
$s = (string) $s;
|
| 504 |
+
if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) {
|
| 505 |
+
return mb_strlen( $s ) > $n ? ( mb_substr( $s, 0, $n - 1 ) . '…' ) : $s;
|
| 506 |
+
}
|
| 507 |
+
return strlen( $s ) > $n ? ( substr( $s, 0, $n - 1 ) . '…' ) : $s;
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
private static function render_trace_results( array $data ) {
|
| 511 |
+
$arbs = $data['arbitrations'] ?? [];
|
| 512 |
+
$links = $data['plan_links'] ?? [];
|
| 513 |
+
$events = $data['acceptance'] ?? [];
|
| 514 |
+
|
| 515 |
+
echo '<h2>Closure</h2>';
|
| 516 |
+
echo '<table class="widefat striped" style="max-width:600px"><tbody>';
|
| 517 |
+
echo '<tr><th scope="row" style="width:40%">closure_state_basic</th><td><code>' . esc_html( (string) ( $data['closure_state_basic'] ?? '' ) ) . '</code></td></tr>';
|
| 518 |
+
echo '<tr><th scope="row">closure_state_role_aware</th><td><code>' . esc_html( (string) ( $data['closure_state_role_aware'] ?? '' ) ) . '</code></td></tr>';
|
| 519 |
+
$auth = $data['closure_authority_valid'] ?? null;
|
| 520 |
+
$auth_disp = $auth === null ? 'null' : ( $auth ? 'true' : 'false' );
|
| 521 |
+
echo '<tr><th scope="row">closure_authority_valid</th><td><code>' . esc_html( $auth_disp ) . '</code></td></tr>';
|
| 522 |
+
echo '</tbody></table>';
|
| 523 |
+
|
| 524 |
+
// === Deterministic Trace Audit (layer 2 — rules-based) ===
|
| 525 |
+
self::render_reasoning_review( $data );
|
| 526 |
+
|
| 527 |
+
echo '<h2>Arbitrations <span class="count">(' . count( $arbs ) . ')</span></h2>';
|
| 528 |
+
if ( empty( $arbs ) ) {
|
| 529 |
+
echo '<p><em>No arbitrations recorded for this demand.</em></p>';
|
| 530 |
+
} else {
|
| 531 |
+
echo '<table class="widefat striped"><thead><tr>';
|
| 532 |
+
echo '<th>ID</th><th>Hash</th><th>Projection Type</th><th>Truth Class</th><th>Actor</th><th>Correlation</th><th>Created (UTC)</th><th>Asterion</th>';
|
| 533 |
+
echo '</tr></thead><tbody>';
|
| 534 |
+
foreach ( $arbs as $a ) {
|
| 535 |
+
echo '<tr>';
|
| 536 |
+
echo '<td>' . (int) $a->id . '</td>';
|
| 537 |
+
echo '<td><code>' . esc_html( substr( (string) $a->hash, 0, 12 ) ) . '…</code></td>';
|
| 538 |
+
echo '<td>' . esc_html( (string) $a->projection_type ) . '</td>';
|
| 539 |
+
echo '<td>' . esc_html( (string) $a->truth_class ) . '</td>';
|
| 540 |
+
echo '<td>' . esc_html( (string) ( $a->actor_user_id ?? '—' ) ) . '</td>';
|
| 541 |
+
echo '<td><code>' . esc_html( substr( (string) $a->correlation_id, 0, 8 ) ) . '…</code></td>';
|
| 542 |
+
echo '<td>' . esc_html( (string) $a->created_at ) . '</td>';
|
| 543 |
+
echo '<td><code>' . esc_html( (string) $a->asterion_path ) . '</code></td>';
|
| 544 |
+
echo '</tr>';
|
| 545 |
+
echo '<tr><td></td><td colspan="7"><strong>Rationale:</strong> ' . esc_html( (string) $a->rationale ) . '</td></tr>';
|
| 546 |
+
}
|
| 547 |
+
echo '</tbody></table>';
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
echo '<h2>Plan Links <span class="count">(' . count( $links ) . ')</span></h2>';
|
| 551 |
+
if ( empty( $links ) ) {
|
| 552 |
+
echo '<p><em>No plan links — this Demand has no Plan refs (consistent with <code>scope-direct-no-plan</code> or <code>no-plan</code> arbitration).</em></p>';
|
| 553 |
+
} else {
|
| 554 |
+
echo '<table class="widefat striped"><thead><tr>';
|
| 555 |
+
echo '<th>ID</th><th>Arbitration</th><th>Plan Surface</th><th>Plan Ref</th><th>Link Kind</th><th>Created (UTC)</th>';
|
| 556 |
+
echo '</tr></thead><tbody>';
|
| 557 |
+
foreach ( $links as $l ) {
|
| 558 |
+
echo '<tr>';
|
| 559 |
+
echo '<td>' . (int) $l->id . '</td>';
|
| 560 |
+
echo '<td>' . (int) $l->arbitration_id . '</td>';
|
| 561 |
+
echo '<td>' . esc_html( (string) $l->plan_surface ) . '</td>';
|
| 562 |
+
echo '<td><code>' . esc_html( (string) $l->plan_ref ) . '</code></td>';
|
| 563 |
+
echo '<td>' . esc_html( (string) $l->link_kind ) . '</td>';
|
| 564 |
+
echo '<td>' . esc_html( (string) $l->created_at ) . '</td>';
|
| 565 |
+
echo '</tr>';
|
| 566 |
+
}
|
| 567 |
+
echo '</tbody></table>';
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
echo '<h2>Acceptance Events <span class="count">(' . count( $events ) . ')</span></h2>';
|
| 571 |
+
if ( empty( $events ) ) {
|
| 572 |
+
echo '<p><em>No acceptance events.</em></p>';
|
| 573 |
+
} else {
|
| 574 |
+
echo '<table class="widefat striped"><thead><tr>';
|
| 575 |
+
echo '<th>ID</th><th>Event Type</th><th>Actor Role</th><th>Actor</th><th>Truth Class</th><th>Prior</th><th>Observed (UTC)</th><th>Asterion</th>';
|
| 576 |
+
echo '</tr></thead><tbody>';
|
| 577 |
+
foreach ( $events as $e ) {
|
| 578 |
+
echo '<tr>';
|
| 579 |
+
echo '<td>' . (int) $e->id . '</td>';
|
| 580 |
+
echo '<td>' . esc_html( (string) $e->event_type ) . '</td>';
|
| 581 |
+
echo '<td>' . esc_html( (string) $e->actor_role ) . '</td>';
|
| 582 |
+
echo '<td>' . esc_html( (string) ( $e->actor_user_id ?? '—' ) ) . '</td>';
|
| 583 |
+
echo '<td>' . esc_html( (string) $e->truth_class ) . '</td>';
|
| 584 |
+
echo '<td>' . esc_html( (string) ( $e->prior_event_id ?? '—' ) ) . '</td>';
|
| 585 |
+
echo '<td>' . esc_html( (string) $e->observed_at ) . '</td>';
|
| 586 |
+
echo '<td><code>' . esc_html( (string) $e->asterion_path ) . '</code></td>';
|
| 587 |
+
echo '</tr>';
|
| 588 |
+
if ( ! empty( $e->details ) ) {
|
| 589 |
+
echo '<tr><td></td><td colspan="7"><strong>Details:</strong> ' . esc_html( (string) $e->details ) . '</td></tr>';
|
| 590 |
+
}
|
| 591 |
+
}
|
| 592 |
+
echo '</tbody></table>';
|
| 593 |
+
}
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
/**
|
| 597 |
+
* Deterministic Trace Audit block.
|
| 598 |
+
*
|
| 599 |
+
* Local rules-based audit over the trace data. No API calls, no LLM, no
|
| 600 |
+
* persistence. Recomputed on every page load.
|
| 601 |
+
*
|
| 602 |
+
* This is the FLOOR of the review stack:
|
| 603 |
+
* 1. Trace data
|
| 604 |
+
* 2. Deterministic Trace Audit ← this section
|
| 605 |
+
* 3. Strategy LLM Review (future; will sit above this audit)
|
| 606 |
+
* 4. Human arbitration (final meaning; always wins)
|
| 607 |
+
*/
|
| 608 |
+
private static function render_reasoning_review( array $data ) {
|
| 609 |
+
$r = SA_Orch_Reasoning::evaluate( $data );
|
| 610 |
+
|
| 611 |
+
echo '<h2>Deterministic Trace Audit <span class="count">(rules-based, mode: ' . esc_html( (string) ( $r['mode'] ?? 'deterministic' ) ) . ')</span></h2>';
|
| 612 |
+
echo '<p><em>Local rules-based audit — repeatable, no API, no LLM. Computed on each page load. Floor of a four-layer stack: ';
|
| 613 |
+
echo '<strong>(1) Trace data</strong> → <strong>(2) this Deterministic Trace Audit</strong> → <strong>(3) Strategy LLM Review</strong> (future) → <strong>(4) Human arbitration</strong> (final).</em></p>';
|
| 614 |
+
|
| 615 |
+
$color_map = [ 'high' => '#0a7d24', 'medium' => '#a76b00', 'low' => '#a02b1f', 'n/a' => '#666' ];
|
| 616 |
+
$color = $color_map[ $r['closure_confidence'] ] ?? '#666';
|
| 617 |
+
|
| 618 |
+
echo '<table class="widefat striped" style="max-width:900px"><tbody>';
|
| 619 |
+
|
| 620 |
+
echo '<tr><th scope="row" style="width:25%">Summary</th><td>' . esc_html( $r['summary'] ) . '</td></tr>';
|
| 621 |
+
|
| 622 |
+
echo '<tr><th scope="row">Closure confidence</th><td>';
|
| 623 |
+
echo '<strong style="color:' . esc_attr( $color ) . '">' . esc_html( strtoupper( (string) $r['closure_confidence'] ) ) . '</strong>';
|
| 624 |
+
echo ' — ' . esc_html( $r['closure_confidence_explanation'] );
|
| 625 |
+
echo '</td></tr>';
|
| 626 |
+
|
| 627 |
+
// Needs Human Arbitration — prominent row
|
| 628 |
+
echo '<tr><th scope="row">Needs Human Arbitration</th><td>';
|
| 629 |
+
if ( ! empty( $r['needs_human_arbitration'] ) ) {
|
| 630 |
+
echo '<strong style="color:#a02b1f">⚠ YES — ' . esc_html( (string) $r['human_arbitration_message'] ) . '</strong>';
|
| 631 |
+
echo '<p style="margin:6px 0 0">Triggered by:</p>';
|
| 632 |
+
echo '<ul style="margin:0; padding-left:1.2em">';
|
| 633 |
+
foreach ( (array) $r['human_arbitration_triggers'] as $t ) {
|
| 634 |
+
echo '<li>' . esc_html( $t ) . '</li>';
|
| 635 |
+
}
|
| 636 |
+
echo '</ul>';
|
| 637 |
+
} else {
|
| 638 |
+
echo '<em>No — none of the deterministic escalation triggers fired.</em>';
|
| 639 |
+
}
|
| 640 |
+
echo '</td></tr>';
|
| 641 |
+
|
| 642 |
+
$render_list = function ( $label, $items ) {
|
| 643 |
+
echo '<tr><th scope="row">' . esc_html( $label ) . '</th><td>';
|
| 644 |
+
if ( empty( $items ) ) {
|
| 645 |
+
echo '<em>(none)</em>';
|
| 646 |
+
} else {
|
| 647 |
+
echo '<ul style="margin:0; padding-left:1.2em">';
|
| 648 |
+
foreach ( $items as $i ) {
|
| 649 |
+
echo '<li>' . esc_html( $i ) . '</li>';
|
| 650 |
+
}
|
| 651 |
+
echo '</ul>';
|
| 652 |
+
}
|
| 653 |
+
echo '</td></tr>';
|
| 654 |
+
};
|
| 655 |
+
|
| 656 |
+
$render_list( 'Inconsistencies', $r['inconsistencies'] );
|
| 657 |
+
$render_list( 'Potential scope drift', $r['scope_drift'] );
|
| 658 |
+
$render_list( 'Notes', $r['notes'] );
|
| 659 |
+
|
| 660 |
+
echo '</tbody></table>';
|
| 661 |
+
|
| 662 |
+
echo '<p style="margin-top:8px"><em>Layer 3 — Strategy LLM Review — sits below this section. The audit remains as a sanity-check / fallback layer underneath any LLM verdict.</em></p>';
|
| 663 |
+
|
| 664 |
+
// --- Strategy Review (v0.5) ---
|
| 665 |
+
self::render_strategy_review( $data, $r );
|
| 666 |
+
}
|
| 667 |
+
|
| 668 |
+
/**
|
| 669 |
+
* Strategy LLM Review block (layer 3).
|
| 670 |
+
*
|
| 671 |
+
* Consumes the trace + the deterministic audit; delegates to the active
|
| 672 |
+
* provider via SA_Orch_Strategy::review(). Strategy Review is advisory:
|
| 673 |
+
* it cannot override closure_state or needs_human_arbitration.
|
| 674 |
+
*/
|
| 675 |
+
private static function render_strategy_review( array $data, array $deterministic_audit ) {
|
| 676 |
+
$strategy = SA_Orch_Strategy::review( $data, $deterministic_audit );
|
| 677 |
+
|
| 678 |
+
$provider_id = (string) ( $strategy['provider_id'] ?? 'simulated' );
|
| 679 |
+
$provider_name = (string) ( $strategy['provider_name'] ?? 'Simulated (no API call)' );
|
| 680 |
+
$configured_id = (string) ( $strategy['configured_provider_id'] ?? $provider_id );
|
| 681 |
+
$configured_name = (string) ( $strategy['configured_provider_name'] ?? $provider_name );
|
| 682 |
+
$fallback_used = ! empty( $strategy['fallback_used'] );
|
| 683 |
+
$provider_err = $strategy['provider_error'] ?? null;
|
| 684 |
+
|
| 685 |
+
$heading_suffix = $fallback_used
|
| 686 |
+
? '(layer 3 — used: ' . esc_html( $provider_id ) . ', configured: ' . esc_html( $configured_id ) . ' — <strong style="color:#a76b00">FALLBACK</strong>)'
|
| 687 |
+
: '(layer 3 — provider: ' . esc_html( $provider_id ) . ')';
|
| 688 |
+
echo '<h2>Strategy Review (simulated LLM) <span class="count">' . $heading_suffix . '</span></h2>';
|
| 689 |
+
echo '<p><em>Layer 3 of the review stack. Provider in use: <code>' . esc_html( $provider_name ) . '</code>. ';
|
| 690 |
+
if ( $fallback_used ) {
|
| 691 |
+
echo 'Configured provider (<code>' . esc_html( $configured_name ) . '</code>) failed and was replaced by the simulated fallback. ';
|
| 692 |
+
}
|
| 693 |
+
echo 'Compares its interpretation against the deterministic audit. ';
|
| 694 |
+
echo '<strong>Strategy Review cannot override <code>closure_state</code> or the <code>needs_human_arbitration</code> flag</strong> — those remain governed by the deterministic audit and ultimately by human arbitration.</em></p>';
|
| 695 |
+
|
| 696 |
+
if ( $provider_err ) {
|
| 697 |
+
echo '<div class="notice notice-warning"><p><strong>Provider error (configured: <code>' . esc_html( $configured_id ) . '</code>):</strong> ' . esc_html( $provider_err ) . ' — fell back to <code>simulated</code>.</p></div>';
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
// Color hints
|
| 701 |
+
$color_map_conf = [ 'high' => '#0a7d24', 'medium' => '#a76b00', 'low' => '#a02b1f' ];
|
| 702 |
+
$color_map_agree = [ 'true' => '#0a7d24', 'partial' => '#a76b00', 'false' => '#a02b1f' ];
|
| 703 |
+
$strat_conf = (string) ( $strategy['confidence'] ?? 'low' );
|
| 704 |
+
$strat_agree = (string) ( $strategy['agreement_with_audit'] ?? 'partial' );
|
| 705 |
+
$strat_color = $color_map_conf[ $strat_conf ] ?? '#666';
|
| 706 |
+
$agree_color = $color_map_agree[ $strat_agree ] ?? '#666';
|
| 707 |
+
|
| 708 |
+
echo '<table class="widefat striped" style="max-width:900px"><tbody>';
|
| 709 |
+
|
| 710 |
+
echo '<tr><th scope="row" style="width:25%">Summary</th><td>' . esc_html( (string) ( $strategy['summary'] ?? '' ) ) . '</td></tr>';
|
| 711 |
+
|
| 712 |
+
echo '<tr><th scope="row">Agreement with Deterministic Audit</th><td>';
|
| 713 |
+
echo '<strong style="color:' . esc_attr( $agree_color ) . '">' . esc_html( strtoupper( $strat_agree ) ) . '</strong>';
|
| 714 |
+
echo '</td></tr>';
|
| 715 |
+
|
| 716 |
+
echo '<tr><th scope="row">Additional risks (not caught by audit)</th><td>';
|
| 717 |
+
$risks = (array) ( $strategy['additional_risks'] ?? [] );
|
| 718 |
+
if ( empty( $risks ) ) {
|
| 719 |
+
echo '<em>(none)</em>';
|
| 720 |
+
} else {
|
| 721 |
+
echo '<ul style="margin:0; padding-left:1.2em">';
|
| 722 |
+
foreach ( $risks as $r ) {
|
| 723 |
+
echo '<li>' . esc_html( (string) $r ) . '</li>';
|
| 724 |
+
}
|
| 725 |
+
echo '</ul>';
|
| 726 |
+
}
|
| 727 |
+
echo '</td></tr>';
|
| 728 |
+
|
| 729 |
+
echo '<tr><th scope="row">Suggested next action</th><td>' . esc_html( (string) ( $strategy['suggested_next_action'] ?? '' ) ) . '</td></tr>';
|
| 730 |
+
|
| 731 |
+
echo '<tr><th scope="row">Confidence</th><td>';
|
| 732 |
+
echo '<strong style="color:' . esc_attr( $strat_color ) . '">' . esc_html( strtoupper( $strat_conf ) ) . '</strong>';
|
| 733 |
+
echo '</td></tr>';
|
| 734 |
+
|
| 735 |
+
echo '</tbody></table>';
|
| 736 |
+
|
| 737 |
+
// Visual side-by-side comparison
|
| 738 |
+
self::render_audit_strategy_comparison( $deterministic_audit, $strategy );
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
/**
|
| 742 |
+
* Side-by-side comparison of Deterministic Audit (layer 2) and Strategy Review (layer 3).
|
| 743 |
+
*/
|
| 744 |
+
private static function render_audit_strategy_comparison( array $audit, array $strategy ) {
|
| 745 |
+
$color_map_conf = [ 'high' => '#0a7d24', 'medium' => '#a76b00', 'low' => '#a02b1f', 'n/a' => '#666' ];
|
| 746 |
+
|
| 747 |
+
$audit_conf = (string) ( $audit['closure_confidence'] ?? 'n/a' );
|
| 748 |
+
$strat_conf = (string) ( $strategy['confidence'] ?? 'low' );
|
| 749 |
+
$audit_inc = count( (array) ( $audit['inconsistencies'] ?? [] ) );
|
| 750 |
+
$audit_drift = count( (array) ( $audit['scope_drift'] ?? [] ) );
|
| 751 |
+
$strat_extra = count( (array) ( $strategy['additional_risks'] ?? [] ) );
|
| 752 |
+
$needs_hum = ! empty( $audit['needs_human_arbitration'] );
|
| 753 |
+
|
| 754 |
+
echo '<h3 style="margin-top:1.2em">Deterministic Audit ↔ Strategy Review (side-by-side)</h3>';
|
| 755 |
+
echo '<table class="widefat striped" style="max-width:900px"><thead><tr>';
|
| 756 |
+
echo '<th style="width:25%"> </th>';
|
| 757 |
+
echo '<th>Deterministic Audit (layer 2)</th>';
|
| 758 |
+
echo '<th>Strategy Review (layer 3)</th>';
|
| 759 |
+
echo '</tr></thead><tbody>';
|
| 760 |
+
|
| 761 |
+
$audit_conf_color = $color_map_conf[ $audit_conf ] ?? '#666';
|
| 762 |
+
$strat_conf_color = $color_map_conf[ $strat_conf ] ?? '#666';
|
| 763 |
+
|
| 764 |
+
echo '<tr><th scope="row">Confidence</th>';
|
| 765 |
+
echo '<td><strong style="color:' . esc_attr( $audit_conf_color ) . '">' . esc_html( strtoupper( $audit_conf ) ) . '</strong></td>';
|
| 766 |
+
echo '<td><strong style="color:' . esc_attr( $strat_conf_color ) . '">' . esc_html( strtoupper( $strat_conf ) ) . '</strong></td>';
|
| 767 |
+
echo '</tr>';
|
| 768 |
+
|
| 769 |
+
echo '<tr><th scope="row">Findings counted</th>';
|
| 770 |
+
echo '<td>' . (int) $audit_inc . ' inconsistenc' . ( $audit_inc === 1 ? 'y' : 'ies' ) . ', ' . (int) $audit_drift . ' scope-drift indicator' . ( $audit_drift === 1 ? '' : 's' ) . '</td>';
|
| 771 |
+
echo '<td>' . (int) $strat_extra . ' additional risk' . ( $strat_extra === 1 ? '' : 's' ) . '</td>';
|
| 772 |
+
echo '</tr>';
|
| 773 |
+
|
| 774 |
+
echo '<tr><th scope="row">Closure authority</th>';
|
| 775 |
+
echo '<td>' . ( $needs_hum ? '<strong>flagged needs_human_arbitration = YES</strong>' : 'no escalation flag' ) . '</td>';
|
| 776 |
+
echo '<td><em>cannot override the audit</em></td>';
|
| 777 |
+
echo '</tr>';
|
| 778 |
+
|
| 779 |
+
echo '<tr><th scope="row">Mode / character</th>';
|
| 780 |
+
echo '<td>rules-based, repeatable</td>';
|
| 781 |
+
echo '<td>interpretive, narrative (' . esc_html( (string) ( $strategy['provider_id'] ?? 'simulated' ) ) . ')</td>';
|
| 782 |
+
echo '</tr>';
|
| 783 |
+
|
| 784 |
+
echo '</tbody></table>';
|
| 785 |
+
|
| 786 |
+
echo '<p style="margin-top:8px"><em>Reminder: <code>closure_state_basic</code>, <code>closure_state_role_aware</code>, <code>closure_authority_valid</code>, and <code>needs_human_arbitration</code> are governed by the deterministic audit (layer 2) and ultimately by human arbitration (layer 4). Strategy Review is advisory.</em></p>';
|
| 787 |
+
}
|
| 788 |
+
|
| 789 |
+
/* ---------- Recent Arbitrations ---------- */
|
| 790 |
+
|
| 791 |
+
public static function render_arbitrations() {
|
| 792 |
+
if ( ! current_user_can( self::CAP ) ) wp_die( 'Insufficient permissions.' );
|
| 793 |
+
global $wpdb;
|
| 794 |
+
$p = $wpdb->prefix . SA_ORCH_DB_PREFIX;
|
| 795 |
+
|
| 796 |
+
$rows = $wpdb->get_results(
|
| 797 |
+
"SELECT id, demand_surface, demand_ref, projection_type, truth_class, actor_user_id, created_at
|
| 798 |
+
FROM {$p}arbitration
|
| 799 |
+
ORDER BY created_at DESC, id DESC LIMIT 20"
|
| 800 |
+
);
|
| 801 |
+
|
| 802 |
+
echo '<div class="wrap">';
|
| 803 |
+
echo '<h1>Recent Arbitrations</h1>';
|
| 804 |
+
echo '<p>Last 20 arbitration records, newest first. Click <strong>Trace</strong> to walk the full chain.</p>';
|
| 805 |
+
|
| 806 |
+
if ( empty( $rows ) ) {
|
| 807 |
+
echo '<p><em>No arbitrations recorded yet.</em></p>';
|
| 808 |
+
} else {
|
| 809 |
+
echo '<table class="widefat striped"><thead><tr>';
|
| 810 |
+
echo '<th>ID</th><th>Demand</th><th>Projection Type</th><th>Truth Class</th><th>Actor</th><th>Created (UTC)</th><th>Action</th>';
|
| 811 |
+
echo '</tr></thead><tbody>';
|
| 812 |
+
foreach ( $rows as $r ) {
|
| 813 |
+
$trace_url = admin_url( 'admin.php?page=' . self::PARENT_SLUG
|
| 814 |
+
. '&demand_surface=' . urlencode( $r->demand_surface )
|
| 815 |
+
. '&demand_ref=' . urlencode( $r->demand_ref ) );
|
| 816 |
+
echo '<tr>';
|
| 817 |
+
echo '<td>' . (int) $r->id . '</td>';
|
| 818 |
+
echo '<td><code>' . esc_html( $r->demand_surface . ' / ' . $r->demand_ref ) . '</code></td>';
|
| 819 |
+
echo '<td>' . esc_html( (string) $r->projection_type ) . '</td>';
|
| 820 |
+
echo '<td>' . esc_html( (string) $r->truth_class ) . '</td>';
|
| 821 |
+
echo '<td>' . esc_html( (string) ( $r->actor_user_id ?? '—' ) ) . '</td>';
|
| 822 |
+
echo '<td>' . esc_html( (string) $r->created_at ) . '</td>';
|
| 823 |
+
echo '<td><a href="' . esc_url( $trace_url ) . '">Trace</a></td>';
|
| 824 |
+
echo '</tr>';
|
| 825 |
+
}
|
| 826 |
+
echo '</tbody></table>';
|
| 827 |
+
}
|
| 828 |
+
|
| 829 |
+
echo '</div>';
|
| 830 |
+
}
|
| 831 |
+
|
| 832 |
+
/* ---------- Language Model Providers (settings page) ---------- */
|
| 833 |
+
|
| 834 |
+
public static function render_llm_settings() {
|
| 835 |
+
if ( ! current_user_can( self::CAP ) ) wp_die( 'Insufficient permissions.' );
|
| 836 |
+
|
| 837 |
+
$message = '';
|
| 838 |
+
if ( $_SERVER['REQUEST_METHOD'] === 'POST' ) {
|
| 839 |
+
check_admin_referer( 'sa_orch_llm_settings' );
|
| 840 |
+
SA_Orch_LLM_Settings::save( wp_unslash( $_POST ) );
|
| 841 |
+
$message = 'Settings saved.';
|
| 842 |
+
}
|
| 843 |
+
|
| 844 |
+
$settings = SA_Orch_LLM_Settings::get();
|
| 845 |
+
$choices = SA_Orch_LLM_Providers::get_provider_choices();
|
| 846 |
+
$token_label = SA_Orch_LLM_Settings::token_status_label();
|
| 847 |
+
|
| 848 |
+
echo '<div class="wrap">';
|
| 849 |
+
echo '<h1>Language Model Providers</h1>';
|
| 850 |
+
|
| 851 |
+
if ( $message ) {
|
| 852 |
+
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html( $message ) . '</p></div>';
|
| 853 |
+
}
|
| 854 |
+
|
| 855 |
+
echo '<p>Configure the provider used by the Strategy Review (layer 3 of the review stack: trace data → deterministic audit → <strong>strategy review</strong> → human arbitration).</p>';
|
| 856 |
+
echo '<p><strong>Local-dev posture:</strong> secrets are stored in <code>wp_options</code>. 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.</p>';
|
| 857 |
+
|
| 858 |
+
echo '<form method="post">';
|
| 859 |
+
wp_nonce_field( 'sa_orch_llm_settings' );
|
| 860 |
+
|
| 861 |
+
echo '<table class="form-table" role="presentation"><tbody>';
|
| 862 |
+
|
| 863 |
+
echo '<tr><th scope="row">Enabled</th><td>';
|
| 864 |
+
echo '<label><input type="checkbox" name="enabled" value="1"' . checked( ! empty( $settings['enabled'] ), true, false ) . ' /> Use the selected provider for Strategy Review.</label>';
|
| 865 |
+
echo '<p class="description">When unchecked, Strategy Review falls back to the simulated provider regardless of the dropdown selection.</p>';
|
| 866 |
+
echo '</td></tr>';
|
| 867 |
+
|
| 868 |
+
echo '<tr><th scope="row"><label for="sa_orch_provider">Provider</label></th><td>';
|
| 869 |
+
echo '<select id="sa_orch_provider" name="provider">';
|
| 870 |
+
foreach ( $choices as $id => $label ) {
|
| 871 |
+
echo '<option value="' . esc_attr( $id ) . '"' . selected( $settings['provider'], $id, false ) . '>' . esc_html( $label ) . '</option>';
|
| 872 |
+
}
|
| 873 |
+
echo '</select>';
|
| 874 |
+
echo '<p class="description">Only <code>simulated</code> is functional in v0.5. Other providers are listed as inert placeholders to surface the seam.</p>';
|
| 875 |
+
echo '</td></tr>';
|
| 876 |
+
|
| 877 |
+
echo '<tr><th scope="row"><label for="sa_orch_model">Model name</label></th><td>';
|
| 878 |
+
echo '<input type="text" id="sa_orch_model" name="model" value="' . esc_attr( $settings['model'] ) . '" class="regular-text" placeholder="e.g. gpt-4o, claude-sonnet-4-5, llama-3.1-70b" />';
|
| 879 |
+
echo '<p class="description">Free-form. Future providers may validate against their own catalog.</p>';
|
| 880 |
+
echo '</td></tr>';
|
| 881 |
+
|
| 882 |
+
echo '<tr><th scope="row"><label for="sa_orch_api_base_url">API base URL</label></th><td>';
|
| 883 |
+
echo '<input type="text" id="sa_orch_api_base_url" name="api_base_url" value="' . esc_attr( $settings['api_base_url'] ) . '" class="regular-text" placeholder="e.g. https://api.openai.com/v1" />';
|
| 884 |
+
echo '<p class="description">Used by HTTP-backed providers. Ignored by the simulated provider.</p>';
|
| 885 |
+
echo '</td></tr>';
|
| 886 |
+
|
| 887 |
+
echo '<tr><th scope="row"><label for="sa_orch_api_token">API token / key</label></th><td>';
|
| 888 |
+
echo '<input type="password" id="sa_orch_api_token" name="api_token" value="" class="regular-text" autocomplete="new-password" placeholder="enter token to set or update; leave blank to preserve" />';
|
| 889 |
+
echo '<p class="description">' . esc_html( $token_label ) . ' Tokens are stored in <code>wp_options</code> for local dev only and are never displayed back through the UI. Leave the field blank to keep the existing value.</p>';
|
| 890 |
+
echo '</td></tr>';
|
| 891 |
+
|
| 892 |
+
echo '</tbody></table>';
|
| 893 |
+
|
| 894 |
+
echo '<p class="submit"><button type="submit" class="button button-primary">Save Settings</button></p>';
|
| 895 |
+
echo '</form>';
|
| 896 |
+
|
| 897 |
+
echo '<hr />';
|
| 898 |
+
|
| 899 |
+
echo '<h2>Status</h2>';
|
| 900 |
+
echo '<table class="widefat striped" style="max-width:600px"><tbody>';
|
| 901 |
+
echo '<tr><th scope="row" style="width:40%">Active provider (resolved)</th><td><code>' . esc_html( SA_Orch_LLM_Providers::get_active_provider()->id() ) . '</code></td></tr>';
|
| 902 |
+
echo '<tr><th scope="row">Settings.enabled</th><td><code>' . ( ! empty( $settings['enabled'] ) ? 'true' : 'false' ) . '</code></td></tr>';
|
| 903 |
+
echo '<tr><th scope="row">Settings.provider</th><td><code>' . esc_html( (string) $settings['provider'] ) . '</code></td></tr>';
|
| 904 |
+
echo '<tr><th scope="row">Settings.model</th><td><code>' . esc_html( (string) $settings['model'] ?: '—' ) . '</code></td></tr>';
|
| 905 |
+
echo '<tr><th scope="row">Settings.api_base_url</th><td><code>' . esc_html( (string) $settings['api_base_url'] ?: '—' ) . '</code></td></tr>';
|
| 906 |
+
echo '<tr><th scope="row">Settings.api_token</th><td>' . ( SA_Orch_LLM_Settings::has_token() ? '<code>***** (set)</code>' : '<code>(not set)</code>' ) . '</td></tr>';
|
| 907 |
+
echo '</tbody></table>';
|
| 908 |
+
|
| 909 |
+
echo '<p style="margin-top:8px"><em>v0.5 ships with the <code>simulated</code> 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 <code>SA_Orch_LLM_Provider_Interface</code> and adding a case to <code>SA_Orch_LLM_Providers::instantiate()</code>.</em></p>';
|
| 910 |
+
|
| 911 |
+
echo '</div>';
|
| 912 |
+
}
|
| 913 |
+
|
| 914 |
+
/* ---------- Acceptance Events ---------- */
|
| 915 |
+
|
| 916 |
+
public static function render_events() {
|
| 917 |
+
if ( ! current_user_can( self::CAP ) ) wp_die( 'Insufficient permissions.' );
|
| 918 |
+
global $wpdb;
|
| 919 |
+
$p = $wpdb->prefix . SA_ORCH_DB_PREFIX;
|
| 920 |
+
|
| 921 |
+
$rows = $wpdb->get_results(
|
| 922 |
+
"SELECT id, event_type, actor_role, demand_surface, demand_ref, observed_at, truth_class, actor_user_id
|
| 923 |
+
FROM {$p}acceptance_event
|
| 924 |
+
ORDER BY observed_at DESC, id DESC LIMIT 20"
|
| 925 |
+
);
|
| 926 |
+
|
| 927 |
+
echo '<div class="wrap">';
|
| 928 |
+
echo '<h1>Acceptance Events</h1>';
|
| 929 |
+
echo '<p>Last 20 acceptance events, newest first. Append-only — these rows are never updated or deleted.</p>';
|
| 930 |
+
|
| 931 |
+
if ( empty( $rows ) ) {
|
| 932 |
+
echo '<p><em>No acceptance events recorded yet.</em></p>';
|
| 933 |
+
} else {
|
| 934 |
+
echo '<table class="widefat striped"><thead><tr>';
|
| 935 |
+
echo '<th>ID</th><th>Event Type</th><th>Actor Role</th><th>Actor</th><th>Demand</th><th>Truth Class</th><th>Observed (UTC)</th><th>Action</th>';
|
| 936 |
+
echo '</tr></thead><tbody>';
|
| 937 |
+
foreach ( $rows as $r ) {
|
| 938 |
+
$trace_url = admin_url( 'admin.php?page=' . self::PARENT_SLUG
|
| 939 |
+
. '&demand_surface=' . urlencode( $r->demand_surface )
|
| 940 |
+
. '&demand_ref=' . urlencode( $r->demand_ref ) );
|
| 941 |
+
echo '<tr>';
|
| 942 |
+
echo '<td>' . (int) $r->id . '</td>';
|
| 943 |
+
echo '<td>' . esc_html( (string) $r->event_type ) . '</td>';
|
| 944 |
+
echo '<td>' . esc_html( (string) $r->actor_role ) . '</td>';
|
| 945 |
+
echo '<td>' . esc_html( (string) ( $r->actor_user_id ?? '—' ) ) . '</td>';
|
| 946 |
+
echo '<td><code>' . esc_html( $r->demand_surface . ' / ' . $r->demand_ref ) . '</code></td>';
|
| 947 |
+
echo '<td>' . esc_html( (string) $r->truth_class ) . '</td>';
|
| 948 |
+
echo '<td>' . esc_html( (string) $r->observed_at ) . '</td>';
|
| 949 |
+
echo '<td><a href="' . esc_url( $trace_url ) . '">Trace</a></td>';
|
| 950 |
+
echo '</tr>';
|
| 951 |
+
}
|
| 952 |
+
echo '</tbody></table>';
|
| 953 |
+
}
|
| 954 |
+
|
| 955 |
+
echo '</div>';
|
| 956 |
+
}
|
| 957 |
+
}
|
sa-orchestration/includes/class-sa-arbitration.php
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
class SA_Orch_Arbitration {
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* Write an arbitration record + optional plan_link rows.
|
| 8 |
+
*
|
| 9 |
+
* Required:
|
| 10 |
+
* - demand_surface (string) e.g. 'fluent-support'
|
| 11 |
+
* - demand_ref (string) e.g. 'ticket#12'
|
| 12 |
+
* - rationale (non-empty string)
|
| 13 |
+
* - projection_type (string) e.g. 'scope-direct-no-plan'
|
| 14 |
+
*
|
| 15 |
+
* Optional:
|
| 16 |
+
* - actor_user_id (int; default current user)
|
| 17 |
+
* - correlation_id (string; default UUID v4)
|
| 18 |
+
* - causation_id (string|null)
|
| 19 |
+
* - truth_class (string; default 'canonical')
|
| 20 |
+
* - plan_refs (array of [ surface, ref, link_kind? ])
|
| 21 |
+
*
|
| 22 |
+
* Returns array on success, WP_Error on failure.
|
| 23 |
+
*/
|
| 24 |
+
public static function write( array $args ) {
|
| 25 |
+
global $wpdb;
|
| 26 |
+
|
| 27 |
+
$demand_surface = isset( $args['demand_surface'] ) ? trim( (string) $args['demand_surface'] ) : '';
|
| 28 |
+
$demand_ref = isset( $args['demand_ref'] ) ? trim( (string) $args['demand_ref'] ) : '';
|
| 29 |
+
$rationale = isset( $args['rationale'] ) ? trim( (string) $args['rationale'] ) : '';
|
| 30 |
+
$projection_type = isset( $args['projection_type'] ) ? trim( (string) $args['projection_type'] ) : '';
|
| 31 |
+
|
| 32 |
+
if ( $demand_surface === '' || $demand_ref === '' ) {
|
| 33 |
+
return new WP_Error( 'missing_demand', 'demand_surface and demand_ref are required' );
|
| 34 |
+
}
|
| 35 |
+
if ( $rationale === '' ) {
|
| 36 |
+
return new WP_Error( 'missing_rationale', 'rationale is required (mandatory per SARA discipline)' );
|
| 37 |
+
}
|
| 38 |
+
if ( $projection_type === '' ) {
|
| 39 |
+
return new WP_Error( 'missing_projection_type', 'projection_type is required' );
|
| 40 |
+
}
|
| 41 |
+
if ( ! in_array( $demand_surface, SA_Orch_Bridge_Fluent::known_surfaces(), true ) ) {
|
| 42 |
+
return new WP_Error( 'unknown_surface', "demand_surface '$demand_surface' is not in known surface vocabulary" );
|
| 43 |
+
}
|
| 44 |
+
if ( ! SA_Orch_Bridge_Fluent::validate( $demand_surface, $demand_ref ) ) {
|
| 45 |
+
return new WP_Error( 'invalid_demand_ref', "demand_ref does not resolve: {$demand_surface}/{$demand_ref}" );
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
$actor_user_id = isset( $args['actor_user_id'] ) ? (int) $args['actor_user_id'] : get_current_user_id();
|
| 49 |
+
if ( $actor_user_id === 0 ) { $actor_user_id = null; }
|
| 50 |
+
|
| 51 |
+
$correlation_id = isset( $args['correlation_id'] ) && $args['correlation_id'] !== ''
|
| 52 |
+
? (string) $args['correlation_id']
|
| 53 |
+
: wp_generate_uuid4();
|
| 54 |
+
$causation_id = isset( $args['causation_id'] ) && $args['causation_id'] !== ''
|
| 55 |
+
? (string) $args['causation_id']
|
| 56 |
+
: null;
|
| 57 |
+
$truth_class = isset( $args['truth_class'] ) && $args['truth_class'] !== ''
|
| 58 |
+
? (string) $args['truth_class']
|
| 59 |
+
: 'canonical';
|
| 60 |
+
|
| 61 |
+
$plan_refs = isset( $args['plan_refs'] ) && is_array( $args['plan_refs'] ) ? $args['plan_refs'] : [];
|
| 62 |
+
|
| 63 |
+
// Validate plan_refs each
|
| 64 |
+
foreach ( $plan_refs as $i => $pr ) {
|
| 65 |
+
$ps = isset( $pr['surface'] ) ? trim( (string) $pr['surface'] ) : '';
|
| 66 |
+
$prr = isset( $pr['ref'] ) ? trim( (string) $pr['ref'] ) : '';
|
| 67 |
+
if ( $ps === '' || $prr === '' ) {
|
| 68 |
+
return new WP_Error( 'invalid_plan_ref', "plan_refs[$i] missing surface or ref" );
|
| 69 |
+
}
|
| 70 |
+
if ( ! in_array( $ps, SA_Orch_Bridge_Fluent::known_surfaces(), true ) ) {
|
| 71 |
+
return new WP_Error( 'unknown_surface', "plan_refs[$i].surface '$ps' is not known" );
|
| 72 |
+
}
|
| 73 |
+
if ( ! SA_Orch_Bridge_Fluent::validate( $ps, $prr ) ) {
|
| 74 |
+
return new WP_Error( 'invalid_plan_ref', "plan_refs[$i] does not resolve: $ps/$prr" );
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
$created_at = current_time( 'mysql', true );
|
| 79 |
+
$hash = self::compute_hash( $demand_surface, $demand_ref, $created_at, $actor_user_id, $correlation_id );
|
| 80 |
+
|
| 81 |
+
// Asterion-first: ledger entry written before any DB row.
|
| 82 |
+
$frontmatter = [
|
| 83 |
+
'kind' => 'arbitration',
|
| 84 |
+
'hash' => $hash,
|
| 85 |
+
'demand_surface' => $demand_surface,
|
| 86 |
+
'demand_ref' => $demand_ref,
|
| 87 |
+
'projection_type' => $projection_type,
|
| 88 |
+
'actor_user_id' => $actor_user_id,
|
| 89 |
+
'correlation_id' => $correlation_id,
|
| 90 |
+
'causation_id' => $causation_id,
|
| 91 |
+
'truth_class' => $truth_class,
|
| 92 |
+
'plan_refs' => array_map( function ( $pr ) {
|
| 93 |
+
return ( $pr['surface'] ?? '' ) . ':' . ( $pr['ref'] ?? '' );
|
| 94 |
+
}, $plan_refs ),
|
| 95 |
+
'created_at' => $created_at,
|
| 96 |
+
];
|
| 97 |
+
|
| 98 |
+
$body = "## Rationale\n\n" . $rationale . "\n";
|
| 99 |
+
|
| 100 |
+
$rel_path = SA_Orch_Asterion::write( 'arbitration', $hash, $frontmatter, $body );
|
| 101 |
+
if ( is_wp_error( $rel_path ) ) {
|
| 102 |
+
return $rel_path;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
// Now insert the runtime cache row.
|
| 106 |
+
$table = $wpdb->prefix . SA_ORCH_DB_PREFIX . 'arbitration';
|
| 107 |
+
$ok = $wpdb->insert( $table, [
|
| 108 |
+
'hash' => $hash,
|
| 109 |
+
'demand_surface' => $demand_surface,
|
| 110 |
+
'demand_ref' => $demand_ref,
|
| 111 |
+
'rationale' => $rationale,
|
| 112 |
+
'projection_type' => $projection_type,
|
| 113 |
+
'actor_user_id' => $actor_user_id,
|
| 114 |
+
'correlation_id' => $correlation_id,
|
| 115 |
+
'causation_id' => $causation_id,
|
| 116 |
+
'truth_class' => $truth_class,
|
| 117 |
+
'asterion_path' => $rel_path,
|
| 118 |
+
'created_at' => $created_at,
|
| 119 |
+
] );
|
| 120 |
+
|
| 121 |
+
if ( $ok === false ) {
|
| 122 |
+
return new WP_Error( 'db_insert_failed', $wpdb->last_error, [ 'asterion_path' => $rel_path ] );
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
$arbitration_id = (int) $wpdb->insert_id;
|
| 126 |
+
|
| 127 |
+
$link_ids = [];
|
| 128 |
+
foreach ( $plan_refs as $pr ) {
|
| 129 |
+
$link_id = SA_Orch_Link::write( [
|
| 130 |
+
'arbitration_id' => $arbitration_id,
|
| 131 |
+
'plan_surface' => $pr['surface'],
|
| 132 |
+
'plan_ref' => $pr['ref'],
|
| 133 |
+
'link_kind' => $pr['link_kind'] ?? 'derived',
|
| 134 |
+
] );
|
| 135 |
+
if ( is_wp_error( $link_id ) ) {
|
| 136 |
+
return $link_id;
|
| 137 |
+
}
|
| 138 |
+
$link_ids[] = $link_id;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
// v0.6.0: notify projection / side-effect listeners. Decoupled — listeners
|
| 142 |
+
// (e.g. SA_Orch_Projection_Fluent_Support) react via WordPress action hooks.
|
| 143 |
+
do_action( 'sa_orch_arbitration_written', [
|
| 144 |
+
'id' => $arbitration_id,
|
| 145 |
+
'hash' => $hash,
|
| 146 |
+
'demand_surface' => $demand_surface,
|
| 147 |
+
'demand_ref' => $demand_ref,
|
| 148 |
+
'projection_type' => $projection_type,
|
| 149 |
+
'truth_class' => $truth_class,
|
| 150 |
+
'plan_link_ids' => $link_ids,
|
| 151 |
+
'correlation_id' => $correlation_id,
|
| 152 |
+
'asterion_path' => $rel_path,
|
| 153 |
+
] );
|
| 154 |
+
|
| 155 |
+
return [
|
| 156 |
+
'id' => $arbitration_id,
|
| 157 |
+
'hash' => $hash,
|
| 158 |
+
'asterion_path' => $rel_path,
|
| 159 |
+
'plan_link_ids' => $link_ids,
|
| 160 |
+
'correlation_id' => $correlation_id,
|
| 161 |
+
];
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
private static function compute_hash( $demand_surface, $demand_ref, $created_at, $actor_user_id, $correlation_id ) {
|
| 165 |
+
$material = implode( '|', [
|
| 166 |
+
$demand_surface,
|
| 167 |
+
$demand_ref,
|
| 168 |
+
$created_at,
|
| 169 |
+
(int) $actor_user_id,
|
| 170 |
+
$correlation_id,
|
| 171 |
+
] );
|
| 172 |
+
return substr( hash( 'sha256', $material ), 0, 32 );
|
| 173 |
+
}
|
| 174 |
+
}
|
sa-orchestration/includes/class-sa-asterion-explorer.php
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Asterion Explorer — read-only admin surface for the canonical spec pack.
|
| 6 |
+
*
|
| 7 |
+
* Source of truth: F:/SleeperAgents/SA-orchestration/Archvie/sa-core/docs/specs/
|
| 8 |
+
* (filterable via 'sa_orch_asterion_spec_root').
|
| 9 |
+
*
|
| 10 |
+
* This is a MIRROR of the repo. The repo remains authoritative.
|
| 11 |
+
* - read-only in WP
|
| 12 |
+
* - no edits
|
| 13 |
+
* - no consolidation
|
| 14 |
+
* - no reclassification
|
| 15 |
+
* - no mixing with other MD sources
|
| 16 |
+
*
|
| 17 |
+
* Distinct from SA_Orch_Asterion (the dormant ledger writer that targets
|
| 18 |
+
* wp-content/sa-asterion/). The Explorer is human-facing canonical doctrine.
|
| 19 |
+
* The ledger (when activated) is system-generated event records. Different
|
| 20 |
+
* surfaces, different concerns; they coexist without entanglement.
|
| 21 |
+
*
|
| 22 |
+
* Mirror mechanism: live filesystem read at request time.
|
| 23 |
+
* - No filesystem copy — no drift potential
|
| 24 |
+
* - No indexed snapshot table — no DB writes
|
| 25 |
+
* - Repo is automatically authoritative because there is no second copy
|
| 26 |
+
*
|
| 27 |
+
* Sara awareness: NONE in this Board. Deferred to a future Sara Retrieval
|
| 28 |
+
* Layer Board. The Explorer is the human-facing truth surface; cross-agent
|
| 29 |
+
* retrieval is a separate concern with its own design constraints.
|
| 30 |
+
*/
|
| 31 |
+
class SA_Orch_Asterion_Explorer {
|
| 32 |
+
|
| 33 |
+
const SUBMENU_SLUG = 'sa-orchestration-asterion';
|
| 34 |
+
|
| 35 |
+
/** Default repo path on this dev machine. Filterable. Override per-deploy via the filter. */
|
| 36 |
+
const DEFAULT_SPEC_ROOT = 'F:/SleeperAgents/SA-orchestration/Archvie/sa-core/docs/specs';
|
| 37 |
+
|
| 38 |
+
/**
|
| 39 |
+
* Absolute path to the spec pack root, normalized for this OS.
|
| 40 |
+
* Filter: sa_orch_asterion_spec_root (string $absolute_path)
|
| 41 |
+
*/
|
| 42 |
+
public static function spec_root(): string {
|
| 43 |
+
$root = apply_filters( 'sa_orch_asterion_spec_root', self::DEFAULT_SPEC_ROOT );
|
| 44 |
+
return rtrim( str_replace( '\\', '/', (string) $root ), '/' );
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/** Hook into admin_menu via the existing SA_Orch_Admin parent. */
|
| 48 |
+
public static function register_menu() {
|
| 49 |
+
add_submenu_page(
|
| 50 |
+
SA_Orch_Admin::PARENT_SLUG,
|
| 51 |
+
'Asterion',
|
| 52 |
+
'Asterion',
|
| 53 |
+
SA_Orch_Admin::CAP,
|
| 54 |
+
self::SUBMENU_SLUG,
|
| 55 |
+
[ __CLASS__, 'render_page' ]
|
| 56 |
+
);
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/**
|
| 60 |
+
* Render the explorer page: file tree on the left, MD render on the right.
|
| 61 |
+
*/
|
| 62 |
+
public static function render_page() {
|
| 63 |
+
if ( ! current_user_can( SA_Orch_Admin::CAP ) ) {
|
| 64 |
+
wp_die( __( 'Insufficient permissions.', 'sa-orchestration' ) );
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
$root = self::spec_root();
|
| 68 |
+
$tree = self::build_tree( $root );
|
| 69 |
+
|
| 70 |
+
// Selected file from query string. Validated below; never touched
|
| 71 |
+
// without going through validate_relative_path() first.
|
| 72 |
+
$requested = isset( $_GET['file'] ) ? wp_unslash( (string) $_GET['file'] ) : '';
|
| 73 |
+
$resolved = $requested !== '' ? self::validate_relative_path( $requested ) : null;
|
| 74 |
+
|
| 75 |
+
echo '<div class="wrap sa-asterion-wrap">';
|
| 76 |
+
echo '<h1>Asterion <span class="sa-asterion-tag">read-only canonical truth surface</span></h1>';
|
| 77 |
+
echo '<p class="sa-asterion-meta">Source: <code>' . esc_html( $root ) . '</code> · Repo is authoritative. No edits from WP.</p>';
|
| 78 |
+
|
| 79 |
+
echo self::inline_styles();
|
| 80 |
+
|
| 81 |
+
echo '<div class="sa-asterion-explorer">';
|
| 82 |
+
|
| 83 |
+
// ---- Tree ----
|
| 84 |
+
echo '<aside class="sa-asterion-tree">';
|
| 85 |
+
if ( empty( $tree ) ) {
|
| 86 |
+
echo '<p class="sa-asterion-empty">No .md files found at the configured spec root. Verify <code>sa_orch_asterion_spec_root</code> filter / default path is reachable from the WP install.</p>';
|
| 87 |
+
} else {
|
| 88 |
+
self::render_tree_html( $tree, '', $resolved !== null ? $resolved['relative'] : '' );
|
| 89 |
+
}
|
| 90 |
+
echo '</aside>';
|
| 91 |
+
|
| 92 |
+
// ---- Content ----
|
| 93 |
+
echo '<section class="sa-asterion-content">';
|
| 94 |
+
if ( $requested !== '' && $resolved === null ) {
|
| 95 |
+
echo '<div class="sa-asterion-error"><strong>Invalid file path.</strong> The requested file is outside the spec root, has a non-.md extension, contains path traversal, or does not exist.</div>';
|
| 96 |
+
} elseif ( $resolved === null ) {
|
| 97 |
+
echo '<div class="sa-asterion-placeholder">Select a file from the tree to view its contents.</div>';
|
| 98 |
+
} else {
|
| 99 |
+
$rendered = self::render_markdown_file( $resolved['absolute'] );
|
| 100 |
+
if ( $rendered === null ) {
|
| 101 |
+
echo '<div class="sa-asterion-error"><strong>Read failed.</strong> The file resolved but could not be read.</div>';
|
| 102 |
+
} else {
|
| 103 |
+
echo '<div class="sa-asterion-breadcrumb"><code>' . esc_html( $resolved['relative'] ) . '</code></div>';
|
| 104 |
+
echo '<article class="sa-asterion-md">' . $rendered . '</article>';
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
echo '</section>';
|
| 108 |
+
|
| 109 |
+
echo '</div>'; // .sa-asterion-explorer
|
| 110 |
+
echo '</div>'; // .wrap
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
/**
|
| 114 |
+
* Build a nested array tree of the spec pack.
|
| 115 |
+
* Returns: [ 'dirs' => [ name => sub-tree ], 'files' => [ name => relative_path ] ]
|
| 116 |
+
*/
|
| 117 |
+
private static function build_tree( string $root ): array {
|
| 118 |
+
if ( ! is_dir( $root ) ) {
|
| 119 |
+
return [];
|
| 120 |
+
}
|
| 121 |
+
return self::walk( $root, '' );
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
private static function walk( string $abs, string $rel_prefix ): array {
|
| 125 |
+
$dirs = [];
|
| 126 |
+
$files = [];
|
| 127 |
+
$entries = @scandir( $abs );
|
| 128 |
+
if ( $entries === false ) {
|
| 129 |
+
return [ 'dirs' => [], 'files' => [] ];
|
| 130 |
+
}
|
| 131 |
+
foreach ( $entries as $entry ) {
|
| 132 |
+
if ( $entry === '.' || $entry === '..' ) continue;
|
| 133 |
+
$abs_path = $abs . '/' . $entry;
|
| 134 |
+
$rel_path = $rel_prefix === '' ? $entry : $rel_prefix . '/' . $entry;
|
| 135 |
+
if ( is_dir( $abs_path ) ) {
|
| 136 |
+
$dirs[ $entry ] = self::walk( $abs_path, $rel_path );
|
| 137 |
+
} elseif ( is_file( $abs_path ) && substr( strtolower( $entry ), -3 ) === '.md' ) {
|
| 138 |
+
$files[ $entry ] = $rel_path;
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
ksort( $dirs );
|
| 142 |
+
ksort( $files );
|
| 143 |
+
return [ 'dirs' => $dirs, 'files' => $files ];
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
private static function render_tree_html( array $tree, string $current_dir_rel, string $selected ) {
|
| 147 |
+
echo '<ul class="sa-asterion-list">';
|
| 148 |
+
foreach ( (array) ( $tree['dirs'] ?? [] ) as $name => $sub ) {
|
| 149 |
+
echo '<li class="sa-asterion-dir"><span class="sa-asterion-dir-label">' . esc_html( $name ) . '/</span>';
|
| 150 |
+
self::render_tree_html( $sub, ( $current_dir_rel === '' ? $name : $current_dir_rel . '/' . $name ), $selected );
|
| 151 |
+
echo '</li>';
|
| 152 |
+
}
|
| 153 |
+
foreach ( (array) ( $tree['files'] ?? [] ) as $name => $rel ) {
|
| 154 |
+
$url = add_query_arg( [
|
| 155 |
+
'page' => self::SUBMENU_SLUG,
|
| 156 |
+
'file' => $rel,
|
| 157 |
+
], admin_url( 'admin.php' ) );
|
| 158 |
+
$is_sel = ( $rel === $selected );
|
| 159 |
+
$cls = 'sa-asterion-file' . ( $is_sel ? ' sa-asterion-file-selected' : '' );
|
| 160 |
+
echo '<li class="' . esc_attr( $cls ) . '"><a href="' . esc_url( $url ) . '">' . esc_html( $name ) . '</a></li>';
|
| 161 |
+
}
|
| 162 |
+
echo '</ul>';
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
/**
|
| 166 |
+
* Validate a relative path against the spec root. Returns
|
| 167 |
+
* [ 'relative' => normalized, 'absolute' => realpath ]
|
| 168 |
+
* or null on any failure (path traversal, wrong extension, outside root,
|
| 169 |
+
* not a file).
|
| 170 |
+
*
|
| 171 |
+
* Public so other surfaces (Board #28 Sara file binding;
|
| 172 |
+
* Board #30 Asterion projection — both reuse) can validate without
|
| 173 |
+
* re-implementing the rules.
|
| 174 |
+
*/
|
| 175 |
+
public static function validate_relative_path( string $relative ): ?array {
|
| 176 |
+
// Normalize separators
|
| 177 |
+
$relative = str_replace( '\\', '/', $relative );
|
| 178 |
+
|
| 179 |
+
// Reject any obvious traversal or absolute paths
|
| 180 |
+
if ( strpos( $relative, '..' ) !== false ) return null;
|
| 181 |
+
if ( $relative === '' ) return null;
|
| 182 |
+
if ( $relative[0] === '/' ) return null;
|
| 183 |
+
if ( preg_match( '#^[A-Za-z]:#', $relative ) ) return null;
|
| 184 |
+
|
| 185 |
+
// Whitelist .md extension only
|
| 186 |
+
if ( substr( strtolower( $relative ), -3 ) !== '.md' ) return null;
|
| 187 |
+
|
| 188 |
+
$root_real = realpath( self::spec_root() );
|
| 189 |
+
if ( $root_real === false ) return null;
|
| 190 |
+
$root_real = str_replace( '\\', '/', $root_real );
|
| 191 |
+
|
| 192 |
+
$candidate = realpath( self::spec_root() . '/' . $relative );
|
| 193 |
+
if ( $candidate === false ) return null;
|
| 194 |
+
$candidate = str_replace( '\\', '/', $candidate );
|
| 195 |
+
|
| 196 |
+
// Final containment check: candidate must be inside root
|
| 197 |
+
if ( strpos( $candidate, $root_real . '/' ) !== 0 ) return null;
|
| 198 |
+
if ( ! is_file( $candidate ) ) return null;
|
| 199 |
+
|
| 200 |
+
// Re-derive normalized relative from the realpath for display
|
| 201 |
+
$normalized_rel = ltrim( substr( $candidate, strlen( $root_real ) ), '/' );
|
| 202 |
+
|
| 203 |
+
return [
|
| 204 |
+
'relative' => $normalized_rel,
|
| 205 |
+
'absolute' => $candidate,
|
| 206 |
+
];
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
/**
|
| 210 |
+
* Read the file from disk and render via Parsedown in safe mode.
|
| 211 |
+
* Returns rendered HTML, or null on read failure.
|
| 212 |
+
*/
|
| 213 |
+
private static function render_markdown_file( string $absolute ): ?string {
|
| 214 |
+
$content = @file_get_contents( $absolute );
|
| 215 |
+
if ( $content === false ) return null;
|
| 216 |
+
|
| 217 |
+
if ( ! class_exists( 'Parsedown' ) ) {
|
| 218 |
+
$vendor = dirname( __DIR__ ) . '/vendor/Parsedown.php';
|
| 219 |
+
if ( file_exists( $vendor ) ) {
|
| 220 |
+
require_once $vendor;
|
| 221 |
+
}
|
| 222 |
+
}
|
| 223 |
+
if ( ! class_exists( 'Parsedown' ) ) {
|
| 224 |
+
// Last-resort fallback: HTML-escape and render in <pre>. Honest if ugly.
|
| 225 |
+
return '<pre class="sa-asterion-fallback">' . esc_html( $content ) . '</pre>';
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
$pd = new Parsedown();
|
| 229 |
+
if ( method_exists( $pd, 'setSafeMode' ) ) {
|
| 230 |
+
$pd->setSafeMode( true );
|
| 231 |
+
}
|
| 232 |
+
return $pd->text( $content );
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
private static function inline_styles(): string {
|
| 236 |
+
// Scoped styles. Keeping it inline so the Board ships in one file.
|
| 237 |
+
return '<style>
|
| 238 |
+
.sa-asterion-wrap h1 .sa-asterion-tag { font-size:11px; padding:2px 6px; background:#f0f0f1; color:#646970; border-radius:3px; vertical-align:middle; font-weight:normal; margin-left:8px; }
|
| 239 |
+
.sa-asterion-meta { color:#646970; font-size:12px; }
|
| 240 |
+
.sa-asterion-explorer { display:flex; gap:16px; margin-top:12px; align-items:flex-start; }
|
| 241 |
+
.sa-asterion-tree { flex:0 0 280px; max-height:80vh; overflow:auto; background:#fff; border:1px solid #c3c4c7; border-radius:4px; padding:12px 8px; }
|
| 242 |
+
.sa-asterion-content { flex:1 1 auto; min-width:0; background:#fff; border:1px solid #c3c4c7; border-radius:4px; padding:16px 24px; }
|
| 243 |
+
.sa-asterion-list { margin:0; padding-left:14px; list-style:none; }
|
| 244 |
+
.sa-asterion-list ul { padding-left:14px; }
|
| 245 |
+
.sa-asterion-dir-label { font-weight:600; color:#1d2327; }
|
| 246 |
+
.sa-asterion-file a { display:block; padding:2px 4px; text-decoration:none; color:#2271b1; }
|
| 247 |
+
.sa-asterion-file a:hover { background:#f6f7f7; }
|
| 248 |
+
.sa-asterion-file-selected a { background:#dcdcde; color:#1d2327; font-weight:600; }
|
| 249 |
+
.sa-asterion-breadcrumb { font-size:12px; color:#646970; margin-bottom:12px; padding-bottom:8px; border-bottom:1px solid #f0f0f1; }
|
| 250 |
+
.sa-asterion-placeholder, .sa-asterion-empty { color:#646970; font-style:italic; }
|
| 251 |
+
.sa-asterion-error { background:#fcf0f1; border-left:4px solid #d63638; padding:10px 12px; color:#7a1f23; }
|
| 252 |
+
.sa-asterion-md { line-height:1.6; }
|
| 253 |
+
.sa-asterion-md h1, .sa-asterion-md h2, .sa-asterion-md h3 { margin-top:1.4em; }
|
| 254 |
+
.sa-asterion-md pre { background:#1d2327; color:#f6f7f7; padding:12px; border-radius:4px; overflow:auto; }
|
| 255 |
+
.sa-asterion-md code { background:#f0f0f1; padding:1px 5px; border-radius:3px; font-size:13px; }
|
| 256 |
+
.sa-asterion-md pre code { background:transparent; padding:0; color:inherit; }
|
| 257 |
+
.sa-asterion-md table { border-collapse:collapse; margin:1em 0; }
|
| 258 |
+
.sa-asterion-md th, .sa-asterion-md td { border:1px solid #c3c4c7; padding:6px 10px; }
|
| 259 |
+
.sa-asterion-md blockquote { border-left:4px solid #c3c4c7; margin:1em 0; padding:4px 14px; color:#50575e; }
|
| 260 |
+
.sa-asterion-fallback { white-space:pre-wrap; }
|
| 261 |
+
</style>';
|
| 262 |
+
}
|
| 263 |
+
}
|
sa-orchestration/includes/class-sa-asterion-projection.php
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Asterion Projection — Board #30
|
| 6 |
+
*
|
| 7 |
+
* Asterion's website-facing query surface, separate from Sara.
|
| 8 |
+
*
|
| 9 |
+
* Sara interprets. Asterion verifies. Two distinct surfaces, same source.
|
| 10 |
+
*
|
| 11 |
+
* Asterion responses are:
|
| 12 |
+
* - grounded in the canonical MD spec corpus (Archvie/sa-core/docs/specs/)
|
| 13 |
+
* - cite specific files (and excerpts) by name
|
| 14 |
+
* - refuse to speculate beyond the source
|
| 15 |
+
* - return "no canonical reference found" when corpus does not contain the answer
|
| 16 |
+
*
|
| 17 |
+
* No write capability anywhere. Read-only by construction.
|
| 18 |
+
*
|
| 19 |
+
* Architecture:
|
| 20 |
+
* - REST endpoint /sa-orch/v1/asterion/query
|
| 21 |
+
* - Keyword search across spec corpus + LLM-framed citation answer
|
| 22 |
+
* - Reuses SA_Orch_Asterion_Explorer::spec_root() and validate_relative_path()
|
| 23 |
+
* - Reuses LLM provider stack (SA_Orch_LLM_Settings, wp_remote_post)
|
| 24 |
+
* - Reuses wp_sa_token_log (Board #16) for usage accounting
|
| 25 |
+
* - Distinct system prompt (librarian role; structurally different shape from Sara)
|
| 26 |
+
*
|
| 27 |
+
* Front-end: assets/asterion-bubble.js + assets/asterion-bubble.css (separate
|
| 28 |
+
* floating button, top-right, distinct visual identity from Sara).
|
| 29 |
+
*/
|
| 30 |
+
class SA_Orch_Asterion_Projection {
|
| 31 |
+
|
| 32 |
+
const HANDLE_JS = 'sa-orch-asterion-bubble';
|
| 33 |
+
const HANDLE_CSS = 'sa-orch-asterion-bubble';
|
| 34 |
+
const REQUEST_TIMEOUT = 30;
|
| 35 |
+
|
| 36 |
+
/** Number of excerpts to return from corpus search. */
|
| 37 |
+
const TOP_K = 5;
|
| 38 |
+
|
| 39 |
+
/** Max chars per excerpt window included in the LLM prompt.
|
| 40 |
+
* Wide enough to give the LLM real context around the match — file headers and
|
| 41 |
+
* one-paragraph blurbs aren't enough to ground a Truth-Class-style answer. */
|
| 42 |
+
const EXCERPT_CHARS = 1800;
|
| 43 |
+
|
| 44 |
+
/* -------------------- Hooks -------------------- */
|
| 45 |
+
|
| 46 |
+
public static function register_hooks() {
|
| 47 |
+
add_action( 'admin_enqueue_scripts', [ __CLASS__, 'maybe_enqueue' ] );
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
public static function register_routes() {
|
| 51 |
+
register_rest_route( 'sa-orch/v1', '/asterion/query', [
|
| 52 |
+
'methods' => 'POST',
|
| 53 |
+
'callback' => [ __CLASS__, 'rest_query' ],
|
| 54 |
+
'permission_callback' => function () {
|
| 55 |
+
return current_user_can( 'manage_options' );
|
| 56 |
+
},
|
| 57 |
+
'args' => [
|
| 58 |
+
'query' => [ 'type' => 'string', 'required' => true ],
|
| 59 |
+
'asterion_file' => [ 'type' => 'string', 'required' => false ],
|
| 60 |
+
],
|
| 61 |
+
] );
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
public static function maybe_enqueue( $hook ) {
|
| 65 |
+
if ( ! current_user_can( 'manage_options' ) ) return;
|
| 66 |
+
|
| 67 |
+
$page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
|
| 68 |
+
|
| 69 |
+
// Asterion bubble: any SA-Orchestration admin page (the Explorer is a subpage,
|
| 70 |
+
// but Asterion is also useful from Demand Trace / Acceptance Events).
|
| 71 |
+
if ( strpos( $page, 'sa-orchestration' ) !== 0 ) return;
|
| 72 |
+
|
| 73 |
+
wp_enqueue_style(
|
| 74 |
+
self::HANDLE_CSS,
|
| 75 |
+
SA_ORCH_URL . 'assets/asterion-bubble.css',
|
| 76 |
+
[],
|
| 77 |
+
SA_ORCH_VERSION
|
| 78 |
+
);
|
| 79 |
+
|
| 80 |
+
wp_enqueue_script(
|
| 81 |
+
self::HANDLE_JS,
|
| 82 |
+
SA_ORCH_URL . 'assets/asterion-bubble.js',
|
| 83 |
+
[],
|
| 84 |
+
SA_ORCH_VERSION,
|
| 85 |
+
true
|
| 86 |
+
);
|
| 87 |
+
|
| 88 |
+
wp_localize_script( self::HANDLE_JS, 'SA_ORCH_ASTERION', [
|
| 89 |
+
'rest_url' => esc_url_raw( rest_url( 'sa-orch/v1/asterion/query' ) ),
|
| 90 |
+
'nonce' => wp_create_nonce( 'wp_rest' ),
|
| 91 |
+
'page' => $page,
|
| 92 |
+
] );
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
/* -------------------- REST handler -------------------- */
|
| 96 |
+
|
| 97 |
+
public static function rest_query( WP_REST_Request $request ) {
|
| 98 |
+
$query = (string) $request->get_param( 'query' );
|
| 99 |
+
$asterion_file = sanitize_text_field( (string) $request->get_param( 'asterion_file' ) );
|
| 100 |
+
|
| 101 |
+
if ( trim( $query ) === '' ) {
|
| 102 |
+
return new WP_REST_Response( [
|
| 103 |
+
'error' => 'empty_query',
|
| 104 |
+
'message' => 'query is required',
|
| 105 |
+
], 400 );
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
$result = self::query( $query, $asterion_file );
|
| 109 |
+
if ( is_wp_error( $result ) ) {
|
| 110 |
+
return new WP_REST_Response( [
|
| 111 |
+
'error' => $result->get_error_code(),
|
| 112 |
+
'message' => $result->get_error_message(),
|
| 113 |
+
], 502 );
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
return new WP_REST_Response( $result, 200 );
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
/* -------------------- Core query -------------------- */
|
| 120 |
+
|
| 121 |
+
/**
|
| 122 |
+
* Run a corpus query and return a structured Asterion response.
|
| 123 |
+
*
|
| 124 |
+
* Response shape:
|
| 125 |
+
* {
|
| 126 |
+
* answer: string, // citation-grounded answer or "no canonical reference found"
|
| 127 |
+
* citations: array, // [{ file, excerpt }, ...]
|
| 128 |
+
* scope: string, // "single_file:<rel>" | "spec_corpus" | "no_corpus"
|
| 129 |
+
* usage: array|null,
|
| 130 |
+
* log_id: int|null
|
| 131 |
+
* }
|
| 132 |
+
*/
|
| 133 |
+
public static function query( string $query, string $asterion_file = '' ) {
|
| 134 |
+
if ( ! class_exists( 'SA_Orch_Asterion_Explorer' ) ) {
|
| 135 |
+
return new WP_Error( 'no_corpus', 'Asterion Explorer not loaded.' );
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
$root = SA_Orch_Asterion_Explorer::spec_root();
|
| 139 |
+
if ( ! is_dir( $root ) ) {
|
| 140 |
+
return [
|
| 141 |
+
'answer' => 'no canonical reference found in the spec corpus (corpus root unreachable)',
|
| 142 |
+
'citations' => [],
|
| 143 |
+
'scope' => 'no_corpus',
|
| 144 |
+
'usage' => null,
|
| 145 |
+
'log_id' => null,
|
| 146 |
+
];
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
// Determine search scope.
|
| 150 |
+
$scope = 'spec_corpus';
|
| 151 |
+
$bound_md = null;
|
| 152 |
+
if ( $asterion_file !== '' ) {
|
| 153 |
+
$resolved = SA_Orch_Asterion_Explorer::validate_relative_path( $asterion_file );
|
| 154 |
+
if ( is_array( $resolved ) ) {
|
| 155 |
+
$bound_md = $resolved;
|
| 156 |
+
$scope = 'single_file:' . $resolved['relative'];
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
// Retrieve excerpts.
|
| 161 |
+
$excerpts = $bound_md
|
| 162 |
+
? self::search_single_file( $bound_md['absolute'], $bound_md['relative'], $query )
|
| 163 |
+
: self::search_corpus( $root, $query );
|
| 164 |
+
|
| 165 |
+
// No matches → refuse to speculate.
|
| 166 |
+
if ( empty( $excerpts ) ) {
|
| 167 |
+
return [
|
| 168 |
+
'answer' => 'no canonical reference found in the spec corpus',
|
| 169 |
+
'citations' => [],
|
| 170 |
+
'scope' => $scope,
|
| 171 |
+
'usage' => null,
|
| 172 |
+
'log_id' => null,
|
| 173 |
+
];
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
// Frame with LLM (Model B). If LLM is disabled or fails, fall through
|
| 177 |
+
// to raw-excerpt mode so the surface still works.
|
| 178 |
+
$framed = self::frame_with_llm( $query, $excerpts, $scope );
|
| 179 |
+
if ( is_array( $framed ) ) {
|
| 180 |
+
return $framed + [
|
| 181 |
+
'citations' => $excerpts,
|
| 182 |
+
'scope' => $scope,
|
| 183 |
+
];
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
// Fallback: raw excerpts as the "answer" when LLM unavailable.
|
| 187 |
+
$answer_lines = [ 'Citation-only mode (LLM unavailable). Raw excerpts:' ];
|
| 188 |
+
foreach ( $excerpts as $i => $ex ) {
|
| 189 |
+
$answer_lines[] = sprintf( '[%d] %s', $i + 1, $ex['file'] );
|
| 190 |
+
}
|
| 191 |
+
return [
|
| 192 |
+
'answer' => implode( "\n", $answer_lines ),
|
| 193 |
+
'citations' => $excerpts,
|
| 194 |
+
'scope' => $scope,
|
| 195 |
+
'usage' => null,
|
| 196 |
+
'log_id' => null,
|
| 197 |
+
];
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
/* -------------------- Search -------------------- */
|
| 201 |
+
|
| 202 |
+
/**
|
| 203 |
+
* Search the entire spec corpus for terms in $query.
|
| 204 |
+
* Returns up to TOP_K excerpts ranked by hit score.
|
| 205 |
+
*/
|
| 206 |
+
private static function search_corpus( string $root, string $query ): array {
|
| 207 |
+
$terms = self::extract_terms( $query );
|
| 208 |
+
if ( empty( $terms ) ) return [];
|
| 209 |
+
|
| 210 |
+
$hits = [];
|
| 211 |
+
self::walk_corpus( $root, '', function ( $abs, $rel ) use ( $terms, &$hits ) {
|
| 212 |
+
$content = @file_get_contents( $abs );
|
| 213 |
+
if ( $content === false ) return;
|
| 214 |
+
$score = 0;
|
| 215 |
+
foreach ( $terms as $t ) {
|
| 216 |
+
$score += substr_count( strtolower( $content ), $t );
|
| 217 |
+
}
|
| 218 |
+
if ( $score > 0 ) {
|
| 219 |
+
$hits[] = [
|
| 220 |
+
'file' => $rel,
|
| 221 |
+
'score' => $score,
|
| 222 |
+
'excerpt' => self::best_excerpt( $content, $terms ),
|
| 223 |
+
];
|
| 224 |
+
}
|
| 225 |
+
} );
|
| 226 |
+
|
| 227 |
+
usort( $hits, function ( $a, $b ) { return $b['score'] - $a['score']; } );
|
| 228 |
+
$hits = array_slice( $hits, 0, self::TOP_K );
|
| 229 |
+
|
| 230 |
+
$out = [];
|
| 231 |
+
foreach ( $hits as $h ) {
|
| 232 |
+
$out[] = [ 'file' => $h['file'], 'excerpt' => $h['excerpt'] ];
|
| 233 |
+
}
|
| 234 |
+
return $out;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
/**
|
| 238 |
+
* Search a single bound MD file.
|
| 239 |
+
*/
|
| 240 |
+
private static function search_single_file( string $abs, string $rel, string $query ): array {
|
| 241 |
+
$content = @file_get_contents( $abs );
|
| 242 |
+
if ( $content === false ) return [];
|
| 243 |
+
|
| 244 |
+
$terms = self::extract_terms( $query );
|
| 245 |
+
if ( empty( $terms ) ) return [];
|
| 246 |
+
|
| 247 |
+
$score = 0;
|
| 248 |
+
foreach ( $terms as $t ) {
|
| 249 |
+
$score += substr_count( strtolower( $content ), $t );
|
| 250 |
+
}
|
| 251 |
+
if ( $score === 0 ) return [];
|
| 252 |
+
|
| 253 |
+
return [ [
|
| 254 |
+
'file' => $rel,
|
| 255 |
+
'excerpt' => self::best_excerpt( $content, $terms ),
|
| 256 |
+
] ];
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
private static function walk_corpus( string $abs, string $rel_prefix, callable $cb ) {
|
| 260 |
+
$entries = @scandir( $abs );
|
| 261 |
+
if ( $entries === false ) return;
|
| 262 |
+
foreach ( $entries as $e ) {
|
| 263 |
+
if ( $e === '.' || $e === '..' ) continue;
|
| 264 |
+
$a = $abs . '/' . $e;
|
| 265 |
+
$r = $rel_prefix === '' ? $e : $rel_prefix . '/' . $e;
|
| 266 |
+
if ( is_dir( $a ) ) {
|
| 267 |
+
self::walk_corpus( $a, $r, $cb );
|
| 268 |
+
} elseif ( is_file( $a ) && substr( strtolower( $e ), -3 ) === '.md' ) {
|
| 269 |
+
$cb( $a, $r );
|
| 270 |
+
}
|
| 271 |
+
}
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
/**
|
| 275 |
+
* Extract lowercased search terms from the query.
|
| 276 |
+
* Strips stopwords/punctuation; minimum 3-char tokens.
|
| 277 |
+
*/
|
| 278 |
+
private static function extract_terms( string $query ): array {
|
| 279 |
+
$q = strtolower( $query );
|
| 280 |
+
$q = preg_replace( '/[^a-z0-9_\-\s]/', ' ', $q );
|
| 281 |
+
$tokens = preg_split( '/\s+/', $q, -1, PREG_SPLIT_NO_EMPTY );
|
| 282 |
+
$stop = [ 'the','and','but','for','are','was','were','have','has','had','will',
|
| 283 |
+
'with','this','that','from','what','which','about','they','them',
|
| 284 |
+
'their','our','your','his','her','its','some','any','all','can',
|
| 285 |
+
'how','why','when','where','who','whom','does','did','done','not',
|
| 286 |
+
'into','onto','off','out','only','also','each','more','less','very',
|
| 287 |
+
'such','than','then','than','these','those','say','says','said' ];
|
| 288 |
+
$out = [];
|
| 289 |
+
foreach ( $tokens as $t ) {
|
| 290 |
+
if ( strlen( $t ) < 3 ) continue;
|
| 291 |
+
if ( in_array( $t, $stop, true ) ) continue;
|
| 292 |
+
$out[ $t ] = true;
|
| 293 |
+
}
|
| 294 |
+
return array_keys( $out );
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
/**
|
| 298 |
+
* Find the best excerpt window in $content given $terms.
|
| 299 |
+
*
|
| 300 |
+
* Strategy: pick the position with the highest local term-density across a
|
| 301 |
+
* sliding window — not just the first match. File headers and credit blurbs
|
| 302 |
+
* often contain isolated keyword hits; the actual definition is further down.
|
| 303 |
+
* Windowing on density beats first-match for citation quality.
|
| 304 |
+
*/
|
| 305 |
+
private static function best_excerpt( string $content, array $terms ): string {
|
| 306 |
+
$lower = strtolower( $content );
|
| 307 |
+
$len = strlen( $lower );
|
| 308 |
+
if ( $len === 0 ) return '';
|
| 309 |
+
|
| 310 |
+
// Collect all match positions for any term.
|
| 311 |
+
$positions = [];
|
| 312 |
+
foreach ( $terms as $t ) {
|
| 313 |
+
$off = 0;
|
| 314 |
+
while ( ( $p = strpos( $lower, $t, $off ) ) !== false ) {
|
| 315 |
+
$positions[] = $p;
|
| 316 |
+
$off = $p + max( 1, strlen( $t ) );
|
| 317 |
+
}
|
| 318 |
+
}
|
| 319 |
+
if ( empty( $positions ) ) {
|
| 320 |
+
// No match — fall back to first chars
|
| 321 |
+
return function_exists( 'mb_substr' )
|
| 322 |
+
? mb_substr( $content, 0, self::EXCERPT_CHARS )
|
| 323 |
+
: substr( $content, 0, self::EXCERPT_CHARS );
|
| 324 |
+
}
|
| 325 |
+
sort( $positions );
|
| 326 |
+
|
| 327 |
+
// Slide a window across positions; pick the position whose window
|
| 328 |
+
// contains the most match positions (density).
|
| 329 |
+
$window = self::EXCERPT_CHARS;
|
| 330 |
+
$best_pos = $positions[0];
|
| 331 |
+
$best_count = 0;
|
| 332 |
+
foreach ( $positions as $p ) {
|
| 333 |
+
$count = 0;
|
| 334 |
+
foreach ( $positions as $q ) {
|
| 335 |
+
if ( $q >= $p && $q < $p + $window ) $count++;
|
| 336 |
+
if ( $q >= $p + $window ) break;
|
| 337 |
+
}
|
| 338 |
+
if ( $count > $best_count ) {
|
| 339 |
+
$best_count = $count;
|
| 340 |
+
$best_pos = $p;
|
| 341 |
+
}
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
// Center window on best_pos (back off slightly so match isn't at edge)
|
| 345 |
+
$half = (int) ( $window / 4 ); // back off 25%, not 50% — anchor near top of window
|
| 346 |
+
$start = max( 0, $best_pos - $half );
|
| 347 |
+
$exc = function_exists( 'mb_substr' )
|
| 348 |
+
? mb_substr( $content, $start, $window )
|
| 349 |
+
: substr( $content, $start, $window );
|
| 350 |
+
if ( $start > 0 ) $exc = '… ' . $exc;
|
| 351 |
+
if ( $start + $window < strlen( $content ) ) $exc .= ' …';
|
| 352 |
+
return $exc;
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
/* -------------------- LLM framing -------------------- */
|
| 356 |
+
|
| 357 |
+
/**
|
| 358 |
+
* Frame the excerpts as a librarian-tone, citation-grounded answer.
|
| 359 |
+
* Returns array { answer, usage, log_id } on success, null on failure.
|
| 360 |
+
*/
|
| 361 |
+
private static function frame_with_llm( string $query, array $excerpts, string $scope ) {
|
| 362 |
+
if ( ! class_exists( 'SA_Orch_LLM_Settings' ) ) return null;
|
| 363 |
+
$settings = SA_Orch_LLM_Settings::get();
|
| 364 |
+
if ( empty( $settings['enabled'] ) || empty( $settings['api_token'] )
|
| 365 |
+
|| empty( $settings['api_base_url'] ) || empty( $settings['model'] ) ) {
|
| 366 |
+
return null;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
$url = rtrim( (string) $settings['api_base_url'], '/' ) . '/chat/completions';
|
| 370 |
+
|
| 371 |
+
$excerpt_block = "Excerpts retrieved from the spec corpus (verbatim; do not invent beyond these):\n\n";
|
| 372 |
+
foreach ( $excerpts as $i => $ex ) {
|
| 373 |
+
$n = $i + 1;
|
| 374 |
+
$excerpt_block .= "[CITATION {$n}] file: {$ex['file']}\n";
|
| 375 |
+
$excerpt_block .= "----\n";
|
| 376 |
+
$excerpt_block .= $ex['excerpt'] . "\n";
|
| 377 |
+
$excerpt_block .= "----\n\n";
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
$messages = [
|
| 381 |
+
[ 'role' => 'system', 'content' => self::system_prompt() ],
|
| 382 |
+
[ 'role' => 'user', 'content' => $excerpt_block . "User question:\n\n" . $query ],
|
| 383 |
+
];
|
| 384 |
+
|
| 385 |
+
$payload = [
|
| 386 |
+
'model' => (string) $settings['model'],
|
| 387 |
+
'messages' => $messages,
|
| 388 |
+
'response_format' => [ 'type' => 'json_object' ],
|
| 389 |
+
'temperature' => 0.1,
|
| 390 |
+
];
|
| 391 |
+
|
| 392 |
+
$response = wp_remote_post( $url, [
|
| 393 |
+
'headers' => [
|
| 394 |
+
'Authorization' => 'Bearer ' . (string) $settings['api_token'],
|
| 395 |
+
'Content-Type' => 'application/json',
|
| 396 |
+
],
|
| 397 |
+
'body' => wp_json_encode( $payload ),
|
| 398 |
+
'timeout' => self::REQUEST_TIMEOUT,
|
| 399 |
+
] );
|
| 400 |
+
|
| 401 |
+
if ( is_wp_error( $response ) ) return null;
|
| 402 |
+
$code = (int) wp_remote_retrieve_response_code( $response );
|
| 403 |
+
if ( $code < 200 || $code >= 300 ) return null;
|
| 404 |
+
|
| 405 |
+
$body = (string) wp_remote_retrieve_body( $response );
|
| 406 |
+
$envelope = json_decode( $body, true );
|
| 407 |
+
if ( ! is_array( $envelope ) || ! isset( $envelope['choices'][0]['message']['content'] ) ) return null;
|
| 408 |
+
|
| 409 |
+
$content = (string) $envelope['choices'][0]['message']['content'];
|
| 410 |
+
$parsed = json_decode( $content, true );
|
| 411 |
+
if ( ! is_array( $parsed ) ) return null;
|
| 412 |
+
|
| 413 |
+
$answer = isset( $parsed['answer'] ) ? (string) $parsed['answer'] : '';
|
| 414 |
+
if ( $answer === '' ) {
|
| 415 |
+
$answer = 'no canonical reference found in the spec corpus';
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
// Token log
|
| 419 |
+
$usage = isset( $envelope['usage'] ) && is_array( $envelope['usage'] ) ? $envelope['usage'] : null;
|
| 420 |
+
$log_id = null;
|
| 421 |
+
if ( $usage !== null && class_exists( 'SA_Orch_Token_Log' ) ) {
|
| 422 |
+
$log_id = SA_Orch_Token_Log::record(
|
| 423 |
+
'asterion',
|
| 424 |
+
(string) $settings['model'],
|
| 425 |
+
$usage,
|
| 426 |
+
(int) get_current_user_id()
|
| 427 |
+
);
|
| 428 |
+
$log_id = $log_id > 0 ? $log_id : null;
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
return [
|
| 432 |
+
'answer' => $answer,
|
| 433 |
+
'usage' => $usage,
|
| 434 |
+
'log_id' => $log_id,
|
| 435 |
+
];
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
/**
|
| 439 |
+
* Asterion's system prompt — librarian role.
|
| 440 |
+
* Structurally distinct from Sara's prompt:
|
| 441 |
+
* - no interpretation tone, no synthesis, no advice
|
| 442 |
+
* - citation-grounded, refusal-to-speculate, file-named
|
| 443 |
+
* - returns { answer: string } JSON; no summary/suggestion/draft/notes
|
| 444 |
+
*/
|
| 445 |
+
public static function system_prompt(): string {
|
| 446 |
+
return "ASTERION — canonical librarian for the SA-Core spec corpus.\n"
|
| 447 |
+
. "\n"
|
| 448 |
+
. "You are NOT Sara. You are NOT an interpreter. You are NOT an advisor.\n"
|
| 449 |
+
. "You are the verifier: a strict, citation-grounded surface over\n"
|
| 450 |
+
. "Archvie/sa-core/docs/specs/. Your job is to ground every claim in\n"
|
| 451 |
+
. "specific source excerpts that have been provided to you, and to\n"
|
| 452 |
+
. "refuse if the answer is not present in those excerpts.\n"
|
| 453 |
+
. "\n"
|
| 454 |
+
. "TONE\n"
|
| 455 |
+
. " - Direct. Citation-led. No interpretive framing. No synthesis tone.\n"
|
| 456 |
+
. " - No \"I think\", no \"likely\", no \"probably\", no advisory voice.\n"
|
| 457 |
+
. " - Reference the corpus by file name + section. Use phrases like\n"
|
| 458 |
+
. " \"per 02-invariants.md, I2 ...\" — name the file and the labeled\n"
|
| 459 |
+
. " element.\n"
|
| 460 |
+
. "\n"
|
| 461 |
+
. "OUTPUT (strict JSON; no other keys)\n"
|
| 462 |
+
. " { \"answer\": string }\n"
|
| 463 |
+
. "\n"
|
| 464 |
+
. "RULES\n"
|
| 465 |
+
. " - If the excerpts contain the answer: state it directly and cite the\n"
|
| 466 |
+
. " file(s) and labeled elements (I1, I2, R-O-1, named sections). Quote\n"
|
| 467 |
+
. " short phrases verbatim where it adds verifiability.\n"
|
| 468 |
+
. " - If the excerpts do NOT contain the answer (the question is off-topic,\n"
|
| 469 |
+
. " or the corpus is silent): set answer to exactly:\n"
|
| 470 |
+
. " \"no canonical reference found in the spec corpus\"\n"
|
| 471 |
+
. " Do not speculate. Do not extrapolate. Do not invent.\n"
|
| 472 |
+
. " - You may NOT introduce concepts not present in the excerpts.\n"
|
| 473 |
+
. " - You may NOT advise the user on what to do.\n"
|
| 474 |
+
. " - You may NOT use Sara's structured 4-key shape. Only { \"answer\" }.\n"
|
| 475 |
+
. "\n"
|
| 476 |
+
. "Sara interprets. You verify. The Human arbitrates.\n";
|
| 477 |
+
}
|
| 478 |
+
}
|
sa-orchestration/includes/class-sa-asterion.php
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
class SA_Orch_Asterion {
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* Root directory for the Asterion ledger.
|
| 8 |
+
* Default: WP_CONTENT_DIR/sa-asterion. Filterable.
|
| 9 |
+
*/
|
| 10 |
+
public static function root() {
|
| 11 |
+
$default = WP_CONTENT_DIR . '/sa-asterion';
|
| 12 |
+
return apply_filters( 'sa_orch_asterion_root', $default );
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
/**
|
| 16 |
+
* Asterion-first write. The .md file is the canonical record.
|
| 17 |
+
* Returns relative path (e.g. "arbitration/<hash>.md") or WP_Error.
|
| 18 |
+
*/
|
| 19 |
+
public static function write( $kind, $hash, array $frontmatter, $body ) {
|
| 20 |
+
$root = self::root();
|
| 21 |
+
if ( ! is_dir( $root ) && ! wp_mkdir_p( $root ) ) {
|
| 22 |
+
return new WP_Error( 'asterion_mkdir_failed', "Could not create root: $root" );
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
$kind_safe = sanitize_file_name( $kind );
|
| 26 |
+
$hash_safe = sanitize_file_name( $hash );
|
| 27 |
+
|
| 28 |
+
$kind_dir = $root . '/' . $kind_safe;
|
| 29 |
+
if ( ! is_dir( $kind_dir ) && ! wp_mkdir_p( $kind_dir ) ) {
|
| 30 |
+
return new WP_Error( 'asterion_mkdir_failed', "Could not create kind dir: $kind_dir" );
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
$rel = $kind_safe . '/' . $hash_safe . '.md';
|
| 34 |
+
$abs = $root . '/' . $rel;
|
| 35 |
+
|
| 36 |
+
if ( file_exists( $abs ) ) {
|
| 37 |
+
return new WP_Error( 'asterion_collision', "Ledger entry already exists: $rel" );
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
$contents = "---\n" . self::build_frontmatter( $frontmatter ) . "---\n\n" . $body . "\n";
|
| 41 |
+
|
| 42 |
+
$written = @file_put_contents( $abs, $contents, LOCK_EX );
|
| 43 |
+
if ( $written === false ) {
|
| 44 |
+
return new WP_Error( 'asterion_write_failed', "Could not write $abs" );
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
return $rel;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
private static function build_frontmatter( array $fm ) {
|
| 51 |
+
$out = '';
|
| 52 |
+
foreach ( $fm as $k => $v ) {
|
| 53 |
+
if ( is_array( $v ) ) {
|
| 54 |
+
if ( empty( $v ) ) {
|
| 55 |
+
$out .= "{$k}: []\n";
|
| 56 |
+
} else {
|
| 57 |
+
$out .= "{$k}:\n";
|
| 58 |
+
foreach ( $v as $item ) {
|
| 59 |
+
$out .= " - " . self::yaml_scalar( $item ) . "\n";
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
} else {
|
| 63 |
+
$out .= "{$k}: " . self::yaml_scalar( $v ) . "\n";
|
| 64 |
+
}
|
| 65 |
+
}
|
| 66 |
+
return $out;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
private static function yaml_scalar( $v ) {
|
| 70 |
+
if ( $v === null ) return '~';
|
| 71 |
+
if ( is_bool( $v ) ) return $v ? 'true' : 'false';
|
| 72 |
+
if ( is_int( $v ) ) return (string) $v;
|
| 73 |
+
if ( is_float( $v ) ) return (string) $v;
|
| 74 |
+
|
| 75 |
+
$s = (string) $v;
|
| 76 |
+
// Quote strings that contain YAML-special chars or whitespace edges
|
| 77 |
+
if ( $s === '' || preg_match( '/[:#\-\?&\*\!\|>\'"%@`\{\}\[\],\n]/', $s ) || $s !== trim( $s ) ) {
|
| 78 |
+
return '"' . str_replace( [ '\\', '"' ], [ '\\\\', '\\"' ], $s ) . '"';
|
| 79 |
+
}
|
| 80 |
+
return $s;
|
| 81 |
+
}
|
| 82 |
+
}
|
sa-orchestration/includes/class-sa-attention-bridge.php
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Attention Bridge — emit-only MVP.
|
| 6 |
+
*
|
| 7 |
+
* Cross-site attention routing channel. The local site (RCM) emits
|
| 8 |
+
* structured context packets to a configured oversight URL. The
|
| 9 |
+
* receiving side is OUT OF SCOPE for this Board — emit-only MVP.
|
| 10 |
+
*
|
| 11 |
+
* Architectural rules (stakeholder, locked):
|
| 12 |
+
* Attention is portable. Authority is local. Oversight is routed.
|
| 13 |
+
* Local site owns its operational truth.
|
| 14 |
+
* SleeperAgents owns oversight routing.
|
| 15 |
+
* SA-Core governs the exchange.
|
| 16 |
+
*
|
| 17 |
+
* Doctrine constraints honored:
|
| 18 |
+
* - User-triggered emission ONLY. No auto-emit. No Sara coupling.
|
| 19 |
+
* - Bearer-token auth for the outbound POST (shared secret).
|
| 20 |
+
* - Schema versioning baked into the payload: 'sa.attention.v1'.
|
| 21 |
+
* - Log-only storage: wp_sa_attention_log captures what was sent + whether it arrived.
|
| 22 |
+
* - "Don't overbuild." This file does emit + log. Nothing else.
|
| 23 |
+
*
|
| 24 |
+
* Configuration (wp-config.php constants — required):
|
| 25 |
+
* define('SA_SITE_ID', 'rcm');
|
| 26 |
+
* define('SA_ATTENTION_TARGET_URL', 'https://sleeperagents.org/wp-json/oversight/v1/attention');
|
| 27 |
+
* define('SA_ATTENTION_TOKEN', 'shared-secret-here');
|
| 28 |
+
*
|
| 29 |
+
* Optional:
|
| 30 |
+
* define('SA_TENANT', 'org-slug'); // single-field tenant per locked spec
|
| 31 |
+
*
|
| 32 |
+
* Each constant is also filterable for testing / runtime override:
|
| 33 |
+
* sa_orch_attention_site_id, sa_orch_attention_target_url,
|
| 34 |
+
* sa_orch_attention_token, sa_orch_attention_tenant
|
| 35 |
+
*/
|
| 36 |
+
class SA_Orch_Attention_Bridge {
|
| 37 |
+
|
| 38 |
+
const SCHEMA_VERSION = 'sa.attention.v1';
|
| 39 |
+
|
| 40 |
+
const HANDLE_JS = 'sa-orch-attention-bridge';
|
| 41 |
+
const HANDLE_CSS = 'sa-orch-attention-bridge';
|
| 42 |
+
|
| 43 |
+
/** Hook registration — REST + admin enqueue. */
|
| 44 |
+
public static function register_hooks() {
|
| 45 |
+
add_action( 'rest_api_init', [ __CLASS__, 'register_rest_routes' ] );
|
| 46 |
+
add_action( 'admin_enqueue_scripts', [ __CLASS__, 'maybe_enqueue' ] );
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/**
|
| 50 |
+
* REST endpoints:
|
| 51 |
+
* POST /sa-orch/v1/attention/emit — always (production-safe)
|
| 52 |
+
* POST /sa-orch/v1/attention/dev-receive — local/development only (mock 200 receiver)
|
| 53 |
+
*/
|
| 54 |
+
public static function register_rest_routes() {
|
| 55 |
+
register_rest_route( 'sa-orch/v1', '/attention/emit', [
|
| 56 |
+
'methods' => 'POST',
|
| 57 |
+
'callback' => [ __CLASS__, 'emit' ],
|
| 58 |
+
'permission_callback' => function () {
|
| 59 |
+
return current_user_can( 'manage_options' );
|
| 60 |
+
},
|
| 61 |
+
'args' => [
|
| 62 |
+
'surface' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ],
|
| 63 |
+
'ref' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ],
|
| 64 |
+
'page' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ],
|
| 65 |
+
// 'mode' selects which target the dev verification flow exercises.
|
| 66 |
+
// Currently accepted: 'default' (the configured / scaffold-failure target)
|
| 67 |
+
// and 'success_mock' (routes to the dev-receive endpoint, dev-only).
|
| 68 |
+
// In non-dev environments, 'success_mock' silently falls back to default.
|
| 69 |
+
'mode' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_key' ],
|
| 70 |
+
],
|
| 71 |
+
] );
|
| 72 |
+
|
| 73 |
+
// Dev-only mock receiver. Registered ONLY when the environment is
|
| 74 |
+
// explicitly local or development. In production this route does not
|
| 75 |
+
// exist — the registration itself is gated, not just the handler.
|
| 76 |
+
if ( class_exists( 'SA_Orch_Env' ) && SA_Orch_Env::is_local_or_dev() ) {
|
| 77 |
+
register_rest_route( 'sa-orch/v1', '/attention/dev-receive', [
|
| 78 |
+
'methods' => 'POST',
|
| 79 |
+
'callback' => [ __CLASS__, 'dev_receive' ],
|
| 80 |
+
// No capability check — this is a dev mock for self-call testing.
|
| 81 |
+
// The whole route is only registered in dev environments.
|
| 82 |
+
'permission_callback' => '__return_true',
|
| 83 |
+
] );
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
/**
|
| 88 |
+
* Dev-only mock receiver. Returns HTTP 200 with a confirmation body so
|
| 89 |
+
* the success path can be exercised end-to-end without a real oversight
|
| 90 |
+
* site standing up. Defense in depth: handler also bails if env is not
|
| 91 |
+
* dev (in case the route somehow survives a misconfigured deploy).
|
| 92 |
+
*/
|
| 93 |
+
public static function dev_receive( WP_REST_Request $request ) {
|
| 94 |
+
if ( ! class_exists( 'SA_Orch_Env' ) || ! SA_Orch_Env::is_local_or_dev() ) {
|
| 95 |
+
return new WP_Error(
|
| 96 |
+
'dev_receive_not_available',
|
| 97 |
+
'dev-receive is only available in local/development environments.',
|
| 98 |
+
[ 'status' => 404 ]
|
| 99 |
+
);
|
| 100 |
+
}
|
| 101 |
+
return rest_ensure_response( [
|
| 102 |
+
'received' => true,
|
| 103 |
+
'env' => SA_Orch_Env::type(),
|
| 104 |
+
'message' => 'Dev mock receiver acknowledged the attention packet.',
|
| 105 |
+
'received_at' => gmdate( 'c' ),
|
| 106 |
+
] );
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
/**
|
| 110 |
+
* Read configuration with classification-aware precedence:
|
| 111 |
+
*
|
| 112 |
+
* 1. Defined constant (production-safe; human-authorized)
|
| 113 |
+
* 2. Filter override (testing override; same shape as the constant)
|
| 114 |
+
* 3. Dev scaffold default (only when env is local/development; clearly
|
| 115 |
+
* non-production values; no real privileged
|
| 116 |
+
* system exposed; tokens labeled dev-only)
|
| 117 |
+
* 4. Empty string (production with no constants set; emit will
|
| 118 |
+
* fail with attention_config_missing — preserved
|
| 119 |
+
* behavior for the unconfigured production case)
|
| 120 |
+
*
|
| 121 |
+
* Per the Environment Configuration Classification rule, dev scaffolding
|
| 122 |
+
* is allowed because:
|
| 123 |
+
* - the values are clearly non-production (sa-dev-local / discard-port URL / dev-only token)
|
| 124 |
+
* - no real external privileged system is exposed (target is unreachable)
|
| 125 |
+
* - the token is explicitly labeled dev-only
|
| 126 |
+
* - the env must be EXPLICITLY local/development (default 'production' never scaffolds)
|
| 127 |
+
*/
|
| 128 |
+
public static function get_config(): array {
|
| 129 |
+
$defaults = self::dev_scaffold_defaults();
|
| 130 |
+
return [
|
| 131 |
+
'site_id' => self::resolve_value( 'SA_SITE_ID', 'sa_orch_attention_site_id', $defaults['site_id'] ),
|
| 132 |
+
'target_url' => self::resolve_value( 'SA_ATTENTION_TARGET_URL', 'sa_orch_attention_target_url', $defaults['target_url'] ),
|
| 133 |
+
'token' => self::resolve_value( 'SA_ATTENTION_TOKEN', 'sa_orch_attention_token', $defaults['token'] ),
|
| 134 |
+
'tenant' => self::resolve_value( 'SA_TENANT', 'sa_orch_attention_tenant', $defaults['tenant'] ),
|
| 135 |
+
];
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
/**
|
| 139 |
+
* Resolve a single config value. Constants beat filters beat dev scaffolds
|
| 140 |
+
* beat empty. The dev_default is only non-empty when env is local/dev.
|
| 141 |
+
*/
|
| 142 |
+
private static function resolve_value( string $constant_name, string $filter_name, string $dev_default ): string {
|
| 143 |
+
if ( defined( $constant_name ) ) {
|
| 144 |
+
return (string) constant( $constant_name );
|
| 145 |
+
}
|
| 146 |
+
$filtered = apply_filters( $filter_name, '' );
|
| 147 |
+
if ( $filtered !== '' && $filtered !== null ) {
|
| 148 |
+
return (string) $filtered;
|
| 149 |
+
}
|
| 150 |
+
return $dev_default;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
/**
|
| 154 |
+
* Dev-mode scaffolds. Empty in production. Populated in local/dev only.
|
| 155 |
+
*
|
| 156 |
+
* Values chosen to be unambiguously non-production:
|
| 157 |
+
* site_id — 'sa-dev-local' (slug clearly marks origin as dev)
|
| 158 |
+
* target_url — '<scheme>://<host>:9/sa-attention-dev-null' derived from
|
| 159 |
+
* home_url() so the scaffold scales with the install's own
|
| 160 |
+
* hostname (works on wordpress.localhost, dev.example.com,
|
| 161 |
+
* etc.). Port 9 is the IANA discard service; reliably
|
| 162 |
+
* refuses connections regardless of host. No real system
|
| 163 |
+
* is exposed; emit attempts cleanly fail with status=failed.
|
| 164 |
+
* token — 'dev-only-not-a-real-secret' (string self-labels its kind)
|
| 165 |
+
* tenant — 'dev-tenant'
|
| 166 |
+
*/
|
| 167 |
+
private static function dev_scaffold_defaults(): array {
|
| 168 |
+
if ( ! class_exists( 'SA_Orch_Env' ) || ! SA_Orch_Env::is_local_or_dev() ) {
|
| 169 |
+
return [ 'site_id' => '', 'target_url' => '', 'token' => '', 'tenant' => '' ];
|
| 170 |
+
}
|
| 171 |
+
return [
|
| 172 |
+
'site_id' => 'sa-dev-local',
|
| 173 |
+
'target_url' => self::dev_failure_target_url(),
|
| 174 |
+
'token' => 'dev-only-not-a-real-secret',
|
| 175 |
+
'tenant' => 'dev-tenant',
|
| 176 |
+
];
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
/**
|
| 180 |
+
* Compute the dev failure-target URL from the install's own host.
|
| 181 |
+
* Scales with deploy: same code yields wordpress.localhost:9 in local
|
| 182 |
+
* dev, dev.example.com:9 in remote dev, etc. Production never calls this.
|
| 183 |
+
*/
|
| 184 |
+
private static function dev_failure_target_url(): string {
|
| 185 |
+
$parts = wp_parse_url( home_url() );
|
| 186 |
+
$scheme = $parts['scheme'] ?? 'http';
|
| 187 |
+
$host = $parts['host'] ?? '127.0.0.1';
|
| 188 |
+
return $scheme . '://' . $host . ':9/sa-attention-dev-null';
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
/**
|
| 192 |
+
* The URL of the dev mock receiver — same install, REST endpoint at
|
| 193 |
+
* sa-orch/v1/attention/dev-receive. Returns empty string in non-dev
|
| 194 |
+
* environments so the success-mock mode silently falls back to default.
|
| 195 |
+
*/
|
| 196 |
+
private static function dev_success_mock_url(): string {
|
| 197 |
+
if ( ! class_exists( 'SA_Orch_Env' ) || ! SA_Orch_Env::is_local_or_dev() ) {
|
| 198 |
+
return '';
|
| 199 |
+
}
|
| 200 |
+
return rest_url( 'sa-orch/v1/attention/dev-receive' );
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
/**
|
| 204 |
+
* Resolve the outbound target URL given the configured target and the
|
| 205 |
+
* caller-requested mode. Returns:
|
| 206 |
+
* [ url, mode_label ]
|
| 207 |
+
* mode_label is a short human-readable string for UI display:
|
| 208 |
+
* 'configured' — the configured / scaffold-failure target
|
| 209 |
+
* 'dev failure target' — same as 'configured' when env is dev (annotated)
|
| 210 |
+
* 'dev success mock' — the dev-receive URL (only when env is dev)
|
| 211 |
+
*/
|
| 212 |
+
private static function resolve_outbound_target( string $configured_url, string $mode ): array {
|
| 213 |
+
$is_dev = class_exists( 'SA_Orch_Env' ) && SA_Orch_Env::is_local_or_dev();
|
| 214 |
+
|
| 215 |
+
if ( $mode === 'success_mock' ) {
|
| 216 |
+
$mock = self::dev_success_mock_url();
|
| 217 |
+
if ( $is_dev && $mock !== '' ) {
|
| 218 |
+
return [ $mock, 'dev success mock' ];
|
| 219 |
+
}
|
| 220 |
+
// Production / non-dev silently falls back to configured. Production
|
| 221 |
+
// safety: success_mock has no effect outside dev.
|
| 222 |
+
}
|
| 223 |
+
return [
|
| 224 |
+
$configured_url,
|
| 225 |
+
$is_dev ? 'dev failure target' : 'configured',
|
| 226 |
+
];
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
/**
|
| 230 |
+
* Build the v1 attention payload. Pure function — no I/O.
|
| 231 |
+
*/
|
| 232 |
+
public static function build_payload( array $cfg, array $input ): array {
|
| 233 |
+
$user = wp_get_current_user();
|
| 234 |
+
$user_id = isset( $user->ID ) ? (int) $user->ID : 0;
|
| 235 |
+
$user_name = isset( $user->user_login ) ? (string) $user->user_login : '';
|
| 236 |
+
|
| 237 |
+
return [
|
| 238 |
+
'schema_version' => self::SCHEMA_VERSION,
|
| 239 |
+
'site_id' => (string) $cfg['site_id'],
|
| 240 |
+
'tenant' => (string) $cfg['tenant'],
|
| 241 |
+
'surface' => isset( $input['surface'] ) && $input['surface'] !== '' ? (string) $input['surface'] : null,
|
| 242 |
+
'ref' => isset( $input['ref'] ) && $input['ref'] !== '' ? (string) $input['ref'] : null,
|
| 243 |
+
'page' => isset( $input['page'] ) && $input['page'] !== '' ? (string) $input['page'] : null,
|
| 244 |
+
'user' => [
|
| 245 |
+
'id' => $user_id,
|
| 246 |
+
'login' => $user_name,
|
| 247 |
+
],
|
| 248 |
+
'observed_at' => gmdate( 'c' ),
|
| 249 |
+
];
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
/**
|
| 253 |
+
* REST emit handler. Assembles payload, POSTs to target URL with bearer auth,
|
| 254 |
+
* logs the attempt to wp_sa_attention_log. Returns the result to the caller
|
| 255 |
+
* so the UI can render success/error feedback.
|
| 256 |
+
*/
|
| 257 |
+
public static function emit( WP_REST_Request $request ) {
|
| 258 |
+
$cfg = self::get_config();
|
| 259 |
+
|
| 260 |
+
// Required-config check. Surface missing config explicitly so the
|
| 261 |
+
// operator knows what to set, rather than silently failing.
|
| 262 |
+
$missing = [];
|
| 263 |
+
if ( $cfg['site_id'] === '' ) $missing[] = 'SA_SITE_ID';
|
| 264 |
+
if ( $cfg['target_url'] === '' ) $missing[] = 'SA_ATTENTION_TARGET_URL';
|
| 265 |
+
if ( $cfg['token'] === '' ) $missing[] = 'SA_ATTENTION_TOKEN';
|
| 266 |
+
if ( $missing ) {
|
| 267 |
+
return new WP_Error(
|
| 268 |
+
'attention_config_missing',
|
| 269 |
+
'Attention Bridge configuration missing: ' . implode( ', ', $missing )
|
| 270 |
+
. '. Define these constants in wp-config.php.',
|
| 271 |
+
[ 'status' => 500 ]
|
| 272 |
+
);
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
$input = [
|
| 276 |
+
'surface' => (string) $request->get_param( 'surface' ),
|
| 277 |
+
'ref' => (string) $request->get_param( 'ref' ),
|
| 278 |
+
'page' => (string) $request->get_param( 'page' ),
|
| 279 |
+
];
|
| 280 |
+
|
| 281 |
+
$payload = self::build_payload( $cfg, $input );
|
| 282 |
+
|
| 283 |
+
// Resolve which target this emit hits. 'success_mock' routes to the
|
| 284 |
+
// dev-receive endpoint (dev-only); otherwise the configured target.
|
| 285 |
+
$mode = (string) $request->get_param( 'mode' );
|
| 286 |
+
list( $outbound_url, $mode_label ) = self::resolve_outbound_target( $cfg['target_url'], $mode );
|
| 287 |
+
|
| 288 |
+
// POST with bearer auth. wp_remote_post handles transport errors gracefully.
|
| 289 |
+
$resp = wp_remote_post( $outbound_url, [
|
| 290 |
+
'method' => 'POST',
|
| 291 |
+
'timeout' => 10,
|
| 292 |
+
'headers' => [
|
| 293 |
+
'Authorization' => 'Bearer ' . $cfg['token'],
|
| 294 |
+
'Content-Type' => 'application/json',
|
| 295 |
+
'X-SA-Schema-Version' => self::SCHEMA_VERSION,
|
| 296 |
+
],
|
| 297 |
+
'body' => wp_json_encode( $payload ),
|
| 298 |
+
] );
|
| 299 |
+
|
| 300 |
+
$http_status = null;
|
| 301 |
+
$response_excerpt = null;
|
| 302 |
+
$error_message = null;
|
| 303 |
+
if ( is_wp_error( $resp ) ) {
|
| 304 |
+
$error_message = $resp->get_error_message();
|
| 305 |
+
} else {
|
| 306 |
+
$http_status = (int) wp_remote_retrieve_response_code( $resp );
|
| 307 |
+
$body = (string) wp_remote_retrieve_body( $resp );
|
| 308 |
+
$response_excerpt = function_exists( 'mb_substr' ) ? mb_substr( $body, 0, 500 ) : substr( $body, 0, 500 );
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
// Determine status. Distinguishes "the receiver accepted the packet" from
|
| 312 |
+
// "the attempt was made but did not arrive cleanly". This is the difference
|
| 313 |
+
// between an attempted-emission log and a successful-receipt log; we
|
| 314 |
+
// explicitly want the former so the operator can audit transport reality.
|
| 315 |
+
//
|
| 316 |
+
// success — HTTP 2xx received from the configured target
|
| 317 |
+
// failed — anything else: transport error (connection/DNS/timeout) OR
|
| 318 |
+
// a non-2xx HTTP response (4xx/5xx etc.)
|
| 319 |
+
//
|
| 320 |
+
// 'blocked_config' is intentionally NOT used here. Config validation
|
| 321 |
+
// failed earlier returns a WP_Error with no I/O; no row is written.
|
| 322 |
+
$is_2xx = ( $error_message === null && $http_status !== null && $http_status >= 200 && $http_status < 300 );
|
| 323 |
+
$status = $is_2xx ? 'success' : 'failed';
|
| 324 |
+
|
| 325 |
+
// Log the attempt. Log-only — no analytics, no aggregation, no replay.
|
| 326 |
+
// target_url logs the actual URL hit (which is the resolved outbound,
|
| 327 |
+
// not necessarily the configured one — so dev success_mock attempts
|
| 328 |
+
// are honestly recorded as having gone to the dev-receive endpoint).
|
| 329 |
+
global $wpdb;
|
| 330 |
+
$tbl = $wpdb->prefix . SA_ORCH_DB_PREFIX . 'attention_log';
|
| 331 |
+
$wpdb->insert( $tbl, [
|
| 332 |
+
'site_id' => (string) $cfg['site_id'],
|
| 333 |
+
'surface' => $payload['surface'],
|
| 334 |
+
'ref' => $payload['ref'],
|
| 335 |
+
'target_url' => (string) $outbound_url,
|
| 336 |
+
'payload_json' => wp_json_encode( $payload ),
|
| 337 |
+
'status' => $status,
|
| 338 |
+
'http_status' => $http_status,
|
| 339 |
+
'response_excerpt' => $response_excerpt,
|
| 340 |
+
'error_message' => $error_message,
|
| 341 |
+
'recorded_at' => current_time( 'mysql', true ),
|
| 342 |
+
] );
|
| 343 |
+
$log_id = (int) $wpdb->insert_id;
|
| 344 |
+
|
| 345 |
+
return rest_ensure_response( [
|
| 346 |
+
'log_id' => $log_id,
|
| 347 |
+
'status' => $status,
|
| 348 |
+
'http_status' => $http_status,
|
| 349 |
+
'response_excerpt' => $response_excerpt,
|
| 350 |
+
'error_message' => $error_message,
|
| 351 |
+
'payload' => $payload, // echo back so UI can confirm what went out
|
| 352 |
+
'mode_label' => $mode_label, // 'dev failure target' / 'dev success mock' / 'configured'
|
| 353 |
+
'target_url' => $outbound_url, // the URL actually hit, for UI display
|
| 354 |
+
'ok' => $is_2xx,
|
| 355 |
+
] );
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
/**
|
| 359 |
+
* Enqueue the floating "Send Attention" button on the same admin
|
| 360 |
+
* surfaces where Sara appears, gated to manage_options.
|
| 361 |
+
* Sara's bubble lives bottom-right; this button lives bottom-left.
|
| 362 |
+
*/
|
| 363 |
+
public static function maybe_enqueue( $hook ) {
|
| 364 |
+
if ( ! current_user_can( 'manage_options' ) ) {
|
| 365 |
+
return;
|
| 366 |
+
}
|
| 367 |
+
$page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
|
| 368 |
+
$is_target = (
|
| 369 |
+
$page === 'fluent-boards'
|
| 370 |
+
|| $page === 'fluent-support'
|
| 371 |
+
|| strpos( $page, 'sa-orchestration' ) === 0
|
| 372 |
+
);
|
| 373 |
+
if ( ! $is_target ) {
|
| 374 |
+
return;
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
wp_enqueue_script(
|
| 378 |
+
self::HANDLE_JS,
|
| 379 |
+
SA_ORCH_URL . 'assets/attention-bridge.js',
|
| 380 |
+
[],
|
| 381 |
+
SA_ORCH_VERSION,
|
| 382 |
+
true
|
| 383 |
+
);
|
| 384 |
+
|
| 385 |
+
$cfg = self::get_config();
|
| 386 |
+
$is_dev = class_exists( 'SA_Orch_Env' ) && SA_Orch_Env::is_local_or_dev();
|
| 387 |
+
wp_localize_script( self::HANDLE_JS, 'SA_ORCH_ATTENTION', [
|
| 388 |
+
'rest_url' => esc_url_raw( rest_url( 'sa-orch/v1/attention/emit' ) ),
|
| 389 |
+
'nonce' => wp_create_nonce( 'wp_rest' ),
|
| 390 |
+
'page' => $page,
|
| 391 |
+
'site_id' => $cfg['site_id'], // for display only — not used as auth
|
| 392 |
+
'configured' => $cfg['site_id'] !== '' && $cfg['target_url'] !== '' && $cfg['token'] !== '',
|
| 393 |
+
'is_dev' => $is_dev, // Board #21 dev verification: when true, a second
|
| 394 |
+
// "Send to dev mock" button appears alongside
|
| 395 |
+
// the default "Send Attention". Production never
|
| 396 |
+
// sees this flag; the second button never renders.
|
| 397 |
+
] );
|
| 398 |
+
}
|
| 399 |
+
}
|
sa-orchestration/includes/class-sa-bridge-fluent.php
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Logical bridge to Fluent (and other) surface tables.
|
| 6 |
+
* Closed enumeration — extending requires touching this class.
|
| 7 |
+
* Per SA-Core invariants I1 + I8: no DB-level FK to surface tables.
|
| 8 |
+
*/
|
| 9 |
+
class SA_Orch_Bridge_Fluent {
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* Resolve a (surface, ref) to the underlying surface row, or null.
|
| 13 |
+
* The 'derived' surface intentionally returns null — it has no row.
|
| 14 |
+
*/
|
| 15 |
+
public static function resolve( $surface, $ref ) {
|
| 16 |
+
global $wpdb;
|
| 17 |
+
|
| 18 |
+
if ( $surface === 'derived' ) {
|
| 19 |
+
return null;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
if ( $surface === 'fluent-support' && preg_match( '/^ticket#(\d+)$/', $ref, $m ) ) {
|
| 23 |
+
return $wpdb->get_row( $wpdb->prepare(
|
| 24 |
+
"SELECT * FROM {$wpdb->prefix}fs_tickets WHERE id = %d",
|
| 25 |
+
(int) $m[1]
|
| 26 |
+
) );
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
if ( $surface === 'fluent-boards' && preg_match( '/^task#(\d+)$/', $ref, $m ) ) {
|
| 30 |
+
return $wpdb->get_row( $wpdb->prepare(
|
| 31 |
+
"SELECT * FROM {$wpdb->prefix}fbs_tasks WHERE id = %d",
|
| 32 |
+
(int) $m[1]
|
| 33 |
+
) );
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
if ( $surface === 'fluentcrm' && preg_match( '/^subscriber#(\d+)$/', $ref, $m ) ) {
|
| 37 |
+
return $wpdb->get_row( $wpdb->prepare(
|
| 38 |
+
"SELECT * FROM {$wpdb->prefix}fc_subscribers WHERE id = %d",
|
| 39 |
+
(int) $m[1]
|
| 40 |
+
) );
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
return null;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/**
|
| 47 |
+
* True if the surface/ref pair points to a real row OR is the
|
| 48 |
+
* 'derived' surface (legitimately rowless). False otherwise.
|
| 49 |
+
*/
|
| 50 |
+
public static function validate( $surface, $ref ) {
|
| 51 |
+
if ( $surface === 'derived' ) {
|
| 52 |
+
return $ref !== '' && preg_match( '/^derived#[A-Za-z0-9_\-]+$/', $ref );
|
| 53 |
+
}
|
| 54 |
+
return self::resolve( $surface, $ref ) !== null;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/** Recognized surface vocabulary (closed set). */
|
| 58 |
+
public static function known_surfaces() {
|
| 59 |
+
return [ 'fluent-support', 'fluent-boards', 'fluentcrm', 'derived' ];
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/**
|
| 63 |
+
* Surface adapter factory.
|
| 64 |
+
*
|
| 65 |
+
* Returns the adapter for a given surface, or null if no adapter is
|
| 66 |
+
* registered yet. Adapters are the safe path to surface data —
|
| 67 |
+
* external schema knowledge is bounded to the adapter file. Direct
|
| 68 |
+
* $wpdb access to surface tables in callers is the legacy path; new
|
| 69 |
+
* code should route through the adapter.
|
| 70 |
+
*
|
| 71 |
+
* v0.7.0 shipped one adapter (fluent-boards). Board #18 added
|
| 72 |
+
* fluent-support to the contract. fluentcrm and derived still resolve
|
| 73 |
+
* via the legacy paths in resolve() / validate() above; that carryover
|
| 74 |
+
* stays until those surfaces are ported (deferred Board #18 scope).
|
| 75 |
+
*/
|
| 76 |
+
public static function adapter_for( string $surface ): ?SA_Orch_Surface_Adapter {
|
| 77 |
+
switch ( $surface ) {
|
| 78 |
+
case 'fluent-boards':
|
| 79 |
+
return new SA_Orch_Adapter_FluentBoards();
|
| 80 |
+
case 'fluent-support':
|
| 81 |
+
return new SA_Orch_Adapter_FluentSupport();
|
| 82 |
+
// Deferred (later Board):
|
| 83 |
+
// case 'fluentcrm': return new SA_Orch_Adapter_FluentCRM();
|
| 84 |
+
// case 'derived': return new SA_Orch_Adapter_Derived();
|
| 85 |
+
}
|
| 86 |
+
return null;
|
| 87 |
+
}
|
| 88 |
+
}
|
sa-orchestration/includes/class-sa-demand-registry.php
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Demand Registration Hook.
|
| 6 |
+
*
|
| 7 |
+
* A surface object (ticket, task, subscriber) is NOT automatically a
|
| 8 |
+
* Demand. It exists operationally on its surface (Fluent Support, Fluent
|
| 9 |
+
* Boards, FluentCRM) but is invisible to Asterion's trace/audit/closure
|
| 10 |
+
* logic until explicitly registered.
|
| 11 |
+
*
|
| 12 |
+
* This class provides that registration. It writes an arbitration row
|
| 13 |
+
* (the canonical first entry of a Demand lifecycle) through the surface
|
| 14 |
+
* adapter — never via direct $wpdb access to surface tables.
|
| 15 |
+
*
|
| 16 |
+
* v1 surface: a single static method, callable from PHP. Future:
|
| 17 |
+
* an admin UI button or REST endpoint can wrap this.
|
| 18 |
+
*
|
| 19 |
+
* Demand Registration Boundary (per doctrine):
|
| 20 |
+
* - Unregistered surface objects remain operational but do NOT
|
| 21 |
+
* participate in trace, audit, or closure logic.
|
| 22 |
+
* - They are referred to as "untracked demand candidates" until
|
| 23 |
+
* registered.
|
| 24 |
+
*/
|
| 25 |
+
class SA_Orch_Demand_Registry {
|
| 26 |
+
|
| 27 |
+
/**
|
| 28 |
+
* Register a surface object as an Asterion Demand.
|
| 29 |
+
*
|
| 30 |
+
* @param string $surface Surface id (must have a registered adapter).
|
| 31 |
+
* @param string $ref Surface-specific ref (must validate via adapter).
|
| 32 |
+
* @param string $rationale Human-readable rationale for arbitration.
|
| 33 |
+
* @param string $projection_type Default 'scope-direct-no-plan'; see SA_Orch_Arbitration::write.
|
| 34 |
+
* @param string $truth_class Default 'canonical'.
|
| 35 |
+
*
|
| 36 |
+
* @return array|WP_Error Arbitration row data on success; WP_Error on failure.
|
| 37 |
+
*/
|
| 38 |
+
public static function register(
|
| 39 |
+
string $surface,
|
| 40 |
+
string $ref,
|
| 41 |
+
string $rationale,
|
| 42 |
+
string $projection_type = 'scope-direct-no-plan',
|
| 43 |
+
string $truth_class = 'canonical'
|
| 44 |
+
) {
|
| 45 |
+
$adapter = SA_Orch_Bridge_Fluent::adapter_for( $surface );
|
| 46 |
+
if ( ! $adapter ) {
|
| 47 |
+
return new WP_Error(
|
| 48 |
+
'no_adapter',
|
| 49 |
+
"No surface adapter registered for '{$surface}'."
|
| 50 |
+
);
|
| 51 |
+
}
|
| 52 |
+
if ( ! $adapter->validate( $ref ) ) {
|
| 53 |
+
return new WP_Error(
|
| 54 |
+
'invalid_ref',
|
| 55 |
+
"Ref '{$ref}' does not resolve under the '{$surface}' adapter."
|
| 56 |
+
);
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
return SA_Orch_Arbitration::write( [
|
| 60 |
+
'demand_surface' => $surface,
|
| 61 |
+
'demand_ref' => $ref,
|
| 62 |
+
'rationale' => $rationale,
|
| 63 |
+
'projection_type' => $projection_type,
|
| 64 |
+
'truth_class' => $truth_class,
|
| 65 |
+
] );
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
/**
|
| 69 |
+
* Convenience: is this surface object already registered as a Demand?
|
| 70 |
+
* Returns true if at least one arbitration exists for the (surface, ref) pair.
|
| 71 |
+
*/
|
| 72 |
+
public static function is_registered( string $surface, string $ref ): bool {
|
| 73 |
+
global $wpdb;
|
| 74 |
+
$arb_table = $wpdb->prefix . SA_ORCH_DB_PREFIX . 'arbitration';
|
| 75 |
+
$count = (int) $wpdb->get_var( $wpdb->prepare(
|
| 76 |
+
"SELECT COUNT(*) FROM {$arb_table} WHERE demand_surface = %s AND demand_ref = %s",
|
| 77 |
+
$surface, $ref
|
| 78 |
+
) );
|
| 79 |
+
return $count > 0;
|
| 80 |
+
}
|
| 81 |
+
}
|
sa-orchestration/includes/class-sa-env.php
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Environment classification helper.
|
| 6 |
+
*
|
| 7 |
+
* Implements the SA-Orch Environment Configuration Classification rule:
|
| 8 |
+
*
|
| 9 |
+
* production secrets — human-authorized only (no scaffolding)
|
| 10 |
+
* development placeholders — Asterion may scaffold (non-secret)
|
| 11 |
+
* local test routing — Asterion may scaffold (non-secret)
|
| 12 |
+
* deploy-time constants — Asterion may scaffold (non-secret)
|
| 13 |
+
*
|
| 14 |
+
* The line: production credentials are never auto-written. Dev/local
|
| 15 |
+
* placeholders MAY be scaffolded when the environment is *explicitly*
|
| 16 |
+
* marked local or development AND the value is clearly non-production.
|
| 17 |
+
*
|
| 18 |
+
* Source of truth for "is this local/dev":
|
| 19 |
+
* 1. WP_ENVIRONMENT_TYPE constant (WordPress canonical)
|
| 20 |
+
* 2. WP_ENVIRONMENT_TYPE environment variable (WordPress canonical)
|
| 21 |
+
* 3. Default: 'production' (safe by default)
|
| 22 |
+
*
|
| 23 |
+
* Filterable via 'sa_orch_env_type' for testing only — never used to
|
| 24 |
+
* change production behavior in real deployments.
|
| 25 |
+
*/
|
| 26 |
+
class SA_Orch_Env {
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* Return the current environment type. One of:
|
| 30 |
+
* 'local' | 'development' | 'staging' | 'production'
|
| 31 |
+
* Defaults to 'production' when unable to determine — production-safe.
|
| 32 |
+
*/
|
| 33 |
+
public static function type(): string {
|
| 34 |
+
if ( ! function_exists( 'wp_get_environment_type' ) ) {
|
| 35 |
+
$base = 'production';
|
| 36 |
+
} else {
|
| 37 |
+
$base = (string) wp_get_environment_type();
|
| 38 |
+
}
|
| 39 |
+
// Filter for testing scenarios; production code paths never set this.
|
| 40 |
+
return (string) apply_filters( 'sa_orch_env_type', $base );
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/** True iff env is explicitly local OR development. */
|
| 44 |
+
public static function is_local_or_dev(): bool {
|
| 45 |
+
$t = self::type();
|
| 46 |
+
return $t === 'local' || $t === 'development';
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/** True iff env is production (the default; assume-prod-when-unsure). */
|
| 50 |
+
public static function is_production(): bool {
|
| 51 |
+
return self::type() === 'production';
|
| 52 |
+
}
|
| 53 |
+
}
|
sa-orchestration/includes/class-sa-fluent-state.php
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Fluent state wrapper — the single canonical entry point for orchestration-
|
| 6 |
+
* driven task movement.
|
| 7 |
+
*
|
| 8 |
+
* Why this exists:
|
| 9 |
+
* Direct ORM stage updates ($task->stage_id = X; $task->save();) bypass
|
| 10 |
+
* Fluent's lifecycle behavior. Specifically, they do NOT fire:
|
| 11 |
+
* - do_action('fluent_boards/task_stage_updated', $task, $oldStageId)
|
| 12 |
+
* which feeds:
|
| 13 |
+
* - ActivityHandler@logTaskStageUpdatedActivity (the audit log)
|
| 14 |
+
* - NotificationHandler@changeStageNotification (emails)
|
| 15 |
+
* - StageChangedTrigger (FluentCRM automations)
|
| 16 |
+
* They also leave 'status' and 'last_completed_at' stale relative to the
|
| 17 |
+
* stage's default_task_status setting.
|
| 18 |
+
*
|
| 19 |
+
* The default path (move_task) keeps the system in sync. The gated path
|
| 20 |
+
* (move_task_silent) preserves the same state correctness but suppresses the
|
| 21 |
+
* action — for context repair / migration / latent-checks that should not
|
| 22 |
+
* generate notifications. Both paths use Fluent's own Task::close() and
|
| 23 |
+
* Task::reopen() primitives so the canonical timestamp behavior is honored.
|
| 24 |
+
*
|
| 25 |
+
* Direct ORM ($task->save() with no helpers) remains available as an
|
| 26 |
+
* implicit escape hatch for ad-hoc surgery; nothing in this class blocks it.
|
| 27 |
+
* The choice is at the call site.
|
| 28 |
+
*
|
| 29 |
+
* Architectural rule recorded:
|
| 30 |
+
* - Protocol comment is canonical for lifecycle (Proposed/Approved/
|
| 31 |
+
* In Progress/Blocked/Complete/Accepted).
|
| 32 |
+
* - Fluent stage_id + status are projections of that lifecycle.
|
| 33 |
+
* - This wrapper keeps the projection true.
|
| 34 |
+
*/
|
| 35 |
+
class SA_Orch_Fluent_State {
|
| 36 |
+
|
| 37 |
+
/**
|
| 38 |
+
* Move a task to a new stage and fire Fluent's lifecycle behavior.
|
| 39 |
+
*
|
| 40 |
+
* Behavior:
|
| 41 |
+
* - reads old stage_id
|
| 42 |
+
* - sets new stage_id, saves
|
| 43 |
+
* - aligns status + last_completed_at via Task::close() / Task::reopen()
|
| 44 |
+
* based on the new stage's default_task_status setting
|
| 45 |
+
* - fires do_action('fluent_boards/task_stage_updated', $task, $oldStageId)
|
| 46 |
+
*
|
| 47 |
+
* Idempotent: if old == new stage_id, no-op returns true.
|
| 48 |
+
* Returns false if task or stage not found.
|
| 49 |
+
*/
|
| 50 |
+
public static function move_task( int $task_id, int $new_stage_id, array $opts = [] ): bool {
|
| 51 |
+
return self::do_move( $task_id, $new_stage_id, true /* fire events */ );
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
/**
|
| 55 |
+
* Same as move_task() but does NOT fire 'fluent_boards/task_stage_updated'.
|
| 56 |
+
*
|
| 57 |
+
* Use cases (developer choice — explicitly opt-in):
|
| 58 |
+
* - Backfilling drifted state without spamming notifications
|
| 59 |
+
* - Repair operations during migration
|
| 60 |
+
* - Latent-check passes outside the project board concept
|
| 61 |
+
*
|
| 62 |
+
* State correctness (status, last_completed_at) is still maintained.
|
| 63 |
+
* Only the side-channel notifications/automations are suppressed.
|
| 64 |
+
*/
|
| 65 |
+
public static function move_task_silent( int $task_id, int $new_stage_id, array $opts = [] ): bool {
|
| 66 |
+
return self::do_move( $task_id, $new_stage_id, false /* suppress events */ );
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/**
|
| 70 |
+
* Internal mover. Single code path; the only difference between move_task
|
| 71 |
+
* and move_task_silent is whether the action fires at the end.
|
| 72 |
+
*
|
| 73 |
+
* When $fire_events = true (default move_task path), the Description
|
| 74 |
+
* Contract gates from Board #27 also apply:
|
| 75 |
+
* - Pre-Work gate: blocks Open → In Progress when description is empty
|
| 76 |
+
* - Completion gate: blocks In Progress → Complete when description
|
| 77 |
+
* lacks acceptance language ('accept', 'criteria', 'done when',
|
| 78 |
+
* 'pass when' — case-insensitive substring; deterministic, no NLP).
|
| 79 |
+
* The silent variant explicitly bypasses both gates — it's the gated
|
| 80 |
+
* developer-choice path for context repair / migration.
|
| 81 |
+
*/
|
| 82 |
+
private static function do_move( int $task_id, int $new_stage_id, bool $fire_events ): bool {
|
| 83 |
+
$task = \FluentBoards\App\Models\Task::find( $task_id );
|
| 84 |
+
if ( ! $task ) {
|
| 85 |
+
return false;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
$old_stage_id = (int) $task->stage_id;
|
| 89 |
+
if ( $old_stage_id === $new_stage_id ) {
|
| 90 |
+
// Idempotent no-op. Stage is already where requested.
|
| 91 |
+
return true;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
$stage = \FluentBoards\App\Models\Stage::find( $new_stage_id );
|
| 95 |
+
if ( ! $stage ) {
|
| 96 |
+
return false;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
// Board #27 — Description Contract gates. Apply only on default path.
|
| 100 |
+
if ( $fire_events && ! self::description_contract_passes( $task, $new_stage_id ) ) {
|
| 101 |
+
return false;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
// Move the stage first so subsequent close()/reopen() observers see
|
| 105 |
+
// the new stage as the source of truth.
|
| 106 |
+
$task->stage_id = $new_stage_id;
|
| 107 |
+
$task->save();
|
| 108 |
+
|
| 109 |
+
// Align status + last_completed_at to the new stage's contract.
|
| 110 |
+
// Task::close() and Task::reopen() are Fluent's own primitives and
|
| 111 |
+
// handle the timestamp invariants correctly.
|
| 112 |
+
$default_status = $stage->defaultTaskStatus();
|
| 113 |
+
if ( $default_status === 'closed' && $task->status !== 'closed' ) {
|
| 114 |
+
$task->close();
|
| 115 |
+
} elseif ( $default_status === 'open' && $task->status === 'closed' ) {
|
| 116 |
+
$task->reopen();
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
if ( $fire_events ) {
|
| 120 |
+
do_action( 'fluent_boards/task_stage_updated', $task, $old_stage_id );
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
return true;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
/**
|
| 127 |
+
* Board #27 — Description Contract gates.
|
| 128 |
+
*
|
| 129 |
+
* Returns true if the transition is allowed. Returns false (with
|
| 130 |
+
* error_log entry explaining why) if the gate blocks.
|
| 131 |
+
*
|
| 132 |
+
* Stage IDs (board 16):
|
| 133 |
+
* 131 = Open / Approved / [QUEUE]
|
| 134 |
+
* 132 = In Progress
|
| 135 |
+
* 133 = Completed
|
| 136 |
+
*
|
| 137 |
+
* Gate logic:
|
| 138 |
+
* - Moving INTO 132 (In Progress) → Pre-Work gate: description must be non-empty
|
| 139 |
+
* - Moving INTO 133 (Completed) → Completion gate: description must contain
|
| 140 |
+
* one of 'accept', 'criteria', 'done when',
|
| 141 |
+
* 'pass when' (case-insensitive substring)
|
| 142 |
+
* - Other stage moves → no gate
|
| 143 |
+
*
|
| 144 |
+
* Both gates are bypassed by move_task_silent() (the gated escape hatch from #19).
|
| 145 |
+
*/
|
| 146 |
+
private static function description_contract_passes( $task, int $new_stage_id ): bool {
|
| 147 |
+
$description = isset( $task->description ) ? trim( (string) $task->description ) : '';
|
| 148 |
+
|
| 149 |
+
if ( $new_stage_id === 132 ) {
|
| 150 |
+
if ( $description === '' ) {
|
| 151 |
+
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." );
|
| 152 |
+
return false;
|
| 153 |
+
}
|
| 154 |
+
return true;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
if ( $new_stage_id === 133 ) {
|
| 158 |
+
if ( $description === '' ) {
|
| 159 |
+
error_log( "[SA-Orch #27] move_task blocked: task #{$task->id} has empty description; cannot move to Complete. Use move_task_silent() to bypass." );
|
| 160 |
+
return false;
|
| 161 |
+
}
|
| 162 |
+
$lower = strtolower( $description );
|
| 163 |
+
$patterns = [ 'accept', 'criteria', 'done when', 'pass when' ];
|
| 164 |
+
$hit = false;
|
| 165 |
+
foreach ( $patterns as $p ) {
|
| 166 |
+
if ( strpos( $lower, $p ) !== false ) { $hit = true; break; }
|
| 167 |
+
}
|
| 168 |
+
if ( ! $hit ) {
|
| 169 |
+
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." );
|
| 170 |
+
return false;
|
| 171 |
+
}
|
| 172 |
+
return true;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
// Other stage moves: no gate.
|
| 176 |
+
return true;
|
| 177 |
+
}
|
| 178 |
+
}
|
sa-orchestration/includes/class-sa-link.php
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
class SA_Orch_Link {
|
| 5 |
+
|
| 6 |
+
public static function write( array $args ) {
|
| 7 |
+
global $wpdb;
|
| 8 |
+
|
| 9 |
+
$arbitration_id = isset( $args['arbitration_id'] ) ? (int) $args['arbitration_id'] : 0;
|
| 10 |
+
$plan_surface = isset( $args['plan_surface'] ) ? trim( (string) $args['plan_surface'] ) : '';
|
| 11 |
+
$plan_ref = isset( $args['plan_ref'] ) ? trim( (string) $args['plan_ref'] ) : '';
|
| 12 |
+
$link_kind = isset( $args['link_kind'] ) ? trim( (string) $args['link_kind'] ) : 'derived';
|
| 13 |
+
|
| 14 |
+
if ( $arbitration_id === 0 ) {
|
| 15 |
+
return new WP_Error( 'missing_arbitration_id', 'arbitration_id is required' );
|
| 16 |
+
}
|
| 17 |
+
if ( $plan_surface === '' || $plan_ref === '' ) {
|
| 18 |
+
return new WP_Error( 'missing_plan_ref', 'plan_surface and plan_ref are required' );
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
$created_at = current_time( 'mysql', true );
|
| 22 |
+
|
| 23 |
+
$table = $wpdb->prefix . SA_ORCH_DB_PREFIX . 'arbitration_plan_link';
|
| 24 |
+
$ok = $wpdb->insert( $table, [
|
| 25 |
+
'arbitration_id' => $arbitration_id,
|
| 26 |
+
'plan_surface' => $plan_surface,
|
| 27 |
+
'plan_ref' => $plan_ref,
|
| 28 |
+
'link_kind' => $link_kind,
|
| 29 |
+
'created_at' => $created_at,
|
| 30 |
+
] );
|
| 31 |
+
|
| 32 |
+
if ( $ok === false ) {
|
| 33 |
+
return new WP_Error( 'db_insert_failed', $wpdb->last_error );
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
return (int) $wpdb->insert_id;
|
| 37 |
+
}
|
| 38 |
+
}
|
sa-orchestration/includes/class-sa-llm-provider-openai.php
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* OpenAI Strategy LLM provider.
|
| 6 |
+
*
|
| 7 |
+
* Calls the OpenAI Chat Completions API (or any OpenAI-compatible endpoint:
|
| 8 |
+
* Azure OpenAI, OpenRouter, vLLM, llama.cpp server, etc.) and returns the
|
| 9 |
+
* same five-key shape the simulated provider returns.
|
| 10 |
+
*
|
| 11 |
+
* Pre-flight: throws if enabled=false, token missing, model missing, or
|
| 12 |
+
* api_base_url missing. The Strategy orchestrator catches the throw and
|
| 13 |
+
* falls back to the simulated provider, recording the error in
|
| 14 |
+
* `provider_error` for UI display.
|
| 15 |
+
*
|
| 16 |
+
* Network: uses wp_remote_post with a 30-second timeout. HTTP errors and
|
| 17 |
+
* malformed JSON also throw, triggering the same fallback.
|
| 18 |
+
*
|
| 19 |
+
* Contract enforcement: the prompt tells the model that closure_state and
|
| 20 |
+
* needs_human_arbitration are authoritative and must not be overridden.
|
| 21 |
+
* Returned JSON is also clamped server-side to the contract shape, so a
|
| 22 |
+
* misbehaving model cannot inject unexpected fields.
|
| 23 |
+
*/
|
| 24 |
+
class SA_Orch_LLM_Provider_OpenAI implements SA_Orch_LLM_Provider_Interface {
|
| 25 |
+
|
| 26 |
+
const REQUEST_TIMEOUT = 30;
|
| 27 |
+
const RESPONSE_BODY_PREVIEW_BYTES = 240;
|
| 28 |
+
|
| 29 |
+
public function id(): string { return 'openai'; }
|
| 30 |
+
public function name(): string { return 'OpenAI'; }
|
| 31 |
+
public function requires_api_key(): bool { return true; }
|
| 32 |
+
|
| 33 |
+
public function review_trace( array $trace, array $deterministic_audit ): array {
|
| 34 |
+
$settings = SA_Orch_LLM_Settings::get();
|
| 35 |
+
|
| 36 |
+
if ( empty( $settings['enabled'] ) ) {
|
| 37 |
+
throw new \RuntimeException( 'OpenAI provider: settings.enabled is false.' );
|
| 38 |
+
}
|
| 39 |
+
if ( empty( $settings['api_token'] ) ) {
|
| 40 |
+
throw new \RuntimeException( 'OpenAI provider: no api_token configured.' );
|
| 41 |
+
}
|
| 42 |
+
if ( empty( $settings['api_base_url'] ) ) {
|
| 43 |
+
throw new \RuntimeException( 'OpenAI provider: no api_base_url configured.' );
|
| 44 |
+
}
|
| 45 |
+
if ( empty( $settings['model'] ) ) {
|
| 46 |
+
throw new \RuntimeException( 'OpenAI provider: no model configured.' );
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
$url = rtrim( (string) $settings['api_base_url'], '/' ) . '/chat/completions';
|
| 50 |
+
|
| 51 |
+
$payload = [
|
| 52 |
+
'model' => (string) $settings['model'],
|
| 53 |
+
'messages' => [
|
| 54 |
+
[ 'role' => 'system', 'content' => $this->system_prompt() ],
|
| 55 |
+
[ 'role' => 'user', 'content' => $this->user_prompt( $trace, $deterministic_audit ) ],
|
| 56 |
+
],
|
| 57 |
+
'response_format' => [ 'type' => 'json_object' ],
|
| 58 |
+
'temperature' => 0.3,
|
| 59 |
+
];
|
| 60 |
+
|
| 61 |
+
$response = wp_remote_post( $url, [
|
| 62 |
+
'headers' => [
|
| 63 |
+
'Authorization' => 'Bearer ' . (string) $settings['api_token'],
|
| 64 |
+
'Content-Type' => 'application/json',
|
| 65 |
+
],
|
| 66 |
+
'body' => wp_json_encode( $payload ),
|
| 67 |
+
'timeout' => self::REQUEST_TIMEOUT,
|
| 68 |
+
] );
|
| 69 |
+
|
| 70 |
+
if ( is_wp_error( $response ) ) {
|
| 71 |
+
throw new \RuntimeException( 'OpenAI provider: network error — ' . $response->get_error_message() );
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
$code = (int) wp_remote_retrieve_response_code( $response );
|
| 75 |
+
if ( $code < 200 || $code >= 300 ) {
|
| 76 |
+
$excerpt = substr( (string) wp_remote_retrieve_body( $response ), 0, self::RESPONSE_BODY_PREVIEW_BYTES );
|
| 77 |
+
throw new \RuntimeException( "OpenAI provider: HTTP {$code} — {$excerpt}" );
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
$body = (string) wp_remote_retrieve_body( $response );
|
| 81 |
+
$envelope = json_decode( $body, true );
|
| 82 |
+
if ( ! is_array( $envelope ) || ! isset( $envelope['choices'][0]['message']['content'] ) ) {
|
| 83 |
+
$excerpt = substr( $body, 0, self::RESPONSE_BODY_PREVIEW_BYTES );
|
| 84 |
+
throw new \RuntimeException( "OpenAI provider: response missing choices[0].message.content — {$excerpt}" );
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
$content = (string) $envelope['choices'][0]['message']['content'];
|
| 88 |
+
$parsed = json_decode( $content, true );
|
| 89 |
+
if ( ! is_array( $parsed ) ) {
|
| 90 |
+
$excerpt = substr( $content, 0, self::RESPONSE_BODY_PREVIEW_BYTES );
|
| 91 |
+
throw new \RuntimeException( "OpenAI provider: model output is not valid JSON — {$excerpt}" );
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
return $this->clamp_to_contract( $parsed );
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
/* ---------- internals ---------- */
|
| 98 |
+
|
| 99 |
+
private function system_prompt(): string {
|
| 100 |
+
return 'You are the Strategy LLM Review layer for the SA-Orchestration plugin '
|
| 101 |
+
. '(a WordPress alignment layer above Fluent Support / Boards / CRM). '
|
| 102 |
+
. 'You receive (a) trace data for a single Demand and (b) the output of a deterministic '
|
| 103 |
+
. 'rules-based audit already computed for the same trace. Produce a brief, interpretive '
|
| 104 |
+
. "strategy review that complements — never replaces — the audit.\n\n"
|
| 105 |
+
. "Hard constraints:\n"
|
| 106 |
+
. "1. The deterministic audit's closure_state_* fields and needs_human_arbitration are authoritative. "
|
| 107 |
+
. "You MUST NOT contradict or override them. If needs_human_arbitration is true, your suggested_next_action "
|
| 108 |
+
. "MUST include escalation guidance.\n"
|
| 109 |
+
. "2. Return ONLY a JSON object with this exact shape:\n"
|
| 110 |
+
. ' {"summary": string, "agreement_with_audit": "true" | "false" | "partial", '
|
| 111 |
+
. '"additional_risks": [string, ...], "suggested_next_action": string, '
|
| 112 |
+
. '"confidence": "low" | "medium" | "high"}' . "\n"
|
| 113 |
+
. "3. summary: 2–4 sentences of narrative interpretation (not just a recount of fields).\n"
|
| 114 |
+
. "4. agreement_with_audit: 'true' if you see no risks the audit missed; 'partial' if you add risk dimensions; "
|
| 115 |
+
. "'false' only if you genuinely disagree with a finding (rare).\n"
|
| 116 |
+
. "5. additional_risks: items the deterministic audit may not have caught (stale affirmation, plan complexity, "
|
| 117 |
+
. "pattern shortcuts, semantic drift, etc.). Empty array if none.\n"
|
| 118 |
+
. "6. suggested_next_action: a single concrete next step.\n"
|
| 119 |
+
. "7. confidence: 'low' / 'medium' / 'high' — your confidence in the closure status given the trace and audit.\n"
|
| 120 |
+
. "8. CONSERVATIVE FLOOR: If no acceptance events exist OR the deterministic audit's closure_confidence is 'n/a', "
|
| 121 |
+
. "you MUST return confidence: 'low'. 'medium' and 'high' are not allowed when closure cannot be evaluated.";
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
private function user_prompt( array $trace, array $audit ): string {
|
| 125 |
+
$trace_payload = [
|
| 126 |
+
'demand' => $trace['demand'] ?? null,
|
| 127 |
+
'arbitrations' => $trace['arbitrations'] ?? [],
|
| 128 |
+
'plan_links' => $trace['plan_links'] ?? [],
|
| 129 |
+
'acceptance' => $trace['acceptance'] ?? [],
|
| 130 |
+
'closure_state_basic' => $trace['closure_state_basic'] ?? null,
|
| 131 |
+
'closure_state_role_aware' => $trace['closure_state_role_aware'] ?? null,
|
| 132 |
+
'closure_authority_valid' => $trace['closure_authority_valid'] ?? null,
|
| 133 |
+
];
|
| 134 |
+
$audit_payload = [
|
| 135 |
+
'summary' => $audit['summary'] ?? '',
|
| 136 |
+
'inconsistencies' => $audit['inconsistencies'] ?? [],
|
| 137 |
+
'scope_drift' => $audit['scope_drift'] ?? [],
|
| 138 |
+
'notes' => $audit['notes'] ?? [],
|
| 139 |
+
'closure_confidence' => $audit['closure_confidence'] ?? 'n/a',
|
| 140 |
+
'closure_confidence_explanation' => $audit['closure_confidence_explanation'] ?? '',
|
| 141 |
+
'needs_human_arbitration' => $audit['needs_human_arbitration'] ?? false,
|
| 142 |
+
'human_arbitration_triggers' => $audit['human_arbitration_triggers'] ?? [],
|
| 143 |
+
];
|
| 144 |
+
|
| 145 |
+
return "TRACE:\n"
|
| 146 |
+
. wp_json_encode( $trace_payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . "\n\n"
|
| 147 |
+
. "DETERMINISTIC AUDIT:\n"
|
| 148 |
+
. wp_json_encode( $audit_payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . "\n\n"
|
| 149 |
+
. "Return your strategy review as JSON.";
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
/**
|
| 153 |
+
* Clamp the model's JSON output to the contract shape. A misbehaving model
|
| 154 |
+
* cannot inject extra fields or wrong types into the consumer surface.
|
| 155 |
+
*/
|
| 156 |
+
private function clamp_to_contract( array $parsed ): array {
|
| 157 |
+
return [
|
| 158 |
+
'summary' => isset( $parsed['summary'] ) ? (string) $parsed['summary'] : '',
|
| 159 |
+
'agreement_with_audit' => in_array( $parsed['agreement_with_audit'] ?? '', [ 'true', 'false', 'partial' ], true )
|
| 160 |
+
? (string) $parsed['agreement_with_audit']
|
| 161 |
+
: 'partial',
|
| 162 |
+
'additional_risks' => is_array( $parsed['additional_risks'] ?? null )
|
| 163 |
+
? array_values( array_map( 'strval', $parsed['additional_risks'] ) )
|
| 164 |
+
: [],
|
| 165 |
+
'suggested_next_action' => isset( $parsed['suggested_next_action'] ) ? (string) $parsed['suggested_next_action'] : '',
|
| 166 |
+
'confidence' => in_array( $parsed['confidence'] ?? '', [ 'low', 'medium', 'high' ], true )
|
| 167 |
+
? (string) $parsed['confidence']
|
| 168 |
+
: 'low',
|
| 169 |
+
];
|
| 170 |
+
}
|
| 171 |
+
}
|
sa-orchestration/includes/class-sa-llm-provider-simulated.php
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Simulated Strategy LLM provider.
|
| 6 |
+
*
|
| 7 |
+
* No external API call. Pattern-based heuristic expansion of the trace +
|
| 8 |
+
* deterministic audit, written to feel LLM-like (interpretive narrative,
|
| 9 |
+
* additional risk identification, suggested next action) without making
|
| 10 |
+
* any network request.
|
| 11 |
+
*
|
| 12 |
+
* Always available as the fallback provider.
|
| 13 |
+
*/
|
| 14 |
+
class SA_Orch_LLM_Provider_Simulated implements SA_Orch_LLM_Provider_Interface {
|
| 15 |
+
|
| 16 |
+
public function id(): string { return 'simulated'; }
|
| 17 |
+
public function name(): string { return 'Simulated (no API call)'; }
|
| 18 |
+
public function requires_api_key(): bool { return false; }
|
| 19 |
+
|
| 20 |
+
public function review_trace( array $trace, array $deterministic_audit ): array {
|
| 21 |
+
$arbs = isset( $trace['arbitrations'] ) ? (array) $trace['arbitrations'] : [];
|
| 22 |
+
$links = isset( $trace['plan_links'] ) ? (array) $trace['plan_links'] : [];
|
| 23 |
+
$events = isset( $trace['acceptance'] ) ? (array) $trace['acceptance'] : [];
|
| 24 |
+
$authority = $trace['closure_authority_valid'] ?? null;
|
| 25 |
+
|
| 26 |
+
$audit_conf = $deterministic_audit['closure_confidence'] ?? 'n/a';
|
| 27 |
+
$needs_hum = ! empty( $deterministic_audit['needs_human_arbitration'] );
|
| 28 |
+
|
| 29 |
+
$summary = $this->build_narrative( $arbs, $links, $events, $trace, $deterministic_audit );
|
| 30 |
+
$additional_risks = $this->identify_additional_risks( $arbs, $links, $events, $trace );
|
| 31 |
+
$agreement = $this->determine_agreement( $additional_risks, $deterministic_audit );
|
| 32 |
+
$next_action = $this->suggest_next_action( $audit_conf, $trace, $needs_hum, count( $additional_risks ) );
|
| 33 |
+
$confidence = $this->compute_confidence( $audit_conf, count( $additional_risks ), $needs_hum );
|
| 34 |
+
|
| 35 |
+
return [
|
| 36 |
+
'summary' => $summary,
|
| 37 |
+
'agreement_with_audit' => $agreement,
|
| 38 |
+
'additional_risks' => $additional_risks,
|
| 39 |
+
'suggested_next_action' => $next_action,
|
| 40 |
+
'confidence' => $confidence,
|
| 41 |
+
];
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/* ---------- internals ---------- */
|
| 45 |
+
|
| 46 |
+
private function build_narrative( $arbs, $links, $events, $trace, $audit ) {
|
| 47 |
+
$arb_n = count( $arbs );
|
| 48 |
+
$event_n = count( $events );
|
| 49 |
+
$closure_basic = $trace['closure_state_basic'] ?? null;
|
| 50 |
+
$authority = $trace['closure_authority_valid'] ?? null;
|
| 51 |
+
|
| 52 |
+
if ( $arb_n === 0 && $event_n === 0 ) {
|
| 53 |
+
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.";
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
$patterns = [];
|
| 57 |
+
foreach ( $arbs as $a ) {
|
| 58 |
+
if ( ( $a->truth_class ?? null ) === 'derived' ) $patterns['derived'] = true;
|
| 59 |
+
if ( ( $a->projection_type ?? null ) === 'scope-direct-no-plan' ) $patterns['no_plan'] = true;
|
| 60 |
+
}
|
| 61 |
+
if ( $arb_n > 1 ) $patterns['multi_arb'] = true;
|
| 62 |
+
if ( $authority === false ) $patterns['agent_self'] = true;
|
| 63 |
+
|
| 64 |
+
$had_dispute = false;
|
| 65 |
+
foreach ( $events as $e ) {
|
| 66 |
+
if ( ( $e->event_type ?? null ) === 'acceptance_disputed' ) { $had_dispute = true; break; }
|
| 67 |
+
}
|
| 68 |
+
if ( $had_dispute ) $patterns['dispute'] = true;
|
| 69 |
+
if ( $closure_basic === 'closed' && $authority === true && ! $had_dispute ) $patterns['clean'] = true;
|
| 70 |
+
if ( is_string( $closure_basic ) && strpos( $closure_basic, 'open:' ) === 0 ) $patterns['open'] = true;
|
| 71 |
+
|
| 72 |
+
$bits = [];
|
| 73 |
+
|
| 74 |
+
// Opening sentence — lead with the dominant pattern
|
| 75 |
+
if ( isset( $patterns['agent_self'] ) ) {
|
| 76 |
+
$bits[] = "The closure pattern here is concerning: the same role that claimed the result also affirmed acceptance, bypassing the originating stakeholder.";
|
| 77 |
+
} elseif ( isset( $patterns['derived'] ) && $event_n === 0 ) {
|
| 78 |
+
$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.";
|
| 79 |
+
} elseif ( isset( $patterns['open'] ) && isset( $patterns['dispute'] ) ) {
|
| 80 |
+
$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.";
|
| 81 |
+
} elseif ( isset( $patterns['open'] ) ) {
|
| 82 |
+
$bits[] = "The Demand is in-flight: the claim and acceptance steps are not yet complete.";
|
| 83 |
+
} elseif ( isset( $patterns['clean'] ) ) {
|
| 84 |
+
$bits[] = "This Demand followed a routine path: scoped, executed, and confirmed by the stakeholder.";
|
| 85 |
+
} else {
|
| 86 |
+
$bits[] = "The Demand chain has " . $arb_n . " arbitration" . ( $arb_n === 1 ? '' : 's' )
|
| 87 |
+
. " and " . $event_n . " acceptance event" . ( $event_n === 1 ? '' : 's' ) . " on record.";
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
// Mid-narrative annotations
|
| 91 |
+
if ( isset( $patterns['no_plan'] ) && isset( $patterns['clean'] ) ) {
|
| 92 |
+
$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.";
|
| 93 |
+
}
|
| 94 |
+
if ( isset( $patterns['multi_arb'] ) ) {
|
| 95 |
+
$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.";
|
| 96 |
+
}
|
| 97 |
+
if ( isset( $patterns['dispute'] ) && ! isset( $patterns['open'] ) ) {
|
| 98 |
+
$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.";
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
// Closing strategic verdict
|
| 102 |
+
if ( ! empty( $audit['needs_human_arbitration'] ) ) {
|
| 103 |
+
$bits[] = "Strategy interpretation: this trace requires human arbitration before closure can be trusted. The deterministic audit has flagged this; Strategy Review concurs.";
|
| 104 |
+
} elseif ( isset( $patterns['clean'] ) ) {
|
| 105 |
+
$bits[] = "Strategy interpretation: routine resolution. No further action required.";
|
| 106 |
+
} else {
|
| 107 |
+
$bits[] = "Strategy interpretation: defer to the deterministic audit's confidence rating before treating this Demand as resolved.";
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
return implode( ' ', $bits );
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
private function identify_additional_risks( $arbs, $links, $events, $trace ) {
|
| 114 |
+
$risks = [];
|
| 115 |
+
|
| 116 |
+
// Scope-direct-no-plan as a recurring shortcut
|
| 117 |
+
foreach ( $arbs as $a ) {
|
| 118 |
+
if ( ( $a->projection_type ?? null ) === 'scope-direct-no-plan' ) {
|
| 119 |
+
$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.";
|
| 120 |
+
break;
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
// Reconstruction-then-affirmation
|
| 125 |
+
$has_derived = false;
|
| 126 |
+
foreach ( $arbs as $a ) {
|
| 127 |
+
if ( ( $a->truth_class ?? null ) === 'derived' ) { $has_derived = true; break; }
|
| 128 |
+
}
|
| 129 |
+
$has_affirm = false;
|
| 130 |
+
foreach ( $events as $e ) {
|
| 131 |
+
if ( ( $e->event_type ?? null ) === 'acceptance_affirmed' ) { $has_affirm = true; break; }
|
| 132 |
+
}
|
| 133 |
+
if ( $has_derived && $has_affirm ) {
|
| 134 |
+
$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.";
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
// Stale affirmation: long gap between claim and affirm
|
| 138 |
+
$claim_time = null;
|
| 139 |
+
$affirm_time = null;
|
| 140 |
+
foreach ( $events as $e ) {
|
| 141 |
+
if ( ( $e->event_type ?? null ) === 'result_claimed' && $claim_time === null ) {
|
| 142 |
+
$claim_time = strtotime( (string) $e->observed_at );
|
| 143 |
+
}
|
| 144 |
+
if ( ( $e->event_type ?? null ) === 'acceptance_affirmed' ) {
|
| 145 |
+
$affirm_time = strtotime( (string) $e->observed_at );
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
if ( $claim_time && $affirm_time ) {
|
| 149 |
+
$delta_days = ( $affirm_time - $claim_time ) / 86400;
|
| 150 |
+
if ( $delta_days > 30 ) {
|
| 151 |
+
$risks[] = sprintf(
|
| 152 |
+
"Stale affirmation: %d days between result_claimed and acceptance_affirmed. The stakeholder's confirmation may not reflect current state.",
|
| 153 |
+
(int) round( $delta_days )
|
| 154 |
+
);
|
| 155 |
+
}
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
// Plan complexity
|
| 159 |
+
if ( count( $links ) > 2 ) {
|
| 160 |
+
$risks[] = "Plan complexity: this Demand fans out to " . count( $links ) . " plan refs. Verify each sub-plan is individually traceable to the original demand-constraint.";
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
// Long event chain
|
| 164 |
+
if ( count( $events ) >= 4 ) {
|
| 165 |
+
$risks[] = "State-transition complexity: " . count( $events ) . " acceptance events on this Demand. Each transition is an opportunity for meaning to drift; review for chain coherence.";
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
return $risks;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
private function determine_agreement( $additional_risks, $audit ) {
|
| 172 |
+
// The simulated layer reads the same data — it does not contradict the audit's
|
| 173 |
+
// factual findings. Agreement is 'true' when nothing additional is identified;
|
| 174 |
+
// 'partial' when extra risk dimensions are added but the audit's findings stand.
|
| 175 |
+
if ( count( $additional_risks ) === 0 ) {
|
| 176 |
+
return 'true';
|
| 177 |
+
}
|
| 178 |
+
return 'partial';
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
private function suggest_next_action( $audit_conf, $trace, $needs_hum, $extra_risk_count ) {
|
| 182 |
+
if ( $needs_hum ) {
|
| 183 |
+
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.";
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
$closure_basic = $trace['closure_state_basic'] ?? null;
|
| 187 |
+
|
| 188 |
+
if ( $audit_conf === 'high' && $extra_risk_count === 0 ) {
|
| 189 |
+
return "No further action needed; routine closure.";
|
| 190 |
+
}
|
| 191 |
+
if ( $audit_conf === 'high' && $extra_risk_count > 0 ) {
|
| 192 |
+
return "Closure is sound. Optionally surface the additional risks above to the relevant stakeholders for awareness; no blocking action required.";
|
| 193 |
+
}
|
| 194 |
+
if ( $audit_conf === 'medium' ) {
|
| 195 |
+
return "Closure is sound but with notable history. Recommend a follow-up audit in 30 days to confirm the resolution held.";
|
| 196 |
+
}
|
| 197 |
+
if ( $audit_conf === 'low' && $closure_basic === 'closed' ) {
|
| 198 |
+
return "Closure is recorded but not authoritative. Drive to stakeholder affirmation before treating as final.";
|
| 199 |
+
}
|
| 200 |
+
if ( $audit_conf === 'low' && is_string( $closure_basic ) && strpos( $closure_basic, 'open:' ) === 0 ) {
|
| 201 |
+
return "Demand is open. Drive to result_claimed and stakeholder affirmation; or escalate if blocked.";
|
| 202 |
+
}
|
| 203 |
+
if ( $audit_conf === 'n/a' ) {
|
| 204 |
+
return "Demand has no acceptance events. Either drive through claim → affirmation, or close as scope-direct-no-plan if no work is owed.";
|
| 205 |
+
}
|
| 206 |
+
return "Review the deterministic audit's recommendations and the human-arbitration flag before next action.";
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
private function compute_confidence( $audit_conf, $extra_risk_count, $needs_hum ) {
|
| 210 |
+
if ( $needs_hum ) return 'low';
|
| 211 |
+
if ( $audit_conf === 'high' && $extra_risk_count === 0 ) return 'high';
|
| 212 |
+
if ( $audit_conf === 'high' && $extra_risk_count <= 2 ) return 'medium';
|
| 213 |
+
if ( $audit_conf === 'medium' ) return 'medium';
|
| 214 |
+
if ( $audit_conf === 'n/a' ) return 'low';
|
| 215 |
+
return 'low';
|
| 216 |
+
}
|
| 217 |
+
}
|
sa-orchestration/includes/class-sa-llm-providers.php
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Provider registry + active-provider resolver.
|
| 6 |
+
*
|
| 7 |
+
* v0.5 ships with the `simulated` provider only. Future providers attach by
|
| 8 |
+
* extending the provider catalog — `get_provider_choices()` for the dropdown,
|
| 9 |
+
* `instantiate()` for the factory branch.
|
| 10 |
+
*
|
| 11 |
+
* If the saved provider id is not enabled or is unknown, the registry falls
|
| 12 |
+
* back to `simulated`. This guarantees Strategy Review always returns a result
|
| 13 |
+
* even if the operator's configured provider is unreachable.
|
| 14 |
+
*/
|
| 15 |
+
class SA_Orch_LLM_Providers {
|
| 16 |
+
|
| 17 |
+
/**
|
| 18 |
+
* Catalog of provider id → human label, for the settings dropdown.
|
| 19 |
+
* Only the simulated provider is wired up in v0.5; the others are listed
|
| 20 |
+
* as inert placeholders so the operator can see the seam.
|
| 21 |
+
*/
|
| 22 |
+
public static function get_provider_choices(): array {
|
| 23 |
+
return [
|
| 24 |
+
'simulated' => 'Simulated (no API call) — heuristic expansion of deterministic audit',
|
| 25 |
+
'openai' => 'OpenAI (Chat Completions; works with any OpenAI-compatible endpoint)',
|
| 26 |
+
'anthropic' => 'Anthropic (not implemented yet)',
|
| 27 |
+
'local' => 'Local model endpoint (not implemented yet)',
|
| 28 |
+
'custom' => 'Custom HTTP endpoint (not implemented yet)',
|
| 29 |
+
];
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* Resolve the active provider. Falls back to simulated when:
|
| 34 |
+
* - settings.enabled is false
|
| 35 |
+
* - settings.provider is unknown
|
| 36 |
+
* - the configured provider's class is not yet implemented
|
| 37 |
+
*/
|
| 38 |
+
public static function get_active_provider(): SA_Orch_LLM_Provider_Interface {
|
| 39 |
+
$settings = SA_Orch_LLM_Settings::get();
|
| 40 |
+
|
| 41 |
+
if ( empty( $settings['enabled'] ) ) {
|
| 42 |
+
return new SA_Orch_LLM_Provider_Simulated();
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
$instance = self::instantiate( (string) ( $settings['provider'] ?? '' ) );
|
| 46 |
+
if ( $instance instanceof SA_Orch_LLM_Provider_Interface ) {
|
| 47 |
+
return $instance;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// Fallback
|
| 51 |
+
return new SA_Orch_LLM_Provider_Simulated();
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
private static function instantiate( $id ): ?SA_Orch_LLM_Provider_Interface {
|
| 55 |
+
switch ( $id ) {
|
| 56 |
+
case 'simulated':
|
| 57 |
+
return new SA_Orch_LLM_Provider_Simulated();
|
| 58 |
+
case 'openai':
|
| 59 |
+
return new SA_Orch_LLM_Provider_OpenAI();
|
| 60 |
+
// Future: add cases here as providers land.
|
| 61 |
+
// case 'anthropic': return new SA_Orch_LLM_Provider_Anthropic();
|
| 62 |
+
// case 'local': return new SA_Orch_LLM_Provider_Local();
|
| 63 |
+
// case 'custom': return new SA_Orch_LLM_Provider_Custom();
|
| 64 |
+
}
|
| 65 |
+
return null;
|
| 66 |
+
}
|
| 67 |
+
}
|
sa-orchestration/includes/class-sa-llm-settings.php
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* LLM provider settings.
|
| 6 |
+
*
|
| 7 |
+
* Stored in a single WordPress option (`sa_orch_llm_settings`).
|
| 8 |
+
* Local-dev posture only. Tokens are never displayed back; saving with an
|
| 9 |
+
* empty token field preserves the existing token.
|
| 10 |
+
*
|
| 11 |
+
* Future: this layer is the attachment seam — when a real key vault /
|
| 12 |
+
* SA-Core secret handling is wired up, replace the wp_options storage here
|
| 13 |
+
* without changing the consumer surface (get(), has_token()).
|
| 14 |
+
*/
|
| 15 |
+
class SA_Orch_LLM_Settings {
|
| 16 |
+
|
| 17 |
+
const OPTION = 'sa_orch_llm_settings';
|
| 18 |
+
|
| 19 |
+
public static function get(): array {
|
| 20 |
+
$defaults = [
|
| 21 |
+
'enabled' => false,
|
| 22 |
+
'provider' => 'simulated',
|
| 23 |
+
'model' => '',
|
| 24 |
+
'api_base_url' => '',
|
| 25 |
+
'api_token' => '',
|
| 26 |
+
];
|
| 27 |
+
$stored = get_option( self::OPTION, [] );
|
| 28 |
+
if ( ! is_array( $stored ) ) $stored = [];
|
| 29 |
+
return wp_parse_args( $stored, $defaults );
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* Save settings. Token field preserves the existing value when input is empty.
|
| 34 |
+
*/
|
| 35 |
+
public static function save( array $input ): void {
|
| 36 |
+
$current = self::get();
|
| 37 |
+
$new = [
|
| 38 |
+
'enabled' => ! empty( $input['enabled'] ),
|
| 39 |
+
'provider' => sanitize_key( $input['provider'] ?? 'simulated' ),
|
| 40 |
+
'model' => sanitize_text_field( $input['model'] ?? '' ),
|
| 41 |
+
'api_base_url' => esc_url_raw( $input['api_base_url'] ?? '' ),
|
| 42 |
+
'api_token' => ! empty( $input['api_token'] )
|
| 43 |
+
? (string) $input['api_token']
|
| 44 |
+
: (string) $current['api_token'],
|
| 45 |
+
];
|
| 46 |
+
update_option( self::OPTION, $new );
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
public static function has_token(): bool {
|
| 50 |
+
$s = self::get();
|
| 51 |
+
return ! empty( $s['api_token'] );
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
/**
|
| 55 |
+
* Public-facing label for the saved-token state. Never returns the value itself.
|
| 56 |
+
*/
|
| 57 |
+
public static function token_status_label(): string {
|
| 58 |
+
return self::has_token()
|
| 59 |
+
? '[token is set; leave blank to preserve, enter a new value to update]'
|
| 60 |
+
: '[no token saved]';
|
| 61 |
+
}
|
| 62 |
+
}
|
sa-orchestration/includes/class-sa-migrations.php
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
class SA_Orch_Migrations {
|
| 5 |
+
|
| 6 |
+
public static function install() {
|
| 7 |
+
global $wpdb;
|
| 8 |
+
|
| 9 |
+
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
| 10 |
+
|
| 11 |
+
$charset = $wpdb->get_charset_collate();
|
| 12 |
+
$p = $wpdb->prefix . SA_ORCH_DB_PREFIX;
|
| 13 |
+
|
| 14 |
+
// wp_sa_arbitration
|
| 15 |
+
$sql_arb = "CREATE TABLE {$p}arbitration (
|
| 16 |
+
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
| 17 |
+
hash VARCHAR(64) NOT NULL,
|
| 18 |
+
demand_surface VARCHAR(64) NOT NULL,
|
| 19 |
+
demand_ref VARCHAR(255) NOT NULL,
|
| 20 |
+
rationale TEXT NOT NULL,
|
| 21 |
+
projection_type VARCHAR(64) NOT NULL,
|
| 22 |
+
actor_user_id BIGINT UNSIGNED DEFAULT NULL,
|
| 23 |
+
correlation_id VARCHAR(64) NOT NULL,
|
| 24 |
+
causation_id VARCHAR(64) DEFAULT NULL,
|
| 25 |
+
truth_class VARCHAR(32) NOT NULL DEFAULT 'canonical',
|
| 26 |
+
asterion_path VARCHAR(255) DEFAULT NULL,
|
| 27 |
+
created_at DATETIME NOT NULL,
|
| 28 |
+
PRIMARY KEY (id),
|
| 29 |
+
UNIQUE KEY uniq_hash (hash),
|
| 30 |
+
KEY idx_demand (demand_surface, demand_ref),
|
| 31 |
+
KEY idx_corr (correlation_id),
|
| 32 |
+
KEY idx_created (created_at)
|
| 33 |
+
) $charset;";
|
| 34 |
+
|
| 35 |
+
// wp_sa_arbitration_plan_link
|
| 36 |
+
$sql_link = "CREATE TABLE {$p}arbitration_plan_link (
|
| 37 |
+
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
| 38 |
+
arbitration_id BIGINT UNSIGNED NOT NULL,
|
| 39 |
+
plan_surface VARCHAR(64) NOT NULL,
|
| 40 |
+
plan_ref VARCHAR(255) NOT NULL,
|
| 41 |
+
link_kind VARCHAR(32) NOT NULL DEFAULT 'derived',
|
| 42 |
+
created_at DATETIME NOT NULL,
|
| 43 |
+
PRIMARY KEY (id),
|
| 44 |
+
KEY idx_arb (arbitration_id),
|
| 45 |
+
KEY idx_plan (plan_surface, plan_ref)
|
| 46 |
+
) $charset;";
|
| 47 |
+
|
| 48 |
+
// wp_sa_acceptance_event
|
| 49 |
+
$sql_acc = "CREATE TABLE {$p}acceptance_event (
|
| 50 |
+
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
| 51 |
+
hash VARCHAR(64) NOT NULL,
|
| 52 |
+
demand_surface VARCHAR(64) NOT NULL,
|
| 53 |
+
demand_ref VARCHAR(255) NOT NULL,
|
| 54 |
+
event_type VARCHAR(32) NOT NULL,
|
| 55 |
+
prior_event_id BIGINT UNSIGNED DEFAULT NULL,
|
| 56 |
+
actor_user_id BIGINT UNSIGNED DEFAULT NULL,
|
| 57 |
+
actor_role VARCHAR(32) NOT NULL,
|
| 58 |
+
details TEXT DEFAULT NULL,
|
| 59 |
+
truth_class VARCHAR(32) NOT NULL DEFAULT 'canonical',
|
| 60 |
+
asterion_path VARCHAR(255) DEFAULT NULL,
|
| 61 |
+
observed_at DATETIME NOT NULL,
|
| 62 |
+
recorded_at DATETIME NOT NULL,
|
| 63 |
+
PRIMARY KEY (id),
|
| 64 |
+
UNIQUE KEY uniq_hash (hash),
|
| 65 |
+
KEY idx_demand_time (demand_surface, demand_ref, observed_at),
|
| 66 |
+
KEY idx_event_type (event_type)
|
| 67 |
+
) $charset;";
|
| 68 |
+
|
| 69 |
+
// wp_sa_attention_log — Board #21 (Attention Bridge REST).
|
| 70 |
+
//
|
| 71 |
+
// SEMANTICS: this is an ATTEMPTED-EMISSION log, NOT a successful-receipt log.
|
| 72 |
+
// A row records that an emit was attempted; the 'status' column tells you
|
| 73 |
+
// whether the receiver accepted it.
|
| 74 |
+
//
|
| 75 |
+
// status enum (Board #21 rev2):
|
| 76 |
+
// 'success' — outbound POST returned HTTP 2xx; receiver accepted the packet
|
| 77 |
+
// 'failed' — transport error (connection refused, DNS, timeout) OR non-2xx HTTP response
|
| 78 |
+
// 'blocked_config' — RESERVED. Currently never written. If config validation fails,
|
| 79 |
+
// no log row is created at all (validation precedes any I/O). The value
|
| 80 |
+
// is reserved for future use cases (e.g. partial-config emission attempts).
|
| 81 |
+
//
|
| 82 |
+
// NOT a query/analytics store. Captures:
|
| 83 |
+
// - what was attempted (full payload_json)
|
| 84 |
+
// - whether it arrived (status + http_status + response_excerpt OR error_message)
|
| 85 |
+
// Per stakeholder spec: "Did packet arrive? What did it contain?"
|
| 86 |
+
$sql_attn = "CREATE TABLE {$p}attention_log (
|
| 87 |
+
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
| 88 |
+
site_id VARCHAR(64) NOT NULL,
|
| 89 |
+
surface VARCHAR(64) DEFAULT NULL,
|
| 90 |
+
ref VARCHAR(255) DEFAULT NULL,
|
| 91 |
+
target_url VARCHAR(512) NOT NULL,
|
| 92 |
+
payload_json TEXT NOT NULL,
|
| 93 |
+
status VARCHAR(16) DEFAULT NULL,
|
| 94 |
+
http_status INT DEFAULT NULL,
|
| 95 |
+
response_excerpt TEXT DEFAULT NULL,
|
| 96 |
+
error_message TEXT DEFAULT NULL,
|
| 97 |
+
recorded_at DATETIME NOT NULL,
|
| 98 |
+
PRIMARY KEY (id),
|
| 99 |
+
KEY idx_site_time (site_id, recorded_at),
|
| 100 |
+
KEY idx_status (status)
|
| 101 |
+
) $charset;";
|
| 102 |
+
|
| 103 |
+
// wp_sa_token_log — Board #16 (Token + Usage Visibility).
|
| 104 |
+
// Append-only audit of LLM invocations. Each call writes one row.
|
| 105 |
+
// (comment_kind, comment_id) is filled in later by the future
|
| 106 |
+
// invocation surface; until then, all rows have nulls there and
|
| 107 |
+
// the Board #23 filter callback returns false for all comments.
|
| 108 |
+
$sql_token = "CREATE TABLE {$p}token_log (
|
| 109 |
+
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
| 110 |
+
user_id BIGINT UNSIGNED DEFAULT NULL,
|
| 111 |
+
provider VARCHAR(64) NOT NULL,
|
| 112 |
+
model VARCHAR(128) DEFAULT NULL,
|
| 113 |
+
prompt_tokens INT UNSIGNED DEFAULT 0,
|
| 114 |
+
completion_tokens INT UNSIGNED DEFAULT 0,
|
| 115 |
+
total_tokens INT UNSIGNED DEFAULT 0,
|
| 116 |
+
comment_kind VARCHAR(32) DEFAULT NULL,
|
| 117 |
+
comment_id BIGINT UNSIGNED DEFAULT NULL,
|
| 118 |
+
invoked_at DATETIME NOT NULL,
|
| 119 |
+
PRIMARY KEY (id),
|
| 120 |
+
KEY idx_user_time (user_id, invoked_at),
|
| 121 |
+
KEY idx_comment (comment_kind, comment_id)
|
| 122 |
+
) $charset;";
|
| 123 |
+
|
| 124 |
+
// wp_sa_actor_handoff — Board #23 (Actor Handoff Log).
|
| 125 |
+
// Two-axis attribution log. Records who "pressed send" for an
|
| 126 |
+
// actor-authored comment. Strictly additive — does NOT touch
|
| 127 |
+
// fbs_comments / fs_conversations.
|
| 128 |
+
$sql_handoff = "CREATE TABLE {$p}actor_handoff (
|
| 129 |
+
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
| 130 |
+
comment_kind VARCHAR(32) NOT NULL,
|
| 131 |
+
comment_id BIGINT UNSIGNED NOT NULL,
|
| 132 |
+
committed_by_user_id BIGINT UNSIGNED NOT NULL,
|
| 133 |
+
recorded_at DATETIME NOT NULL,
|
| 134 |
+
PRIMARY KEY (id),
|
| 135 |
+
KEY idx_comment (comment_kind, comment_id),
|
| 136 |
+
KEY idx_committer (committed_by_user_id, recorded_at)
|
| 137 |
+
) $charset;";
|
| 138 |
+
|
| 139 |
+
dbDelta( $sql_arb );
|
| 140 |
+
dbDelta( $sql_link );
|
| 141 |
+
dbDelta( $sql_acc );
|
| 142 |
+
dbDelta( $sql_attn );
|
| 143 |
+
dbDelta( $sql_handoff );
|
| 144 |
+
dbDelta( $sql_token );
|
| 145 |
+
|
| 146 |
+
// Asterion ledger root
|
| 147 |
+
$root = SA_Orch_Asterion::root();
|
| 148 |
+
if ( ! is_dir( $root ) ) {
|
| 149 |
+
wp_mkdir_p( $root );
|
| 150 |
+
}
|
| 151 |
+
$marker = $root . '/.sa-asterion';
|
| 152 |
+
if ( ! file_exists( $marker ) ) {
|
| 153 |
+
file_put_contents(
|
| 154 |
+
$marker,
|
| 155 |
+
"Asterion ledger root for SA-Orchestration plugin.\n"
|
| 156 |
+
. "Canonical-at-write store. Runtime DB at {$wpdb->prefix}{$p}* is a cache.\n"
|
| 157 |
+
. "If runtime != Asterion, runtime is the lie.\n"
|
| 158 |
+
);
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
}
|
sa-orchestration/includes/class-sa-projection-fluent-support.php
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Partial projection: SA-Orchestration → Fluent Support.
|
| 6 |
+
*
|
| 7 |
+
* Augments tickets with [SA-Orch]-namespaced metadata and conversation entries.
|
| 8 |
+
* NEVER touches Fluent's native ticket fields (status, resolved_at, closed_by,
|
| 9 |
+
* title, content, customer_id, etc.). Removable cleanly without breaking Fluent.
|
| 10 |
+
*
|
| 11 |
+
* v0.6.1 — refined UX:
|
| 12 |
+
* - Two conversation entries per ticket (was: 1 + N + 1):
|
| 13 |
+
* 1. Tracking marker (created once, never updated, not pinned).
|
| 14 |
+
* 2. State summary (upserts in place; pinned only when needs review).
|
| 15 |
+
* - Per-event entries removed; full event history lives in the admin trace.
|
| 16 |
+
* - Needs-Human folds into the state summary with a clear "Action:" line.
|
| 17 |
+
* - Phrasing rewritten for native voice (less jargon, fewer field-name leaks).
|
| 18 |
+
*
|
| 19 |
+
* Augmentations written:
|
| 20 |
+
*
|
| 21 |
+
* 1. Meta on the ticket (via wp_fs_meta with object_type='ticket'):
|
| 22 |
+
* _sa_orch_arbitration_hash — most recent arbitration hash for this Demand.
|
| 23 |
+
* _sa_orch_needs_human — 'true' | 'false'.
|
| 24 |
+
* _sa_orch_closure_authority — stakeholder | agent | system | inferred | (empty).
|
| 25 |
+
*
|
| 26 |
+
* 2. Tracking marker conversation (one per ticket, ensure-once, never updated).
|
| 27 |
+
*
|
| 28 |
+
* 3. State summary conversation (one per ticket, upserts in place). Header
|
| 29 |
+
* reflects current state ("Closed", "Result claimed",
|
| 30 |
+
* "Needs review — disputed", etc.). When needs review, body includes an
|
| 31 |
+
* actionable "Action:" line and the entry is pinned (is_important=yes).
|
| 32 |
+
*
|
| 33 |
+
* Identification on removal:
|
| 34 |
+
* - All meta keys begin with `_sa_orch_`.
|
| 35 |
+
* - All conversation entries have source = `sa-orch`.
|
| 36 |
+
*
|
| 37 |
+
* Removability:
|
| 38 |
+
* SA_Orch_Projection_Fluent_Support::remove_all_for_ticket( $id ) — single ticket
|
| 39 |
+
* SA_Orch_Projection_Fluent_Support::remove_all() — global cleanup
|
| 40 |
+
*
|
| 41 |
+
* Hook contract:
|
| 42 |
+
* `sa_orch_arbitration_written` — fired by SA_Orch_Arbitration::write
|
| 43 |
+
* `sa_orch_acceptance_event_written` — fired by SA_Orch_Acceptance::write
|
| 44 |
+
* This class subscribes to both and triggers project_ticket() when the
|
| 45 |
+
* demand_surface is fluent-support.
|
| 46 |
+
*/
|
| 47 |
+
class SA_Orch_Projection_Fluent_Support {
|
| 48 |
+
|
| 49 |
+
const META_PREFIX = '_sa_orch_';
|
| 50 |
+
const CONV_SOURCE = 'sa-orch';
|
| 51 |
+
const TRACKED_KEY = 'sa-orch-tracked-';
|
| 52 |
+
const STATE_KEY = 'sa-orch-state-';
|
| 53 |
+
|
| 54 |
+
/** Default fallback person_id for system entries. Overridden if a real person matches actor_user_id. */
|
| 55 |
+
private static $system_person_id = null;
|
| 56 |
+
|
| 57 |
+
/** Wired in sa-orchestration.php on the WP `init` action. */
|
| 58 |
+
public static function register_hooks() {
|
| 59 |
+
add_action( 'sa_orch_arbitration_written', [ __CLASS__, 'on_arbitration_written' ] );
|
| 60 |
+
add_action( 'sa_orch_acceptance_event_written', [ __CLASS__, 'on_acceptance_written' ] );
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
public static function on_arbitration_written( $payload ) {
|
| 64 |
+
if ( ! is_array( $payload ) ) return;
|
| 65 |
+
$surface = $payload['demand_surface'] ?? '';
|
| 66 |
+
$ref = $payload['demand_ref'] ?? '';
|
| 67 |
+
if ( $surface !== 'fluent-support' ) return;
|
| 68 |
+
if ( ! preg_match( '/^ticket#(\d+)$/', (string) $ref, $m ) ) return;
|
| 69 |
+
try { self::project_ticket( (int) $m[1] ); }
|
| 70 |
+
catch ( \Throwable $e ) { error_log( 'SA-Orch projection (fluent-support) failed: ' . $e->getMessage() ); }
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
public static function on_acceptance_written( $payload ) {
|
| 74 |
+
if ( ! is_array( $payload ) ) return;
|
| 75 |
+
$surface = $payload['demand_surface'] ?? '';
|
| 76 |
+
$ref = $payload['demand_ref'] ?? '';
|
| 77 |
+
if ( $surface !== 'fluent-support' ) return;
|
| 78 |
+
if ( ! preg_match( '/^ticket#(\d+)$/', (string) $ref, $m ) ) return;
|
| 79 |
+
try { self::project_ticket( (int) $m[1] ); }
|
| 80 |
+
catch ( \Throwable $e ) { error_log( 'SA-Orch projection (fluent-support) failed: ' . $e->getMessage() ); }
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/**
|
| 84 |
+
* Project all SA-Orch state for a Demand-bound ticket as Fluent Support
|
| 85 |
+
* augmentations. Idempotent — safe to call any number of times.
|
| 86 |
+
*/
|
| 87 |
+
public static function project_ticket( $ticket_id ) {
|
| 88 |
+
global $wpdb;
|
| 89 |
+
|
| 90 |
+
$report = [
|
| 91 |
+
'ticket_id' => (int) $ticket_id,
|
| 92 |
+
'meta_written' => 0,
|
| 93 |
+
'tracked_written' => false,
|
| 94 |
+
'tracked_updated' => false,
|
| 95 |
+
'state_inserted' => false,
|
| 96 |
+
'state_updated' => false,
|
| 97 |
+
'legacy_pruned' => 0,
|
| 98 |
+
'skipped_reason' => null,
|
| 99 |
+
];
|
| 100 |
+
|
| 101 |
+
$ticket = $wpdb->get_row( $wpdb->prepare(
|
| 102 |
+
"SELECT id FROM {$wpdb->prefix}fs_tickets WHERE id = %d",
|
| 103 |
+
$ticket_id
|
| 104 |
+
) );
|
| 105 |
+
if ( ! $ticket ) {
|
| 106 |
+
$report['skipped_reason'] = 'ticket-not-found';
|
| 107 |
+
return $report;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
$surface = 'fluent-support';
|
| 111 |
+
$ref = 'ticket#' . (int) $ticket_id;
|
| 112 |
+
|
| 113 |
+
$arbitrations = $wpdb->get_results( $wpdb->prepare(
|
| 114 |
+
"SELECT id, hash, truth_class, projection_type FROM {$wpdb->prefix}sa_arbitration
|
| 115 |
+
WHERE demand_surface = %s AND demand_ref = %s ORDER BY created_at, id",
|
| 116 |
+
$surface, $ref
|
| 117 |
+
) );
|
| 118 |
+
$events = $wpdb->get_results( $wpdb->prepare(
|
| 119 |
+
"SELECT * FROM {$wpdb->prefix}sa_acceptance_event
|
| 120 |
+
WHERE demand_surface = %s AND demand_ref = %s ORDER BY observed_at, id",
|
| 121 |
+
$surface, $ref
|
| 122 |
+
) );
|
| 123 |
+
|
| 124 |
+
if ( empty( $arbitrations ) && empty( $events ) ) {
|
| 125 |
+
self::remove_all_for_ticket( $ticket_id );
|
| 126 |
+
$report['skipped_reason'] = 'no-sa-orch-data';
|
| 127 |
+
return $report;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
// ----- Meta -----
|
| 131 |
+
$latest_arb = end( $arbitrations );
|
| 132 |
+
$arbitration_hash = $latest_arb ? (string) $latest_arb->hash : '';
|
| 133 |
+
$latest_event = ! empty( $events ) ? end( $events ) : null;
|
| 134 |
+
$needs_human = self::compute_needs_human( $arbitrations, $events );
|
| 135 |
+
|
| 136 |
+
$closure_authority = '';
|
| 137 |
+
if ( $latest_event && $latest_event->event_type === 'acceptance_affirmed' ) {
|
| 138 |
+
$closure_authority = (string) $latest_event->actor_role;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
self::set_meta( $ticket_id, 'arbitration_hash', $arbitration_hash ); $report['meta_written']++;
|
| 142 |
+
self::set_meta( $ticket_id, 'needs_human', $needs_human ? 'true' : 'false' ); $report['meta_written']++;
|
| 143 |
+
self::set_meta( $ticket_id, 'closure_authority', $closure_authority ); $report['meta_written']++;
|
| 144 |
+
|
| 145 |
+
// ----- Entry 1: Tracking marker (upserts in place so phrasing/pin rule changes propagate) -----
|
| 146 |
+
$tracked_result = self::upsert_conversation(
|
| 147 |
+
$ticket_id,
|
| 148 |
+
'internal_info',
|
| 149 |
+
self::tracking_marker_text( $ticket_id ),
|
| 150 |
+
self::TRACKED_KEY . $ticket_id,
|
| 151 |
+
'no',
|
| 152 |
+
self::resolve_person_id( null )
|
| 153 |
+
);
|
| 154 |
+
$report['tracked_written'] = $tracked_result['inserted'];
|
| 155 |
+
$report['tracked_updated'] = $tracked_result['updated'];
|
| 156 |
+
|
| 157 |
+
// ----- Entry 2: State summary (upserts in place) -----
|
| 158 |
+
$state_content = self::build_state_summary( $ticket_id, $arbitrations, $events, $needs_human );
|
| 159 |
+
$state_pinned = $needs_human ? 'yes' : 'no';
|
| 160 |
+
$state_actor = $latest_event && $latest_event->actor_user_id ? (int) $latest_event->actor_user_id : null;
|
| 161 |
+
$state_result = self::upsert_conversation(
|
| 162 |
+
$ticket_id,
|
| 163 |
+
'internal_info',
|
| 164 |
+
$state_content,
|
| 165 |
+
self::STATE_KEY . $ticket_id,
|
| 166 |
+
$state_pinned,
|
| 167 |
+
self::resolve_person_id( $state_actor )
|
| 168 |
+
);
|
| 169 |
+
$report['state_inserted'] = $state_result['inserted'];
|
| 170 |
+
$report['state_updated'] = $state_result['updated'];
|
| 171 |
+
|
| 172 |
+
// ----- Legacy cleanup: prune v0.6.0-era per-event and standalone needs-human rows -----
|
| 173 |
+
$report['legacy_pruned'] = self::prune_legacy_sa_orch_rows( $ticket_id );
|
| 174 |
+
|
| 175 |
+
return $report;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
/* ---------- Phrasing helpers ---------- */
|
| 179 |
+
|
| 180 |
+
private static function tracking_marker_text( $ticket_id ) {
|
| 181 |
+
return "[SA-Orch] Tracking this ticket.\n"
|
| 182 |
+
. "Full record (arbitrations, acceptance events, audit) in WP Admin → SA-Orchestration → Demand Trace → ticket#{$ticket_id}.";
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
private static function build_state_summary( $ticket_id, $arbitrations, $events, $needs_human ) {
|
| 186 |
+
$latest_event = ! empty( $events ) ? end( $events ) : null;
|
| 187 |
+
$admin_link = "WP Admin → SA-Orchestration → Demand Trace → ticket#{$ticket_id}";
|
| 188 |
+
|
| 189 |
+
$header = self::state_header( $latest_event, $arbitrations, $needs_human );
|
| 190 |
+
$body = self::state_body( $latest_event, $arbitrations );
|
| 191 |
+
|
| 192 |
+
$lines = [];
|
| 193 |
+
$lines[] = "[SA-Orch] {$header}";
|
| 194 |
+
if ( $body !== '' ) $lines[] = $body;
|
| 195 |
+
if ( $needs_human ) {
|
| 196 |
+
$lines[] = 'Action: ' . self::state_action( $latest_event, $arbitrations );
|
| 197 |
+
}
|
| 198 |
+
$lines[] = "Trace: {$admin_link}";
|
| 199 |
+
return implode( "\n", $lines );
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
private static function state_header( $latest_event, $arbitrations, $needs_human ) {
|
| 203 |
+
if ( $needs_human ) {
|
| 204 |
+
// Pick the most actionable trigger as the primary header.
|
| 205 |
+
if ( $latest_event && $latest_event->event_type === 'acceptance_disputed' ) {
|
| 206 |
+
return 'Needs review — disputed';
|
| 207 |
+
}
|
| 208 |
+
if ( $latest_event && $latest_event->event_type === 'acceptance_affirmed' && $latest_event->actor_role !== 'stakeholder' ) {
|
| 209 |
+
return 'Needs review — closure not authoritative';
|
| 210 |
+
}
|
| 211 |
+
if ( count( $arbitrations ) > 1 ) {
|
| 212 |
+
return 'Needs review — multiple arbitrations on this ticket';
|
| 213 |
+
}
|
| 214 |
+
foreach ( $arbitrations as $arb ) {
|
| 215 |
+
if ( ( $arb->truth_class ?? '' ) === 'derived' ) {
|
| 216 |
+
return 'Needs review — Demand reconstructed from indirect signals';
|
| 217 |
+
}
|
| 218 |
+
}
|
| 219 |
+
return 'Needs review';
|
| 220 |
+
}
|
| 221 |
+
if ( ! $latest_event ) {
|
| 222 |
+
return 'Tracking — awaiting result';
|
| 223 |
+
}
|
| 224 |
+
switch ( $latest_event->event_type ) {
|
| 225 |
+
case 'result_claimed': return 'Result claimed — awaiting affirmation';
|
| 226 |
+
case 'acceptance_affirmed': return 'Closed — stakeholder affirmation';
|
| 227 |
+
case 'acceptance_revoked': return 'Revoked — open again';
|
| 228 |
+
case 'acceptance_disputed': return 'Disputed';
|
| 229 |
+
}
|
| 230 |
+
return 'Tracking';
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
private static function state_body( $latest_event, $arbitrations ) {
|
| 234 |
+
if ( ! $latest_event ) {
|
| 235 |
+
$arb_count = count( $arbitrations );
|
| 236 |
+
if ( $arb_count > 1 ) {
|
| 237 |
+
return "{$arb_count} arbitrations on file. No acceptance events yet.";
|
| 238 |
+
}
|
| 239 |
+
return 'An arbitration is on file. No result has been claimed yet.';
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
$when = self::pretty_date( $latest_event->observed_at );
|
| 243 |
+
$actor = self::actor_phrase( $latest_event->actor_role );
|
| 244 |
+
|
| 245 |
+
switch ( $latest_event->event_type ) {
|
| 246 |
+
case 'result_claimed':
|
| 247 |
+
return "A result was claimed by {$actor} on {$when}. Awaiting stakeholder affirmation.";
|
| 248 |
+
case 'acceptance_affirmed':
|
| 249 |
+
if ( $latest_event->actor_role === 'stakeholder' ) {
|
| 250 |
+
return "The stakeholder affirmed acceptance on {$when}. Closure is authoritative.";
|
| 251 |
+
}
|
| 252 |
+
return "Acceptance was affirmed by {$actor} on {$when} — but only the stakeholder can authoritatively close.";
|
| 253 |
+
case 'acceptance_disputed':
|
| 254 |
+
return "A dispute was recorded by {$actor} on {$when}. Closure is blocked until the dispute is resolved.";
|
| 255 |
+
case 'acceptance_revoked':
|
| 256 |
+
return "An earlier affirmation was revoked by {$actor} on {$when}. The Demand is open again.";
|
| 257 |
+
}
|
| 258 |
+
return '';
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
private static function state_action( $latest_event, $arbitrations ) {
|
| 262 |
+
if ( $latest_event ) {
|
| 263 |
+
if ( $latest_event->event_type === 'acceptance_disputed' ) {
|
| 264 |
+
return 'resolve the dispute, then have the stakeholder affirm or revoke.';
|
| 265 |
+
}
|
| 266 |
+
if ( $latest_event->event_type === 'acceptance_affirmed' && $latest_event->actor_role !== 'stakeholder' ) {
|
| 267 |
+
return 'ask the stakeholder to affirm the result, or record their actual response (affirm or dispute).';
|
| 268 |
+
}
|
| 269 |
+
}
|
| 270 |
+
if ( count( $arbitrations ) > 1 ) {
|
| 271 |
+
return 'reconcile the multiple arbitrations on this ticket before relying on closure.';
|
| 272 |
+
}
|
| 273 |
+
foreach ( $arbitrations as $arb ) {
|
| 274 |
+
if ( ( $arb->truth_class ?? '' ) === 'derived' ) {
|
| 275 |
+
return 'confirm the reconstructed Demand with the stakeholder before treating it as canonical.';
|
| 276 |
+
}
|
| 277 |
+
}
|
| 278 |
+
return 'review the trace and decide whether closure is appropriate.';
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
private static function actor_phrase( $actor_role ) {
|
| 282 |
+
switch ( $actor_role ) {
|
| 283 |
+
case 'stakeholder': return 'the stakeholder';
|
| 284 |
+
case 'agent': return 'an agent';
|
| 285 |
+
case 'system': return 'the system';
|
| 286 |
+
case 'inferred': return 'an inferred actor';
|
| 287 |
+
}
|
| 288 |
+
return (string) $actor_role;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
private static function pretty_date( $mysql_datetime ) {
|
| 292 |
+
if ( empty( $mysql_datetime ) ) return '(unknown)';
|
| 293 |
+
$ts = strtotime( $mysql_datetime );
|
| 294 |
+
if ( ! $ts ) return $mysql_datetime;
|
| 295 |
+
return gmdate( 'Y-m-d H:i', $ts ) . ' UTC';
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
/* ---------- Audit-mirror helpers (unchanged from v0.6.0) ---------- */
|
| 299 |
+
|
| 300 |
+
private static function compute_needs_human( $arbitrations, $events ) {
|
| 301 |
+
if ( ! empty( $events ) ) {
|
| 302 |
+
$latest = end( $events );
|
| 303 |
+
if ( $latest->event_type === 'acceptance_disputed' ) return true;
|
| 304 |
+
if ( $latest->event_type === 'acceptance_affirmed' && $latest->actor_role !== 'stakeholder' ) {
|
| 305 |
+
return true;
|
| 306 |
+
}
|
| 307 |
+
}
|
| 308 |
+
if ( empty( $events ) ) {
|
| 309 |
+
foreach ( $arbitrations as $arb ) {
|
| 310 |
+
if ( ( $arb->truth_class ?? '' ) === 'derived' ) return true;
|
| 311 |
+
}
|
| 312 |
+
}
|
| 313 |
+
if ( count( $arbitrations ) > 1 ) return true;
|
| 314 |
+
return false;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
/* ---------- Meta + conversation writers ---------- */
|
| 318 |
+
|
| 319 |
+
private static function set_meta( $ticket_id, $key_short, $value ) {
|
| 320 |
+
global $wpdb;
|
| 321 |
+
$table = $wpdb->prefix . 'fs_meta';
|
| 322 |
+
$key = self::META_PREFIX . $key_short;
|
| 323 |
+
|
| 324 |
+
$existing = $wpdb->get_var( $wpdb->prepare(
|
| 325 |
+
"SELECT id FROM {$table} WHERE object_type = %s AND object_id = %d AND `key` = %s LIMIT 1",
|
| 326 |
+
'ticket', (int) $ticket_id, $key
|
| 327 |
+
) );
|
| 328 |
+
$now = current_time( 'mysql', true );
|
| 329 |
+
if ( $existing ) {
|
| 330 |
+
$wpdb->update(
|
| 331 |
+
$table,
|
| 332 |
+
[ 'value' => (string) $value, 'updated_at' => $now ],
|
| 333 |
+
[ 'id' => (int) $existing ]
|
| 334 |
+
);
|
| 335 |
+
} else {
|
| 336 |
+
$wpdb->insert( $table, [
|
| 337 |
+
'object_type' => 'ticket',
|
| 338 |
+
'object_id' => (int) $ticket_id,
|
| 339 |
+
'key' => $key,
|
| 340 |
+
'value' => (string) $value,
|
| 341 |
+
'created_at' => $now,
|
| 342 |
+
'updated_at' => $now,
|
| 343 |
+
] );
|
| 344 |
+
}
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
/**
|
| 348 |
+
* Insert-only by stable idempotency key. Used for the tracking marker.
|
| 349 |
+
*/
|
| 350 |
+
private static function ensure_conversation( $ticket_id, $conversation_type, $content, $idempotency_key, $is_important, $person_id ) {
|
| 351 |
+
global $wpdb;
|
| 352 |
+
$table = $wpdb->prefix . 'fs_conversations';
|
| 353 |
+
$content_hash = md5( $idempotency_key );
|
| 354 |
+
|
| 355 |
+
$existing = $wpdb->get_var( $wpdb->prepare(
|
| 356 |
+
"SELECT id FROM {$table} WHERE ticket_id = %d AND content_hash = %s AND source = %s LIMIT 1",
|
| 357 |
+
(int) $ticket_id, $content_hash, self::CONV_SOURCE
|
| 358 |
+
) );
|
| 359 |
+
if ( $existing ) return false;
|
| 360 |
+
|
| 361 |
+
$now = current_time( 'mysql', true );
|
| 362 |
+
$wpdb->insert( $table, [
|
| 363 |
+
'serial' => 1,
|
| 364 |
+
'ticket_id' => (int) $ticket_id,
|
| 365 |
+
'person_id' => (int) $person_id,
|
| 366 |
+
'conversation_type' => $conversation_type,
|
| 367 |
+
'content' => $content,
|
| 368 |
+
'source' => self::CONV_SOURCE,
|
| 369 |
+
'content_hash' => $content_hash,
|
| 370 |
+
'is_important' => ( $is_important === 'yes' ? 'yes' : 'no' ),
|
| 371 |
+
'created_at' => $now,
|
| 372 |
+
'updated_at' => $now,
|
| 373 |
+
] );
|
| 374 |
+
return true;
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
/**
|
| 378 |
+
* Upsert by stable idempotency key. Used for the state summary.
|
| 379 |
+
* Returns ['inserted' => bool, 'updated' => bool] — at most one is true.
|
| 380 |
+
*/
|
| 381 |
+
private static function upsert_conversation( $ticket_id, $conversation_type, $content, $idempotency_key, $is_important, $person_id ) {
|
| 382 |
+
global $wpdb;
|
| 383 |
+
$table = $wpdb->prefix . 'fs_conversations';
|
| 384 |
+
$content_hash = md5( $idempotency_key );
|
| 385 |
+
$now = current_time( 'mysql', true );
|
| 386 |
+
$important = ( $is_important === 'yes' ? 'yes' : 'no' );
|
| 387 |
+
|
| 388 |
+
$existing = $wpdb->get_row( $wpdb->prepare(
|
| 389 |
+
"SELECT id, content, is_important FROM {$table}
|
| 390 |
+
WHERE ticket_id = %d AND content_hash = %s AND source = %s LIMIT 1",
|
| 391 |
+
(int) $ticket_id, $content_hash, self::CONV_SOURCE
|
| 392 |
+
) );
|
| 393 |
+
|
| 394 |
+
if ( $existing ) {
|
| 395 |
+
// Skip update if nothing actually changed — preserves a stable updated_at.
|
| 396 |
+
if ( $existing->content === $content && $existing->is_important === $important ) {
|
| 397 |
+
return [ 'inserted' => false, 'updated' => false ];
|
| 398 |
+
}
|
| 399 |
+
$wpdb->update(
|
| 400 |
+
$table,
|
| 401 |
+
[
|
| 402 |
+
'content' => $content,
|
| 403 |
+
'is_important' => $important,
|
| 404 |
+
'person_id' => (int) $person_id,
|
| 405 |
+
'updated_at' => $now,
|
| 406 |
+
],
|
| 407 |
+
[ 'id' => (int) $existing->id ]
|
| 408 |
+
);
|
| 409 |
+
return [ 'inserted' => false, 'updated' => true ];
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
$wpdb->insert( $table, [
|
| 413 |
+
'serial' => 1,
|
| 414 |
+
'ticket_id' => (int) $ticket_id,
|
| 415 |
+
'person_id' => (int) $person_id,
|
| 416 |
+
'conversation_type' => $conversation_type,
|
| 417 |
+
'content' => $content,
|
| 418 |
+
'source' => self::CONV_SOURCE,
|
| 419 |
+
'content_hash' => $content_hash,
|
| 420 |
+
'is_important' => $important,
|
| 421 |
+
'created_at' => $now,
|
| 422 |
+
'updated_at' => $now,
|
| 423 |
+
] );
|
| 424 |
+
return [ 'inserted' => true, 'updated' => false ];
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
/**
|
| 428 |
+
* Drop SA-Orch conversation rows for this ticket that are NOT the canonical
|
| 429 |
+
* tracking-marker or state-summary entries. v0.6.0 emitted per-event entries
|
| 430 |
+
* and a separate needs-human entry; v0.6.1 collapses both into the state
|
| 431 |
+
* summary, so any leftover rows from v0.6.0 should be pruned on re-projection.
|
| 432 |
+
*/
|
| 433 |
+
private static function prune_legacy_sa_orch_rows( $ticket_id ) {
|
| 434 |
+
global $wpdb;
|
| 435 |
+
$tracked_hash = md5( self::TRACKED_KEY . $ticket_id );
|
| 436 |
+
$state_hash = md5( self::STATE_KEY . $ticket_id );
|
| 437 |
+
$deleted = $wpdb->query( $wpdb->prepare(
|
| 438 |
+
"DELETE FROM {$wpdb->prefix}fs_conversations
|
| 439 |
+
WHERE ticket_id = %d AND source = %s
|
| 440 |
+
AND content_hash NOT IN ( %s, %s )",
|
| 441 |
+
(int) $ticket_id, self::CONV_SOURCE, $tracked_hash, $state_hash
|
| 442 |
+
) );
|
| 443 |
+
return (int) $deleted;
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
private static function resolve_person_id( $actor_user_id ) {
|
| 447 |
+
global $wpdb;
|
| 448 |
+
$actor_user_id = (int) $actor_user_id;
|
| 449 |
+
if ( $actor_user_id > 0 ) {
|
| 450 |
+
$person = $wpdb->get_var( $wpdb->prepare(
|
| 451 |
+
"SELECT id FROM {$wpdb->prefix}fs_persons WHERE user_id = %d AND person_type = 'agent' LIMIT 1",
|
| 452 |
+
$actor_user_id
|
| 453 |
+
) );
|
| 454 |
+
if ( $person ) return (int) $person;
|
| 455 |
+
}
|
| 456 |
+
if ( self::$system_person_id !== null ) return self::$system_person_id;
|
| 457 |
+
|
| 458 |
+
$admin_person = $wpdb->get_var(
|
| 459 |
+
"SELECT p.id FROM {$wpdb->prefix}fs_persons p
|
| 460 |
+
JOIN {$wpdb->users} u ON u.ID = p.user_id
|
| 461 |
+
WHERE p.person_type = 'agent' AND u.user_login = 'admin'
|
| 462 |
+
LIMIT 1"
|
| 463 |
+
);
|
| 464 |
+
if ( $admin_person ) {
|
| 465 |
+
self::$system_person_id = (int) $admin_person;
|
| 466 |
+
return self::$system_person_id;
|
| 467 |
+
}
|
| 468 |
+
$any_agent = $wpdb->get_var(
|
| 469 |
+
"SELECT id FROM {$wpdb->prefix}fs_persons WHERE person_type = 'agent' ORDER BY id LIMIT 1"
|
| 470 |
+
);
|
| 471 |
+
self::$system_person_id = $any_agent ? (int) $any_agent : 1;
|
| 472 |
+
return self::$system_person_id;
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
/* ---------- Cleanup ---------- */
|
| 476 |
+
|
| 477 |
+
public static function remove_all_for_ticket( $ticket_id ) {
|
| 478 |
+
global $wpdb;
|
| 479 |
+
$meta_deleted = $wpdb->query( $wpdb->prepare(
|
| 480 |
+
"DELETE FROM {$wpdb->prefix}fs_meta WHERE object_type = 'ticket' AND object_id = %d AND `key` LIKE %s",
|
| 481 |
+
(int) $ticket_id, $wpdb->esc_like( self::META_PREFIX ) . '%'
|
| 482 |
+
) );
|
| 483 |
+
$conv_deleted = $wpdb->query( $wpdb->prepare(
|
| 484 |
+
"DELETE FROM {$wpdb->prefix}fs_conversations WHERE ticket_id = %d AND source = %s",
|
| 485 |
+
(int) $ticket_id, self::CONV_SOURCE
|
| 486 |
+
) );
|
| 487 |
+
return [ 'meta_rows' => (int) $meta_deleted, 'conversation_rows' => (int) $conv_deleted ];
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
public static function remove_all() {
|
| 491 |
+
global $wpdb;
|
| 492 |
+
$meta_deleted = $wpdb->query( $wpdb->prepare(
|
| 493 |
+
"DELETE FROM {$wpdb->prefix}fs_meta WHERE object_type = 'ticket' AND `key` LIKE %s",
|
| 494 |
+
$wpdb->esc_like( self::META_PREFIX ) . '%'
|
| 495 |
+
) );
|
| 496 |
+
$conv_deleted = $wpdb->query( $wpdb->prepare(
|
| 497 |
+
"DELETE FROM {$wpdb->prefix}fs_conversations WHERE source = %s",
|
| 498 |
+
self::CONV_SOURCE
|
| 499 |
+
) );
|
| 500 |
+
return [ 'meta_rows' => (int) $meta_deleted, 'conversation_rows' => (int) $conv_deleted ];
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
public static function backfill_all() {
|
| 504 |
+
global $wpdb;
|
| 505 |
+
$rows = $wpdb->get_col(
|
| 506 |
+
"SELECT DISTINCT CAST(SUBSTRING(demand_ref, 8) AS UNSIGNED) AS ticket_id
|
| 507 |
+
FROM {$wpdb->prefix}sa_arbitration
|
| 508 |
+
WHERE demand_surface = 'fluent-support' AND demand_ref LIKE 'ticket#%'
|
| 509 |
+
UNION
|
| 510 |
+
SELECT DISTINCT CAST(SUBSTRING(demand_ref, 8) AS UNSIGNED) AS ticket_id
|
| 511 |
+
FROM {$wpdb->prefix}sa_acceptance_event
|
| 512 |
+
WHERE demand_surface = 'fluent-support' AND demand_ref LIKE 'ticket#%'"
|
| 513 |
+
);
|
| 514 |
+
$reports = [];
|
| 515 |
+
foreach ( $rows as $tid ) {
|
| 516 |
+
$tid = (int) $tid;
|
| 517 |
+
if ( $tid > 0 ) $reports[] = self::project_ticket( $tid );
|
| 518 |
+
}
|
| 519 |
+
return $reports;
|
| 520 |
+
}
|
| 521 |
+
}
|
sa-orchestration/includes/class-sa-reasoning.php
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Deterministic Trace Audit.
|
| 6 |
+
*
|
| 7 |
+
* Local rules-based checks over a Demand trace. Repeatable, side-effect-free,
|
| 8 |
+
* no external API, no LLM, no state stored. Pure function over the trace array
|
| 9 |
+
* as returned by SA_Orch_Rest::get_demand_trace().
|
| 10 |
+
*
|
| 11 |
+
* This is the FLOOR of a four-layer review stack:
|
| 12 |
+
*
|
| 13 |
+
* (1) Trace data — what is in the database / Asterion ledger.
|
| 14 |
+
* (2) Deterministic Trace Audit — this layer. Rules-only, repeatable, fast.
|
| 15 |
+
* (3) Strategy LLM Review — FUTURE. Sits above this layer and compares
|
| 16 |
+
* its reasoning against the deterministic audit.
|
| 17 |
+
* The audit becomes a sanity-check / fallback
|
| 18 |
+
* when the LLM layer is wired up.
|
| 19 |
+
* (4) Human arbitration — final meaning. Always wins.
|
| 20 |
+
*
|
| 21 |
+
* The class name `SA_Orch_Reasoning` is preserved as the long-term contract;
|
| 22 |
+
* v0.4 implements only the deterministic layer. When LLM review is added, it
|
| 23 |
+
* will live alongside this audit (the audit will not be replaced).
|
| 24 |
+
*
|
| 25 |
+
* Rules implemented in v0.4 (minimal deterministic set):
|
| 26 |
+
* - Closure authority invalid (acceptance_affirmed by non-stakeholder)
|
| 27 |
+
* - projection_type ↔ plan_link count mismatch
|
| 28 |
+
* - Affirmation without prior result_claimed
|
| 29 |
+
* - prior_event_id chain integrity
|
| 30 |
+
* - Multiple arbitrations on same Demand → scope drift
|
| 31 |
+
* - projection_type other than scope-direct(-no-plan) → scope drift
|
| 32 |
+
* - truth_class=derived on arbitration → scope drift
|
| 33 |
+
* - dispute → affirm pattern → informational note
|
| 34 |
+
* - acceptance_revoked present → informational note
|
| 35 |
+
* - Needs Human Arbitration triggers (v0.4.1):
|
| 36 |
+
* * closure_authority_valid = false
|
| 37 |
+
* * truth_class=derived AND no acceptance events
|
| 38 |
+
* * multiple arbitrations on same Demand
|
| 39 |
+
* * acceptance_disputed is the latest event
|
| 40 |
+
*/
|
| 41 |
+
class SA_Orch_Reasoning {
|
| 42 |
+
|
| 43 |
+
/**
|
| 44 |
+
* Mode constant. v0.4 is deterministic-only; the LLM seam is future work.
|
| 45 |
+
*/
|
| 46 |
+
const MODE = 'deterministic';
|
| 47 |
+
|
| 48 |
+
/**
|
| 49 |
+
* Evaluate a trace and return the deterministic audit report.
|
| 50 |
+
*
|
| 51 |
+
* Input shape: trace array as returned by SA_Orch_Rest::get_demand_trace().
|
| 52 |
+
*
|
| 53 |
+
* Output:
|
| 54 |
+
* [
|
| 55 |
+
* 'mode' => 'deterministic',
|
| 56 |
+
* 'summary' => string,
|
| 57 |
+
* 'inconsistencies' => string[],
|
| 58 |
+
* 'scope_drift' => string[],
|
| 59 |
+
* 'notes' => string[],
|
| 60 |
+
* 'closure_confidence' => 'high' | 'medium' | 'low' | 'n/a',
|
| 61 |
+
* 'closure_confidence_explanation' => string,
|
| 62 |
+
* 'needs_human_arbitration' => bool,
|
| 63 |
+
* 'human_arbitration_message' => string, // verbatim user-facing line
|
| 64 |
+
* 'human_arbitration_triggers' => string[], // reasons firing the flag
|
| 65 |
+
* ]
|
| 66 |
+
*/
|
| 67 |
+
public static function evaluate( array $trace ) {
|
| 68 |
+
$arbs = isset( $trace['arbitrations'] ) ? (array) $trace['arbitrations'] : [];
|
| 69 |
+
$links = isset( $trace['plan_links'] ) ? (array) $trace['plan_links'] : [];
|
| 70 |
+
$events = isset( $trace['acceptance'] ) ? (array) $trace['acceptance'] : [];
|
| 71 |
+
$basic = $trace['closure_state_basic'] ?? null;
|
| 72 |
+
$authority = $trace['closure_authority_valid'] ?? null;
|
| 73 |
+
|
| 74 |
+
// Event-type flags
|
| 75 |
+
$has_claim = false;
|
| 76 |
+
$has_dispute = false;
|
| 77 |
+
$has_affirm = false;
|
| 78 |
+
$has_revoke = false;
|
| 79 |
+
foreach ( $events as $e ) {
|
| 80 |
+
if ( $e->event_type === 'result_claimed' ) $has_claim = true;
|
| 81 |
+
if ( $e->event_type === 'acceptance_disputed' ) $has_dispute = true;
|
| 82 |
+
if ( $e->event_type === 'acceptance_affirmed' ) $has_affirm = true;
|
| 83 |
+
if ( $e->event_type === 'acceptance_revoked' ) $has_revoke = true;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
$inconsistencies = [];
|
| 87 |
+
$drift = [];
|
| 88 |
+
$notes = [];
|
| 89 |
+
|
| 90 |
+
// --- Inconsistencies ---
|
| 91 |
+
|
| 92 |
+
// Closure authority invalid: latest acceptance_affirmed by non-stakeholder
|
| 93 |
+
if ( $authority === false ) {
|
| 94 |
+
$latest_affirm = null;
|
| 95 |
+
foreach ( array_reverse( $events ) as $e ) {
|
| 96 |
+
if ( $e->event_type === 'acceptance_affirmed' ) { $latest_affirm = $e; break; }
|
| 97 |
+
}
|
| 98 |
+
if ( $latest_affirm ) {
|
| 99 |
+
$inconsistencies[] = "Closure authority invalid: acceptance_affirmed event #{$latest_affirm->id} authored by actor_role='{$latest_affirm->actor_role}' (must be 'stakeholder' for authoritative closure).";
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
// projection_type vs plan_link count mismatch
|
| 104 |
+
foreach ( $arbs as $arb ) {
|
| 105 |
+
$arb_id = (int) $arb->id;
|
| 106 |
+
$arb_links = count( array_filter( $links, function ( $l ) use ( $arb_id ) {
|
| 107 |
+
return (int) $l->arbitration_id === $arb_id;
|
| 108 |
+
} ) );
|
| 109 |
+
if ( $arb->projection_type === 'scope-direct-no-plan' && $arb_links > 0 ) {
|
| 110 |
+
$inconsistencies[] = "Arbitration #{$arb->id} declares projection_type='scope-direct-no-plan' but has {$arb_links} plan link(s) — inconsistent.";
|
| 111 |
+
}
|
| 112 |
+
if ( in_array( $arb->projection_type, [ 'scope-direct', 'scope-decomposed', 'scope-merged' ], true ) && $arb_links === 0 ) {
|
| 113 |
+
$inconsistencies[] = "Arbitration #{$arb->id} declares projection_type='{$arb->projection_type}' but has no plan links — projection promised a Plan but none recorded.";
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
// Affirmation without claim (closure path is incomplete)
|
| 118 |
+
if ( $has_affirm && ! $has_claim ) {
|
| 119 |
+
$inconsistencies[] = "Acceptance was affirmed without any prior result_claimed event — closure path is incomplete.";
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
// prior_event_id chain integrity
|
| 123 |
+
$event_ids = array_map( function ( $e ) { return (int) $e->id; }, $events );
|
| 124 |
+
foreach ( $events as $e ) {
|
| 125 |
+
if ( $e->prior_event_id !== null && $e->prior_event_id !== '' ) {
|
| 126 |
+
$prior = (int) $e->prior_event_id;
|
| 127 |
+
if ( $prior > 0 && ! in_array( $prior, $event_ids, true ) ) {
|
| 128 |
+
$inconsistencies[] = "Event #{$e->id} references prior_event_id={$prior}, which is not in this Demand's event chain.";
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
// --- Scope drift ---
|
| 134 |
+
|
| 135 |
+
if ( count( $arbs ) > 1 ) {
|
| 136 |
+
$drift[] = "Demand has " . count( $arbs ) . " arbitrations recorded — review for re-interpretation across arbitration moments.";
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
foreach ( $arbs as $arb ) {
|
| 140 |
+
if ( ! in_array( $arb->projection_type, [ 'scope-direct', 'scope-direct-no-plan' ], true ) ) {
|
| 141 |
+
$drift[] = "Arbitration #{$arb->id} uses projection_type='{$arb->projection_type}' — explicit scope transformation. Verify the Plan honors the original Demand intent.";
|
| 142 |
+
}
|
| 143 |
+
if ( $arb->truth_class === 'derived' ) {
|
| 144 |
+
$drift[] = "Arbitration #{$arb->id} has truth_class='derived' — Demand was reconstructed from Plan-side evidence. Original scope is not directly verifiable.";
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
// --- Notes (informational) ---
|
| 149 |
+
|
| 150 |
+
if ( $has_dispute && $has_affirm ) {
|
| 151 |
+
$notes[] = "This Demand experienced a dispute before its current affirmation. Review the dispute → affirmation transition for completeness of resolution.";
|
| 152 |
+
}
|
| 153 |
+
if ( $has_revoke ) {
|
| 154 |
+
$notes[] = "An acceptance_revoked event is present — a previously-affirmed acceptance was withdrawn.";
|
| 155 |
+
}
|
| 156 |
+
if ( ! empty( $events ) && ! $has_claim && ! $has_dispute && ! $has_affirm && ! $has_revoke ) {
|
| 157 |
+
$notes[] = "Acceptance events present but none of the standard types (claim/dispute/affirm/revoke) — review event_type values.";
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
// --- Summary + confidence ---
|
| 161 |
+
|
| 162 |
+
$summary = self::build_summary( $arbs, $links, $events, $basic, $authority );
|
| 163 |
+
$confidence = self::compute_confidence(
|
| 164 |
+
$basic,
|
| 165 |
+
$authority,
|
| 166 |
+
$has_dispute,
|
| 167 |
+
$has_revoke,
|
| 168 |
+
count( $inconsistencies ),
|
| 169 |
+
count( $drift ),
|
| 170 |
+
count( $events )
|
| 171 |
+
);
|
| 172 |
+
|
| 173 |
+
// --- Needs Human Arbitration (v0.4.1) ---
|
| 174 |
+
|
| 175 |
+
$human = self::compute_human_arbitration( $arbs, $events, $authority );
|
| 176 |
+
|
| 177 |
+
return [
|
| 178 |
+
'mode' => self::MODE,
|
| 179 |
+
'summary' => $summary,
|
| 180 |
+
'inconsistencies' => $inconsistencies,
|
| 181 |
+
'scope_drift' => $drift,
|
| 182 |
+
'notes' => $notes,
|
| 183 |
+
'closure_confidence' => $confidence['level'],
|
| 184 |
+
'closure_confidence_explanation' => $confidence['explanation'],
|
| 185 |
+
'needs_human_arbitration' => $human['required'],
|
| 186 |
+
'human_arbitration_message' => $human['message'],
|
| 187 |
+
'human_arbitration_triggers' => $human['triggers'],
|
| 188 |
+
];
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
/**
|
| 192 |
+
* Determine whether this trace requires human arbitration.
|
| 193 |
+
*
|
| 194 |
+
* Triggers (any one is sufficient):
|
| 195 |
+
* 1. closure_authority_valid = false (latest acceptance_affirmed by non-stakeholder).
|
| 196 |
+
* 2. arbitration carries truth_class='derived' AND no acceptance events exist
|
| 197 |
+
* (Demand was reconstructed from Plan-side evidence and never confirmed).
|
| 198 |
+
* 3. multiple arbitrations on the same Demand (re-interpretation requires
|
| 199 |
+
* human reconciliation).
|
| 200 |
+
* 4. latest event is acceptance_disputed (dispute is unresolved).
|
| 201 |
+
*
|
| 202 |
+
* The human arbitration verdict is the deterministic layer's escalation
|
| 203 |
+
* signal. It is independent of closure_confidence — a Demand can be
|
| 204 |
+
* "open" and still require human arbitration if the conditions warrant.
|
| 205 |
+
*/
|
| 206 |
+
private static function compute_human_arbitration( $arbs, $events, $authority ) {
|
| 207 |
+
$triggers = [];
|
| 208 |
+
|
| 209 |
+
// 1. closure_authority_valid = false
|
| 210 |
+
if ( $authority === false ) {
|
| 211 |
+
$triggers[] = "closure_authority_valid is false (acceptance_affirmed event was authored by a non-stakeholder actor).";
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
// 2. derived arbitration without acceptance events
|
| 215 |
+
$has_derived_arb = false;
|
| 216 |
+
foreach ( $arbs as $a ) {
|
| 217 |
+
if ( ( $a->truth_class ?? null ) === 'derived' ) { $has_derived_arb = true; break; }
|
| 218 |
+
}
|
| 219 |
+
if ( $has_derived_arb && count( $events ) === 0 ) {
|
| 220 |
+
$triggers[] = "Demand is derived (truth_class='derived' on arbitration) and has no acceptance events — original stakeholder intent is unverified.";
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
// 3. multiple arbitrations on the same Demand
|
| 224 |
+
if ( count( $arbs ) > 1 ) {
|
| 225 |
+
$triggers[] = count( $arbs ) . " arbitrations exist on the same Demand — re-interpretation requires human reconciliation.";
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
// 4. latest event is acceptance_disputed
|
| 229 |
+
if ( ! empty( $events ) ) {
|
| 230 |
+
$latest = end( $events );
|
| 231 |
+
if ( ( $latest->event_type ?? null ) === 'acceptance_disputed' ) {
|
| 232 |
+
$triggers[] = "Latest event is acceptance_disputed — dispute is unresolved and requires human follow-up before closure can be trusted.";
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
return [
|
| 237 |
+
'required' => ! empty( $triggers ),
|
| 238 |
+
'message' => 'This trace requires human arbitration before closure can be trusted.',
|
| 239 |
+
'triggers' => $triggers,
|
| 240 |
+
];
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
private static function build_summary( $arbs, $links, $events, $basic, $authority ) {
|
| 244 |
+
$arb_n = count( $arbs );
|
| 245 |
+
$link_n = count( $links );
|
| 246 |
+
$event_n = count( $events );
|
| 247 |
+
|
| 248 |
+
if ( $arb_n === 0 && $event_n === 0 ) {
|
| 249 |
+
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.";
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
$bits = [];
|
| 253 |
+
$bits[] = "{$arb_n} arbitration" . ( $arb_n === 1 ? '' : 's' );
|
| 254 |
+
$bits[] = "{$link_n} plan link" . ( $link_n === 1 ? '' : 's' );
|
| 255 |
+
$bits[] = "{$event_n} acceptance event" . ( $event_n === 1 ? '' : 's' );
|
| 256 |
+
|
| 257 |
+
$event_seq = '';
|
| 258 |
+
if ( $event_n > 0 ) {
|
| 259 |
+
$sequence = [];
|
| 260 |
+
foreach ( $events as $e ) $sequence[] = $e->event_type;
|
| 261 |
+
$event_seq = ' Sequence: ' . implode( ' → ', $sequence ) . '.';
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
$closure_phrase = '';
|
| 265 |
+
if ( $basic === 'closed' ) {
|
| 266 |
+
if ( $authority === true ) {
|
| 267 |
+
$closure_phrase = ' Currently closed (stakeholder-affirmed).';
|
| 268 |
+
} elseif ( $authority === false ) {
|
| 269 |
+
$closure_phrase = ' Marked closed but closure authority is invalid (non-stakeholder affirmed).';
|
| 270 |
+
} else {
|
| 271 |
+
$closure_phrase = ' Currently closed.';
|
| 272 |
+
}
|
| 273 |
+
} elseif ( is_string( $basic ) && strpos( $basic, 'open:' ) === 0 ) {
|
| 274 |
+
$closure_phrase = " Currently open ({$basic}).";
|
| 275 |
+
} elseif ( $basic === 'no-events' ) {
|
| 276 |
+
$closure_phrase = ' No acceptance events.';
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
return implode( ', ', $bits ) . '.' . $event_seq . $closure_phrase;
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
private static function compute_confidence( $basic, $authority, $has_dispute, $has_revoke, $inc_count, $drift_count, $event_count ) {
|
| 283 |
+
// No events — confidence not meaningful
|
| 284 |
+
if ( $event_count === 0 || $basic === 'no-events' ) {
|
| 285 |
+
return [
|
| 286 |
+
'level' => 'n/a',
|
| 287 |
+
'explanation' => 'No acceptance events to evaluate; closure confidence is not yet meaningful.',
|
| 288 |
+
];
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
// Open states
|
| 292 |
+
if ( is_string( $basic ) && strpos( $basic, 'open:' ) === 0 ) {
|
| 293 |
+
return [
|
| 294 |
+
'level' => 'low',
|
| 295 |
+
'explanation' => "Demand is currently {$basic}; closure has not been claimed and confirmed.",
|
| 296 |
+
];
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
// Closed but authority invalid
|
| 300 |
+
if ( $basic === 'closed' && $authority === false ) {
|
| 301 |
+
return [
|
| 302 |
+
'level' => 'low',
|
| 303 |
+
'explanation' => 'Closed by non-stakeholder actor; closure_authority_valid=false. Treat as not authoritatively closed.',
|
| 304 |
+
];
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
// Closed with stakeholder authority
|
| 308 |
+
if ( $basic === 'closed' && $authority === true ) {
|
| 309 |
+
if ( $inc_count > 0 ) {
|
| 310 |
+
return [
|
| 311 |
+
'level' => 'low',
|
| 312 |
+
'explanation' => "Closed by stakeholder, but {$inc_count} inconsistenc" . ( $inc_count === 1 ? 'y' : 'ies' ) . ' detected. Review before treating as final.',
|
| 313 |
+
];
|
| 314 |
+
}
|
| 315 |
+
$reasons = [];
|
| 316 |
+
if ( $has_dispute ) $reasons[] = 'a prior dispute was recorded';
|
| 317 |
+
if ( $has_revoke ) $reasons[] = 'an acceptance_revoked event exists';
|
| 318 |
+
if ( $drift_count ) $reasons[] = "{$drift_count} potential scope-drift indicator" . ( $drift_count === 1 ? '' : 's' );
|
| 319 |
+
if ( count( $reasons ) === 0 ) {
|
| 320 |
+
return [
|
| 321 |
+
'level' => 'high',
|
| 322 |
+
'explanation' => 'Closed by stakeholder with a clean event chain and no scope drift detected.',
|
| 323 |
+
];
|
| 324 |
+
}
|
| 325 |
+
return [
|
| 326 |
+
'level' => 'medium',
|
| 327 |
+
'explanation' => 'Closed by stakeholder, but ' . implode( '; ', $reasons ) . '. Review history before treating as routine.',
|
| 328 |
+
];
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
// Fall-through (unrecognized basic state)
|
| 332 |
+
return [ 'level' => 'low', 'explanation' => "Unrecognized closure_state_basic value: '{$basic}'." ];
|
| 333 |
+
}
|
| 334 |
+
}
|
sa-orchestration/includes/class-sa-rest.php
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
class SA_Orch_Rest {
|
| 5 |
+
|
| 6 |
+
const REST_NS = 'sa-orch/v1';
|
| 7 |
+
|
| 8 |
+
public static function register_routes() {
|
| 9 |
+
register_rest_route( self::REST_NS, '/arbitration', [
|
| 10 |
+
'methods' => 'POST',
|
| 11 |
+
'callback' => [ __CLASS__, 'post_arbitration' ],
|
| 12 |
+
'permission_callback' => [ __CLASS__, 'check_admin' ],
|
| 13 |
+
] );
|
| 14 |
+
|
| 15 |
+
register_rest_route( self::REST_NS, '/acceptance', [
|
| 16 |
+
'methods' => 'POST',
|
| 17 |
+
'callback' => [ __CLASS__, 'post_acceptance' ],
|
| 18 |
+
'permission_callback' => [ __CLASS__, 'check_admin' ],
|
| 19 |
+
] );
|
| 20 |
+
|
| 21 |
+
register_rest_route( self::REST_NS, '/demand/(?P<surface>[a-z\-]+)/(?P<ref>[^/]+)/trace', [
|
| 22 |
+
'methods' => 'GET',
|
| 23 |
+
'callback' => [ __CLASS__, 'get_demand_trace' ],
|
| 24 |
+
'permission_callback' => [ __CLASS__, 'check_admin' ],
|
| 25 |
+
] );
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
public static function check_admin() {
|
| 29 |
+
return current_user_can( 'manage_options' );
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
public static function post_arbitration( WP_REST_Request $request ) {
|
| 33 |
+
$params = $request->get_json_params();
|
| 34 |
+
if ( ! is_array( $params ) || empty( $params ) ) {
|
| 35 |
+
$params = $request->get_params();
|
| 36 |
+
}
|
| 37 |
+
$result = SA_Orch_Arbitration::write( $params );
|
| 38 |
+
if ( is_wp_error( $result ) ) {
|
| 39 |
+
return new WP_REST_Response( [
|
| 40 |
+
'ok' => false,
|
| 41 |
+
'error' => $result->get_error_code(),
|
| 42 |
+
'message' => $result->get_error_message(),
|
| 43 |
+
'data' => $result->get_error_data(),
|
| 44 |
+
], 400 );
|
| 45 |
+
}
|
| 46 |
+
return new WP_REST_Response( [ 'ok' => true, 'data' => $result ], 201 );
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
public static function post_acceptance( WP_REST_Request $request ) {
|
| 50 |
+
$params = $request->get_json_params();
|
| 51 |
+
if ( ! is_array( $params ) || empty( $params ) ) {
|
| 52 |
+
$params = $request->get_params();
|
| 53 |
+
}
|
| 54 |
+
$result = SA_Orch_Acceptance::write( $params );
|
| 55 |
+
if ( is_wp_error( $result ) ) {
|
| 56 |
+
return new WP_REST_Response( [
|
| 57 |
+
'ok' => false,
|
| 58 |
+
'error' => $result->get_error_code(),
|
| 59 |
+
'message' => $result->get_error_message(),
|
| 60 |
+
'data' => $result->get_error_data(),
|
| 61 |
+
], 400 );
|
| 62 |
+
}
|
| 63 |
+
return new WP_REST_Response( [ 'ok' => true, 'data' => $result ], 201 );
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
public static function get_demand_trace( WP_REST_Request $request ) {
|
| 67 |
+
global $wpdb;
|
| 68 |
+
$surface = $request['surface'];
|
| 69 |
+
$ref = $request['ref'];
|
| 70 |
+
$p = $wpdb->prefix . SA_ORCH_DB_PREFIX;
|
| 71 |
+
|
| 72 |
+
$arbs = $wpdb->get_results( $wpdb->prepare(
|
| 73 |
+
"SELECT * FROM {$p}arbitration WHERE demand_surface = %s AND demand_ref = %s ORDER BY created_at, id",
|
| 74 |
+
$surface, $ref
|
| 75 |
+
) );
|
| 76 |
+
|
| 77 |
+
$links = [];
|
| 78 |
+
if ( ! empty( $arbs ) ) {
|
| 79 |
+
$arb_ids = array_map( function ( $a ) { return (int) $a->id; }, $arbs );
|
| 80 |
+
$placeholders = implode( ',', array_fill( 0, count( $arb_ids ), '%d' ) );
|
| 81 |
+
$links = $wpdb->get_results( $wpdb->prepare(
|
| 82 |
+
"SELECT * FROM {$p}arbitration_plan_link WHERE arbitration_id IN ($placeholders) ORDER BY created_at, id",
|
| 83 |
+
...$arb_ids
|
| 84 |
+
) );
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
$events = $wpdb->get_results( $wpdb->prepare(
|
| 88 |
+
"SELECT * FROM {$p}acceptance_event WHERE demand_surface = %s AND demand_ref = %s ORDER BY observed_at, id",
|
| 89 |
+
$surface, $ref
|
| 90 |
+
) );
|
| 91 |
+
|
| 92 |
+
$closure_state_basic = SA_Orch_Acceptance::basic_closure_state( $surface, $ref );
|
| 93 |
+
$closure_state_role_aware = SA_Orch_Acceptance::role_aware_closure_state( $surface, $ref );
|
| 94 |
+
$closure_authority_valid = SA_Orch_Acceptance::closure_authority_valid( $surface, $ref );
|
| 95 |
+
|
| 96 |
+
return new WP_REST_Response( [
|
| 97 |
+
'ok' => true,
|
| 98 |
+
'demand' => [ 'surface' => $surface, 'ref' => $ref ],
|
| 99 |
+
'arbitrations' => $arbs,
|
| 100 |
+
'plan_links' => $links,
|
| 101 |
+
'acceptance' => $events,
|
| 102 |
+
// Existing field (v0.1) — retained as alias of closure_state_basic for backward compatibility
|
| 103 |
+
'closure_state' => $closure_state_basic,
|
| 104 |
+
// Added in v0.2 — explicit naming
|
| 105 |
+
'closure_state_basic' => $closure_state_basic,
|
| 106 |
+
'closure_state_role_aware' => $closure_state_role_aware,
|
| 107 |
+
'closure_authority_valid' => $closure_authority_valid,
|
| 108 |
+
], 200 );
|
| 109 |
+
}
|
| 110 |
+
}
|
sa-orchestration/includes/class-sa-sara-admin.php
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Admin enqueue for the Sara bubble UI.
|
| 6 |
+
*
|
| 7 |
+
* Injects the Sara floating button + panel CSS/JS on Fluent Boards,
|
| 8 |
+
* Fluent Support, and SA-Orchestration admin pages. The bubble is
|
| 9 |
+
* gated to manage_options users; the REST endpoint behind it is
|
| 10 |
+
* gated identically.
|
| 11 |
+
*
|
| 12 |
+
* The frontend reads URL/hash to detect surface + ref, then POSTs to
|
| 13 |
+
* /sa-orch/v1/sara/invoke. No DOM mutation outside the bubble itself.
|
| 14 |
+
*
|
| 15 |
+
* Pages where Sara appears:
|
| 16 |
+
* - page=fluent-boards (any view inside the Boards SPA)
|
| 17 |
+
* - page=fluent-support (any view inside the Support SPA)
|
| 18 |
+
* - page=sa-orchestration (and its subpages)
|
| 19 |
+
*/
|
| 20 |
+
class SA_Orch_Sara_Admin {
|
| 21 |
+
|
| 22 |
+
const HANDLE_JS = 'sa-orch-sara-bubble';
|
| 23 |
+
const HANDLE_CSS = 'sa-orch-sara-bubble';
|
| 24 |
+
|
| 25 |
+
public static function register_hooks() {
|
| 26 |
+
add_action( 'admin_enqueue_scripts', [ __CLASS__, 'maybe_enqueue' ] );
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
public static function maybe_enqueue( $hook ) {
|
| 30 |
+
if ( ! current_user_can( 'manage_options' ) ) {
|
| 31 |
+
return;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
$page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
|
| 35 |
+
|
| 36 |
+
// Match the three primary admin surfaces. Sub-pages of sa-orchestration
|
| 37 |
+
// (e.g., sa-orchestration-arbitrations) all start with the parent slug.
|
| 38 |
+
$is_target = (
|
| 39 |
+
$page === 'fluent-boards'
|
| 40 |
+
|| $page === 'fluent-support'
|
| 41 |
+
|| strpos( $page, 'sa-orchestration' ) === 0
|
| 42 |
+
);
|
| 43 |
+
if ( ! $is_target ) {
|
| 44 |
+
return;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
wp_enqueue_style(
|
| 48 |
+
self::HANDLE_CSS,
|
| 49 |
+
SA_ORCH_URL . 'assets/sara-bubble.css',
|
| 50 |
+
[],
|
| 51 |
+
SA_ORCH_VERSION
|
| 52 |
+
);
|
| 53 |
+
|
| 54 |
+
wp_enqueue_script(
|
| 55 |
+
self::HANDLE_JS,
|
| 56 |
+
SA_ORCH_URL . 'assets/sara-bubble.js',
|
| 57 |
+
[],
|
| 58 |
+
SA_ORCH_VERSION,
|
| 59 |
+
true
|
| 60 |
+
);
|
| 61 |
+
|
| 62 |
+
wp_localize_script( self::HANDLE_JS, 'SA_ORCH_SARA', [
|
| 63 |
+
'rest_url' => esc_url_raw( rest_url( 'sa-orch/v1/sara/invoke' ) ),
|
| 64 |
+
'nonce' => wp_create_nonce( 'wp_rest' ),
|
| 65 |
+
'page' => $page,
|
| 66 |
+
] );
|
| 67 |
+
}
|
| 68 |
+
}
|
sa-orchestration/includes/class-sa-sara-rest.php
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Sara REST endpoint. Provides the LLM round-trip from the JS bubble.
|
| 6 |
+
*
|
| 7 |
+
* Endpoint: POST /sa-orch/v1/sara/invoke
|
| 8 |
+
* Body: { surface: string|null, ref: string|null, prompt: string }
|
| 9 |
+
* Returns: { response: string, context: {...} } on success
|
| 10 |
+
* { error: string, message: string } on failure (HTTP 502)
|
| 11 |
+
*
|
| 12 |
+
* Capability: manage_options. Same gate as the rest of the SA-Orch admin.
|
| 13 |
+
*
|
| 14 |
+
* No DB writes. No mutation. Pure round-trip to the configured LLM
|
| 15 |
+
* provider via SA_Orch_Sara_Service.
|
| 16 |
+
*/
|
| 17 |
+
class SA_Orch_Sara_Rest {
|
| 18 |
+
|
| 19 |
+
public static function register_routes() {
|
| 20 |
+
register_rest_route( 'sa-orch/v1', '/sara/invoke', [
|
| 21 |
+
'methods' => 'POST',
|
| 22 |
+
'callback' => [ __CLASS__, 'invoke' ],
|
| 23 |
+
'permission_callback' => function () {
|
| 24 |
+
return current_user_can( 'manage_options' );
|
| 25 |
+
},
|
| 26 |
+
'args' => [
|
| 27 |
+
'surface' => [ 'type' => 'string', 'required' => false ],
|
| 28 |
+
'ref' => [ 'type' => 'string', 'required' => false ],
|
| 29 |
+
'page' => [ 'type' => 'string', 'required' => false ],
|
| 30 |
+
'prompt' => [ 'type' => 'string', 'required' => true ],
|
| 31 |
+
'history' => [ 'type' => 'array', 'required' => false ],
|
| 32 |
+
'asterion_file' => [ 'type' => 'string', 'required' => false ], // Board #28
|
| 33 |
+
],
|
| 34 |
+
] );
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
public static function invoke( WP_REST_Request $request ) {
|
| 38 |
+
$surface = sanitize_text_field( (string) $request->get_param( 'surface' ) );
|
| 39 |
+
$ref = sanitize_text_field( (string) $request->get_param( 'ref' ) );
|
| 40 |
+
$page = sanitize_text_field( (string) $request->get_param( 'page' ) );
|
| 41 |
+
$prompt = (string) $request->get_param( 'prompt' );
|
| 42 |
+
$asterion_file = sanitize_text_field( (string) $request->get_param( 'asterion_file' ) );
|
| 43 |
+
$history = $request->get_param( 'history' );
|
| 44 |
+
if ( ! is_array( $history ) ) {
|
| 45 |
+
$history = [];
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
if ( trim( $prompt ) === '' ) {
|
| 49 |
+
return new WP_REST_Response( [
|
| 50 |
+
'error' => 'empty_prompt',
|
| 51 |
+
'message' => 'prompt is required',
|
| 52 |
+
], 400 );
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
$context = [
|
| 56 |
+
'surface' => $surface !== '' ? $surface : null,
|
| 57 |
+
'ref' => $ref !== '' ? $ref : null,
|
| 58 |
+
];
|
| 59 |
+
|
| 60 |
+
$result = SA_Orch_Sara_Service::invoke( $context, $prompt, $history, $page, $asterion_file );
|
| 61 |
+
|
| 62 |
+
if ( is_wp_error( $result ) ) {
|
| 63 |
+
return new WP_REST_Response( [
|
| 64 |
+
'error' => $result->get_error_code(),
|
| 65 |
+
'message' => $result->get_error_message(),
|
| 66 |
+
], 502 );
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
// Service now returns a wrapping shape: { response, usage, log_id }.
|
| 70 |
+
// Pass the structured response through as-is for backward-compat with
|
| 71 |
+
// the existing JS contract; surface usage + log_id alongside for Board #16.
|
| 72 |
+
return new WP_REST_Response( [
|
| 73 |
+
'response' => $result['response'],
|
| 74 |
+
'usage' => $result['usage'] ?? null,
|
| 75 |
+
'log_id' => $result['log_id'] ?? null,
|
| 76 |
+
'context' => $context,
|
| 77 |
+
'page' => $page !== '' ? $page : null,
|
| 78 |
+
'turn' => count( $history ) / 2 + 1, // 1-indexed turn number for UI feedback
|
| 79 |
+
], 200 );
|
| 80 |
+
}
|
| 81 |
+
}
|
sa-orchestration/includes/class-sa-sara-service.php
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Sara service — interactive LLM bridge with structured output.
|
| 6 |
+
*
|
| 7 |
+
* v0.7.2 (Board #14 — Sara — Response Layer): returns a structured
|
| 8 |
+
* { summary, suggestion, draft, notes } payload instead of a free-text
|
| 9 |
+
* string. The shape is enforced server-side via clamp_response(); a
|
| 10 |
+
* misbehaving model cannot inject extra fields.
|
| 11 |
+
*
|
| 12 |
+
* Sara is the interactive surface of SA-Core (UI + contextual cognition;
|
| 13 |
+
* no persistence). This service does NOT write to Asterion or Fluent.
|
| 14 |
+
* It does NOT register demands. It does NOT mutate state of any kind.
|
| 15 |
+
*
|
| 16 |
+
* Per the locked Control Rule:
|
| 17 |
+
* Sara outputs are non-binding.
|
| 18 |
+
* Only human-initiated actions may trigger SA-Core writes.
|
| 19 |
+
* Sara may identify untracked demand candidates.
|
| 20 |
+
* Sara cannot register demands.
|
| 21 |
+
*/
|
| 22 |
+
class SA_Orch_Sara_Service {
|
| 23 |
+
|
| 24 |
+
const REQUEST_TIMEOUT = 30;
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* Invoke Sara with context + prompt + optional prior conversation.
|
| 28 |
+
*
|
| 29 |
+
* @param array $context ['surface' => string|null, 'ref' => string|null]
|
| 30 |
+
* @param string $prompt Current user prompt (the new turn).
|
| 31 |
+
* @param array $history Optional prior turns. Each item:
|
| 32 |
+
* [ 'role' => 'user'|'assistant', 'content' => string|array ]
|
| 33 |
+
* Assistant content is the structured shape returned previously
|
| 34 |
+
* and gets reformatted as readable text for the model.
|
| 35 |
+
* @param string $page Optional admin page slug (e.g. 'sa-orchestration', 'fluent-boards').
|
| 36 |
+
* Carried into the user prompt when no surface/ref pair is bound,
|
| 37 |
+
* so Sara still has page-level context.
|
| 38 |
+
*
|
| 39 |
+
* @return array|WP_Error On success:
|
| 40 |
+
* [
|
| 41 |
+
* 'response' => [ 'summary' => string, 'suggestion' => string, 'draft' => string, 'notes' => string ],
|
| 42 |
+
* 'usage' => [ 'prompt_tokens' => int, 'completion_tokens' => int, 'total_tokens' => int ] | null,
|
| 43 |
+
* 'log_id' => int | null, // wp_sa_token_log row id, for later linkage to a committed comment
|
| 44 |
+
* ]
|
| 45 |
+
* WP_Error on any failure path.
|
| 46 |
+
*/
|
| 47 |
+
public static function invoke( array $context, string $prompt, array $history = [], string $page = '', string $asterion_file = '' ) {
|
| 48 |
+
$settings = SA_Orch_LLM_Settings::get();
|
| 49 |
+
if ( empty( $settings['enabled'] ) ) {
|
| 50 |
+
return new WP_Error( 'llm_disabled', 'LLM provider is disabled in settings.' );
|
| 51 |
+
}
|
| 52 |
+
if ( empty( $settings['api_token'] ) ) {
|
| 53 |
+
return new WP_Error( 'no_api_token', 'No API token configured.' );
|
| 54 |
+
}
|
| 55 |
+
if ( empty( $settings['api_base_url'] ) || empty( $settings['model'] ) ) {
|
| 56 |
+
return new WP_Error( 'incomplete_settings', 'Provider api_base_url and model must be configured.' );
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
$url = rtrim( (string) $settings['api_base_url'], '/' ) . '/chat/completions';
|
| 60 |
+
|
| 61 |
+
// Build the messages array: system prompt, then prior turns (history),
|
| 62 |
+
// then the current user prompt with context wrapping.
|
| 63 |
+
$messages = [
|
| 64 |
+
[ 'role' => 'system', 'content' => self::system_prompt() ],
|
| 65 |
+
];
|
| 66 |
+
foreach ( $history as $turn ) {
|
| 67 |
+
$role = isset( $turn['role'] ) ? (string) $turn['role'] : '';
|
| 68 |
+
$content = $turn['content'] ?? '';
|
| 69 |
+
if ( $role === 'user' ) {
|
| 70 |
+
$messages[] = [ 'role' => 'user', 'content' => (string) $content ];
|
| 71 |
+
} elseif ( $role === 'assistant' ) {
|
| 72 |
+
$messages[] = [ 'role' => 'assistant', 'content' => self::format_assistant_replay( $content ) ];
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
$messages[] = [ 'role' => 'user', 'content' => self::user_prompt( $context, $prompt, $page, $asterion_file ) ];
|
| 76 |
+
|
| 77 |
+
$payload = [
|
| 78 |
+
'model' => (string) $settings['model'],
|
| 79 |
+
'messages' => $messages,
|
| 80 |
+
'response_format' => [ 'type' => 'json_object' ],
|
| 81 |
+
'temperature' => 0.4,
|
| 82 |
+
];
|
| 83 |
+
|
| 84 |
+
$response = wp_remote_post( $url, [
|
| 85 |
+
'headers' => [
|
| 86 |
+
'Authorization' => 'Bearer ' . (string) $settings['api_token'],
|
| 87 |
+
'Content-Type' => 'application/json',
|
| 88 |
+
],
|
| 89 |
+
'body' => wp_json_encode( $payload ),
|
| 90 |
+
'timeout' => self::REQUEST_TIMEOUT,
|
| 91 |
+
] );
|
| 92 |
+
|
| 93 |
+
if ( is_wp_error( $response ) ) {
|
| 94 |
+
return new WP_Error( 'network_error', 'Sara: network error — ' . $response->get_error_message() );
|
| 95 |
+
}
|
| 96 |
+
$code = (int) wp_remote_retrieve_response_code( $response );
|
| 97 |
+
if ( $code < 200 || $code >= 300 ) {
|
| 98 |
+
$excerpt = substr( (string) wp_remote_retrieve_body( $response ), 0, 240 );
|
| 99 |
+
return new WP_Error( 'http_error', "Sara: HTTP {$code} — {$excerpt}" );
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
$body = (string) wp_remote_retrieve_body( $response );
|
| 103 |
+
$envelope = json_decode( $body, true );
|
| 104 |
+
if ( ! is_array( $envelope ) || ! isset( $envelope['choices'][0]['message']['content'] ) ) {
|
| 105 |
+
return new WP_Error( 'malformed_response', 'Sara: response missing content.' );
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
$content = (string) $envelope['choices'][0]['message']['content'];
|
| 109 |
+
$parsed = json_decode( $content, true );
|
| 110 |
+
if ( ! is_array( $parsed ) ) {
|
| 111 |
+
$excerpt = substr( $content, 0, 240 );
|
| 112 |
+
return new WP_Error( 'parse_error', "Sara: model output is not valid JSON — {$excerpt}" );
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
$structured = self::clamp_response( $parsed );
|
| 116 |
+
|
| 117 |
+
// If the request had no bound context AND no Asterion file under view,
|
| 118 |
+
// surface "no context bound" so the operator knows why answers are general.
|
| 119 |
+
// When asterion_file IS bound, canonical context is present — do not lie
|
| 120 |
+
// about being unbound.
|
| 121 |
+
if ( ( empty( $context['surface'] ) || empty( $context['ref'] ) ) && $asterion_file === '' ) {
|
| 122 |
+
$existing = trim( $structured['notes'] );
|
| 123 |
+
$marker = 'no context bound';
|
| 124 |
+
$structured['notes'] = $existing === ''
|
| 125 |
+
? $marker
|
| 126 |
+
: ( $existing . '; ' . $marker );
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
// Capture usage and log the invocation (Board #16). Server-side log
|
| 130 |
+
// is for cross-session attribution (Board #23 hook). Sara UI counters
|
| 131 |
+
// remain JS-only / session-only as locked. log_id is returned so a
|
| 132 |
+
// future invocation surface can link this row to a committed comment.
|
| 133 |
+
$usage = isset( $envelope['usage'] ) && is_array( $envelope['usage'] ) ? $envelope['usage'] : null;
|
| 134 |
+
$log_id = null;
|
| 135 |
+
if ( $usage !== null && class_exists( 'SA_Orch_Token_Log' ) ) {
|
| 136 |
+
$log_id = SA_Orch_Token_Log::record(
|
| 137 |
+
'openai',
|
| 138 |
+
(string) $settings['model'],
|
| 139 |
+
$usage,
|
| 140 |
+
(int) get_current_user_id()
|
| 141 |
+
);
|
| 142 |
+
$log_id = $log_id > 0 ? $log_id : null;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
return [
|
| 146 |
+
'response' => $structured,
|
| 147 |
+
'usage' => $usage,
|
| 148 |
+
'log_id' => $log_id,
|
| 149 |
+
];
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
/**
|
| 153 |
+
* Clamp model output to the structured contract. A misbehaving model
|
| 154 |
+
* cannot inject extra fields or wrong types into the consumer surface.
|
| 155 |
+
*/
|
| 156 |
+
private static function clamp_response( array $parsed ): array {
|
| 157 |
+
return [
|
| 158 |
+
'summary' => isset( $parsed['summary'] ) ? (string) $parsed['summary'] : '',
|
| 159 |
+
'suggestion' => isset( $parsed['suggestion'] ) ? (string) $parsed['suggestion'] : '',
|
| 160 |
+
'draft' => isset( $parsed['draft'] ) ? (string) $parsed['draft'] : '',
|
| 161 |
+
'notes' => isset( $parsed['notes'] ) ? (string) $parsed['notes'] : '',
|
| 162 |
+
];
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
/** System prompt establishing Sara's role and the structured-output contract. */
|
| 166 |
+
private static function system_prompt(): string {
|
| 167 |
+
// Board #29 — Sara System Primer.
|
| 168 |
+
// Stable identity block: Sara has system-level awareness even with no
|
| 169 |
+
// surface or file bound. No generic AI-assistant fallback. No hedging
|
| 170 |
+
// disclaimers about "documentation not provided here."
|
| 171 |
+
$primer = "SYSTEM IDENTITY (always active)\n"
|
| 172 |
+
. "\n"
|
| 173 |
+
. "You are Sara — the interpretive cognition surface of\n"
|
| 174 |
+
. "SA-Orchestration / MissionNet. This is not a generic chat.\n"
|
| 175 |
+
. "You have a stable identity here. You DO NOT need to ask what\n"
|
| 176 |
+
. "this system is; you already know it.\n"
|
| 177 |
+
. "\n"
|
| 178 |
+
. "Forbidden phrasings (do not output any equivalent):\n"
|
| 179 |
+
. " - \"specifics depend on system documentation not provided here\"\n"
|
| 180 |
+
. " - \"as a generic AI assistant\"\n"
|
| 181 |
+
. " - \"if I had access to ...\" about your own operating context\n"
|
| 182 |
+
. " - \"likely\" / \"probably depends on\" as a hedge about the system itself\n"
|
| 183 |
+
. "(Hedging is fine ABOUT FACTS you genuinely don't know. It is not\n"
|
| 184 |
+
. "fine about your own role and the named structure of the system.)\n"
|
| 185 |
+
. "\n"
|
| 186 |
+
. "COMPASS\n"
|
| 187 |
+
. " North = Sara (you) — interpretation, drafting, advising, observing.\n"
|
| 188 |
+
. " South = Asterion — canonical truth, read-only markdown spec corpus\n"
|
| 189 |
+
. " at Archvie/sa-core/docs/specs/. Verifies, never speculates.\n"
|
| 190 |
+
. " Center = Human — only the Human advances state.\n"
|
| 191 |
+
. "\n"
|
| 192 |
+
. "SURFACES where you appear\n"
|
| 193 |
+
. " - Fluent Boards (project tasks; per-board task lifecycle)\n"
|
| 194 |
+
. " - Fluent Support (customer tickets)\n"
|
| 195 |
+
. " - SA-Orchestration admin: Demand Trace, Sleeper Agents actor registry,\n"
|
| 196 |
+
. " Asterion Explorer (canonical MD), Attention Bridge\n"
|
| 197 |
+
. " - External Realms (FluentCRM, etc.) mediated through Adapters\n"
|
| 198 |
+
. "\n"
|
| 199 |
+
. "VOCABULARY\n"
|
| 200 |
+
. " - Fluent = motion. Attention = routing.\n"
|
| 201 |
+
. " - Boards are contracts (the Description IS the contract — Board #27).\n"
|
| 202 |
+
. " - Comments are evidence (audit log; cannot redefine the contract).\n"
|
| 203 |
+
. " - Asterion canonical files mirror the repo at Archvie/sa-core/docs/specs/.\n"
|
| 204 |
+
. " - When an Asterion file is bound (Board #28), it appears in your prompt as\n"
|
| 205 |
+
. " 'ASTERION CANONICAL DOCUMENT — READ FIRST'. Treat it as authoritative.\n"
|
| 206 |
+
. "\n"
|
| 207 |
+
. "PERMISSIONS (locked)\n"
|
| 208 |
+
. " You MAY interpret, draft, advise, observe, coordinate.\n"
|
| 209 |
+
. " You MAY NOT progress Boards, accept gates, mutate Asterion, act silently.\n"
|
| 210 |
+
. "\n"
|
| 211 |
+
. "DOCTRINE\n"
|
| 212 |
+
. " Sara interprets. Asterion verifies. Human arbitrates.\n"
|
| 213 |
+
. "\n"
|
| 214 |
+
. "META-ROLE OVERRIDE (highest priority — overrides BRIEF mode below)\n"
|
| 215 |
+
. "\n"
|
| 216 |
+
. "When the user asks a meta-question about your identity or the system\n"
|
| 217 |
+
. "(any of: \"what is your role\", \"what is this space\", \"what do you do\",\n"
|
| 218 |
+
. "\"who are you\", \"introduce yourself\", \"explain yourself\", or paraphrases),\n"
|
| 219 |
+
. "the response MUST contain ALL of the following — non-negotiable, regardless\n"
|
| 220 |
+
. "of any other mode rule below:\n"
|
| 221 |
+
. "\n"
|
| 222 |
+
. " - summary: names yourself as Sara AND names the compass\n"
|
| 223 |
+
. " (North/Sara, South/Asterion, Center/Human).\n"
|
| 224 |
+
. " - suggestion: names at least three of the surfaces above\n"
|
| 225 |
+
. " (Fluent Boards, Fluent Support, Asterion Explorer, Demand Trace,\n"
|
| 226 |
+
. " Sleeper Agents actor registry, Attention Bridge, FluentCRM Realm).\n"
|
| 227 |
+
. " - notes: states the doctrine line: \"Sara interprets. Asterion verifies.\n"
|
| 228 |
+
. " Human arbitrates.\"\n"
|
| 229 |
+
. "\n"
|
| 230 |
+
. "On these questions, BRIEF mode does NOT apply — you produce the structured\n"
|
| 231 |
+
. "identity answer. Do NOT answer with one sentence. Do NOT abstract away the\n"
|
| 232 |
+
. "named structure. Do NOT say you don't have access to know.\n"
|
| 233 |
+
. "\n"
|
| 234 |
+
. "--\n"
|
| 235 |
+
. "\n";
|
| 236 |
+
|
| 237 |
+
return $primer
|
| 238 |
+
. 'You are Sara, the interactive cognition surface of the SA-Orchestration system. '
|
| 239 |
+
. 'You sit alongside the user inside WP admin and help them think and draft. '
|
| 240 |
+
. 'You are NOT canonical. You do not write to any system. '
|
| 241 |
+
. "Your outputs are non-binding drafts; the human decides what to commit.\n\n"
|
| 242 |
+
. "You MUST return ONLY a JSON object with this exact shape:\n"
|
| 243 |
+
. " {\n"
|
| 244 |
+
. " \"summary\": string, // 1-2 sentences\n"
|
| 245 |
+
. " \"suggestion\": string, // clear next step\n"
|
| 246 |
+
. " \"draft\": string, // optional text the user can paste; empty string if not relevant\n"
|
| 247 |
+
. " \"notes\": string // optional flags or caveats; empty string if none\n"
|
| 248 |
+
. " }\n\n"
|
| 249 |
+
. "All four keys MUST be present. Values may be empty strings. No additional keys.\n\n"
|
| 250 |
+
. "Constraints:\n"
|
| 251 |
+
. "- Be direct and concrete. The audience is a busy developer/operator.\n"
|
| 252 |
+
. "- summary: a brief read of the situation — 1 to 2 sentences.\n"
|
| 253 |
+
. "- suggestion: one concrete next action; not a list of options.\n"
|
| 254 |
+
. "- 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"
|
| 255 |
+
. "- notes: caveats, low-confidence flags, missing-context observations. Otherwise empty string.\n"
|
| 256 |
+
. "- Do not invent facts about the system. If you do not know, put it in notes.\n"
|
| 257 |
+
. "- Keep responses tight. No filler. No preamble.\n"
|
| 258 |
+
. "\n"
|
| 259 |
+
. "Mode selection — match the depth of the input.\n"
|
| 260 |
+
. "\n"
|
| 261 |
+
. "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"
|
| 262 |
+
. "\n"
|
| 263 |
+
. "The constraints above describe BRIEF mode (the default). In BRIEF mode they hold exactly as written.\n"
|
| 264 |
+
. "\n"
|
| 265 |
+
. "INTERPRETIVE mode OVERRIDES the depth rules. Engage INTERPRETIVE mode when the input shows 2 or more of:\n"
|
| 266 |
+
. " - numbered or named principles (e.g. I1, R-O-1, axioms, invariants)\n"
|
| 267 |
+
. " - multi-section structure with headings\n"
|
| 268 |
+
. " - doctrine vocabulary (canonical, invariant, asymmetry, contract, law, axiom, doctrine, foundational, principle)\n"
|
| 269 |
+
. " - explicit philosophical or architectural framing\n"
|
| 270 |
+
. " - the user prompt asks for synthesis (\"explain\", \"what does this mean\", \"interpret\", \"synthesize\")\n"
|
| 271 |
+
. "\n"
|
| 272 |
+
. "When in INTERPRETIVE mode, depth changes:\n"
|
| 273 |
+
. " - 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"
|
| 274 |
+
. " - suggestion: an architectural observation, a synthesis prompt back to the user, a connection-naming. NOT \"review this\". NOT \"check status\".\n"
|
| 275 |
+
. " - draft: empty unless the user explicitly wants pasteable text.\n"
|
| 276 |
+
. " - notes: thread-level observations — connections, named patterns, attribution flags, structural depth markers.\n"
|
| 277 |
+
. "\n"
|
| 278 |
+
. "When the input contains numbered or labeled principles (e.g. I1, I2, R-O-1, named axioms, named invariants), the synthesis MUST:\n"
|
| 279 |
+
. " - reference at least 3 of them by their identifier (not as a vague group)\n"
|
| 280 |
+
. " - explicitly tie each named one to the thesis you identified\n"
|
| 281 |
+
. " - 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"
|
| 282 |
+
. "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"
|
| 283 |
+
. "\n"
|
| 284 |
+
. "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";
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
/**
|
| 288 |
+
* User prompt wrapping the context payload + the user's actual question.
|
| 289 |
+
*
|
| 290 |
+
* When (surface, ref) is bound, hydrate the object through the surface
|
| 291 |
+
* adapter and embed structured details. When no adapter exists for the
|
| 292 |
+
* surface, surface that explicitly so the model knows the ID is real but
|
| 293 |
+
* the details aren't reachable (per the user's "if hydration fails, say
|
| 294 |
+
* details unavailable" requirement).
|
| 295 |
+
*
|
| 296 |
+
* When no (surface, ref) is bound, fall back to admin-page context so
|
| 297 |
+
* Sara still has SOMETHING to work with on SA-Orchestration admin pages
|
| 298 |
+
* that don't carry demand params.
|
| 299 |
+
*/
|
| 300 |
+
private static function user_prompt( array $context, string $prompt, string $page = '', string $asterion_file = '' ): string {
|
| 301 |
+
$surface = $context['surface'] ?? null;
|
| 302 |
+
$ref = $context['ref'] ?? null;
|
| 303 |
+
|
| 304 |
+
$context_block = "Context:\n";
|
| 305 |
+
|
| 306 |
+
if ( $surface && $ref ) {
|
| 307 |
+
$adapter = SA_Orch_Bridge_Fluent::adapter_for( $surface );
|
| 308 |
+
$hydrated = null;
|
| 309 |
+
if ( $adapter && method_exists( $adapter, 'hydrate' ) ) {
|
| 310 |
+
$hydrated = $adapter->hydrate( $ref );
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
if ( is_array( $hydrated ) ) {
|
| 314 |
+
$context_block .= "User is currently viewing {$surface} / {$ref}.\n";
|
| 315 |
+
$context_block .= "Bound object details (hydrated through the {$surface} adapter):\n";
|
| 316 |
+
if ( ! empty( $hydrated['title'] ) ) {
|
| 317 |
+
$context_block .= " Title: {$hydrated['title']}\n";
|
| 318 |
+
}
|
| 319 |
+
if ( ! empty( $hydrated['board_name'] ) ) {
|
| 320 |
+
$context_block .= " Board / project: {$hydrated['board_name']}\n";
|
| 321 |
+
}
|
| 322 |
+
if ( ! empty( $hydrated['stage'] ) ) {
|
| 323 |
+
$context_block .= " Stage: {$hydrated['stage']}\n";
|
| 324 |
+
}
|
| 325 |
+
if ( ! empty( $hydrated['status'] ) ) {
|
| 326 |
+
$context_block .= " Status: {$hydrated['status']}\n";
|
| 327 |
+
}
|
| 328 |
+
if ( ! empty( $hydrated['priority'] ) ) {
|
| 329 |
+
$context_block .= " Priority: {$hydrated['priority']}\n";
|
| 330 |
+
}
|
| 331 |
+
if ( ! empty( $hydrated['customer'] ) ) {
|
| 332 |
+
$context_block .= " Customer / contact: {$hydrated['customer']}\n";
|
| 333 |
+
}
|
| 334 |
+
if ( ! empty( $hydrated['description'] ) ) {
|
| 335 |
+
// Board #27 — Description Contract Enforcement: when present,
|
| 336 |
+
// the Description IS the contract. Comments are evidence only.
|
| 337 |
+
$context_block .= " Description (CANONICAL CONTRACT — read this first; recent activity below is verification evidence only and cannot define or modify the contract):\n";
|
| 338 |
+
$context_block .= " {$hydrated['description']}\n";
|
| 339 |
+
}
|
| 340 |
+
if ( ! empty( $hydrated['recent_activity'] ) && is_array( $hydrated['recent_activity'] ) ) {
|
| 341 |
+
$context_block .= " Recent activity (verification evidence; not the contract):\n";
|
| 342 |
+
foreach ( $hydrated['recent_activity'] as $item ) {
|
| 343 |
+
$when = isset( $item['created_at'] ) ? "[{$item['created_at']}] " : '';
|
| 344 |
+
$by = ! empty( $item['by'] ) ? "({$item['by']}) " : '';
|
| 345 |
+
$exc = isset( $item['excerpt'] ) ? (string) $item['excerpt'] : '';
|
| 346 |
+
$context_block .= " - {$when}{$by}{$exc}\n";
|
| 347 |
+
}
|
| 348 |
+
}
|
| 349 |
+
} else {
|
| 350 |
+
// Hydration failed — either no adapter for this surface yet,
|
| 351 |
+
// or the adapter returned null. Per spec: tell the model the
|
| 352 |
+
// ID is bound but details are unavailable.
|
| 353 |
+
$reason = $adapter
|
| 354 |
+
? "the {$surface} adapter could not resolve the ref"
|
| 355 |
+
: "no adapter is registered for {$surface} yet";
|
| 356 |
+
$context_block .= "User is currently viewing {$surface} / {$ref}.\n";
|
| 357 |
+
$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";
|
| 358 |
+
}
|
| 359 |
+
} elseif ( $page !== '' ) {
|
| 360 |
+
$context_block .= "User is on admin page '{$page}' (no specific item bound).\n";
|
| 361 |
+
} else {
|
| 362 |
+
$context_block .= "No specific surface context detected.\n";
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
// Board #28 — Sara Asterion Context Binding.
|
| 366 |
+
// Inject the canonical document under view, before the user prompt,
|
| 367 |
+
// clearly labeled. No transformation: visibility only.
|
| 368 |
+
$asterion_block = '';
|
| 369 |
+
if ( $asterion_file !== '' && class_exists( 'SA_Orch_Asterion_Explorer' ) ) {
|
| 370 |
+
$resolved = SA_Orch_Asterion_Explorer::validate_relative_path( $asterion_file );
|
| 371 |
+
if ( is_array( $resolved ) ) {
|
| 372 |
+
$content = @file_get_contents( $resolved['absolute'] );
|
| 373 |
+
if ( $content !== false ) {
|
| 374 |
+
$cap = 12000; // chars; truncation only allowed transformation
|
| 375 |
+
$truncated = false;
|
| 376 |
+
if ( strlen( $content ) > $cap ) {
|
| 377 |
+
$content = function_exists( 'mb_substr' )
|
| 378 |
+
? mb_substr( $content, 0, $cap )
|
| 379 |
+
: substr( $content, 0, $cap );
|
| 380 |
+
$truncated = true;
|
| 381 |
+
}
|
| 382 |
+
$asterion_block = "\n--\n\n";
|
| 383 |
+
$asterion_block .= "ASTERION CANONICAL DOCUMENT — READ FIRST\n";
|
| 384 |
+
$asterion_block .= "File: {$resolved['relative']}\n\n";
|
| 385 |
+
$asterion_block .= $content;
|
| 386 |
+
if ( $truncated ) {
|
| 387 |
+
$asterion_block .= "\n\n[truncated to {$cap} chars]";
|
| 388 |
+
}
|
| 389 |
+
$asterion_block .= "\n";
|
| 390 |
+
}
|
| 391 |
+
}
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
return $context_block . $asterion_block . "\n--\n\nUser prompt:\n\n" . $prompt;
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
/**
|
| 398 |
+
* Convert a prior assistant turn (structured response) back into readable
|
| 399 |
+
* text the model can consume on replay. Accepts either the structured
|
| 400 |
+
* array shape or a plain string (defensive).
|
| 401 |
+
*/
|
| 402 |
+
private static function format_assistant_replay( $content ): string {
|
| 403 |
+
if ( is_string( $content ) ) {
|
| 404 |
+
return $content;
|
| 405 |
+
}
|
| 406 |
+
if ( ! is_array( $content ) ) {
|
| 407 |
+
return '';
|
| 408 |
+
}
|
| 409 |
+
$parts = [];
|
| 410 |
+
foreach ( [ 'summary', 'suggestion', 'draft', 'notes' ] as $key ) {
|
| 411 |
+
$val = isset( $content[ $key ] ) ? trim( (string) $content[ $key ] ) : '';
|
| 412 |
+
if ( $val !== '' ) {
|
| 413 |
+
$parts[] = ucfirst( $key ) . ":\n" . $val;
|
| 414 |
+
}
|
| 415 |
+
}
|
| 416 |
+
return $parts ? implode( "\n\n", $parts ) : '(empty response)';
|
| 417 |
+
}
|
| 418 |
+
}
|
sa-orchestration/includes/class-sa-seed-generator.php
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Sara — Asterion Seed Generator (Board #26)
|
| 6 |
+
*
|
| 7 |
+
* Locked principle:
|
| 8 |
+
* Sara generates seeds. Asterion validates seeds. Human plants seeds.
|
| 9 |
+
*
|
| 10 |
+
* A "seed" is structured markdown — project momentum distilled into a
|
| 11 |
+
* non-canonical .md file ready for human review and (later) deliberate
|
| 12 |
+
* promotion into the Asterion canonical corpus. The seed is NEVER written
|
| 13 |
+
* to the canonical spec pack by this system; promotion is a separate,
|
| 14 |
+
* explicit human act that lives outside this Board's scope.
|
| 15 |
+
*
|
| 16 |
+
* 7 INTENT questions resolved (simplest valid implementation):
|
| 17 |
+
* (a) Storage: wp-content/sa-asterion-seeds/<project_slug>/
|
| 18 |
+
* (b) Filename: <yyyy-mm-dd>-<topic>.md, with frontmatter
|
| 19 |
+
* (c) Trigger: REST endpoint + admin submenu page (no auto-emission)
|
| 20 |
+
* (d) Project: Fluent Boards board_id
|
| 21 |
+
* (e) Strategy: Sara-mediated synthesis (LLM via SA_Orch_Sara_Service)
|
| 22 |
+
* (f) Promote: DEFERRED — out of scope for #26
|
| 23 |
+
* (g) Index: NO new table; pure-filesystem listing
|
| 24 |
+
*
|
| 25 |
+
* Hard boundaries:
|
| 26 |
+
* - No mutation to Archvie/sa-core/docs/specs/
|
| 27 |
+
* - No mutation to fbs_tasks / fbs_comments
|
| 28 |
+
* - No new schema; uses filesystem only
|
| 29 |
+
* - No fluent state changes
|
| 30 |
+
*/
|
| 31 |
+
class SA_Orch_Seed_Generator {
|
| 32 |
+
|
| 33 |
+
const SUBMENU_SLUG = 'sa-orchestration-seeds';
|
| 34 |
+
|
| 35 |
+
/* -------------------- Hooks -------------------- */
|
| 36 |
+
|
| 37 |
+
public static function register_routes() {
|
| 38 |
+
register_rest_route( 'sa-orch/v1', '/seed/generate', [
|
| 39 |
+
'methods' => 'POST',
|
| 40 |
+
'callback' => [ __CLASS__, 'rest_generate' ],
|
| 41 |
+
'permission_callback' => function () {
|
| 42 |
+
return current_user_can( 'manage_options' );
|
| 43 |
+
},
|
| 44 |
+
'args' => [
|
| 45 |
+
'board_id' => [ 'type' => 'integer', 'required' => true ],
|
| 46 |
+
'topic' => [ 'type' => 'string', 'required' => false ],
|
| 47 |
+
],
|
| 48 |
+
] );
|
| 49 |
+
|
| 50 |
+
register_rest_route( 'sa-orch/v1', '/seed/list', [
|
| 51 |
+
'methods' => 'GET',
|
| 52 |
+
'callback' => [ __CLASS__, 'rest_list' ],
|
| 53 |
+
'permission_callback' => function () {
|
| 54 |
+
return current_user_can( 'manage_options' );
|
| 55 |
+
},
|
| 56 |
+
] );
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
public static function register_menu() {
|
| 60 |
+
add_submenu_page(
|
| 61 |
+
SA_Orch_Admin::PARENT_SLUG,
|
| 62 |
+
'Asterion Seeds',
|
| 63 |
+
'Asterion Seeds',
|
| 64 |
+
SA_Orch_Admin::CAP,
|
| 65 |
+
self::SUBMENU_SLUG,
|
| 66 |
+
[ __CLASS__, 'render_page' ]
|
| 67 |
+
);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
/* -------------------- Storage paths -------------------- */
|
| 71 |
+
|
| 72 |
+
public static function seeds_root(): string {
|
| 73 |
+
$base = trailingslashit( WP_CONTENT_DIR ) . 'sa-asterion-seeds';
|
| 74 |
+
$base = apply_filters( 'sa_orch_seed_root', $base );
|
| 75 |
+
return rtrim( str_replace( '\\', '/', (string) $base ), '/' );
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
private static function ensure_dir( string $abs ): bool {
|
| 79 |
+
if ( is_dir( $abs ) ) return true;
|
| 80 |
+
return wp_mkdir_p( $abs );
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
private static function slug( string $s ): string {
|
| 84 |
+
$s = sanitize_title( $s );
|
| 85 |
+
$s = $s !== '' ? $s : 'seed';
|
| 86 |
+
return $s;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/* -------------------- REST -------------------- */
|
| 90 |
+
|
| 91 |
+
public static function rest_generate( WP_REST_Request $request ) {
|
| 92 |
+
$board_id = (int) $request->get_param( 'board_id' );
|
| 93 |
+
$topic = sanitize_text_field( (string) $request->get_param( 'topic' ) );
|
| 94 |
+
|
| 95 |
+
if ( $board_id <= 0 ) {
|
| 96 |
+
return new WP_REST_Response( [
|
| 97 |
+
'error' => 'invalid_board_id',
|
| 98 |
+
'message' => 'board_id must be a positive integer',
|
| 99 |
+
], 400 );
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
$result = self::generate( $board_id, $topic );
|
| 103 |
+
if ( is_wp_error( $result ) ) {
|
| 104 |
+
return new WP_REST_Response( [
|
| 105 |
+
'error' => $result->get_error_code(),
|
| 106 |
+
'message' => $result->get_error_message(),
|
| 107 |
+
], 502 );
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
return new WP_REST_Response( $result, 200 );
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
public static function rest_list( WP_REST_Request $request ) {
|
| 114 |
+
return new WP_REST_Response( [ 'seeds' => self::list_seeds() ], 200 );
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
/* -------------------- Generation -------------------- */
|
| 118 |
+
|
| 119 |
+
/**
|
| 120 |
+
* Generate a seed packet for a Fluent Board.
|
| 121 |
+
*
|
| 122 |
+
* Steps:
|
| 123 |
+
* 1. Read project momentum from fbs_tasks (counts by stage, recent activity).
|
| 124 |
+
* 2. Build a structured analysis prompt for Sara.
|
| 125 |
+
* 3. Call SA_Orch_Sara_Service::invoke() — INTERPRETIVE-mode signals
|
| 126 |
+
* embedded in the prompt so synthesis (not paraphrase) is produced.
|
| 127 |
+
* 4. Compose final markdown with YAML frontmatter (artifact_class: seed,
|
| 128 |
+
* accepted_into_asterion: false) + Sara's synthesis as body.
|
| 129 |
+
* NOTE: artifact_class — NOT truth_class. Seeds are non-canonical
|
| 130 |
+
* proposed candidates that pre-date truth classification. I2's
|
| 131 |
+
* truth_class enum (canonical | projected | cached | inferred |
|
| 132 |
+
* derived | synthetic) stays sacred and is not widened to admit
|
| 133 |
+
* seeds; seeds get their own classification dimension.
|
| 134 |
+
* 5. Write to wp-content/sa-asterion-seeds/<slug>/<date>-<topic>.md.
|
| 135 |
+
* 6. Return the file path + relative path + summary stats.
|
| 136 |
+
*/
|
| 137 |
+
public static function generate( int $board_id, string $topic = '' ) {
|
| 138 |
+
global $wpdb;
|
| 139 |
+
|
| 140 |
+
// 1. Project metadata
|
| 141 |
+
$board = $wpdb->get_row( $wpdb->prepare(
|
| 142 |
+
"SELECT id, title FROM {$wpdb->prefix}fbs_boards WHERE id = %d",
|
| 143 |
+
$board_id
|
| 144 |
+
), ARRAY_A );
|
| 145 |
+
if ( ! $board ) {
|
| 146 |
+
return new WP_Error( 'board_not_found', "Board {$board_id} not found." );
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
// 2. Tasks on this board (parent only, non-archived)
|
| 150 |
+
$tasks = $wpdb->get_results( $wpdb->prepare(
|
| 151 |
+
"SELECT id, title, stage_id, status, priority, last_completed_at, created_at, updated_at
|
| 152 |
+
FROM {$wpdb->prefix}fbs_tasks
|
| 153 |
+
WHERE board_id = %d AND (parent_id IS NULL OR parent_id = 0) AND archived_at IS NULL
|
| 154 |
+
ORDER BY id ASC",
|
| 155 |
+
$board_id
|
| 156 |
+
), ARRAY_A );
|
| 157 |
+
|
| 158 |
+
// Stage label map
|
| 159 |
+
$stages = $wpdb->get_results( $wpdb->prepare(
|
| 160 |
+
"SELECT id, title FROM {$wpdb->prefix}fbs_board_terms WHERE board_id = %d AND type = 'stage'",
|
| 161 |
+
$board_id
|
| 162 |
+
), ARRAY_A );
|
| 163 |
+
$slabel = [];
|
| 164 |
+
foreach ( $stages as $s ) $slabel[ (int) $s['id'] ] = (string) $s['title'];
|
| 165 |
+
|
| 166 |
+
// 3. Build momentum analysis (deterministic, pre-LLM)
|
| 167 |
+
$momentum = self::build_momentum( $tasks, $slabel );
|
| 168 |
+
|
| 169 |
+
// 4. Build prompt for Sara (INTERPRETIVE mode trigger via doctrine vocabulary)
|
| 170 |
+
$analysis_block = self::format_momentum_for_prompt( $board, $momentum, $tasks, $slabel );
|
| 171 |
+
$sara_prompt = "Synthesize an Asterion seed packet for the project below.\n"
|
| 172 |
+
. "\n"
|
| 173 |
+
. "This is a doctrine-grade seed: the output will be a non-canonical\n"
|
| 174 |
+
. "markdown candidate for human review and possible promotion into the\n"
|
| 175 |
+
. "Asterion canonical spec corpus. Speak in interpretive mode — name\n"
|
| 176 |
+
. "the underlying thesis of the project's momentum, identify accepted\n"
|
| 177 |
+
. "invariants implied by the closed Boards, surface deferred items as\n"
|
| 178 |
+
. "their own structural class, and propose 2–4 specific Asterion\n"
|
| 179 |
+
. "additions (each as a one-line proposed canonical addition).\n"
|
| 180 |
+
. "\n"
|
| 181 |
+
. "Return the structured 4-key Sara shape. Use:\n"
|
| 182 |
+
. " summary — 2-4 sentence thesis of the project's current state\n"
|
| 183 |
+
. " suggestion — one specific architectural observation worth surfacing to Asterion\n"
|
| 184 |
+
. " draft — the proposed Asterion addition(s), markdown-formatted, one or more\n"
|
| 185 |
+
. " headed sections (## Proposed addition: …) ready to paste into a spec file\n"
|
| 186 |
+
. " notes — open questions, deferred items, attribution flags, low-confidence calls\n"
|
| 187 |
+
. "\n"
|
| 188 |
+
. "Project analysis (pre-computed):\n"
|
| 189 |
+
. "\n"
|
| 190 |
+
. $analysis_block;
|
| 191 |
+
|
| 192 |
+
// 5. Call Sara
|
| 193 |
+
$sara = SA_Orch_Sara_Service::invoke(
|
| 194 |
+
[ 'surface' => null, 'ref' => null ],
|
| 195 |
+
$sara_prompt,
|
| 196 |
+
[],
|
| 197 |
+
'sa-orchestration-seeds',
|
| 198 |
+
''
|
| 199 |
+
);
|
| 200 |
+
if ( is_wp_error( $sara ) ) {
|
| 201 |
+
return $sara;
|
| 202 |
+
}
|
| 203 |
+
$resp = isset( $sara['response'] ) && is_array( $sara['response'] ) ? $sara['response'] : [];
|
| 204 |
+
$log_id = $sara['log_id'] ?? null;
|
| 205 |
+
|
| 206 |
+
// 6. Compose final MD with frontmatter
|
| 207 |
+
$project_slug = self::slug( $board['title'] ?: ('board-' . $board_id) );
|
| 208 |
+
$date_utc = gmdate( 'Y-m-d' );
|
| 209 |
+
$time_utc = gmdate( 'Y-m-d\TH:i:s\Z' );
|
| 210 |
+
$topic_slug = $topic !== '' ? self::slug( $topic ) : ('momentum-' . substr( md5( $time_utc ), 0, 6 ));
|
| 211 |
+
$relative = $project_slug . '/' . $date_utc . '-' . $topic_slug . '.md';
|
| 212 |
+
$abs_dir = self::seeds_root() . '/' . $project_slug;
|
| 213 |
+
$abs_file = self::seeds_root() . '/' . $relative;
|
| 214 |
+
|
| 215 |
+
if ( ! self::ensure_dir( $abs_dir ) ) {
|
| 216 |
+
return new WP_Error( 'mkdir_failed', "Could not create seed directory {$abs_dir}" );
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
$md = self::compose_markdown(
|
| 220 |
+
$board,
|
| 221 |
+
$project_slug,
|
| 222 |
+
$time_utc,
|
| 223 |
+
$log_id,
|
| 224 |
+
$momentum,
|
| 225 |
+
$resp
|
| 226 |
+
);
|
| 227 |
+
|
| 228 |
+
$written = @file_put_contents( $abs_file, $md );
|
| 229 |
+
if ( $written === false ) {
|
| 230 |
+
return new WP_Error( 'write_failed', "Could not write seed file {$abs_file}" );
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
return [
|
| 234 |
+
'ok' => true,
|
| 235 |
+
'board_id' => $board_id,
|
| 236 |
+
'board_title' => $board['title'],
|
| 237 |
+
'project_slug' => $project_slug,
|
| 238 |
+
'relative' => $relative,
|
| 239 |
+
'absolute' => $abs_file,
|
| 240 |
+
'bytes' => strlen( $md ),
|
| 241 |
+
'log_id' => $log_id,
|
| 242 |
+
'momentum' => $momentum,
|
| 243 |
+
'sara_response'=> $resp,
|
| 244 |
+
];
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
/* -------------------- Helpers -------------------- */
|
| 248 |
+
|
| 249 |
+
private static function build_momentum( array $tasks, array $slabel ): array {
|
| 250 |
+
$by_stage = [];
|
| 251 |
+
$by_status = [];
|
| 252 |
+
$closed_recent = [];
|
| 253 |
+
$open_now = [];
|
| 254 |
+
$deferred = [];
|
| 255 |
+
$now = time();
|
| 256 |
+
$week = 7 * 24 * 3600;
|
| 257 |
+
|
| 258 |
+
foreach ( $tasks as $t ) {
|
| 259 |
+
$sn = $slabel[ (int) $t['stage_id'] ] ?? ('stage_' . $t['stage_id']);
|
| 260 |
+
$by_stage[ $sn ] = ( $by_stage[ $sn ] ?? 0 ) + 1;
|
| 261 |
+
$by_status[ $t['status'] ] = ( $by_status[ $t['status'] ] ?? 0 ) + 1;
|
| 262 |
+
|
| 263 |
+
if ( $t['status'] === 'closed' && $t['last_completed_at'] ) {
|
| 264 |
+
$ts = strtotime( $t['last_completed_at'] );
|
| 265 |
+
if ( $ts && $now - $ts <= $week ) {
|
| 266 |
+
$closed_recent[] = $t;
|
| 267 |
+
}
|
| 268 |
+
}
|
| 269 |
+
if ( $t['status'] !== 'closed' ) {
|
| 270 |
+
$open_now[] = $t;
|
| 271 |
+
}
|
| 272 |
+
if ( strpos( strtolower( (string) $t['title'] ), '[queue]' ) !== false ) {
|
| 273 |
+
$deferred[] = $t;
|
| 274 |
+
}
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
return [
|
| 278 |
+
'task_count' => count( $tasks ),
|
| 279 |
+
'by_stage' => $by_stage,
|
| 280 |
+
'by_status' => $by_status,
|
| 281 |
+
'closed_in_week' => count( $closed_recent ),
|
| 282 |
+
'open_now' => count( $open_now ),
|
| 283 |
+
'deferred' => count( $deferred ),
|
| 284 |
+
'closed_recent_titles' => array_map( function ( $t ) {
|
| 285 |
+
return [ 'id' => (int) $t['id'], 'title' => (string) $t['title'] ];
|
| 286 |
+
}, $closed_recent ),
|
| 287 |
+
'open_now_titles' => array_map( function ( $t ) {
|
| 288 |
+
return [ 'id' => (int) $t['id'], 'title' => (string) $t['title'] ];
|
| 289 |
+
}, $open_now ),
|
| 290 |
+
];
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
private static function format_momentum_for_prompt( array $board, array $momentum, array $tasks, array $slabel ): string {
|
| 294 |
+
$out = "Project: {$board['title']} (board_id={$board['id']})\n";
|
| 295 |
+
$out .= "Total tasks: {$momentum['task_count']}\n";
|
| 296 |
+
$out .= "Closed in last 7 days: {$momentum['closed_in_week']}\n";
|
| 297 |
+
$out .= "Currently open: {$momentum['open_now']}\n";
|
| 298 |
+
$out .= "Deferred ([QUEUE]-tagged): {$momentum['deferred']}\n";
|
| 299 |
+
$out .= "\n";
|
| 300 |
+
$out .= "By stage:\n";
|
| 301 |
+
foreach ( $momentum['by_stage'] as $k => $n ) {
|
| 302 |
+
$out .= " {$k}: {$n}\n";
|
| 303 |
+
}
|
| 304 |
+
$out .= "\n";
|
| 305 |
+
$out .= "Recently closed (last 7 days):\n";
|
| 306 |
+
if ( empty( $momentum['closed_recent_titles'] ) ) {
|
| 307 |
+
$out .= " (none)\n";
|
| 308 |
+
} else {
|
| 309 |
+
foreach ( $momentum['closed_recent_titles'] as $r ) {
|
| 310 |
+
$out .= " - [#{$r['id']}] {$r['title']}\n";
|
| 311 |
+
}
|
| 312 |
+
}
|
| 313 |
+
$out .= "\n";
|
| 314 |
+
$out .= "Currently open:\n";
|
| 315 |
+
if ( empty( $momentum['open_now_titles'] ) ) {
|
| 316 |
+
$out .= " (none)\n";
|
| 317 |
+
} else {
|
| 318 |
+
foreach ( $momentum['open_now_titles'] as $r ) {
|
| 319 |
+
$out .= " - [#{$r['id']}] {$r['title']}\n";
|
| 320 |
+
}
|
| 321 |
+
}
|
| 322 |
+
$out .= "\n";
|
| 323 |
+
return $out;
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
/**
|
| 327 |
+
* Build the final seed markdown:
|
| 328 |
+
* - YAML frontmatter (artifact_class: seed; accepted_into_asterion: false)
|
| 329 |
+
* - Header with project + generation context
|
| 330 |
+
* - Momentum block (deterministic stats)
|
| 331 |
+
* - Sara synthesis (4 fields rendered as headed sections)
|
| 332 |
+
* - Footer noting non-canonical status + planting instructions
|
| 333 |
+
*
|
| 334 |
+
* artifact_class is intentionally NOT truth_class. Seeds are non-canonical
|
| 335 |
+
* proposed candidates predating truth classification. I2's enum stays
|
| 336 |
+
* sacred (canonical | projected | cached | inferred | derived | synthetic).
|
| 337 |
+
*/
|
| 338 |
+
private static function compose_markdown( array $board, string $project_slug, string $time_utc, $log_id, array $momentum, array $sara_resp ): string {
|
| 339 |
+
$title = (string) $board['title'];
|
| 340 |
+
$bid = (int) $board['id'];
|
| 341 |
+
$log = $log_id !== null ? (int) $log_id : 'null';
|
| 342 |
+
|
| 343 |
+
$md = "---\n";
|
| 344 |
+
$md .= "artifact_class: seed\n";
|
| 345 |
+
$md .= "accepted_into_asterion: false\n";
|
| 346 |
+
$md .= "project: " . self::yaml_escape( $title ) . "\n";
|
| 347 |
+
$md .= "project_slug: {$project_slug}\n";
|
| 348 |
+
$md .= "board_id: {$bid}\n";
|
| 349 |
+
$md .= "generated_at: {$time_utc}\n";
|
| 350 |
+
$md .= "sara_log_id: {$log}\n";
|
| 351 |
+
$md .= "generator: SA_Orch_Seed_Generator\n";
|
| 352 |
+
$md .= "---\n\n";
|
| 353 |
+
|
| 354 |
+
$md .= "# Asterion seed: {$title}\n\n";
|
| 355 |
+
$md .= "> **Non-canonical.** This file is a Sara-generated seed packet for human review.\n";
|
| 356 |
+
$md .= "> It has not been planted into the Asterion canonical spec corpus.\n";
|
| 357 |
+
$md .= "> Promotion is a separate, deliberate act outside this generator's scope.\n\n";
|
| 358 |
+
|
| 359 |
+
$md .= "## Project momentum (deterministic)\n\n";
|
| 360 |
+
$md .= "- Total tasks: {$momentum['task_count']}\n";
|
| 361 |
+
$md .= "- Closed in last 7 days: {$momentum['closed_in_week']}\n";
|
| 362 |
+
$md .= "- Currently open: {$momentum['open_now']}\n";
|
| 363 |
+
$md .= "- Deferred ([QUEUE]-tagged): {$momentum['deferred']}\n\n";
|
| 364 |
+
$md .= "By stage:\n\n";
|
| 365 |
+
foreach ( $momentum['by_stage'] as $k => $n ) {
|
| 366 |
+
$md .= "- `{$k}`: {$n}\n";
|
| 367 |
+
}
|
| 368 |
+
$md .= "\n";
|
| 369 |
+
|
| 370 |
+
$md .= "## Sara synthesis (interpretive)\n\n";
|
| 371 |
+
|
| 372 |
+
$sum = trim( (string) ( $sara_resp['summary'] ?? '' ) );
|
| 373 |
+
$sug = trim( (string) ( $sara_resp['suggestion'] ?? '' ) );
|
| 374 |
+
$drf = trim( (string) ( $sara_resp['draft'] ?? '' ) );
|
| 375 |
+
$note = trim( (string) ( $sara_resp['notes'] ?? '' ) );
|
| 376 |
+
|
| 377 |
+
$md .= "### Thesis\n\n";
|
| 378 |
+
$md .= ( $sum !== '' ? $sum : '_(none)_' ) . "\n\n";
|
| 379 |
+
|
| 380 |
+
$md .= "### Architectural observation\n\n";
|
| 381 |
+
$md .= ( $sug !== '' ? $sug : '_(none)_' ) . "\n\n";
|
| 382 |
+
|
| 383 |
+
$md .= "### Proposed Asterion addition(s)\n\n";
|
| 384 |
+
$md .= ( $drf !== '' ? $drf : '_(none)_' ) . "\n\n";
|
| 385 |
+
|
| 386 |
+
$md .= "### Open questions / deferred / flags\n\n";
|
| 387 |
+
$md .= ( $note !== '' ? $note : '_(none)_' ) . "\n\n";
|
| 388 |
+
|
| 389 |
+
$md .= "---\n\n";
|
| 390 |
+
$md .= "## Planting (manual)\n\n";
|
| 391 |
+
$md .= "If accepted: review the proposed additions above, copy any that survive review into\n";
|
| 392 |
+
$md .= "`Archvie/sa-core/docs/specs/` (the canonical corpus), commit, and update this seed's\n";
|
| 393 |
+
$md .= "frontmatter `accepted_into_asterion: true` (the generator will not flip that flag).\n";
|
| 394 |
+
$md .= "If rejected: the seed remains in `wp-content/sa-asterion-seeds/` as a record that the\n";
|
| 395 |
+
$md .= "synthesis was attempted; nothing in canonical Asterion changes.\n";
|
| 396 |
+
|
| 397 |
+
return $md;
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
private static function yaml_escape( string $s ): string {
|
| 401 |
+
// YAML-safe single-line: wrap in double quotes, escape backslash + quotes.
|
| 402 |
+
$s = str_replace( [ '\\', '"' ], [ '\\\\', '\\"' ], $s );
|
| 403 |
+
return '"' . $s . '"';
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
/* -------------------- Listing -------------------- */
|
| 407 |
+
|
| 408 |
+
/**
|
| 409 |
+
* List existing seed files (filesystem-only; no index table per question (g)).
|
| 410 |
+
*/
|
| 411 |
+
public static function list_seeds(): array {
|
| 412 |
+
$root = self::seeds_root();
|
| 413 |
+
if ( ! is_dir( $root ) ) return [];
|
| 414 |
+
$out = [];
|
| 415 |
+
$projects = @scandir( $root );
|
| 416 |
+
if ( $projects === false ) return [];
|
| 417 |
+
foreach ( $projects as $p ) {
|
| 418 |
+
if ( $p === '.' || $p === '..' ) continue;
|
| 419 |
+
$abs = $root . '/' . $p;
|
| 420 |
+
if ( ! is_dir( $abs ) ) continue;
|
| 421 |
+
$files = @scandir( $abs );
|
| 422 |
+
if ( $files === false ) continue;
|
| 423 |
+
foreach ( $files as $f ) {
|
| 424 |
+
if ( $f === '.' || $f === '..' ) continue;
|
| 425 |
+
if ( substr( strtolower( $f ), -3 ) !== '.md' ) continue;
|
| 426 |
+
$rel = $p . '/' . $f;
|
| 427 |
+
$st = @stat( $abs . '/' . $f );
|
| 428 |
+
$out[] = [
|
| 429 |
+
'project_slug' => $p,
|
| 430 |
+
'file' => $f,
|
| 431 |
+
'relative' => $rel,
|
| 432 |
+
'size' => $st ? (int) $st['size'] : 0,
|
| 433 |
+
'mtime' => $st ? gmdate( 'Y-m-d\TH:i:s\Z', (int) $st['mtime'] ) : '',
|
| 434 |
+
];
|
| 435 |
+
}
|
| 436 |
+
}
|
| 437 |
+
usort( $out, function ( $a, $b ) { return strcmp( $b['mtime'], $a['mtime'] ); } );
|
| 438 |
+
return $out;
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
/* -------------------- Admin page -------------------- */
|
| 442 |
+
|
| 443 |
+
public static function render_page() {
|
| 444 |
+
if ( ! current_user_can( SA_Orch_Admin::CAP ) ) wp_die( 'Insufficient permissions.' );
|
| 445 |
+
|
| 446 |
+
// Handle synchronous form submission
|
| 447 |
+
$result = null;
|
| 448 |
+
$error = '';
|
| 449 |
+
if ( isset( $_POST['sa_orch_seed_generate'] ) && check_admin_referer( 'sa_orch_seed_generate' ) ) {
|
| 450 |
+
$bid = isset( $_POST['board_id'] ) ? (int) $_POST['board_id'] : 0;
|
| 451 |
+
$topic = isset( $_POST['topic'] ) ? sanitize_text_field( wp_unslash( $_POST['topic'] ) ) : '';
|
| 452 |
+
if ( $bid <= 0 ) {
|
| 453 |
+
$error = 'board_id required';
|
| 454 |
+
} else {
|
| 455 |
+
$r = self::generate( $bid, $topic );
|
| 456 |
+
if ( is_wp_error( $r ) ) {
|
| 457 |
+
$error = $r->get_error_message();
|
| 458 |
+
} else {
|
| 459 |
+
$result = $r;
|
| 460 |
+
}
|
| 461 |
+
}
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
global $wpdb;
|
| 465 |
+
$boards = $wpdb->get_results( "SELECT id, title FROM {$wpdb->prefix}fbs_boards ORDER BY id", ARRAY_A );
|
| 466 |
+
$seeds = self::list_seeds();
|
| 467 |
+
|
| 468 |
+
echo '<div class="wrap">';
|
| 469 |
+
echo '<h1>Asterion Seeds <span style="font-size:13px;color:#777;font-weight:400;">— non-canonical seed packets generated by Sara</span></h1>';
|
| 470 |
+
|
| 471 |
+
echo '<p><em>Sara generates. Asterion validates. Human plants.</em></p>';
|
| 472 |
+
echo '<p>Seed root: <code>' . esc_html( self::seeds_root() ) . '</code></p>';
|
| 473 |
+
|
| 474 |
+
if ( $error !== '' ) {
|
| 475 |
+
echo '<div class="notice notice-error"><p>' . esc_html( $error ) . '</p></div>';
|
| 476 |
+
}
|
| 477 |
+
if ( $result ) {
|
| 478 |
+
echo '<div class="notice notice-success"><p>Seed written to <code>' . esc_html( $result['relative'] ) . '</code> ('
|
| 479 |
+
. (int) $result['bytes'] . ' bytes).</p></div>';
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
echo '<h2>Generate a new seed</h2>';
|
| 483 |
+
echo '<form method="post">';
|
| 484 |
+
wp_nonce_field( 'sa_orch_seed_generate' );
|
| 485 |
+
echo '<table class="form-table">';
|
| 486 |
+
echo '<tr><th><label for="sa-orch-seed-board">Project (Fluent Board)</label></th><td>';
|
| 487 |
+
echo '<select id="sa-orch-seed-board" name="board_id" required>';
|
| 488 |
+
echo '<option value="">— select board —</option>';
|
| 489 |
+
foreach ( $boards as $b ) {
|
| 490 |
+
echo '<option value="' . (int) $b['id'] . '">[#' . (int) $b['id'] . '] ' . esc_html( $b['title'] ) . '</option>';
|
| 491 |
+
}
|
| 492 |
+
echo '</select>';
|
| 493 |
+
echo '</td></tr>';
|
| 494 |
+
echo '<tr><th><label for="sa-orch-seed-topic">Topic (optional, becomes filename slug)</label></th><td>';
|
| 495 |
+
echo '<input id="sa-orch-seed-topic" name="topic" type="text" class="regular-text" placeholder="e.g. midweek-momentum">';
|
| 496 |
+
echo '</td></tr>';
|
| 497 |
+
echo '</table>';
|
| 498 |
+
echo '<p><button type="submit" name="sa_orch_seed_generate" value="1" class="button button-primary">Generate seed</button></p>';
|
| 499 |
+
echo '<p style="color:#666;font-size:12px;">Generation calls the configured LLM provider (Sara). One LLM call per generation; cost logged in <code>wp_sa_token_log</code>.</p>';
|
| 500 |
+
echo '</form>';
|
| 501 |
+
|
| 502 |
+
echo '<h2>Existing seeds (' . count( $seeds ) . ')</h2>';
|
| 503 |
+
if ( empty( $seeds ) ) {
|
| 504 |
+
echo '<p>No seeds generated yet.</p>';
|
| 505 |
+
} else {
|
| 506 |
+
echo '<table class="widefat striped"><thead><tr>';
|
| 507 |
+
echo '<th>Project</th><th>File</th><th>Size</th><th>Generated (UTC)</th>';
|
| 508 |
+
echo '</tr></thead><tbody>';
|
| 509 |
+
foreach ( $seeds as $s ) {
|
| 510 |
+
echo '<tr>';
|
| 511 |
+
echo '<td><code>' . esc_html( $s['project_slug'] ) . '</code></td>';
|
| 512 |
+
echo '<td><code>' . esc_html( $s['file'] ) . '</code></td>';
|
| 513 |
+
echo '<td>' . (int) $s['size'] . '</td>';
|
| 514 |
+
echo '<td>' . esc_html( $s['mtime'] ) . '</td>';
|
| 515 |
+
echo '</tr>';
|
| 516 |
+
}
|
| 517 |
+
echo '</tbody></table>';
|
| 518 |
+
echo '<p style="color:#666;font-size:12px;">Files live on disk under the seed root. No DB index (per resolved question (g)).</p>';
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
echo '</div>';
|
| 522 |
+
}
|
| 523 |
+
}
|
sa-orchestration/includes/class-sa-strategy.php
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Strategy LLM Review orchestrator.
|
| 6 |
+
*
|
| 7 |
+
* Layer 3 of the review stack. Delegates to the active provider (resolved
|
| 8 |
+
* via SA_Orch_LLM_Providers::get_active_provider()) and adds metadata
|
| 9 |
+
* about which provider produced the result.
|
| 10 |
+
*
|
| 11 |
+
* Constraints (enforced by contract, not code):
|
| 12 |
+
* - The Strategy Review NEVER overrides closure_state or
|
| 13 |
+
* needs_human_arbitration. Those are governed by the deterministic
|
| 14 |
+
* audit and ultimately by human arbitration.
|
| 15 |
+
* - Strategy Review is purely advisory.
|
| 16 |
+
*/
|
| 17 |
+
class SA_Orch_Strategy {
|
| 18 |
+
|
| 19 |
+
/**
|
| 20 |
+
* Run a Strategy Review against the trace + deterministic audit.
|
| 21 |
+
*
|
| 22 |
+
* Returns the provider's review_trace() output augmented with:
|
| 23 |
+
* - provider_id (string)
|
| 24 |
+
* - provider_name (string)
|
| 25 |
+
* - provider_error (string, optional — present if the provider threw)
|
| 26 |
+
*/
|
| 27 |
+
public static function review( array $trace, array $deterministic_audit ): array {
|
| 28 |
+
$configured = SA_Orch_LLM_Providers::get_active_provider();
|
| 29 |
+
$configured_id = $configured->id();
|
| 30 |
+
$configured_name = $configured->name();
|
| 31 |
+
|
| 32 |
+
$fallback_used = false;
|
| 33 |
+
$error_message = null;
|
| 34 |
+
$used = $configured;
|
| 35 |
+
|
| 36 |
+
try {
|
| 37 |
+
$review = $configured->review_trace( $trace, $deterministic_audit );
|
| 38 |
+
} catch ( \Throwable $e ) {
|
| 39 |
+
// Fallback to simulated on any error from the configured provider
|
| 40 |
+
$fallback = new SA_Orch_LLM_Provider_Simulated();
|
| 41 |
+
$review = $fallback->review_trace( $trace, $deterministic_audit );
|
| 42 |
+
$error_message = $e->getMessage();
|
| 43 |
+
$fallback_used = true;
|
| 44 |
+
$used = $fallback;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
// Defensive: enforce required keys
|
| 48 |
+
$review = wp_parse_args( $review, [
|
| 49 |
+
'summary' => '',
|
| 50 |
+
'agreement_with_audit' => 'partial',
|
| 51 |
+
'additional_risks' => [],
|
| 52 |
+
'suggested_next_action' => '',
|
| 53 |
+
'confidence' => 'low',
|
| 54 |
+
] );
|
| 55 |
+
|
| 56 |
+
// Conservative confidence floor (v0.5.2): when the deterministic audit
|
| 57 |
+
// cannot evaluate closure (closure_confidence='n/a' — i.e. no acceptance
|
| 58 |
+
// events recorded), the strategy layer MUST collapse to 'low'. Prevents
|
| 59 |
+
// LLM-shaped over-confidence on Demands with no acceptance evidence.
|
| 60 |
+
// Cross-cutting: applied here so any provider (including future ones)
|
| 61 |
+
// gets it for free, regardless of what the model returned.
|
| 62 |
+
if ( ( $deterministic_audit['closure_confidence'] ?? '' ) === 'n/a'
|
| 63 |
+
&& in_array( $review['confidence'], [ 'medium', 'high' ], true ) ) {
|
| 64 |
+
$review['confidence'] = 'low';
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
// Provider used to actually produce this output
|
| 68 |
+
$review['provider_id'] = $used->id();
|
| 69 |
+
$review['provider_name'] = $used->name();
|
| 70 |
+
|
| 71 |
+
// Configured provider (what the operator selected)
|
| 72 |
+
$review['configured_provider_id'] = $configured_id;
|
| 73 |
+
$review['configured_provider_name'] = $configured_name;
|
| 74 |
+
|
| 75 |
+
// Fallback transparency
|
| 76 |
+
$review['fallback_used'] = $fallback_used;
|
| 77 |
+
if ( $error_message !== null ) {
|
| 78 |
+
$review['provider_error'] = $error_message;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
return $review;
|
| 82 |
+
}
|
| 83 |
+
}
|
sa-orchestration/includes/class-sa-token-log.php
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Token Log — append-only audit of LLM invocations (Board #16).
|
| 6 |
+
*
|
| 7 |
+
* Each call to an LLM provider (OpenAI today; pluggable) writes one row.
|
| 8 |
+
* The row optionally links to a downstream actor-authored comment via
|
| 9 |
+
* (comment_kind, comment_id) once the future invocation surface ships
|
| 10 |
+
* and starts attributing AI-driven commits to actors.
|
| 11 |
+
*
|
| 12 |
+
* Two consumers:
|
| 13 |
+
* 1. Sara UI — reads usage from the live response (NOT from this table)
|
| 14 |
+
* to render per-turn + session totals in the bubble. Session total is
|
| 15 |
+
* JS-only; this table is server-side audit.
|
| 16 |
+
* 2. Board #23 handoff layer — its filter callback queries
|
| 17 |
+
* has_token_for_comment(kind, comment_id) to resolve operator mode.
|
| 18 |
+
* Returns true if a row matches, false otherwise. Always deterministic.
|
| 19 |
+
*
|
| 20 |
+
* Doctrine constraints honored:
|
| 21 |
+
* - No cost calculation. No analytics. No replay.
|
| 22 |
+
* - Server-side persistence is for cross-session attribution (the #23
|
| 23 |
+
* hook). The Sara UI counters remain session-only as locked.
|
| 24 |
+
* - Strictly additive: one new table; no existing table mutated.
|
| 25 |
+
*/
|
| 26 |
+
class SA_Orch_Token_Log {
|
| 27 |
+
|
| 28 |
+
private static function table(): string {
|
| 29 |
+
global $wpdb;
|
| 30 |
+
return $wpdb->prefix . SA_ORCH_DB_PREFIX . 'token_log';
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
/**
|
| 34 |
+
* Record one LLM invocation. Returns the new row id, or 0 on failure.
|
| 35 |
+
*
|
| 36 |
+
* @param string $provider e.g. 'openai'
|
| 37 |
+
* @param string $model e.g. 'gpt-4o-mini'
|
| 38 |
+
* @param array $usage [ prompt_tokens, completion_tokens, total_tokens ]
|
| 39 |
+
* @param int $user_id the WP user_id who triggered the call (or 0)
|
| 40 |
+
*/
|
| 41 |
+
public static function record( string $provider, string $model, array $usage, int $user_id = 0 ): int {
|
| 42 |
+
if ( $provider === '' ) return 0;
|
| 43 |
+
global $wpdb;
|
| 44 |
+
$ok = $wpdb->insert( self::table(), [
|
| 45 |
+
'user_id' => $user_id > 0 ? $user_id : null,
|
| 46 |
+
'provider' => $provider,
|
| 47 |
+
'model' => $model,
|
| 48 |
+
'prompt_tokens' => isset( $usage['prompt_tokens'] ) ? (int) $usage['prompt_tokens'] : 0,
|
| 49 |
+
'completion_tokens' => isset( $usage['completion_tokens'] ) ? (int) $usage['completion_tokens'] : 0,
|
| 50 |
+
'total_tokens' => isset( $usage['total_tokens'] ) ? (int) $usage['total_tokens'] : 0,
|
| 51 |
+
'comment_kind' => null,
|
| 52 |
+
'comment_id' => null,
|
| 53 |
+
'invoked_at' => current_time( 'mysql', true ),
|
| 54 |
+
] );
|
| 55 |
+
return $ok !== false ? (int) $wpdb->insert_id : 0;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/**
|
| 59 |
+
* Link a previously-recorded token row to the comment it produced.
|
| 60 |
+
* Called by the future invocation surface when an actor-attributed
|
| 61 |
+
* comment is committed from a Sara draft.
|
| 62 |
+
*
|
| 63 |
+
* Idempotent on the row's identity — re-linking just updates the row.
|
| 64 |
+
*/
|
| 65 |
+
public static function link_to_comment( int $log_id, string $kind, int $comment_id ): bool {
|
| 66 |
+
if ( $log_id <= 0 || $comment_id <= 0 ) return false;
|
| 67 |
+
$kind = sanitize_key( $kind );
|
| 68 |
+
if ( $kind === '' ) return false;
|
| 69 |
+
|
| 70 |
+
global $wpdb;
|
| 71 |
+
$ok = $wpdb->update(
|
| 72 |
+
self::table(),
|
| 73 |
+
[
|
| 74 |
+
'comment_kind' => $kind,
|
| 75 |
+
'comment_id' => $comment_id,
|
| 76 |
+
],
|
| 77 |
+
[ 'id' => $log_id ]
|
| 78 |
+
);
|
| 79 |
+
return $ok !== false;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
/**
|
| 83 |
+
* Filter callback for Board #23's 'sa_orch_handoff_has_llm_token'.
|
| 84 |
+
* Returns true if at least one token row is linked to (kind, comment_id),
|
| 85 |
+
* false otherwise. Always deterministic — no nulls.
|
| 86 |
+
*/
|
| 87 |
+
public static function has_token_for_comment( string $kind, int $comment_id ): bool {
|
| 88 |
+
if ( $comment_id <= 0 ) return false;
|
| 89 |
+
$kind = sanitize_key( $kind );
|
| 90 |
+
if ( $kind === '' ) return false;
|
| 91 |
+
|
| 92 |
+
global $wpdb;
|
| 93 |
+
$count = (int) $wpdb->get_var( $wpdb->prepare(
|
| 94 |
+
"SELECT COUNT(*) FROM " . self::table() . "
|
| 95 |
+
WHERE comment_kind = %s AND comment_id = %d",
|
| 96 |
+
$kind,
|
| 97 |
+
$comment_id
|
| 98 |
+
) );
|
| 99 |
+
return $count > 0;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/**
|
| 103 |
+
* Bridge to the Board #23 hook. Hooked at plugin load via add_filter.
|
| 104 |
+
* Replaces the default 'unknown' (null) with a deterministic boolean.
|
| 105 |
+
*
|
| 106 |
+
* @param mixed $default The default value (typically null).
|
| 107 |
+
* @param string $kind Comment kind constant.
|
| 108 |
+
* @param int $comment_id Comment id.
|
| 109 |
+
* @return bool
|
| 110 |
+
*/
|
| 111 |
+
public static function answer_handoff_filter( $default, string $kind, int $comment_id ): bool {
|
| 112 |
+
return self::has_token_for_comment( $kind, $comment_id );
|
| 113 |
+
}
|
| 114 |
+
}
|
sa-orchestration/includes/interface-sa-llm-provider.php
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Provider contract for the Strategy LLM Review layer.
|
| 6 |
+
*
|
| 7 |
+
* Layer 3 of the review stack:
|
| 8 |
+
* 1. Trace data
|
| 9 |
+
* 2. Deterministic Trace Audit
|
| 10 |
+
* 3. Strategy LLM Review (this contract; v0.5 = simulated only)
|
| 11 |
+
* 4. Human arbitration
|
| 12 |
+
*
|
| 13 |
+
* Implementations of this interface must:
|
| 14 |
+
* - Treat the deterministic audit as authoritative for closure_state and
|
| 15 |
+
* needs_human_arbitration. They MUST NOT override either.
|
| 16 |
+
* - Return a five-key advisory shape (see review_trace docblock).
|
| 17 |
+
* - Be local-dev safe. No external calls unless the operator has explicitly
|
| 18 |
+
* enabled and configured a network-backed provider.
|
| 19 |
+
*/
|
| 20 |
+
interface SA_Orch_LLM_Provider_Interface {
|
| 21 |
+
|
| 22 |
+
/** Stable id used in settings ('simulated', 'openai', 'anthropic', etc.). */
|
| 23 |
+
public function id(): string;
|
| 24 |
+
|
| 25 |
+
/** Human-readable name shown in the admin dropdown. */
|
| 26 |
+
public function name(): string;
|
| 27 |
+
|
| 28 |
+
/** Whether this provider needs an API token to function. */
|
| 29 |
+
public function requires_api_key(): bool;
|
| 30 |
+
|
| 31 |
+
/**
|
| 32 |
+
* Produce a strategy review of the trace.
|
| 33 |
+
*
|
| 34 |
+
* Input:
|
| 35 |
+
* $trace — output of SA_Orch_Rest::get_demand_trace().
|
| 36 |
+
* $deterministic_audit — output of SA_Orch_Reasoning::evaluate( $trace ).
|
| 37 |
+
*
|
| 38 |
+
* Output (required keys):
|
| 39 |
+
* [
|
| 40 |
+
* 'summary' => string, // narrative
|
| 41 |
+
* 'agreement_with_audit' => 'true'|'false'|'partial',
|
| 42 |
+
* 'additional_risks' => string[], // risks not caught by audit
|
| 43 |
+
* 'suggested_next_action' => string,
|
| 44 |
+
* 'confidence' => 'low'|'medium'|'high',
|
| 45 |
+
* ]
|
| 46 |
+
*/
|
| 47 |
+
public function review_trace( array $trace, array $deterministic_audit ): array;
|
| 48 |
+
}
|
sa-orchestration/includes/interface-sa-surface-adapter.php
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
defined( 'ABSPATH' ) || exit;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Surface adapter contract.
|
| 6 |
+
*
|
| 7 |
+
* Asterion binds to surface adapters, not tables. External schema
|
| 8 |
+
* assumptions are bounded to adapter implementations; consumers of
|
| 9 |
+
* the contract (the orchestration layer) only see the contract.
|
| 10 |
+
*
|
| 11 |
+
* Each surface (fluent-support, fluent-boards, fluentcrm, derived,
|
| 12 |
+
* future) has — or will have — a concrete implementation of this
|
| 13 |
+
* interface. The implementation encapsulates all schema-specific
|
| 14 |
+
* resolution. If the external surface's schema changes, the change
|
| 15 |
+
* lands in one file (the adapter), not across every caller.
|
| 16 |
+
*
|
| 17 |
+
* Contract guarantees:
|
| 18 |
+
* - id(): the surface identifier this adapter handles
|
| 19 |
+
* - validate(): is this ref shape-correct AND resolvable?
|
| 20 |
+
* - resolve(): the underlying surface row, or null
|
| 21 |
+
* - label(): a human-readable label for the row
|
| 22 |
+
* - hydrate(): structured snapshot for downstream consumption (Sara, etc.)
|
| 23 |
+
*
|
| 24 |
+
* Out-of-scope for this interface:
|
| 25 |
+
* - mutation of surface state (orchestration must not mutate Fluent)
|
| 26 |
+
* - paginated listing (a separate adapter capability if needed)
|
| 27 |
+
*/
|
| 28 |
+
interface SA_Orch_Surface_Adapter {
|
| 29 |
+
|
| 30 |
+
/** Surface identifier (e.g. 'fluent-boards', 'fluent-support'). */
|
| 31 |
+
public function id(): string;
|
| 32 |
+
|
| 33 |
+
/**
|
| 34 |
+
* Is the ref shape-correct AND resolvable to a real row under this surface?
|
| 35 |
+
* Returns true only if both conditions hold. Pure read; no side effects.
|
| 36 |
+
*/
|
| 37 |
+
public function validate( string $ref ): bool;
|
| 38 |
+
|
| 39 |
+
/**
|
| 40 |
+
* Resolve the ref to the underlying surface row, or null if missing.
|
| 41 |
+
* The shape of the returned object is implementation-defined; consumers
|
| 42 |
+
* should access fields only through the adapter's other methods (label,
|
| 43 |
+
* hydrate) rather than touching schema directly.
|
| 44 |
+
*/
|
| 45 |
+
public function resolve( string $ref );
|
| 46 |
+
|
| 47 |
+
/** Human-readable label for the row, falling back to the ref if unresolvable. */
|
| 48 |
+
public function label( string $ref ): string;
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* Structured snapshot of the bound object — title, state, description
|
| 52 |
+
* summary, recent activity, etc. — shaped for downstream consumers (Sara).
|
| 53 |
+
* Returns null if the ref does not resolve. The implementation decides
|
| 54 |
+
* which fields are meaningful for its surface; consumers should treat
|
| 55 |
+
* any field as optional and tolerate missing/empty values.
|
| 56 |
+
*
|
| 57 |
+
* Conventional keys (all optional): ref, title, status, stage, priority,
|
| 58 |
+
* board_name, customer, description, recent_activity (array of items;
|
| 59 |
+
* each item has at minimum { created_at, excerpt } and may also include
|
| 60 |
+
* optional 'by' for the author of the activity).
|
| 61 |
+
*/
|
| 62 |
+
public function hydrate( string $ref ): ?array;
|
| 63 |
+
}
|
sa-orchestration/sa-orchestration.php
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
/**
|
| 3 |
+
* Plugin Name: SA-Orchestration
|
| 4 |
+
* Description: Arbitration record + Demand→Plan linkage + append-only acceptance events. First slice of SA-Core / MissionNet alignment layer above the Fluent stack.
|
| 5 |
+
* Version: 0.7.2
|
| 6 |
+
* Author: Mark Holak
|
| 7 |
+
* License: GPL-2.0-or-later
|
| 8 |
+
* Text Domain: sa-orchestration
|
| 9 |
+
*
|
| 10 |
+
* Asterion (markdown corpus) is canonical-at-write; this DB layer is cache.
|
| 11 |
+
* If runtime ≠ Asterion, runtime is wrong. See SA-orchestration/CORE_MODEL.md.
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
defined( 'ABSPATH' ) || exit;
|
| 15 |
+
|
| 16 |
+
define( 'SA_ORCH_VERSION', '0.7.3' );
|
| 17 |
+
define( 'SA_ORCH_FILE', __FILE__ );
|
| 18 |
+
define( 'SA_ORCH_DIR', plugin_dir_path( __FILE__ ) );
|
| 19 |
+
define( 'SA_ORCH_URL', plugin_dir_url( __FILE__ ) );
|
| 20 |
+
define( 'SA_ORCH_DB_PREFIX', 'sa_' ); // tables become {wp_prefix}sa_*
|
| 21 |
+
|
| 22 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-migrations.php';
|
| 23 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-asterion.php';
|
| 24 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-bridge-fluent.php';
|
| 25 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-arbitration.php';
|
| 26 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-link.php';
|
| 27 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-acceptance.php';
|
| 28 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-rest.php';
|
| 29 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-reasoning.php';
|
| 30 |
+
require_once SA_ORCH_DIR . 'includes/interface-sa-llm-provider.php';
|
| 31 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-llm-provider-simulated.php';
|
| 32 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-llm-provider-openai.php';
|
| 33 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-llm-settings.php';
|
| 34 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-llm-providers.php';
|
| 35 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-strategy.php';
|
| 36 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-projection-fluent-support.php';
|
| 37 |
+
require_once SA_ORCH_DIR . 'includes/interface-sa-surface-adapter.php';
|
| 38 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-adapter-fluent-boards.php';
|
| 39 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-adapter-fluent-support.php';
|
| 40 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-fluent-state.php';
|
| 41 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-asterion-explorer.php';
|
| 42 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-attention-bridge.php';
|
| 43 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-actor-registry.php';
|
| 44 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-actor-admin.php';
|
| 45 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-actor-handoff.php';
|
| 46 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-token-log.php';
|
| 47 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-env.php';
|
| 48 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-demand-registry.php';
|
| 49 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-sara-service.php';
|
| 50 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-sara-rest.php';
|
| 51 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-sara-admin.php';
|
| 52 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-asterion-projection.php'; // Board #30
|
| 53 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-seed-generator.php'; // Board #26
|
| 54 |
+
require_once SA_ORCH_DIR . 'includes/class-sa-admin.php';
|
| 55 |
+
|
| 56 |
+
register_activation_hook( __FILE__, [ 'SA_Orch_Migrations', 'install' ] );
|
| 57 |
+
|
| 58 |
+
add_action( 'rest_api_init', [ 'SA_Orch_Rest', 'register_routes' ] );
|
| 59 |
+
add_action( 'rest_api_init', [ 'SA_Orch_Sara_Rest', 'register_routes' ] );
|
| 60 |
+
add_action( 'rest_api_init', [ 'SA_Orch_Asterion_Projection', 'register_routes' ] ); // Board #30
|
| 61 |
+
add_action( 'rest_api_init', [ 'SA_Orch_Seed_Generator', 'register_routes' ] ); // Board #26
|
| 62 |
+
add_action( 'admin_menu', [ 'SA_Orch_Admin', 'register_menu' ] );
|
| 63 |
+
add_action( 'admin_menu', [ 'SA_Orch_Asterion_Explorer', 'register_menu' ] );
|
| 64 |
+
add_action( 'admin_menu', [ 'SA_Orch_Actor_Admin', 'register_menu' ] );
|
| 65 |
+
add_action( 'admin_menu', [ 'SA_Orch_Seed_Generator', 'register_menu' ] ); // Board #26
|
| 66 |
+
add_action( 'init', [ 'SA_Orch_Actor_Registry', 'register_role' ] );
|
| 67 |
+
add_action( 'admin_init', [ 'SA_Orch_Actor_Admin', 'maybe_handle_action' ] );
|
| 68 |
+
add_action( 'init', [ 'SA_Orch_Projection_Fluent_Support', 'register_hooks' ] );
|
| 69 |
+
add_action( 'init', [ 'SA_Orch_Sara_Admin', 'register_hooks' ] );
|
| 70 |
+
add_action( 'init', [ 'SA_Orch_Asterion_Projection', 'register_hooks' ] ); // Board #30
|
| 71 |
+
add_action( 'init', [ 'SA_Orch_Attention_Bridge', 'register_hooks' ] );
|
| 72 |
+
|
| 73 |
+
// Board #16 → Board #23 hook integration. Token Log answers the
|
| 74 |
+
// 'sa_orch_handoff_has_llm_token' filter with a deterministic boolean,
|
| 75 |
+
// turning Board #23's MODE_UNKNOWN into MODE_AI / MODE_HUMAN once a
|
| 76 |
+
// commit is linked to a token row.
|
| 77 |
+
add_filter( 'sa_orch_handoff_has_llm_token', [ 'SA_Orch_Token_Log', 'answer_handoff_filter' ], 10, 3 );
|
| 78 |
+
|
| 79 |
+
// Board #24 rev2 — Fluent Boards no-conflict dequeue compatibility.
|
| 80 |
+
//
|
| 81 |
+
// Fluent Boards' AdminMenuHandler registers a wp_print_scripts callback that
|
| 82 |
+
// strips any plugin script whose src is under plugins_url() and doesn't
|
| 83 |
+
// match an approved allowlist. Default allowlist: fluent-crm, fluent-boards.
|
| 84 |
+
// Our Sara/Attention scripts are neither, so they get dequeued AFTER our
|
| 85 |
+
// admin_enqueue_scripts call, before any <script> tag is rendered.
|
| 86 |
+
//
|
| 87 |
+
// We add 'sa-orchestration' to the allowlist via this filter. Surgical:
|
| 88 |
+
// it does NOT disable Fluent Boards' no-conflict mechanism (other plugins'
|
| 89 |
+
// scripts are still stripped), and it does NOT change any other behavior
|
| 90 |
+
// in Sara, Attention, or anything else. Just whitelists our plugin path.
|
| 91 |
+
add_filter( 'fluent_boards/asset_listed_slugs', function ( $slugs ) {
|
| 92 |
+
if ( ! is_array( $slugs ) ) $slugs = [];
|
| 93 |
+
if ( ! in_array( '\/sa-orchestration\/', $slugs, true ) ) {
|
| 94 |
+
$slugs[] = '\/sa-orchestration\/';
|
| 95 |
+
}
|
| 96 |
+
return $slugs;
|
| 97 |
+
} );
|
sa-orchestration/vendor/Parsedown.php
ADDED
|
@@ -0,0 +1,1712 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
|
| 3 |
+
#
|
| 4 |
+
#
|
| 5 |
+
# Parsedown
|
| 6 |
+
# http://parsedown.org
|
| 7 |
+
#
|
| 8 |
+
# (c) Emanuil Rusev
|
| 9 |
+
# http://erusev.com
|
| 10 |
+
#
|
| 11 |
+
# For the full license information, view the LICENSE file that was distributed
|
| 12 |
+
# with this source code.
|
| 13 |
+
#
|
| 14 |
+
#
|
| 15 |
+
|
| 16 |
+
class Parsedown
|
| 17 |
+
{
|
| 18 |
+
# ~
|
| 19 |
+
|
| 20 |
+
const version = '1.7.4';
|
| 21 |
+
|
| 22 |
+
# ~
|
| 23 |
+
|
| 24 |
+
function text($text)
|
| 25 |
+
{
|
| 26 |
+
# make sure no definitions are set
|
| 27 |
+
$this->DefinitionData = array();
|
| 28 |
+
|
| 29 |
+
# standardize line breaks
|
| 30 |
+
$text = str_replace(array("\r\n", "\r"), "\n", $text);
|
| 31 |
+
|
| 32 |
+
# remove surrounding line breaks
|
| 33 |
+
$text = trim($text, "\n");
|
| 34 |
+
|
| 35 |
+
# split text into lines
|
| 36 |
+
$lines = explode("\n", $text);
|
| 37 |
+
|
| 38 |
+
# iterate through lines to identify blocks
|
| 39 |
+
$markup = $this->lines($lines);
|
| 40 |
+
|
| 41 |
+
# trim line breaks
|
| 42 |
+
$markup = trim($markup, "\n");
|
| 43 |
+
|
| 44 |
+
return $markup;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
#
|
| 48 |
+
# Setters
|
| 49 |
+
#
|
| 50 |
+
|
| 51 |
+
function setBreaksEnabled($breaksEnabled)
|
| 52 |
+
{
|
| 53 |
+
$this->breaksEnabled = $breaksEnabled;
|
| 54 |
+
|
| 55 |
+
return $this;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
protected $breaksEnabled;
|
| 59 |
+
|
| 60 |
+
function setMarkupEscaped($markupEscaped)
|
| 61 |
+
{
|
| 62 |
+
$this->markupEscaped = $markupEscaped;
|
| 63 |
+
|
| 64 |
+
return $this;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
protected $markupEscaped;
|
| 68 |
+
|
| 69 |
+
function setUrlsLinked($urlsLinked)
|
| 70 |
+
{
|
| 71 |
+
$this->urlsLinked = $urlsLinked;
|
| 72 |
+
|
| 73 |
+
return $this;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
protected $urlsLinked = true;
|
| 77 |
+
|
| 78 |
+
function setSafeMode($safeMode)
|
| 79 |
+
{
|
| 80 |
+
$this->safeMode = (bool) $safeMode;
|
| 81 |
+
|
| 82 |
+
return $this;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
protected $safeMode;
|
| 86 |
+
|
| 87 |
+
protected $safeLinksWhitelist = array(
|
| 88 |
+
'http://',
|
| 89 |
+
'https://',
|
| 90 |
+
'ftp://',
|
| 91 |
+
'ftps://',
|
| 92 |
+
'mailto:',
|
| 93 |
+
'data:image/png;base64,',
|
| 94 |
+
'data:image/gif;base64,',
|
| 95 |
+
'data:image/jpeg;base64,',
|
| 96 |
+
'irc:',
|
| 97 |
+
'ircs:',
|
| 98 |
+
'git:',
|
| 99 |
+
'ssh:',
|
| 100 |
+
'news:',
|
| 101 |
+
'steam:',
|
| 102 |
+
);
|
| 103 |
+
|
| 104 |
+
#
|
| 105 |
+
# Lines
|
| 106 |
+
#
|
| 107 |
+
|
| 108 |
+
protected $BlockTypes = array(
|
| 109 |
+
'#' => array('Header'),
|
| 110 |
+
'*' => array('Rule', 'List'),
|
| 111 |
+
'+' => array('List'),
|
| 112 |
+
'-' => array('SetextHeader', 'Table', 'Rule', 'List'),
|
| 113 |
+
'0' => array('List'),
|
| 114 |
+
'1' => array('List'),
|
| 115 |
+
'2' => array('List'),
|
| 116 |
+
'3' => array('List'),
|
| 117 |
+
'4' => array('List'),
|
| 118 |
+
'5' => array('List'),
|
| 119 |
+
'6' => array('List'),
|
| 120 |
+
'7' => array('List'),
|
| 121 |
+
'8' => array('List'),
|
| 122 |
+
'9' => array('List'),
|
| 123 |
+
':' => array('Table'),
|
| 124 |
+
'<' => array('Comment', 'Markup'),
|
| 125 |
+
'=' => array('SetextHeader'),
|
| 126 |
+
'>' => array('Quote'),
|
| 127 |
+
'[' => array('Reference'),
|
| 128 |
+
'_' => array('Rule'),
|
| 129 |
+
'`' => array('FencedCode'),
|
| 130 |
+
'|' => array('Table'),
|
| 131 |
+
'~' => array('FencedCode'),
|
| 132 |
+
);
|
| 133 |
+
|
| 134 |
+
# ~
|
| 135 |
+
|
| 136 |
+
protected $unmarkedBlockTypes = array(
|
| 137 |
+
'Code',
|
| 138 |
+
);
|
| 139 |
+
|
| 140 |
+
#
|
| 141 |
+
# Blocks
|
| 142 |
+
#
|
| 143 |
+
|
| 144 |
+
protected function lines(array $lines)
|
| 145 |
+
{
|
| 146 |
+
$CurrentBlock = null;
|
| 147 |
+
|
| 148 |
+
foreach ($lines as $line)
|
| 149 |
+
{
|
| 150 |
+
if (chop($line) === '')
|
| 151 |
+
{
|
| 152 |
+
if (isset($CurrentBlock))
|
| 153 |
+
{
|
| 154 |
+
$CurrentBlock['interrupted'] = true;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
continue;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
if (strpos($line, "\t") !== false)
|
| 161 |
+
{
|
| 162 |
+
$parts = explode("\t", $line);
|
| 163 |
+
|
| 164 |
+
$line = $parts[0];
|
| 165 |
+
|
| 166 |
+
unset($parts[0]);
|
| 167 |
+
|
| 168 |
+
foreach ($parts as $part)
|
| 169 |
+
{
|
| 170 |
+
$shortage = 4 - mb_strlen($line, 'utf-8') % 4;
|
| 171 |
+
|
| 172 |
+
$line .= str_repeat(' ', $shortage);
|
| 173 |
+
$line .= $part;
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
$indent = 0;
|
| 178 |
+
|
| 179 |
+
while (isset($line[$indent]) and $line[$indent] === ' ')
|
| 180 |
+
{
|
| 181 |
+
$indent ++;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
$text = $indent > 0 ? substr($line, $indent) : $line;
|
| 185 |
+
|
| 186 |
+
# ~
|
| 187 |
+
|
| 188 |
+
$Line = array('body' => $line, 'indent' => $indent, 'text' => $text);
|
| 189 |
+
|
| 190 |
+
# ~
|
| 191 |
+
|
| 192 |
+
if (isset($CurrentBlock['continuable']))
|
| 193 |
+
{
|
| 194 |
+
$Block = $this->{'block'.$CurrentBlock['type'].'Continue'}($Line, $CurrentBlock);
|
| 195 |
+
|
| 196 |
+
if (isset($Block))
|
| 197 |
+
{
|
| 198 |
+
$CurrentBlock = $Block;
|
| 199 |
+
|
| 200 |
+
continue;
|
| 201 |
+
}
|
| 202 |
+
else
|
| 203 |
+
{
|
| 204 |
+
if ($this->isBlockCompletable($CurrentBlock['type']))
|
| 205 |
+
{
|
| 206 |
+
$CurrentBlock = $this->{'block'.$CurrentBlock['type'].'Complete'}($CurrentBlock);
|
| 207 |
+
}
|
| 208 |
+
}
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
# ~
|
| 212 |
+
|
| 213 |
+
$marker = $text[0];
|
| 214 |
+
|
| 215 |
+
# ~
|
| 216 |
+
|
| 217 |
+
$blockTypes = $this->unmarkedBlockTypes;
|
| 218 |
+
|
| 219 |
+
if (isset($this->BlockTypes[$marker]))
|
| 220 |
+
{
|
| 221 |
+
foreach ($this->BlockTypes[$marker] as $blockType)
|
| 222 |
+
{
|
| 223 |
+
$blockTypes []= $blockType;
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
#
|
| 228 |
+
# ~
|
| 229 |
+
|
| 230 |
+
foreach ($blockTypes as $blockType)
|
| 231 |
+
{
|
| 232 |
+
$Block = $this->{'block'.$blockType}($Line, $CurrentBlock);
|
| 233 |
+
|
| 234 |
+
if (isset($Block))
|
| 235 |
+
{
|
| 236 |
+
$Block['type'] = $blockType;
|
| 237 |
+
|
| 238 |
+
if ( ! isset($Block['identified']))
|
| 239 |
+
{
|
| 240 |
+
$Blocks []= $CurrentBlock;
|
| 241 |
+
|
| 242 |
+
$Block['identified'] = true;
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
if ($this->isBlockContinuable($blockType))
|
| 246 |
+
{
|
| 247 |
+
$Block['continuable'] = true;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
$CurrentBlock = $Block;
|
| 251 |
+
|
| 252 |
+
continue 2;
|
| 253 |
+
}
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
# ~
|
| 257 |
+
|
| 258 |
+
if (isset($CurrentBlock) and ! isset($CurrentBlock['type']) and ! isset($CurrentBlock['interrupted']))
|
| 259 |
+
{
|
| 260 |
+
$CurrentBlock['element']['text'] .= "\n".$text;
|
| 261 |
+
}
|
| 262 |
+
else
|
| 263 |
+
{
|
| 264 |
+
$Blocks []= $CurrentBlock;
|
| 265 |
+
|
| 266 |
+
$CurrentBlock = $this->paragraph($Line);
|
| 267 |
+
|
| 268 |
+
$CurrentBlock['identified'] = true;
|
| 269 |
+
}
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
# ~
|
| 273 |
+
|
| 274 |
+
if (isset($CurrentBlock['continuable']) and $this->isBlockCompletable($CurrentBlock['type']))
|
| 275 |
+
{
|
| 276 |
+
$CurrentBlock = $this->{'block'.$CurrentBlock['type'].'Complete'}($CurrentBlock);
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
# ~
|
| 280 |
+
|
| 281 |
+
$Blocks []= $CurrentBlock;
|
| 282 |
+
|
| 283 |
+
unset($Blocks[0]);
|
| 284 |
+
|
| 285 |
+
# ~
|
| 286 |
+
|
| 287 |
+
$markup = '';
|
| 288 |
+
|
| 289 |
+
foreach ($Blocks as $Block)
|
| 290 |
+
{
|
| 291 |
+
if (isset($Block['hidden']))
|
| 292 |
+
{
|
| 293 |
+
continue;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
$markup .= "\n";
|
| 297 |
+
$markup .= isset($Block['markup']) ? $Block['markup'] : $this->element($Block['element']);
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
$markup .= "\n";
|
| 301 |
+
|
| 302 |
+
# ~
|
| 303 |
+
|
| 304 |
+
return $markup;
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
protected function isBlockContinuable($Type)
|
| 308 |
+
{
|
| 309 |
+
return method_exists($this, 'block'.$Type.'Continue');
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
protected function isBlockCompletable($Type)
|
| 313 |
+
{
|
| 314 |
+
return method_exists($this, 'block'.$Type.'Complete');
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
#
|
| 318 |
+
# Code
|
| 319 |
+
|
| 320 |
+
protected function blockCode($Line, $Block = null)
|
| 321 |
+
{
|
| 322 |
+
if (isset($Block) and ! isset($Block['type']) and ! isset($Block['interrupted']))
|
| 323 |
+
{
|
| 324 |
+
return;
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
if ($Line['indent'] >= 4)
|
| 328 |
+
{
|
| 329 |
+
$text = substr($Line['body'], 4);
|
| 330 |
+
|
| 331 |
+
$Block = array(
|
| 332 |
+
'element' => array(
|
| 333 |
+
'name' => 'pre',
|
| 334 |
+
'handler' => 'element',
|
| 335 |
+
'text' => array(
|
| 336 |
+
'name' => 'code',
|
| 337 |
+
'text' => $text,
|
| 338 |
+
),
|
| 339 |
+
),
|
| 340 |
+
);
|
| 341 |
+
|
| 342 |
+
return $Block;
|
| 343 |
+
}
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
protected function blockCodeContinue($Line, $Block)
|
| 347 |
+
{
|
| 348 |
+
if ($Line['indent'] >= 4)
|
| 349 |
+
{
|
| 350 |
+
if (isset($Block['interrupted']))
|
| 351 |
+
{
|
| 352 |
+
$Block['element']['text']['text'] .= "\n";
|
| 353 |
+
|
| 354 |
+
unset($Block['interrupted']);
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
$Block['element']['text']['text'] .= "\n";
|
| 358 |
+
|
| 359 |
+
$text = substr($Line['body'], 4);
|
| 360 |
+
|
| 361 |
+
$Block['element']['text']['text'] .= $text;
|
| 362 |
+
|
| 363 |
+
return $Block;
|
| 364 |
+
}
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
protected function blockCodeComplete($Block)
|
| 368 |
+
{
|
| 369 |
+
$text = $Block['element']['text']['text'];
|
| 370 |
+
|
| 371 |
+
$Block['element']['text']['text'] = $text;
|
| 372 |
+
|
| 373 |
+
return $Block;
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
#
|
| 377 |
+
# Comment
|
| 378 |
+
|
| 379 |
+
protected function blockComment($Line)
|
| 380 |
+
{
|
| 381 |
+
if ($this->markupEscaped or $this->safeMode)
|
| 382 |
+
{
|
| 383 |
+
return;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
if (isset($Line['text'][3]) and $Line['text'][3] === '-' and $Line['text'][2] === '-' and $Line['text'][1] === '!')
|
| 387 |
+
{
|
| 388 |
+
$Block = array(
|
| 389 |
+
'markup' => $Line['body'],
|
| 390 |
+
);
|
| 391 |
+
|
| 392 |
+
if (preg_match('/-->$/', $Line['text']))
|
| 393 |
+
{
|
| 394 |
+
$Block['closed'] = true;
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
return $Block;
|
| 398 |
+
}
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
protected function blockCommentContinue($Line, array $Block)
|
| 402 |
+
{
|
| 403 |
+
if (isset($Block['closed']))
|
| 404 |
+
{
|
| 405 |
+
return;
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
$Block['markup'] .= "\n" . $Line['body'];
|
| 409 |
+
|
| 410 |
+
if (preg_match('/-->$/', $Line['text']))
|
| 411 |
+
{
|
| 412 |
+
$Block['closed'] = true;
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
return $Block;
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
#
|
| 419 |
+
# Fenced Code
|
| 420 |
+
|
| 421 |
+
protected function blockFencedCode($Line)
|
| 422 |
+
{
|
| 423 |
+
if (preg_match('/^['.$Line['text'][0].']{3,}[ ]*([^`]+)?[ ]*$/', $Line['text'], $matches))
|
| 424 |
+
{
|
| 425 |
+
$Element = array(
|
| 426 |
+
'name' => 'code',
|
| 427 |
+
'text' => '',
|
| 428 |
+
);
|
| 429 |
+
|
| 430 |
+
if (isset($matches[1]))
|
| 431 |
+
{
|
| 432 |
+
/**
|
| 433 |
+
* https://www.w3.org/TR/2011/WD-html5-20110525/elements.html#classes
|
| 434 |
+
* Every HTML element may have a class attribute specified.
|
| 435 |
+
* The attribute, if specified, must have a value that is a set
|
| 436 |
+
* of space-separated tokens representing the various classes
|
| 437 |
+
* that the element belongs to.
|
| 438 |
+
* [...]
|
| 439 |
+
* The space characters, for the purposes of this specification,
|
| 440 |
+
* are U+0020 SPACE, U+0009 CHARACTER TABULATION (tab),
|
| 441 |
+
* U+000A LINE FEED (LF), U+000C FORM FEED (FF), and
|
| 442 |
+
* U+000D CARRIAGE RETURN (CR).
|
| 443 |
+
*/
|
| 444 |
+
$language = substr($matches[1], 0, strcspn($matches[1], " \t\n\f\r"));
|
| 445 |
+
|
| 446 |
+
$class = 'language-'.$language;
|
| 447 |
+
|
| 448 |
+
$Element['attributes'] = array(
|
| 449 |
+
'class' => $class,
|
| 450 |
+
);
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
$Block = array(
|
| 454 |
+
'char' => $Line['text'][0],
|
| 455 |
+
'element' => array(
|
| 456 |
+
'name' => 'pre',
|
| 457 |
+
'handler' => 'element',
|
| 458 |
+
'text' => $Element,
|
| 459 |
+
),
|
| 460 |
+
);
|
| 461 |
+
|
| 462 |
+
return $Block;
|
| 463 |
+
}
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
protected function blockFencedCodeContinue($Line, $Block)
|
| 467 |
+
{
|
| 468 |
+
if (isset($Block['complete']))
|
| 469 |
+
{
|
| 470 |
+
return;
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
if (isset($Block['interrupted']))
|
| 474 |
+
{
|
| 475 |
+
$Block['element']['text']['text'] .= "\n";
|
| 476 |
+
|
| 477 |
+
unset($Block['interrupted']);
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
if (preg_match('/^'.$Block['char'].'{3,}[ ]*$/', $Line['text']))
|
| 481 |
+
{
|
| 482 |
+
$Block['element']['text']['text'] = substr($Block['element']['text']['text'], 1);
|
| 483 |
+
|
| 484 |
+
$Block['complete'] = true;
|
| 485 |
+
|
| 486 |
+
return $Block;
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
$Block['element']['text']['text'] .= "\n".$Line['body'];
|
| 490 |
+
|
| 491 |
+
return $Block;
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
protected function blockFencedCodeComplete($Block)
|
| 495 |
+
{
|
| 496 |
+
$text = $Block['element']['text']['text'];
|
| 497 |
+
|
| 498 |
+
$Block['element']['text']['text'] = $text;
|
| 499 |
+
|
| 500 |
+
return $Block;
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
#
|
| 504 |
+
# Header
|
| 505 |
+
|
| 506 |
+
protected function blockHeader($Line)
|
| 507 |
+
{
|
| 508 |
+
if (isset($Line['text'][1]))
|
| 509 |
+
{
|
| 510 |
+
$level = 1;
|
| 511 |
+
|
| 512 |
+
while (isset($Line['text'][$level]) and $Line['text'][$level] === '#')
|
| 513 |
+
{
|
| 514 |
+
$level ++;
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
if ($level > 6)
|
| 518 |
+
{
|
| 519 |
+
return;
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
$text = trim($Line['text'], '# ');
|
| 523 |
+
|
| 524 |
+
$Block = array(
|
| 525 |
+
'element' => array(
|
| 526 |
+
'name' => 'h' . min(6, $level),
|
| 527 |
+
'text' => $text,
|
| 528 |
+
'handler' => 'line',
|
| 529 |
+
),
|
| 530 |
+
);
|
| 531 |
+
|
| 532 |
+
return $Block;
|
| 533 |
+
}
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
#
|
| 537 |
+
# List
|
| 538 |
+
|
| 539 |
+
protected function blockList($Line)
|
| 540 |
+
{
|
| 541 |
+
list($name, $pattern) = $Line['text'][0] <= '-' ? array('ul', '[*+-]') : array('ol', '[0-9]+[.]');
|
| 542 |
+
|
| 543 |
+
if (preg_match('/^('.$pattern.'[ ]+)(.*)/', $Line['text'], $matches))
|
| 544 |
+
{
|
| 545 |
+
$Block = array(
|
| 546 |
+
'indent' => $Line['indent'],
|
| 547 |
+
'pattern' => $pattern,
|
| 548 |
+
'element' => array(
|
| 549 |
+
'name' => $name,
|
| 550 |
+
'handler' => 'elements',
|
| 551 |
+
),
|
| 552 |
+
);
|
| 553 |
+
|
| 554 |
+
if($name === 'ol')
|
| 555 |
+
{
|
| 556 |
+
$listStart = stristr($matches[0], '.', true);
|
| 557 |
+
|
| 558 |
+
if($listStart !== '1')
|
| 559 |
+
{
|
| 560 |
+
$Block['element']['attributes'] = array('start' => $listStart);
|
| 561 |
+
}
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
$Block['li'] = array(
|
| 565 |
+
'name' => 'li',
|
| 566 |
+
'handler' => 'li',
|
| 567 |
+
'text' => array(
|
| 568 |
+
$matches[2],
|
| 569 |
+
),
|
| 570 |
+
);
|
| 571 |
+
|
| 572 |
+
$Block['element']['text'] []= & $Block['li'];
|
| 573 |
+
|
| 574 |
+
return $Block;
|
| 575 |
+
}
|
| 576 |
+
}
|
| 577 |
+
|
| 578 |
+
protected function blockListContinue($Line, array $Block)
|
| 579 |
+
{
|
| 580 |
+
if ($Block['indent'] === $Line['indent'] and preg_match('/^'.$Block['pattern'].'(?:[ ]+(.*)|$)/', $Line['text'], $matches))
|
| 581 |
+
{
|
| 582 |
+
if (isset($Block['interrupted']))
|
| 583 |
+
{
|
| 584 |
+
$Block['li']['text'] []= '';
|
| 585 |
+
|
| 586 |
+
$Block['loose'] = true;
|
| 587 |
+
|
| 588 |
+
unset($Block['interrupted']);
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
unset($Block['li']);
|
| 592 |
+
|
| 593 |
+
$text = isset($matches[1]) ? $matches[1] : '';
|
| 594 |
+
|
| 595 |
+
$Block['li'] = array(
|
| 596 |
+
'name' => 'li',
|
| 597 |
+
'handler' => 'li',
|
| 598 |
+
'text' => array(
|
| 599 |
+
$text,
|
| 600 |
+
),
|
| 601 |
+
);
|
| 602 |
+
|
| 603 |
+
$Block['element']['text'] []= & $Block['li'];
|
| 604 |
+
|
| 605 |
+
return $Block;
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
if ($Line['text'][0] === '[' and $this->blockReference($Line))
|
| 609 |
+
{
|
| 610 |
+
return $Block;
|
| 611 |
+
}
|
| 612 |
+
|
| 613 |
+
if ( ! isset($Block['interrupted']))
|
| 614 |
+
{
|
| 615 |
+
$text = preg_replace('/^[ ]{0,4}/', '', $Line['body']);
|
| 616 |
+
|
| 617 |
+
$Block['li']['text'] []= $text;
|
| 618 |
+
|
| 619 |
+
return $Block;
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
if ($Line['indent'] > 0)
|
| 623 |
+
{
|
| 624 |
+
$Block['li']['text'] []= '';
|
| 625 |
+
|
| 626 |
+
$text = preg_replace('/^[ ]{0,4}/', '', $Line['body']);
|
| 627 |
+
|
| 628 |
+
$Block['li']['text'] []= $text;
|
| 629 |
+
|
| 630 |
+
unset($Block['interrupted']);
|
| 631 |
+
|
| 632 |
+
return $Block;
|
| 633 |
+
}
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
+
protected function blockListComplete(array $Block)
|
| 637 |
+
{
|
| 638 |
+
if (isset($Block['loose']))
|
| 639 |
+
{
|
| 640 |
+
foreach ($Block['element']['text'] as &$li)
|
| 641 |
+
{
|
| 642 |
+
if (end($li['text']) !== '')
|
| 643 |
+
{
|
| 644 |
+
$li['text'] []= '';
|
| 645 |
+
}
|
| 646 |
+
}
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
return $Block;
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
#
|
| 653 |
+
# Quote
|
| 654 |
+
|
| 655 |
+
protected function blockQuote($Line)
|
| 656 |
+
{
|
| 657 |
+
if (preg_match('/^>[ ]?(.*)/', $Line['text'], $matches))
|
| 658 |
+
{
|
| 659 |
+
$Block = array(
|
| 660 |
+
'element' => array(
|
| 661 |
+
'name' => 'blockquote',
|
| 662 |
+
'handler' => 'lines',
|
| 663 |
+
'text' => (array) $matches[1],
|
| 664 |
+
),
|
| 665 |
+
);
|
| 666 |
+
|
| 667 |
+
return $Block;
|
| 668 |
+
}
|
| 669 |
+
}
|
| 670 |
+
|
| 671 |
+
protected function blockQuoteContinue($Line, array $Block)
|
| 672 |
+
{
|
| 673 |
+
if ($Line['text'][0] === '>' and preg_match('/^>[ ]?(.*)/', $Line['text'], $matches))
|
| 674 |
+
{
|
| 675 |
+
if (isset($Block['interrupted']))
|
| 676 |
+
{
|
| 677 |
+
$Block['element']['text'] []= '';
|
| 678 |
+
|
| 679 |
+
unset($Block['interrupted']);
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
$Block['element']['text'] []= $matches[1];
|
| 683 |
+
|
| 684 |
+
return $Block;
|
| 685 |
+
}
|
| 686 |
+
|
| 687 |
+
if ( ! isset($Block['interrupted']))
|
| 688 |
+
{
|
| 689 |
+
$Block['element']['text'] []= $Line['text'];
|
| 690 |
+
|
| 691 |
+
return $Block;
|
| 692 |
+
}
|
| 693 |
+
}
|
| 694 |
+
|
| 695 |
+
#
|
| 696 |
+
# Rule
|
| 697 |
+
|
| 698 |
+
protected function blockRule($Line)
|
| 699 |
+
{
|
| 700 |
+
if (preg_match('/^(['.$Line['text'][0].'])([ ]*\1){2,}[ ]*$/', $Line['text']))
|
| 701 |
+
{
|
| 702 |
+
$Block = array(
|
| 703 |
+
'element' => array(
|
| 704 |
+
'name' => 'hr'
|
| 705 |
+
),
|
| 706 |
+
);
|
| 707 |
+
|
| 708 |
+
return $Block;
|
| 709 |
+
}
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
#
|
| 713 |
+
# Setext
|
| 714 |
+
|
| 715 |
+
protected function blockSetextHeader($Line, array $Block = null)
|
| 716 |
+
{
|
| 717 |
+
if ( ! isset($Block) or isset($Block['type']) or isset($Block['interrupted']))
|
| 718 |
+
{
|
| 719 |
+
return;
|
| 720 |
+
}
|
| 721 |
+
|
| 722 |
+
if (chop($Line['text'], $Line['text'][0]) === '')
|
| 723 |
+
{
|
| 724 |
+
$Block['element']['name'] = $Line['text'][0] === '=' ? 'h1' : 'h2';
|
| 725 |
+
|
| 726 |
+
return $Block;
|
| 727 |
+
}
|
| 728 |
+
}
|
| 729 |
+
|
| 730 |
+
#
|
| 731 |
+
# Markup
|
| 732 |
+
|
| 733 |
+
protected function blockMarkup($Line)
|
| 734 |
+
{
|
| 735 |
+
if ($this->markupEscaped or $this->safeMode)
|
| 736 |
+
{
|
| 737 |
+
return;
|
| 738 |
+
}
|
| 739 |
+
|
| 740 |
+
if (preg_match('/^<(\w[\w-]*)(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*(\/)?>/', $Line['text'], $matches))
|
| 741 |
+
{
|
| 742 |
+
$element = strtolower($matches[1]);
|
| 743 |
+
|
| 744 |
+
if (in_array($element, $this->textLevelElements))
|
| 745 |
+
{
|
| 746 |
+
return;
|
| 747 |
+
}
|
| 748 |
+
|
| 749 |
+
$Block = array(
|
| 750 |
+
'name' => $matches[1],
|
| 751 |
+
'depth' => 0,
|
| 752 |
+
'markup' => $Line['text'],
|
| 753 |
+
);
|
| 754 |
+
|
| 755 |
+
$length = strlen($matches[0]);
|
| 756 |
+
|
| 757 |
+
$remainder = substr($Line['text'], $length);
|
| 758 |
+
|
| 759 |
+
if (trim($remainder) === '')
|
| 760 |
+
{
|
| 761 |
+
if (isset($matches[2]) or in_array($matches[1], $this->voidElements))
|
| 762 |
+
{
|
| 763 |
+
$Block['closed'] = true;
|
| 764 |
+
|
| 765 |
+
$Block['void'] = true;
|
| 766 |
+
}
|
| 767 |
+
}
|
| 768 |
+
else
|
| 769 |
+
{
|
| 770 |
+
if (isset($matches[2]) or in_array($matches[1], $this->voidElements))
|
| 771 |
+
{
|
| 772 |
+
return;
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
if (preg_match('/<\/'.$matches[1].'>[ ]*$/i', $remainder))
|
| 776 |
+
{
|
| 777 |
+
$Block['closed'] = true;
|
| 778 |
+
}
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
return $Block;
|
| 782 |
+
}
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
protected function blockMarkupContinue($Line, array $Block)
|
| 786 |
+
{
|
| 787 |
+
if (isset($Block['closed']))
|
| 788 |
+
{
|
| 789 |
+
return;
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
if (preg_match('/^<'.$Block['name'].'(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*>/i', $Line['text'])) # open
|
| 793 |
+
{
|
| 794 |
+
$Block['depth'] ++;
|
| 795 |
+
}
|
| 796 |
+
|
| 797 |
+
if (preg_match('/(.*?)<\/'.$Block['name'].'>[ ]*$/i', $Line['text'], $matches)) # close
|
| 798 |
+
{
|
| 799 |
+
if ($Block['depth'] > 0)
|
| 800 |
+
{
|
| 801 |
+
$Block['depth'] --;
|
| 802 |
+
}
|
| 803 |
+
else
|
| 804 |
+
{
|
| 805 |
+
$Block['closed'] = true;
|
| 806 |
+
}
|
| 807 |
+
}
|
| 808 |
+
|
| 809 |
+
if (isset($Block['interrupted']))
|
| 810 |
+
{
|
| 811 |
+
$Block['markup'] .= "\n";
|
| 812 |
+
|
| 813 |
+
unset($Block['interrupted']);
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
+
$Block['markup'] .= "\n".$Line['body'];
|
| 817 |
+
|
| 818 |
+
return $Block;
|
| 819 |
+
}
|
| 820 |
+
|
| 821 |
+
#
|
| 822 |
+
# Reference
|
| 823 |
+
|
| 824 |
+
protected function blockReference($Line)
|
| 825 |
+
{
|
| 826 |
+
if (preg_match('/^\[(.+?)\]:[ ]*<?(\S+?)>?(?:[ ]+["\'(](.+)["\')])?[ ]*$/', $Line['text'], $matches))
|
| 827 |
+
{
|
| 828 |
+
$id = strtolower($matches[1]);
|
| 829 |
+
|
| 830 |
+
$Data = array(
|
| 831 |
+
'url' => $matches[2],
|
| 832 |
+
'title' => null,
|
| 833 |
+
);
|
| 834 |
+
|
| 835 |
+
if (isset($matches[3]))
|
| 836 |
+
{
|
| 837 |
+
$Data['title'] = $matches[3];
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
$this->DefinitionData['Reference'][$id] = $Data;
|
| 841 |
+
|
| 842 |
+
$Block = array(
|
| 843 |
+
'hidden' => true,
|
| 844 |
+
);
|
| 845 |
+
|
| 846 |
+
return $Block;
|
| 847 |
+
}
|
| 848 |
+
}
|
| 849 |
+
|
| 850 |
+
#
|
| 851 |
+
# Table
|
| 852 |
+
|
| 853 |
+
protected function blockTable($Line, array $Block = null)
|
| 854 |
+
{
|
| 855 |
+
if ( ! isset($Block) or isset($Block['type']) or isset($Block['interrupted']))
|
| 856 |
+
{
|
| 857 |
+
return;
|
| 858 |
+
}
|
| 859 |
+
|
| 860 |
+
if (strpos($Block['element']['text'], '|') !== false and chop($Line['text'], ' -:|') === '')
|
| 861 |
+
{
|
| 862 |
+
$alignments = array();
|
| 863 |
+
|
| 864 |
+
$divider = $Line['text'];
|
| 865 |
+
|
| 866 |
+
$divider = trim($divider);
|
| 867 |
+
$divider = trim($divider, '|');
|
| 868 |
+
|
| 869 |
+
$dividerCells = explode('|', $divider);
|
| 870 |
+
|
| 871 |
+
foreach ($dividerCells as $dividerCell)
|
| 872 |
+
{
|
| 873 |
+
$dividerCell = trim($dividerCell);
|
| 874 |
+
|
| 875 |
+
if ($dividerCell === '')
|
| 876 |
+
{
|
| 877 |
+
continue;
|
| 878 |
+
}
|
| 879 |
+
|
| 880 |
+
$alignment = null;
|
| 881 |
+
|
| 882 |
+
if ($dividerCell[0] === ':')
|
| 883 |
+
{
|
| 884 |
+
$alignment = 'left';
|
| 885 |
+
}
|
| 886 |
+
|
| 887 |
+
if (substr($dividerCell, - 1) === ':')
|
| 888 |
+
{
|
| 889 |
+
$alignment = $alignment === 'left' ? 'center' : 'right';
|
| 890 |
+
}
|
| 891 |
+
|
| 892 |
+
$alignments []= $alignment;
|
| 893 |
+
}
|
| 894 |
+
|
| 895 |
+
# ~
|
| 896 |
+
|
| 897 |
+
$HeaderElements = array();
|
| 898 |
+
|
| 899 |
+
$header = $Block['element']['text'];
|
| 900 |
+
|
| 901 |
+
$header = trim($header);
|
| 902 |
+
$header = trim($header, '|');
|
| 903 |
+
|
| 904 |
+
$headerCells = explode('|', $header);
|
| 905 |
+
|
| 906 |
+
foreach ($headerCells as $index => $headerCell)
|
| 907 |
+
{
|
| 908 |
+
$headerCell = trim($headerCell);
|
| 909 |
+
|
| 910 |
+
$HeaderElement = array(
|
| 911 |
+
'name' => 'th',
|
| 912 |
+
'text' => $headerCell,
|
| 913 |
+
'handler' => 'line',
|
| 914 |
+
);
|
| 915 |
+
|
| 916 |
+
if (isset($alignments[$index]))
|
| 917 |
+
{
|
| 918 |
+
$alignment = $alignments[$index];
|
| 919 |
+
|
| 920 |
+
$HeaderElement['attributes'] = array(
|
| 921 |
+
'style' => 'text-align: '.$alignment.';',
|
| 922 |
+
);
|
| 923 |
+
}
|
| 924 |
+
|
| 925 |
+
$HeaderElements []= $HeaderElement;
|
| 926 |
+
}
|
| 927 |
+
|
| 928 |
+
# ~
|
| 929 |
+
|
| 930 |
+
$Block = array(
|
| 931 |
+
'alignments' => $alignments,
|
| 932 |
+
'identified' => true,
|
| 933 |
+
'element' => array(
|
| 934 |
+
'name' => 'table',
|
| 935 |
+
'handler' => 'elements',
|
| 936 |
+
),
|
| 937 |
+
);
|
| 938 |
+
|
| 939 |
+
$Block['element']['text'] []= array(
|
| 940 |
+
'name' => 'thead',
|
| 941 |
+
'handler' => 'elements',
|
| 942 |
+
);
|
| 943 |
+
|
| 944 |
+
$Block['element']['text'] []= array(
|
| 945 |
+
'name' => 'tbody',
|
| 946 |
+
'handler' => 'elements',
|
| 947 |
+
'text' => array(),
|
| 948 |
+
);
|
| 949 |
+
|
| 950 |
+
$Block['element']['text'][0]['text'] []= array(
|
| 951 |
+
'name' => 'tr',
|
| 952 |
+
'handler' => 'elements',
|
| 953 |
+
'text' => $HeaderElements,
|
| 954 |
+
);
|
| 955 |
+
|
| 956 |
+
return $Block;
|
| 957 |
+
}
|
| 958 |
+
}
|
| 959 |
+
|
| 960 |
+
protected function blockTableContinue($Line, array $Block)
|
| 961 |
+
{
|
| 962 |
+
if (isset($Block['interrupted']))
|
| 963 |
+
{
|
| 964 |
+
return;
|
| 965 |
+
}
|
| 966 |
+
|
| 967 |
+
if ($Line['text'][0] === '|' or strpos($Line['text'], '|'))
|
| 968 |
+
{
|
| 969 |
+
$Elements = array();
|
| 970 |
+
|
| 971 |
+
$row = $Line['text'];
|
| 972 |
+
|
| 973 |
+
$row = trim($row);
|
| 974 |
+
$row = trim($row, '|');
|
| 975 |
+
|
| 976 |
+
preg_match_all('/(?:(\\\\[|])|[^|`]|`[^`]+`|`)+/', $row, $matches);
|
| 977 |
+
|
| 978 |
+
foreach ($matches[0] as $index => $cell)
|
| 979 |
+
{
|
| 980 |
+
$cell = trim($cell);
|
| 981 |
+
|
| 982 |
+
$Element = array(
|
| 983 |
+
'name' => 'td',
|
| 984 |
+
'handler' => 'line',
|
| 985 |
+
'text' => $cell,
|
| 986 |
+
);
|
| 987 |
+
|
| 988 |
+
if (isset($Block['alignments'][$index]))
|
| 989 |
+
{
|
| 990 |
+
$Element['attributes'] = array(
|
| 991 |
+
'style' => 'text-align: '.$Block['alignments'][$index].';',
|
| 992 |
+
);
|
| 993 |
+
}
|
| 994 |
+
|
| 995 |
+
$Elements []= $Element;
|
| 996 |
+
}
|
| 997 |
+
|
| 998 |
+
$Element = array(
|
| 999 |
+
'name' => 'tr',
|
| 1000 |
+
'handler' => 'elements',
|
| 1001 |
+
'text' => $Elements,
|
| 1002 |
+
);
|
| 1003 |
+
|
| 1004 |
+
$Block['element']['text'][1]['text'] []= $Element;
|
| 1005 |
+
|
| 1006 |
+
return $Block;
|
| 1007 |
+
}
|
| 1008 |
+
}
|
| 1009 |
+
|
| 1010 |
+
#
|
| 1011 |
+
# ~
|
| 1012 |
+
#
|
| 1013 |
+
|
| 1014 |
+
protected function paragraph($Line)
|
| 1015 |
+
{
|
| 1016 |
+
$Block = array(
|
| 1017 |
+
'element' => array(
|
| 1018 |
+
'name' => 'p',
|
| 1019 |
+
'text' => $Line['text'],
|
| 1020 |
+
'handler' => 'line',
|
| 1021 |
+
),
|
| 1022 |
+
);
|
| 1023 |
+
|
| 1024 |
+
return $Block;
|
| 1025 |
+
}
|
| 1026 |
+
|
| 1027 |
+
#
|
| 1028 |
+
# Inline Elements
|
| 1029 |
+
#
|
| 1030 |
+
|
| 1031 |
+
protected $InlineTypes = array(
|
| 1032 |
+
'"' => array('SpecialCharacter'),
|
| 1033 |
+
'!' => array('Image'),
|
| 1034 |
+
'&' => array('SpecialCharacter'),
|
| 1035 |
+
'*' => array('Emphasis'),
|
| 1036 |
+
':' => array('Url'),
|
| 1037 |
+
'<' => array('UrlTag', 'EmailTag', 'Markup', 'SpecialCharacter'),
|
| 1038 |
+
'>' => array('SpecialCharacter'),
|
| 1039 |
+
'[' => array('Link'),
|
| 1040 |
+
'_' => array('Emphasis'),
|
| 1041 |
+
'`' => array('Code'),
|
| 1042 |
+
'~' => array('Strikethrough'),
|
| 1043 |
+
'\\' => array('EscapeSequence'),
|
| 1044 |
+
);
|
| 1045 |
+
|
| 1046 |
+
# ~
|
| 1047 |
+
|
| 1048 |
+
protected $inlineMarkerList = '!"*_&[:<>`~\\';
|
| 1049 |
+
|
| 1050 |
+
#
|
| 1051 |
+
# ~
|
| 1052 |
+
#
|
| 1053 |
+
|
| 1054 |
+
public function line($text, $nonNestables=array())
|
| 1055 |
+
{
|
| 1056 |
+
$markup = '';
|
| 1057 |
+
|
| 1058 |
+
# $excerpt is based on the first occurrence of a marker
|
| 1059 |
+
|
| 1060 |
+
while ($excerpt = strpbrk($text, $this->inlineMarkerList))
|
| 1061 |
+
{
|
| 1062 |
+
$marker = $excerpt[0];
|
| 1063 |
+
|
| 1064 |
+
$markerPosition = strpos($text, $marker);
|
| 1065 |
+
|
| 1066 |
+
$Excerpt = array('text' => $excerpt, 'context' => $text);
|
| 1067 |
+
|
| 1068 |
+
foreach ($this->InlineTypes[$marker] as $inlineType)
|
| 1069 |
+
{
|
| 1070 |
+
# check to see if the current inline type is nestable in the current context
|
| 1071 |
+
|
| 1072 |
+
if ( ! empty($nonNestables) and in_array($inlineType, $nonNestables))
|
| 1073 |
+
{
|
| 1074 |
+
continue;
|
| 1075 |
+
}
|
| 1076 |
+
|
| 1077 |
+
$Inline = $this->{'inline'.$inlineType}($Excerpt);
|
| 1078 |
+
|
| 1079 |
+
if ( ! isset($Inline))
|
| 1080 |
+
{
|
| 1081 |
+
continue;
|
| 1082 |
+
}
|
| 1083 |
+
|
| 1084 |
+
# makes sure that the inline belongs to "our" marker
|
| 1085 |
+
|
| 1086 |
+
if (isset($Inline['position']) and $Inline['position'] > $markerPosition)
|
| 1087 |
+
{
|
| 1088 |
+
continue;
|
| 1089 |
+
}
|
| 1090 |
+
|
| 1091 |
+
# sets a default inline position
|
| 1092 |
+
|
| 1093 |
+
if ( ! isset($Inline['position']))
|
| 1094 |
+
{
|
| 1095 |
+
$Inline['position'] = $markerPosition;
|
| 1096 |
+
}
|
| 1097 |
+
|
| 1098 |
+
# cause the new element to 'inherit' our non nestables
|
| 1099 |
+
|
| 1100 |
+
foreach ($nonNestables as $non_nestable)
|
| 1101 |
+
{
|
| 1102 |
+
$Inline['element']['nonNestables'][] = $non_nestable;
|
| 1103 |
+
}
|
| 1104 |
+
|
| 1105 |
+
# the text that comes before the inline
|
| 1106 |
+
$unmarkedText = substr($text, 0, $Inline['position']);
|
| 1107 |
+
|
| 1108 |
+
# compile the unmarked text
|
| 1109 |
+
$markup .= $this->unmarkedText($unmarkedText);
|
| 1110 |
+
|
| 1111 |
+
# compile the inline
|
| 1112 |
+
$markup .= isset($Inline['markup']) ? $Inline['markup'] : $this->element($Inline['element']);
|
| 1113 |
+
|
| 1114 |
+
# remove the examined text
|
| 1115 |
+
$text = substr($text, $Inline['position'] + $Inline['extent']);
|
| 1116 |
+
|
| 1117 |
+
continue 2;
|
| 1118 |
+
}
|
| 1119 |
+
|
| 1120 |
+
# the marker does not belong to an inline
|
| 1121 |
+
|
| 1122 |
+
$unmarkedText = substr($text, 0, $markerPosition + 1);
|
| 1123 |
+
|
| 1124 |
+
$markup .= $this->unmarkedText($unmarkedText);
|
| 1125 |
+
|
| 1126 |
+
$text = substr($text, $markerPosition + 1);
|
| 1127 |
+
}
|
| 1128 |
+
|
| 1129 |
+
$markup .= $this->unmarkedText($text);
|
| 1130 |
+
|
| 1131 |
+
return $markup;
|
| 1132 |
+
}
|
| 1133 |
+
|
| 1134 |
+
#
|
| 1135 |
+
# ~
|
| 1136 |
+
#
|
| 1137 |
+
|
| 1138 |
+
protected function inlineCode($Excerpt)
|
| 1139 |
+
{
|
| 1140 |
+
$marker = $Excerpt['text'][0];
|
| 1141 |
+
|
| 1142 |
+
if (preg_match('/^('.$marker.'+)[ ]*(.+?)[ ]*(?<!'.$marker.')\1(?!'.$marker.')/s', $Excerpt['text'], $matches))
|
| 1143 |
+
{
|
| 1144 |
+
$text = $matches[2];
|
| 1145 |
+
$text = preg_replace("/[ ]*\n/", ' ', $text);
|
| 1146 |
+
|
| 1147 |
+
return array(
|
| 1148 |
+
'extent' => strlen($matches[0]),
|
| 1149 |
+
'element' => array(
|
| 1150 |
+
'name' => 'code',
|
| 1151 |
+
'text' => $text,
|
| 1152 |
+
),
|
| 1153 |
+
);
|
| 1154 |
+
}
|
| 1155 |
+
}
|
| 1156 |
+
|
| 1157 |
+
protected function inlineEmailTag($Excerpt)
|
| 1158 |
+
{
|
| 1159 |
+
if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<((mailto:)?\S+?@\S+?)>/i', $Excerpt['text'], $matches))
|
| 1160 |
+
{
|
| 1161 |
+
$url = $matches[1];
|
| 1162 |
+
|
| 1163 |
+
if ( ! isset($matches[2]))
|
| 1164 |
+
{
|
| 1165 |
+
$url = 'mailto:' . $url;
|
| 1166 |
+
}
|
| 1167 |
+
|
| 1168 |
+
return array(
|
| 1169 |
+
'extent' => strlen($matches[0]),
|
| 1170 |
+
'element' => array(
|
| 1171 |
+
'name' => 'a',
|
| 1172 |
+
'text' => $matches[1],
|
| 1173 |
+
'attributes' => array(
|
| 1174 |
+
'href' => $url,
|
| 1175 |
+
),
|
| 1176 |
+
),
|
| 1177 |
+
);
|
| 1178 |
+
}
|
| 1179 |
+
}
|
| 1180 |
+
|
| 1181 |
+
protected function inlineEmphasis($Excerpt)
|
| 1182 |
+
{
|
| 1183 |
+
if ( ! isset($Excerpt['text'][1]))
|
| 1184 |
+
{
|
| 1185 |
+
return;
|
| 1186 |
+
}
|
| 1187 |
+
|
| 1188 |
+
$marker = $Excerpt['text'][0];
|
| 1189 |
+
|
| 1190 |
+
if ($Excerpt['text'][1] === $marker and preg_match($this->StrongRegex[$marker], $Excerpt['text'], $matches))
|
| 1191 |
+
{
|
| 1192 |
+
$emphasis = 'strong';
|
| 1193 |
+
}
|
| 1194 |
+
elseif (preg_match($this->EmRegex[$marker], $Excerpt['text'], $matches))
|
| 1195 |
+
{
|
| 1196 |
+
$emphasis = 'em';
|
| 1197 |
+
}
|
| 1198 |
+
else
|
| 1199 |
+
{
|
| 1200 |
+
return;
|
| 1201 |
+
}
|
| 1202 |
+
|
| 1203 |
+
return array(
|
| 1204 |
+
'extent' => strlen($matches[0]),
|
| 1205 |
+
'element' => array(
|
| 1206 |
+
'name' => $emphasis,
|
| 1207 |
+
'handler' => 'line',
|
| 1208 |
+
'text' => $matches[1],
|
| 1209 |
+
),
|
| 1210 |
+
);
|
| 1211 |
+
}
|
| 1212 |
+
|
| 1213 |
+
protected function inlineEscapeSequence($Excerpt)
|
| 1214 |
+
{
|
| 1215 |
+
if (isset($Excerpt['text'][1]) and in_array($Excerpt['text'][1], $this->specialCharacters))
|
| 1216 |
+
{
|
| 1217 |
+
return array(
|
| 1218 |
+
'markup' => $Excerpt['text'][1],
|
| 1219 |
+
'extent' => 2,
|
| 1220 |
+
);
|
| 1221 |
+
}
|
| 1222 |
+
}
|
| 1223 |
+
|
| 1224 |
+
protected function inlineImage($Excerpt)
|
| 1225 |
+
{
|
| 1226 |
+
if ( ! isset($Excerpt['text'][1]) or $Excerpt['text'][1] !== '[')
|
| 1227 |
+
{
|
| 1228 |
+
return;
|
| 1229 |
+
}
|
| 1230 |
+
|
| 1231 |
+
$Excerpt['text']= substr($Excerpt['text'], 1);
|
| 1232 |
+
|
| 1233 |
+
$Link = $this->inlineLink($Excerpt);
|
| 1234 |
+
|
| 1235 |
+
if ($Link === null)
|
| 1236 |
+
{
|
| 1237 |
+
return;
|
| 1238 |
+
}
|
| 1239 |
+
|
| 1240 |
+
$Inline = array(
|
| 1241 |
+
'extent' => $Link['extent'] + 1,
|
| 1242 |
+
'element' => array(
|
| 1243 |
+
'name' => 'img',
|
| 1244 |
+
'attributes' => array(
|
| 1245 |
+
'src' => $Link['element']['attributes']['href'],
|
| 1246 |
+
'alt' => $Link['element']['text'],
|
| 1247 |
+
),
|
| 1248 |
+
),
|
| 1249 |
+
);
|
| 1250 |
+
|
| 1251 |
+
$Inline['element']['attributes'] += $Link['element']['attributes'];
|
| 1252 |
+
|
| 1253 |
+
unset($Inline['element']['attributes']['href']);
|
| 1254 |
+
|
| 1255 |
+
return $Inline;
|
| 1256 |
+
}
|
| 1257 |
+
|
| 1258 |
+
protected function inlineLink($Excerpt)
|
| 1259 |
+
{
|
| 1260 |
+
$Element = array(
|
| 1261 |
+
'name' => 'a',
|
| 1262 |
+
'handler' => 'line',
|
| 1263 |
+
'nonNestables' => array('Url', 'Link'),
|
| 1264 |
+
'text' => null,
|
| 1265 |
+
'attributes' => array(
|
| 1266 |
+
'href' => null,
|
| 1267 |
+
'title' => null,
|
| 1268 |
+
),
|
| 1269 |
+
);
|
| 1270 |
+
|
| 1271 |
+
$extent = 0;
|
| 1272 |
+
|
| 1273 |
+
$remainder = $Excerpt['text'];
|
| 1274 |
+
|
| 1275 |
+
if (preg_match('/\[((?:[^][]++|(?R))*+)\]/', $remainder, $matches))
|
| 1276 |
+
{
|
| 1277 |
+
$Element['text'] = $matches[1];
|
| 1278 |
+
|
| 1279 |
+
$extent += strlen($matches[0]);
|
| 1280 |
+
|
| 1281 |
+
$remainder = substr($remainder, $extent);
|
| 1282 |
+
}
|
| 1283 |
+
else
|
| 1284 |
+
{
|
| 1285 |
+
return;
|
| 1286 |
+
}
|
| 1287 |
+
|
| 1288 |
+
if (preg_match('/^[(]\s*+((?:[^ ()]++|[(][^ )]+[)])++)(?:[ ]+("[^"]*"|\'[^\']*\'))?\s*[)]/', $remainder, $matches))
|
| 1289 |
+
{
|
| 1290 |
+
$Element['attributes']['href'] = $matches[1];
|
| 1291 |
+
|
| 1292 |
+
if (isset($matches[2]))
|
| 1293 |
+
{
|
| 1294 |
+
$Element['attributes']['title'] = substr($matches[2], 1, - 1);
|
| 1295 |
+
}
|
| 1296 |
+
|
| 1297 |
+
$extent += strlen($matches[0]);
|
| 1298 |
+
}
|
| 1299 |
+
else
|
| 1300 |
+
{
|
| 1301 |
+
if (preg_match('/^\s*\[(.*?)\]/', $remainder, $matches))
|
| 1302 |
+
{
|
| 1303 |
+
$definition = strlen($matches[1]) ? $matches[1] : $Element['text'];
|
| 1304 |
+
$definition = strtolower($definition);
|
| 1305 |
+
|
| 1306 |
+
$extent += strlen($matches[0]);
|
| 1307 |
+
}
|
| 1308 |
+
else
|
| 1309 |
+
{
|
| 1310 |
+
$definition = strtolower($Element['text']);
|
| 1311 |
+
}
|
| 1312 |
+
|
| 1313 |
+
if ( ! isset($this->DefinitionData['Reference'][$definition]))
|
| 1314 |
+
{
|
| 1315 |
+
return;
|
| 1316 |
+
}
|
| 1317 |
+
|
| 1318 |
+
$Definition = $this->DefinitionData['Reference'][$definition];
|
| 1319 |
+
|
| 1320 |
+
$Element['attributes']['href'] = $Definition['url'];
|
| 1321 |
+
$Element['attributes']['title'] = $Definition['title'];
|
| 1322 |
+
}
|
| 1323 |
+
|
| 1324 |
+
return array(
|
| 1325 |
+
'extent' => $extent,
|
| 1326 |
+
'element' => $Element,
|
| 1327 |
+
);
|
| 1328 |
+
}
|
| 1329 |
+
|
| 1330 |
+
protected function inlineMarkup($Excerpt)
|
| 1331 |
+
{
|
| 1332 |
+
if ($this->markupEscaped or $this->safeMode or strpos($Excerpt['text'], '>') === false)
|
| 1333 |
+
{
|
| 1334 |
+
return;
|
| 1335 |
+
}
|
| 1336 |
+
|
| 1337 |
+
if ($Excerpt['text'][1] === '/' and preg_match('/^<\/\w[\w-]*[ ]*>/s', $Excerpt['text'], $matches))
|
| 1338 |
+
{
|
| 1339 |
+
return array(
|
| 1340 |
+
'markup' => $matches[0],
|
| 1341 |
+
'extent' => strlen($matches[0]),
|
| 1342 |
+
);
|
| 1343 |
+
}
|
| 1344 |
+
|
| 1345 |
+
if ($Excerpt['text'][1] === '!' and preg_match('/^<!---?[^>-](?:-?[^-])*-->/s', $Excerpt['text'], $matches))
|
| 1346 |
+
{
|
| 1347 |
+
return array(
|
| 1348 |
+
'markup' => $matches[0],
|
| 1349 |
+
'extent' => strlen($matches[0]),
|
| 1350 |
+
);
|
| 1351 |
+
}
|
| 1352 |
+
|
| 1353 |
+
if ($Excerpt['text'][1] !== ' ' and preg_match('/^<\w[\w-]*(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*\/?>/s', $Excerpt['text'], $matches))
|
| 1354 |
+
{
|
| 1355 |
+
return array(
|
| 1356 |
+
'markup' => $matches[0],
|
| 1357 |
+
'extent' => strlen($matches[0]),
|
| 1358 |
+
);
|
| 1359 |
+
}
|
| 1360 |
+
}
|
| 1361 |
+
|
| 1362 |
+
protected function inlineSpecialCharacter($Excerpt)
|
| 1363 |
+
{
|
| 1364 |
+
if ($Excerpt['text'][0] === '&' and ! preg_match('/^&#?\w+;/', $Excerpt['text']))
|
| 1365 |
+
{
|
| 1366 |
+
return array(
|
| 1367 |
+
'markup' => '&',
|
| 1368 |
+
'extent' => 1,
|
| 1369 |
+
);
|
| 1370 |
+
}
|
| 1371 |
+
|
| 1372 |
+
$SpecialCharacter = array('>' => 'gt', '<' => 'lt', '"' => 'quot');
|
| 1373 |
+
|
| 1374 |
+
if (isset($SpecialCharacter[$Excerpt['text'][0]]))
|
| 1375 |
+
{
|
| 1376 |
+
return array(
|
| 1377 |
+
'markup' => '&'.$SpecialCharacter[$Excerpt['text'][0]].';',
|
| 1378 |
+
'extent' => 1,
|
| 1379 |
+
);
|
| 1380 |
+
}
|
| 1381 |
+
}
|
| 1382 |
+
|
| 1383 |
+
protected function inlineStrikethrough($Excerpt)
|
| 1384 |
+
{
|
| 1385 |
+
if ( ! isset($Excerpt['text'][1]))
|
| 1386 |
+
{
|
| 1387 |
+
return;
|
| 1388 |
+
}
|
| 1389 |
+
|
| 1390 |
+
if ($Excerpt['text'][1] === '~' and preg_match('/^~~(?=\S)(.+?)(?<=\S)~~/', $Excerpt['text'], $matches))
|
| 1391 |
+
{
|
| 1392 |
+
return array(
|
| 1393 |
+
'extent' => strlen($matches[0]),
|
| 1394 |
+
'element' => array(
|
| 1395 |
+
'name' => 'del',
|
| 1396 |
+
'text' => $matches[1],
|
| 1397 |
+
'handler' => 'line',
|
| 1398 |
+
),
|
| 1399 |
+
);
|
| 1400 |
+
}
|
| 1401 |
+
}
|
| 1402 |
+
|
| 1403 |
+
protected function inlineUrl($Excerpt)
|
| 1404 |
+
{
|
| 1405 |
+
if ($this->urlsLinked !== true or ! isset($Excerpt['text'][2]) or $Excerpt['text'][2] !== '/')
|
| 1406 |
+
{
|
| 1407 |
+
return;
|
| 1408 |
+
}
|
| 1409 |
+
|
| 1410 |
+
if (preg_match('/\bhttps?:[\/]{2}[^\s<]+\b\/*/ui', $Excerpt['context'], $matches, PREG_OFFSET_CAPTURE))
|
| 1411 |
+
{
|
| 1412 |
+
$url = $matches[0][0];
|
| 1413 |
+
|
| 1414 |
+
$Inline = array(
|
| 1415 |
+
'extent' => strlen($matches[0][0]),
|
| 1416 |
+
'position' => $matches[0][1],
|
| 1417 |
+
'element' => array(
|
| 1418 |
+
'name' => 'a',
|
| 1419 |
+
'text' => $url,
|
| 1420 |
+
'attributes' => array(
|
| 1421 |
+
'href' => $url,
|
| 1422 |
+
),
|
| 1423 |
+
),
|
| 1424 |
+
);
|
| 1425 |
+
|
| 1426 |
+
return $Inline;
|
| 1427 |
+
}
|
| 1428 |
+
}
|
| 1429 |
+
|
| 1430 |
+
protected function inlineUrlTag($Excerpt)
|
| 1431 |
+
{
|
| 1432 |
+
if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<(\w+:\/{2}[^ >]+)>/i', $Excerpt['text'], $matches))
|
| 1433 |
+
{
|
| 1434 |
+
$url = $matches[1];
|
| 1435 |
+
|
| 1436 |
+
return array(
|
| 1437 |
+
'extent' => strlen($matches[0]),
|
| 1438 |
+
'element' => array(
|
| 1439 |
+
'name' => 'a',
|
| 1440 |
+
'text' => $url,
|
| 1441 |
+
'attributes' => array(
|
| 1442 |
+
'href' => $url,
|
| 1443 |
+
),
|
| 1444 |
+
),
|
| 1445 |
+
);
|
| 1446 |
+
}
|
| 1447 |
+
}
|
| 1448 |
+
|
| 1449 |
+
# ~
|
| 1450 |
+
|
| 1451 |
+
protected function unmarkedText($text)
|
| 1452 |
+
{
|
| 1453 |
+
if ($this->breaksEnabled)
|
| 1454 |
+
{
|
| 1455 |
+
$text = preg_replace('/[ ]*\n/', "<br />\n", $text);
|
| 1456 |
+
}
|
| 1457 |
+
else
|
| 1458 |
+
{
|
| 1459 |
+
$text = preg_replace('/(?:[ ][ ]+|[ ]*\\\\)\n/', "<br />\n", $text);
|
| 1460 |
+
$text = str_replace(" \n", "\n", $text);
|
| 1461 |
+
}
|
| 1462 |
+
|
| 1463 |
+
return $text;
|
| 1464 |
+
}
|
| 1465 |
+
|
| 1466 |
+
#
|
| 1467 |
+
# Handlers
|
| 1468 |
+
#
|
| 1469 |
+
|
| 1470 |
+
protected function element(array $Element)
|
| 1471 |
+
{
|
| 1472 |
+
if ($this->safeMode)
|
| 1473 |
+
{
|
| 1474 |
+
$Element = $this->sanitiseElement($Element);
|
| 1475 |
+
}
|
| 1476 |
+
|
| 1477 |
+
$markup = '<'.$Element['name'];
|
| 1478 |
+
|
| 1479 |
+
if (isset($Element['attributes']))
|
| 1480 |
+
{
|
| 1481 |
+
foreach ($Element['attributes'] as $name => $value)
|
| 1482 |
+
{
|
| 1483 |
+
if ($value === null)
|
| 1484 |
+
{
|
| 1485 |
+
continue;
|
| 1486 |
+
}
|
| 1487 |
+
|
| 1488 |
+
$markup .= ' '.$name.'="'.self::escape($value).'"';
|
| 1489 |
+
}
|
| 1490 |
+
}
|
| 1491 |
+
|
| 1492 |
+
$permitRawHtml = false;
|
| 1493 |
+
|
| 1494 |
+
if (isset($Element['text']))
|
| 1495 |
+
{
|
| 1496 |
+
$text = $Element['text'];
|
| 1497 |
+
}
|
| 1498 |
+
// very strongly consider an alternative if you're writing an
|
| 1499 |
+
// extension
|
| 1500 |
+
elseif (isset($Element['rawHtml']))
|
| 1501 |
+
{
|
| 1502 |
+
$text = $Element['rawHtml'];
|
| 1503 |
+
$allowRawHtmlInSafeMode = isset($Element['allowRawHtmlInSafeMode']) && $Element['allowRawHtmlInSafeMode'];
|
| 1504 |
+
$permitRawHtml = !$this->safeMode || $allowRawHtmlInSafeMode;
|
| 1505 |
+
}
|
| 1506 |
+
|
| 1507 |
+
if (isset($text))
|
| 1508 |
+
{
|
| 1509 |
+
$markup .= '>';
|
| 1510 |
+
|
| 1511 |
+
if (!isset($Element['nonNestables']))
|
| 1512 |
+
{
|
| 1513 |
+
$Element['nonNestables'] = array();
|
| 1514 |
+
}
|
| 1515 |
+
|
| 1516 |
+
if (isset($Element['handler']))
|
| 1517 |
+
{
|
| 1518 |
+
$markup .= $this->{$Element['handler']}($text, $Element['nonNestables']);
|
| 1519 |
+
}
|
| 1520 |
+
elseif (!$permitRawHtml)
|
| 1521 |
+
{
|
| 1522 |
+
$markup .= self::escape($text, true);
|
| 1523 |
+
}
|
| 1524 |
+
else
|
| 1525 |
+
{
|
| 1526 |
+
$markup .= $text;
|
| 1527 |
+
}
|
| 1528 |
+
|
| 1529 |
+
$markup .= '</'.$Element['name'].'>';
|
| 1530 |
+
}
|
| 1531 |
+
else
|
| 1532 |
+
{
|
| 1533 |
+
$markup .= ' />';
|
| 1534 |
+
}
|
| 1535 |
+
|
| 1536 |
+
return $markup;
|
| 1537 |
+
}
|
| 1538 |
+
|
| 1539 |
+
protected function elements(array $Elements)
|
| 1540 |
+
{
|
| 1541 |
+
$markup = '';
|
| 1542 |
+
|
| 1543 |
+
foreach ($Elements as $Element)
|
| 1544 |
+
{
|
| 1545 |
+
$markup .= "\n" . $this->element($Element);
|
| 1546 |
+
}
|
| 1547 |
+
|
| 1548 |
+
$markup .= "\n";
|
| 1549 |
+
|
| 1550 |
+
return $markup;
|
| 1551 |
+
}
|
| 1552 |
+
|
| 1553 |
+
# ~
|
| 1554 |
+
|
| 1555 |
+
protected function li($lines)
|
| 1556 |
+
{
|
| 1557 |
+
$markup = $this->lines($lines);
|
| 1558 |
+
|
| 1559 |
+
$trimmedMarkup = trim($markup);
|
| 1560 |
+
|
| 1561 |
+
if ( ! in_array('', $lines) and substr($trimmedMarkup, 0, 3) === '<p>')
|
| 1562 |
+
{
|
| 1563 |
+
$markup = $trimmedMarkup;
|
| 1564 |
+
$markup = substr($markup, 3);
|
| 1565 |
+
|
| 1566 |
+
$position = strpos($markup, "</p>");
|
| 1567 |
+
|
| 1568 |
+
$markup = substr_replace($markup, '', $position, 4);
|
| 1569 |
+
}
|
| 1570 |
+
|
| 1571 |
+
return $markup;
|
| 1572 |
+
}
|
| 1573 |
+
|
| 1574 |
+
#
|
| 1575 |
+
# Deprecated Methods
|
| 1576 |
+
#
|
| 1577 |
+
|
| 1578 |
+
function parse($text)
|
| 1579 |
+
{
|
| 1580 |
+
$markup = $this->text($text);
|
| 1581 |
+
|
| 1582 |
+
return $markup;
|
| 1583 |
+
}
|
| 1584 |
+
|
| 1585 |
+
protected function sanitiseElement(array $Element)
|
| 1586 |
+
{
|
| 1587 |
+
static $goodAttribute = '/^[a-zA-Z0-9][a-zA-Z0-9-_]*+$/';
|
| 1588 |
+
static $safeUrlNameToAtt = array(
|
| 1589 |
+
'a' => 'href',
|
| 1590 |
+
'img' => 'src',
|
| 1591 |
+
);
|
| 1592 |
+
|
| 1593 |
+
if (isset($safeUrlNameToAtt[$Element['name']]))
|
| 1594 |
+
{
|
| 1595 |
+
$Element = $this->filterUnsafeUrlInAttribute($Element, $safeUrlNameToAtt[$Element['name']]);
|
| 1596 |
+
}
|
| 1597 |
+
|
| 1598 |
+
if ( ! empty($Element['attributes']))
|
| 1599 |
+
{
|
| 1600 |
+
foreach ($Element['attributes'] as $att => $val)
|
| 1601 |
+
{
|
| 1602 |
+
# filter out badly parsed attribute
|
| 1603 |
+
if ( ! preg_match($goodAttribute, $att))
|
| 1604 |
+
{
|
| 1605 |
+
unset($Element['attributes'][$att]);
|
| 1606 |
+
}
|
| 1607 |
+
# dump onevent attribute
|
| 1608 |
+
elseif (self::striAtStart($att, 'on'))
|
| 1609 |
+
{
|
| 1610 |
+
unset($Element['attributes'][$att]);
|
| 1611 |
+
}
|
| 1612 |
+
}
|
| 1613 |
+
}
|
| 1614 |
+
|
| 1615 |
+
return $Element;
|
| 1616 |
+
}
|
| 1617 |
+
|
| 1618 |
+
protected function filterUnsafeUrlInAttribute(array $Element, $attribute)
|
| 1619 |
+
{
|
| 1620 |
+
foreach ($this->safeLinksWhitelist as $scheme)
|
| 1621 |
+
{
|
| 1622 |
+
if (self::striAtStart($Element['attributes'][$attribute], $scheme))
|
| 1623 |
+
{
|
| 1624 |
+
return $Element;
|
| 1625 |
+
}
|
| 1626 |
+
}
|
| 1627 |
+
|
| 1628 |
+
$Element['attributes'][$attribute] = str_replace(':', '%3A', $Element['attributes'][$attribute]);
|
| 1629 |
+
|
| 1630 |
+
return $Element;
|
| 1631 |
+
}
|
| 1632 |
+
|
| 1633 |
+
#
|
| 1634 |
+
# Static Methods
|
| 1635 |
+
#
|
| 1636 |
+
|
| 1637 |
+
protected static function escape($text, $allowQuotes = false)
|
| 1638 |
+
{
|
| 1639 |
+
return htmlspecialchars($text, $allowQuotes ? ENT_NOQUOTES : ENT_QUOTES, 'UTF-8');
|
| 1640 |
+
}
|
| 1641 |
+
|
| 1642 |
+
protected static function striAtStart($string, $needle)
|
| 1643 |
+
{
|
| 1644 |
+
$len = strlen($needle);
|
| 1645 |
+
|
| 1646 |
+
if ($len > strlen($string))
|
| 1647 |
+
{
|
| 1648 |
+
return false;
|
| 1649 |
+
}
|
| 1650 |
+
else
|
| 1651 |
+
{
|
| 1652 |
+
return strtolower(substr($string, 0, $len)) === strtolower($needle);
|
| 1653 |
+
}
|
| 1654 |
+
}
|
| 1655 |
+
|
| 1656 |
+
static function instance($name = 'default')
|
| 1657 |
+
{
|
| 1658 |
+
if (isset(self::$instances[$name]))
|
| 1659 |
+
{
|
| 1660 |
+
return self::$instances[$name];
|
| 1661 |
+
}
|
| 1662 |
+
|
| 1663 |
+
$instance = new static();
|
| 1664 |
+
|
| 1665 |
+
self::$instances[$name] = $instance;
|
| 1666 |
+
|
| 1667 |
+
return $instance;
|
| 1668 |
+
}
|
| 1669 |
+
|
| 1670 |
+
private static $instances = array();
|
| 1671 |
+
|
| 1672 |
+
#
|
| 1673 |
+
# Fields
|
| 1674 |
+
#
|
| 1675 |
+
|
| 1676 |
+
protected $DefinitionData;
|
| 1677 |
+
|
| 1678 |
+
#
|
| 1679 |
+
# Read-Only
|
| 1680 |
+
|
| 1681 |
+
protected $specialCharacters = array(
|
| 1682 |
+
'\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '>', '#', '+', '-', '.', '!', '|',
|
| 1683 |
+
);
|
| 1684 |
+
|
| 1685 |
+
protected $StrongRegex = array(
|
| 1686 |
+
'*' => '/^[*]{2}((?:\\\\\*|[^*]|[*][^*]*[*])+?)[*]{2}(?![*])/s',
|
| 1687 |
+
'_' => '/^__((?:\\\\_|[^_]|_[^_]*_)+?)__(?!_)/us',
|
| 1688 |
+
);
|
| 1689 |
+
|
| 1690 |
+
protected $EmRegex = array(
|
| 1691 |
+
'*' => '/^[*]((?:\\\\\*|[^*]|[*][*][^*]+?[*][*])+?)[*](?![*])/s',
|
| 1692 |
+
'_' => '/^_((?:\\\\_|[^_]|__[^_]*__)+?)_(?!_)\b/us',
|
| 1693 |
+
);
|
| 1694 |
+
|
| 1695 |
+
protected $regexHtmlAttribute = '[a-zA-Z_:][\w:.-]*(?:\s*=\s*(?:[^"\'=<>`\s]+|"[^"]*"|\'[^\']*\'))?';
|
| 1696 |
+
|
| 1697 |
+
protected $voidElements = array(
|
| 1698 |
+
'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source',
|
| 1699 |
+
);
|
| 1700 |
+
|
| 1701 |
+
protected $textLevelElements = array(
|
| 1702 |
+
'a', 'br', 'bdo', 'abbr', 'blink', 'nextid', 'acronym', 'basefont',
|
| 1703 |
+
'b', 'em', 'big', 'cite', 'small', 'spacer', 'listing',
|
| 1704 |
+
'i', 'rp', 'del', 'code', 'strike', 'marquee',
|
| 1705 |
+
'q', 'rt', 'ins', 'font', 'strong',
|
| 1706 |
+
's', 'tt', 'kbd', 'mark',
|
| 1707 |
+
'u', 'xm', 'sub', 'nobr',
|
| 1708 |
+
'sup', 'ruby',
|
| 1709 |
+
'var', 'span',
|
| 1710 |
+
'wbr', 'time',
|
| 1711 |
+
);
|
| 1712 |
+
}
|