@smartmemory/compose 0.4.1 → 0.5.1

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 (127) hide show
  1. package/.claude/agents/compose-architect.md +40 -0
  2. package/.claude/agents/compose-explorer.md +35 -0
  3. package/.claude/hooks/canon-guard.mjs +52 -0
  4. package/README.md +15 -1
  5. package/bin/compose.js +57 -17
  6. package/bin/git-hooks/pre-push.template +26 -1
  7. package/bin/receipts-gate.js +39 -0
  8. package/contracts/fluid-record.schema.json +5 -0
  9. package/dist/assets/{App-Z4MU-H_F.js → App-DC7paCZv.js} +190 -190
  10. package/dist/assets/{_baseUniq-ClWoCPFl.js → _baseUniq-Czad7yiy.js} +1 -1
  11. package/dist/assets/{arc-DY26UIVo.js → arc-EquvLk8y.js} +1 -1
  12. package/dist/assets/{architectureDiagram-Q4EWVU46-6Ggq4DqJ.js → architectureDiagram-Q4EWVU46-Dr_qinWi.js} +1 -1
  13. package/dist/assets/{blockDiagram-DXYQGD6D-CH3Ked0l.js → blockDiagram-DXYQGD6D-D2z46ED_.js} +1 -1
  14. package/dist/assets/{c4Diagram-AHTNJAMY-Bk8dYilu.js → c4Diagram-AHTNJAMY-BHob1Yt0.js} +1 -1
  15. package/dist/assets/channel-B-7ZRCKC.js +1 -0
  16. package/dist/assets/{chunk-4BX2VUAB-BMR0XaAQ.js → chunk-4BX2VUAB-DomWBRa_.js} +1 -1
  17. package/dist/assets/{chunk-4TB4RGXK-JytR14a9.js → chunk-4TB4RGXK-WyC_x_DH.js} +1 -1
  18. package/dist/assets/{chunk-55IACEB6-B4Q97BCP.js → chunk-55IACEB6-BajRv3zx.js} +1 -1
  19. package/dist/assets/{chunk-EDXVE4YY-R_qarkSf.js → chunk-EDXVE4YY-rMnedK_r.js} +1 -1
  20. package/dist/assets/{chunk-FMBD7UC4-C9s7KR9m.js → chunk-FMBD7UC4-BPi03Hcb.js} +1 -1
  21. package/dist/assets/{chunk-OYMX7WX6-BySQzVxc.js → chunk-OYMX7WX6-B7J_mKX0.js} +1 -1
  22. package/dist/assets/{chunk-QZHKN3VN-DdpSYZsW.js → chunk-QZHKN3VN-BLXTVr8N.js} +1 -1
  23. package/dist/assets/{chunk-YZCP3GAM-iE_tzriw.js → chunk-YZCP3GAM-BYWjo2OJ.js} +1 -1
  24. package/dist/assets/classDiagram-6PBFFD2Q-Balz1OEB.js +1 -0
  25. package/dist/assets/classDiagram-v2-HSJHXN6E-Balz1OEB.js +1 -0
  26. package/dist/assets/clone-CfNV0lUO.js +1 -0
  27. package/dist/assets/{cose-bilkent-S5V4N54A-BdlU6ZX_.js → cose-bilkent-S5V4N54A-Coaq0xaU.js} +1 -1
  28. package/dist/assets/{dagre-KV5264BT-Cp3F5KTn.js → dagre-KV5264BT-DvUvAxlj.js} +1 -1
  29. package/dist/assets/{diagram-5BDNPKRD-DiR6_2q_.js → diagram-5BDNPKRD-70bXRUXV.js} +1 -1
  30. package/dist/assets/{diagram-G4DWMVQ6-w0i-p5HX.js → diagram-G4DWMVQ6-hMA8wgzx.js} +1 -1
  31. package/dist/assets/{diagram-MMDJMWI5-tIHhwUv3.js → diagram-MMDJMWI5-BNir7C6i.js} +1 -1
  32. package/dist/assets/{diagram-TYMM5635-BAeY3B19.js → diagram-TYMM5635-BCYl1xrE.js} +1 -1
  33. package/dist/assets/{erDiagram-SMLLAGMA-Ckx_Knko.js → erDiagram-SMLLAGMA-bjxP0_bt.js} +1 -1
  34. package/dist/assets/{flowDiagram-DWJPFMVM-DeoNka6J.js → flowDiagram-DWJPFMVM-CBn9fhEp.js} +1 -1
  35. package/dist/assets/{ganttDiagram-T4ZO3ILL-BmGnFbEg.js → ganttDiagram-T4ZO3ILL-y1O7mWzn.js} +1 -1
  36. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Dk48IHsx.js → gitGraphDiagram-UUTBAWPF-DIxwDXHB.js} +1 -1
  37. package/dist/assets/{graph-BNzKGvoy.js → graph-9D1ZumWp.js} +1 -1
  38. package/dist/assets/{index-BEfrNBp8.js → index-Ds_IXQo3.js} +2 -2
  39. package/dist/assets/{infoDiagram-42DDH7IO-BRf827i0.js → infoDiagram-42DDH7IO-DsWLGhaY.js} +1 -1
  40. package/dist/assets/{ishikawaDiagram-UXIWVN3A-0kCZaeCM.js → ishikawaDiagram-UXIWVN3A-CipZIE90.js} +1 -1
  41. package/dist/assets/{journeyDiagram-VCZTEJTY-rvU7ayRt.js → journeyDiagram-VCZTEJTY-Vr5xqcQm.js} +1 -1
  42. package/dist/assets/{kanban-definition-6JOO6SKY-DpQwX1C5.js → kanban-definition-6JOO6SKY-EqUYneyh.js} +1 -1
  43. package/dist/assets/{layout-BI8cXFPI.js → layout-hfWIIs0-.js} +1 -1
  44. package/dist/assets/{linear-a0glcDiw.js → linear-BdDWoN0t.js} +1 -1
  45. package/dist/assets/{min-vPHfnXcC.js → min-Bn_xAS7n.js} +1 -1
  46. package/dist/assets/{mindmap-definition-QFDTVHPH-D14eF-7C.js → mindmap-definition-QFDTVHPH-qsgubzCF.js} +1 -1
  47. package/dist/assets/{pieDiagram-DEJITSTG-Cno-gETh.js → pieDiagram-DEJITSTG-Bv1xq_58.js} +1 -1
  48. package/dist/assets/{quadrantDiagram-34T5L4WZ-BUQM1Hfm.js → quadrantDiagram-34T5L4WZ-DwMbAegF.js} +1 -1
  49. package/dist/assets/{requirementDiagram-MS252O5E-pOXlN2-q.js → requirementDiagram-MS252O5E-BJVmLNcp.js} +1 -1
  50. package/dist/assets/{sankeyDiagram-XADWPNL6-Crynd3_b.js → sankeyDiagram-XADWPNL6-o5GZb8Y1.js} +1 -1
  51. package/dist/assets/{sequenceDiagram-FGHM5R23-D9fZdCM8.js → sequenceDiagram-FGHM5R23-ocqJp2qk.js} +1 -1
  52. package/dist/assets/{stateDiagram-FHFEXIEX-CW9qVec8.js → stateDiagram-FHFEXIEX-DGaDUFxP.js} +1 -1
  53. package/dist/assets/stateDiagram-v2-QKLJ7IA2-Dz-15i-r.js +1 -0
  54. package/dist/assets/{timeline-definition-GMOUNBTQ-BcHzhm_8.js → timeline-definition-GMOUNBTQ-C4YwFvAn.js} +1 -1
  55. package/dist/assets/{vennDiagram-DHZGUBPP-BfytJcWk.js → vennDiagram-DHZGUBPP-uOKn9j-y.js} +1 -1
  56. package/dist/assets/{wardley-RL74JXVD-DLj-IjyB.js → wardley-RL74JXVD-DIQSmQde.js} +1 -1
  57. package/dist/assets/{wardleyDiagram-NUSXRM2D-Ds0Ue68c.js → wardleyDiagram-NUSXRM2D-CdamsEDC.js} +1 -1
  58. package/dist/assets/{xychartDiagram-5P7HB3ND-vjWDXFL6.js → xychartDiagram-5P7HB3ND-DhLs41yk.js} +1 -1
  59. package/dist/index.html +1 -1
  60. package/lib/agent-string.js +9 -4
  61. package/lib/build-cancel.js +205 -0
  62. package/lib/build-stream-writer.js +6 -0
  63. package/lib/build.js +1189 -165
  64. package/lib/canon-guard.js +3 -24
  65. package/lib/canon-registry.js +2 -71
  66. package/lib/codex-preflight.js +8 -0
  67. package/lib/colleague/context.js +123 -0
  68. package/lib/consumer-fanout.js +427 -17
  69. package/lib/decision-blocks.js +38 -0
  70. package/lib/dispatch-ledger.js +7 -0
  71. package/lib/experiment-pricing.js +5 -1
  72. package/lib/flow-state.js +38 -0
  73. package/lib/fluid/factory.js +112 -1
  74. package/lib/fluid/ideabox-manifest.js +203 -0
  75. package/lib/fluid/ideabox-migrate.js +177 -29
  76. package/lib/fluid/ideabox-preamble.js +155 -0
  77. package/lib/fluid/ideabox-readable.js +83 -0
  78. package/lib/fluid/ideabox-recover.js +393 -0
  79. package/lib/fluid/import-ideabox.js +188 -45
  80. package/lib/fluid/local-provider.js +6 -0
  81. package/lib/fluid/portfolio.js +255 -0
  82. package/lib/fluid/record-shape.js +7 -0
  83. package/lib/fluid/render-ideabox.js +153 -7
  84. package/lib/fluid/smartmemory-provider.js +6 -0
  85. package/lib/gate-prompt.js +14 -7
  86. package/lib/gsd.js +95 -48
  87. package/lib/ideabox-cli.js +68 -0
  88. package/lib/ideabox.js +209 -9
  89. package/lib/maya-identity.js +16 -2
  90. package/lib/model-pricing.js +4 -1
  91. package/lib/output-gate.js +81 -0
  92. package/lib/pipeline-profiles.js +200 -0
  93. package/lib/process-termination.js +121 -3
  94. package/lib/receipts-gate.js +268 -0
  95. package/lib/result-normalizer.js +41 -1
  96. package/lib/smartmemory-client.js +68 -1
  97. package/lib/stratum-mcp-client.js +104 -5
  98. package/lib/team-flag.js +1 -1
  99. package/lib/tool-inventory.js +0 -1
  100. package/lib/version-check.js +9 -3
  101. package/lib/wave-checkpoint.js +100 -0
  102. package/package.json +7 -5
  103. package/presets/team-fable-astra.profiles.json +18 -0
  104. package/presets/team-fable-astra.stratum.yaml +236 -0
  105. package/server/build-stream-bridge.js +43 -1
  106. package/server/cc-session-watcher.js +54 -5
  107. package/server/compose-mcp-tools.js +48 -50
  108. package/server/compose-mcp.js +0 -2
  109. package/server/design-routes.js +1 -1
  110. package/server/file-watcher.js +14 -0
  111. package/server/ideabox-routes.js +10 -0
  112. package/server/index.js +5 -1
  113. package/server/lifecycle-guard.js +13 -0
  114. package/server/maya-routes.js +111 -7
  115. package/server/mcp-tool-defs.js +0 -25
  116. package/server/mcp-tool-policy.js +6 -13
  117. package/server/model-tiers.js +14 -6
  118. package/server/stratum-client.js +61 -15
  119. package/server/supervisor.js +18 -4
  120. package/server/vision-routes.js +9 -3
  121. package/dist/assets/channel-SnZzzh7k.js +0 -1
  122. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +0 -1
  123. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +0 -1
  124. package/dist/assets/clone-DgklGjHm.js +0 -1
  125. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +0 -1
  126. package/lib/append-integrity.js +0 -81
  127. package/lib/canon-override.js +0 -196
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Contract:
9
9
  * - Query calls: 5s timeout, 1 retry on timeout, no retry on error
