@smartmemory/compose 0.2.49-beta → 0.2.51-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/.claude/skills/compose/SKILL.md +24 -1
  2. package/.claude/skills/compose/references/hermes-tools.md +80 -0
  3. package/README.md +1 -1
  4. package/bin/compose.js +340 -70
  5. package/dist/assets/App-CT0vXPgd.js +724 -0
  6. package/dist/assets/_baseUniq-CsOHc_iS.js +1 -0
  7. package/dist/assets/{arc-B1TqAC_R.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-Dc6swoQo.js → c4Diagram-AHTNJAMY-nBIRLJTv.js} +1 -1
  12. package/dist/assets/channel-Dd4XaiYv.js +1 -0
  13. package/dist/assets/{chunk-4BX2VUAB-CdqXgd68.js → chunk-4BX2VUAB-CNmGyhrp.js} +1 -1
  14. package/dist/assets/chunk-4TB4RGXK-MLT7I7w4.js +206 -0
  15. package/dist/assets/{chunk-55IACEB6-D7VauOmU.js → chunk-55IACEB6-D8KuKVLa.js} +1 -1
  16. package/dist/assets/{chunk-2J33WTMH-6HKDSnOw.js → chunk-EDXVE4YY-TPlt1bS2.js} +1 -1
  17. package/dist/assets/{chunk-FMBD7UC4-C1Z5m8DP.js → chunk-FMBD7UC4-tYY8xcjr.js} +1 -1
  18. package/dist/assets/chunk-OYMX7WX6-yBPwT1AT.js +231 -0
  19. package/dist/assets/{chunk-QZHKN3VN-DY9GU2uD.js → chunk-QZHKN3VN-B4Wfybww.js} +1 -1
  20. package/dist/assets/{chunk-ND2GUHAM-DmqZMEQe.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-B34g9xzV.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-C05_Iqmb.js → erDiagram-SMLLAGMA-CtxJi57K.js} +1 -1
  31. package/dist/assets/{flowDiagram-I6XJVG4X-lXpnb-Ch.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-CgdsWdgu.js → ishikawaDiagram-UXIWVN3A-DWx-7BBy.js} +5 -5
  40. package/dist/assets/{journeyDiagram-JHISSGLW-CyvHmE3z.js → journeyDiagram-VCZTEJTY-C42hXNha.js} +1 -1
  41. package/dist/assets/{kanban-definition-UN3LZRKU-Bqjdyv0T.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-DecnRXf0.js → linear-D158F7OT.js} +1 -1
  45. package/dist/assets/min-hESxN-c0.js +1 -0
  46. package/dist/assets/{mindmap-definition-RKZ34NQL-DM1x9vNY.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-DD-Gg_y5.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-BPm2AEQj.js → timeline-definition-GMOUNBTQ-k_0vJQAK.js} +1 -1
  57. package/dist/assets/{vennDiagram-CIIHVFJN-DQCDWwlj.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-Ba05SXXF.js → xychartDiagram-5P7HB3ND-CynkZcUb.js} +1 -1
  61. package/dist/index.html +4 -4
  62. package/lib/build-all.js +2 -1
  63. package/lib/build.js +363 -38
  64. package/lib/checkpoint/checkpoint-writer.js +3 -2
  65. package/lib/codex-preflight.js +151 -0
  66. package/lib/completion-writer.js +59 -15
  67. package/lib/feature-json.js +14 -3
  68. package/lib/feature-validator.js +27 -10
  69. package/lib/feature-write-guard.js +5 -7
  70. package/lib/feature-writer.js +3 -3
  71. package/lib/followup-writer.js +2 -2
  72. package/lib/get-roadmap.js +2 -2
  73. package/lib/gsd.js +2 -1
  74. package/lib/hooks-status.js +135 -0
  75. package/lib/ideabox.js +3 -2
  76. package/lib/install-agent-defs.js +22 -0
  77. package/lib/journal-writer.js +3 -3
  78. package/lib/migrate-anon.js +277 -0
  79. package/lib/migrate-roadmap.js +2 -3
  80. package/lib/paths-core.js +47 -0
  81. package/lib/project-paths.js +46 -37
  82. package/lib/roadmap-gen.js +3 -4
  83. package/lib/roadmap-graph/collect.js +5 -5
  84. package/lib/roadmap-graph/index.js +2 -2
  85. package/lib/roadmap-parser.js +1 -1
  86. package/lib/state-migrations.js +3 -3
  87. package/lib/test-bootstrap.js +165 -2
  88. package/lib/tracker/local-provider.js +2 -2
  89. package/lib/triage.js +7 -5
  90. package/lib/vocabulary-inject.js +103 -0
  91. package/lib/xref-push.js +4 -4
  92. package/lib/xref-sync.js +4 -4
  93. package/package.json +2 -1
  94. package/pipelines/build-quick.stratum.yaml +433 -0
  95. package/pipelines/build.stratum.yaml +60 -3
  96. package/scripts/watch-server.sh +32 -0
  97. package/server/build-routes.js +89 -9
  98. package/server/build-stream-bridge.js +12 -0
  99. package/server/compose-mcp-tools.js +73 -1
  100. package/server/compose-mcp.js +27 -0
  101. package/server/design-routes.js +9 -4
  102. package/server/drift-axes.js +5 -3
  103. package/server/feature-scaffold-routes.js +82 -0
  104. package/server/feature-scan.js +8 -2
  105. package/server/file-watcher.js +13 -7
  106. package/server/health-routes.js +261 -0
  107. package/server/ideabox-routes.js +12 -11
  108. package/server/project-root.js +7 -4
  109. package/server/qa-scope-routes.js +96 -0
  110. package/server/session-routes.js +2 -2
  111. package/server/stratum-sync.js +85 -9
  112. package/server/validate-routes.js +70 -0
  113. package/server/vision-routes.js +46 -2
  114. package/server/vision-server.js +25 -3
  115. package/server/vision-utils.js +23 -4
  116. package/dist/assets/App-DOerhoDp.js +0 -706
  117. package/dist/assets/architectureDiagram-3BPJPVTR-C4hfxNOw.js +0 -36
  118. package/dist/assets/blockDiagram-GPEHLZMM-DRqkGncy.js +0 -132
  119. package/dist/assets/channel-KsY1x-7s.js +0 -1
  120. package/dist/assets/chunk-727SXJPM-CeKpXDif.js +0 -206
  121. package/dist/assets/chunk-AQP2D5EJ-y2XL-YHV.js +0 -231
  122. package/dist/assets/classDiagram-4FO5ZUOK-zDEDoVPk.js +0 -1
  123. package/dist/assets/classDiagram-v2-Q7XG4LA2-zDEDoVPk.js +0 -1
  124. package/dist/assets/dagre-BM42HDAG-klZ_GOcw.js +0 -4
  125. package/dist/assets/diagram-2AECGRRQ-D0FmM6Wf.js +0 -43
  126. package/dist/assets/diagram-5GNKFQAL-DYuhby6q.js +0 -10
  127. package/dist/assets/diagram-KO2AKTUF-CCtQZgal.js +0 -3
  128. package/dist/assets/diagram-LMA3HP47-Cd1OqEln.js +0 -24
  129. package/dist/assets/diagram-OG6HWLK6-CiJrhoxo.js +0 -24
  130. package/dist/assets/ganttDiagram-6RSMTGT7-EPbL_2Hr.js +0 -292
  131. package/dist/assets/gitGraphDiagram-PVQCEYII-COCoF_vW.js +0 -106
  132. package/dist/assets/graph-BBXaecIU.js +0 -331
  133. package/dist/assets/graph-CAnANduQ.js +0 -1
  134. package/dist/assets/index-BwLfbbOu.css +0 -1
  135. package/dist/assets/index-DlgxA8dX.js +0 -123
  136. package/dist/assets/infoDiagram-5YYISTIA-B1sJw6j5.js +0 -2
  137. package/dist/assets/katex-C5jXJg4s.js +0 -257
  138. package/dist/assets/layout-DGIYPm2g.js +0 -1
  139. package/dist/assets/mobile-Cag5dHlF.css +0 -1
  140. package/dist/assets/mobile-DwmxS_O4.js +0 -17
  141. package/dist/assets/pieDiagram-4H26LBE5-CRtmCvwV.js +0 -30
  142. package/dist/assets/quadrantDiagram-W4KKPZXB-CsBLWg27.js +0 -7
  143. package/dist/assets/sankeyDiagram-5OEKKPKP-BPdMN8UU.js +0 -40
  144. package/dist/assets/sequenceDiagram-3UESZ5HK-D4AENdze.js +0 -162
  145. package/dist/assets/stateDiagram-AJRCARHV-DDplpCf_.js +0 -1
  146. package/dist/assets/stateDiagram-v2-BHNVJYJU-J4t9AVzF.js +0 -1
  147. package/dist/assets/wardley-L42UT6IY-WERqOmJt.js +0 -173
  148. package/dist/assets/wardleyDiagram-YWT4CUSO-5teUSOiI.js +0 -78
