@smartmemory/compose 0.2.50-beta → 0.2.51

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 (118) hide show
  1. package/.claude/skills/compose/SKILL.md +24 -1
  2. package/.claude/skills/compose/references/hermes-tools.md +80 -0
  3. package/README.md +1 -1
  4. package/bin/compose.js +310 -52
  5. package/dist/assets/App-CT0vXPgd.js +724 -0
  6. package/dist/assets/_baseUniq-CsOHc_iS.js +1 -0
  7. package/dist/assets/{arc-DeHak63Z.js → arc-BXht3LyE.js} +1 -1
  8. package/dist/assets/architectureDiagram-Q4EWVU46-BV1r86-w.js +36 -0
  9. package/dist/assets/blockDiagram-DXYQGD6D-BgcMJjAR.js +132 -0
  10. package/dist/assets/{browser-DW2JlJCC.js → browser-CnKiSnlr.js} +6 -6
  11. package/dist/assets/{c4Diagram-AAUBKEIU-BsFJmPuS.js → c4Diagram-AHTNJAMY-nBIRLJTv.js} +1 -1
  12. package/dist/assets/channel-Dd4XaiYv.js +1 -0
  13. package/dist/assets/{chunk-4BX2VUAB-XO4S2_89.js → chunk-4BX2VUAB-CNmGyhrp.js} +1 -1
  14. package/dist/assets/chunk-4TB4RGXK-MLT7I7w4.js +206 -0
  15. package/dist/assets/{chunk-55IACEB6-CtFSLInj.js → chunk-55IACEB6-D8KuKVLa.js} +1 -1
  16. package/dist/assets/{chunk-2J33WTMH-AEu6HIoY.js → chunk-EDXVE4YY-TPlt1bS2.js} +1 -1
  17. package/dist/assets/{chunk-FMBD7UC4-BMGd_Y9a.js → chunk-FMBD7UC4-tYY8xcjr.js} +1 -1
  18. package/dist/assets/chunk-OYMX7WX6-yBPwT1AT.js +231 -0
  19. package/dist/assets/{chunk-QZHKN3VN-DHhUJ8RK.js → chunk-QZHKN3VN-B4Wfybww.js} +1 -1
  20. package/dist/assets/{chunk-ND2GUHAM-BlrOECEr.js → chunk-YZCP3GAM-DE6_5aqh.js} +1 -1
  21. package/dist/assets/classDiagram-6PBFFD2Q-DshSQDF9.js +1 -0
  22. package/dist/assets/classDiagram-v2-HSJHXN6E-DshSQDF9.js +1 -0
  23. package/dist/assets/clone-CBsbNGAa.js +1 -0
  24. package/dist/assets/{cose-bilkent-S5V4N54A-C3M0mZqk.js → cose-bilkent-S5V4N54A-DzzRyJtB.js} +1 -1
  25. package/dist/assets/dagre-KV5264BT-CTcTVGdU.js +4 -0
  26. package/dist/assets/diagram-5BDNPKRD-qR6mc8kJ.js +10 -0
  27. package/dist/assets/diagram-G4DWMVQ6-CUcGXyNb.js +24 -0
  28. package/dist/assets/diagram-MMDJMWI5-B68cPonH.js +43 -0
  29. package/dist/assets/diagram-TYMM5635-q8Id8eFG.js +24 -0
  30. package/dist/assets/{erDiagram-TEJ5UH35-AMDitV0b.js → erDiagram-SMLLAGMA-CtxJi57K.js} +1 -1
  31. package/dist/assets/{flowDiagram-I6XJVG4X-C0t2ZGQv.js → flowDiagram-DWJPFMVM-BzzO4JYU.js} +1 -1
  32. package/dist/assets/ganttDiagram-T4ZO3ILL-Dn0wIyhR.js +292 -0
  33. package/dist/assets/gitGraphDiagram-UUTBAWPF-tnNFXfMM.js +106 -0
  34. package/dist/assets/graph-DZe55uk8.js +331 -0
  35. package/dist/assets/graph-Tq_bs_r0.js +1 -0
  36. package/dist/assets/index-8UhRLbGq.js +123 -0
  37. package/dist/assets/index-CRqB9els.css +1 -0
  38. package/dist/assets/infoDiagram-42DDH7IO-BXLvLeS0.js +2 -0
  39. package/dist/assets/{ishikawaDiagram-YF4QCWOH-CCBwgjFs.js → ishikawaDiagram-UXIWVN3A-DWx-7BBy.js} +5 -5
  40. package/dist/assets/{journeyDiagram-JHISSGLW-zUJN38EU.js → journeyDiagram-VCZTEJTY-C42hXNha.js} +1 -1
  41. package/dist/assets/{kanban-definition-UN3LZRKU-BNxWi-8J.js → kanban-definition-6JOO6SKY-CEq930ew.js} +1 -1
  42. package/dist/assets/katex-DkKDou_j.js +257 -0
  43. package/dist/assets/layout-qtUgN9BC.js +1 -0
  44. package/dist/assets/{linear-CRH9b4g7.js → linear-D158F7OT.js} +1 -1
  45. package/dist/assets/min-hESxN-c0.js +1 -0
  46. package/dist/assets/{mindmap-definition-RKZ34NQL-DolYwq7Y.js → mindmap-definition-QFDTVHPH-d9SSh2nG.js} +7 -7
  47. package/dist/assets/mobile-1gVCT0OK.css +1 -0
  48. package/dist/assets/mobile-Chw8RWyH.js +17 -0
  49. package/dist/assets/pieDiagram-DEJITSTG-gglmeMav.js +30 -0
  50. package/dist/assets/quadrantDiagram-34T5L4WZ-CqFC_htU.js +7 -0
  51. package/dist/assets/{requirementDiagram-4Y6WPE33-DsXZ05jo.js → requirementDiagram-MS252O5E-B1ZFIotK.js} +1 -1
  52. package/dist/assets/sankeyDiagram-XADWPNL6-BvItvZt5.js +10 -0
  53. package/dist/assets/sequenceDiagram-FGHM5R23-CAIn5Z3U.js +157 -0
  54. package/dist/assets/stateDiagram-FHFEXIEX-CyJrGzib.js +1 -0
  55. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DprhF_Ho.js +1 -0
  56. package/dist/assets/{timeline-definition-PNZ67QCA-Bvnhw58e.js → timeline-definition-GMOUNBTQ-k_0vJQAK.js} +1 -1
  57. package/dist/assets/{vennDiagram-CIIHVFJN-5WnyjZqf.js → vennDiagram-DHZGUBPP-TZAW60b0.js} +4 -4
  58. package/dist/assets/wardley-RL74JXVD-DzQEoG6X.js +162 -0
  59. package/dist/assets/wardleyDiagram-NUSXRM2D-BAoVVIjZ.js +20 -0
  60. package/dist/assets/{xychartDiagram-2RQKCTM6-BCShes6B.js → xychartDiagram-5P7HB3ND-CynkZcUb.js} +1 -1
  61. package/dist/index.html +4 -4
  62. package/lib/build.js +272 -33
  63. package/lib/codex-preflight.js +151 -0
  64. package/lib/completion-writer.js +21 -1
  65. package/lib/feature-validator.js +5 -1
  66. package/lib/hooks-status.js +135 -0
  67. package/lib/install-agent-defs.js +22 -0
  68. package/lib/migrate-anon.js +277 -0
  69. package/lib/roadmap-parser.js +1 -1
  70. package/lib/test-bootstrap.js +165 -2
  71. package/lib/vocabulary-inject.js +103 -0
  72. package/package.json +2 -1
  73. package/pipelines/build-quick.stratum.yaml +433 -0
  74. package/pipelines/build.stratum.yaml +60 -3
  75. package/scripts/watch-server.sh +32 -0
  76. package/server/build-routes.js +89 -9
  77. package/server/build-stream-bridge.js +12 -0
  78. package/server/compose-mcp-tools.js +73 -1
  79. package/server/compose-mcp.js +27 -0
  80. package/server/feature-scaffold-routes.js +82 -0
  81. package/server/health-routes.js +261 -0
  82. package/server/qa-scope-routes.js +96 -0
  83. package/server/validate-routes.js +70 -0
  84. package/server/vision-routes.js +29 -0
  85. package/server/vision-server.js +21 -0
  86. package/dist/assets/App-Bxyif1yv.js +0 -706
  87. package/dist/assets/architectureDiagram-3BPJPVTR-C5Nkyl9y.js +0 -36
  88. package/dist/assets/blockDiagram-GPEHLZMM-BilnUBG2.js +0 -132
  89. package/dist/assets/channel-ZgbQ1k0u.js +0 -1
  90. package/dist/assets/chunk-727SXJPM-CZeBRpM9.js +0 -206
  91. package/dist/assets/chunk-AQP2D5EJ-_vmxcBc4.js +0 -231
  92. package/dist/assets/classDiagram-4FO5ZUOK-KXxOorUx.js +0 -1
  93. package/dist/assets/classDiagram-v2-Q7XG4LA2-KXxOorUx.js +0 -1
  94. package/dist/assets/dagre-BM42HDAG-DJlm4fEk.js +0 -4
  95. package/dist/assets/diagram-2AECGRRQ-BmcONQG5.js +0 -43
  96. package/dist/assets/diagram-5GNKFQAL-5JbWGIwd.js +0 -10
  97. package/dist/assets/diagram-KO2AKTUF-DAnIvyI0.js +0 -3
  98. package/dist/assets/diagram-LMA3HP47-Bmh7tvmI.js +0 -24
  99. package/dist/assets/diagram-OG6HWLK6-hnHVMNnk.js +0 -24
  100. package/dist/assets/ganttDiagram-6RSMTGT7-BPlM-BEN.js +0 -292
  101. package/dist/assets/gitGraphDiagram-PVQCEYII-Lv_YTxnb.js +0 -106
  102. package/dist/assets/graph-BBXaecIU.js +0 -331
  103. package/dist/assets/graph-CAnANduQ.js +0 -1
  104. package/dist/assets/index-BwLfbbOu.css +0 -1
  105. package/dist/assets/index-COq21Zym.js +0 -119
  106. package/dist/assets/infoDiagram-5YYISTIA-Dul1vdUm.js +0 -2
  107. package/dist/assets/katex-C5jXJg4s.js +0 -257
  108. package/dist/assets/layout-DGIYPm2g.js +0 -1
  109. package/dist/assets/mobile-Cag5dHlF.css +0 -1
  110. package/dist/assets/mobile-DwmxS_O4.js +0 -17
  111. package/dist/assets/pieDiagram-4H26LBE5-oxNfr2TX.js +0 -30
  112. package/dist/assets/quadrantDiagram-W4KKPZXB-BmZCvD-z.js +0 -7
  113. package/dist/assets/sankeyDiagram-5OEKKPKP-DsnYaawP.js +0 -40
  114. package/dist/assets/sequenceDiagram-3UESZ5HK-Dt5mu3g8.js +0 -162
  115. package/dist/assets/stateDiagram-AJRCARHV-BFR5ZINQ.js +0 -1
  116. package/dist/assets/stateDiagram-v2-BHNVJYJU-BDrQD8fR.js +0 -1
  117. package/dist/assets/wardley-L42UT6IY-CTxW4xow.js +0 -173
  118. package/dist/assets/wardleyDiagram-YWT4CUSO-K8Y1EOvb.js +0 -78
