@smartmemory/compose 0.2.52-beta → 0.2.54-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 (148) hide show
  1. package/.claude/skills/compose/SKILL.md +0 -8
  2. package/README.md +39 -0
  3. package/bin/compose.js +378 -3
  4. package/dist/assets/App-D_tvTs_n.js +896 -0
  5. package/dist/assets/abnfDiagram-VRR7QNED-Dhb7O5rp.js +1 -0
  6. package/dist/assets/arc-C4oVdnk2.js +1 -0
  7. package/dist/assets/architectureDiagram-ZJ3FMSHR-DqHrb5_F.js +36 -0
  8. package/dist/assets/blockDiagram-677ZJIJ3-DUyBayYK.js +132 -0
  9. package/dist/assets/{browser-CnKiSnlr.js → browser-Cr0recrN.js} +6 -6
  10. package/dist/assets/{c4Diagram-AHTNJAMY-DtDSNlOX.js → c4Diagram-LMCZKHZV-CxlkWMRK.js} +1 -1
  11. package/dist/assets/channel-xb4mQfuL.js +1 -0
  12. package/dist/assets/{chunk-QZHKN3VN-BMWFLZ8t.js → chunk-2Q5K7J3B-C6QJsyOQ.js} +1 -1
  13. package/dist/assets/{chunk-YZCP3GAM-CqjNXooj.js → chunk-32BRIVSS-D9NgYu9W.js} +1 -1
  14. package/dist/assets/{chunk-FMBD7UC4-BAa7Edp6.js → chunk-5VM5RSS4-DbD86rhK.js} +1 -1
  15. package/dist/assets/chunk-EX3LRPZG-E4m4xNMw.js +231 -0
  16. package/dist/assets/{chunk-4BX2VUAB-oXU2npoL.js → chunk-JWPE2WC7-DHGlRWbw.js} +1 -1
  17. package/dist/assets/chunk-MOJQB5TN-BblYnFUq.js +88 -0
  18. package/dist/assets/chunk-RYQCIY6F-CGT-88vL.js +1 -0
  19. package/dist/assets/chunk-V7JOEXUC-BJJmX0mc.js +206 -0
  20. package/dist/assets/{chunk-EDXVE4YY-Bse2kdRh.js → chunk-VR4S4FIN-B4ygduCD.js} +1 -1
  21. package/dist/assets/{chunk-55IACEB6-BEEk2FdH.js → chunk-XXDRQBXY-7APXuI2s.js} +1 -1
  22. package/dist/assets/classDiagram-OUVF2IWQ-BIvgPTgh.js +1 -0
  23. package/dist/assets/classDiagram-v2-EOCWNBFH-BIvgPTgh.js +1 -0
  24. package/dist/assets/{cose-bilkent-S5V4N54A-NYnH3mVW.js → cose-bilkent-JH36ORCC-DnVcT6R6.js} +1 -1
  25. package/dist/assets/cynefin-VYW2F7L2-C9qiUSfX.js +178 -0
  26. package/dist/assets/cynefinDiagram-TSTJHNR4-BowkQhEu.js +62 -0
  27. package/dist/assets/dagre-VKFMJZFB-Y5V-7U3p.js +4 -0
  28. package/dist/assets/diagram-FQU43EPY-I7wDE-Xp.js +3 -0
  29. package/dist/assets/diagram-G47NLZAW-DapGa1HI.js +24 -0
  30. package/dist/assets/diagram-NH7WQ7WH-CHO1JntO.js +24 -0
  31. package/dist/assets/diagram-OA4YK3LP-mBWP2D1G.js +30 -0
  32. package/dist/assets/diagram-WEI45ONY-C8KN8YnF.js +41 -0
  33. package/dist/assets/ebnfDiagram-CCIWWBDH-ZkJG9NOL.js +1 -0
  34. package/dist/assets/erDiagram-Q63AITRT-DGnL3kW1.js +85 -0
  35. package/dist/assets/flowDiagram-23GEKE2U-DHYupfOu.js +156 -0
  36. package/dist/assets/ganttDiagram-NO4QXBWP-B1B2zpwR.js +292 -0
  37. package/dist/assets/gitGraphDiagram-IHSO6WYX-B9_OP4Fs.js +106 -0
  38. package/dist/assets/graph-C9eacEi8.js +1 -0
  39. package/dist/assets/graph-xkel59g2.js +331 -0
  40. package/dist/assets/index-0Hq9436Q.js +119 -0
  41. package/dist/assets/index-LIwREYgH.css +1 -0
  42. package/dist/assets/infoDiagram-FWYZ7A6U-D-a34TqE.js +2 -0
  43. package/dist/assets/{ishikawaDiagram-UXIWVN3A-CK2IFFAP.js → ishikawaDiagram-FXEZZL3T-h0bUvXGV.js} +5 -5
  44. package/dist/assets/{journeyDiagram-VCZTEJTY-DAH5Stkf.js → journeyDiagram-5HDEW3XC-B4lm0RBa.js} +1 -1
  45. package/dist/assets/{kanban-definition-6JOO6SKY-Bi0aCqkv.js → kanban-definition-HUTT4EX6-msvfouRX.js} +7 -7
  46. package/dist/assets/katex-C5jXJg4s.js +257 -0
  47. package/dist/assets/layout-DEXfKzaS.js +1 -0
  48. package/dist/assets/{linear-ClGEGlS2.js → linear-B0trv2xN.js} +1 -1
  49. package/dist/assets/map-Czzmt4hB.js +1 -0
  50. package/dist/assets/{mindmap-definition-QFDTVHPH-Wjr-PgC6.js → mindmap-definition-LN4V7U3C-CvswbRgd.js} +7 -7
  51. package/dist/assets/{mobile-Chw8RWyH.js → mobile-CNLMhdFP.js} +2 -2
  52. package/dist/assets/pegDiagram-2B236MQR-DZEmIYC7.js +1 -0
  53. package/dist/assets/pieDiagram-ENE6RG2P-1ZfqxHkL.js +39 -0
  54. package/dist/assets/quadrantDiagram-ABIIQ3AL-Owx49uiJ.js +7 -0
  55. package/dist/assets/railroadDiagram-RFXS5EU6-B7RiaIGf.js +1 -0
  56. package/dist/assets/{requirementDiagram-MS252O5E-CU9Rlyq5.js → requirementDiagram-TGXJPOKE-fqmHs_5o.js} +3 -3
  57. package/dist/assets/sankeyDiagram-HTMAVEWB-D0Q3FeKV.js +40 -0
  58. package/dist/assets/sequenceDiagram-DBY2YBRQ-CnPuB3fy.js +162 -0
  59. package/dist/assets/sizeCapture-X5ZJPWSS-CsHoR37V.js +1 -0
  60. package/dist/assets/stateDiagram-2N3HPSRC-GVwpflU5.js +1 -0
  61. package/dist/assets/stateDiagram-v2-6OUMAXLB-DCGIHFu9.js +1 -0
  62. package/dist/assets/swimlanes-5IMT3BWC-DRR41dsQ.js +2 -0
  63. package/dist/assets/swimlanesDiagram-G3AALYLV-C4RhLkZo.js +8 -0
  64. package/dist/assets/{timeline-definition-GMOUNBTQ-C8SZmaLp.js → timeline-definition-FHXFAJF6-ePaEemL7.js} +3 -3
  65. package/dist/assets/vennDiagram-L72KCM5P-BAYP8dNF.js +34 -0
  66. package/dist/assets/wardleyDiagram-EHGQE667-jNVH5Adm.js +78 -0
  67. package/dist/assets/xychartDiagram-FW5EYKEG-BYDWGXbZ.js +7 -0
  68. package/dist/index.html +3 -3
  69. package/lib/agent-string.js +34 -0
  70. package/lib/build.js +542 -148
  71. package/lib/experiment-judge.js +145 -0
  72. package/lib/experiment-metrics.js +265 -0
  73. package/lib/experiment-pricing.js +60 -0
  74. package/lib/experiment-report.js +306 -0
  75. package/lib/experiment-sandbox.js +147 -0
  76. package/lib/experiment.js +539 -0
  77. package/lib/feature-events.js +10 -0
  78. package/lib/feature-json.js +1 -1
  79. package/lib/feature-writer.js +30 -0
  80. package/lib/flow-state.js +36 -0
  81. package/lib/gate-prompt.js +66 -6
  82. package/lib/lifecycle-modes.js +213 -0
  83. package/lib/new.js +6 -2
  84. package/lib/roadmap-graph/index.js +26 -28
  85. package/lib/roadmap-graph/vision-adapter.js +126 -0
  86. package/lib/smartmemory-client.js +139 -0
  87. package/lib/smartmemory-config.js +50 -0
  88. package/lib/smartmemory-ingest.js +115 -0
  89. package/lib/smartmemory-sync.js +275 -0
  90. package/lib/stratum-mcp-client.js +9 -0
  91. package/lib/triage.js +7 -1
  92. package/lib/vision-writer.js +30 -6
  93. package/package.json +1 -1
  94. package/pipelines/plan.stratum.yaml +161 -0
  95. package/server/artifact-manager.js +30 -3
  96. package/server/compose-mcp-tools.js +11 -9
  97. package/server/compose-mcp.js +4 -0
  98. package/server/feature-scan.js +87 -173
  99. package/server/gate-log-store.js +11 -1
  100. package/server/graph-export.js +0 -0
  101. package/server/index.js +3 -5
  102. package/server/lifecycle-guard.js +62 -25
  103. package/server/roadmap-graph-vision.js +138 -0
  104. package/server/smartmemory-routes.js +113 -0
  105. package/server/status-snapshot.js +8 -7
  106. package/server/vision-routes.js +64 -33
  107. package/server/vision-server.js +6 -0
  108. package/server/vision-store.js +13 -4
  109. package/.claude/skills/compose/references/hermes-tools.md +0 -80
  110. package/dist/assets/App-Ddhfx18X.js +0 -889
  111. package/dist/assets/_baseUniq-CoEVVYyC.js +0 -1
  112. package/dist/assets/arc-DFnGxuCB.js +0 -1
  113. package/dist/assets/architectureDiagram-Q4EWVU46-66rx01Tn.js +0 -36
  114. package/dist/assets/blockDiagram-DXYQGD6D-AE5PKSak.js +0 -132
  115. package/dist/assets/channel-CScyWprq.js +0 -1
  116. package/dist/assets/chunk-4TB4RGXK-5w2lZAjW.js +0 -206
  117. package/dist/assets/chunk-OYMX7WX6-CGr_YiAc.js +0 -231
  118. package/dist/assets/classDiagram-6PBFFD2Q-DibVYYdr.js +0 -1
  119. package/dist/assets/classDiagram-v2-HSJHXN6E-DibVYYdr.js +0 -1
  120. package/dist/assets/clone-ZMALIFmw.js +0 -1
  121. package/dist/assets/dagre-KV5264BT--mrfaTHR.js +0 -4
  122. package/dist/assets/diagram-5BDNPKRD-DjXvyFzh.js +0 -10
  123. package/dist/assets/diagram-G4DWMVQ6-BrrexEvm.js +0 -24
  124. package/dist/assets/diagram-MMDJMWI5-D3Q0OT2M.js +0 -43
  125. package/dist/assets/diagram-TYMM5635-DKTntmxE.js +0 -24
  126. package/dist/assets/erDiagram-SMLLAGMA-BWbR0dPj.js +0 -85
  127. package/dist/assets/flowDiagram-DWJPFMVM-Dj5M5XaY.js +0 -162
  128. package/dist/assets/ganttDiagram-T4ZO3ILL-DKppFh_5.js +0 -292
  129. package/dist/assets/gitGraphDiagram-UUTBAWPF-9xQ9O25V.js +0 -106
  130. package/dist/assets/graph-Cgmhvu1T.js +0 -1
  131. package/dist/assets/graph-DZe55uk8.js +0 -331
  132. package/dist/assets/index-BxRamj_i.css +0 -1
  133. package/dist/assets/index-D8uDfn8y.js +0 -123
  134. package/dist/assets/infoDiagram-42DDH7IO-DQYWVzVN.js +0 -2
  135. package/dist/assets/katex-DkKDou_j.js +0 -257
  136. package/dist/assets/layout-DN13RkA1.js +0 -1
  137. package/dist/assets/min-CXr-fUFS.js +0 -1
  138. package/dist/assets/pieDiagram-DEJITSTG-CVipbIlr.js +0 -30
  139. package/dist/assets/quadrantDiagram-34T5L4WZ-CHJFyhm-.js +0 -7
  140. package/dist/assets/sankeyDiagram-XADWPNL6-BSULeqST.js +0 -10
  141. package/dist/assets/sequenceDiagram-FGHM5R23-CCOKXvjj.js +0 -157
  142. package/dist/assets/stateDiagram-FHFEXIEX-B91gKA7Q.js +0 -1
  143. package/dist/assets/stateDiagram-v2-QKLJ7IA2-Dc8ur9ta.js +0 -1
  144. package/dist/assets/vennDiagram-DHZGUBPP-YvEPDlsM.js +0 -34
  145. package/dist/assets/wardley-RL74JXVD-TMBUq_w1.js +0 -162
  146. package/dist/assets/wardleyDiagram-NUSXRM2D-DzEkPrri.js +0 -20
  147. package/dist/assets/xychartDiagram-5P7HB3ND-BIiCeiYk.js +0 -7
  148. package/lib/roadmap-graph/collect.js +0 -178