package/lib/build.js CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, renameSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs';
12
- import { join, resolve, dirname } from 'node:path';
12
+ import { join, resolve, dirname, relative } from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { homedir, tmpdir } from 'node:os';
15
15
  import { execSync, execFileSync } from 'node:child_process';
@@ -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';
@@ -35,7 +36,8 @@ import { rtkPrefix } from './rtk.js';
35
36
 
36
37
  import YAML from 'yaml';
37
38
  // feature-json direct imports removed — mutations now go through TrackerProvider (T9)
38
- import { loadFeaturesDir } from './project-paths.js';
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
  */
@@ -605,10 +648,19 @@ export async function runBuild(featureCode, opts = {}) {
605
648
  // `docs/features/<featureCode>/`. Callers must use this (not inline
606
649
  // string concatenation) so the bug-mode path stays in sync.
607
650
  // COMP-MCP-MIGRATION-2: feature-mode honors paths.features override.
651
+ // COMP-PATHS-EXTERNAL D7: two representations on purpose.
652
+ // - `featuresDir` stays RELATIVE (loadFeaturesDir): it flows into
653
+ // context.featuresDir → the MCP-enforcement guard, which matches against
654
+ // repo-relative `git status` output. External artifacts live in another
655
+ // repo and are out of that guard's scope by construction; making this
656
+ // absolute would break the relative match. triage consumers are
657
+ // absolute-safe (resolvePathValue) so the relative value is fine there too.
658
+ // - `resolveItemDir` resolves the actual FILESYSTEM dir via
659
+ // resolveFeaturesPath (absolute — handles absolute/../-escaping config).
608
660
  const featuresDir = loadFeaturesDir(cwd);
609
661
  const resolveItemDir = (code) => isBugMode
610
662
  ? join(cwd, 'docs', 'bugs', code)
611
- : join(cwd, featuresDir, code);
663
+ : join(resolveFeaturesPath(cwd), code);
612
664
 
613
665
  // COMP-MCP-MIGRATION-1: per-build correlation ID stamped onto every audit
614
666
  // row written during this run, so `executeShipStep`'s pre-stage scan can
@@ -663,7 +715,7 @@ export async function runBuild(featureCode, opts = {}) {
663
715
  }
664
716
  let composeConfig = {};
665
717
  try { composeConfig = JSON.parse(readFileSync(configPath, 'utf-8')); } catch { /* use defaults */ }
666
- const contextDirPath = join(cwd, composeConfig.paths?.context ?? 'docs/context');
718
+ const contextDirPath = resolveContextPath(cwd);
667
719
 
668
720
  // COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY (D5): per-task pre-merge gate is opt-in.
669
721
  // Resolve ONCE and thread into startFresh's planInputs only when the capability
@@ -737,28 +789,38 @@ export async function runBuild(featureCode, opts = {}) {
737
789
  // verifyPipelineIntegrity() re-reads from disk, so we must compare against the original file content.
738
790
  const specFileHash = _sha256(specYaml);
739
791
 
740
- // Apply triage profile to spec — toggle skip_if on skippable steps
741
- 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) {
742
799
  try {
743
800
  const specObj = YAML.parse(specYaml);
744
- const flows = specObj?.flows ?? {};
745
- // Find the build flow (or first flow)
746
- const flowKey = Object.keys(flows).includes('build') ? 'build' : Object.keys(flows)[0];
747
- const steps = flows[flowKey]?.steps ?? [];
748
- const skippableSteps = ['prd', 'architecture', 'verification', 'report'];
749
- for (const step of steps) {
750
- if (!skippableSteps.includes(step.id)) continue;
751
- const needsKey = `needs_${step.id}`;
752
- if (buildProfile[needsKey] === true) {
753
- // Enable step remove skip_if/skip_reason
754
- delete step.skip_if;
755
- delete step.skip_reason;
756
- } else if (buildProfile[needsKey] === false) {
757
- // Disable step mark as unconditionally skipped
758
- step.skip_if = 'true';
759
- 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
+ }
760
819
  }
761
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));
762
824
  specYaml = YAML.stringify(specObj);
763
825
  } catch (err) {
764
826
  // Non-fatal — fall back to unmodified spec
@@ -864,6 +926,33 @@ export async function runBuild(featureCode, opts = {}) {
864
926
  let response;
865
927
  let isFreshStart = true;
866
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
+
867
956
  // COMP-FIX-HARD T8: explicit `--resume` flag (compose fix <code> --resume).
868
957
  // When opts.resumeFlowId is set, skip stratum.plan entirely and resume the
869
958
  // given flow. CLI validates the flowId belongs to this code before calling.
@@ -888,6 +977,9 @@ export async function runBuild(featureCode, opts = {}) {
888
977
  console.log(`Resuming flow ${opts.resumeFlowId} for ${featureCode}...`);
889
978
  response = await stratum.resume(opts.resumeFlowId);
890
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);
891
983
  // Refresh active-build.json so streaming/UI sees this as the live build.
892
984
  const flowName = extractFlowName(specYaml, templateName);
893
985
  writeActiveBuild(dataDir, {
@@ -904,6 +996,9 @@ export async function runBuild(featureCode, opts = {}) {
904
996
  violations: [],
905
997
  status: 'running',
906
998
  resumedAt: new Date().toISOString(),
999
+ // COMP-CODEX-IMPL: carry the (restored or default) roles forward on resume-refresh.
1000
+ implementerAgent,
1001
+ reviewerAgent,
907
1002
  });
908
1003
  } else if (active && active.featureCode === featureCode && active.flowId) {
909
1004
  // Same feature — try to resume or start fresh
@@ -916,10 +1011,10 @@ export async function runBuild(featureCode, opts = {}) {
916
1011
  `Previous build for ${featureCode} was in ${active.mode} mode, ` +
917
1012
  `current invocation is ${mode} mode. Starting fresh.`
918
1013
  );
919
- response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate);
1014
+ response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate, roles);
920
1015
  } else if (active.status && active.status !== 'running') {
921
1016
  console.log(`Previous build ${active.status}. Starting fresh.`);
922
- response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate);
1017
+ response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate, roles);
923
1018
  } else if (active.pid && active.pid !== process.pid && isProcessAlive(active.pid)) {
924
1019
  // Same feature, different live process — block
925
1020
  throw new Error(
@@ -932,10 +1027,12 @@ export async function runBuild(featureCode, opts = {}) {
932
1027
  response = await stratum.resume(active.flowId);
933
1028
  if (isTerminalFlow(response.status)) {
934
1029
  console.log(`Previous build already ${response.status}. Starting fresh.`);
935
- response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate);
1030
+ response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate, roles);
936
1031
  } else {
937
1032
  console.log(`Resuming from step: ${response.step_id}`);
938
1033
  isFreshStart = false;
1034
+ // COMP-CODEX-IMPL: real resume — restore roles from the persisted build.
1035
+ restoreRolesFromActive(active);
939
1036
  }
940
1037
  } catch (err) {
941
1038
  const recoverable = err?.code === 'flow_not_found'
@@ -943,7 +1040,7 @@ export async function runBuild(featureCode, opts = {}) {
943
1040
  || err?.message?.includes('No active flow');
944
1041
  if (recoverable) {
945
1042
  console.log('Previous flow not found. Starting fresh.');
946
- response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate);
1043
+ response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate, roles);
947
1044
  } else {
948
1045
  throw err;
949
1046
  }