package/lib/build.js CHANGED
@@ -18,6 +18,7 @@ import { createHash, randomUUID } from 'node:crypto';
18
18
  import { StratumMcpClient, StratumError } from './stratum-mcp-client.js';
19
19
  import { runAndNormalize, AgentTimeoutError, UserInterruptError, AgentError } from './result-normalizer.js';
20
20
  import { checkCapabilityViolation } from './capability-checker.js';
21
+ import { preflightCodexWorktreeProbe, codexProbeAbortMessage } from './codex-preflight.js';
21
22
  import { buildStepPrompt, buildRetryPrompt, buildGateContext, clearAmbientContextCache, formatBounceForPrompt } from './step-prompt.js';
22
23
  import { promptGate } from './gate-prompt.js';
23
24
  import { VisionWriter, ServerUnreachableError } from './vision-writer.js';
@@ -36,6 +37,7 @@ import { rtkPrefix } from './rtk.js';
36
37
  import YAML from 'yaml';
37
38
  // feature-json direct imports removed — mutations now go through TrackerProvider (T9)
38
39
  import { loadFeaturesDir, resolveContextPath, resolveRoadmapPath, resolveFeaturesPath } from './project-paths.js';
40
+ import { vocabularyEnabled, injectVocabularyEnsure, tagVocabularyViolations } from './vocabulary-inject.js';
39
41
 