10
- * - Mutation calls: 10s timeout, no retry (mutations are not idempotent to retry)
10
+ * - Mutation calls: 30s timeout, no retry (mutations are not idempotent to retry)
11
11
  * - Exit 0 → parse stdout as JSON, return result
12
12
  * - Exit 2 → conflict (idempotency), return { conflict: true, ... }
13
13
  * - Non-zero → log stderr internally, return { error: { code, message, detail } }
@@ -22,7 +22,13 @@ import { resolveStratumBin, resolveStratumEngine as resolveEngine } from '../lib
22
22
  let _execFile = _execFileDefault;
23
23
  export function _testOnly_setExecFile(fn) { _execFile = fn; }
24
24
  const QUERY_TIMEOUT_MS = 5_000;
25
- const MUTATION_TIMEOUT_MS = 10_000;
25
+ // Measured 2026-09-07 on this seam: a `guard transition` subprocess costs
26
+ // 1.5-3.9 s idle (node startup + the stratum CLI module graph + guard store IO).
27
+ // A 10 s budget left under 3x headroom, and under full-suite load 6 of 200
28
+ // lifecycle-guard-e2e runs blew it; at 30 s the same probe under the same load
29
+ // was 0 of 200. A mutation timeout is a fail-closed refusal to the caller, so
30
+ // the budget must clear a loaded machine, not just an idle one.
31
+ const MUTATION_TIMEOUT_MS = 30_000;
26
32
 