@@ -953,7 +1050,54 @@ export async function runBuild(featureCode, opts = {}) {
953
1050
  // Different feature or no active build — start fresh.
954
1051
  // active-build.json is last-writer-wins: concurrent builds for
955
1052
  // different features are allowed; the UI shows the most recent.
956
- 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
+ }
957
1101
  }
958
1102
 
959
1103
  // Update vision state
@@ -962,6 +1106,9 @@ export async function runBuild(featureCode, opts = {}) {
962
1106
  // Stream writer — instantiated after plan/resume succeeds to prevent
963
1107
  // a rejected/duplicate invocation from truncating an active build's stream.
964
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);
965
1112
  streamWriter = new BuildStreamWriter(composeDir, featureCode, { truncate: isFreshStart });
966
1113
  streamWriter.write({
967
1114
  type: isFreshStart ? 'build_start' : 'build_resume',
@@ -1002,10 +1149,17 @@ export async function runBuild(featureCode, opts = {}) {
1002
1149
  contextDir: contextDirPath,
1003
1150
  stepHistory,
1004
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,
1005
1155
  enforceMcpForFeatureMgmt,
1006
1156
  build_id,
1007
1157
  buildStartedAt,
1008
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,
1009
1163
  ...(isBugMode ? { bug_code: featureCode } : {}),
1010
1164
  };
1011
1165
 
@@ -1594,6 +1748,14 @@ export async function runBuild(featureCode, opts = {}) {
1594
1748
  // If no test framework is detected and no test directory exists, scaffold first.
1595
1749
  // If the project has no tests at all (truly empty), skip coverage gracefully.
1596
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
+ }
1597
1759
  const detected = detectTestFramework(agentCwd);
1598
1760
  const hasTestDir = existsSync(join(agentCwd, 'test')) ||
1599
1761
  existsSync(join(agentCwd, 'tests')) ||
@@ -1641,6 +1803,37 @@ export async function runBuild(featureCode, opts = {}) {
1641
1803
  }
1642
1804
  }
1643
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
+
1644
1837
  let childResult = await executeChildFlow(
1645
1838
  response, stratum, context,
1646
1839
  visionWriter, itemId, dataDir, opts.gateOpts ?? {}, progress,
@@ -1649,7 +1842,12 @@ export async function runBuild(featureCode, opts = {}) {
1649
1842
 
1650
1843
  // STRAT-REV-7: After review child flow completes, run cross-model synthesis if diff is large.
1651
1844
  // If cross-model ran, skip the pipeline's separate codex_review step (avoid duplicate Codex pass).
1652
- 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) {
1653
1851
  const mergedResult = childResult.output;
1654
1852
  const synthesized = await runCrossModelReview(
1655
1853
  mergedResult,
@@ -1677,6 +1875,18 @@ export async function runBuild(featureCode, opts = {}) {
1677
1875
  buildSignals.test_coverage = childResult.output;
1678
1876
  } else if (childFlowName === 'parallel_review' && childResult?.output != null) {
1679
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
+ });
1680
1890
  }
1681
1891
 
1682
1892
  // Report child completion envelope to parent flow step.
@@ -1739,7 +1949,8 @@ export async function runBuild(featureCode, opts = {}) {
1739
1949
  }
1740
1950
  progress.retry('build', stepId, response.agent);
1741
1951
  const violations = response.violations ?? [];
1742
- 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));
1743
1954
  // STRAT-REV-5: defensive fallback — for non-flow-step paths named 'review'.
