@smartmemory/compose 0.2.58-beta → 0.3.1-beta

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 (100) hide show
  1. package/README.md +2 -2
  2. package/bin/compose.js +55 -54
  3. package/dist/assets/{App-s4CulNkO.js → App-DR9pCiNE.js} +290 -290
  4. package/dist/assets/{abnfDiagram-VRR7QNED-CDwzQ5wr.js → abnfDiagram-VRR7QNED-CZ6p2bRe.js} +1 -1
  5. package/dist/assets/{arc-kIU8DDth.js → arc-6j04LBqn.js} +1 -1
  6. package/dist/assets/{architectureDiagram-ZJ3FMSHR-CleorO_0.js → architectureDiagram-ZJ3FMSHR-00QM3pmc.js} +1 -1
  7. package/dist/assets/{blockDiagram-677ZJIJ3-NvEFfoMk.js → blockDiagram-677ZJIJ3-BUb9Kjfd.js} +1 -1
  8. package/dist/assets/{c4Diagram-LMCZKHZV-BcDQdTVB.js → c4Diagram-LMCZKHZV-D1PkhmSm.js} +1 -1
  9. package/dist/assets/channel-BcWZM2KA.js +1 -0
  10. package/dist/assets/{chunk-2Q5K7J3B-BseRkpm4.js → chunk-2Q5K7J3B-CduDdMf4.js} +1 -1
  11. package/dist/assets/{chunk-32BRIVSS-DCHhvgFV.js → chunk-32BRIVSS-CH8Kj5rh.js} +1 -1
  12. package/dist/assets/{chunk-5VM5RSS4-DcTpMdJ4.js → chunk-5VM5RSS4-Cfts6Lix.js} +1 -1
  13. package/dist/assets/{chunk-EX3LRPZG-BdN8fvWs.js → chunk-EX3LRPZG-Dguk4yKZ.js} +1 -1
  14. package/dist/assets/{chunk-JWPE2WC7-DmZJP-8E.js → chunk-JWPE2WC7-DbCJp6t9.js} +1 -1
  15. package/dist/assets/{chunk-MOJQB5TN-V74uYM17.js → chunk-MOJQB5TN-CNjM_9_U.js} +1 -1
  16. package/dist/assets/{chunk-RYQCIY6F-CoXUXbn_.js → chunk-RYQCIY6F-D4k-BUbh.js} +1 -1
  17. package/dist/assets/{chunk-V7JOEXUC-DUscIydR.js → chunk-V7JOEXUC-O1sFoHf1.js} +1 -1
  18. package/dist/assets/{chunk-VR4S4FIN-C4UDHq_a.js → chunk-VR4S4FIN-CGbSXVPL.js} +1 -1
  19. package/dist/assets/{chunk-XXDRQBXY-DIH9i8mZ.js → chunk-XXDRQBXY-iIelV7SO.js} +1 -1
  20. package/dist/assets/classDiagram-OUVF2IWQ-Ce7YjlIK.js +1 -0
  21. package/dist/assets/classDiagram-v2-EOCWNBFH-Ce7YjlIK.js +1 -0
  22. package/dist/assets/{cose-bilkent-JH36ORCC-Dn6Gy6l1.js → cose-bilkent-JH36ORCC-BKmPLgw2.js} +1 -1
  23. package/dist/assets/{cynefin-VYW2F7L2-C2LW5fzB.js → cynefin-VYW2F7L2-CmT6ieC5.js} +1 -1
  24. package/dist/assets/{cynefinDiagram-TSTJHNR4-CGoAq7C9.js → cynefinDiagram-TSTJHNR4-Hn2vSVOw.js} +1 -1
  25. package/dist/assets/{dagre-VKFMJZFB-BaOKtuBE.js → dagre-VKFMJZFB-B0ifIwlU.js} +1 -1
  26. package/dist/assets/{diagram-FQU43EPY-BdQwqt4x.js → diagram-FQU43EPY-BuCDB-JG.js} +1 -1
  27. package/dist/assets/{diagram-G47NLZAW-Dkcf0o5z.js → diagram-G47NLZAW-D5avTZAQ.js} +1 -1
  28. package/dist/assets/{diagram-NH7WQ7WH-C_fDi59r.js → diagram-NH7WQ7WH-24QCIgpj.js} +1 -1
  29. package/dist/assets/{diagram-OA4YK3LP-jaTQcOxt.js → diagram-OA4YK3LP-A-bbUTK3.js} +1 -1
  30. package/dist/assets/{diagram-WEI45ONY-dEtqVj3s.js → diagram-WEI45ONY-BIMDm-Kj.js} +1 -1
  31. package/dist/assets/{ebnfDiagram-CCIWWBDH-s8ImqsSs.js → ebnfDiagram-CCIWWBDH-DCXMfG-M.js} +1 -1
  32. package/dist/assets/{erDiagram-Q63AITRT-5Zy29-SA.js → erDiagram-Q63AITRT-BlDrG0Ev.js} +1 -1
  33. package/dist/assets/{flowDiagram-23GEKE2U-DV74V9he.js → flowDiagram-23GEKE2U-aTyazY6P.js} +1 -1
  34. package/dist/assets/{ganttDiagram-NO4QXBWP-BND-CiLO.js → ganttDiagram-NO4QXBWP-Bxo-a80f.js} +1 -1
  35. package/dist/assets/{gitGraphDiagram-IHSO6WYX-B_-Mf9Jo.js → gitGraphDiagram-IHSO6WYX-COhKb9wR.js} +1 -1
  36. package/dist/assets/index-BcmsQZX-.js +119 -0
  37. package/dist/assets/{infoDiagram-FWYZ7A6U-Duyz8rZM.js → infoDiagram-FWYZ7A6U-DLCbpinV.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-FXEZZL3T-D2Qft0e5.js → ishikawaDiagram-FXEZZL3T-eyHF7GIj.js} +1 -1
  39. package/dist/assets/{journeyDiagram-5HDEW3XC-q6rG4Rig.js → journeyDiagram-5HDEW3XC-BsVocmlw.js} +1 -1
  40. package/dist/assets/{kanban-definition-HUTT4EX6-CsPX7gBL.js → kanban-definition-HUTT4EX6-CaFPSZxY.js} +1 -1
  41. package/dist/assets/{linear-CiccpAAd.js → linear-cqnI_wgW.js} +1 -1
  42. package/dist/assets/{mindmap-definition-LN4V7U3C-DRW2njpA.js → mindmap-definition-LN4V7U3C-B66BGqKn.js} +1 -1
  43. package/dist/assets/{pegDiagram-2B236MQR-DoDgm2S_.js → pegDiagram-2B236MQR-F6IN0_L5.js} +1 -1
  44. package/dist/assets/{pieDiagram-ENE6RG2P-CNSYLVAF.js → pieDiagram-ENE6RG2P-D5nOQxpo.js} +1 -1
  45. package/dist/assets/{quadrantDiagram-ABIIQ3AL-8-Nd6ony.js → quadrantDiagram-ABIIQ3AL-DwUAYbMO.js} +1 -1
  46. package/dist/assets/{railroadDiagram-RFXS5EU6-bfdg6Bo4.js → railroadDiagram-RFXS5EU6-Cvmlah5P.js} +1 -1
  47. package/dist/assets/{requirementDiagram-TGXJPOKE-slDkxCQy.js → requirementDiagram-TGXJPOKE-DoUtsFnL.js} +1 -1
  48. package/dist/assets/{sankeyDiagram-HTMAVEWB-CR3CboiK.js → sankeyDiagram-HTMAVEWB-D4XTwiKp.js} +1 -1
  49. package/dist/assets/{sequenceDiagram-DBY2YBRQ-BIXw1JS1.js → sequenceDiagram-DBY2YBRQ-CiG9yRIR.js} +1 -1
  50. package/dist/assets/{sizeCapture-X5ZJPWSS-BdKI7JyF.js → sizeCapture-X5ZJPWSS-zd7ydQ_Y.js} +1 -1
  51. package/dist/assets/{stateDiagram-2N3HPSRC-ktVgpBZS.js → stateDiagram-2N3HPSRC-BZesBHQ4.js} +1 -1
  52. package/dist/assets/stateDiagram-v2-6OUMAXLB-BnlLOJzB.js +1 -0
  53. package/dist/assets/{swimlanes-5IMT3BWC-CaXpC3FM.js → swimlanes-5IMT3BWC-Bg8exSE7.js} +2 -2
  54. package/dist/assets/swimlanesDiagram-G3AALYLV-DZMVxqbs.js +8 -0
  55. package/dist/assets/{timeline-definition-FHXFAJF6-CKWSlRUl.js → timeline-definition-FHXFAJF6-TKj_HY9k.js} +1 -1
  56. package/dist/assets/{vennDiagram-L72KCM5P-CwOhxDyv.js → vennDiagram-L72KCM5P-CJf-bqn0.js} +1 -1
  57. package/dist/assets/{wardleyDiagram-EHGQE667-DGavMpEB.js → wardleyDiagram-EHGQE667-CqizSfCL.js} +1 -1
  58. package/dist/assets/{xychartDiagram-FW5EYKEG-CWmnmLZz.js → xychartDiagram-FW5EYKEG-CK-OErMM.js} +1 -1
  59. package/dist/index.html +1 -1
  60. package/lib/build-all.js +0 -5
  61. package/lib/build-stream-schema.js +1 -1
  62. package/lib/build.js +1762 -2614
  63. package/lib/consumer-fanout.js +1317 -0
  64. package/lib/feature-validator.js +6 -4
  65. package/lib/flow-state.js +15 -14
  66. package/lib/gsd-budget.js +48 -10
  67. package/lib/gsd-prompt.js +3 -4
  68. package/lib/gsd-stuck.js +1 -1
  69. package/lib/gsd.js +303 -149
  70. package/lib/local-claude-connector.js +149 -0
  71. package/lib/new.js +162 -307
  72. package/lib/result-normalizer.js +233 -18
  73. package/lib/review-lenses.js +1 -1
  74. package/lib/step-prompt.js +41 -119
  75. package/lib/stratum-engine.js +297 -0
  76. package/lib/stratum-mcp-client.js +224 -213
  77. package/lib/vocabulary-compliance.js +268 -0
  78. package/lib/vocabulary-inject.js +1 -36
  79. package/package.json +6 -2
  80. package/pipelines/build-quick.stratum.yaml +5 -17
  81. package/pipelines/build.profiles.json +11 -0
  82. package/pipelines/build.stratum.yaml +288 -467
  83. package/pipelines/gsd.stratum.yaml +73 -125
  84. package/pipelines/new.stratum.yaml +68 -149
  85. package/server/build-routes.js +4 -1
  86. package/server/design-routes.js +37 -21
  87. package/server/index.js +13 -21
  88. package/server/lifecycle-guard.js +1 -1
  89. package/server/pipeline-routes.js +113 -31
  90. package/server/stratum-client.js +33 -47
  91. package/server/stratum-sync.js +3 -4
  92. package/server/vision-server.js +1 -1
  93. package/dist/assets/channel-DiJkE9og.js +0 -1
  94. package/dist/assets/classDiagram-OUVF2IWQ-DS76VBEL.js +0 -1
  95. package/dist/assets/classDiagram-v2-EOCWNBFH-DS76VBEL.js +0 -1
  96. package/dist/assets/index-CBWbmG7W.js +0 -119
  97. package/dist/assets/stateDiagram-v2-6OUMAXLB-CzPQnlan.js +0 -1
  98. package/dist/assets/swimlanesDiagram-G3AALYLV-rIDv6EuQ.js +0 -8
  99. package/lib/connector-factory-shim.js +0 -167
  100. package/server/agent-mcp.js +0 -10