package/lib/build.js CHANGED
@@ -22,13 +22,14 @@ import { preflightCodexWorktreeProbe, codexProbeAbortMessage } from './codex-pre
22
22
  import { buildStepPrompt, buildRetryPrompt, buildGateContext, clearAmbientContextCache, formatBounceForPrompt } from './step-prompt.js';
23
23
  import { promptGate } from './gate-prompt.js';
24
24
  import { VisionWriter, ServerUnreachableError } from './vision-writer.js';
25
+ import { readFlowRound } from './flow-state.js';
25
26
  import { resolvePort } from './resolve-port.js';
26
27
  import { probeServer } from './server-probe.js';
27
28
  import { CliProgress } from './cli-progress.js';
28
29
  import { BuildStreamWriter } from './build-stream-writer.js';
29
30
  import { appendBuildHistory, projectHistorySteps, stepOutcomeToStatus } from './build-history.js';
30
31
  import { KNOWN_VERSIONS } from './build-stream-schema.js';
31
- import { resolveAgentConfig } from './agent-string.js';
32
+ import { resolveAgentConfig, parseAgentString } from './agent-string.js';
32
33
  import { installFactoryShim } from './connector-factory-shim.js';
33
34
  import { emitSections as emitPlanSections, appendTrailers as appendSectionTrailers, analyzeRollup, writeRollup } from './sections.js';
34
35
  import { SECTIONS_DIR } from './constants.js';
@@ -37,6 +38,7 @@ import { rtkPrefix } from './rtk.js';
37
38
  import YAML from 'yaml';
38
39
  // feature-json direct imports removed — mutations now go through TrackerProvider (T9)
39
40
  import { loadFeaturesDir, resolveContextPath, resolveRoadmapPath, resolveFeaturesPath } from './project-paths.js';
41
+ import { getMode } from './lifecycle-modes.js';
40
42
  import { vocabularyEnabled, injectVocabularyEnsure, tagVocabularyViolations } from './vocabulary-inject.js';
41
43
 
42
44
  // Lazy provider accessor — avoids circular import risk (factory → local-provider
@@ -114,6 +116,58 @@ export function parseRetriesCap(specYaml) {
114
116
  return cap;
115
117
  }
116
118
 