40
42
  // Lazy provider accessor — avoids circular import risk (factory → local-provider
41
43
  // does NOT import build.js, so a static import is safe, but lazy is used for
@@ -50,7 +52,7 @@ import { shouldRunCrossModel, LENS_DEFINITIONS } from './review-lenses.js';
50
52
  import { injectCertInstructions } from './cert-inject.js';
51
53
  import { buildReviewPrompt } from './review-prompt.js';
52
54
  import { normalizeCrossModelResult } from './review-normalize.js';
53
- import { detectTestFramework, scaffoldTestFramework } from './test-bootstrap.js';
55
+ import { detectTestFramework, scaffoldTestFramework, parseTestSummary, deriveTestsPass, isTestFile } from './test-bootstrap.js';
54
56
  import { classifyStepAsTier, evaluateTiers } from './gate-tiers.js';
55
57
  import { mapFilesToRoutes, classifyRoutes, isDocsOnlyDiff } from './qa-scoping.js';
56
58
  import { computeCompositeScore } from './health-score.js';
@@ -473,6 +475,47 @@ function clearPriorDirtyLenses(composeDir) {
473
475
  if (existsSync(p)) unlinkSync(p);
474
476
  }
475
477
 
478
+ // COMP-TEST-BOOTSTRAP-4-1: repo-relative test files touched since HEAD
479
+ // (changed-vs-HEAD + untracked), filtered to test paths. Used to scope the
480
+ // post-coverage test-review pass to the tests this build produced.
481
+ function listChangedTestFiles(cwd) {
482
+ try {
483
+ const out = execSync('git diff --name-only HEAD 2>/dev/null; git ls-files --others --exclude-standard 2>/dev/null', {
484
+ cwd, encoding: 'utf-8', timeout: 5000,
485
+ }).trim();
486
+ if (!out) return [];
487
+ return [...new Set(out.split('\n').map(s => s.trim()).filter(Boolean))].filter(isTestFile);
488
+ } catch {
489
+ return [];
490
+ }
491
+ }
492
+
493
+ // COMP-TEST-BOOTSTRAP-4-1: the pre-coverage test-file snapshot is persisted to a
494
+ // sidecar (not just in-memory context) so a build that RESUMES after coverage and
495
+ // lands on test_review still has the correct baseline — otherwise scope would widen
496
+ // to every changed test file. Mirrors the prior_dirty_lenses sidecar pattern.
497
+ function preCoverageTestsPath(composeDir) {
498
+ return join(composeDir, 'pre_coverage_tests.json');
499
+ }
500
+
501
+ function persistPreCoverageTests(composeDir, files) {
502
+ mkdirSync(composeDir, { recursive: true });
503
+ writeFileSync(preCoverageTestsPath(composeDir), JSON.stringify(files ?? [], null, 2));
504
+ }
505
+
506
+ function loadPreCoverageTests(composeDir) {
507
+ try {
508
+ return new Set(JSON.parse(readFileSync(preCoverageTestsPath(composeDir), 'utf-8')));
509
+ } catch {
510
+ return new Set();
511
+ }
512
+ }
513
+
514
+ function clearPreCoverageTests(composeDir) {
515
+ const p = preCoverageTestsPath(composeDir);
516
+ if (existsSync(p)) unlinkSync(p);
517
+ }
518
+
476
519
  /**
477
520
  * Check whether a process with the given PID is still alive.
478
521
  */