package/lib/gsd.js CHANGED
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // Self-contained status loop. Does NOT modify lib/build.js. Reuses primitives:
6
6
  // - StratumMcpClient (lib/stratum-mcp-client.js) for plan/stepDone/runAgentText
7
- // - executeParallelDispatchServer (lib/build.js) for the execute step
7
+ // - runConsumerIssuance (lib/build.js) for TS consumer fanout work
8
8
  // - validateBoundaryMap (lib/boundary-map.js) for precondition check
9
9
  // - enrichTaskGraph (lib/gsd-decompose-enrich.js) for decompose validation
10
10
  // - buildTaskDescription (lib/gsd-prompt.js) for description repair fallback
@@ -17,15 +17,23 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, rmSync
17
17
  import { join, resolve, dirname } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { execSync } from 'node:child_process';
20
+ import { createHash } from 'node:crypto';
21
+ import YAML from 'yaml';
20
22
 
21
23
  import { StratumMcpClient } from './stratum-mcp-client.js';
24
+ import { resolveStratumMcpConnection } from './stratum-engine.js';
22
25
  import { validateBoundaryMap } from './boundary-map.js';
23
26
  import { enrichTaskGraph } from './gsd-decompose-enrich.js';
24
27
  import { buildTaskDescription } from './gsd-prompt.js';