119
+ // ---------------------------------------------------------------------------
120
+ // COMP-ROADMAP-PLAN S8: gate the `ship` interception by mode.
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /**
124
+ * The `ship` step interception runs executeShipStep (git stage/commit/audit),
125
+ * which is build/fix-specific. It must run for build AND bug (bug-fix depends on
126
+ * it) but NOT for plan — plan's `ship` is a handoff/verify agent step.
127
+ *
128
+ * Gate on `mode !== 'plan'`, NOT on cfg.tracksFeatureJson: fix mode is
129
+ * tracksFeatureJson:false yet still needs the ship path (COMP-ROADMAP-PLAN C12).
130
+ *
131
+ * @param {string} stepId — the current pipeline step id
132
+ * @param {string} mode — runtime mode token (feature | bug | plan)
133
+ * @returns {boolean} true when the ship interception should run
134
+ */
135
+ export function shouldInterceptShip(stepId, mode) {
136
+ return stepId === 'ship' && mode !== 'plan';
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // COMP-ROADMAP-PLAN S5: ratify a plan-authored design instead of clobbering it.
141
+ // ---------------------------------------------------------------------------
142
+
143
+ /**
144
+ * When a feature was authored by the `plan` lifecycle (feature.json.plannedBy is
145
+ * set), rewrite the build pipeline's `explore_design` step so it RATIFIES the
146
+ * existing plan-approved design.md rather than writing one from scratch (which
147
+ * would clobber the plan output). Mutates `specObj` in place; returns true if it
148
+ * rewrote a step. Pure and testable — no I/O.
149
+ *
150
+ * @param {object} specObj — parsed Stratum spec
151
+ * @param {string} flowName — the flow Stratum will run (from extractFlowName)
152
+ * @param {string|null} plannedBy — the originating plan session code, or null
153
+ * @returns {boolean} true when the explore_design intent was rewritten
154
+ */
155
+ export function applyPlannedByRatify(specObj, flowName, plannedBy) {
156
+ if (!plannedBy) return false;
157
+ const flows = specObj?.flows ?? {};
158
+ const flowKey = Object.keys(flows).includes(flowName) ? flowName : Object.keys(flows)[0];
159
+ const steps = flows?.[flowKey]?.steps ?? [];
160
+ const step = steps.find((s) => s && s.id === 'explore_design');
161
+ if (!step) return false;
162
+ step.intent =
163
+ `A plan-approved design already exists at docs/features/{featureCode}/design.md ` +
164
+ `(authored by plan session ${plannedBy}). READ it fully FIRST, then RATIFY it: ` +
165
+ `refine only if something is missing, wrong, or unimplementable; otherwise keep it ` +
166
+ `as-is. Do NOT rewrite the design from scratch and do NOT discard the plan's intent. ` +
167
+ `Return the design path (docs/features/{featureCode}/design.md) in the "artifact" field.`;
168
+ return true;
169
+ }
170
+
117
171
  // ---------------------------------------------------------------------------
118
172
  // COMP-FIX-HARD T6: hypothesis ledger append on diagnose success.
119
173
  // ---------------------------------------------------------------------------
@@ -421,6 +475,70 @@ function writeActiveBuild(dataDir, state) {
421
475
  renameSync(tmp, target);
422
476
  }
423
477
 
478
+ /**
479
+ * Decide how a compose build invocation should start.
480
+ *
481
+ * Pure decision table for COMP-BUILD-RESUME. The caller is responsible for all
482
+ * I/O inputs: active-build state, pid liveness, flow terminality, and mode match.
483
+ *
484
+ * @param {object} params
485
+ * @param {object|null} params.active
486
+ * @param {object} params.opts
487
+ * @param {boolean} params.pidAlive
488
+ * @param {boolean} params.flowTerminal
489
+ * @param {boolean} params.sameMode
490
+ * @returns {{ action: 'resume'|'fresh'|'refuse'|'error', flowId?: string, reason: string }}
491
+ */
492
+ export function decideBuildStart({ active, opts = {}, pidAlive = false, flowTerminal = false, sameMode = true } = {}) {
493
+ const wantsResume = Boolean(opts.resume || opts.resumeFlowId);
494
+ const wantsFresh = Boolean(opts.fresh);
495
+ const flowId = opts.resumeFlowId ?? active?.flowId;
496
+
497
+ if (wantsResume && wantsFresh) {
498
+ return { action: 'error', reason: '--resume and --fresh are mutually exclusive' };
499
+ }
500
+
501
+ // COMP-BUILD-RESUME: a BARE programmatic flow id (resumeFlowId without the
502
+ // `--resume` flag) is a self-sufficient resume target — the fix pipeline and
503
+ // crash recovery pass it directly. It does NOT depend on active-build.json:
504
+ // the caller resumes that exact flow and handles a terminal flow post-resume.
505
+ // The `--resume` *flag* (opts.resume) is the guard-subject path below: it
506
+ // discovers the flow from active-build state and errors if nothing is
507
+ // resumable. When BOTH are set (flag + explicit id) the flag wins and the
508
+ // guards apply, with resumeFlowId only supplying the id. So short-circuit
509
+ // here (after the mutual-exclusion guard) ONLY for the bare-id case.
510
+ if (opts.resumeFlowId && !opts.resume) {
511
+ return { action: 'resume', flowId: opts.resumeFlowId, reason: 'Resuming specified flow' };
512
+ }
513
+
514
+ if (!active || !flowId || flowTerminal) {
515
+ if (wantsResume) {
516
+ return { action: 'error', reason: 'Nothing to resume (no in-progress or failed build found)' };
517
+ }
518
+ return { action: 'fresh', reason: 'No resumable build found' };
519
+ }
520
+
521
+ if (!sameMode) {
522
+ if (wantsResume) {
523
+ return { action: 'error', reason: 'Nothing to resume for this mode (active build mode differs)' };
524
+ }
525
+ return { action: 'fresh', reason: 'Previous build mode differs' };
526
+ }
527
+
528
+ if (active.status === 'running' && pidAlive) {
529
+ return {
530
+ action: 'refuse',
531
+ reason: `Build already running${active.pid ? ` (pid ${active.pid})` : ''}. Use 'compose build --abort' to cancel it.`,
532
+ };
533
+ }
534
+
535
+ if (wantsFresh) {
536
+ return { action: 'fresh', reason: 'Fresh build requested' };
537
+ }
538
+
539
+ return { action: 'resume', flowId, reason: 'Resuming previous build' };
540
+ }
541
+
424
542
  /**
425
543
  * COMP-MOBILE-1-1: persist a COMP-HEALTH gate downgrade back to
426
544
  * active-build.json. The terminal write happens BEFORE the health gate runs,
@@ -524,8 +642,12 @@ function isProcessAlive(pid) {
524
642
  try {
525
643
  process.kill(pid, 0); // signal 0 = existence check, no actual signal
526
644
  return true;
527
- } catch {
528
- return false;
645
+ } catch (err) {
646
+ // EPERM => the process exists but belongs to a different uid (e.g. a prior
647
+ // run under sudo) — it is alive. Returning false here let the concurrent-
648
+ // build guard treat a live process as dead and stomp active-build.json.
649
+ // Matches the authoritative pidAlive() in gsd-state.js.
650
+ return err.code === 'EPERM';
529
651
  }
530
652
  }
531
653
 
@@ -576,6 +698,13 @@ function isTerminalFlow(status) {
576
698
  return status === 'complete' || status === 'killed';
577
699
  }
578
700
 
701
+ function isRecoverableFlowProbeError(err) {
702
+ return err?.code === 'flow_not_found'
703
+ || err?.code === 'STRATUM_ERROR'
704
+ || err?.message?.includes('No active flow')
705
+ || err?.message?.includes('flow_not_found');
706
+ }
707
+
579
708
  // ---------------------------------------------------------------------------
580
709
  // Template resolution
581
710
  // ---------------------------------------------------------------------------
@@ -605,6 +734,101 @@ export function resolveTemplatePath(name, cwd) {
605
734
  // Main entry point
606
735
  // ---------------------------------------------------------------------------
607
736
 
737
+ function buildFailureReason({ buildStatus = 'failed', stepHistory = [], healthDowngradeReason = null, fallback = null } = {}) {
738
+ if (fallback) return fallback;
739
+ if (buildStatus === 'complete') return null;
740
+ const lastFailedStep = [...stepHistory].reverse().find(s => s.outcome === 'failed');
741
+ return lastFailedStep?.summary ?? healthDowngradeReason ?? `Build ${buildStatus}`;
742
+ }
743
+
744
+ async function writeFailedBuildTerminalState({
745
+ cwd,
746
+ dataDir,
747
+ cfg,
748
+ visionWriter,
749
+ itemId,
750
+ featureCode,
751
+ flowId = null,
752
+ failureReason,
753
+ }) {
754
+ const termState = readActiveBuild(dataDir);
755
+ if (termState) {
756
+ const sameFlow = !flowId || !termState.flowId || termState.flowId === flowId;
757
+ const sameFeature = !termState.featureCode || termState.featureCode === featureCode;
758
+ if (sameFlow && sameFeature) {
759
+ writeActiveBuild(dataDir, {
760
+ ...termState,
761
+ status: 'failed',
762
+ failureReason: termState.failureReason ?? failureReason,
763
+ completedAt: termState.completedAt ?? new Date().toISOString(),
764
+ });
765
+ }
766
+ }
767
+ if (cfg.tracksFeatureJson) {
768
+ const _bp = await getBuildProvider(cwd);
769
+ const _feat = await _bp.getFeature(featureCode);
770
+ if (_feat) {
771
+ // Raw write back to PLANNED — no transition policy, no events, no renderRoadmap.
772
+ // Matches original updateFeature semantics; keeps teardown side-effect-free.
773
+ await _bp.persistFeatureRaw(featureCode, { ..._feat, status: 'PLANNED' });
774
+ }
775
+ }
776
+ try {
777
+ await visionWriter.updateItemStatus(itemId, 'blocked');
778
+ } catch {
779
+ // Best-effort UI projection only; durable build/feature state is already written.
780
+ }
781
+ }
782
+
783
+ async function terminalizeThrownBuild({
784
+ cwd,
785
+ dataDir,
786
+ cfg,
787
+ visionWriter,
788
+ itemId,
789
+ featureCode,
790
+ mode,
791
+ response,
792
+ buildStartedAt,
793
+ buildCostTotals,
794
+ stepHistory,
795
+ failureReason,
796
+ historyWritten,
797
+ }) {
798
+ const flowId = response?.flow_id ?? null;
799
+ if (!flowId) return false;
800
+ await writeFailedBuildTerminalState({
801
+ cwd,
802
+ dataDir,
803
+ cfg,
804
+ visionWriter,
805
+ itemId,
806
+ featureCode,
807
+ flowId,
808
+ failureReason,
809
+ });
810
+ if (!historyWritten.value) {
811
+ appendBuildHistory(dataDir, {
812
+ featureCode,
813
+ flowId,
814
+ mode,
815
+ status: 'failed',
816
+ startedAt: buildStartedAt,
817
+ completedAt: new Date().toISOString(),
818
+ durationMs: Date.now() - new Date(buildStartedAt).getTime(),
819
+ cost_usd: buildCostTotals.cost_usd,
820
+ input_tokens: buildCostTotals.input_tokens,
821
+ output_tokens: buildCostTotals.output_tokens,
822
+ stepCount: stepHistory.length,
823
+ failureReason,
824
+ itemId,
825
+ steps: projectHistorySteps(stepHistory),
826
+ });
827
+ historyWritten.value = true;
828
+ }
829
+ return true;
830
+ }
831
+
608
832
  /**
609
833
  * Run a feature through the Stratum lifecycle.
610
834
  *
@@ -631,8 +855,14 @@ export async function runBuild(featureCode, opts = {}) {
631
855
  // feature-json updates, plan with {featureCode, description}.
632
856
  // mode === 'bug': docs/bugs/<code>/, no feature-json updates,
633
857
  // plan with {task: description}.
634
- const mode = opts.mode === 'bug' ? 'bug' : 'feature';
858
+ // COMP-ROADMAP-MODES: 3-valued runner mode. The runtime token stays
859
+ // feature|bug|plan (byte-identical persistence in active-build.json and the
860
+ // resume guard); `cfg` is the registry's per-mode behavioral switches
861
+ // (getMode normalizes feature→build, bug→fix, plan→plan). isBugMode is kept as
862
+ // a derived flag so the bug-SPECIFIC positive checks downstream are untouched.
863
+ const mode = opts.mode === 'bug' ? 'bug' : (opts.mode === 'plan' ? 'plan' : 'feature');
635
864
  const isBugMode = mode === 'bug';
865
+ const cfg = getMode(mode).runner;
636
866
 
637
867
  // Resolve project paths
638
868
  const composeDir = join(cwd, '.compose');
@@ -658,9 +888,13 @@ export async function runBuild(featureCode, opts = {}) {
658
888
  // - `resolveItemDir` resolves the actual FILESYSTEM dir via
659
889
  // resolveFeaturesPath (absolute — handles absolute/../-escaping config).
660
890
  const featuresDir = loadFeaturesDir(cwd);
661
- const resolveItemDir = (code) => isBugMode
662
- ? join(cwd, 'docs', 'bugs', code)
663
- : join(resolveFeaturesPath(cwd), code);
891
+ // The artifact dir is driven by the mode's `artifactRoot` token: 'features'
892
+ // resolves the (absolute, override-aware) features path; any other token is a
893
+ // literal repo-relative dir (bug → docs/bugs, plan → docs/plans). Byte-identical
894
+ // to the prior feature/bug ternary for those two modes.
895
+ const resolveItemDir = (code) => cfg.artifactRoot === 'features'
896
+ ? join(resolveFeaturesPath(cwd), code)
897
+ : join(cwd, ...cfg.artifactRoot.split('/'), code);
664
898
 
665
899
  // COMP-MCP-MIGRATION-1: per-build correlation ID stamped onto every audit
666
900
  // row written during this run, so `executeShipStep`'s pre-stage scan can
@@ -734,9 +968,9 @@ export async function runBuild(featureCode, opts = {}) {
734
968
  // ---------------------------------------------------------------------------
735
969
  let buildProfile = null;
736
970
  let _buildTierLabel = '?'; // for skip_reason label in spec YAML mutation below
737
- // Bug mode skips pre-build triage entirely — triage is feature-shaped
738
- // (writes feature.json, profile selection per feature complexity tiers).
739
- if (!isBugMode && !opts.skipTriage && !opts.template) {
971
+ // Only modes that run feature triage do so — triage is feature-shaped (writes
972
+ // feature.json, profile selection per complexity tiers). bug AND plan skip it.
973
+ if (cfg.runsTriage && !opts.skipTriage && !opts.template) {
740
974
  const _buildProvider = await getBuildProvider(cwd);
741
975
  let cachedFeature = await _buildProvider.getFeature(featureCode);
742
976
  if (cachedFeature?.profile && !isTriageStale(cwd, featureCode, featuresDir)) {
@@ -777,9 +1011,11 @@ export async function runBuild(featureCode, opts = {}) {
777
1011
  }
778
1012
  }
779
1013
 
780
- // Load lifecycle spec (template selection)
781
- const templateName = opts.template ?? 'build';
782
- const specPath = resolveTemplatePath(opts.template, cwd);
1014
+ // Load lifecycle spec (template selection). The mode's defaultTemplate is the
1015
+ // fallback when no explicit --template is given (build → 'build', byte-identical
1016
+ // since resolveTemplatePath also defaults undefined→'build'; plan → 'new').
1017
+ const templateName = opts.template ?? cfg.defaultTemplate;
1018
+ const specPath = resolveTemplatePath(templateName, cwd);
783
1019
  if (!existsSync(specPath)) {
784
1020
  throw new Error(`Lifecycle spec not found: ${specPath}`);
785
1021
  }
@@ -795,7 +1031,18 @@ export async function runBuild(featureCode, opts = {}) {
795
1031
  // so in-memory edits here don't trip verifyPipelineIntegrity (same pattern the
796
1032
  // triage profile already relied on).
797
1033
  const vocabOn = vocabularyEnabled(cwd, composeConfig);
798
- if (buildProfile || vocabOn) {
1034
+ // COMP-ROADMAP-PLAN S5: a plan-authored feature (feature.json.plannedBy) makes
1035
+ // explore_design ratify the existing design instead of rewriting it. Read it
1036
+ // independently of the triage block (whose provider is block-scoped) so the
1037
+ // ratify fires even on --skip-triage / explicit-template builds.
1038
+ let plannedBy = null;
1039
+ if (mode === 'feature') {
1040
+ try {
1041
+ const _ratifyProvider = await getBuildProvider(cwd);
1042
+ plannedBy = (await _ratifyProvider.getFeature(featureCode))?.plannedBy ?? null;
1043
+ } catch { /* best-effort — no ratify if unreadable */ }
1044
+ }
1045
+ if (buildProfile || vocabOn || plannedBy) {
799
1046
  try {
800
1047
  const specObj = YAML.parse(specYaml);
801
1048
  if (buildProfile) {
@@ -821,6 +1068,7 @@ export async function runBuild(featureCode, opts = {}) {
821
1068
  // STRAT-VOCAB-3: append vocabulary_compliance to the EXECUTED flow's review
822
1069
  // step (extractFlowName resolves the same flow Stratum will run).
823
1070
  if (vocabOn) injectVocabularyEnsure(specObj, extractFlowName(specYaml, templateName));
1071
+ if (plannedBy) applyPlannedByRatify(specObj, extractFlowName(specYaml, templateName), plannedBy);
824
1072
  specYaml = YAML.stringify(specObj);
825
1073
  } catch (err) {
826
1074
  // Non-fatal — fall back to unmodified spec
@@ -832,14 +1080,15 @@ export async function runBuild(featureCode, opts = {}) {
832
1080
  // Stratum doesn't enforce `retries`; Compose force-terminates when iterN exceeds the cap.
833
1081
  const retriesCap = parseRetriesCap(specYaml);
834
1082
 
835
- // Build description from feature/bug folder
836
- const description = opts.description ?? (isBugMode
1083
+ // Build description from the mode's folder. The bug loader reads docs/bugs;
1084
+ // every other mode uses the feature loader (byte-identical for feature/bug).
1085
+ const description = opts.description ?? (cfg.descriptionLoader === 'bug'
837
1086
  ? loadBugDescription(featureDir, featureCode)
838
1087
  : loadFeatureDescription(featureDir, featureCode));
839
1088
 
840
1089
  // Vision writer — thread mode so a UI-created bug item binds as type:bug
841
1090
  // (and a brand-new fallback item is created with the right type) (#31).
842
- const visionWriter = new VisionWriter(dataDir);
1091
+ const visionWriter = opts.visionWriter ?? new VisionWriter(dataDir);
843
1092
  const itemId = await visionWriter.ensureFeatureItem(featureCode, featureCode, mode);
844
1093
 
845
1094
  // Load policy settings (lazy from disk — works for all callers)
@@ -877,9 +1126,9 @@ export async function runBuild(featureCode, opts = {}) {
877
1126
  installFactoryShim(stratum, opts.connectorFactory, agentCwd);
878
1127
  }
879
1128
 
880
- // Update feature.json status to IN_PROGRESS (feature mode only;
881
- // bug mode does not use feature.json).
882
- if (!isBugMode) {
1129
+ // Update feature.json status to IN_PROGRESS (only modes that track
1130
+ // feature.json lifecycle status; bug AND plan do not).
1131
+ if (cfg.tracksFeatureJson) {
883
1132
  const _bp = await getBuildProvider(cwd);
884
1133
  // Guard: feature.json may not exist if triage was skipped AND no prior
885
1134
  // createFeature ran (e.g. test harnesses that only create the folder).
@@ -896,9 +1145,16 @@ export async function runBuild(featureCode, opts = {}) {
896
1145
  let streamWriter = null;
897
1146
  let buildStatus = 'complete';
898
1147
  let signalHandler = null;
1148
+ let response;
1149
+ let stepHistory = [];
1150
+ const terminalHistoryWritten = { value: false };
899
1151
  // COMP-OBS-COST: Accumulate token/cost totals across all steps (hoisted for finally-block)
900
1152
  // On resume, seed from active-build.json to preserve pre-resume cost totals
901
1153
  const buildCostTotals = { input_tokens: 0, output_tokens: 0, cost_usd: 0 };
1154
+ // COMP-MODEL-AB: capture structured test counts from the ship step so they can
1155
+ // be persisted to build-history.jsonl for the metrics consumer (experiment-metrics.js).
1156
+ // Null when ship didn't run (failed/killed builds) or testSummary was unparsed.
1157
+ let shipStepTestData = null;
902
1158
 
903
1159
  // COMP-OBS-GATES: accumulate tier pass/fail results for this build.
904
1160
  // Keys are tier IDs (T0–T4), values are true (passed), false (failed), or null (not yet run).
@@ -923,7 +1179,6 @@ export async function runBuild(featureCode, opts = {}) {
923
1179
  try {
924
1180
  // Check for active build (resume)
925
1181
  const active = readActiveBuild(dataDir);
926
- let response;
927
1182
  let isFreshStart = true;
928
1183
 
929
1184
  // COMP-CODEX-IMPL: implementer/reviewer roles. A FRESH start derives them from
@@ -939,6 +1194,23 @@ export async function runBuild(featureCode, opts = {}) {
939
1194
  // (Codex impl-review finding #1).
940
1195
  let implementerAgent = opts.codex ? 'codex' : 'claude';
941
1196
  let reviewerAgent = opts.codex ? 'claude' : 'codex';
1197
+ // COMP-MODEL-AB: explicit --implementer/--reviewer override --codex-derived defaults.
1198
+ // Validated in bin/compose.js before reaching here; validate again for programmatic
1199
+ // callers that bypass the CLI (unknown provider = hard error).
1200
+ if (opts.implementer != null) {
1201
+ const { provider } = parseAgentString(opts.implementer);
1202
+ if (!['claude', 'codex'].includes(provider)) {
1203
+ throw new Error(`Invalid implementer agent string "${opts.implementer}": unknown provider "${provider}"`);
1204
+ }
1205
+ implementerAgent = opts.implementer;
1206
+ }
1207
+ if (opts.reviewer != null) {
1208
+ const { provider } = parseAgentString(opts.reviewer);
1209
+ if (!['claude', 'codex'].includes(provider)) {
1210
+ throw new Error(`Invalid reviewer agent string "${opts.reviewer}": unknown provider "${provider}"`);
1211
+ }
1212
+ reviewerAgent = opts.reviewer;
1213
+ }
942
1214
  const roles = { implementerAgent, reviewerAgent };
943
1215
  // Restore persisted roles when (and only when) a resume actually happens.
944
1216
  const restoreRolesFromActive = (src) => {
@@ -953,104 +1225,91 @@ export async function runBuild(featureCode, opts = {}) {
953
1225
  reviewerAgent = src.reviewerAgent ?? reviewerAgent;
954
1226
  };
955
1227
 
956
- // COMP-FIX-HARD T8: explicit `--resume` flag (compose fix <code> --resume).
957
- // When opts.resumeFlowId is set, skip stratum.plan entirely and resume the
958
- // given flow. CLI validates the flowId belongs to this code before calling.
959
- if (opts.resumeFlowId) {
960
- // Re-read active state to verify ownership before clobbering — prevents
961
- // two concurrent `compose fix --resume` invocations from racing on
962
- // active-build.json. If another live process owns it, refuse to resume.
963
- const activeNow = readActiveBuild(dataDir);
964
- if (activeNow && activeNow.pid && activeNow.pid !== process.pid && isProcessAlive(activeNow.pid)) {
965
- throw new Error(
966
- `Cannot --resume: another live process (pid ${activeNow.pid}) owns the build for ${featureCode}.`
967
- );
968
- }
969
- // Verify the active build matches the mode the caller asserts. Without
970
- // this check, `compose fix CODE --resume` against a feature build with
971
- // the same code would silently resume a feature flow as a bug flow.
972
- if (activeNow && activeNow.mode && activeNow.mode !== mode) {
973
- throw new Error(
974
- `Cannot --resume: active build is in ${activeNow.mode} mode, but caller invoked ${mode} mode.`
975
- );
1228
+ const activeForDecision = active && active.featureCode === featureCode ? active : null;
1229
+ const pidAlive = Boolean(
1230
+ activeForDecision?.status === 'running'
1231
+ && activeForDecision.pid
1232
+ && activeForDecision.pid !== process.pid
1233
+ && isProcessAlive(activeForDecision.pid)
1234
+ );
1235
+ const sameMode = !activeForDecision?.mode || activeForDecision.mode === mode;
1236
+ let flowTerminal = !activeForDecision?.flowId;
1237
+ if (activeForDecision?.flowId && ['complete', 'aborted', 'killed'].includes(activeForDecision.status)) {
1238
+ flowTerminal = true;
1239
+ } else if (activeForDecision?.flowId && !pidAlive) {
1240
+ try {
1241
+ const audit = await stratum.audit(activeForDecision.flowId);
1242
+ flowTerminal = isTerminalFlow(audit?.status);
1243
+ } catch (err) {
1244
+ if (isRecoverableFlowProbeError(err)) {
1245
+ flowTerminal = true;
1246
+ } else {
1247
+ throw err;
1248
+ }
976
1249
  }
977
- console.log(`Resuming flow ${opts.resumeFlowId} for ${featureCode}...`);
978
- response = await stratum.resume(opts.resumeFlowId);
979
- isFreshStart = false;
980
- // COMP-CODEX-IMPL: this is a real resume — restore roles from persisted state
981
- // (the refresh-write below then persists the restored roles, not flag-derived).
982
- restoreRolesFromActive(activeNow);
983
- // Refresh active-build.json so streaming/UI sees this as the live build.
984
- const flowName = extractFlowName(specYaml, templateName);
985
- writeActiveBuild(dataDir, {
986
- featureCode,
987
- flowId: response.flow_id ?? opts.resumeFlowId,
988
- pipeline: flowName,
989
- mode,
990
- pid: process.pid,
991
- currentStepId: response.step_id,
992
- specPath: `pipelines/${templateName}.stratum.yaml`,
993
- stepNum: response.step_number ?? 1,
994
- totalSteps: response.total_steps ?? null,
995
- retries: 0,
996
- violations: [],
997
- status: 'running',
998
- resumedAt: new Date().toISOString(),
999
- // COMP-CODEX-IMPL: carry the (restored or default) roles forward on resume-refresh.
1000
- implementerAgent,
1001
- reviewerAgent,
1002
- });
1003
- } else if (active && active.featureCode === featureCode && active.flowId) {
1004
- // Same feature — try to resume or start fresh
1005
- // Refuse implicit resume across modes: a stale bug-mode active-build
1006
- // with the same code as a feature build (or vice versa) would otherwise
1007
- // resume the wrong flow shape. Only blocks when active.mode is set
1008
- // (legacy active-build.json files predate the field).
1009
- if (active.mode && active.mode !== mode) {
1010
- console.log(
1011
- `Previous build for ${featureCode} was in ${active.mode} mode, ` +
1012
- `current invocation is ${mode} mode. Starting fresh.`
1013
- );
1014
- response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate, roles);
1015
- } else if (active.status && active.status !== 'running') {
1016
- console.log(`Previous build ${active.status}. Starting fresh.`);
1017
- response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate, roles);
1018
- } else if (active.pid && active.pid !== process.pid && isProcessAlive(active.pid)) {
1019
- // Same feature, different live process — block
1020
- throw new Error(
1021
- `Build already running for ${featureCode} (pid ${active.pid}). ` +
1022
- `Use 'compose build --abort' to cancel it.`
1023
- );
1024
- } else {
1025
- console.log(`Found previous build for ${featureCode} (flow: ${active.flowId})`);
1026
- try {
1027
- response = await stratum.resume(active.flowId);
1028
- if (isTerminalFlow(response.status)) {
1029
- console.log(`Previous build already ${response.status}. Starting fresh.`);
1030
- response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate, roles);
1031
- } else {
1032
- console.log(`Resuming from step: ${response.step_id}`);
1033
- isFreshStart = false;
1034
- // COMP-CODEX-IMPL: real resume — restore roles from the persisted build.
1035
- restoreRolesFromActive(active);
1036
- }
1037
- } catch (err) {
1038
- const recoverable = err?.code === 'flow_not_found'
1039
- || err?.code === 'STRATUM_ERROR'
1040
- || err?.message?.includes('No active flow');
1041
- if (recoverable) {
1042
- console.log('Previous flow not found. Starting fresh.');
1043
- response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate, roles);
1044
- } else {
1045
- throw err;
1046
- }
1250
+ }
1251
+
1252
+ const verdict = decideBuildStart({
1253
+ active: activeForDecision,
1254
+ opts,
1255
+ pidAlive,
1256
+ flowTerminal,
1257
+ sameMode,
1258
+ });
1259
+ isFreshStart = verdict.action === 'fresh';
1260
+
1261
+ if (verdict.action === 'resume') {
1262
+ const flowId = verdict.flowId;
1263
+ console.log(`Resuming flow ${flowId} for ${featureCode}...`);
1264
+ response = await stratum.resume(flowId);
1265
+ // A BARE programmatic resumeFlowId (no --resume flag) resumes the named
1266
+ // flow as-is and proceeds — exactly as pre-COMP-BUILD-RESUME, which had no
1267
+ // post-resume terminal check on this path. Only the --resume flag /
1268
+ // auto-resume path re-evaluates terminality: a terminal resume response
1269
+ // there means "nothing to resume" (flag → error, auto → start fresh).
1270
+ const bareFlowResume = Boolean(opts.resumeFlowId && !opts.resume);
1271
+ if (!bareFlowResume && isTerminalFlow(response.status)) {
1272
+ const explicitResume = Boolean(opts.resume || opts.resumeFlowId);
1273
+ if (explicitResume) {
1274
+ throw new Error(`Nothing to resume for ${featureCode} (no in-progress or failed build found)`);
1047
1275
  }
1276
+ response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate, roles);
1277
+ isFreshStart = true;
1048
1278
  }
1049
- } else {
1050
- // Different feature or no active build — start fresh.
1051
- // active-build.json is last-writer-wins: concurrent builds for
1052
- // different features are allowed; the UI shows the most recent.
1279
+ if (!isFreshStart) {
1280
+ console.log(`Resuming from step: ${response.step_id}`);
1281
+ // COMP-CODEX-IMPL: this is a real resume — restore roles from persisted state
1282
+ // (the refresh-write below then persists the restored roles, not flag-derived).
1283
+ restoreRolesFromActive(activeForDecision);
1284
+ // Refresh active-build.json so streaming/UI sees this as the live build.
1285
+ const flowName = extractFlowName(specYaml, templateName);
1286
+ writeActiveBuild(dataDir, {
1287
+ featureCode,
1288
+ flowId: response.flow_id ?? flowId,
1289
+ pipeline: flowName,
1290
+ mode,
1291
+ pid: process.pid,
1292
+ currentStepId: response.step_id,
1293
+ specPath: `pipelines/${templateName}.stratum.yaml`,
1294
+ stepNum: response.step_number ?? 1,
1295
+ totalSteps: response.total_steps ?? null,
1296
+ retries: 0,
1297
+ violations: [],
1298
+ status: 'running',
1299
+ resumedAt: new Date().toISOString(),
1300
+ // COMP-CODEX-IMPL: carry the (restored or default) roles forward on resume-refresh.
1301
+ implementerAgent,
1302
+ reviewerAgent,
1303
+ });
1304
+ }
1305
+ } else if (verdict.action === 'fresh') {
1306
+ if (activeForDecision?.flowId) console.log(`${verdict.reason}. Starting fresh.`);
1053
1307
  response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate, roles);
1308
+ } else {
1309
+ const reason = verdict.reason.includes(featureCode)
1310
+ ? verdict.reason
1311
+ : verdict.reason.replace('Build already running', `Build already running for ${featureCode}`);
1312
+ throw new Error(reason);
1054
1313
  }
1055
1314
 
1056
1315
  // COMP-CODEX-IMPL: verify Codex can write inside a detached git worktree (the
@@ -1074,7 +1333,7 @@ export async function runBuild(featureCode, opts = {}) {
1074
1333
  // terminal handlers run, so roll BOTH back here (mirrors the killed/failed
1075
1334
  // teardown) — otherwise a build that never dispatched a step strands stale
1076
1335
  // state (Codex impl-review findings).
1077
- if (!isBugMode) {
1336
+ if (cfg.tracksFeatureJson) {
1078
1337
  try {
1079
1338
  const _bp = await getBuildProvider(cwd);
1080
1339
  const _feat = await _bp.getFeature(featureCode);
@@ -1127,7 +1386,7 @@ export async function runBuild(featureCode, opts = {}) {
1127
1386
 
1128
1387
  // Dispatch loop — agents operate in agentCwd (which may differ from cwd for cross-repo builds)
1129
1388
  // stepHistory accumulates context across steps so downstream steps don't re-explore
1130
- const stepHistory = [];
1389
+ stepHistory = [];
1131
1390
  // COMP-MCP-MIGRATION: read enforcement.mcpForFeatureMgmt from settings.
1132
1391
  // When true, step-prompt.js injects a hard instruction telling the agent
1133
1392
  // to use typed MCP tools instead of free-text Edit/Write for ROADMAP /
@@ -1164,6 +1423,13 @@ export async function runBuild(featureCode, opts = {}) {
1164
1423
  };
1165
1424
 
1166
1425
 
1426
+ // COMP-PLAN-GATE-LOOP: per-step gate re-entry counter. A `revise` that
1427
+ // routes back through earlier steps re-enters the same gate; the round-aware
1428
+ // gate id (below) keeps each re-entry a fresh pending gate, but this counter
1429
+ // is the backstop — if the round can't be threaded for any reason, it trips
1430
+ // instead of letting the gate spin unbounded (the 52-round loop).
1431
+ const gateReentries = new Map();
1432
+
1167
1433
  while (response.status !== 'complete' && response.status !== 'killed') {
1168
1434
  const stepId = response.step_id;
1169
1435
  const flowId = response.flow_id;
@@ -1189,8 +1455,16 @@ export async function runBuild(featureCode, opts = {}) {
1189
1455
  // Ship step: run git commit in-process instead of delegating to a sandboxed agent.
1190
1456
  // The agent can't git commit (sandbox blocks it), so we do it here where we have
1191
1457
  // full shell access. This turns a 10+ minute spiral into a <5 second operation.
1192
- if (stepId === 'ship') {
1458
+ // COMP-ROADMAP-PLAN S8: build/fix only — plan's `ship` falls through to the
1459
+ // normal agent step (handoff/verify), never executeShipStep.
1460
+ if (shouldInterceptShip(stepId, mode)) {
1193
1461
  const shipResult = await executeShipStep(featureCode, agentCwd, cwd, context, description, progress);
1462
+ // COMP-MODEL-AB fix B: capture test counts here, in the interception branch that
1463
+ // `continue`s before the generic step-completion path at ~1703. Without this
1464
+ // capture, shipStepTestData stays null for all real builds and appendBuildHistory
1465
+ // never persists test_count/pass_rate. Must mirror the generic path exactly.
1466
+ const _interceptedTestMetrics = _extractShipTestMetrics(shipResult);
1467
+ if (_interceptedTestMetrics !== null) shipStepTestData = _interceptedTestMetrics;
1194
1468
  stepHistory.push({
1195
1469
  stepId: 'ship',
1196
1470
  artifact: shipResult.artifact,
@@ -1432,6 +1706,15 @@ export async function runBuild(featureCode, opts = {}) {
1432
1706
  stepHistory.push(entry);
1433
1707
  progress.stepDone(stepId);
1434
1708
 
1709
+ // COMP-MODEL-AB: capture test counts from ship step for build-history persistence.
1710
+ // Generic path (non-intercepted ship / plan mode ship-as-agent). Mirrors the
1711
+ // ship-interception capture above; uses the same _extractShipTestMetrics helper
1712
+ // so both paths produce identical shipStepTestData shapes.
1713
+ if (stepId === 'ship') {
1714
+ const _genericTestMetrics = _extractShipTestMetrics(result);
1715
+ if (_genericTestMetrics !== null) shipStepTestData = _genericTestMetrics;
1716
+ }
1717
+
1435
1718
  // Note: scope-step BuildProfile persistence has been replaced by pre-build triage.
1436
1719
  // runTriage() runs before stratum_plan() and populates feature.json directly.
1437
1720
 
@@ -1584,6 +1867,12 @@ export async function runBuild(featureCode, opts = {}) {
1584
1867
  } else if (response.status === 'await_gate') {
1585
1868
  updateActiveBuildStep(dataDir, stepId);
1586
1869
 
1870
+ // COMP-PLAN-GATE-LOOP: trip the backstop before doing any gate work if
1871
+ // this step has re-entered its gate too many times without converging.
1872
+ const gateReentryCount = (gateReentries.get(stepId) ?? 0) + 1;
1873
+ gateReentries.set(stepId, gateReentryCount);
1874
+ assertGateReentryWithinCap(gateReentryCount, stepId);
1875
+
1587
1876
  // Gate enrichment extras for STRAT-COMP-6
1588
1877
  const gateExtras = {
1589
1878
  fromPhase: response.from_phase ?? null,
@@ -1642,8 +1931,16 @@ export async function runBuild(featureCode, opts = {}) {
1642
1931
  const serverUp = await probeServer();
1643
1932
  let outcome, rationale;
1644
1933
 
1934
+ // COMP-PLAN-GATE-LOOP: thread Stratum's current round into the gate id
1935
+ // so a `revise` re-entry mints a fresh `<flowId>:<stepId>:<round>` gate
1936
+ // (pending) instead of colliding with the prior resolved gate and
1937
+ // replaying its stale outcome. Stratum tracks the round in the flow
1938
+ // state but omits it from the await_gate dispatch, so read it from the
1939
+ // persisted flow file (fail-open to round 1 if unreadable).
1940
+ const round = readFlowRound(flowId);
1941
+
1645
1942
  if (serverUp) {
1646
- const gateId = await visionWriter.createGate(flowId, stepId, itemId, { ...gateExtras, policyMode: 'gate' });
1943
+ const gateId = await visionWriter.createGate(flowId, stepId, itemId, { ...gateExtras, policyMode: 'gate', round });
1647
1944
  console.log('Gate delegated to web UI. Waiting for resolution...');
1648
1945
  const resolved = await pollGateResolution(visionWriter, gateId);
1649
1946
  if (resolved) {
@@ -1662,7 +1959,7 @@ export async function runBuild(featureCode, opts = {}) {
1662
1959
  try { await visionWriter._restResolveGate(gateId, outcome); } catch { /* ignore */ }
1663
1960
  }
1664
1961
  } else {
1665
- const gateId = await visionWriter.createGate(flowId, stepId, itemId, { ...gateExtras, policyMode: 'gate' });
1962
+ const gateId = await visionWriter.createGate(flowId, stepId, itemId, { ...gateExtras, policyMode: 'gate', round });
1666
1963
  const result = await promptGate(response, {
1667
1964
  ...(opts.gateOpts ?? {}),
1668
1965
  artifact: context.cwd,
@@ -2127,7 +2424,7 @@ export async function runBuild(featureCode, opts = {}) {
2127
2424
  await visionWriter.updateItemStatus(itemId, 'complete');
2128
2425
  // COMP-QA: persist filesChanged so `compose qa-scope` can read them post-build.
2129
2426
  // Bug mode skips feature-json — bugs don't have feature.json (COMP-FIX-HARD T4).
2130
- if (!isBugMode) {
2427
+ if (cfg.tracksFeatureJson) {
2131
2428
  const _bp = await getBuildProvider(cwd);
2132
2429
  // Guard: feature.json may not exist when triage was skipped (test harnesses).
2133
2430
  // Original updateFeature silently no-oped when feature was missing.
@@ -2147,7 +2444,7 @@ export async function runBuild(featureCode, opts = {}) {
2147
2444
  buildStatus = 'killed';
2148
2445
  console.log('\nBuild killed.');
2149
2446
  await visionWriter.updateItemStatus(itemId, 'killed');
2150
- if (!isBugMode) {
2447
+ if (cfg.tracksFeatureJson) {
2151
2448
  const _bp = await getBuildProvider(cwd);
2152
2449
  const _feat = await _bp.getFeature(featureCode);
2153
2450
  if (_feat) {
@@ -2163,20 +2460,16 @@ export async function runBuild(featureCode, opts = {}) {
2163
2460
  } else if (buildStatus === 'failed') {
2164
2461
  // Ship failure or other explicit failure — write terminal state
2165
2462
  console.log('\nBuild failed.');
2166
- await visionWriter.updateItemStatus(itemId, 'failed');
2167
- if (!isBugMode) {
2168
- const _bp = await getBuildProvider(cwd);
2169
- const _feat = await _bp.getFeature(featureCode);
2170
- if (_feat) {
2171
- // Raw write back to PLANNED — no transition policy, no events, no renderRoadmap.
2172
- // Matches original updateFeature semantics; keeps teardown side-effect-free.
2173
- await _bp.persistFeatureRaw(featureCode, { ..._feat, status: 'PLANNED' });
2174
- }
2175
- }
2176
- const termState = readActiveBuild(dataDir);
2177
- if (termState) {
2178
- writeActiveBuild(dataDir, { ...termState, status: 'failed', completedAt: new Date().toISOString() });
2179
- }
2463
+ await writeFailedBuildTerminalState({
2464
+ cwd,
2465
+ dataDir,
2466
+ cfg,
2467
+ visionWriter,
2468
+ itemId,
2469
+ featureCode,
2470
+ flowId: response?.flow_id ?? null,
2471
+ failureReason: buildFailureReason({ buildStatus, stepHistory }),
2472
+ });
2180
2473
  } else {
2181
2474
  buildStatus = 'failed';
2182
2475
  }
@@ -2272,10 +2565,7 @@ export async function runBuild(featureCode, opts = {}) {
2272
2565
  // Assembled from the in-memory build context for THIS run (never re-read
2273
2566
  // active-build.json, which is last-writer-wins across concurrent builds).
2274
2567
  if (['complete', 'aborted', 'failed', 'killed'].includes(buildStatus)) {
2275
- const lastFailedStep = [...stepHistory].reverse().find(s => s.outcome === 'failed');
2276
- const failureReason = buildStatus === 'complete'
2277
- ? null
2278
- : (lastFailedStep?.summary ?? healthDowngradeReason ?? `Build ${buildStatus}`);
2568
+ const failureReason = buildFailureReason({ buildStatus, stepHistory, healthDowngradeReason });
2279
2569
  appendBuildHistory(dataDir, {
2280
2570
  featureCode,
2281
2571
  flowId: response?.flow_id ?? null,
@@ -2293,7 +2583,11 @@ export async function runBuild(featureCode, opts = {}) {
2293
2583
  // COMP-MOBILE-1-1: compact per-step results so history consumers can
2294
2584
  // render which-step-failed without the live active-build state.
2295
2585
  steps: projectHistorySteps(stepHistory),
2586
+ // COMP-MODEL-AB: structured test counts from ship step — present only when
2587
+ // the build ran tests and the framework output was parseable.
2588
+ ...(shipStepTestData !== null ? shipStepTestData : {}),
2296
2589
  });
2590
+ terminalHistoryWritten.value = true;
2297
2591
  }
2298
2592
 
2299
2593
  // COMP-OBS-GATES: emit gate_tier_summary and persist savings on build completion
@@ -2343,7 +2637,7 @@ export async function runBuild(featureCode, opts = {}) {
2343
2637
  join(featureDir, 'audit.json'),
2344
2638
  JSON.stringify(response, null, 2)
2345
2639
  );
2346
- console.log(`Audit trace written to ${isBugMode ? 'docs/bugs' : 'docs/features'}/${featureCode}/audit.json`);
2640
+ console.log(`Audit trace written to ${cfg.artifactRoot === 'features' ? 'docs/features' : cfg.artifactRoot}/${featureCode}/audit.json`);
2347
2641
  } catch (err) {
2348
2642
  console.warn(`Warning: could not write audit trace: ${err.message}`);
2349
2643
  }
@@ -2356,7 +2650,7 @@ export async function runBuild(featureCode, opts = {}) {
2356
2650
  join(featureDir, 'audit.json'),
2357
2651
  JSON.stringify(audit, null, 2)
2358
2652
  );
2359
- console.log(`Audit trace written to ${isBugMode ? 'docs/bugs' : 'docs/features'}/${featureCode}/audit.json`);
2653
+ console.log(`Audit trace written to ${cfg.artifactRoot === 'features' ? 'docs/features' : cfg.artifactRoot}/${featureCode}/audit.json`);
2360
2654
  } catch (err) {
2361
2655
  console.warn(`Warning: could not write audit trace: ${err.message}`);
2362
2656
  }
@@ -2364,6 +2658,29 @@ export async function runBuild(featureCode, opts = {}) {
2364
2658
 
2365
2659
  // File retained on disk per STRAT-COMP-4 — overwritten on next build start
2366
2660
 
2661
+ } catch (err) {
2662
+ buildStatus = 'failed';
2663
+ const failureReason = err?.message ?? 'Build failed';
2664
+ try {
2665
+ await terminalizeThrownBuild({
2666
+ cwd,
2667
+ dataDir,
2668
+ cfg,
2669
+ visionWriter,
2670
+ itemId,
2671
+ featureCode,
2672
+ mode,
2673
+ response,
2674
+ buildStartedAt,
2675
+ buildCostTotals,
2676
+ stepHistory,
2677
+ failureReason,
2678
+ historyWritten: terminalHistoryWritten,
2679
+ });
2680
+ } catch (terminalErr) {
2681
+ console.warn(`[build] Failed to terminalize crashed build: ${terminalErr.message}`);
2682
+ }
2683
+ throw err;
2367
2684
  } finally {
2368
2685
  // Close stream writer with appropriate status (idempotent — signal handler may have already closed)
2369
2686
  if (streamWriter) {
@@ -2467,6 +2784,25 @@ function noticeExternalArtifacts(cwd, featureCode, buildToplevel) {
2467
2784
  } catch { /* notice is best-effort */ }
2468
2785
  }
2469
2786
 
2787
+ /**
2788
+ * Extract structured test counts from a ship step result for build-history persistence.
2789
+ * Exported so tests can assert the capture logic without running a full build loop.
2790
+ *
2791
+ * Called in BOTH the ship-interception branch (shouldInterceptShip path) and the
2792
+ * generic step-completion path so both code paths produce the same history record.
2793
+ *
2794
+ * Returns null when testSummary was unparsed (test_count absent or not a number).
2795
+ * The `?? 0` on pass_rate is defensive — parseTestSummary always sets it when
2796
+ * parsed=true, but this prevents a null from silently reaching the history record.
2797
+ *
2798
+ * @param {object|null} shipResult Return value from executeShipStep
2799
+ * @returns {{ test_count: number, pass_rate: number }|null}
2800
+ */
2801
+ export function _extractShipTestMetrics(shipResult) {
2802
+ if (typeof shipResult?.test_count !== 'number') return null;
2803
+ return { test_count: shipResult.test_count, pass_rate: shipResult.pass_rate ?? 0 };
2804
+ }
2805
+
2470
2806
  /**
2471
2807
  * Execute the ship step: run tests, stage feature files, commit.
2472
2808
  * Returns a PhaseResult-shaped object.
@@ -2475,9 +2811,13 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
2475
2811
  // COMP-FIX-HARD T4: bug mode stages docs/bugs/<code>/ instead of <featuresDir>/<code>/
2476
2812
  // COMP-MCP-MIGRATION-2: feature mode honors paths.features override.
2477
2813
  const featuresDir = loadFeaturesDir(cwd);
2478
- const featureDir = context?.mode === 'bug'
2479
- ? `docs/bugs/${featureCode}`
2480
- : `${featuresDir}/${featureCode}`;
2814
+ // RELATIVE staging dir, driven by the mode's artifactRoot (the relative form is
2815
+ // load-bearing for the MCP-enforcement git-status guard). Byte-identical to the
2816
+ // prior feature/bug branch: 'features' → <featuresDir>, else the literal token.
2817
+ const shipCfg = getMode(context?.mode).runner;
2818
+ const featureDir = shipCfg.artifactRoot === 'features'
2819
+ ? `${featuresDir}/${featureCode}`
2820
+ : `${shipCfg.artifactRoot}/${featureCode}`;
2481
2821
 
2482
2822
  // COMP-BUILD-QUICK-1: when a feature was built via the trimmed quick lifecycle
2483
2823
  // (which omits the report phase by design), stamp built_via onto feature.json so
@@ -2758,6 +3098,10 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
2758
3098
  : `Committed: ${commitMsg} (${stagedFiles.length} files)`,
2759
3099
  commit: sha,
2760
3100
  filesChanged,
3101
+ // COMP-MODEL-AB: thread structured test counts into the step result so the
3102
+ // main loop can persist them to build-history.jsonl for metrics consumers.
3103
+ // Only present when testSummary.parsed=true (framework detected + output parsed).
3104
+ ...(testSummary.parsed ? { test_count: testSummary.test_count, pass_rate: testSummary.pass_rate } : {}),
2761
3105
  ...(completionWarning ? { completionWarning } : {}),
2762
3106
  };
2763
3107
 
@@ -3154,7 +3498,8 @@ export async function executeChildFlow(
3154
3498
 
3155
3499
  if (progress) progress.pause();
3156
3500
  console.log(` [${childFlowName}] Gate: ${resp.step_id}`);
3157
- const gateId = await visionWriter.createGate(childFlowId, resp.step_id, itemId);
3501
+ // COMP-PLAN-GATE-LOOP: round-aware gate id (child flow has its own round).
3502
+ const gateId = await visionWriter.createGate(childFlowId, resp.step_id, itemId, { round: readFlowRound(childFlowId) });
3158
3503
  const childAskAgent = makeAskAgent(stratum, context, resp, null);
3159
3504
 
3160
3505
  const childGateExtras = {
@@ -3357,6 +3702,15 @@ export function shouldUseServerDispatch(dispatchResponse) {
3357
3702
  const SERVER_DISPATCH_POLL_MS = () =>
3358
3703
  Number(process.env.COMPOSE_SERVER_DISPATCH_POLL_MS) || 500;
3359
3704
 
3705
+ // COMP-PLAN-GATE-LOOP (F3): wall-clock ceiling for a single parallel-dispatch
3706
+ // step's poll loop. The loop exits on an outcome, a stuck verdict (GSD only),
3707
+ // or a poll error — but if Stratum ever leaves tasks 'running' forever with
3708
+ // fresh heartbeats and no error, none fire and the loop is unbounded. No single
3709
+ // parallel step legitimately runs 6h, so this turns a silent hang into a
3710
+ // diagnostic failure. Env-overridable.
3711
+ const MAX_PARALLEL_DISPATCH_MS = () =>
3712
+ Number(process.env.COMPOSE_MAX_PARALLEL_DISPATCH_MS) || 6 * 60 * 60 * 1000;
3713
+
3360
3714
  /**
3361
3715
  * Emit per-task state-transition events. Uses build_task_start/done subtypes
3362
3716
  * (distinct from build_step_start/done) to avoid stepId key collisions in
@@ -3483,7 +3837,17 @@ export async function executeParallelDispatchServer(
3483
3837
  let pollResult;
3484
3838
  let stuckVerdict = null;
3485
3839
  const intervalMs = SERVER_DISPATCH_POLL_MS();
3840
+ const pollDeadline = Date.now() + MAX_PARALLEL_DISPATCH_MS();
3486
3841
  while (true) {
3842
+ // F3: guard against an unbounded poll if Stratum never delivers an
3843
+ // outcome (tasks stuck 'running' with fresh heartbeats, no error).
3844
+ if (Date.now() > pollDeadline) {
3845
+ throw new Error(
3846
+ `executeParallelDispatchServer: parallel step '${stepId}' exceeded the ` +
3847
+ `${Math.round(MAX_PARALLEL_DISPATCH_MS() / 3_600_000)}h dispatch ceiling without an outcome ` +
3848
+ `(last task states: ${JSON.stringify(pollResult?.tasks ?? {})}). Aborting to avoid an unbounded hang.`
3849
+ );
3850
+ }
3487
3851
  pollResult = await stratum.parallelPoll(flowId, stepId);
3488
3852
  if (pollResult?.error) {
3489
3853
  throw new Error(
@@ -4629,9 +4993,15 @@ export async function startFresh(stratum, specYaml, featureCode, description, da
4629
4993
  // Defaults reproduce today's behavior (claude implements, codex reviews) byte-identically.
4630
4994
  const implementerAgent = roles?.implementerAgent ?? 'claude';
4631
4995
  const reviewerAgent = roles?.reviewerAgent ?? 'codex';
4632
- const planInputs = mode === 'bug'
4996
+ // The plan-input envelope is the mode's flow input contract. bug → { task };
4997
+ // plan → { projectName, intent } (the new.stratum.yaml shape); feature → the
4998
+ // full feature envelope. Byte-identical to the prior bug/feature ternary.
4999
+ const planCfg = getMode(mode).runner;
5000
+ const planInputs = planCfg.planInputs === 'bug'
4633
5001
  ? { task: description }
4634
- : { featureCode, description, implementer_agent: implementerAgent, reviewer_agent: reviewerAgent, ...(preMergeGate !== undefined ? { pre_merge_gate: preMergeGate } : {}) };
5002
+ : planCfg.planInputs === 'plan'
5003
+ ? { projectName: featureCode, intent: description }
5004
+ : { featureCode, description, implementer_agent: implementerAgent, reviewer_agent: reviewerAgent, ...(preMergeGate !== undefined ? { pre_merge_gate: preMergeGate } : {}) };
4635
5005
  const response = await stratum.plan(specYaml, flowName, planInputs);
4636
5006
 
4637
5007
  writeActiveBuild(dataDir, {
@@ -4747,6 +5117,30 @@ async function pollGateResolution(visionWriter, gateId, intervalMs = 2000) {
4747
5117
  }
4748
5118
  }
4749
5119
 
5120
+ /**
5121
+ * COMP-PLAN-GATE-LOOP: backstop cap on how many times a single step may
5122
+ * re-enter its gate within one build. With the round-aware gate id this should
5123
+ * never trip (each re-entry blocks for a real decision), but if the round can't
5124
+ * be threaded the gate would otherwise spin forever (the observed 52-round
5125
+ * loop). Trip loudly instead — the Stratum flow state is preserved, so the
5126
+ * gate can be resolved and the build resumed.
5127
+ *
5128
+ * @param {number} count - re-entry count for this step (1 on first entry)
5129
+ * @param {string} stepId
5130
+ * @param {number} [cap=MAX_GATE_REENTRIES]
5131
+ */
5132
+ export const MAX_GATE_REENTRIES = 20;
5133
+
5134
+ export function assertGateReentryWithinCap(count, stepId, cap = MAX_GATE_REENTRIES) {
5135
+ if (count > cap) {
5136
+ throw new Error(
5137
+ `Gate "${stepId}" re-entered ${count} times without converging (cap ${cap}). ` +
5138
+ `Aborting to avoid an infinite gate loop. The Stratum flow state is preserved — ` +
5139
+ `resolve the gate (e.g. approve it) and re-run with --resume to continue.`
5140
+ );
5141
+ }
5142
+ }
5143
+
4750
5144
  /**
4751
5145
  * Append a decision log entry to docs/context/decisions.md.
4752
5146
  * Only writes if the file already exists (created by `compose init`).