@@ -746,28 +789,38 @@ export async function runBuild(featureCode, opts = {}) {
746
789
  // verifyPipelineIntegrity() re-reads from disk, so we must compare against the original file content.
747
790
  const specFileHash = _sha256(specYaml);
748
791
 
749
- // Apply triage profile to spec — toggle skip_if on skippable steps
750
- if (buildProfile) {
792
+ // Apply spec mutations: triage profile (skip_if toggles) + STRAT-VOCAB-3
793
+ // vocabulary-ensure injection. Parsed once, applied conditionally, stringified
794
+ // once. The tamper hash above is taken from the on-disk file BEFORE mutation,
795
+ // so in-memory edits here don't trip verifyPipelineIntegrity (same pattern the
796
+ // triage profile already relied on).
797
+ const vocabOn = vocabularyEnabled(cwd, composeConfig);
798
+ if (buildProfile || vocabOn) {
751
799
  try {
752
800
  const specObj = YAML.parse(specYaml);
753
- const flows = specObj?.flows ?? {};
754
- // Find the build flow (or first flow)
755
- const flowKey = Object.keys(flows).includes('build') ? 'build' : Object.keys(flows)[0];
756
- const steps = flows[flowKey]?.steps ?? [];
757
- const skippableSteps = ['prd', 'architecture', 'verification', 'report'];
758
- for (const step of steps) {
759
- if (!skippableSteps.includes(step.id)) continue;
760
- const needsKey = `needs_${step.id}`;
761
- if (buildProfile[needsKey] === true) {
762
- // Enable step remove skip_if/skip_reason
763
- delete step.skip_if;
764
- delete step.skip_reason;
765
- } else if (buildProfile[needsKey] === false) {
766
- // Disable step mark as unconditionally skipped
767
- step.skip_if = 'true';
768
- step.skip_reason = `Skipped by triage (tier ${_buildTierLabel})`;
801
+ if (buildProfile) {
802
+ const flows = specObj?.flows ?? {};
803
+ // Find the build flow (or first flow)
804
+ const flowKey = Object.keys(flows).includes('build') ? 'build' : Object.keys(flows)[0];
805
+ const steps = flows[flowKey]?.steps ?? [];
806
+ const skippableSteps = ['prd', 'architecture', 'verification', 'report'];
807
+ for (const step of steps) {
808
+ if (!skippableSteps.includes(step.id)) continue;
809
+ const needsKey = `needs_${step.id}`;
810
+ if (buildProfile[needsKey] === true) {
811
+ // Enable step — remove skip_if/skip_reason
812
+ delete step.skip_if;
813
+ delete step.skip_reason;
814
+ } else if (buildProfile[needsKey] === false) {
815
+ // Disable step mark as unconditionally skipped
816
+ step.skip_if = 'true';
817
+ step.skip_reason = `Skipped by triage (tier ${_buildTierLabel})`;
818
+ }
769
819
  }
770
820
  }
821
+ // STRAT-VOCAB-3: append vocabulary_compliance to the EXECUTED flow's review
822
+ // step (extractFlowName resolves the same flow Stratum will run).
823
+ if (vocabOn) injectVocabularyEnsure(specObj, extractFlowName(specYaml, templateName));
771
824
  specYaml = YAML.stringify(specObj);
772
825
  } catch (err) {
773
826
  // Non-fatal — fall back to unmodified spec
@@ -873,6 +926,33 @@ export async function runBuild(featureCode, opts = {}) {
873
926
  let response;
874
927
  let isFreshStart = true;
875
928
 
929
+ // COMP-CODEX-IMPL: implementer/reviewer roles. A FRESH start derives them from
930
+ // the flag (--codex flips Claude-implements/Codex-reviews → Codex/Claude); the
931
+ // `roles` snapshot below carries those flag-derived values into startFresh.
932
+ //
933
+ // A RESUME instead restores roles from active-build state — the build context is
934
+ // rebuilt locally each invocation, so a `--codex` build resumed WITHOUT the flag
935
+ // must keep its Codex role (else fix-routing + cross-model suppression silently
936
+ // revert). Restoration is gated on ACTUALLY resuming (done in the resume branches
937
+ // below), never eagerly — otherwise a completed `--codex` build's persisted role
938
+ // would bleed into a later plain `compose build <same-code>` that starts fresh
939
+ // (Codex impl-review finding #1).
940
+ let implementerAgent = opts.codex ? 'codex' : 'claude';
941
+ let reviewerAgent = opts.codex ? 'claude' : 'codex';
942
+ const roles = { implementerAgent, reviewerAgent };
943
+ // Restore persisted roles when (and only when) a resume actually happens.
944
+ const restoreRolesFromActive = (src) => {
945
+ if (mode !== 'feature' || !src || !src.implementerAgent) return;
946
+ if (src.implementerAgent !== implementerAgent) {
947
+ console.warn(
948
+ `⚠ Resuming ${featureCode}: build started with implementer=${src.implementerAgent}; ` +
949
+ `honoring the persisted role over the current invocation's flag.`
950
+ );
951
+ }
952
+ implementerAgent = src.implementerAgent;
953
+ reviewerAgent = src.reviewerAgent ?? reviewerAgent;
954
+ };
955
+
876
956
  // COMP-FIX-HARD T8: explicit `--resume` flag (compose fix <code> --resume).
877
957
  // When opts.resumeFlowId is set, skip stratum.plan entirely and resume the
878
958
  // given flow. CLI validates the flowId belongs to this code before calling.
@@ -897,6 +977,9 @@ export async function runBuild(featureCode, opts = {}) {
897
977
  console.log(`Resuming flow ${opts.resumeFlowId} for ${featureCode}...`);
898
978
  response = await stratum.resume(opts.resumeFlowId);
899
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);
900
983
  // Refresh active-build.json so streaming/UI sees this as the live build.
901
984
  const flowName = extractFlowName(specYaml, templateName);
902
985
  writeActiveBuild(dataDir, {
@@ -913,6 +996,9 @@ export async function runBuild(featureCode, opts = {}) {
913
996
  violations: [],
914
997
  status: 'running',
915
998
  resumedAt: new Date().toISOString(),
999
+ // COMP-CODEX-IMPL: carry the (restored or default) roles forward on resume-refresh.
1000
+ implementerAgent,
1001
+ reviewerAgent,
916
1002
  });
917
1003
  } else if (active && active.featureCode === featureCode && active.flowId) {
918
1004
  // Same feature — try to resume or start fresh
@@ -925,10 +1011,10 @@ export async function runBuild(featureCode, opts = {}) {
925
1011
  `Previous build for ${featureCode} was in ${active.mode} mode, ` +
926
1012
  `current invocation is ${mode} mode. Starting fresh.`
927
1013
  );
928
- response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate);
1014
+ response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate, roles);
929
1015
  } else if (active.status && active.status !== 'running') {
930
1016
  console.log(`Previous build ${active.status}. Starting fresh.`);
931
- response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate);
1017
+ response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate, roles);
932
1018
  } else if (active.pid && active.pid !== process.pid && isProcessAlive(active.pid)) {
933
1019
  // Same feature, different live process — block
934
1020
  throw new Error(
@@ -941,10 +1027,12 @@ export async function runBuild(featureCode, opts = {}) {
941
1027
  response = await stratum.resume(active.flowId);
942
1028
  if (isTerminalFlow(response.status)) {
943
1029
  console.log(`Previous build already ${response.status}. Starting fresh.`);
944
- response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate);
1030
+ response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate, roles);
945
1031
  } else {
946
1032
  console.log(`Resuming from step: ${response.step_id}`);
947
1033
  isFreshStart = false;
1034
+ // COMP-CODEX-IMPL: real resume — restore roles from the persisted build.
1035
+ restoreRolesFromActive(active);
948
1036
  }
949
1037
  } catch (err) {
950
1038
  const recoverable = err?.code === 'flow_not_found'
@@ -952,7 +1040,7 @@ export async function runBuild(featureCode, opts = {}) {
952
1040
  || err?.message?.includes('No active flow');
953
1041
  if (recoverable) {
954
1042
  console.log('Previous flow not found. Starting fresh.');
955
- response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate);
1043
+ response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate, roles);
956
1044
  } else {
957
1045
  throw err;
958
1046
  }
@@ -962,7 +1050,54 @@ export async function runBuild(featureCode, opts = {}) {
962
1050
  // Different feature or no active build — start fresh.
963
1051
  // active-build.json is last-writer-wins: concurrent builds for
964
1052
  // different features are allowed; the UI shows the most recent.
965
- response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate);
1053
+ response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate, roles);
1054
+ }
1055
+
1056
+ // COMP-CODEX-IMPL: verify Codex can write inside a detached git worktree (the
1057
+ // execute step's isolation primitive) before any step dispatches. Runs on the
1058
+ // EFFECTIVE role — after resume restoration above — so a resumed Codex build is
1059
+ // probed too (cached per-repo, so resume is normally a no-op); gating on the
1060
+ // pre-restore flag would skip resumed Codex builds (Codex impl-review finding).
1061
+ // The plan/resume above only created the flow object; no agent/worktree work has
1062
+ // happened yet, so aborting here still means we never reach `execute` (Codex
1063
+ // review finding #2). Cached + skippable via COMPOSE_SKIP_CODEX_PROBE.
1064
+ if (implementerAgent === 'codex') {
1065
+ const probe = await preflightCodexWorktreeProbe({
1066
+ cwd: agentCwd,
1067
+ stratum,
1068
+ dataDir,
1069
+ ts: new Date().toISOString().replace(/[:.]/g, '-'),
1070
+ });
1071
+ if (!probe.ok) {
1072
+ // The plan/resume above already persisted feature.json=IN_PROGRESS and
1073
+ // active-build.json='running'. A preflight abort throws before the normal
1074
+ // terminal handlers run, so roll BOTH back here (mirrors the killed/failed
1075
+ // teardown) — otherwise a build that never dispatched a step strands stale
1076
+ // state (Codex impl-review findings).
1077
+ if (!isBugMode) {
1078
+ try {
1079
+ const _bp = await getBuildProvider(cwd);
1080
+ const _feat = await _bp.getFeature(featureCode);
1081
+ if (_feat) await _bp.persistFeatureRaw(featureCode, { ..._feat, status: 'PLANNED' });
1082
+ } catch { /* best-effort */ }
1083
+ }
1084
+ // Identity-guarded active-build downgrade: active-build.json is last-writer-wins
1085
+ // across concurrent feature builds and the probe can sit up to its timeout, so
1086
+ // only downgrade if the on-disk state is still THIS build (same pattern as
1087
+ // persistHealthGateDowngrade) — never clobber a concurrent build's state.
1088
+ try {
1089
+ const cur = readActiveBuild(dataDir);
1090
+ const sameFlow = !cur?.flowId || !response?.flow_id || cur.flowId === response.flow_id;
1091
+ const sameFeature = !cur?.featureCode || cur.featureCode === featureCode;
1092
+ if (cur && sameFlow && sameFeature) {
1093
+ writeActiveBuild(dataDir, { ...cur, status: 'aborted', completedAt: new Date().toISOString() });
1094
+ }
1095
+ } catch { /* best-effort cleanup */ }
1096
+ throw new Error(codexProbeAbortMessage(probe.reason));
1097
+ }
1098
+ if (!probe.cached && !probe.skipped) {
1099
+ console.log(`✓ Codex worktree probe passed — ${probe.reason}`);
1100
+ }
966
1101
  }
967
1102
 
968
1103
  // Update vision state
@@ -971,6 +1106,9 @@ export async function runBuild(featureCode, opts = {}) {
971
1106
  // Stream writer — instantiated after plan/resume succeeds to prevent
972
1107
  // a rejected/duplicate invocation from truncating an active build's stream.
973
1108
  // Only truncate on fresh starts; resumed builds append to existing stream.
1109
+ // COMP-TEST-BOOTSTRAP-4-1: clear any stale pre-coverage test snapshot on a fresh
1110
+ // start (it is per-build; a resume must keep the prior session's snapshot).
1111
+ if (isFreshStart) clearPreCoverageTests(composeDir);
974
1112
  streamWriter = new BuildStreamWriter(composeDir, featureCode, { truncate: isFreshStart });
975
1113
  streamWriter.write({
976
1114
  type: isFreshStart ? 'build_start' : 'build_resume',
@@ -1011,10 +1149,17 @@ export async function runBuild(featureCode, opts = {}) {
1011
1149
  contextDir: contextDirPath,
1012
1150
  stepHistory,
1013
1151
  mode,
1152
+ // COMP-BUILD-QUICK-1: the pipeline template (e.g. 'build-quick') so the ship
1153
+ // step can stamp built_via onto feature.json for the validator's exemption.
1154
+ templateName,
1014
1155
  enforceMcpForFeatureMgmt,
1015
1156
  build_id,
1016
1157
  buildStartedAt,
1017
1158
  featuresDir,
1159
+ // COMP-CODEX-IMPL: roles drive fix-routing (fixer = implementer) and Codex
1160
+ // self-review suppression. Restored from active-build state on resume (above).
1161
+ implementerAgent,
1162
+ reviewerAgent,
1018
1163
  ...(isBugMode ? { bug_code: featureCode } : {}),
1019
1164
  };
1020
1165
 
@@ -1603,6 +1748,14 @@ export async function runBuild(featureCode, opts = {}) {
1603
1748
  // If no test framework is detected and no test directory exists, scaffold first.
1604
1749
  // If the project has no tests at all (truly empty), skip coverage gracefully.
1605
1750
  if (childFlowName === 'coverage_check') {
1751
+ // COMP-TEST-BOOTSTRAP-4-1: snapshot test files present BEFORE coverage runs,
1752
+ // so the later test_review step can review only the tests coverage generates.
1753
+ // Persisted to a sidecar so a resume landing on test_review keeps the baseline.
1754
+ {
1755
+ const preCov = listChangedTestFiles(agentCwd);
1756
+ context._preCoverageTestFiles = new Set(preCov);
1757
+ persistPreCoverageTests(composeDir, preCov);
1758
+ }
1606
1759
  const detected = detectTestFramework(agentCwd);
1607
1760
  const hasTestDir = existsSync(join(agentCwd, 'test')) ||
1608
1761
  existsSync(join(agentCwd, 'tests')) ||
@@ -1650,6 +1803,37 @@ export async function runBuild(featureCode, opts = {}) {
1650
1803
  }
1651
1804
  }
1652
1805
 
1806
+ // COMP-TEST-BOOTSTRAP-4-1: the test_review step reviews the test files the
1807
+ // coverage step produced this build (the implementation review runs BEFORE
1808
+ // coverage, so generated tests are never otherwise reviewed). Scope to NEW
1809
+ // test files (not present before coverage); skip the step entirely when none.
1810
+ if (childFlowName === 'test_review') {
1811
+ // Resume-safe: fall back to the sidecar when the in-memory snapshot is absent
1812
+ // (a build resumed after coverage rebuilds context fresh).
1813
+ const preCoverage = context._preCoverageTestFiles ?? loadPreCoverageTests(composeDir);
1814
+ const generatedTests = listChangedTestFiles(agentCwd).filter(f => !preCoverage.has(f));
1815
+ if (generatedTests.length === 0) {
1816
+ progress.subFlowStep('test_review', 'skipped — no generated tests');
1817
+ const skipResult = { clean: true, summary: 'No generated test files this build — test review skipped', findings: [] };
1818
+ buildSignals.test_review = skipResult;
1819
+ streamWriter?.write({ type: 'test_review', clean: true, summary: skipResult.summary, findings: [] });
1820
+ verifyPipelineIntegrity(specPath, specFileHash);
1821
+ response = await stratum.stepDone(parentFlowId, parentStepId, { status: 'ok', output: skipResult });
1822
+ continue;
1823
+ }
1824
+ // Inject the exact file list into the review intent (child-flow inputs are
1825
+ // declarative; intent-append is the established runtime-injection pattern).
1826
+ if (response.child_step?.intent !== undefined) {
1827
+ response = {
1828
+ ...response,
1829
+ child_step: {
1830
+ ...response.child_step,
1831
+ intent: `${response.child_step.intent}\n\nGenerated test files to review (written this build):\n${generatedTests.map(f => `- ${f}`).join('\n')}`,
1832
+ },
1833
+ };
1834
+ }
1835
+ }
1836
+
1653
1837
  let childResult = await executeChildFlow(
1654
1838
  response, stratum, context,
1655
1839
  visionWriter, itemId, dataDir, opts.gateOpts ?? {}, progress,
@@ -1658,7 +1842,12 @@ export async function runBuild(featureCode, opts = {}) {
1658
1842
 
1659
1843
  // STRAT-REV-7: After review child flow completes, run cross-model synthesis if diff is large.
1660
1844
  // If cross-model ran, skip the pipeline's separate codex_review step (avoid duplicate Codex pass).
1661
- if (childFlowName === 'parallel_review' && childResult?.output && (context.filesChanged?.length ?? 0) > 0) {
1845
+ // COMP-CODEX-IMPL: when Codex is the IMPLEMENTER, this Codex second-opinion pass
1846
+ // would be Codex reviewing its own diff — suppress it. The Claude parallel_review
1847
+ // lenses already provide cross-model review of Codex's implementation.
1848
+ if (childFlowName === 'parallel_review' && context.implementerAgent === 'codex') {
1849
+ streamWriter?.write({ type: 'cross_model_review', status: 'codex_skipped', reason: 'Codex is the implementer — its own diff is reviewed by Claude lenses, not a Codex second opinion' });
1850
+ } else if (childFlowName === 'parallel_review' && childResult?.output && (context.filesChanged?.length ?? 0) > 0) {
1662
1851
  const mergedResult = childResult.output;
1663
1852
  const synthesized = await runCrossModelReview(
1664
1853
  mergedResult,
@@ -1686,6 +1875,18 @@ export async function runBuild(featureCode, opts = {}) {
1686
1875
  buildSignals.test_coverage = childResult.output;
1687
1876
  } else if (childFlowName === 'parallel_review' && childResult?.output != null) {
1688
1877
  buildSignals.review_findings = childResult.output;
1878
+ } else if (childFlowName === 'test_review' && childResult?.output != null) {
1879
+ // COMP-TEST-BOOTSTRAP-4-1: advisory — never blocks ship. Surface findings on
1880
+ // the build stream (buildSignals alone is not read by any consumer) so the
1881
+ // cockpit/report can show them.
1882
+ const tr = childResult.output;
1883
+ buildSignals.test_review = tr;
1884
+ streamWriter?.write({
1885
+ type: 'test_review',
1886
+ clean: tr.clean ?? true,
1887
+ summary: typeof tr.summary === 'string' ? tr.summary : '',
1888
+ findings: Array.isArray(tr.findings) ? tr.findings : [],
1889
+ });
1689
1890
  }
1690
1891
 
1691
1892
  // Report child completion envelope to parent flow step.
@@ -1748,7 +1949,8 @@ export async function runBuild(featureCode, opts = {}) {
1748
1949
  }
1749
1950
  progress.retry('build', stepId, response.agent);
1750
1951
  const violations = response.violations ?? [];
1751
- if (violations.length > 0) progress.findings(violations);
1952
+ // STRAT-VOCAB-3: surface vocabulary violations as must-fix (they block ship).
1953
+ if (violations.length > 0) progress.findings(tagVocabularyViolations(violations));
1752
1954
  // STRAT-REV-5: defensive fallback — for non-flow-step paths named 'review'.
1753
1955
  // For flow-steps the ensure fires inside executeChildFlow (see handler below).
1754
1956
  if ((response.step_id ?? stepId) === 'review') {
@@ -2277,6 +2479,11 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
2277
2479
  ? `docs/bugs/${featureCode}`
2278
2480
  : `${featuresDir}/${featureCode}`;
2279
2481
 
2482
+ // COMP-BUILD-QUICK-1: when a feature was built via the trimmed quick lifecycle
2483
+ // (which omits the report phase by design), stamp built_via onto feature.json so
2484
+ // the validator exempts it from MISSING_COMPLETION_REPORT. Null for normal builds.
2485
+ const builtVia = context?.templateName === 'build-quick' ? 'build-quick' : null;
2486
+
2280
2487
  try {
2281
2488
  // 0. Check if we're in a git repository — if not, skip git operations
2282
2489
  let isGitRepo = false;
@@ -2297,9 +2504,12 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
2297
2504
  await recordCompletion(cwd, {
2298
2505
  feature_code: featureCode,
2299
2506
  // commit_sha omitted — non-git workspace, stamped with the null-SHA
2507
+ // COMP-TEST-BOOTSTRAP-4: this path returns before the test run, so
2508
+ // there is no parsed signal — degrade to true (no block).
2300
2509
  tests_pass: true,
2301
2510
  files_changed: [],
2302
2511
  notes: description.split('\n')[0].slice(0, 72),
2512
+ ...(builtVia ? { built_via: builtVia } : {}),
2303
2513
  });
2304
2514
  } catch (err) {
2305
2515
  completionWarning = `completion record failed (${err.code || 'UNKNOWN'}): ${err.message}`;
@@ -2319,12 +2529,21 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
2319
2529
 
2320
2530
  // 1. Run feature-relevant tests (best-effort — don't block ship on test infra issues)
2321
2531
  if (progress) progress.toolUse('ship', 'Running tests...');
2532
+ // COMP-TEST-BOOTSTRAP-4: parse the run output into a structured signal and
2533
+ // derive a real tests_pass for the completion attestation. Degrades to
2534
+ // `true` (no block) whenever the output can't be parsed — see deriveTestsPass.
2535
+ let testSummary = { test_count: 0, pass_rate: 0, parsed: false };
2322
2536
  try {
2323
2537
  // COMP-TEST-BOOTSTRAP item 128: use detected test command instead of hard-coded npm test
2324
2538
  const testFramework = detectTestFramework(agentCwd);
2325
2539
  const testCommand = testFramework?.command ?? 'npm test';
2326
- execSync(`${testCommand} 2>&1 || true`, { cwd: agentCwd, encoding: 'utf-8', timeout: 120_000 });
2327
- } catch { /* test runner not available or timed out — proceed */ }
2540
+ const testOutput = execSync(`${testCommand} 2>&1 || true`, { cwd: agentCwd, encoding: 'utf-8', timeout: 120_000 });
2541
+ testSummary = parseTestSummary(testFramework?.framework, testOutput);
2542
+ } catch { /* test runner not available or timed out — proceed (testSummary stays unparsed) */ }
2543
+ const testsPass = deriveTestsPass(testSummary);
2544
+ if (progress && testSummary.parsed) {
2545
+ progress.toolUse('ship', `Tests: ${testSummary.test_count} run, ${testSummary.pass_rate}% passing`);
2546
+ }
2328
2547
 
2329
2548
  // 2. Collect files to stage
2330
2549
  const filesToStage = new Set();
@@ -2508,9 +2727,12 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
2508
2727
  await recordCompletion(cwd, {
2509
2728
  feature_code: featureCode,
2510
2729
  commit_sha: sha,
2511
- tests_pass: true,
2730
+ // COMP-TEST-BOOTSTRAP-4: derived from the parsed test run above
2731
+ // (true when unparseable — never a false block).
2732
+ tests_pass: testsPass,
2512
2733
  files_changed: filesChanged,
2513
2734
  notes: shortDesc,
2735
+ ...(builtVia ? { built_via: builtVia } : {}),
2514
2736
  });
2515
2737
  if (progress) progress.toolUse('ship', `Recorded completion for ${featureCode}`);
2516
2738
  } catch (err) {
@@ -2993,7 +3215,8 @@ export async function executeChildFlow(
2993
3215
  });
2994
3216
  }
2995
3217
  const violations = resp.violations ?? [];
2996
- if (violations.length > 0 && progress) progress.findings(violations);
3218
+ // STRAT-VOCAB-3: surface vocabulary violations as must-fix (they block ship).
3219
+ if (violations.length > 0 && progress) progress.findings(tagVocabularyViolations(violations));
2997
3220
  // STRAT-REV-5: flow-step ensures fire here (inside executeChildFlow), not in the
2998
3221
  // main dispatch loop — persist dirty lenses so triage selectively re-runs on retry.
2999
3222
  if (resp.step_id === 'review') {
@@ -3004,7 +3227,14 @@ export async function executeChildFlow(
3004
3227
  }
3005
3228
  }
3006
3229
  const stepAgent = resp.agent ?? 'claude';
3007
- const fixAgent = stepAgent === 'codex' ? 'claude' : stepAgent;
3230
+ // COMP-CODEX-IMPL: the fixer is the IMPLEMENTER whoever wrote the code fixes
3231
+ // the review findings against it. This replaces the old hardcoded
3232
+ // `stepAgent === 'codex' ? 'claude' : stepAgent` swap, which assumed codex is
3233
+ // always the reviewer and claude always the implementer; that assumption inverts
3234
+ // under --codex. Default (implementer=claude) reproduces the old outcome exactly:
3235
+ // a codex reviewer's finding is fixed by claude, the implementer. (stepAgent is
3236
+ // still the reviewer, used for the retry display below.)
3237
+ const fixAgent = context.implementerAgent || 'claude';
3008
3238
 
3009
3239
  if (progress) {
3010
3240
  progress.fix(childFlowName, fixAgent, resp.step_id);
@@ -4385,7 +4615,7 @@ export async function executeParallelDispatch(
4385
4615
  }
4386
4616
  }
4387
4617
 
4388
- export async function startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode = 'feature', preMergeGate) {
4618
+ export async function startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode = 'feature', preMergeGate, roles) {
4389
4619
  const flowName = extractFlowName(specYaml, templateName);
4390
4620
  console.log(`Starting ${flowName} for ${featureCode}...`);
4391
4621
  // COMP-FIX-HARD T4: bug-mode flows take input as { task: <description> }
@@ -4394,9 +4624,14 @@ export async function startFresh(stratum, specYaml, featureCode, description, da
4394
4624
  // COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY (D5): fold pre_merge_gate into the
4395
4625
  // feature plan envelope ONLY when resolved (undefined ⇒ key omitted, not [])
4396
4626
  // so the default-OFF path is byte-identical to pre-feature behavior.
4627
+ // COMP-CODEX-IMPL: feature flows always carry implementer_agent/reviewer_agent so
4628
+ // the interpolated execute/review agents (STRAT-AGENT-INTERP) always resolve.
4629
+ // Defaults reproduce today's behavior (claude implements, codex reviews) byte-identically.
4630
+ const implementerAgent = roles?.implementerAgent ?? 'claude';
4631
+ const reviewerAgent = roles?.reviewerAgent ?? 'codex';
4397
4632
  const planInputs = mode === 'bug'
4398
4633
  ? { task: description }
4399
- : { featureCode, description, ...(preMergeGate !== undefined ? { pre_merge_gate: preMergeGate } : {}) };
4634
+ : { featureCode, description, implementer_agent: implementerAgent, reviewer_agent: reviewerAgent, ...(preMergeGate !== undefined ? { pre_merge_gate: preMergeGate } : {}) };
4400
4635
  const response = await stratum.plan(specYaml, flowName, planInputs);
4401
4636
 
4402
4637
  writeActiveBuild(dataDir, {
@@ -4413,6 +4648,10 @@ export async function startFresh(stratum, specYaml, featureCode, description, da
4413
4648
  violations: [],
4414
4649
  status: 'running',
4415
4650
  startedAt: new Date().toISOString(),
4651
+ // COMP-CODEX-IMPL: roles are durable across resume (the build context is rebuilt
4652
+ // locally each invocation, so a resume without --codex must restore them).
4653
+ implementerAgent,
4654
+ reviewerAgent,
4416
4655
  });
4417
4656
 
4418
4657
  return response;