1744
1955
  // For flow-steps the ensure fires inside executeChildFlow (see handler below).
1745
1956
  if ((response.step_id ?? stepId) === 'review') {
@@ -2219,6 +2430,43 @@ export function maybeEmitSectionsAfterPlanGate(stepId, featureDir, opts = {}) {
2219
2430
  // Ship step — runs git commit in-process (not via agent)
2220
2431
  // ---------------------------------------------------------------------------
2221
2432
 
2433
+ /**
2434
+ * The git repository toplevel that contains `dir`, or null if `dir` is not
2435
+ * inside any git work tree. Used so ship decides commit ownership per-file by
2436
+ * containing repo, not by assuming the workspace root == the repo
2437
+ * (COMP-PATHS-EXTERNAL D6a).
2438
+ */
2439
+ function gitToplevel(dir) {
2440
+ try {
2441
+ return execSync('git rev-parse --show-toplevel', {
2442
+ cwd: dir, encoding: 'utf-8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'],
2443
+ }).trim() || null;
2444
+ } catch { return null; }
2445
+ }
2446
+
2447
+ /**
2448
+ * For each owned artifact (ROADMAP, the feature folder) that resolves into a
2449
+ * git repo OTHER than the build's repo, log a one-line "commit it there"
2450
+ * notice. v1 does not auto-commit other repos (that is COMP-PATHS-EXTERNAL-1).
2451
+ * Artifacts in the build's own repo, or in no repo at all, produce no notice.
2452
+ */
2453
+ function noticeExternalArtifacts(cwd, featureCode, buildToplevel) {
2454
+ try {
2455
+ const candidates = [
2456
+ ['ROADMAP.md', resolveRoadmapPath(cwd)],
2457
+ ['feature folder', join(resolveFeaturesPath(cwd), featureCode)],
2458
+ ];
2459
+ for (const [label, abs] of candidates) {
2460
+ if (!existsSync(abs)) continue;
2461
+ const top = gitToplevel(dirname(abs));
2462
+ if (top && top !== buildToplevel) {
2463
+ // eslint-disable-next-line no-console
2464
+ console.warn(`[build/ship] 📝 wrote ${label} in ${top} — commit it there (Compose does not auto-commit other repos in v1)`);
2465
+ }
2466
+ }
2467
+ } catch { /* notice is best-effort */ }
2468
+ }
2469
+
2222
2470
  /**
2223
2471
  * Execute the ship step: run tests, stage feature files, commit.
2224
2472
  * Returns a PhaseResult-shaped object.
@@ -2231,6 +2479,11 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
2231
2479
  ? `docs/bugs/${featureCode}`
2232
2480
  : `${featuresDir}/${featureCode}`;
2233
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
+
2234
2487
  try {
2235
2488
  // 0. Check if we're in a git repository — if not, skip git operations
2236
2489
  let isGitRepo = false;
@@ -2240,22 +2493,57 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
2240
2493
  } catch { /* not a git repo */ }