25
28
  import { writeAll, validate as validateTaskResult, read as readBlackboard } from './gsd-blackboard.js';
26
- import { executeParallelDispatchServer, executeShipStep } from './build.js';
29
+ import { executeShipStep, runConsumerIssuance, ConsumerStuckError, filesOwnedConflict, toEngineUsage } from './build.js';
30
+ import {
31
+ ConsumerFanoutArtifacts,
32
+ ConsumerMergeDecisionError,
33
+ isConsumerDescriptor,
34
+ } from './consumer-fanout.js';
27
35
  import { GsdStuckDetector, DEFAULT_THRESHOLDS } from './gsd-stuck.js';
28
- import { readGsdBudgetConfig, buildBudgetBlock, injectBudget, composeBudgetDiagnostic } from './gsd-budget.js';
36
+ import { readGsdBudgetConfig, buildBudgetBlock, injectBudget, composeBudgetDiagnostic, budgetStateFromLedger } from './gsd-budget.js';
29
37
  import { recordGsdUsage, checkGsdCumulativeBudget } from './budget-ledger.js';
30
38
  // COMP-GSD-6: continuous run-state checkpoint + canonical pid-liveness probe.
31
39
  // pidAlive is canonical in gsd-state.js (EPERM=alive) and imported one-way here.
@@ -45,6 +53,10 @@ const DEFAULT_GATE_COMMANDS = ['pnpm lint', 'pnpm build', 'pnpm test'];
45
53
  // (execute.pre_merge_verify) and the instructed gate (task descriptions).
46
54
  const DEFAULT_FAST_GATE = ['pnpm lint', 'pnpm build'];
47
55
 
56
+ function sha256(value) {
57
+ return createHash('sha256').update(String(value)).digest('hex');
58
+ }
59
+
48
60
  // ---------- Public API ----------
49
61
 
