NujacsaintS commited on
Commit
f51c224
·
1 Parent(s): 0f71b56
Files changed (44) hide show
  1. CLAUDE.md +3 -1
  2. ONBOARDING.md +15 -9
  3. README.md +14 -1
  4. sa-orchestration/README.md +33 -0
  5. sa-orchestration/assets/asterion-bubble.css +245 -0
  6. sa-orchestration/assets/asterion-bubble.js +237 -0
  7. sa-orchestration/assets/attention-bridge.js +263 -0
  8. sa-orchestration/assets/sara-bubble.css +304 -0
  9. sa-orchestration/assets/sara-bubble.js +560 -0
  10. sa-orchestration/includes/class-sa-acceptance.php +216 -0
  11. sa-orchestration/includes/class-sa-actor-admin.php +235 -0
  12. sa-orchestration/includes/class-sa-actor-handoff.php +134 -0
  13. sa-orchestration/includes/class-sa-actor-registry.php +192 -0
  14. sa-orchestration/includes/class-sa-adapter-fluent-boards.php +116 -0
  15. sa-orchestration/includes/class-sa-adapter-fluent-support.php +214 -0
  16. sa-orchestration/includes/class-sa-admin.php +957 -0
  17. sa-orchestration/includes/class-sa-arbitration.php +174 -0
  18. sa-orchestration/includes/class-sa-asterion-explorer.php +263 -0
  19. sa-orchestration/includes/class-sa-asterion-projection.php +478 -0
  20. sa-orchestration/includes/class-sa-asterion.php +82 -0
  21. sa-orchestration/includes/class-sa-attention-bridge.php +399 -0
  22. sa-orchestration/includes/class-sa-bridge-fluent.php +88 -0
  23. sa-orchestration/includes/class-sa-demand-registry.php +81 -0
  24. sa-orchestration/includes/class-sa-env.php +53 -0
  25. sa-orchestration/includes/class-sa-fluent-state.php +178 -0
  26. sa-orchestration/includes/class-sa-link.php +38 -0
  27. sa-orchestration/includes/class-sa-llm-provider-openai.php +171 -0
  28. sa-orchestration/includes/class-sa-llm-provider-simulated.php +217 -0
  29. sa-orchestration/includes/class-sa-llm-providers.php +67 -0
  30. sa-orchestration/includes/class-sa-llm-settings.php +62 -0
  31. sa-orchestration/includes/class-sa-migrations.php +161 -0
  32. sa-orchestration/includes/class-sa-projection-fluent-support.php +521 -0
  33. sa-orchestration/includes/class-sa-reasoning.php +334 -0
  34. sa-orchestration/includes/class-sa-rest.php +110 -0
  35. sa-orchestration/includes/class-sa-sara-admin.php +68 -0
  36. sa-orchestration/includes/class-sa-sara-rest.php +81 -0
  37. sa-orchestration/includes/class-sa-sara-service.php +418 -0
  38. sa-orchestration/includes/class-sa-seed-generator.php +523 -0
  39. sa-orchestration/includes/class-sa-strategy.php +83 -0
  40. sa-orchestration/includes/class-sa-token-log.php +114 -0
  41. sa-orchestration/includes/interface-sa-llm-provider.php +48 -0
  42. sa-orchestration/includes/interface-sa-surface-adapter.php +63 -0
  43. sa-orchestration/sa-orchestration.php +97 -0
  44. 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
- F:\SleeperAgents\RcmEmailAutomation\
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? Check for:
68
- - XAMPP at `C:\xampp` or `F:\XAMPP` or similar
69
- - Local by Flywheel at `C:\Users\<user>\Local Sites`
70
- - Laragon at `C:\laragon`
71
- - MAMP at `/Applications/MAMP` (macOS) or equivalent
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
- return 'F:/SleeperAgents/RcmEmailAutomation/asterion';
 
 
 
186
  }, 5 );
187
  ```
188
 
189
- Choose A for cleanliness (corpus lives inside the WP install). Choose B for "single source of truth" (corpus stays in this repo, the WP install reads through). Either is acceptable for JNO.
 
 
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 at `F:/SleeperAgents/SA-orchestration/asterion/`). Read it when you encounter a term you are uncertain about.
 
 
 
 
 
 
 
 
 
 
 
 
 
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 . '&notice=' . $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 . '&notice=' . $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> &nbsp; ';
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> &nbsp; ';
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 ) . '">&larr; 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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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> &rarr; <strong>(2) this Deterministic Trace Audit</strong> &rarr; <strong>(3) Strategy LLM Review</strong> (future) &rarr; <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 ' &mdash; ' . 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">&#9888; YES &mdash; ' . 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 &mdash; 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 ) . ' &mdash; <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 ) . ' &mdash; 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 &harr; Strategy Review (side-by-side)</h3>';
755
+ echo '<table class="widefat striped" style="max-width:900px"><thead><tr>';
756
+ echo '<th style="width:25%">&nbsp;</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 &rarr; deterministic audit &rarr; <strong>strategy review</strong> &rarr; human arbitration).</p>';
856
+ echo '<p><strong>Local-dev posture:</strong> secrets are stored in <code>wp_options</code>. Tokens are never displayed back &mdash; 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> &nbsp;·&nbsp; 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' => '&amp;',
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
+ }