@smartmemory/compose 0.3.7 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/.claude/skills/compose/SKILL.md +12 -3
  2. package/.compose-deps.json +51 -25
  3. package/README.md +79 -7
  4. package/bin/compose.js +495 -360
  5. package/bin/judgment-migrate.js +387 -0
  6. package/contracts/comp-obs-contract.schema.json +9 -3
  7. package/contracts/fluid-record.schema.json +209 -0
  8. package/contracts/lifecycle-backfill.schema.json +322 -0
  9. package/dist/assets/App-Z4MU-H_F.js +916 -0
  10. package/dist/assets/{_baseUniq-Bo837sRJ.js → _baseUniq-ClWoCPFl.js} +1 -1
  11. package/dist/assets/{arc-BafGpyqE.js → arc-DY26UIVo.js} +1 -1
  12. package/dist/assets/{architectureDiagram-Q4EWVU46-BOBfUsqL.js → architectureDiagram-Q4EWVU46-6Ggq4DqJ.js} +1 -1
  13. package/dist/assets/{blockDiagram-DXYQGD6D-Dwodev1a.js → blockDiagram-DXYQGD6D-CH3Ked0l.js} +1 -1
  14. package/dist/assets/{browser-1ntj1-x_.js → browser-BWkrenen.js} +1 -1
  15. package/dist/assets/{c4Diagram-AHTNJAMY-CU_bhYag.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  16. package/dist/assets/channel-SnZzzh7k.js +1 -0
  17. package/dist/assets/{chunk-4BX2VUAB-p8WsDwnO.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  18. package/dist/assets/{chunk-4TB4RGXK-B8h7-eR0.js → chunk-4TB4RGXK-JytR14a9.js} +1 -1
  19. package/dist/assets/{chunk-55IACEB6-DxeEr98s.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  20. package/dist/assets/{chunk-EDXVE4YY-BYt8F151.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  21. package/dist/assets/{chunk-FMBD7UC4-DGSOVeie.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  22. package/dist/assets/{chunk-OYMX7WX6-B-QdgYR2.js → chunk-OYMX7WX6-BySQzVxc.js} +1 -1
  23. package/dist/assets/{chunk-QZHKN3VN-Du5UAZLs.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  24. package/dist/assets/{chunk-YZCP3GAM-C8JbNBSk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  25. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  26. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  27. package/dist/assets/clone-DgklGjHm.js +1 -0
  28. package/dist/assets/{cose-bilkent-S5V4N54A-O1ESaqge.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  29. package/dist/assets/{dagre-KV5264BT-CPTmFPHw.js → dagre-KV5264BT-Cp3F5KTn.js} +1 -1
  30. package/dist/assets/{diagram-5BDNPKRD-B3PNrWs5.js → diagram-5BDNPKRD-DiR6_2q_.js} +1 -1
  31. package/dist/assets/{diagram-G4DWMVQ6-Cscfr6vc.js → diagram-G4DWMVQ6-w0i-p5HX.js} +1 -1
  32. package/dist/assets/{diagram-MMDJMWI5-CSfqZ-TM.js → diagram-MMDJMWI5-tIHhwUv3.js} +1 -1
  33. package/dist/assets/{diagram-TYMM5635-Cg4aYS7W.js → diagram-TYMM5635-BAeY3B19.js} +1 -1
  34. package/dist/assets/{erDiagram-SMLLAGMA-_ZqwG5pl.js → erDiagram-SMLLAGMA-Ckx_Knko.js} +1 -1
  35. package/dist/assets/{flowDiagram-DWJPFMVM-C83boxFT.js → flowDiagram-DWJPFMVM-DeoNka6J.js} +1 -1
  36. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWnIjuEi.js → ganttDiagram-T4ZO3ILL-BmGnFbEg.js} +1 -1
  37. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DrMdxZfH.js → gitGraphDiagram-UUTBAWPF-Dk48IHsx.js} +1 -1
  38. package/dist/assets/{graph-RE4I7Ty7.js → graph-BNzKGvoy.js} +1 -1
  39. package/dist/assets/{graph-Bi99_6Yf.js → graph-CI_1htl0.js} +1 -1
  40. package/dist/assets/{index-Rm2RE-c0.js → index-BEfrNBp8.js} +3 -3
  41. package/dist/assets/index-yyrA5OZd.css +1 -0
  42. package/dist/assets/{infoDiagram-42DDH7IO-BLmP4Epr.js → infoDiagram-42DDH7IO-BRf827i0.js} +1 -1
  43. package/dist/assets/{ishikawaDiagram-UXIWVN3A-yuWWshKN.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +1 -1
  44. package/dist/assets/{journeyDiagram-VCZTEJTY-BOfhaJov.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  45. package/dist/assets/{kanban-definition-6JOO6SKY-Bbolde15.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +1 -1
  46. package/dist/assets/{layout-BSf33zm8.js → layout-BI8cXFPI.js} +1 -1
  47. package/dist/assets/{linear-AvSTWMqx.js → linear-a0glcDiw.js} +1 -1
  48. package/dist/assets/{min-QBM8H4xN.js → min-vPHfnXcC.js} +1 -1
  49. package/dist/assets/{mindmap-definition-QFDTVHPH-BuvgtqIc.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +1 -1
  50. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  51. package/dist/assets/{pieDiagram-DEJITSTG-DIzF16vh.js → pieDiagram-DEJITSTG-Cno-gETh.js} +1 -1
  52. package/dist/assets/{quadrantDiagram-34T5L4WZ-D-mbUIjS.js → quadrantDiagram-34T5L4WZ-BUQM1Hfm.js} +1 -1
  53. package/dist/assets/{requirementDiagram-MS252O5E-CEs4kCLd.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +1 -1
  54. package/dist/assets/{sankeyDiagram-XADWPNL6-DFsnCr9n.js → sankeyDiagram-XADWPNL6-Crynd3_b.js} +1 -1
  55. package/dist/assets/{sequenceDiagram-FGHM5R23-BEJYdTjQ.js → sequenceDiagram-FGHM5R23-D9fZdCM8.js} +1 -1
  56. package/dist/assets/{stateDiagram-FHFEXIEX-BBXs57uY.js → stateDiagram-FHFEXIEX-CW9qVec8.js} +1 -1
  57. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  58. package/dist/assets/{timeline-definition-GMOUNBTQ-BGvLoVAY.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +1 -1
  59. package/dist/assets/{vennDiagram-DHZGUBPP-9LaBTMe0.js → vennDiagram-DHZGUBPP-BfytJcWk.js} +1 -1
  60. package/dist/assets/{wardley-RL74JXVD-P4MEqMTP.js → wardley-RL74JXVD-DLj-IjyB.js} +1 -1
  61. package/dist/assets/{wardleyDiagram-NUSXRM2D-o-tmxnlC.js → wardleyDiagram-NUSXRM2D-Ds0Ue68c.js} +1 -1
  62. package/dist/assets/{xychartDiagram-5P7HB3ND-Dpn7V6qk.js → xychartDiagram-5P7HB3ND-vjWDXFL6.js} +1 -1
  63. package/dist/index.html +3 -3
  64. package/lib/agent-string.js +7 -5
  65. package/lib/append-integrity.js +81 -0
  66. package/lib/backfill-evidence.js +109 -0
  67. package/lib/bug-escalation.js +9 -0
  68. package/lib/build-stream-schema.js +3 -1
  69. package/lib/build-stream-writer.js +25 -0
  70. package/lib/build.js +874 -170
  71. package/lib/canon-guard.js +28 -6
  72. package/lib/canon-override.js +196 -0
  73. package/lib/canon-registry.js +104 -0
  74. package/lib/cli-commands.js +144 -0
  75. package/lib/codex-preflight.js +26 -13
  76. package/lib/colleague/context.js +215 -0
  77. package/lib/colleague/writeback.js +95 -0
  78. package/lib/completion-gate.js +1421 -0
  79. package/lib/completion-writer.js +47 -47
  80. package/lib/consumer-fanout.js +105 -11
  81. package/lib/coverage-gate.js +200 -0
  82. package/lib/deps.js +164 -7
  83. package/lib/dir-lock.js +170 -0
  84. package/lib/dispatch-ledger.js +3 -3
  85. package/lib/feature-json.js +1 -1
  86. package/lib/feature-reconciler.js +8 -0
  87. package/lib/feature-validator.js +64 -1
  88. package/lib/feature-writer.js +57 -2
  89. package/lib/fluid/factory.js +167 -0
  90. package/lib/fluid/ideabox-dates.js +73 -0
  91. package/lib/fluid/ideabox-migrate.js +154 -0
  92. package/lib/fluid/ideabox-ops.js +585 -0
  93. package/lib/fluid/ideabox-view.js +146 -0
  94. package/lib/fluid/import-ideabox.js +186 -0
  95. package/lib/fluid/local-provider.js +606 -0
  96. package/lib/fluid/provider.js +684 -0
  97. package/lib/fluid/record-shape.js +214 -0
  98. package/lib/fluid/record-store.js +328 -0
  99. package/lib/fluid/render-ideabox.js +261 -0
  100. package/lib/fluid/schema.js +40 -0
  101. package/lib/fluid/smartmemory-provider.js +1695 -0
  102. package/lib/gsd.js +63 -23
  103. package/lib/guard-cli.js +175 -0
  104. package/lib/guard-custody.js +141 -0
  105. package/lib/guard-descriptors.js +530 -0
  106. package/lib/guard-enrol.js +254 -0
  107. package/lib/health-score.js +1 -1
  108. package/lib/ideabox-cli.js +315 -0
  109. package/lib/ideabox.js +121 -21
  110. package/lib/judgment/store/index.js +9 -1
  111. package/lib/judgment/store/records.js +1 -1
  112. package/lib/judgment/trace.js +380 -0
  113. package/lib/judgment-decision-write.js +277 -0
  114. package/lib/judgment-decisions.js +466 -0
  115. package/lib/judgment-gen.js +5 -1
  116. package/lib/judgment-writer.js +56 -2
  117. package/lib/lifecycle-modes.js +4 -4
  118. package/lib/lineage.js +400 -0
  119. package/lib/local-claude-connector.js +52 -1
  120. package/lib/maya-client.js +302 -0
  121. package/lib/maya-config.js +53 -0
  122. package/lib/maya-identity.js +283 -0
  123. package/lib/migrate-anon.js +5 -0
  124. package/lib/migrate-roadmap.js +15 -0
  125. package/lib/new.js +13 -1
  126. package/lib/pipeline-compat.js +104 -0
  127. package/lib/policy-catalog.js +295 -0
  128. package/lib/policy-check.js +0 -0
  129. package/lib/process-termination.js +98 -0
  130. package/lib/resolve-workspace.js +5 -1
  131. package/lib/result-normalizer.js +396 -199
  132. package/lib/roadmap-errors.js +65 -0
  133. package/lib/roadmap-preservers.js +24 -4
  134. package/lib/roadmap-residue.js +299 -0
  135. package/lib/smartmemory-client.js +614 -78
  136. package/lib/smartmemory-config.js +54 -0
  137. package/lib/smartmemory-ingest.js +19 -2
  138. package/lib/step-prompt.js +7 -6
  139. package/lib/stratum-engine.js +53 -4
  140. package/lib/stratum-mcp-client.js +271 -36
  141. package/lib/test-bootstrap.js +31 -0
  142. package/lib/tool-inventory.js +122 -0
  143. package/lib/version-check.js +91 -19
  144. package/lib/vision-writer.js +88 -1
  145. package/package.json +7 -6
  146. package/pipelines/bug-fix.stratum.yaml +205 -211
  147. package/pipelines/build-quick.profiles.json +12 -0
  148. package/pipelines/build-quick.stratum.yaml +263 -350
  149. package/pipelines/content.stratum.yaml +81 -77
  150. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  151. package/pipelines/plan.stratum.yaml +76 -86
  152. package/pipelines/refactor.stratum.yaml +125 -125
  153. package/pipelines/research.stratum.yaml +56 -58
  154. package/pipelines/review-fix.profiles.json +6 -0
  155. package/pipelines/review-fix.stratum.yaml +110 -83
  156. package/presets/team-feature.profiles.json +6 -0
  157. package/presets/team-feature.stratum.yaml +93 -66
  158. package/presets/team-research.profiles.json +6 -0
  159. package/presets/team-research.stratum.yaml +89 -80
  160. package/presets/team-review.profiles.json +8 -0
  161. package/presets/team-review.stratum.yaml +98 -80
  162. package/scripts/cost-census.mjs +70 -0
  163. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  164. package/server/agent-health.js +22 -0
  165. package/server/agent-hooks.js +14 -1
  166. package/server/agent-server.js +5 -248
  167. package/server/agent-spawn.js +3 -4
  168. package/server/agent-workspace.js +294 -0
  169. package/server/build-routes.js +6 -5
  170. package/server/build-stream-bridge.js +53 -0
  171. package/server/cc-session-watcher.js +4 -1
  172. package/server/coalescing-buffer.js +7 -1
  173. package/server/completion-projection.js +228 -0
  174. package/server/compose-mcp-tools.js +109 -23
  175. package/server/compose-mcp.js +88 -882
  176. package/server/decision-event-emit.js +41 -2
  177. package/server/decision-event-id.js +17 -0
  178. package/server/decision-events-snapshot.js +3 -0
  179. package/server/design-routes.js +14 -8
  180. package/server/feature-scan.js +76 -2
  181. package/server/file-watcher.js +170 -21
  182. package/server/ideabox-routes.js +166 -224
  183. package/server/index.js +70 -100
  184. package/server/lifecycle-guard.js +240 -10
  185. package/server/lifecycle-phase-history.js +276 -0
  186. package/server/maya-routes.js +507 -0
  187. package/server/mcp-tool-defs.js +940 -0
  188. package/server/mcp-tool-policy.js +34 -2
  189. package/server/model-tiers.js +22 -5
  190. package/server/pipeline-routes.js +21 -11
  191. package/server/project-root.js +58 -19
  192. package/server/remote-utils.js +3 -1
  193. package/server/schema-validator.js +7 -1
  194. package/server/session-manager.js +5 -6
  195. package/server/session-routes.js +3 -1
  196. package/server/stratum-client.js +57 -10
  197. package/server/stratum-sync.js +6 -3
  198. package/server/summarizer.js +3 -4
  199. package/server/supervisor.js +0 -1
  200. package/server/vision-routes.js +208 -98
  201. package/server/vision-server.js +86 -23
  202. package/server/vision-store.js +60 -6
  203. package/server/vision-utils.js +3 -4
  204. package/server/workspace-activity.js +18 -0
  205. package/server/workspace-middleware.js +2 -2
  206. package/server/workspace-runtime.js +243 -0
  207. package/server/worktree-gc.js +1 -0
  208. package/dist/assets/App-PkZzHeMj.js +0 -894
  209. package/dist/assets/channel-qVK_qn4E.js +0 -1
  210. package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +0 -1
  211. package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +0 -1
  212. package/dist/assets/clone-Pu3RyLUh.js +0 -1
  213. package/dist/assets/index-LIwREYgH.css +0 -1
  214. package/dist/assets/mobile-BnXEOE3U.js +0 -17
  215. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +0 -1
  216. package/lib/staleness.js +0 -87
  217. package/server/ideabox-cache.js +0 -77
@@ -0,0 +1,507 @@
1
+ /**
2
+ * maya-routes.js — COMP-FOH FOH-6 S1: the colleague relay.
3
+ *
4
+ * Routes:
5
+ * GET /api/maya/status — degrade-never-fail funnel probe (always a shaped
6
+ * 200, the smartmemory-routes.js model). The panel keys its state machine
7
+ * off this: not-installed → connect-smartmemory → offline →
8
+ * workspace-collision → ready.
9
+ * POST /api/maya/message — one colleague turn: compose findings context →
10
+ * relay to Maya's POST /api/chat with the server-held token → (S4)
11
+ * write-back → `{ok, reply, message_id, writeback, context}`. Turn
12
+ * failures are shaped `{ok:false, error:{kind}}` 200s so the panel can
13
+ * render the matching funnel; transport-level errors stay HTTP errors.
14
+ *
15
+ * AUTH POSTURE (design §1, Codex r1 must-fix): these routes stay BEHIND the
16
+ * remote-mode auth gate — NEVER add them to the allowlist in server/index.js.
17
+ * Allowlisted paths bypass authentication entirely (auth-middleware.js:196);
18
+ * an allowlisted /api/maya/message would publish an unauthenticated proxy
19
+ * wielding the server-held Maya credential.
20
+ *
21
+ * OPS NOTE: a Maya deployment used by this relay must keep
22
+ * MAYA_PROACTIVE_CHECK_INTERVAL_S=0 (the default). Non-zero turns Maya into a
23
+ * proactive WRITER against the workspace of the last-seen JWT
24
+ * (maya routes.py:3886-3956) — not wanted from a relay identity.
25
+ */
26
+
27
+ import {
28
+ getMayaConfig as defaultGetMayaConfig,
29
+ getFluidWorkspaceId as defaultGetFluidWorkspaceId,
30
+ hasSmartmemoryFluidProvider as defaultHasSmartmemoryFluidProvider,
31
+ } from '../lib/maya-config.js';
32
+ import { getSmartmemoryConfig as defaultGetSmartmemoryConfig } from '../lib/smartmemory-config.js';
33
+ import { createMayaClient as defaultCreateClient, MayaAuthError, MayaHttpError } from '../lib/maya-client.js';
34
+ import {
35
+ loadIdentity as defaultLoadIdentity,
36
+ ensureIdentity as defaultEnsureIdentity,
37
+ teardownIdentity as defaultTeardownIdentity,
38
+ saveIdentity as defaultSaveIdentity,
39
+ clearIdentity as defaultClearIdentity,
40
+ fetchVerifiedWorkspace as defaultFetchVerifiedWorkspace,
41
+ validateWorkspaceIsolation,
42
+ workspaceClaimOf,
43
+ MayaIdentityError,
44
+ MayaWorkspaceCollisionError,
45
+ } from '../lib/maya-identity.js';
46
+ import { ideaboxContext } from '../lib/fluid/ideabox-ops.js';
47
+ import { composeColleagueContext } from '../lib/colleague/context.js';
48
+ import { writebackReply } from '../lib/colleague/writeback.js';
49
+
50
+ /**
51
+ * The provider's declared semantic subset today (smartmemory-provider.js
52
+ * declares CHALLENGE/CONVICTION/CONTRADICTION; CALIBRATION is blocked upstream,
53
+ * "no subject"). Rendered "visibly unavailable, not faked" per COLLEAGUE-ALL-IN.
54
+ * S2's context builder still checks provider.has(CAP.X) per turn — this static
55
+ * map only feeds the panel's capability strip.
56
+ */
57
+ const CAPABILITIES = Object.freeze({
58
+ challenge: true, conviction: true, contradiction: true, calibration: false,
59
+ });
60
+
61
+ /** The real per-turn composer (S2): an ideabox ops context over the project's
62
+ * fluid provider, then the priority-ordered findings blocks. A fresh context
63
+ * per turn, deliberately — the provider holds paths, not state (the
64
+ * ideabox-routes.js reasoning). */
65
+ // Origin note: the colleague CREATES no records (scope fence), so provenance
66
+ // origin never fires on these paths — write-back attribution rides
67
+ // `author:'maya'` plus the embedded msg marker. `ui:ideabox` is the cockpit
68
+ // door these contexts genuinely come through; a colleague-specific origin
69
+ // joins the contract enum only when the panel gains a record-creating op.
70
+ async function defaultComposeContext(root, { focusId }) {
71
+ const ctx = await ideaboxContext(root, { origin: 'ui:ideabox' });
72
+ return composeColleagueContext(ctx, { focusId });
73
+ }
74
+
75
+ /** The real write-back (S4): reconcile-then-append through the shared ops
76
+ * module. Returns an OUTCOME, never throws (lib/colleague/writeback.js). */
77
+ async function defaultPerformWriteback(root, args) {
78
+ const ctx = await ideaboxContext(root, { origin: 'ui:ideabox' });
79
+ return writebackReply(ctx, args);
80
+ }
81
+
82
+ function shortReason(e) {
83
+ return e?.message?.slice(0, 300) || 'unknown error';
84
+ }
85
+
86
+ /** Map a turn failure to the panel's funnel taxonomy. */
87
+ function errorEnvelope(err) {
88
+ if (err instanceof MayaWorkspaceCollisionError) {
89
+ return { kind: 'workspace-collision', message: shortReason(err) };
90
+ }
91
+ if (err instanceof MayaAuthError) {
92
+ return {
93
+ kind: 'auth',
94
+ message: shortReason(err),
95
+ // Both actions are continuity-costing and therefore EXPLICIT — the
96
+ // relay never takes either on its own (design §2).
97
+ actions: ['re-provision — starts a fresh conversation', 'paste a new token'],
98
+ };
99
+ }
100
+ if (err instanceof MayaIdentityError) {
101
+ return { kind: err.kind ?? 'auth', message: shortReason(err) };
102
+ }
103
+ if (err instanceof MayaHttpError) {
104
+ if (err.status === 0) return { kind: 'offline', message: shortReason(err) };
105
+ return { kind: 'upstream', message: shortReason(err), status: err.status };
106
+ }
107
+ return { kind: 'upstream', message: shortReason(err) };
108
+ }
109
+
110
+ /**
111
+ * @param {import('express').Express} app
112
+ * @param {object} [deps] — every reader/client injectable for tests
113
+ */
114
+ export function attachMayaRoutes(app, {
115
+ getMayaConfig = defaultGetMayaConfig,
116
+ getFluidWorkspaceId = defaultGetFluidWorkspaceId,
117
+ hasSmartmemoryFluidProvider = defaultHasSmartmemoryFluidProvider,
118
+ getSmartmemoryConfig = defaultGetSmartmemoryConfig,
119
+ createClient = defaultCreateClient,
120
+ loadIdentity = defaultLoadIdentity,
121
+ ensureIdentity = defaultEnsureIdentity,
122
+ teardownIdentity = defaultTeardownIdentity,
123
+ saveIdentity = defaultSaveIdentity,
124
+ clearIdentity = defaultClearIdentity,
125
+ fetchVerifiedWorkspace = defaultFetchVerifiedWorkspace,
126
+ composeContext = defaultComposeContext,
127
+ performWriteback = defaultPerformWriteback,
128
+ } = {}) {
129
+ /**
130
+ * Resolve the per-request project scope. Null ONLY when the feature is not
131
+ * installed (no `maya` block at all) — a block whose `baseUrl` is missing or
132
+ * malformed is `misconfigured: true`, a FUNNEL, not a hidden button: the
133
+ * presence of the block is the feature switch, and hiding a broken config
134
+ * would violate funnel-not-hide (Codex r1 P2).
135
+ */
136
+ function scopeOf(req) {
137
+ const root = req.workspace?.root;
138
+ if (!root) return null;
139
+ let cfg;
140
+ try { cfg = getMayaConfig(root); } catch { cfg = null; }
141
+ if (!cfg) return null;
142
+ if (typeof cfg.baseUrl !== 'string' || !cfg.baseUrl) {
143
+ return { root, cfg, mode: cfg.auth?.mode ?? 'provision', misconfigured: true };
144
+ }
145
+ return { root, cfg, mode: cfg.auth?.mode ?? 'provision', misconfigured: false };
146
+ }
147
+
148
+ /** Shared pre-flight for both transports. Nothing upstream-facing starts
149
+ * until every funnel, isolation check, and context build has passed. */
150
+ async function prepareMessage(req) {
151
+ const scope = scopeOf(req);
152
+ if (!scope) return { errorBody: { ok: false, error: { kind: 'not-installed' } } };
153
+ if (scope.misconfigured) {
154
+ return {
155
+ errorBody: {
156
+ ok: false,
157
+ error: { kind: 'misconfigured', message: 'the maya block has no baseUrl' },
158
+ },
159
+ };
160
+ }
161
+ const { root, cfg, mode } = scope;
162
+
163
+ const text = String(req.body?.text ?? '').trim();
164
+ if (!text) {
165
+ return {
166
+ errorBody: { ok: false, error: { kind: 'invalid', message: 'text is required' } },
167
+ };
168
+ }
169
+ const focusId = req.body?.focusId ? String(req.body.focusId) : null;
170
+
171
+ try {
172
+ if (!hasSmartmemoryFluidProvider(root)) {
173
+ return { errorBody: { ok: false, error: { kind: 'connect-smartmemory' } } };
174
+ }
175
+
176
+ // Identity BEFORE any upstream traffic: a stored fluid-workspace token
177
+ // must be refused before provisioning side effects or a chat turn.
178
+ let stored = loadIdentity(root);
179
+ // A LEGACY static identity (stored before paste-time verification
180
+ // existed) carries no workspace claim, which would make the isolation
181
+ // check vacuous — verify-and-migrate it on first use, failing closed
182
+ // into the auth funnel when it cannot be verified (Codex r2 P1).
183
+ if (stored?.mode === 'static' && !workspaceClaimOf(stored)) {
184
+ const team = await fetchVerifiedWorkspace({
185
+ smBaseUrl: getSmartmemoryConfig(root)?.baseUrl, token: stored.access_token,
186
+ });
187
+ const candidate = { ...stored, team_id: team };
188
+ // Validate BEFORE persisting: writing a colliding claim first would
189
+ // pin the refusal to a stale verification — a colliding token is
190
+ // refused each turn against a FRESH upstream answer instead.
191
+ validateWorkspaceIsolation(candidate, getFluidWorkspaceId(root));
192
+ saveIdentity(root, candidate);
193
+ stored = candidate;
194
+ }
195
+ validateWorkspaceIsolation(stored, getFluidWorkspaceId(root));
196
+ const identity = stored ?? await ensureIdentity(root, {
197
+ smBaseUrl: getSmartmemoryConfig(root)?.baseUrl, mode,
198
+ });
199
+ // A lazily-provisioned identity gets the same refusal before first use.
200
+ if (!stored) validateWorkspaceIsolation(identity, getFluidWorkspaceId(root));
201
+ // Complete a pending NDA on a stored identity (crash between provision
202
+ // and accept) — same identity, never a fresh one.
203
+ if (stored && stored.mode === 'provision' && !stored.ndaAccepted) {
204
+ await ensureIdentity(root, { smBaseUrl: getSmartmemoryConfig(root)?.baseUrl, mode });
205
+ }
206
+
207
+ // Context composition is load-bearing: a failure here is a funnel, never
208
+ // a silent fall-through to plain chat (COLLEAGUE-ALL-IN).
209
+ let context;
210
+ try {
211
+ context = await composeContext(root, { focusId });
212
+ } catch (e) {
213
+ return {
214
+ errorBody: {
215
+ ok: false,
216
+ error: { kind: 'context', message: `findings composition failed: ${shortReason(e)}` },
217
+ },
218
+ };
219
+ }
220
+
221
+ const client = createClient({
222
+ baseUrl: cfg.baseUrl,
223
+ getToken: () => loadIdentity(root)?.access_token ?? identity.access_token,
224
+ });
225
+ return {
226
+ root,
227
+ text,
228
+ focusId,
229
+ context,
230
+ client,
231
+ writebackEnabled: !!(focusId && req.body?.writeback !== false),
232
+ };
233
+ } catch (err) {
234
+ return { errorBody: { ok: false, error: errorEnvelope(err) } };
235
+ }
236
+ }
237
+
238
+ async function completeWriteback({ root, focusId, reply, enabled }) {
239
+ if (!enabled) return null;
240
+ try {
241
+ return await performWriteback(root, {
242
+ focusId, messageId: reply.message_id, text: reply.response,
243
+ });
244
+ } catch (e) {
245
+ return { outcome: 'failed', focusId, reason: shortReason(e) };
246
+ }
247
+ }
248
+
249
+ app.get('/api/maya/status', async (req, res) => {
250
+ try {
251
+ const scope = scopeOf(req);
252
+ if (!scope) return res.json({ enabled: false });
253
+ const { root, cfg, mode } = scope;
254
+
255
+ if (scope.misconfigured) {
256
+ return res.json({
257
+ enabled: true, state: 'misconfigured',
258
+ error: 'the maya block in .compose/compose.json has no baseUrl',
259
+ });
260
+ }
261
+
262
+ if (!hasSmartmemoryFluidProvider(root)) {
263
+ // COLLEAGUE-ALL-IN: no degraded plain-chat mode — the colleague
264
+ // hard-requires the SmartMemory provider (service stack; lite lacks
265
+ // the capability surface).
266
+ return res.json({ enabled: true, state: 'connect-smartmemory' });
267
+ }
268
+
269
+ const identity = loadIdentity(root);
270
+ const client = createClient({
271
+ baseUrl: cfg.baseUrl,
272
+ getToken: () => identity?.access_token ?? '',
273
+ });
274
+ const alive = await client.health();
275
+ if (!alive.ok) {
276
+ return res.json({
277
+ enabled: true, state: 'offline', baseUrl: cfg.baseUrl,
278
+ hint: 'start the Maya stack (dev.sh — port 9005), then reopen the panel',
279
+ });
280
+ }
281
+
282
+ try {
283
+ validateWorkspaceIsolation(identity, getFluidWorkspaceId(root));
284
+ } catch (err) {
285
+ return res.json({ enabled: true, state: 'workspace-collision', error: shortReason(err) });
286
+ }
287
+
288
+ // Static mode with no pasted token cannot chat — reporting 'ready' would
289
+ // promise a conversation the first turn immediately refuses (Codex r1
290
+ // P2). The auth funnel (paste action) is the honest state.
291
+ if (mode === 'static' && !identity) {
292
+ return res.json({
293
+ enabled: true, state: 'auth',
294
+ auth: { mode, identity: false },
295
+ error: 'static token mode with no token stored — paste one',
296
+ });
297
+ }
298
+
299
+ return res.json({
300
+ enabled: true,
301
+ state: 'ready',
302
+ auth: { mode, identity: !!identity },
303
+ capabilities: CAPABILITIES,
304
+ });
305
+ } catch (e) {
306
+ // Degrade-never-fail: an unexpected reader failure is still a shaped 200.
307
+ return res.json({ enabled: false, error: shortReason(e) });
308
+ }
309
+ });
310
+
311
+ app.post('/api/maya/message', async (req, res) => {
312
+ const prepared = await prepareMessage(req);
313
+ if (prepared.errorBody) return res.json(prepared.errorBody);
314
+ const { root, text, focusId, context, client, writebackEnabled } = prepared;
315
+
316
+ if (req.query?.stream !== '1') {
317
+ try {
318
+ const reply = await client.chat({ message: text, channelContext: context.blocks });
319
+
320
+ // Write-back (S4): the chat result is AUTHORITATIVE — her reply renders
321
+ // whatever happens here, and a write-back failure is an outcome field,
322
+ // never a failed turn (resending the chat would double-charge her
323
+ // session with the same turn). Toggleable per request, default on.
324
+ const writeback = await completeWriteback({
325
+ root, focusId, reply, enabled: writebackEnabled,
326
+ });
327
+
328
+ return res.json({
329
+ ok: true,
330
+ reply: reply.response,
331
+ message_id: reply.message_id,
332
+ memory_available: reply.memory_available ?? null,
333
+ writeback,
334
+ context: {
335
+ sent: context.blocks.map((b) => b.author),
336
+ omissions: context.omissions,
337
+ // The composed blocks themselves — the panel's findings accordion
338
+ // renders these (design §4); authors carry the provenance labels.
339
+ blocks: context.blocks,
340
+ },
341
+ });
342
+ } catch (err) {
343
+ return res.json({ ok: false, error: errorEnvelope(err) });
344
+ }
345
+ }
346
+
347
+ // Downstream cancellation is deliberately NOT connected to the upstream
348
+ // controller. Maya's final and the durable write-back still complete.
349
+ let closed = false;
350
+ let upstreamOpened = false;
351
+ res.on('close', () => { closed = true; });
352
+ const writable = () => !closed && !res.writableEnded && !res.destroyed;
353
+ const openStream = () => {
354
+ upstreamOpened = true;
355
+ if (!writable() || res.headersSent) return;
356
+ try {
357
+ res.status(200);
358
+ res.setHeader('Content-Type', 'text/event-stream');
359
+ res.setHeader('Cache-Control', 'no-cache');
360
+ // Upstream Maya sends this too — without it the documented nginx
361
+ // reverse-proxy deployment buffers the SSE body until res.end(),
362
+ // collapsing S5 back into a one-shot response (Codex r1 P2).
363
+ res.setHeader('X-Accel-Buffering', 'no');
364
+ res.flushHeaders();
365
+ } catch {
366
+ closed = true;
367
+ }
368
+ };
369
+ const writeEvent = (event, payload) => {
370
+ if (!writable()) return;
371
+ try {
372
+ res.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
373
+ } catch {
374
+ closed = true;
375
+ }
376
+ };
377
+ const endStream = () => {
378
+ if (!writable()) return;
379
+ try { res.end(); } catch { closed = true; }
380
+ };
381
+
382
+ try {
383
+ const reply = await client.chatStream({
384
+ message: text,
385
+ channelContext: context.blocks,
386
+ onToken: (token) => {
387
+ openStream();
388
+ writeEvent('token', { text: token });
389
+ },
390
+ });
391
+ openStream();
392
+ writeEvent('final', {
393
+ ok: true,
394
+ reply: reply.response,
395
+ message_id: reply.message_id,
396
+ memory_available: reply.memory_available ?? null,
397
+ context: {
398
+ sent: context.blocks.map((b) => b.author),
399
+ omissions: context.omissions,
400
+ blocks: context.blocks,
401
+ },
402
+ });
403
+
404
+ const writeback = await completeWriteback({
405
+ root, focusId, reply, enabled: writebackEnabled,
406
+ });
407
+ if (writebackEnabled) writeEvent('writeback', writeback);
408
+ return endStream();
409
+ } catch (err) {
410
+ if (upstreamOpened || err?.streamStarted) {
411
+ openStream();
412
+ writeEvent('error', errorEnvelope(err));
413
+ return endStream();
414
+ }
415
+ return res.json({ ok: false, error: errorEnvelope(err) });
416
+ }
417
+ });
418
+
419
+ /**
420
+ * The auth funnel's two EXPLICIT recovery actions (design §2). Both are
421
+ * continuity-costing, so neither ever happens implicitly — this endpoint
422
+ * only exists for the user pressing the button.
423
+ */
424
+ app.post('/api/maya/identity', async (req, res) => {
425
+ const scope = scopeOf(req);
426
+ if (!scope) return res.json({ ok: false, error: { kind: 'not-installed' } });
427
+ const { root } = scope;
428
+ const action = req.body?.action;
429
+
430
+ const { mode } = scope;
431
+
432
+ try {
433
+ if (action === 'reprovision') {
434
+ // Provision-mode only: in static mode clearing the store just re-enters
435
+ // the auth funnel (the next turn refuses to provision), so offering it
436
+ // would be a dead-end action (Codex r1 P2).
437
+ if (mode === 'static') {
438
+ return res.json({
439
+ ok: false,
440
+ error: { kind: 'invalid', message: 're-provision applies to provision mode — paste a token instead' },
441
+ });
442
+ }
443
+ // Best-effort upstream teardown; the local clear is the real action —
444
+ // the next turn lazily provisions a fresh identity (fresh thread).
445
+ try {
446
+ await teardownIdentity(root, { smBaseUrl: getSmartmemoryConfig(root)?.baseUrl });
447
+ } catch {
448
+ clearIdentity(root);
449
+ }
450
+ return res.json({ ok: true });
451
+ }
452
+
453
+ if (action === 'static') {
454
+ const token = String(req.body?.token ?? '').trim();
455
+ if (!token) {
456
+ return res.json({ ok: false, error: { kind: 'invalid', message: 'token is required' } });
457
+ }
458
+ // Cheap offline pre-check: a JWT that self-declares the fluid
459
+ // workspace is refused without a network round-trip.
460
+ validateWorkspaceIsolation({ mode: 'static', access_token: token }, getFluidWorkspaceId(root));
461
+ // Authoritative check — FAILS CLOSED. Real tokens carry no workspace
462
+ // claims in the JWT (VERIFY-1), so the pre-check alone is vacuous; the
463
+ // verified workspace comes from the service's own user record, and a
464
+ // token that cannot be verified is not stored (Codex r1 P1).
465
+ const verifiedTeam = await fetchVerifiedWorkspace({
466
+ smBaseUrl: getSmartmemoryConfig(root)?.baseUrl, token,
467
+ });
468
+ const candidate = { mode: 'static', access_token: token, team_id: verifiedTeam };
469
+ validateWorkspaceIsolation(candidate, getFluidWorkspaceId(root));
470
+ saveIdentity(root, candidate);
471
+ return res.json({ ok: true });
472
+ }
473
+
474
+ return res.json({ ok: false, error: { kind: 'invalid', message: `unknown action: ${action}` } });
475
+ } catch (err) {
476
+ return res.json({ ok: false, error: errorEnvelope(err) });
477
+ }
478
+ });
479
+
480
+ /**
481
+ * Retry a failed write-back — APPEND-ONLY, never the chat turn. Runs the
482
+ * same reconcile-then-append as the message path: if the original append
483
+ * actually landed (a 'failed' outcome does not prove it didn't), the
484
+ * `message_id` marker dedups this into a no-op.
485
+ */
486
+ app.post('/api/maya/writeback-retry', async (req, res) => {
487
+ const scope = scopeOf(req);
488
+ if (!scope) return res.json({ ok: false, error: { kind: 'not-installed' } });
489
+
490
+ const focusId = String(req.body?.focusId ?? '').trim();
491
+ const messageId = String(req.body?.message_id ?? '').trim();
492
+ const text = String(req.body?.text ?? '');
493
+ if (!focusId || !messageId || !text.trim()) {
494
+ return res.json({
495
+ ok: false,
496
+ error: { kind: 'invalid', message: 'focusId, message_id and text are required' },
497
+ });
498
+ }
499
+
500
+ try {
501
+ const writeback = await performWriteback(scope.root, { focusId, messageId, text });
502
+ return res.json({ ok: true, writeback });
503
+ } catch (e) {
504
+ return res.json({ ok: true, writeback: { outcome: 'failed', focusId, reason: shortReason(e) } });
505
+ }
506
+ });
507
+ }