50
62
  export async function runGsd(featureCode, opts = {}) {
@@ -138,6 +150,8 @@ export async function runGsd(featureCode, opts = {}) {
138
150
  // run (and plain `compose build`) is byte-identical.
139
151
  const budgetCfg = readGsdBudgetConfig(cwd);
140
152
  const specYaml = injectBudget(readFileSync(specPath, 'utf-8'), budgetCfg);
153
+ const localSpec = YAML.parse(specYaml);
154
+ const localSpecDigest = sha256(JSON.stringify(localSpec));
141
155
 
142
156
  // 4a. COMP-GSD-4: cumulative cross-session ceiling pre-check (tokens/cost).
143
157
  // Refuse to start/resume a run that has already spent its lifetime budget —
@@ -154,13 +168,13 @@ export async function runGsd(featureCode, opts = {}) {
154
168
 
155
169
  // 4b. COMP-GSD-5 stuck detector — thresholds from .compose/compose.json
156
170
  // `gsd.stuck.*` with documented defaults. ONLY gsd passes this into the
157
- // shared executeParallelDispatchServer, so build mode is byte-identical.
171
+ // TS consumer runner, so build mode remains unaffected.
158
172
  const stuckDetector = buildStuckDetector(cwd);
159
173
 
160
174
  // 5. Connect Stratum + plan (only after preconditions pass)
161
175
  const stratum = opts.stratum ?? new StratumMcpClient();
162
176
  const ownsStratum = !opts.stratum;
163
- if (ownsStratum) await stratum.connect();
177
+ if (ownsStratum) await stratum.connect(resolveStratumMcpConnection(cwd));
164
178
  // COMP-GSD-4: ownership flag — release the resume lock in finally ONLY if THIS
165
179
  // process successfully claimed it (set below). Prevents (a) a non-resume run
166
180
  // from clobbering a concurrent resume's valid claim and (b) a claim-race loser
@@ -217,8 +231,12 @@ export async function runGsd(featureCode, opts = {}) {
217
231
  // docs unless context.filesChanged is provided.
218
232
  stepCtx = {
219
233
  stratum, cwd, featureCode, blueprintText, gateCommands, preMergeGate,
234
+ localSpec, localSpecDigest,
220
235
  filesChanged: [],
221
236
  stuckDetector,
237
+ // D2(a): per-ITEM wall-clock ceiling from gsd.budget.per_task_ms, enforced
238
+ // compose-side in runConsumerIssuance (the engine can't bound N items).
239
+ perItemTimeoutMs: budgetCfg.per_task_ms ?? null,
222
240
  resumeTaskGraph,
223
241
  stuck: null, // set by runOneStep on a stuck verdict
224
242
  runState: initialState, // COMP-GSD-6: flushState merges into this
@@ -269,43 +287,22 @@ export async function runGsd(featureCode, opts = {}) {
269
287
  featureCode,
270
288
  gateCommands,
271
289
  pre_merge_gate: preMergeGate,
272
- });
273
- const flowId = response.flow_id;
290
+ }, { workspaceRoot: cwd });
291
+ const flowId = response.runId;
274
292
  flushState(stepCtx, { flowId, phase: 'decompose' });
275
293
  emitPhaseOnce(stepCtx, 'decompose'); // COMP-GSD-7-EVENTLOG
276
294
 
277
- // 5. Status loop. `stuck` (COMP-GSD-5) and `budget_exhausted` (COMP-GSD-4)
278
- // are terminal statuses. `stuck` is set compose-side by runOneStep; budget
279
- // is the stratum flow-budget terminal, surfaced verbatim through the advance/
280
- // poll envelopes (and carries budget_state).
295
+ // 5. TS status loop. `stuck` is a Compose-side terminal and
296
+ // `budget_exhausted` is the Stratum flow-budget terminal.
281
297
  while (
282
- response.status !== 'complete' &&
283
- response.status !== 'killed' &&
298
+ response.status !== 'completed' &&
299
+ response.status !== 'failed' &&
284
300
  response.status !== 'stuck' &&
285
- response.status !== 'budget_exhausted' &&
286
- response.status !== 'error' // COMP-PAR-MERGE-QUEUE: terminal step failure (e.g. retries_exhausted)
301
+ response.status !== 'budget_exhausted'
287
302
  ) {
288
303
  response = await runOneStep(response, stepCtx);
289
304
  }
290
305
 
291
- // COMP-PAR-MERGE-QUEUE: a step that exhausted its retries (e.g. the execute
292
- // step after repeated pre-merge gate failures) surfaces as a terminal `error`
293
- // envelope rather than silently advancing to ship. Stop here with the failure
294
- // and its bounce context instead of throwing `unknown response status`.
295
- if (response.status === 'error') {
296
- emitCompletionDeltas(stepCtx);
297
- flushState(stepCtx, { status: 'failed' });
298
- return {
299
- status: 'failed',
300
- flowId,
301
- stepId: response.step_id ?? stepCtx.lastStepId ?? null,
302
- errorType: response.error_type ?? 'step_failed',
303
- message: response.message ?? 'GSD step failed',
304
- violations: response.violations ?? [],
305
- bouncedTasks: response.bounced_tasks ?? [],
306
- };
307
- }
308
-
309
306
  if (response.status === 'stuck') {
310
307
  // Artifacts (stuck.md/json + pause.json) were written by runOneStep.
311
308
  // COMP-GSD-7-EVENTLOG: flush any completions that finished before the stuck
@@ -327,9 +324,10 @@ export async function runGsd(featureCode, opts = {}) {
327
324
  // cascade-cancelled in-flight siblings. Persist budget.{md,json} +
328
325
  // pause.json (kind:budget) for --resume, record cumulative usage, and
329
326
  // return a terminal `budget` envelope. pause.lock is released by finally.
330
- const budgetState = response.budget_state ?? {};
327
+ // D2(c): derive Compose's budget diagnostic from the TS ledger.
328
+ const budgetState = budgetStateFromLedger(response.ledger) ?? {};
331
329
  writeBudgetArtifacts(stepCtx, response, budgetState);
332
- recordGsdUsageFromState(cwd, featureCode, budgetState);
330
+ recordGsdUsageFromState(cwd, featureCode, budgetState, stepCtx.recordedUsage);
333
331
  const axis = composeBudgetDiagnostic(budgetState, { feature: featureCode }).json.axis;
334
332
  // COMP-GSD-7-EVENTLOG: flush pre-halt completions, then record the pause.
335
333
  emitCompletionDeltas(stepCtx);
@@ -347,20 +345,22 @@ export async function runGsd(featureCode, opts = {}) {
347
345
 
348
346
  // 6b. COMP-GSD-5: a clean (non-stuck) finish clears any pause.json — the
349
347
  // resume completed, or a fresh run superseded a stale pause.
350
- if (response.status === 'complete') {
348
+ if (response.status === 'complete' || response.status === 'completed') {
351
349
  // COMP-GSD-4: record this run's cumulative usage (best-effort; no-op when
352
- // the complete envelope carries no budget_state, e.g. un-budgeted runs).
353
- recordGsdUsageFromState(cwd, featureCode, response.budget_state);
350
+ // the terminal envelope carries no budget accounting, e.g. un-budgeted
351
+ // runs). D2(c): the TS engine returns `ledger`, not `budget_state`.
352
+ const terminalBudgetState = response.budget_state ?? budgetStateFromLedger(response.ledger);
353
+ recordGsdUsageFromState(cwd, featureCode, terminalBudgetState, stepCtx.recordedUsage);
354
354
  clearPauseFile(cwd, featureCode);
355
355
  // COMP-GSD-7: on a clean complete, budget.json is NOT written (only halts
356
356
  // write it). Persist a budget-final.json snapshot so the milestone report
357
357
  // (auto + retroactive `gsd report`) has actuals-vs-caps. No-op when the
358
- // envelope carries no budget_state (un-budgeted run). Best-effort: this is
359
- // a derived report input — a write failure must NEVER demote a successful
360
- // run to 'failed' via the outer catch.
361
- if (response.budget_state) {
358
+ // envelope carries no budget accounting (un-budgeted run). Best-effort:
359
+ // this is a derived report input — a write failure must NEVER demote a
360
+ // successful run to 'failed' via the outer catch.
361
+ if (terminalBudgetState) {
362
362
  try {
363
- writeBudgetFinalSnapshot(stepCtx, response.budget_state);
363
+ writeBudgetFinalSnapshot(stepCtx, terminalBudgetState);
364
364
  } catch (err) {
365
365
  console.warn(`[gsd] budget-final snapshot failed: ${err.message}`);
366
366
  }
@@ -371,7 +371,7 @@ export async function runGsd(featureCode, opts = {}) {
371
371
  // other terminal here (e.g. stratum 'killed') maps to 'failed' so we stay
372
372
  // within the closed status vocabulary the contract + supervisor share.
373
373
  // COMP-GSD-7: stamp completedAt so retroactive reports can recover wall-clock.
374
- const terminalStatus = response.status === 'complete' ? 'complete' : 'failed';
374
+ const terminalStatus = ['complete', 'completed'].includes(response.status) ? 'complete' : 'failed';
375
375
  // COMP-GSD-7-EVENTLOG: emit the terminal event. complete → final completion
376
376
  // deltas + 'completed'; any other terminal (e.g. stratum 'killed') → 'failed'.
377
377
  if (terminalStatus === 'complete') {
@@ -475,61 +475,78 @@ export function resolvePreMergeGate(cwd, override) {
475
475
  }
476
476
 
477
477
  async function runOneStep(response, ctx) {
478
- const { stratum, cwd, featureCode, blueprintText, gateCommands, preMergeGate } = ctx;
479
- const flowId = response.flow_id;
480
- const stepId = response.step_id;
481
- const stepType = response.type ?? response.step_type;
482
- if (stepId) ctx.lastStepId = stepId; // COMP-GSD-4: for the budget pause's stepId
483
-
484
- if (response.status === 'execute_step') {
485
- // parallel_dispatch step (the `execute` step)
486
- if (stepType === 'parallel_dispatch' || response.tasks) {
487
- const outcome = await executeParallelDispatchServer(
488
- response,
489
- stratum,
490
- { cwd, featureCode, gsd: true }, // COMP-GSD-7: gates timing+diff capture
491
- null, // progress
492
- { write: () => {} }, // streamWriter — no-op for v1
493
- cwd,
494
- {
495
- stuckDetector: ctx.stuckDetector, // COMP-GSD-5 (null in non-gsd callers)
496
- // COMP-GSD-6: bump state.json's heartbeat on every task event so a long
497
- // task sitting in the dispatch poll loop isn't mistaken for crashed.
498
- onHeartbeat: ctx.runState ? () => { try { flushState(ctx, {}); } catch { /* best-effort */ } } : null,
499
- },
500
- );
501
-
502
- // COMP-GSD-5: a stuck verdict halts the run. Persist the diagnostic +
503
- // resume state, then return a terminal `stuck` envelope so runGsd's loop
504
- // exits. The task was already cancelled (conflict) inside dispatch.
505
- if (outcome && outcome.stuck) {
506
- ctx.stuck = outcome.stuck;
507
- writeStuckArtifacts(ctx, response, outcome.stuck);
508
- return { status: 'stuck', flow_id: flowId, step_id: stepId };
478
+ const { stratum, cwd, featureCode, localSpec, localSpecDigest } = ctx;
479
+ const flowId = response.runId;
480
+
481
+ if (response.status === 'ready') {
482
+ const ready = response.ready ?? [];
483
+ if (ready.length === 0) throw new Error('runGsd: TS engine returned ready without a ready entry');
484
+ const descriptor = ready.find(isConsumerDescriptor);
485
+ if (descriptor) {
486
+ if (!ctx.consumerArtifacts) {
487
+ ctx.consumerArtifacts = new ConsumerFanoutArtifacts({
488
+ runId: flowId,
489
+ targetCwd: cwd,
490
+ revisionDigest: descriptor.revisionDigest,
491
+ specDigest: localSpecDigest,
492
+ });
509
493
  }
510
-
511
- // After diffs are merged, capture the touched files for ship_gsd
512
- // staging. The clean-workspace precondition above guarantees every
513
- // file in the post-execute dirty set is genuinely a GSD-produced change.
514
- ctx.filesChanged = collectChangedFiles(cwd);
515
- // COMP-GSD-6: checkpoint completed tasks after the execute merge.
516
- // COMP-GSD-7-EVENTLOG: emit the execute-phase transition once, then a
517
- // task_completed event per newly-completed task.
518
- if (ctx.runState) {
519
- const completed = collectCompletedTaskIds(cwd, featureCode);
520
- flushState(ctx, { phase: 'execute', completedTaskIds: completed });
521
- emitPhaseOnce(ctx, 'execute'); // dedupes; runState.phase can't gate this
522
- emitCompletionDeltas(ctx, completed);
494
+ ctx.consumerArtifacts.bindRunRevision({
495
+ revisionDigest: descriptor.revisionDigest,
496
+ specDigest: localSpecDigest,
497
+ });
498
+ // D7(a): render the exact TaskResult path from the item's task id (the
499
+ // fanout `over` is decompose_gsd.output.tasks, indexed by itemIndex).
500
+ const consumerItem = ctx.lastTaskGraph?.tasks?.[descriptor.itemIndex];
501
+ const taskResultPath = consumerItem?.id
502
+ ? gsdTaskResultPath(featureCode, consumerItem.id)
503
+ : undefined;
504
+ try {
505
+ return await runConsumerIssuance({
506
+ descriptor,
507
+ flowId,
508
+ stratum,
509
+ artifacts: ctx.consumerArtifacts,
510
+ audit: await stratum.audit(flowId),
511
+ localSpec,
512
+ // D2(b): onUsage debits each item's agent usage into the cumulative
513
+ // ledger. D2(a): per-item wall-clock ceiling from gsd.budget.per_task_ms.
514
+ // D3: the stuck detector observes this item's agent tool events.
515
+ // D7(a): taskResultPath spells out the exact TaskResult filename.
516
+ // I3: `gsd: true` is the REAL GSD marker the milestone instrumentation
517
+ // in runConsumerIssuance keys on — every consumer item dispatched here is
518
+ // by definition a GSD task, so timing.json + diffs/<id>.diff are written
519
+ // for the milestone report. The build consumer path omits it (no marker),
520
+ // so build-mode fanout stays byte-identical.
521
+ context: { cwd, featureCode, gsd: true, gsdTaskId: consumerItem?.id, filesChanged: ctx.filesChanged, onUsage: (usage) => recordTsAgentUsage(ctx, usage), taskResultPath },
522
+ // runAndNormalize narrates via progress.debug/warn/info/toolUse — gsd
523
+ // has no cockpit, so a COMPLETE no-op (not just stepStart/stepDone) is
524
+ // required or the item throws "progress.debug is not a function".
525
+ progress: NOOP_PROGRESS,
526
+ streamWriter: { write() {} },
527
+ perItemTimeoutMs: ctx.perItemTimeoutMs,
528
+ stuckDetector: ctx.stuckDetector,
529
+ });
530
+ } catch (error) {
531
+ // D3: a stuck verdict halts the run. Persist the diagnostic + resume
532
+ // state and return a terminal `stuck` envelope so runGsd's loop exits.
533
+ if (!(error instanceof ConsumerStuckError)) throw error;
534
+ const verdict = {
535
+ taskId: error.taskId,
536
+ signal: error.verdict?.signal,
537
+ detail: error.verdict?.detail,
538
+ attemptCounts: ctx.stuckDetector?.attemptCounts?.(error.taskId) ?? {},
539
+ };
540
+ ctx.stuck = verdict;
541
+ writeStuckArtifacts(ctx, { flow_id: flowId, step_id: descriptor.step ?? descriptor.id }, verdict);
542
+ return { status: 'stuck', flow_id: flowId, step_id: descriptor.step ?? descriptor.id };
523
543
  }
524
- // executeParallelDispatchServer returns the next-step dispatch envelope
525
- return outcome;
526
544
  }
527
545
 
528
- // ship_gsd: delegate to executeShipStep with filesChanged from execute step
529
- // so source files are staged. Agent sandbox blocks git, so commit must
530
- // run in-process (mirrors runBuild's special case at lib/build.js:963-981).
531
- if (stepId === 'ship_gsd') {
532
- const shipResult = await executeShipStep(
546
+ const step = ready[0];
547
+ ctx.lastStepId = step.id;
548
+ if (step.id === 'ship_gsd') {
549
+ const result = await executeShipStep(
533
550
  featureCode,
534
551
  cwd,
535
552
  cwd,
@@ -537,80 +554,161 @@ async function runOneStep(response, ctx) {
537
554
  '',
538
555
  null,
539
556
  );
540
- // executeShipStep stages + commits but does NOT push. Push is a
541
- // user-facing operation deferred to the user in v1; runBuild's ship
542
- // step doesn't auto-push either. Document via ship intent later.
543
- return await stratum.stepDone(flowId, stepId, shipResult);
557
+ return stratum.stepDone(flowId, step.id, { output: result }, step.dispatchToken);
544
558
  }
545
-
546
- // COMP-GSD-5 resume: skip the decompose AGENT entirely and substitute the
547
- // persisted task graph (already enriched/repaired during the original run
548
- // and already filtered to exclude completedTaskIds). We do NOT re-run
549
- // validateAndRepairTaskGraph: enrichTaskGraph would flag the completed
550
- // tasks' Boundary Map slices as orphaned (no task in the SUBSET owns them).
551
- // Stable task IDs + no re-decompose are the whole point.
552
- if (stepId === 'decompose_gsd' && ctx.resumeTaskGraph) {
559
+ if (step.id === 'decompose_gsd' && ctx.resumeTaskGraph) {
553
560
  ctx.lastTaskGraph = ctx.resumeTaskGraph;
554
- // COMP-GSD-6: a resume already has the (filtered) task graph — mark
555
- // resumeReady so a re-crash during execute resumes rather than restarts.
556
- if (ctx.runState) {
557
- flushState(ctx, {
558
- phase: 'execute',
559
- resumeReady: true,
560
- decomposedTasks: (ctx.resumeTaskGraph.tasks ?? []).map((t) => ({ ...t })),
561
- });
562
- }
563
- return await stratum.stepDone(flowId, stepId, ctx.resumeTaskGraph);
561
+ return stratum.stepDone(
562
+ flowId, step.id, { output: ctx.resumeTaskGraph }, step.dispatchToken,
563
+ );
564
564
  }
565
565
 
566
- // Single-agent step: dispatch via runAgentText. The agent returns text;
567
- // we expect JSON matching the step's output_contract.
568
- const prompt = response.intent ?? '';
569
- const text = await stratum.runAgentText(response.agent ?? 'claude', prompt, { cwd });
566
+ // D2(b): dispatch via agentRun (not runAgentText) so the TS complete
567
+ // envelope's usage is captured and debited into the cumulative ledger.
568
+ const runOut = await stratum.agentRun(step.agent ?? 'claude', step.do ?? '', { cwd });
569
+ const text = runOut?.text ?? '';
570
+ recordTsAgentUsage(ctx, runOut?.usage);
571
+ // V1: also report usage in the step_done envelope so the ENGINE debits its
572
+ // flow budget (not just compose's cumulative ledger above).
573
+ const ordinaryUsage = toEngineUsage(runOut?.usage);
570
574
  let result;
571
575
  try {
572
576
  result = parseJsonFromText(text);
573
577
  } catch (err) {
574
- throw new Error(
575
- `runGsd: step ${stepId} agent did not return parseable JSON: ${err.message}`,
576
- );
578
+ throw new Error(`runGsd: step ${step.id} agent did not return parseable JSON: ${err.message}`);
577
579
  }
578
-
579
- // T6 step 7: validate decompose_gsd output and repair missing descriptions.
580
- if (stepId === 'decompose_gsd') {
581
- // COMP-PAR-MERGE-QUEUE: single-source the per-task instructed gate to the
582
- // fast pre-merge gate (== the enforced execute.pre_merge_verify). Full
583
- // `pnpm test` is instructed only at ship_gsd.
584
- result = validateAndRepairTaskGraph(result, blueprintText, preMergeGate ?? gateCommands);
585
- // COMP-GSD-5: remember the ENRICHED graph so a later stuck halt can
586
- // persist the full task definitions (with descriptions/produces/consumes)
587
- // into pause.json — resume re-dispatches these without re-enriching.
580
+ if (step.id === 'decompose_gsd') {
581
+ try {
582
+ result = validateAndRepairTaskGraph(result, ctx.blueprintText, ctx.preMergeGate ?? ctx.gateCommands);
583
+ } catch (err) {
584
+ // F6: an ownership conflict is an expected, retryable rejection. Send a
585
+ // FAILURE step_done envelope (not a thrown abort) so decompose_gsd's
586
+ // declared attempts govern the engine retries with previousFailure
587
+ // feedback. Genuinely unexpected errors still propagate.
588
+ if (!(err instanceof TaskGraphOwnershipError) && !(err instanceof TaskGraphDuplicateIdError)) throw err;
589
+ return stratum.stepDone(
590
+ flowId, step.id,
591
+ { failure: err.message, ...(ordinaryUsage ? { usage: ordinaryUsage } : {}) },
592
+ step.dispatchToken,
593
+ );
594
+ }
588
595
  ctx.lastTaskGraph = result;
589
- // COMP-GSD-6: the task graph now exists → resumeReady true; persist it so a
590
- // crash during execute can synthesize a resume graph from state.json.
591
596
  if (ctx.runState) {
592
597
  flushState(ctx, {
593
598
  phase: 'execute',
594
599
  resumeReady: true,
595
- decomposedTasks: (result.tasks ?? []).map((t) => ({ ...t })),
600
+ decomposedTasks: (result.tasks ?? []).map((task) => ({ ...task })),
596
601
  });
597
602
  }
598
603
  }
599
-
600
- return await stratum.stepDone(flowId, stepId, result);
604
+ return stratum.stepDone(
605
+ flowId, step.id,
606
+ { output: result, ...(ordinaryUsage ? { usage: ordinaryUsage } : {}) },
607
+ step.dispatchToken,
608
+ );
601
609
  }
602
610
 
603
- if (response.status === 'await_gate') {
604
- // GSD has no gates in v1. If we hit one, surface it.
605
- throw new Error(
606
- `runGsd: unexpected gate at step ${stepId}. v1 has no gates in the gsd flow.`,
611
+ if (response.status === 'running') {
612
+ const audit = await stratum.audit(flowId);
613
+ const waiting = Object.entries(audit?.steps ?? {})
614
+ .find(([, state]) => state?.status === 'waiting_gate');
615
+ if (!waiting) throw new Error('runGsd: TS engine is running without ready work or a waiting gate');
616
+ const [gateStepId, gateState] = waiting;
617
+ const steps = localSpec.flows[localSpec.flows.entry].steps;
618
+ const gateStep = steps.find((step) => step.id === gateStepId);
619
+ const predecessorIds = new Set(gateStep?.after ?? []);
620
+ const fanoutStep = steps.find((step) => predecessorIds.has(step.id) && step.fanout?.dispatch === 'consumer');
621
+ if (!fanoutStep) {
622
+ throw new Error(`runGsd: unexpected non-consumer gate ${gateStepId} on the TS path`);
623
+ }
624
+ if (!ctx.consumerArtifacts) {
625
+ ctx.consumerArtifacts = new ConsumerFanoutArtifacts({
626
+ runId: flowId,
627
+ targetCwd: cwd,
628
+ revisionDigest: response.revisionDigest,
629
+ specDigest: localSpecDigest,
630
+ });
631
+ }
632
+ const artifacts = ctx.consumerArtifacts;
633
+ artifacts.recordGateBinding({ gateStepId, fanoutStepId: fanoutStep.id });
634
+ let transaction;
635
+ let outcome = 'approve';
636
+ let rationale = 'consumer fanout artifacts merged';
637
+ try {
638
+ transaction = artifacts.prepareMerge({
639
+ gateStepId,
640
+ gateToken: gateState.gateToken,
641
+ fanoutStepId: fanoutStep.id,
642
+ audit,
643
+ });
644
+ await artifacts.applyMerge(transaction);
645
+ } catch (error) {
646
+ if (!(error instanceof ConsumerMergeDecisionError)) throw error;
647
+ outcome = gateStep?.gate?.on_revise ? 'revise' : 'kill';
648
+ rationale = `${error.code}: ${error.message}`;
649
+ transaction ??= artifacts.journal.mergeTransactions.find(
650
+ (entry) => entry.gateToken === gateState.gateToken,
651
+ );
652
+ if (transaction) artifacts.restoreMergeBaseline(transaction, audit);
653
+ }
654
+ const next = await stratum.gateResolve(
655
+ flowId, gateStepId, outcome, rationale, 'system', gateState.gateToken,
607
656
  );
657
+ artifacts.markGateResolved(transaction, outcome);
658
+ if (outcome !== 'revise') artifacts.cleanupWorktrees(`GSD merge gate ${outcome}`);
659
+ if (outcome === 'approve') ctx.filesChanged = collectChangedFiles(cwd);
660
+ return next;
608
661
  }
609
662
 
610
- throw new Error(`runGsd: unknown response status: ${response.status}`);
663
+ return response;
664
+ }
665
+
666
+ /**
667
+ * F6: raised by validateAndRepairTaskGraph on a files_owned conflict. A typed
668
+ * class lets the decompose_gsd site distinguish this expected, retryable rejection
669
+ * (→ failure step_done envelope, engine attempts govern) from a genuinely
670
+ * unexpected error (which still propagates).
671
+ */
672
+ export class TaskGraphOwnershipError extends Error {
673
+ constructor(conflict) {
674
+ super(`decompose_gsd file-ownership conflict: ${conflict}`);
675
+ this.name = 'TaskGraphOwnershipError';
676
+ this.conflict = conflict;
677
+ }
678
+ }
679
+
680
+ /**
681
+ * Raised by validateAndRepairTaskGraph when two tasks share an `id`. Task ids are
682
+ * the primary key for EVERY GSD id-keyed system: the blackboard, `results/<id>.json`,
683
+ * the milestone timing/diff instrumentation (keyed by gsdTaskId), and the stuck
684
+ * detector's per-task bookkeeping. A duplicate id silently cross-contaminates all of
685
+ * them (last-writer-wins results, inherited detector state), so reject it up front —
686
+ * a retryable malformed-decompose rejection, same channel as the ownership conflict.
687
+ */
688
+ export class TaskGraphDuplicateIdError extends Error {
689
+ constructor(id) {
690
+ super(`decompose_gsd duplicate task id: ${id} (task ids must be unique)`);
691
+ this.name = 'TaskGraphDuplicateIdError';
692
+ this.id = id;
693
+ }
611
694
  }
612
695
 
613
696
  export function validateAndRepairTaskGraph(taskGraph, blueprintText, gateCommands) {
697
+ // D5: deterministic file-ownership enforcement — reject overlapping
698
+ // files_owned before any repair. Two tasks owning the same file would race
699
+ // in their worktrees and collide at merge; fail loudly with the conflict.
700
+ const conflict = filesOwnedConflict(taskGraph?.tasks);
701
+ if (conflict) throw new TaskGraphOwnershipError(conflict);
702
+
703
+ // Unique-id enforcement — every id-keyed GSD system (blackboard, results,
704
+ // milestone instrumentation, stuck detector) assumes task ids are unique.
705
+ const seenIds = new Set();
706
+ for (const task of taskGraph?.tasks ?? []) {
707
+ if (task?.id == null) continue;
708
+ if (seenIds.has(task.id)) throw new TaskGraphDuplicateIdError(task.id);
709
+ seenIds.add(task.id);
710
+ }
711
+
614
712
  // Structural check via enrichTaskGraph. Throws on orphan slice/task —
615
713
  // that's a "fail loudly" case (no reliable repair path).
616
714
  const enriched = enrichTaskGraph(taskGraph, blueprintText);
@@ -1226,8 +1324,8 @@ function writeBudgetArtifacts(ctx, response, budgetState) {
1226
1324
  writeFileSync(join(dir, 'budget.md'), md);
1227
1325
 
1228
1326
  const pause = {
1229
- flowId: response.flow_id ?? null,
1230
- stepId: response.step_id ?? ctx.lastStepId ?? 'execute',
1327
+ flowId: response.runId ?? null,
1328
+ stepId: ctx.lastStepId ?? 'execute',
1231
1329
  kind: 'budget',
1232
1330
  budget: { axis: json.axis, caps: budgetState.caps ?? {}, consumed: budgetState.consumed ?? {} },
1233
1331
  decomposedTasks,
@@ -1263,15 +1361,71 @@ export function writeBudgetFinalSnapshot(ctx, budgetState) {
1263
1361
  * from the stratum budget_state.consumed ({tokens,dispatches,wall_s,dollars}).
1264
1362
  * No-op when budget_state is absent (un-budgeted runs).
1265
1363
  */
1266
- function recordGsdUsageFromState(cwd, featureCode, budgetState) {
1364
+ export function recordGsdUsageFromState(cwd, featureCode, budgetState, alreadyRecorded = null) {
1267
1365
  const consumed = budgetState?.consumed;
1268
1366
  if (!consumed) return;
1269
- recordGsdUsage(join(cwd, '.compose'), featureCode, {
1367
+ const engine = {
1270
1368
  tokens: consumed.tokens ?? 0,
1271
1369
  costUsd: consumed.dollars ?? 0,
1272
1370
  dispatches: consumed.dispatches ?? 0,
1273
1371
  timeMs: Math.round((consumed.wall_s ?? 0) * 1000),
1274
- });
1372
+ };
1373
+ // G4: the engine ledger is ground truth, but on the TS path it now INCLUDES the
1374
+ // per-invocation usage compose already recorded incrementally into the same
1375
+ // cumulative store (via recordTsAgentUsage, from the step_done envelopes). Fold
1376
+ // only the DELTA — the engine-only debits (e.g. judged predicates) not already
1377
+ // counted — so nothing is double-debited. `alreadyRecorded` is this run's
1378
+ // incremental total (ctx.recordedUsage); absent → full fold.
1379
+ const prior = alreadyRecorded ?? { tokens: 0, costUsd: 0, dispatches: 0, timeMs: 0 };
1380
+ const delta = {
1381
+ tokens: Math.max(0, engine.tokens - (prior.tokens ?? 0)),
1382
+ costUsd: Math.max(0, engine.costUsd - (prior.costUsd ?? 0)),
1383
+ dispatches: Math.max(0, engine.dispatches - (prior.dispatches ?? 0)),
1384
+ timeMs: Math.max(0, engine.timeMs - (prior.timeMs ?? 0)),
1385
+ };
1386
+ if (delta.tokens === 0 && delta.costUsd === 0 && delta.dispatches === 0 && delta.timeMs === 0) return;
1387
+ recordGsdUsage(join(cwd, '.compose'), featureCode, delta);
1388
+ }
1389
+
1390
+ /**
1391
+ * D7(a): the exact TaskResult path a gsd execute item must write, keyed by the
1392
+ * item's task id. Single-sources the filename with the blackboard reader
1393
+ * (collectBlackboard reads .compose/gsd/<feature>/results/<taskId>.json).
1394
+ */
1395
+ export function gsdTaskResultPath(featureCode, taskId) {
1396
+ return `.compose/gsd/${featureCode}/results/${taskId}.json`;
1397
+ }
1398
+
1399
+ // runAndNormalize narrates through a progress object (debug/warn/info/toolUse/
1400
+ // toolSummary/stepStart/stepDone). GSD runs headless with no cockpit, so it
1401
+ // hands the consumer loop a COMPLETE no-op — a partial stub throws mid-item.
1402
+ const NOOP_PROGRESS = Object.freeze({
1403
+ stepStart() {}, stepDone() {}, debug() {}, warn() {}, info() {},
1404
+ toolUse() {}, toolSummary() {},
1405
+ });
1406
+
1407
+ /**
1408
+ * D2(b): debit one TS-route agent invocation's usage into the cumulative gsd
1409
+ * ledger. On the TS path the engine's flow ledger never sees compose-dispatched
1410
+ * agents, so compose accounts each dispatch itself. `usage` is the normalized
1411
+ * shape from runAndNormalize ({input_tokens, output_tokens, cost_usd,
1412
+ * duration_ms}) or the raw TS complete usage ({tokens, usd, ms}).
1413
+ */
1414
+ export function recordTsAgentUsage(ctx, usage) {
1415
+ if (!usage || !ctx?.cwd || !ctx?.featureCode) return;
1416
+ const tokens = usage.tokens ?? ((usage.input_tokens ?? 0) + (usage.output_tokens ?? 0));
1417
+ const costUsd = usage.cost_usd ?? usage.usd ?? 0;
1418
+ const timeMs = usage.duration_ms ?? usage.ms ?? 0;
1419
+ if (tokens === 0 && costUsd === 0 && timeMs === 0) return;
1420
+ recordGsdUsage(join(ctx.cwd, '.compose'), ctx.featureCode, { tokens, costUsd, timeMs, dispatches: 1 });
1421
+ // G4: remember what THIS run recorded incrementally so the terminal/budget-halt
1422
+ // fold (recordGsdUsageFromState) records only the engine-only delta, never
1423
+ // double-counting these envelope-reported invocations.
1424
+ ctx.recordedUsage ??= { tokens: 0, costUsd: 0, dispatches: 0, timeMs: 0 };
1425
+ ctx.recordedUsage.tokens += tokens;
1426
+ ctx.recordedUsage.costUsd += costUsd;
1427
+ ctx.recordedUsage.timeMs += timeMs;
1428
+ ctx.recordedUsage.dispatches += 1;
1275
1429
  }
1276
1430
 
1277
1431
  /**