@smartmemory/compose 0.3.7 → 0.3.8

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 (215) hide show
  1. package/.compose-deps.json +1 -13
  2. package/README.md +72 -5
  3. package/bin/compose.js +470 -351
  4. package/bin/judgment-migrate.js +387 -0
  5. package/contracts/comp-obs-contract.schema.json +9 -3
  6. package/contracts/fluid-record.schema.json +209 -0
  7. package/contracts/lifecycle-backfill.schema.json +322 -0
  8. package/dist/assets/App-Z4MU-H_F.js +916 -0
  9. package/dist/assets/{_baseUniq-Bo837sRJ.js → _baseUniq-ClWoCPFl.js} +1 -1
  10. package/dist/assets/{arc-BafGpyqE.js → arc-DY26UIVo.js} +1 -1
  11. package/dist/assets/{architectureDiagram-Q4EWVU46-BOBfUsqL.js → architectureDiagram-Q4EWVU46-6Ggq4DqJ.js} +1 -1
  12. package/dist/assets/{blockDiagram-DXYQGD6D-Dwodev1a.js → blockDiagram-DXYQGD6D-CH3Ked0l.js} +1 -1
  13. package/dist/assets/{browser-1ntj1-x_.js → browser-BWkrenen.js} +1 -1
  14. package/dist/assets/{c4Diagram-AHTNJAMY-CU_bhYag.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  15. package/dist/assets/channel-SnZzzh7k.js +1 -0
  16. package/dist/assets/{chunk-4BX2VUAB-p8WsDwnO.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  17. package/dist/assets/{chunk-4TB4RGXK-B8h7-eR0.js → chunk-4TB4RGXK-JytR14a9.js} +1 -1
  18. package/dist/assets/{chunk-55IACEB6-DxeEr98s.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  19. package/dist/assets/{chunk-EDXVE4YY-BYt8F151.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  20. package/dist/assets/{chunk-FMBD7UC4-DGSOVeie.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  21. package/dist/assets/{chunk-OYMX7WX6-B-QdgYR2.js → chunk-OYMX7WX6-BySQzVxc.js} +1 -1
  22. package/dist/assets/{chunk-QZHKN3VN-Du5UAZLs.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  23. package/dist/assets/{chunk-YZCP3GAM-C8JbNBSk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  24. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  25. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  26. package/dist/assets/clone-DgklGjHm.js +1 -0
  27. package/dist/assets/{cose-bilkent-S5V4N54A-O1ESaqge.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  28. package/dist/assets/{dagre-KV5264BT-CPTmFPHw.js → dagre-KV5264BT-Cp3F5KTn.js} +1 -1
  29. package/dist/assets/{diagram-5BDNPKRD-B3PNrWs5.js → diagram-5BDNPKRD-DiR6_2q_.js} +1 -1
  30. package/dist/assets/{diagram-G4DWMVQ6-Cscfr6vc.js → diagram-G4DWMVQ6-w0i-p5HX.js} +1 -1
  31. package/dist/assets/{diagram-MMDJMWI5-CSfqZ-TM.js → diagram-MMDJMWI5-tIHhwUv3.js} +1 -1
  32. package/dist/assets/{diagram-TYMM5635-Cg4aYS7W.js → diagram-TYMM5635-BAeY3B19.js} +1 -1
  33. package/dist/assets/{erDiagram-SMLLAGMA-_ZqwG5pl.js → erDiagram-SMLLAGMA-Ckx_Knko.js} +1 -1
  34. package/dist/assets/{flowDiagram-DWJPFMVM-C83boxFT.js → flowDiagram-DWJPFMVM-DeoNka6J.js} +1 -1
  35. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWnIjuEi.js → ganttDiagram-T4ZO3ILL-BmGnFbEg.js} +1 -1
  36. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DrMdxZfH.js → gitGraphDiagram-UUTBAWPF-Dk48IHsx.js} +1 -1
  37. package/dist/assets/{graph-RE4I7Ty7.js → graph-BNzKGvoy.js} +1 -1
  38. package/dist/assets/{graph-Bi99_6Yf.js → graph-CI_1htl0.js} +1 -1
  39. package/dist/assets/{index-Rm2RE-c0.js → index-BEfrNBp8.js} +3 -3
  40. package/dist/assets/index-yyrA5OZd.css +1 -0
  41. package/dist/assets/{infoDiagram-42DDH7IO-BLmP4Epr.js → infoDiagram-42DDH7IO-BRf827i0.js} +1 -1
  42. package/dist/assets/{ishikawaDiagram-UXIWVN3A-yuWWshKN.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +1 -1
  43. package/dist/assets/{journeyDiagram-VCZTEJTY-BOfhaJov.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  44. package/dist/assets/{kanban-definition-6JOO6SKY-Bbolde15.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +1 -1
  45. package/dist/assets/{layout-BSf33zm8.js → layout-BI8cXFPI.js} +1 -1
  46. package/dist/assets/{linear-AvSTWMqx.js → linear-a0glcDiw.js} +1 -1
  47. package/dist/assets/{min-QBM8H4xN.js → min-vPHfnXcC.js} +1 -1
  48. package/dist/assets/{mindmap-definition-QFDTVHPH-BuvgtqIc.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +1 -1
  49. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  50. package/dist/assets/{pieDiagram-DEJITSTG-DIzF16vh.js → pieDiagram-DEJITSTG-Cno-gETh.js} +1 -1
  51. package/dist/assets/{quadrantDiagram-34T5L4WZ-D-mbUIjS.js → quadrantDiagram-34T5L4WZ-BUQM1Hfm.js} +1 -1
  52. package/dist/assets/{requirementDiagram-MS252O5E-CEs4kCLd.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +1 -1
  53. package/dist/assets/{sankeyDiagram-XADWPNL6-DFsnCr9n.js → sankeyDiagram-XADWPNL6-Crynd3_b.js} +1 -1
  54. package/dist/assets/{sequenceDiagram-FGHM5R23-BEJYdTjQ.js → sequenceDiagram-FGHM5R23-D9fZdCM8.js} +1 -1
  55. package/dist/assets/{stateDiagram-FHFEXIEX-BBXs57uY.js → stateDiagram-FHFEXIEX-CW9qVec8.js} +1 -1
  56. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  57. package/dist/assets/{timeline-definition-GMOUNBTQ-BGvLoVAY.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +1 -1
  58. package/dist/assets/{vennDiagram-DHZGUBPP-9LaBTMe0.js → vennDiagram-DHZGUBPP-BfytJcWk.js} +1 -1
  59. package/dist/assets/{wardley-RL74JXVD-P4MEqMTP.js → wardley-RL74JXVD-DLj-IjyB.js} +1 -1
  60. package/dist/assets/{wardleyDiagram-NUSXRM2D-o-tmxnlC.js → wardleyDiagram-NUSXRM2D-Ds0Ue68c.js} +1 -1
  61. package/dist/assets/{xychartDiagram-5P7HB3ND-Dpn7V6qk.js → xychartDiagram-5P7HB3ND-vjWDXFL6.js} +1 -1
  62. package/dist/index.html +3 -3
  63. package/lib/agent-string.js +7 -5
  64. package/lib/append-integrity.js +81 -0
  65. package/lib/backfill-evidence.js +109 -0
  66. package/lib/bug-escalation.js +9 -0
  67. package/lib/build-stream-schema.js +3 -1
  68. package/lib/build-stream-writer.js +25 -0
  69. package/lib/build.js +874 -170
  70. package/lib/canon-guard.js +28 -6
  71. package/lib/canon-override.js +196 -0
  72. package/lib/canon-registry.js +104 -0
  73. package/lib/cli-commands.js +144 -0
  74. package/lib/codex-preflight.js +26 -13
  75. package/lib/colleague/context.js +215 -0
  76. package/lib/colleague/writeback.js +95 -0
  77. package/lib/completion-gate.js +1421 -0
  78. package/lib/completion-writer.js +47 -47
  79. package/lib/consumer-fanout.js +105 -11
  80. package/lib/coverage-gate.js +200 -0
  81. package/lib/dir-lock.js +170 -0
  82. package/lib/dispatch-ledger.js +3 -3
  83. package/lib/feature-json.js +1 -1
  84. package/lib/feature-reconciler.js +8 -0
  85. package/lib/feature-validator.js +64 -1
  86. package/lib/feature-writer.js +57 -2
  87. package/lib/fluid/factory.js +167 -0
  88. package/lib/fluid/ideabox-dates.js +73 -0
  89. package/lib/fluid/ideabox-migrate.js +154 -0
  90. package/lib/fluid/ideabox-ops.js +585 -0
  91. package/lib/fluid/ideabox-view.js +146 -0
  92. package/lib/fluid/import-ideabox.js +186 -0
  93. package/lib/fluid/local-provider.js +606 -0
  94. package/lib/fluid/provider.js +684 -0
  95. package/lib/fluid/record-shape.js +214 -0
  96. package/lib/fluid/record-store.js +328 -0
  97. package/lib/fluid/render-ideabox.js +261 -0
  98. package/lib/fluid/schema.js +40 -0
  99. package/lib/fluid/smartmemory-provider.js +1695 -0
  100. package/lib/gsd.js +63 -23
  101. package/lib/guard-cli.js +175 -0
  102. package/lib/guard-custody.js +141 -0
  103. package/lib/guard-descriptors.js +530 -0
  104. package/lib/guard-enrol.js +254 -0
  105. package/lib/health-score.js +1 -1
  106. package/lib/ideabox-cli.js +315 -0
  107. package/lib/ideabox.js +121 -21
  108. package/lib/judgment/store/index.js +9 -1
  109. package/lib/judgment/store/records.js +1 -1
  110. package/lib/judgment/trace.js +380 -0
  111. package/lib/judgment-decision-write.js +277 -0
  112. package/lib/judgment-decisions.js +466 -0
  113. package/lib/judgment-gen.js +5 -1
  114. package/lib/judgment-writer.js +56 -2
  115. package/lib/lifecycle-modes.js +4 -4
  116. package/lib/lineage.js +400 -0
  117. package/lib/local-claude-connector.js +52 -1
  118. package/lib/maya-client.js +302 -0
  119. package/lib/maya-config.js +53 -0
  120. package/lib/maya-identity.js +283 -0
  121. package/lib/migrate-anon.js +5 -0
  122. package/lib/migrate-roadmap.js +15 -0
  123. package/lib/new.js +13 -1
  124. package/lib/pipeline-compat.js +104 -0
  125. package/lib/policy-catalog.js +295 -0
  126. package/lib/policy-check.js +0 -0
  127. package/lib/process-termination.js +98 -0
  128. package/lib/resolve-workspace.js +5 -1
  129. package/lib/result-normalizer.js +396 -199
  130. package/lib/roadmap-errors.js +65 -0
  131. package/lib/roadmap-preservers.js +24 -4
  132. package/lib/roadmap-residue.js +299 -0
  133. package/lib/smartmemory-client.js +614 -78
  134. package/lib/smartmemory-config.js +54 -0
  135. package/lib/smartmemory-ingest.js +19 -2
  136. package/lib/step-prompt.js +7 -6
  137. package/lib/stratum-engine.js +53 -4
  138. package/lib/stratum-mcp-client.js +271 -36
  139. package/lib/test-bootstrap.js +31 -0
  140. package/lib/tool-inventory.js +122 -0
  141. package/lib/version-check.js +91 -19
  142. package/lib/vision-writer.js +88 -1
  143. package/package.json +7 -6
  144. package/pipelines/bug-fix.stratum.yaml +205 -211
  145. package/pipelines/build-quick.profiles.json +12 -0
  146. package/pipelines/build-quick.stratum.yaml +263 -350
  147. package/pipelines/content.stratum.yaml +81 -77
  148. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  149. package/pipelines/plan.stratum.yaml +76 -86
  150. package/pipelines/refactor.stratum.yaml +125 -125
  151. package/pipelines/research.stratum.yaml +56 -58
  152. package/pipelines/review-fix.profiles.json +6 -0
  153. package/pipelines/review-fix.stratum.yaml +110 -83
  154. package/presets/team-feature.profiles.json +6 -0
  155. package/presets/team-feature.stratum.yaml +93 -66
  156. package/presets/team-research.profiles.json +6 -0
  157. package/presets/team-research.stratum.yaml +89 -80
  158. package/presets/team-review.profiles.json +8 -0
  159. package/presets/team-review.stratum.yaml +98 -80
  160. package/scripts/cost-census.mjs +70 -0
  161. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  162. package/server/agent-health.js +22 -0
  163. package/server/agent-hooks.js +14 -1
  164. package/server/agent-server.js +5 -248
  165. package/server/agent-spawn.js +3 -4
  166. package/server/agent-workspace.js +294 -0
  167. package/server/build-routes.js +6 -5
  168. package/server/build-stream-bridge.js +53 -0
  169. package/server/cc-session-watcher.js +4 -1
  170. package/server/coalescing-buffer.js +7 -1
  171. package/server/completion-projection.js +228 -0
  172. package/server/compose-mcp-tools.js +109 -23
  173. package/server/compose-mcp.js +88 -882
  174. package/server/decision-event-emit.js +41 -2
  175. package/server/decision-event-id.js +17 -0
  176. package/server/decision-events-snapshot.js +3 -0
  177. package/server/design-routes.js +14 -8
  178. package/server/feature-scan.js +76 -2
  179. package/server/file-watcher.js +170 -21
  180. package/server/ideabox-routes.js +166 -224
  181. package/server/index.js +70 -100
  182. package/server/lifecycle-guard.js +240 -10
  183. package/server/lifecycle-phase-history.js +276 -0
  184. package/server/maya-routes.js +507 -0
  185. package/server/mcp-tool-defs.js +940 -0
  186. package/server/mcp-tool-policy.js +34 -2
  187. package/server/model-tiers.js +22 -5
  188. package/server/pipeline-routes.js +21 -11
  189. package/server/project-root.js +58 -19
  190. package/server/remote-utils.js +3 -1
  191. package/server/schema-validator.js +7 -1
  192. package/server/session-manager.js +5 -6
  193. package/server/session-routes.js +3 -1
  194. package/server/stratum-client.js +57 -10
  195. package/server/stratum-sync.js +6 -3
  196. package/server/summarizer.js +3 -4
  197. package/server/supervisor.js +0 -1
  198. package/server/vision-routes.js +208 -98
  199. package/server/vision-server.js +86 -23
  200. package/server/vision-store.js +60 -6
  201. package/server/vision-utils.js +3 -4
  202. package/server/workspace-activity.js +18 -0
  203. package/server/workspace-middleware.js +2 -2
  204. package/server/workspace-runtime.js +243 -0
  205. package/server/worktree-gc.js +1 -0
  206. package/dist/assets/App-PkZzHeMj.js +0 -894
  207. package/dist/assets/channel-qVK_qn4E.js +0 -1
  208. package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +0 -1
  209. package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +0 -1
  210. package/dist/assets/clone-Pu3RyLUh.js +0 -1
  211. package/dist/assets/index-LIwREYgH.css +0 -1
  212. package/dist/assets/mobile-BnXEOE3U.js +0 -17
  213. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +0 -1
  214. package/lib/staleness.js +0 -87
  215. package/server/ideabox-cache.js +0 -77
@@ -0,0 +1,302 @@
1
+ /**
2
+ * maya-client.js — COMP-FOH FOH-6 S1
3
+ *
4
+ * HTTP client for Maya's chat surface (`/Users/ruze/reg/my/SmartMemory/maya/`,
5
+ * read-only upstream — POST /api/chat, routes.py:5354; GET /health,
6
+ * maya_server.py:46). Speaks HTTP and nothing else; context composition and
7
+ * write-back belong to the relay.
8
+ *
9
+ * Failure conventions, per design-foh-6.md §2 and the smartmemory-client
10
+ * doctrine:
11
+ * - 401 → ONE retry with the SAME token (transient), then MayaAuthError.
12
+ * Never re-provision here: the identity owns the standing conversation,
13
+ * and a 401 does not prove expiry.
14
+ * - a 2xx we cannot trust (non-JSON body, success !== true, missing
15
+ * message_id) is a failure with kind 'malformed-response', not a success —
16
+ * message_id is the write-back idempotency key, so a reply without one
17
+ * cannot be treated as a completed turn.
18
+ * - network failure / deadline → status 0.
19
+ * - health() never throws.
20
+ *
21
+ * The chat deadline defaults generously (her turn does LLM work upstream);
22
+ * the live VERIFY used 120s.
23
+ */
24
+
25
+ /** Non-auth failure of a Maya call. */
26
+ export class MayaHttpError extends Error {
27
+ constructor(message, status, kind) {
28
+ super(message);
29
+ this.name = 'MayaHttpError';
30
+ this.status = status;
31
+ this.kind = kind;
32
+ }
33
+ }
34
+
35
+ /** 401 after the single same-token retry. The relay maps this to the auth
36
+ * funnel with its explicit continuity-costing actions. */
37
+ export class MayaAuthError extends MayaHttpError {
38
+ constructor(message) {
39
+ super(message, 401);
40
+ this.name = 'MayaAuthError';
41
+ }
42
+ }
43
+
44
+ /**
45
+ * @param {object} opts
46
+ * @param {string} opts.baseUrl
47
+ * @param {() => (string|Promise<string>)} opts.getToken read at CALL time
48
+ * @param {number} [opts.timeoutMs] health deadline (default 3000)
49
+ * @param {number} [opts.chatTimeoutMs] chat deadline (default 120000)
50
+ */
51
+ export function createMayaClient({ baseUrl, getToken, timeoutMs = 3000, chatTimeoutMs = 120000 }) {
52
+ async function fetchJson(path, { init, deadline }) {
53
+ const controller = new AbortController();
54
+ const timer = setTimeout(() => controller.abort(), deadline);
55
+ let res;
56
+ let text;
57
+ try {
58
+ res = await fetch(`${baseUrl}${path}`, { ...init, signal: controller.signal });
59
+ text = await res.text();
60
+ } catch (err) {
61
+ throw new MayaHttpError(
62
+ `maya: ${path} request failed: ${err?.message ?? 'unknown error'}`, 0,
63
+ );
64
+ } finally {
65
+ clearTimeout(timer);
66
+ }
67
+ let parsed = null;
68
+ let parseFailed = false;
69
+ try { parsed = JSON.parse(text); } catch { parseFailed = true; }
70
+ return { status: res.status, ok: res.ok, body: parsed, parseFailed };
71
+ }
72
+
73
+ /** Liveness only. Unauthenticated, never throws. */
74
+ async function health() {
75
+ try {
76
+ const res = await fetchJson('/health', { deadline: timeoutMs });
77
+ return { ok: res.ok, status: res.status };
78
+ } catch {
79
+ return { ok: false };
80
+ }
81
+ }
82
+
83
+ /**
84
+ * One conversational turn. `channelContext` travels verbatim as the wire's
85
+ * `channel_context: [{author, text}]` and is OMITTED (not sent empty) when
86
+ * absent — an empty array still exercises Maya's context-injection path.
87
+ *
88
+ * @param {{message: string, channelContext?: Array<{author: string, text: string}>,
89
+ * surface?: string, timezone?: string}} args
90
+ * @returns {Promise<{success: true, response: string, message_id: string,
91
+ * memory_available?: boolean}>}
92
+ */
93
+ async function chat({ message, channelContext, surface, timezone = 'UTC' }) {
94
+ const body = { message, timezone };
95
+ if (Array.isArray(channelContext) && channelContext.length) {
96
+ body.channel_context = channelContext;
97
+ }
98
+ if (surface !== undefined) body.surface = surface;
99
+
100
+ // Captured ONCE per chat() call: "one same-token retry" is a literal
101
+ // contract — reading the token again for the retry could pick up a
102
+ // concurrent paste/re-provision and silently move the retry onto a
103
+ // different identity and conversation (Codex r1 P2).
104
+ const token = await getToken();
105
+ const send = () => fetchJson('/api/chat', {
106
+ deadline: chatTimeoutMs,
107
+ init: {
108
+ method: 'POST',
109
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
110
+ body: JSON.stringify(body),
111
+ },
112
+ });
113
+
114
+ let res = await send();
115
+ if (res.status === 401) res = await send(); // one same-token retry
116
+ if (res.status === 401) {
117
+ throw new MayaAuthError('maya: authentication rejected after one same-token retry');
118
+ }
119
+ if (!res.ok) {
120
+ throw new MayaHttpError(`maya: chat failed (HTTP ${res.status})`, res.status);
121
+ }
122
+ if (res.parseFailed) {
123
+ throw new MayaHttpError(
124
+ `maya: chat returned a 2xx (HTTP ${res.status}) with a non-JSON body`,
125
+ res.status, 'malformed-response',
126
+ );
127
+ }
128
+ const reply = res.body;
129
+ if (reply?.success !== true || typeof reply?.response !== 'string'
130
+ || typeof reply?.message_id !== 'string' || !reply.message_id) {
131
+ throw new MayaHttpError(
132
+ 'maya: chat returned a 2xx body this relay cannot trust '
133
+ + '(needs success:true, a response string, and a message_id)',
134
+ res.status, 'malformed-response',
135
+ );
136
+ }
137
+ return reply;
138
+ }
139
+
140
+ /** Streaming conversational turn. Token chunks are display-only; only a
141
+ * validated final envelope completes the turn. */
142
+ async function chatStream({ message, channelContext, surface, timezone = 'UTC', onToken }) {
143
+ const body = { message, timezone };
144
+ if (Array.isArray(channelContext) && channelContext.length) {
145
+ body.channel_context = channelContext;
146
+ }
147
+ if (surface !== undefined) body.surface = surface;
148
+
149
+ // One deadline and one captured token cover both the HTTP-status retry and
150
+ // the complete response stream. A mid-stream 401 is never retried.
151
+ const token = await getToken();
152
+ const controller = new AbortController();
153
+ const timer = setTimeout(() => controller.abort(), chatTimeoutMs);
154
+ let streamOpened = false;
155
+ let detached = false;
156
+
157
+ const send = () => fetch(`${baseUrl}/api/chat/stream`, {
158
+ method: 'POST',
159
+ headers: {
160
+ 'Content-Type': 'application/json',
161
+ Accept: 'text/event-stream',
162
+ Authorization: `Bearer ${token}`,
163
+ },
164
+ body: JSON.stringify(body),
165
+ signal: controller.signal,
166
+ });
167
+
168
+ try {
169
+ let res = await send();
170
+ if (res.status === 401) {
171
+ await res.body?.cancel();
172
+ res = await send();
173
+ }
174
+ if (res.status === 401) {
175
+ throw new MayaAuthError('maya: authentication rejected after one same-token retry');
176
+ }
177
+ if (!res.ok) {
178
+ throw new MayaHttpError(`maya: chat failed (HTTP ${res.status})`, res.status);
179
+ }
180
+ const contentType = res.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase();
181
+ if (contentType !== 'text/event-stream' || !res.body) {
182
+ throw new MayaHttpError(
183
+ `maya: chat stream returned a 2xx (HTTP ${res.status}) without text/event-stream`,
184
+ res.status, 'malformed-response',
185
+ );
186
+ }
187
+ streamOpened = true;
188
+
189
+ const handleFrame = (frame) => {
190
+ let event = '';
191
+ const data = [];
192
+ for (const line of frame.split('\n')) {
193
+ if (line.startsWith(':')) continue;
194
+ const colon = line.indexOf(':');
195
+ const field = colon === -1 ? line : line.slice(0, colon);
196
+ let value = colon === -1 ? '' : line.slice(colon + 1);
197
+ if (value.startsWith(' ')) value = value.slice(1);
198
+ if (field === 'event') event = value;
199
+ if (field === 'data') data.push(value);
200
+ }
201
+ if (!['token', 'final', 'error'].includes(event)) return null;
202
+
203
+ let envelope;
204
+ try { envelope = JSON.parse(data.join('\n')); } catch {
205
+ throw new MayaHttpError(
206
+ 'maya: chat stream contained a malformed event payload',
207
+ res.status, 'malformed-response',
208
+ );
209
+ }
210
+ const payload = envelope?.payload;
211
+ if (event === 'token') {
212
+ if (typeof payload?.text === 'string' && typeof onToken === 'function') {
213
+ onToken(payload.text);
214
+ }
215
+ return null;
216
+ }
217
+ if (event === 'error') {
218
+ const detail = typeof payload?.detail === 'string' ? payload.detail : 'maya: stream failed';
219
+ if (payload?.status_code === 401) throw new MayaAuthError(detail);
220
+ throw new MayaHttpError(detail, payload?.status_code);
221
+ }
222
+ if (payload?.success !== true || typeof payload?.response !== 'string'
223
+ || typeof payload?.message_id !== 'string' || !payload.message_id) {
224
+ throw new MayaHttpError(
225
+ 'maya: chat stream final payload cannot be trusted '
226
+ + '(needs success:true, a response string, and a message_id)',
227
+ res.status, 'malformed-response',
228
+ );
229
+ }
230
+ return payload;
231
+ };
232
+
233
+ const reader = res.body.getReader();
234
+ const decoder = new TextDecoder('utf-8');
235
+ let buffer = '';
236
+
237
+ // Maya persists the turn AFTER emitting `final` (routes.py:5106-5310:
238
+ // conversation history, engagement, storage enqueue) — her own web
239
+ // client drains to EOF. Aborting on receipt of `final` would race that
240
+ // post-final work, so the validated reply returns immediately while a
241
+ // detached drain consumes the stream to EOF. The overall deadline still
242
+ // bounds the socket: on expiry the drain is aborted, never the reply.
243
+ const finishAfterFinal = (reply) => {
244
+ detached = true;
245
+ (async () => {
246
+ try {
247
+ while (!(await reader.read()).done) { /* contents ignored */ }
248
+ } catch { /* deadline abort or teardown — the reply already landed */ }
249
+ finally {
250
+ clearTimeout(timer);
251
+ controller.abort();
252
+ }
253
+ })();
254
+ return reply;
255
+ };
256
+
257
+ while (true) {
258
+ const { done, value } = await reader.read();
259
+ if (done) {
260
+ buffer += decoder.decode();
261
+ } else {
262
+ buffer += decoder.decode(value, { stream: true });
263
+ }
264
+
265
+ let boundary;
266
+ while ((boundary = buffer.indexOf('\n\n')) !== -1) {
267
+ const frame = buffer.slice(0, boundary);
268
+ buffer = buffer.slice(boundary + 2);
269
+ const reply = handleFrame(frame);
270
+ if (reply) return done ? reply : finishAfterFinal(reply);
271
+ }
272
+ if (done) break;
273
+ }
274
+
275
+ if (buffer) {
276
+ const reply = handleFrame(buffer);
277
+ if (reply) return reply;
278
+ }
279
+ throw new MayaHttpError(
280
+ 'maya: chat stream ended without a valid final event',
281
+ res.status, 'malformed-response',
282
+ );
283
+ } catch (err) {
284
+ const failure = err instanceof MayaHttpError
285
+ ? err
286
+ : new MayaHttpError(
287
+ `maya: /api/chat/stream request failed: ${err?.message ?? 'unknown error'}`, 0,
288
+ );
289
+ // The relay uses this to preserve the JSON-before-open / SSE-after-open
290
+ // boundary even when an error event arrives before the first token.
291
+ if (streamOpened) failure.streamStarted = true;
292
+ throw failure;
293
+ } finally {
294
+ if (!detached) {
295
+ clearTimeout(timer);
296
+ controller.abort();
297
+ }
298
+ }
299
+ }
300
+
301
+ return { health, chat, chatStream };
302
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * maya-config.js — COMP-FOH FOH-6 S1
3
+ *
4
+ * Config readers for the Maya colleague coupling. Leaf module (no fetch, no
5
+ * state writes), mirroring lib/smartmemory-config.js: uncached direct reads of
6
+ * `.compose/compose.json`, try/catch → absent on missing/malformed.
7
+ *
8
+ * The feature switch is the PRESENCE of the `maya` block (design-foh-6.md
9
+ * §Config): absent means "not installed" (no chrome button), which is a
10
+ * different state from "installed but degraded" (a funnel).
11
+ */
12
+
13
+ import { readFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+
16
+ function readComposeJson(cwd) {
17
+ try {
18
+ return JSON.parse(readFileSync(join(cwd, '.compose', 'compose.json'), 'utf-8'));
19
+ } catch {
20
+ return {};
21
+ }
22
+ }
23
+
24
+ /**
25
+ * The `maya` block, or null when the feature is not installed.
26
+ * @param {string} cwd
27
+ * @returns {{ baseUrl?: string, auth?: { mode?: 'provision'|'static' } } | null}
28
+ */
29
+ export function getMayaConfig(cwd) {
30
+ const block = readComposeJson(cwd).maya;
31
+ return block && typeof block === 'object' ? block : null;
32
+ }
33
+
34
+ /**
35
+ * The fluid SmartMemory workspace id — the value the colleague identity's
36
+ * workspace claim must NOT equal (shallow-binding isolation, design §2).
37
+ * @param {string} cwd
38
+ * @returns {string | null}
39
+ */
40
+ export function getFluidWorkspaceId(cwd) {
41
+ const ws = readComposeJson(cwd).fluid?.smartmemory?.workspaceId;
42
+ return typeof ws === 'string' && ws ? ws : null;
43
+ }
44
+
45
+ /**
46
+ * Whether the fluid provider is SmartMemory-backed. The colleague never runs
47
+ * degraded (COLLEAGUE-ALL-IN): on the local floor the panel is a
48
+ * connect-SmartMemory funnel, not a plain chat.
49
+ * @param {string} cwd
50
+ */
51
+ export function hasSmartmemoryFluidProvider(cwd) {
52
+ return readComposeJson(cwd).fluid?.provider === 'smartmemory';
53
+ }
@@ -0,0 +1,283 @@
1
+ /**
2
+ * maya-identity.js — COMP-FOH FOH-6 S1
3
+ *
4
+ * The colleague identity store: `data/maya-identity.json` (covered by
5
+ * `.gitignore`'s blanket `data/` entry). One identity per project; it OWNS
6
+ * Maya's single standing conversation (session per JWT identity,
7
+ * session_registry.py:486-527), so nothing here re-provisions implicitly —
8
+ * minting a fresh identity destroys the thread AND the colleague-workspace
9
+ * memory, and is only ever an explicit user action (design §2).
10
+ *
11
+ * Provisioning uses smart-memory-service's sanctioned test surface, the same
12
+ * path the FOH-4/5/6 live-fires used:
13
+ * POST /test/provision-user {email} → {user_id, tenant_id, team_id, access_token}
14
+ * (409 on a repeated email — probed live 2026-08-11; there is no
15
+ * token-refresh path on the test surface yet, upstream ask filed with the
16
+ * channel_context issue)
17
+ * POST /memory/beta/nda/accept {"version":"v1"} (Bearer-authenticated)
18
+ * DELETE /test/provision-user {email, user_id, tenant_id}
19
+ */
20
+
21
+ import { readFileSync, writeFileSync, renameSync, rmSync, mkdirSync, chmodSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import { randomBytes } from 'node:crypto';
24
+
25
+ /** Provisioning/NDA failure. `kind` mirrors the relay's funnel taxonomy. */
26
+ export class MayaIdentityError extends Error {
27
+ constructor(message, kind = 'auth') {
28
+ super(message);
29
+ this.name = 'MayaIdentityError';
30
+ this.kind = kind;
31
+ }
32
+ }
33
+
34
+ /** The colleague token's workspace claim equals the fluid workspace —
35
+ * accepting it would silently enable the deferred deep-binding behaviour
36
+ * (Maya's turn-ingestion writing into the fluid workspace). */
37
+ export class MayaWorkspaceCollisionError extends Error {
38
+ constructor(claim) {
39
+ super(
40
+ `maya: the colleague token's workspace claim (${claim}) IS the fluid workspace — `
41
+ + 'refusing. Shallow binding requires a dedicated colleague workspace; '
42
+ + 'provision a separate identity or paste a token scoped elsewhere.',
43
+ );
44
+ this.name = 'MayaWorkspaceCollisionError';
45
+ this.claim = claim;
46
+ }
47
+ }
48
+
49
+ function identityPath(projectRoot) {
50
+ return join(projectRoot, 'data', 'maya-identity.json');
51
+ }
52
+
53
+ /** @returns {object|null} the stored identity, or null when none exists */
54
+ export function loadIdentity(projectRoot) {
55
+ const path = identityPath(projectRoot);
56
+ try {
57
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
58
+ if (!parsed || typeof parsed !== 'object') return null;
59
+ // Enforce owner-only permissions on files created before the 0600 write
60
+ // path existed (Codex r1 P2). Best-effort — a read-only checkout must not
61
+ // break loading.
62
+ try { chmodSync(path, 0o600); } catch { /* best-effort */ }
63
+ return parsed;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Owner-only (0600) and ATOMIC: the file holds a bearer credential, so it must
71
+ * never be group/world-readable, and a crash mid-write must never truncate it
72
+ * — a truncated file loads as null and the next turn would silently provision
73
+ * a fresh identity, destroying the standing conversation (Codex r1 P2).
74
+ */
75
+ export function saveIdentity(projectRoot, identity) {
76
+ mkdirSync(join(projectRoot, 'data'), { recursive: true });
77
+ const path = identityPath(projectRoot);
78
+ const tmp = `${path}.tmp.${randomBytes(6).toString('hex')}`;
79
+ try {
80
+ writeFileSync(tmp, JSON.stringify(identity, null, 2) + '\n', { mode: 0o600 });
81
+ renameSync(tmp, path);
82
+ } catch (err) {
83
+ rmSync(tmp, { force: true });
84
+ throw err;
85
+ }
86
+ }
87
+
88
+ export function clearIdentity(projectRoot) {
89
+ rmSync(identityPath(projectRoot), { force: true });
90
+ }
91
+
92
+ async function requestJson(url, { body, token, timeoutMs = 30000, fetchFn = fetch, method = 'POST' }) {
93
+ const controller = new AbortController();
94
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
95
+ try {
96
+ const res = await fetchFn(url, {
97
+ method,
98
+ headers: {
99
+ ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
100
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
101
+ },
102
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
103
+ signal: controller.signal,
104
+ });
105
+ const text = await res.text();
106
+ let parsed = null;
107
+ try { parsed = JSON.parse(text); } catch { /* tolerated; shape-checked below */ }
108
+ return { status: res.status, body: parsed };
109
+ } catch (err) {
110
+ throw new MayaIdentityError(`maya: ${method} ${url} failed: ${err?.message ?? 'unknown'}`);
111
+ } finally {
112
+ clearTimeout(timer);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Mint a fresh colleague identity. A RANDOM email per call — the provision
118
+ * surface refuses repeats (409), and a colleague identity is never shared
119
+ * across projects (documented-unsupported, design §2).
120
+ *
121
+ * Does NOT save: the caller decides persistence (ensureIdentity does both).
122
+ */
123
+ export async function provisionIdentity({ smBaseUrl, fetchFn = fetch }) {
124
+ const email = `maya-colleague-${randomBytes(6).toString('hex')}@compose.invalid`;
125
+ const res = await requestJson(`${smBaseUrl}/test/provision-user`, { body: { email }, fetchFn });
126
+ if (res.status !== 200 || typeof res.body?.access_token !== 'string') {
127
+ throw new MayaIdentityError(
128
+ `maya: provisioning failed (HTTP ${res.status})`
129
+ + (res.body?.detail ? ` — ${String(res.body.detail).slice(0, 200)}` : ''),
130
+ );
131
+ }
132
+ const { user_id, tenant_id, team_id, access_token } = res.body;
133
+ return {
134
+ mode: 'provision', email, user_id, tenant_id, team_id, access_token,
135
+ ndaAccepted: false, provisionedAt: new Date().toISOString(),
136
+ };
137
+ }
138
+
139
+ /** Accept the beta NDA for a provisioned identity (403 `nda_required` gates
140
+ * every memory route until this runs — FOH-4 ledger prereq 3). */
141
+ export async function acceptNda({ smBaseUrl, token, fetchFn = fetch }) {
142
+ const res = await requestJson(`${smBaseUrl}/memory/beta/nda/accept`, {
143
+ body: { version: 'v1' }, token, fetchFn,
144
+ });
145
+ if (res.status < 200 || res.status >= 300) {
146
+ throw new MayaIdentityError(`maya: NDA accept failed (HTTP ${res.status})`);
147
+ }
148
+ }
149
+
150
+ /** Tear down a provisioned identity (explicit user action only). */
151
+ export async function teardownIdentity(projectRoot, { smBaseUrl, fetchFn = fetch }) {
152
+ const identity = loadIdentity(projectRoot);
153
+ if (!identity) return { removed: false };
154
+ if (identity.mode === 'provision' && identity.email) {
155
+ await requestJson(`${smBaseUrl}/test/provision-user`, {
156
+ method: 'DELETE', fetchFn,
157
+ body: { email: identity.email, user_id: identity.user_id, tenant_id: identity.tenant_id },
158
+ });
159
+ }
160
+ clearIdentity(projectRoot);
161
+ return { removed: true };
162
+ }
163
+
164
+ /**
165
+ * The identity's workspace claim, or null when none is derivable. Provisioned
166
+ * identities carry `team_id` from the provision response (VERIFY-1: the JWT
167
+ * payload itself carries NO workspace claims — Maya resolves the workspace
168
+ * server-side from the verified user record). Static tokens fall back to
169
+ * decoding the JWT body for the claims Maya's `_extract_team_id` reads.
170
+ */
171
+ export function workspaceClaimOf(identity) {
172
+ if (!identity) return null;
173
+ if (typeof identity.team_id === 'string' && identity.team_id) return identity.team_id;
174
+ const token = identity.access_token;
175
+ if (typeof token !== 'string') return null;
176
+ const parts = token.split('.');
177
+ if (parts.length < 2) return null;
178
+ try {
179
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
180
+ return payload.workspace_id ?? payload.current_workspace_id
181
+ ?? payload.default_team_id ?? payload.team_id ?? null;
182
+ } catch {
183
+ return null;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * The shallow-binding invariant (design §2, VERIFY-3): refuse a colleague
189
+ * token whose workspace claim equals the fluid workspace. An underivable claim
190
+ * is ALLOWED here for STORED identities — provisioned identities always carry
191
+ * `team_id`, and static identities have their claim resolved and stored at
192
+ * paste time by `fetchVerifiedWorkspace` (which fails CLOSED — see below), so
193
+ * an underivable claim on a stored identity means only that this project has
194
+ * no fluid workspace to collide with. Cross-project token reuse remains
195
+ * documented-unsupported, not detected.
196
+ */
197
+ export function validateWorkspaceIsolation(identity, fluidWorkspaceId) {
198
+ if (!fluidWorkspaceId) return;
199
+ const claim = workspaceClaimOf(identity);
200
+ if (claim && claim === fluidWorkspaceId) throw new MayaWorkspaceCollisionError(claim);
201
+ }
202
+
203
+ /**
204
+ * Resolve a token's VERIFIED workspace from smart-memory-service itself
205
+ * (`GET /auth/me` → `default_team_id` — the same user-record field Maya's
206
+ * `_extract_team_id` falls back to). Exists because the JWT-decode fallback is
207
+ * vacuous in practice: real tokens carry NO workspace claims (VERIFY-1), so a
208
+ * pasted static token could silently defeat the isolation check (Codex r1 P1).
209
+ *
210
+ * FAILS CLOSED: an unreachable service, a rejected token, or a user record
211
+ * without a resolvable team all throw — the paste flow refuses to store a
212
+ * token whose workspace it cannot verify.
213
+ */
214
+ export async function fetchVerifiedWorkspace({ smBaseUrl, token, fetchFn = fetch }) {
215
+ if (!smBaseUrl) {
216
+ throw new MayaIdentityError('maya: cannot verify the token — smartmemory baseUrl is not configured');
217
+ }
218
+ const res = await requestJson(`${smBaseUrl}/auth/me`, { method: 'GET', token, fetchFn });
219
+ if (res.status !== 200 || !res.body || typeof res.body !== 'object') {
220
+ throw new MayaIdentityError(
221
+ `maya: could not verify the token against SmartMemory (HTTP ${res.status}) — not storing it`,
222
+ );
223
+ }
224
+ const team = res.body.default_team_id;
225
+ if (typeof team !== 'string' || !team) {
226
+ throw new MayaIdentityError(
227
+ 'maya: the token verified but resolves no workspace (no default_team_id) — '
228
+ + 'refusing to store a token whose isolation cannot be checked',
229
+ );
230
+ }
231
+ return team;
232
+ }
233
+
234
+ /**
235
+ * Per-project serialization for ensureIdentity. Two concurrent first turns
236
+ * that both observe "no identity" would otherwise provision two competing
237
+ * users, overwrite each other's files, and fork the standing conversation
238
+ * (Codex r1 P2). The server is a single process, so an in-process promise
239
+ * chain is the whole fix: every caller re-loads at the head of its own turn
240
+ * in the chain and sees the winner's identity.
241
+ */
242
+ const _ensureChain = new Map(); // projectRoot -> tail Promise
243
+
244
+ async function ensureIdentityUnserialized(projectRoot, { smBaseUrl, mode, fetchFn = fetch }) {
245
+ let identity = loadIdentity(projectRoot);
246
+ if (!identity) {
247
+ if (mode !== 'provision') {
248
+ throw new MayaIdentityError('maya: no token configured — paste one in the panel', 'auth');
249
+ }
250
+ if (!smBaseUrl) {
251
+ throw new MayaIdentityError('maya: provisioning needs the smartmemory baseUrl', 'auth');
252
+ }
253
+ identity = await provisionIdentity({ smBaseUrl, fetchFn });
254
+ saveIdentity(projectRoot, identity);
255
+ }
256
+ // A saved-but-unaccepted NDA (crash between provision and accept) is retried
257
+ // on the SAME identity rather than minting a new one.
258
+ if (identity.mode === 'provision' && !identity.ndaAccepted) {
259
+ await acceptNda({ smBaseUrl, token: identity.access_token, fetchFn });
260
+ identity.ndaAccepted = true;
261
+ saveIdentity(projectRoot, identity);
262
+ }
263
+ return identity;
264
+ }
265
+
266
+ /**
267
+ * The relay's per-turn identity resolution: load, lazily provision on FIRST
268
+ * use (provision mode only — static mode with no pasted token is an auth
269
+ * funnel), and complete a pending NDA. Never called on a 401 — that funnel is
270
+ * explicit-action-only. Serialized per project (see `_ensureChain`).
271
+ */
272
+ export async function ensureIdentity(projectRoot, opts) {
273
+ const tail = _ensureChain.get(projectRoot) ?? Promise.resolve();
274
+ const run = tail
275
+ .catch(() => {}) // a predecessor's failure must not poison the chain
276
+ .then(() => ensureIdentityUnserialized(projectRoot, opts));
277
+ _ensureChain.set(projectRoot, run);
278
+ try {
279
+ return await run;
280
+ } finally {
281
+ if (_ensureChain.get(projectRoot) === run) _ensureChain.delete(projectRoot);
282
+ }
283
+ }
@@ -181,6 +181,11 @@ export async function promoteAnonRow(cwd, row, { code, status = 'PLANNED', scaff
181
181
  status,
182
182
  complexity: 'S',
183
183
  position,
184
+ // COMP-COMPLETION-GATE: promoting an anonymous ROADMAP row transcribes a
185
+ // status that already exists in the roadmap — including COMPLETE for work
186
+ // finished long before the gate. That is history, not a new completion, so
187
+ // it takes the named migration exemption rather than the completion path.
188
+ _migration: { reason: `promoting anonymous ROADMAP row in phase ${row.phaseId}` },
184
189
  });
185
190
  } catch (err) {
186
191
  if (err && err.code === 'ROADMAP_PARTIAL_WRITE') throw err;
@@ -81,6 +81,21 @@ export function migrateRoadmap(cwd, opts = {}) {
81
81
  feature.created = new Date().toISOString().slice(0, 10);
82
82
  }
83
83
 
84
+ // COMP-COMPLETION-GATE slice 3 (AC-17, path 13): the migration legitimately
85
+ // copies historical COMPLETE rows into feature.json — it transcribes a
86
+ // completion that already happened, it does not mint one. The exemption is
87
+ // NAMED and LOGGED per write and covers BOTH branches: an existing file
88
+ // (`--overwrite`) and a missing one (created without it — §2.7 round 2
89
+ // finding 7: an exemption keyed on --overwrite alone would leave the
90
+ // create path as an unlogged bypass). `writeFeature` is the raw file
91
+ // primitive; this callsite is on the completion-write allowlist.
92
+ if (feature.status === 'COMPLETE') {
93
+ console.warn(
94
+ `[migrate-roadmap] migration exemption (${existing ? 'overwrite' : 'create'}): writing ` +
95
+ `${entry.code} as COMPLETE from its ROADMAP row without a completion record — ` +
96
+ `ROADMAP is the source of truth during migration.`,
97
+ );
98
+ }
84
99
  if (opts.dryRun) {
85
100
  console.log(`${existing ? 'update' : 'create'}: ${featuresDir}/${entry.code}/feature.json`);
86
101
  } else {