@smartmemory/compose 0.2.52-beta → 0.2.53-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.
- package/.claude/skills/compose/SKILL.md +0 -8
- package/README.md +11 -0
- package/bin/compose.js +335 -3
- package/dist/assets/App-Bu9KMtTa.js +891 -0
- package/dist/assets/abnfDiagram-VRR7QNED-6zp3w9rx.js +1 -0
- package/dist/assets/arc-CnLxxuah.js +1 -0
- package/dist/assets/architectureDiagram-ZJ3FMSHR-COM46S9k.js +36 -0
- package/dist/assets/blockDiagram-677ZJIJ3-wKzgOwF8.js +132 -0
- package/dist/assets/{browser-CnKiSnlr.js → browser-Cr0recrN.js} +6 -6
- package/dist/assets/{c4Diagram-AHTNJAMY-DtDSNlOX.js → c4Diagram-LMCZKHZV-DIXC_mR6.js} +1 -1
- package/dist/assets/channel-DQJPWkb_.js +1 -0
- package/dist/assets/{chunk-QZHKN3VN-BMWFLZ8t.js → chunk-2Q5K7J3B-BqLqZX6m.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-CqjNXooj.js → chunk-32BRIVSS-CarSmVlX.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-BAa7Edp6.js → chunk-5VM5RSS4-D-f_Jn3G.js} +1 -1
- package/dist/assets/chunk-EX3LRPZG-DT42xo8E.js +231 -0
- package/dist/assets/{chunk-4BX2VUAB-oXU2npoL.js → chunk-JWPE2WC7-CwY1aZc4.js} +1 -1
- package/dist/assets/chunk-MOJQB5TN-DBbZU_KX.js +88 -0
- package/dist/assets/chunk-RYQCIY6F-DqxtcLJ3.js +1 -0
- package/dist/assets/chunk-V7JOEXUC-Cy4Ixww6.js +206 -0
- package/dist/assets/{chunk-EDXVE4YY-Bse2kdRh.js → chunk-VR4S4FIN-CFexfU_a.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-BEEk2FdH.js → chunk-XXDRQBXY-uE-zN_vc.js} +1 -1
- package/dist/assets/classDiagram-OUVF2IWQ-CTLbAiUK.js +1 -0
- package/dist/assets/classDiagram-v2-EOCWNBFH-CTLbAiUK.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-NYnH3mVW.js → cose-bilkent-JH36ORCC-DIjpekos.js} +1 -1
- package/dist/assets/cynefin-VYW2F7L2-Ba2gbQow.js +178 -0
- package/dist/assets/cynefinDiagram-TSTJHNR4-njGzb7Tg.js +62 -0
- package/dist/assets/dagre-VKFMJZFB-DuyZREZ3.js +4 -0
- package/dist/assets/diagram-FQU43EPY-Npq-5o3a.js +3 -0
- package/dist/assets/diagram-G47NLZAW-fm4k3axC.js +24 -0
- package/dist/assets/diagram-NH7WQ7WH-B8EFGrTG.js +24 -0
- package/dist/assets/diagram-OA4YK3LP-BC7UHx9Q.js +30 -0
- package/dist/assets/diagram-WEI45ONY-BqjHLcCX.js +41 -0
- package/dist/assets/ebnfDiagram-CCIWWBDH-D5zXd4Qg.js +1 -0
- package/dist/assets/erDiagram-Q63AITRT-DC2FMcra.js +85 -0
- package/dist/assets/flowDiagram-23GEKE2U-D7Dx7JU4.js +156 -0
- package/dist/assets/ganttDiagram-NO4QXBWP-8sQH2y5K.js +292 -0
- package/dist/assets/gitGraphDiagram-IHSO6WYX-Ci_rhxur.js +106 -0
- package/dist/assets/graph-C9eacEi8.js +1 -0
- package/dist/assets/graph-xkel59g2.js +331 -0
- package/dist/assets/index-B7-HQenC.js +119 -0
- package/dist/assets/infoDiagram-FWYZ7A6U-B9FGQ0Cb.js +2 -0
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-CK2IFFAP.js → ishikawaDiagram-FXEZZL3T-BEKLyH6A.js} +5 -5
- package/dist/assets/{journeyDiagram-VCZTEJTY-DAH5Stkf.js → journeyDiagram-5HDEW3XC-BM-IDVRo.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-Bi0aCqkv.js → kanban-definition-HUTT4EX6-Beb2k3pt.js} +7 -7
- package/dist/assets/katex-C5jXJg4s.js +257 -0
- package/dist/assets/layout-DEXfKzaS.js +1 -0
- package/dist/assets/{linear-ClGEGlS2.js → linear-Dwg7dTpz.js} +1 -1
- package/dist/assets/map-Czzmt4hB.js +1 -0
- package/dist/assets/{mindmap-definition-QFDTVHPH-Wjr-PgC6.js → mindmap-definition-LN4V7U3C-CeaynfcE.js} +7 -7
- package/dist/assets/{mobile-Chw8RWyH.js → mobile-CNLMhdFP.js} +2 -2
- package/dist/assets/pegDiagram-2B236MQR-7SVdAVqj.js +1 -0
- package/dist/assets/pieDiagram-ENE6RG2P-CzqQ4TaE.js +39 -0
- package/dist/assets/quadrantDiagram-ABIIQ3AL-B9B6W80N.js +7 -0
- package/dist/assets/railroadDiagram-RFXS5EU6-BRNLawsr.js +1 -0
- package/dist/assets/{requirementDiagram-MS252O5E-CU9Rlyq5.js → requirementDiagram-TGXJPOKE-CmASubeG.js} +3 -3
- package/dist/assets/sankeyDiagram-HTMAVEWB-DiReR0OB.js +40 -0
- package/dist/assets/sequenceDiagram-DBY2YBRQ-DVn5iZKR.js +162 -0
- package/dist/assets/sizeCapture-X5ZJPWSS-C8CuDOdp.js +1 -0
- package/dist/assets/stateDiagram-2N3HPSRC-BIdkHglY.js +1 -0
- package/dist/assets/stateDiagram-v2-6OUMAXLB-B9yje3Er.js +1 -0
- package/dist/assets/swimlanes-5IMT3BWC-BlVsYyyN.js +2 -0
- package/dist/assets/swimlanesDiagram-G3AALYLV-BTLpU45n.js +8 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-C8SZmaLp.js → timeline-definition-FHXFAJF6-CnTqJmQ2.js} +3 -3
- package/dist/assets/vennDiagram-L72KCM5P-D_qZxRhe.js +34 -0
- package/dist/assets/wardleyDiagram-EHGQE667-CT-WHFEO.js +78 -0
- package/dist/assets/xychartDiagram-FW5EYKEG-CVRFHwUQ.js +7 -0
- package/dist/index.html +2 -2
- package/lib/agent-string.js +34 -0
- package/lib/build.js +542 -148
- package/lib/experiment-judge.js +145 -0
- package/lib/experiment-metrics.js +265 -0
- package/lib/experiment-pricing.js +60 -0
- package/lib/experiment-report.js +306 -0
- package/lib/experiment-sandbox.js +147 -0
- package/lib/experiment.js +539 -0
- package/lib/feature-json.js +1 -1
- package/lib/feature-writer.js +30 -0
- package/lib/flow-state.js +36 -0
- package/lib/gate-prompt.js +66 -6
- package/lib/lifecycle-modes.js +213 -0
- package/lib/new.js +6 -2
- package/lib/roadmap-graph/index.js +26 -28
- package/lib/roadmap-graph/vision-adapter.js +126 -0
- package/lib/stratum-mcp-client.js +9 -0
- package/lib/triage.js +7 -1
- package/lib/vision-writer.js +30 -6
- package/package.json +1 -1
- package/pipelines/plan.stratum.yaml +161 -0
- package/server/artifact-manager.js +30 -3
- package/server/compose-mcp-tools.js +11 -9
- package/server/compose-mcp.js +4 -0
- package/server/feature-scan.js +87 -173
- package/server/graph-export.js +0 -0
- package/server/index.js +3 -5
- package/server/lifecycle-guard.js +62 -25
- package/server/roadmap-graph-vision.js +138 -0
- package/server/status-snapshot.js +8 -7
- package/server/vision-routes.js +64 -33
- package/server/vision-store.js +13 -4
- package/.claude/skills/compose/references/hermes-tools.md +0 -80
- package/dist/assets/App-Ddhfx18X.js +0 -889
- package/dist/assets/_baseUniq-CoEVVYyC.js +0 -1
- package/dist/assets/arc-DFnGxuCB.js +0 -1
- package/dist/assets/architectureDiagram-Q4EWVU46-66rx01Tn.js +0 -36
- package/dist/assets/blockDiagram-DXYQGD6D-AE5PKSak.js +0 -132
- package/dist/assets/channel-CScyWprq.js +0 -1
- package/dist/assets/chunk-4TB4RGXK-5w2lZAjW.js +0 -206
- package/dist/assets/chunk-OYMX7WX6-CGr_YiAc.js +0 -231
- package/dist/assets/classDiagram-6PBFFD2Q-DibVYYdr.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-DibVYYdr.js +0 -1
- package/dist/assets/clone-ZMALIFmw.js +0 -1
- package/dist/assets/dagre-KV5264BT--mrfaTHR.js +0 -4
- package/dist/assets/diagram-5BDNPKRD-DjXvyFzh.js +0 -10
- package/dist/assets/diagram-G4DWMVQ6-BrrexEvm.js +0 -24
- package/dist/assets/diagram-MMDJMWI5-D3Q0OT2M.js +0 -43
- package/dist/assets/diagram-TYMM5635-DKTntmxE.js +0 -24
- package/dist/assets/erDiagram-SMLLAGMA-BWbR0dPj.js +0 -85
- package/dist/assets/flowDiagram-DWJPFMVM-Dj5M5XaY.js +0 -162
- package/dist/assets/ganttDiagram-T4ZO3ILL-DKppFh_5.js +0 -292
- package/dist/assets/gitGraphDiagram-UUTBAWPF-9xQ9O25V.js +0 -106
- package/dist/assets/graph-Cgmhvu1T.js +0 -1
- package/dist/assets/graph-DZe55uk8.js +0 -331
- package/dist/assets/index-D8uDfn8y.js +0 -123
- package/dist/assets/infoDiagram-42DDH7IO-DQYWVzVN.js +0 -2
- package/dist/assets/katex-DkKDou_j.js +0 -257
- package/dist/assets/layout-DN13RkA1.js +0 -1
- package/dist/assets/min-CXr-fUFS.js +0 -1
- package/dist/assets/pieDiagram-DEJITSTG-CVipbIlr.js +0 -30
- package/dist/assets/quadrantDiagram-34T5L4WZ-CHJFyhm-.js +0 -7
- package/dist/assets/sankeyDiagram-XADWPNL6-BSULeqST.js +0 -10
- package/dist/assets/sequenceDiagram-FGHM5R23-CCOKXvjj.js +0 -157
- package/dist/assets/stateDiagram-FHFEXIEX-B91gKA7Q.js +0 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-Dc8ur9ta.js +0 -1
- package/dist/assets/vennDiagram-DHZGUBPP-YvEPDlsM.js +0 -34
- package/dist/assets/wardley-RL74JXVD-TMBUq_w1.js +0 -162
- package/dist/assets/wardleyDiagram-NUSXRM2D-DzEkPrri.js +0 -20
- package/dist/assets/xychartDiagram-5P7HB3ND-BIiCeiYk.js +0 -7
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
//
|
|
738
|
-
//
|
|
739
|
-
if (
|
|
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
|
-
|
|
782
|
-
|
|
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
|
-
|
|
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
|
|
836
|
-
|
|
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 (
|
|
881
|
-
// bug
|
|
882
|
-
if (
|
|
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
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
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
|
|
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 ${
|
|
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 ${
|
|
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
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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`).
|