2241
2494
 
2242
2495
  if (!isGitRepo) {
2496
+ // COMP-PATHS-EXTERNAL D6b: there is no repo to commit into (e.g. a
2497
+ // forge-top-shaped workspace), but the lifecycle must still advance —
2498
+ // record a commit-less completion (null-SHA) so status flips to COMPLETE.
2499
+ // Best-effort: a completion failure must not fail ship.
2500
+ let completionWarning = null;
2501
+ if (featureCode) {
2502
+ try {
2503
+ const { recordCompletion } = await import('./completion-writer.js');
2504
+ await recordCompletion(cwd, {
2505
+ feature_code: featureCode,
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).
2509
+ tests_pass: true,
2510
+ files_changed: [],
2511
+ notes: description.split('\n')[0].slice(0, 72),
2512
+ ...(builtVia ? { built_via: builtVia } : {}),
2513
+ });
2514
+ } catch (err) {
2515
+ completionWarning = `completion record failed (${err.code || 'UNKNOWN'}): ${err.message}`;
2516
+ // eslint-disable-next-line no-console
2517
+ console.warn(`[build/ship] ${featureCode}: ${completionWarning}`);
2518
+ }
2519
+ }
2243
2520
  return {
2244
2521
  phase: 'ship',
2245
2522
  artifact: 'no-git',
2246
2523
  outcome: 'complete',
2247
- summary: 'No git repository — commit skipped (non-fatal)',
2524
+ summary: 'No git repository — wrote artifacts, recorded completion (commit skipped)',
2525
+ commit: null,
2526
+ ...(completionWarning ? { completionWarning } : {}),
2248
2527
  };