27
33
  // ---------------------------------------------------------------------------
28
34
  // Engine selection (COMP-STRATUM-TS)
@@ -81,8 +87,28 @@ function _spawnResult(bin, err, out, err2) {
81
87
  const stdout = out || '';
82
88
  let stderr = err2 || '';
83
89
  let code;
84
- if (err?.code === 'ETIMEDOUT') code = -1;
85
- else if (typeof err?.code === 'number') code = err.code;
90
+ // A timeout kill NEVER reaches the execFile CALLBACK as `code: 'ETIMEDOUT'`
91
+ // node kills the child and reports `{ code: null, killed: true, signal:
92
+ // 'SIGTERM' }`. Matching only on ETIMEDOUT left every real timeout falling
93
+ // through to the generic `code = 1` branch below, so the TIMEOUT arms of
94
+ // runQuery/runMutation/runGuard (and runQuery's retry) were unreachable and a
95
+ // timed-out guard transition surfaced as `UNKNOWN` — rendered by the lifecycle
96
+ // routes as "transition refused by guard", i.e. an infrastructure timeout
97
+ // claiming the evidence was evaluated and rejected. Measured 2026-09-07:
98
+ // 6/200 lifecycle-guard-e2e runs under full-suite load, every one this shape.
99
+ // (`ETIMEDOUT` is still matched: execFileSync/spawnSync do set it, and
100
+ // probeStratumBin in lib/stratum-engine.js already checks all three.)
101
+ // `killed` is set only when NODE killed the child (timeout). A child that
102
+ // died to an outside signal reports `signal` with `killed: false`; that is
103
+ // not a timeout and is named as what it was rather than relabelled.
104
+ if (err?.code === 'ETIMEDOUT' || err?.killed === true) {
105
+ code = -1;
106
+ stderr = stderr || err.message || String(err);
107
+ } else if (typeof err?.code === 'number') code = err.code;
108
+ else if (err?.signal != null) {
109
+ code = 1;
110
+ stderr = stderr || `stratum child killed by ${err.signal}`;
111
+ }
86
112
  else if (typeof err?.code === 'string' && _SPAWN_CODES.has(err.code)) {
87
113
  code = -2;
88
114
  stderr = _spawnRemedy(bin, err.code);
@@ -102,6 +128,18 @@ function _spawnRemedy(bin, code) {
102
128
  return `${bin} (TS stratum engine) failed to spawn (${code}). Install @smartmemory/stratum or set COMPOSE_STRATUM_TS_CLI_BIN`;
103
129
  }
104
130
 
131
+ /**
132
+ * A bounded, diagnosable tail of what the subprocess actually produced. The
133
+ * TIMEOUT/PARSE_ERROR envelopes used to carry `detail: ''`, which made every
134
+ * such failure indistinguishable from every other one in a log. stderr is not
135
+ * forwarded to REST callers (see the module contract) — this detail is the
136
+ * process-level shape (exit code + a stdout excerpt), not the child's stderr.
137
+ */
138
+ function _detail(result) {
139
+ const out = String(result?.stdout ?? '').trim();
140
+ return `exit=${result?.code}${out ? ` stdout=${JSON.stringify(out.slice(0, 400))}` : ' stdout=<empty>'}`;
141
+ }
142
+
105
143
  /**
106
144
  * Run a query command (read-only). Retries once on timeout.
107
145
  *
@@ -115,7 +153,7 @@ async function runQuery(args) {
115
153
  // Retry once on timeout
116
154
  result = await spawnStratum(args, QUERY_TIMEOUT_MS, bin);
117
155
  if (result.code === -1) {
118
- return { error: { code: 'TIMEOUT', message: 'Stratum query timed out', detail: '' } };
156
+ return { error: { code: 'TIMEOUT', message: 'Stratum query timed out', detail: _detail(result) } };
119
157
  }
120
158
  }
121
159
 
@@ -136,7 +174,7 @@ async function runQuery(args) {
136
174
  try {
137
175
  return JSON.parse(result.stdout);
138
176
  } catch {
139
- return { error: { code: 'PARSE_ERROR', message: 'Stratum returned invalid JSON', detail: '' } };
177
+ return { error: { code: 'PARSE_ERROR', message: 'Stratum returned invalid JSON', detail: _detail(result) } };
140
178
  }
141
179
  }
142
180
 
@@ -149,7 +187,7 @@ async function runMutation(args) {
149
187
  const result = await spawnStratum(args, MUTATION_TIMEOUT_MS, flowGateBin());
150
188
 
151
189
  if (result.code === -1) {
152
- return { error: { code: 'TIMEOUT', message: 'Stratum gate timed out', detail: '' } };
190
+ return { error: { code: 'TIMEOUT', message: 'Stratum gate timed out', detail: _detail(result) } };
153
191
  }
154
192
 
155
193
  if (result.code === -2) {
@@ -177,7 +215,7 @@ async function runMutation(args) {
177
215
  try {
178
216
  return JSON.parse(result.stdout);
179
217
  } catch {
180
- return { error: { code: 'PARSE_ERROR', message: 'Stratum returned invalid JSON', detail: '' } };
218
+ return { error: { code: 'PARSE_ERROR', message: 'Stratum returned invalid JSON', detail: _detail(result) } };
181
219
  }
182
220
  }
183
221
 
@@ -226,7 +264,7 @@ async function runGuard(action, kwargs, timeoutMs = MUTATION_TIMEOUT_MS, extraEn
226
264
  const result = await spawnStratumStdin(['guard', action], JSON.stringify(kwargs), timeoutMs, flowGateBin(), extraEnv);
227
265
 
228
266
  if (result.code === -1) {
229
- return { error: { code: 'TIMEOUT', message: 'Stratum guard timed out', detail: '' } };
267
+ return { error: { code: 'TIMEOUT', message: 'Stratum guard timed out', detail: _detail(result) } };
230
268
  }
231
269
  if (result.code === -2) {
232
270
  console.error('[stratum-client] guard spawn failure:', result.stderr);
@@ -247,7 +285,7 @@ async function runGuard(action, kwargs, timeoutMs = MUTATION_TIMEOUT_MS, extraEn
247
285
  try {
248
286
  return JSON.parse(result.stdout);
249
287
  } catch {
250
- return { error: { code: 'PARSE_ERROR', message: 'Stratum returned invalid JSON', detail: '' } };
288
+ return { error: { code: 'PARSE_ERROR', message: 'Stratum returned invalid JSON', detail: _detail(result) } };
251
289
  }
252
290
  }
253
291
 
@@ -367,17 +405,25 @@ export async function guardTransition({ resourceId, fromState, toState, artifact
367
405
  }
368
406
 
369
407
  /**
370
- * The single sanctioned bypass of predicate verification. Requires an
371
- * out-of-band override token (server env STRATUM_GUARD_OVERRIDE_TOKEN), a human
372
- * resolver, and a rationale. Records a 'deviation' ledger entry.
408
+ * The single sanctioned bypass of predicate verification. Requires a signed
409
+ * one-shot AUTHORIZATION, a human resolver, and a rationale. Records a
410
+ * 'deviation' ledger entry.
411
+ *
412
+ * Corrected 2026-09-07: this wrapper sent `override_token`, the shared secret
413
+ * stratum retired in STRAT-GUARD-AUTHZ @3647b4c. Stratum now reads
414
+ * `authorization` (`ts/src/mcp/server.ts:270`) — an sshsig over a payload it
415
+ * reconstructs, bound to the resource's ledger head — so every call this wrapper
416
+ * could have made was destined to fail on a missing authorization. It has no
417
+ * production caller; the field name is fixed so the first one does not inherit
418
+ * the break.
373
419
  * @returns {Promise<{status:string,ledger_ref:string,current_state:string}|ErrorResult>}
374
420
  */
375
- export async function guardOverride({ resourceId, fromState, toState, overrideToken, rationale, resolvedBy = 'human' }) {
421
+ export async function guardOverride({ resourceId, fromState, toState, authorization, rationale, resolvedBy = 'human' }) {
376
422
  return runGuard('override', _compact({
377
423
  resource_id: resourceId,
378
424
  from_state: fromState,
379
425
  to_state: toState,
380
- override_token: overrideToken,
426
+ authorization,
381
427
  rationale,
382
428
  resolved_by: resolvedBy,
383
429
  }));
@@ -22,6 +22,8 @@ import { COMPOSE_HOME, getTargetRoot, ensureDataDir } from './project-root.js';
22
22
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
23
  console.log('[supervisor] Target project:', getTargetRoot());
24
24
  const PID_FILE = path.join(COMPOSE_HOME, '.compose-supervisor.pid');
25
+ const VITE_BIN = path.join(COMPOSE_HOME, 'node_modules', '.bin', 'vite');
26
+ const IS_SOURCE_CHECKOUT = fs.existsSync(path.join(COMPOSE_HOME, '.git'));
25
27
 
26
28
  const PROCESSES = [
27
29
  {
@@ -36,13 +38,25 @@ const PROCESSES = [
36
38
  port: process.env.AGENT_PORT || 4002,
37
39
  type: 'fork',
38
40
  },
39
- {
41
+ ];
42
+
43
+ if (IS_SOURCE_CHECKOUT) {
44
+ delete process.env.COMPOSE_PACKAGED_UI;
45
+ if (!fs.existsSync(VITE_BIN)) {
46
+ console.error(`[supervisor] Vite is required in a Compose source checkout but was not found at ${VITE_BIN}`);
47
+ console.error(`[supervisor] Run \`npm install\` in ${COMPOSE_HOME}, then retry \`compose start\`.`);
48
+ process.exit(1);
49
+ }
50
+ PROCESSES.push({
40
51
  name: 'vite',
41
- command: path.join(COMPOSE_HOME, 'node_modules', '.bin', 'vite'),
52
+ command: VITE_BIN,
42
53
  port: process.env.VITE_PORT || 5195,
43
54
  type: 'spawn',
44
- },
45
- ];
55
+ });
56
+ } else {
57
+ process.env.COMPOSE_PACKAGED_UI = '1';
58
+ console.log('[supervisor] Packaged install: serving cockpit from dist/ through the API server');
59
+ }
46
60
 
47
61
  const MIN_BACKOFF = 500;
48
62
  const MAX_BACKOFF = 10_000;
@@ -53,7 +53,7 @@ import { addOpenLoop, resolveOpenLoop, listOpenLoops } from './open-loops-store.
53
53
  import {
54
54
  TERMINAL,
55
55
  guardedTransition, ensureGuard, projectFeatureStatus,
56
- verifyCompletionEvidence, guardTestCommand,
56
+ verifyCompletionEvidence, guardTestCommand, isGuardInfraError,
57
57
  } from './lifecycle-guard.js';
58
58
  import {
59
59
  transitionsOf, skippableOf, completablePhaseOf, getMode, resolveMode,
@@ -85,6 +85,12 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
85
85
  // COMP-MCP-ENFORCE: when enabled, lifecycle transitions are verdict-gated by
86
86
  // stratum's STRAT-GUARD (fail-closed). Default OFF — legacy behavior intact.
87
87
  const guardEnabled = () => capabilities?.guard === true;
88
+ // A guard that never answered is 503 "guard unavailable"; a guard that
89
+ // answered no is 422 "transition refused by guard". Collapsing them told the
90
+ // user their evidence was rejected when the subprocess had merely timed out.
91
+ const guardFailure = (res, g, from, to) => isGuardInfraError(g)
92
+ ? res.status(503).json({ error: 'guard unavailable', from, to, guardError: g.error })
93
+ : res.status(422).json({ error: 'transition refused by guard', from, to, verdict: g.verdict, guardError: g.error });
88
94
 
89
95
  // COMP-MCP-ENFORCE Slice 4: opt-in loopback REST auth on vision MUTATION
90
96
  // endpoints (lifecycle transitions, iterations, gate resolve, item CRUD,
@@ -404,7 +410,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
404
410
  // COMP-MCP-ENFORCE: verdict-gate the transition (fail-closed) before mutating.
405
411
  if (guardEnabled()) {
406
412
  const g = await guardedTransition({ featureCode: item.lifecycle.featureCode, from, to: targetPhase, workspaceRoot: projectRoot, resolvedBy: 'agent', mode: modeOf(item) });
407
- if (!g.applied) return res.status(422).json({ error: 'transition refused by guard', from, to: targetPhase, verdict: g.verdict, guardError: g.error });
413
+ if (!g.applied) return guardFailure(res, g, from, targetPhase);
408
414
  }
409
415
 
410
416
  item.lifecycle.currentPhase = targetPhase;
@@ -446,7 +452,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
446
452
  // COMP-MCP-ENFORCE: verdict-gate the skip (fail-closed) before mutating.
447
453
  if (guardEnabled()) {
448
454
  const g = await guardedTransition({ featureCode: item.lifecycle.featureCode, from, to: targetPhase, workspaceRoot: projectRoot, resolvedBy: 'agent', mode: modeOf(item) });
449
- if (!g.applied) return res.status(422).json({ error: 'transition refused by guard', from, to: targetPhase, verdict: g.verdict, guardError: g.error });
455
+ if (!g.applied) return guardFailure(res, g, from, targetPhase);
450
456
  }
451
457
 
452
458
  item.lifecycle.currentPhase = targetPhase;
@@ -1 +0,0 @@
1
- import{aq as o,ar as n}from"./App-Z4MU-H_F.js";const t=(r,a)=>o.lang.round(n.parse(r)[a]);export{t as c};
@@ -1 +0,0 @@
1
- import{s as a,c as s,a as e,C as t}from"./chunk-4TB4RGXK-JytR14a9.js";import{_ as i}from"./App-Z4MU-H_F.js";import"./chunk-FMBD7UC4-C9s7KR9m.js";import"./chunk-YZCP3GAM-iE_tzriw.js";import"./chunk-55IACEB6-B4Q97BCP.js";import"./chunk-EDXVE4YY-R_qarkSf.js";import"./mobile-B7m9EO9D.js";import"./index-BEfrNBp8.js";import"./graph-CI_1htl0.js";var b={parser:e,get db(){return new t},renderer:s,styles:a,init:i(r=>{r.class||(r.class={}),r.class.arrowMarkerAbsolute=r.arrowMarkerAbsolute},"init")};export{b as diagram};
@@ -1 +0,0 @@
1
- import{s as a,c as s,a as e,C as t}from"./chunk-4TB4RGXK-JytR14a9.js";import{_ as i}from"./App-Z4MU-H_F.js";import"./chunk-FMBD7UC4-C9s7KR9m.js";import"./chunk-YZCP3GAM-iE_tzriw.js";import"./chunk-55IACEB6-B4Q97BCP.js";import"./chunk-EDXVE4YY-R_qarkSf.js";import"./mobile-B7m9EO9D.js";import"./index-BEfrNBp8.js";import"./graph-CI_1htl0.js";var b={parser:e,get db(){return new t},renderer:s,styles:a,init:i(r=>{r.class||(r.class={}),r.class.arrowMarkerAbsolute=r.arrowMarkerAbsolute},"init")};export{b as diagram};
@@ -1 +0,0 @@
1
- import{b as r}from"./graph-BNzKGvoy.js";var e=4;function a(o){return r(o,e)}export{a as c};
@@ -1 +0,0 @@
1
- import{s as r,b as e,a,S as s}from"./chunk-OYMX7WX6-BySQzVxc.js";import{_ as i}from"./App-Z4MU-H_F.js";import"./chunk-55IACEB6-B4Q97BCP.js";import"./chunk-EDXVE4YY-R_qarkSf.js";import"./mobile-B7m9EO9D.js";import"./index-BEfrNBp8.js";import"./graph-CI_1htl0.js";var n={parser:a,get db(){return new s(2)},renderer:e,styles:r,init:i(t=>{t.state||(t.state={}),t.state.arrowMarkerAbsolute=t.arrowMarkerAbsolute},"init")};export{n as diagram};
@@ -1,81 +0,0 @@
1
- /**
2
- * append-integrity.js — integrity for append-only canon files.
3
- *
4
- * COMP-CANON-OVERRIDE S1. Shared with COMP-CANON-ATTEST, which needs the same
5
- * primitive for `docs/judgment/records/ledger.jsonl`.
6
- *
7
- * A whole-file hash over an append-only file churns on every append and so
8
- * carries no signal about history. The useful question is narrower: **is the
9
- * prefix we previously attested still byte-identical?** That is what
10
- * `{length, prefix_hash}` answers — `prefix_hash` is sha256 over
11
- * `bytes[0, length)`, so a legitimate append (which only extends) verifies
12
- * clean while any rewrite of earlier content does not.
13
- *
14
- * A `{length, tail_hash}` variant was tried first and rejected: hashing the
15
- * trailing bytes says nothing about the prefix, so an in-place edit of an
16
- * early row that preserves total byte length — the shape a careless
17
- * `sed -i 's/park/ship/'` takes — produced a byte-identical attestation.
18
- *
19
- * SCOPE: this is drift detection, not enforcement. It catches careless and
20
- * accidental corruption. A deliberate actor who rewrites the file AND
21
- * recomputes the baseline passes, because both live in the workspace the
22
- * actor can write. See design.md, "The in-workspace ceiling".
23
- *
24
- * Pure — no I/O. Callers own reading the file and storing the baseline.
25
- */
26
- import { createHash } from 'node:crypto';
27
-
28
- /** @typedef {{ length: number, prefix_hash: string }} AppendBaseline */
29
- /** @typedef {{ ok: boolean, kind: 'clean'|'shrunk'|'prefix_changed'|'malformed_baseline' }} AppendVerdict */
30
-
31
- const HEX64 = /^[0-9a-f]{64}$/;
32
-
33
- function toBuffer(bytes) {
34
- return Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes ?? '');
35
- }
36
-
37
- function sha256(buf) {
38
- return createHash('sha256').update(buf).digest('hex');
39
- }
40
-
41
- /**
42
- * Capture the attestable state of an append-only file.
43
- *
44
- * @param {Buffer|string} bytes current file contents
45
- * @returns {AppendBaseline}
46
- */
47
- export function baselineFor(bytes) {
48
- const buf = toBuffer(bytes);
49
- return { length: buf.length, prefix_hash: sha256(buf) };
50
- }
51
-
52
- /**
53
- * Verify that `bytes` is `baseline` plus zero or more appended bytes.
54
- *
55
- * Fails closed on a malformed baseline: a missing or nonsensical baseline is
56
- * never treated as "nothing to check". That mirrors the malformed-record rule
57
- * the judgment attestation already follows (S5 R4).
58
- *
59
- * @param {Buffer|string} bytes current file contents
60
- * @param {AppendBaseline} baseline previously attested state
61
- * @returns {AppendVerdict}
62
- */
63
- export function verifyAppend(bytes, baseline) {
64
- if (
65
- !baseline
66
- || typeof baseline !== 'object'
67
- || !Number.isInteger(baseline.length)
68
- || baseline.length < 0
69
- || typeof baseline.prefix_hash !== 'string'
70
- || !HEX64.test(baseline.prefix_hash)
71
- ) {
72
- return { ok: false, kind: 'malformed_baseline' };
73
- }
74
-
75
- const buf = toBuffer(bytes);
76
- if (buf.length < baseline.length) return { ok: false, kind: 'shrunk' };
77
- if (sha256(buf.subarray(0, baseline.length)) !== baseline.prefix_hash) {
78
- return { ok: false, kind: 'prefix_changed' };
79
- }
80
- return { ok: true, kind: 'clean' };
81
- }
@@ -1,196 +0,0 @@
1
- /**
2
- * canon-override.js — the canon override: grant-then-write, ledger-first.
3
- *
4
- * COMP-CANON-OVERRIDE S2, implementing COMP-CANON-GUARD Decision 4.
5
- *
6
- * WHAT THIS IS: audit and careless-drift tooling. It makes the cooperative
7
- * path logged and the accidental path hard. It is NOT enforcement and must
8
- * never be described as such — `Bash` and Codex writes never reach the hook,
9
- * and every piece of state here lives in the workspace the agent can write.
10
- * A determined actor who forges a token AND its ledger row AND the baseline
11
- * passes. See design.md, "The in-workspace ceiling".
12
- *
13
- * ORDERING IS THE ATOMICITY ANSWER. The bypass row and the attest baseline are
14
- * written BEFORE the token is minted, so the failure mode is over-recording,
15
- * never under-recording: a token cannot exist without its row. If the write
16
- * that follows never happens, a bypass entry exists for a write that did not
17
- * occur — deliberately the safe direction.
18
- *
19
- * SINGLE-USE IS A RENAME, NOT A LOCK. Each grant is its own file, claimed by
20
- * renaming it into `consumed/`. Exactly one caller can rename a given path;
21
- * every loser gets ENOENT. An earlier draft used one shared JSON file with
22
- * temp-write-plus-rename, which gives atomic *publication* but not mutual
23
- * exclusion — two readers could each drop the token from their own snapshot
24
- * and both succeed.
25
- */
26
- import {
27
- mkdirSync, writeFileSync, readFileSync, renameSync, readdirSync, existsSync, appendFileSync,
28
- } from 'node:fs';
29
- import { join, dirname } from 'node:path';
30
- import { randomUUID } from 'node:crypto';
31
- import { isOverrideEligible } from './canon-registry.js';
32
- import { baselineFor } from './append-integrity.js';
33
-
34
- export const LEDGER_REL = '.compose/canon-overrides.jsonl';
35
- export const ATTEST_REL = '.compose/canon-overrides-attest.json';
36
- export const GRANTS_REL = '.compose/data/canon-grants';
37
- const CONSUMED_SUBDIR = 'consumed';
38
-
39
- /** 5 minutes: long enough for a grant-then-write round trip through an agent
40
- * turn, short enough that a forgotten grant is not a standing hole. */
41
- export const GRANT_TTL_MS = 5 * 60 * 1000;
42
-
43
- function typedError(code, message) {
44
- const err = new Error(message);
45
- err.code = code;
46
- return err;
47
- }
48
-
49
- function ensureDir(path) {
50
- mkdirSync(path, { recursive: true });
51
- }
52
-
53
- /** Read the bypass ledger as parsed rows. Malformed lines are skipped rather
54
- * than thrown on — a corrupt row must not make every claim fail open. */
55
- export function readOverrideLedger(cwd) {
56
- const path = join(cwd, LEDGER_REL);
57
- let raw;
58
- try {
59
- raw = readFileSync(path, 'utf8');
60
- } catch (err) {
61
- if (err?.code === 'ENOENT') return [];
62
- throw err;
63
- }
64
- const rows = [];
65
- for (const line of raw.split('\n')) {
66
- if (!line.trim()) continue;
67
- try { rows.push(JSON.parse(line)); } catch { /* skip a corrupt row */ }
68
- }
69
- return rows;
70
- }
71
-
72
- /**
73
- * Mint a single-use, path-scoped grant.
74
- *
75
- * @param {string} cwd workspace root
76
- * @param {{path:string, reason:string, operation?:string, featuresDir?:string}} args
77
- * `actor` is deliberately NOT accepted — it is stamped here per Decision 3.
78
- * @returns {{token_id:string, path:string, expires_at:string}}
79
- */
80
- export function mintGrant(cwd, { path, reason, operation, featuresDir = 'docs/features' } = {}) {
81
- if (typeof reason !== 'string' || reason.trim() === '') {
82
- throw typedError(
83
- 'CANON_OVERRIDE_REASON',
84
- 'canon_override_grant: a non-empty reason is required — the bypass row is the whole point',
85
- );
86
- }
87
- if (typeof path !== 'string' || path.trim() === '') {
88
- throw typedError('CANON_OVERRIDE_PATH', 'canon_override_grant: path is required');
89
- }
90
- if (!isOverrideEligible(path, { featuresDir, point: 'hook' })) {
91
- throw typedError(
92
- 'CANON_OVERRIDE_INELIGIBLE',
93
- `canon_override_grant: ${path} is not override-eligible. Either it is not guarded at the `
94
- + 'write-time hook (nothing is blocking it), or it is the override\'s own governance state, '
95
- + 'which is deliberately ungrantable so a bypass cannot authorise rewriting its own record.',
96
- );
97
- }
98
-
99
- const now = new Date();
100
- const tokenId = randomUUID();
101
- const row = {
102
- ts: now.toISOString(),
103
- actor: 'agent', // stamped here; never caller-supplied
104
- path,
105
- reason: reason.trim(),
106
- operation: typeof operation === 'string' ? operation : null,
107
- token_id: tokenId,
108
- };
109
-
110
- // ── Ledger and baseline FIRST ──────────────────────────────────────────────
111
- const ledgerPath = join(cwd, LEDGER_REL);
112
- ensureDir(dirname(ledgerPath));
113
- appendFileSync(ledgerPath, `${JSON.stringify(row)}\n`);
114
- writeAttestBaseline(cwd);
115
-
116
- // ── Then the token ─────────────────────────────────────────────────────────
117
- const grantsDir = join(cwd, GRANTS_REL);
118
- ensureDir(grantsDir);
119
- const expiresAt = new Date(now.getTime() + GRANT_TTL_MS).toISOString();
120
- writeFileSync(
121
- join(grantsDir, `${tokenId}.json`),
122
- `${JSON.stringify({
123
- token_id: tokenId,
124
- path,
125
- actor: 'agent',
126
- operation: row.operation,
127
- issued_at: now.toISOString(),
128
- expires_at: expiresAt,
129
- }, null, 2)}\n`,
130
- { flag: 'wx' }, // exclusive create: mint is serialized too
131
- );
132
-
133
- return { token_id: tokenId, path, expires_at: expiresAt };
134
- }
135
-
136
- /** Re-attest the bypass ledger. Called only from the append path — never as a
137
- * standalone "fix", which would be the laundering step (S5 R1). */
138
- function writeAttestBaseline(cwd) {
139
- const ledgerPath = join(cwd, LEDGER_REL);
140
- const bytes = existsSync(ledgerPath) ? readFileSync(ledgerPath) : Buffer.alloc(0);
141
- const attestPath = join(cwd, ATTEST_REL);
142
- ensureDir(dirname(attestPath));
143
- writeFileSync(attestPath, `${JSON.stringify(baselineFor(bytes), null, 2)}\n`);
144
- }
145
-
146
- /**
147
- * Claim a live grant for exactly `path`. Returns true if the write may proceed.
148
- *
149
- * Never throws: the hook's policy is fail-open, and a claim that explodes must
150
- * not wedge the session. A false return means "no grant" and the caller denies
151
- * on its own terms.
152
- */
153
- export function claimGrant(cwd, path) {
154
- try {
155
- const grantsDir = join(cwd, GRANTS_REL);
156
- if (!existsSync(grantsDir)) return false;
157
-
158
- // A token is only honoured if its row is in the ledger. This is what stops
159
- // a raw-written token file from being consumable — the Bash-forgery case.
160
- // It binds the two artifacts without pretending to be crypto: an actor who
161
- // writes BOTH still passes, which is the documented ceiling.
162
- const ledgerTokenIds = new Set(
163
- readOverrideLedger(cwd).filter((r) => r?.path === path).map((r) => r?.token_id),
164
- );
165
- if (ledgerTokenIds.size === 0) return false;
166
-
167
- const now = Date.now();
168
- for (const entry of readdirSync(grantsDir, { withFileTypes: true })) {
169
- if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
170
- const tokenPath = join(grantsDir, entry.name);
171
-
172
- let token;
173
- try { token = JSON.parse(readFileSync(tokenPath, 'utf8')); } catch { continue; }
174
- if (token?.path !== path) continue;
175
- if (!ledgerTokenIds.has(token?.token_id)) continue;
176
-
177
- // Expiry from the immutable stamp, NOT mtime: a checkout gives a file a
178
- // fresh mtime, which would revive a mistakenly committed token.
179
- const expiresAt = Date.parse(token?.expires_at ?? '');
180
- if (!Number.isFinite(expiresAt) || expiresAt <= now) continue;
181
-
182
- // The claim. Exactly one caller can rename a given path; losers get ENOENT.
183
- const consumedDir = join(grantsDir, CONSUMED_SUBDIR);
184
- ensureDir(consumedDir);
185
- try {
186
- renameSync(tokenPath, join(consumedDir, entry.name));
187
- return true;
188
- } catch {
189
- continue; // another process won; try the next token
190
- }
191
- }
192
- return false;
193
- } catch {
194
- return false;
195
- }
196
- }