2249
2528
  }
2250
2529
 
2251
2530
  // 1. Run feature-relevant tests (best-effort — don't block ship on test infra issues)
2252
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 };
2253
2536
  try {
2254
2537
  // COMP-TEST-BOOTSTRAP item 128: use detected test command instead of hard-coded npm test
2255
2538
  const testFramework = detectTestFramework(agentCwd);
2256
2539
  const testCommand = testFramework?.command ?? 'npm test';
2257
- execSync(`${testCommand} 2>&1 || true`, { cwd: agentCwd, encoding: 'utf-8', timeout: 120_000 });
2258
- } 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
+ }
2259
2547
 
2260
2548
  // 2. Collect files to stage
2261
2549
  const filesToStage = new Set();
@@ -2316,6 +2604,18 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
2316
2604
  const dataDir = join(cwd, '.compose', 'data');
2317
2605
  const mode = readEnforcementMode(dataDir);
2318
2606
  if (mode !== 'off') {
2607
+ // COMP-PATHS-EXTERNAL: the pre-stage guard matches repo-relative
2608
+ // `git status`, so relocated canon (an external paths.roadmap /
2609
+ // paths.features) is OUT of its scope — unauthorized edits there are
2610
+ // NOT enforced. Surface that visibly instead of failing silently.
2611
+ // Full external-canon enforcement needs cross-repo dirty detection
2612
+ // (tracked with COMP-PATHS-EXTERNAL-1).
2613
+ for (const [label, abs] of [['paths.roadmap', resolveRoadmapPath(cwd)], ['paths.features', resolveFeaturesPath(cwd)]]) {
2614
+ if (relative(cwd, abs).startsWith('..')) {
2615
+ // eslint-disable-next-line no-console
2616
+ console.warn(`[ship] MCP enforcement (${mode}) does NOT cover external ${label} (${abs}) — edits there are unguarded in v1.`);
2617
+ }
2618
+ }
2319
2619
  const events = readEvents(cwd, { since: context.buildStartedAt });
2320
2620
  const { violations } = scanGuarded({
2321
2621
  dirtyFiles: featureFiles,
@@ -2427,9 +2727,12 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
2427
2727
  await recordCompletion(cwd, {
2428
2728
  feature_code: featureCode,
2429
2729
  commit_sha: sha,
2430
- 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,
2431
2733
  files_changed: filesChanged,
2432
2734
  notes: shortDesc,
2735
+ ...(builtVia ? { built_via: builtVia } : {}),
2433
2736
  });
2434
2737
  if (progress) progress.toolUse('ship', `Recorded completion for ${featureCode}`);
2435
2738
  } catch (err) {
@@ -2441,6 +2744,11 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
2441
2744
  }
2442
2745
  }
2443
2746
 
2747
+ // COMP-PATHS-EXTERNAL D6a: if ROADMAP / the feature folder resolved into a
2748
+ // DIFFERENT git repo, they were written but not committed here — tell the
2749
+ // user to commit them there (v1 does not auto-commit other repos).
2750
+ noticeExternalArtifacts(cwd, featureCode, gitToplevel(agentCwd));
2751
+
2444
2752
  return {
2445
2753
  phase: 'ship',
2446
2754
  artifact: sha ?? '',
@@ -2907,7 +3215,8 @@ export async function executeChildFlow(
2907
3215
  });
2908
3216
  }
2909
3217
  const violations = resp.violations ?? [];
2910
- 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));
2911
3220
  // STRAT-REV-5: flow-step ensures fire here (inside executeChildFlow), not in the
2912
3221
  // main dispatch loop — persist dirty lenses so triage selectively re-runs on retry.
2913
3222
  if (resp.step_id === 'review') {
@@ -2918,7 +3227,14 @@ export async function executeChildFlow(
2918
3227
  }
2919
3228
  }
2920
3229
  const stepAgent = resp.agent ?? 'claude';
2921
- 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';
2922
3238
 
2923
3239
  if (progress) {
2924
3240
  progress.fix(childFlowName, fixAgent, resp.step_id);
@@ -4299,7 +4615,7 @@ export async function executeParallelDispatch(
4299
4615
  }
4300
4616
  }
4301
4617
 
4302
- 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) {
4303
4619
  const flowName = extractFlowName(specYaml, templateName);
4304
4620
  console.log(`Starting ${flowName} for ${featureCode}...`);
4305
4621
  // COMP-FIX-HARD T4: bug-mode flows take input as { task: <description> }
@@ -4308,9 +4624,14 @@ export async function startFresh(stratum, specYaml, featureCode, description, da
4308
4624
  // COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY (D5): fold pre_merge_gate into the
4309
4625
  // feature plan envelope ONLY when resolved (undefined ⇒ key omitted, not [])
4310
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';
4311
4632
  const planInputs = mode === 'bug'
4312
4633
  ? { task: description }
4313
- : { featureCode, description, ...(preMergeGate !== undefined ? { pre_merge_gate: preMergeGate } : {}) };
4634
+ : { featureCode, description, implementer_agent: implementerAgent, reviewer_agent: reviewerAgent, ...(preMergeGate !== undefined ? { pre_merge_gate: preMergeGate } : {}) };
4314
4635
  const response = await stratum.plan(specYaml, flowName, planInputs);
4315
4636
 
4316
4637
  writeActiveBuild(dataDir, {
@@ -4327,6 +4648,10 @@ export async function startFresh(stratum, specYaml, featureCode, description, da
4327
4648
  violations: [],
4328
4649
  status: 'running',
4329
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,
4330
4655
  });
4331
4656
 
4332
4657
  return response;