mandrel 2.25.0 → 2.26.0

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 (131) hide show
  1. package/.agents/agents/acceptance-critic.md +10 -6
  2. package/.agents/audit-checklists/baselines.md +21 -0
  3. package/.agents/docs/quality-gates.md +80 -18
  4. package/.agents/docs/workflows.md +3 -1
  5. package/.agents/instructions.md +1 -1
  6. package/.agents/schemas/audit-rules.json +15 -0
  7. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  8. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  9. package/.agents/schemas/baselines/crap.schema.json +8 -0
  10. package/.agents/schemas/model-attribution.schema.json +4 -0
  11. package/.agents/scripts/acceptance-eval.js +89 -6
  12. package/.agents/scripts/audit-baselines.js +136 -0
  13. package/.agents/scripts/check-arch-cycles.js +12 -93
  14. package/.agents/scripts/check-baseline-drift.js +16 -3
  15. package/.agents/scripts/check-baselines.js +19 -3
  16. package/.agents/scripts/check-cyclomatic.js +214 -0
  17. package/.agents/scripts/check-schema-references.js +392 -0
  18. package/.agents/scripts/check-test-temp-hygiene.js +38 -1
  19. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  20. package/.agents/scripts/diagnose-friction.js +85 -19
  21. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  22. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  23. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  24. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  25. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  26. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  27. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  28. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  29. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  30. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  31. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  32. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  33. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  34. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  35. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  36. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  37. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  38. package/.agents/scripts/lib/baselines/git-base.js +26 -4
  39. package/.agents/scripts/lib/baselines/kinds/crap.js +112 -15
  40. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  41. package/.agents/scripts/lib/baselines/scope.js +39 -90
  42. package/.agents/scripts/lib/baselines/writer.js +16 -11
  43. package/.agents/scripts/lib/changed-files.js +8 -1
  44. package/.agents/scripts/lib/cli-args.js +115 -1
  45. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  46. package/.agents/scripts/lib/crap-engine.js +32 -13
  47. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  48. package/.agents/scripts/lib/crap-utils.js +13 -0
  49. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  50. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  51. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  52. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  53. package/.agents/scripts/lib/git-utils.js +136 -80
  54. package/.agents/scripts/lib/import-graph.js +156 -0
  55. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  56. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  57. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  58. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  59. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  60. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  61. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  62. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  63. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  64. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  65. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  66. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +209 -109
  67. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
  68. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  69. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  70. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  71. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  72. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  73. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  74. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
  75. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  76. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  77. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  78. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
  79. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  80. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  81. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  82. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  83. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  84. package/.agents/scripts/lib/story-adjacency.js +3 -3
  85. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  86. package/.agents/scripts/lib/test-tiers.js +11 -2
  87. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  88. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  89. package/.agents/scripts/lib/wave-runner/live-probe.js +24 -14
  90. package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
  91. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
  92. package/.agents/scripts/lib/workers/crap-worker.js +2 -10
  93. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  94. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  95. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  96. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  97. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  98. package/.agents/scripts/lib/worktree-manager.js +14 -0
  99. package/.agents/scripts/plan-run-epilogue.js +17 -5
  100. package/.agents/scripts/providers/github/tickets.js +33 -10
  101. package/.agents/scripts/provision-git-hooks.js +85 -0
  102. package/.agents/scripts/quality-preview.js +112 -28
  103. package/.agents/scripts/resolve-stories.js +4 -1
  104. package/.agents/scripts/run-coverage.js +86 -35
  105. package/.agents/scripts/run-lint.js +20 -0
  106. package/.agents/scripts/run-tests.js +26 -36
  107. package/.agents/scripts/single-story-close.js +28 -2
  108. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  109. package/.agents/scripts/stories-wave-tick.js +214 -38
  110. package/.agents/scripts/update-coverage-baseline.js +34 -4
  111. package/.agents/scripts/update-duplication-baseline.js +209 -83
  112. package/.agents/scripts/validate-docs-freshness.js +1 -0
  113. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  114. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  115. package/.agents/skills/skills.index.json +2 -2
  116. package/.agents/workflows/audit-baselines.md +289 -0
  117. package/.agents/workflows/audit-navigability.md +5 -4
  118. package/.agents/workflows/deliver.md +13 -4
  119. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  120. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  121. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  122. package/.agents/workflows/helpers/deliver-reference.md +77 -1
  123. package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
  124. package/.agents/workflows/helpers/plan-reference.md +15 -5
  125. package/.agents/workflows/memory-consolidate.md +116 -0
  126. package/.agents/workflows/plan.md +3 -0
  127. package/README.md +13 -6
  128. package/docs/CHANGELOG.md +64 -0
  129. package/package.json +9 -4
  130. package/.agents/schemas/friction-event.schema.json +0 -56
  131. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -3,14 +3,16 @@
3
3
  * (Story #2197, Epic #2173).
4
4
  *
5
5
  * `refreshBaseline()` is the single funnel through which every baseline
6
- * regeneration (maintainability, crap, coverage) must flow. Callers that
7
- * previously assembled their own envelopes and called `fs.writeFileSync`
8
- * MUST migrate to this entry point — Stories 3/4/5 of Epic #2173 do that
9
- * migration; Story #2197 only lands the service surface and tests.
6
+ * regeneration (maintainability, crap, coverage, duplication) must flow.
7
+ * Callers that previously assembled their own envelopes and called
8
+ * `fs.writeFileSync` MUST migrate to this entry point — Stories 3/4/5 of
9
+ * Epic #2173 do that migration; Story #2197 only lands the service surface
10
+ * and tests. Story #4944 migrated the last holdout, `duplication`.
10
11
  *
11
12
  * The service is **scoring-agnostic**: it does not itself walk the
12
- * filesystem to compute MI / CRAP / coverage scores. Scoring is provided
13
- * by the per-kind default scorers resolved lazily via `resolveDefaultScorer`
13
+ * filesystem to compute MI / CRAP / coverage / duplication scores. Scoring
14
+ * is provided by the per-kind default scorers resolved lazily via
15
+ * `resolveDefaultScorer`
14
16
  * (built with the project config resolved against `cwd`) and, in tests or
15
17
  * production wiring, injected via the `scorer` option for hermetic
16
18
  * determinism. The service is the policy layer:
@@ -29,7 +31,8 @@
29
31
  * Public API (Task #2203, AC-1 / AC-2 / AC-7):
30
32
  *
31
33
  * refreshBaseline({
32
- * kind, // 'maintainability' | 'crap' | 'coverage' REQUIRED
34
+ * kind, // 'maintainability' | 'crap' | 'coverage'
35
+ * // | 'duplication' REQUIRED
33
36
  * baseRef, // git ref to diff against; default 'origin/main'
34
37
  * headRef, // git ref under inspection; default 'HEAD'
35
38
  * scopeFiles, // Array<string> | null
@@ -62,8 +65,9 @@
62
65
  *
63
66
  * Acceptance contract:
64
67
  *
65
- * AC-1: All callers that produce a maintainability/crap/coverage baseline
66
- * go through refreshBaseline(). Enforced by Task #2208 invariant.
68
+ * AC-1: All callers that produce a maintainability/crap/coverage/
69
+ * duplication baseline go through refreshBaseline(). Enforced by
70
+ * the Task #2208 invariant.
67
71
  * AC-2: scopeFiles=null && !fullScope -> diff-derived scope (Task #2207).
68
72
  * AC-4: Out-of-scope rows + their updatedAt fields are preserved byte-
69
73
  * for-byte (Task #2209).
@@ -93,6 +97,7 @@ import {
93
97
  isIgnoredByGlobs as isIgnoredByGlobsMi,
94
98
  scanDirectory as scanDirectoryMi,
95
99
  } from '../maintainability-utils.js';
100
+ import { resolveDetectClones, scanDuplication } from './duplication-scanner.js';
96
101
  import { filterExcludedRows } from './kinds/maintainability.js';
97
102
  import { canonicalizeBaselinePath } from './path-canon.js';
98
103
  import {
@@ -108,7 +113,12 @@ const execFileAsync = promisify(nodeExecFile);
108
113
  * Kinds the refresh service knows how to dispatch. Stays in lockstep with
109
114
  * the per-kind modules under `.agents/scripts/lib/baselines/kinds/`.
110
115
  */
111
- const SUPPORTED_KINDS = Object.freeze(['maintainability', 'crap', 'coverage']);
116
+ const SUPPORTED_KINDS = Object.freeze([
117
+ 'maintainability',
118
+ 'crap',
119
+ 'coverage',
120
+ 'duplication',
121
+ ]);
112
122
 
113
123
  /**
114
124
  * Per-kind file-extension predicate for diff-scope derivation (Task #2207).
@@ -125,6 +135,12 @@ const KIND_FILE_PREDICATES = Object.freeze({
125
135
  maintainability: (p) => /\.(?:m?[jt]sx?)$/i.test(p),
126
136
  crap: (p) => /\.(?:m?[jt]sx?)$/i.test(p),
127
137
  coverage: (p) => /\.(?:m?[jt]sx?)$/i.test(p),
138
+ // Duplication shares the source-extension filter deliberately. jscpd is
139
+ // configured for the `javascript` format only, so a changed `.ts` file
140
+ // admitted here simply yields no row on either side of the merge —
141
+ // over-inclusive is inert, under-inclusive would silently pin a changed
142
+ // file's prior row. Erring wide is the safe direction.
143
+ duplication: (p) => /\.(?:m?[jt]sx?)$/i.test(p),
128
144
  });
129
145
 
130
146
  /**
@@ -338,6 +354,7 @@ const KIND_SCORER_BUILDERS = Object.freeze({
338
354
  maintainability: buildDefaultMaintainabilityScorer,
339
355
  crap: buildDefaultCrapScorer,
340
356
  coverage: buildDefaultCoverageScorer,
357
+ duplication: buildDefaultDuplicationScorer,
341
358
  });
342
359
 
343
360
  /**
@@ -384,7 +401,7 @@ export function resolveDefaultScorer(kind, { cwd } = {}) {
384
401
  * so callers (and tests) can assert what actually got scored.
385
402
  *
386
403
  * @param {{
387
- * kind: 'maintainability' | 'crap' | 'coverage',
404
+ * kind: 'maintainability' | 'crap' | 'coverage' | 'duplication',
388
405
  * baseRef?: string,
389
406
  * headRef?: string,
390
407
  * scopeFiles?: string[] | null,
@@ -745,3 +762,44 @@ function validateOptions({ kind, scopeFiles, fullScope, writePath }) {
745
762
  );
746
763
  }
747
764
  }
765
+
766
+ /**
767
+ * Build the default duplication scorer.
768
+ *
769
+ * **This scorer always scans the whole target tree, in every scope mode.**
770
+ * That is not an oversight — duplication is the only kind here whose metric
771
+ * is *pairwise*: a clone is a relationship between two files, and jscpd can
772
+ * only report it if both sides are in the corpus it was handed. Narrowing the
773
+ * scan to the diff would drop every clone between a changed file and an
774
+ * unchanged one, i.e. exactly the duplication a refactor is most likely to
775
+ * introduce. So the `files` argument is intentionally unused: scope narrowing
776
+ * for this kind is a **write-side** concern, applied by the service handing
777
+ * `scope` to `writer.write()`, where `mergeRowsByScope` keeps the freshly
778
+ * scanned rows for in-scope files and preserves the prior rows verbatim for
779
+ * everything else.
780
+ *
781
+ * Reads `targetDirs` / `ignoreGlobs` off `gates.duplication`, not off a
782
+ * flattened accessor: `resolveQuality` never lifted duplication into the
783
+ * legacy bag (the kind post-dates it), so `quality.duplication` is always
784
+ * `undefined` and reading it would silently scan nothing.
785
+ *
786
+ * @param {{ cwd: string, config?: object, quality?: object, detect?: (opts: object) => Promise<object[]> }} opts
787
+ * @returns {(files: string[], opts: object) => Promise<object[]>}
788
+ */
789
+ function buildDefaultDuplicationScorer({ cwd, config, quality, detect } = {}) {
790
+ const dupCfg =
791
+ resolveQualityBlock({ quality, config })?.gates?.duplication ?? {};
792
+ const targetDirs = Array.isArray(dupCfg.targetDirs) ? dupCfg.targetDirs : [];
793
+ const ignoreGlobs = Array.isArray(dupCfg.ignoreGlobs)
794
+ ? dupCfg.ignoreGlobs
795
+ : [];
796
+ return async (_files, opts) => {
797
+ const effectiveCwd = opts?.cwd ?? cwd ?? process.cwd();
798
+ return scanDuplication({
799
+ targetDirs,
800
+ cwd: effectiveCwd,
801
+ ignoreGlobs,
802
+ detect: detect ?? resolveDetectClones(),
803
+ });
804
+ };
805
+ }
@@ -1,33 +1,44 @@
1
1
  // .agents/scripts/lib/baselines/scope.js
2
2
  //
3
- // Story #1962 / Task #1970 — One ScopeResolution helper that the
4
- // `check-baselines.js` dispatcher and the per-kind regression writers
5
- // (Epic #1943) both consume. Routing every scope decision through this
6
- // single function is what guarantees read/write parity: the dispatcher
7
- // can never decide "diff against epic/1943" while the writer assumes
8
- // "full repo", because both call `resolveScope()` with the same inputs.
3
+ // Story #1962 / Task #1970 — the ScopeResolution helper behind the
4
+ // `check-baselines.js` dispatcher: `resolveDispatchScope` in
5
+ // `lib/orchestration/check-baselines/phases/compare.js` is its one
6
+ // caller. Routing the read side's scope decision through a single pure
7
+ // function keeps the precedence rules from being re-implemented per gate.
8
+ //
9
+ // It is NOT the writers' resolver. `lib/baselines/refresh-service.js`
10
+ // defines its own private `resolveScope` (Story #3658) over a different
11
+ // input set, and the header here used to claim otherwise — a claim that
12
+ // made the read/write pair look coupled when it is not. Corrected in
13
+ // Story #4922.
9
14
  //
10
15
  // The resolver is intentionally pure — it takes already-extracted
11
- // inputs and returns a frozen ScopeResolution. CLI parsing, env
12
- // reading, and config loading happen in the caller; that keeps this
13
- // module trivially testable and prevents the precedence rules from
14
- // being silently re-implemented at every call site.
16
+ // inputs and returns a frozen ScopeResolution. Env reading and config
17
+ // loading happen in the caller; that keeps this module trivially
18
+ // testable.
15
19
  //
16
20
  // Precedence (highest → lowest):
17
21
  //
18
- // 1. CLI flags — `cliFlags.fullScope: true` or `cliFlags.changedSinceRef`.
19
- // Operator-typed beats anything in env/config. A CLI override of
20
- // `--full-scope` wins even if the config says `'diff'`.
21
- // 2. Environment — `BASELINE_SCOPE` ('full' | 'diff') and
22
+ // 1. Environment — `BASELINE_SCOPE` ('full' | 'diff') and
22
23
  // `BASELINE_REF` (any git ref). The dispatcher reads these from
23
- // `process.env` and forwards via `cliFlags.envScope` /
24
- // `cliFlags.envRef` so the resolver itself never touches process
25
- // state. CI usually sets these.
26
- // 3. Config — `configScope` ('full' | 'diff') and `configRef` (any
24
+ // `process.env` and forwards via `envScope` / `envRef` so the
25
+ // resolver itself never touches process state. CI sets these.
26
+ // 2. Config `configScope` ('full' | 'diff') and `configRef` (any
27
27
  // git ref) from `delivery.quality.gateScoping` in `.agentrc.json`.
28
- // 4. Default — `mode='diff'` against `ref='main'`. This is the
28
+ // 3. Default — `mode='diff'` against `ref='main'`. This is the
29
29
  // framework-wide fallback when nothing else is configured.
30
30
  //
31
+ // Story #4922 removed a fourth, highest-precedence layer: a
32
+ // `cliFlags.fullScope` / `cliFlags.changedSinceRef` operator override,
33
+ // plus the `cliFlags.changedFiles` → `files` plumbing that fed it. No
34
+ // production caller ever populated any of the three — only this module's
35
+ // own unit tests did — so the layer's only effect was to advertise a
36
+ // `--full-scope` / `--changed-since` contract that `check-baselines.js`
37
+ // does not implement, and a `files` set that no consumer read. Operators
38
+ // who need full scope set `BASELINE_SCOPE=full`, which is what CI does.
39
+ // (`mergeRowsByScope` below still takes a `files`-bearing scope — that
40
+ // one comes from the refresh service's own resolver, not from here.)
41
+ //
31
42
  // Missing-ref fallback: when the resolved mode is `'diff'` but no ref
32
43
  // is supplied at any layer, the resolver falls back to `'main'` rather
33
44
  // than producing a half-resolved scope with `ref=null`. The dispatcher
@@ -45,17 +56,8 @@
45
56
  // kind: string, // echoed back for caller convenience
46
57
  // mode: 'full' | 'diff',
47
58
  // ref: string | null, // null in full mode; ref string in diff mode
48
- // files: Set<string>, // empty Set in full mode (sentinel for "all")
49
59
  // source: string, // which layer won (debug / friction signal)
50
60
  // }
51
- //
52
- // `files` is intentionally a Set rather than an Array — callers
53
- // repeatedly check membership during per-row filtering, and Set lookup
54
- // is O(1). An empty Set in `'full'` mode means "no filter applies".
55
- // A non-empty Set in `'diff'` mode means "only these paths are in
56
- // scope" (the dispatcher pre-computes them via `git diff --name-only`
57
- // and forwards via `cliFlags.changedFiles`); when omitted, the writer
58
- // is expected to compute the diff itself against `ref`.
59
61
 
60
62
  const VALID_MODES = new Set(['full', 'diff']);
61
63
  const DEFAULT_DIFF_REF = 'main';
@@ -84,45 +86,19 @@ function asMode(v) {
84
86
  }
85
87
 
86
88
  /**
87
- * Coerce a candidate set/array of files to a frozen Set. Returns an
88
- * empty Set when the input is missing or empty.
89
- *
90
- * @param {unknown} v
91
- * @returns {Set<string>}
92
- */
93
- function asFilesSet(v) {
94
- if (v instanceof Set) {
95
- return new Set(
96
- Array.from(v).filter((f) => typeof f === 'string' && f.length > 0),
97
- );
98
- }
99
- if (Array.isArray(v)) {
100
- return new Set(v.filter((f) => typeof f === 'string' && f.length > 0));
101
- }
102
- return new Set();
103
- }
104
-
105
- /**
106
- * Resolve a scope against the layered precedence (CLI > env > config >
89
+ * Resolve a scope against the layered precedence (env > config >
107
90
  * default). Pure; no I/O.
108
91
  *
109
92
  * @param {object} input
110
- * @param {string} input.kind - Baseline kind (e.g. `'lint'`).
93
+ * @param {string} input.kind - Baseline kind (e.g. `'lint'`).
111
94
  * @param {string} [input.configScope] - `'full'` | `'diff'` from agentrc.
112
95
  * @param {string} [input.configRef] - Diff ref from agentrc.
113
- * @param {object} [input.cliFlags] - Pre-parsed CLI / env layer.
114
- * @param {boolean} [input.cliFlags.fullScope] - `--full-scope`.
115
- * @param {string} [input.cliFlags.changedSinceRef] - `--changed-since <ref>`.
116
- * @param {string} [input.cliFlags.envScope] - From `BASELINE_SCOPE`.
117
- * @param {string} [input.cliFlags.envRef] - From `BASELINE_REF`.
118
- * @param {Iterable<string>} [input.cliFlags.changedFiles]
119
- * Pre-computed diff paths (when caller already ran `git diff
120
- * --name-only`). Becomes `files`; only meaningful in `'diff'` mode.
96
+ * @param {string} [input.envScope] - From `BASELINE_SCOPE`.
97
+ * @param {string} [input.envRef] - From `BASELINE_REF`.
121
98
  * @returns {{
122
99
  * kind: string,
123
100
  * mode: 'full' | 'diff',
124
101
  * ref: string | null,
125
- * files: Set<string>,
126
102
  * source: string,
127
103
  * }}
128
104
  */
@@ -131,59 +107,34 @@ export function resolveScope(input = {}) {
131
107
  typeof input.kind === 'string' && input.kind.length > 0
132
108
  ? input.kind
133
109
  : 'unknown';
134
- const cli = input.cliFlags ?? {};
135
-
136
- // ---- Layer 1: CLI flags (highest precedence) -------------------------
137
- if (cli.fullScope === true) {
138
- return Object.freeze({
139
- kind,
140
- mode: 'full',
141
- ref: null,
142
- files: new Set(),
143
- source: 'cli:--full-scope',
144
- });
145
- }
146
- const cliRef = asNonEmptyString(cli.changedSinceRef);
147
- if (cliRef) {
148
- return Object.freeze({
149
- kind,
150
- mode: 'diff',
151
- ref: cliRef,
152
- files: asFilesSet(cli.changedFiles),
153
- source: 'cli:--changed-since',
154
- });
155
- }
156
110
 
157
- // ---- Layer 2: Environment (extracted by caller into cliFlags.env*) ---
158
- const envMode = asMode(cli.envScope);
111
+ // ---- Layer 1: Environment (extracted by the caller) ------------------
112
+ const envMode = asMode(input.envScope);
159
113
  if (envMode === 'full') {
160
114
  return Object.freeze({
161
115
  kind,
162
116
  mode: 'full',
163
117
  ref: null,
164
- files: new Set(),
165
118
  source: 'env:BASELINE_SCOPE=full',
166
119
  });
167
120
  }
168
- const envRef = asNonEmptyString(cli.envRef);
121
+ const envRef = asNonEmptyString(input.envRef);
169
122
  if (envMode === 'diff' || envRef) {
170
123
  return Object.freeze({
171
124
  kind,
172
125
  mode: 'diff',
173
126
  ref: envRef ?? DEFAULT_DIFF_REF,
174
- files: asFilesSet(cli.changedFiles),
175
127
  source: envRef ? 'env:BASELINE_REF' : 'env:BASELINE_SCOPE=diff',
176
128
  });
177
129
  }
178
130
 
179
- // ---- Layer 3: Config (delivery.quality.gateScoping) -----------------
131
+ // ---- Layer 2: Config (delivery.quality.gateScoping) ------------------
180
132
  const cfgMode = asMode(input.configScope);
181
133
  if (cfgMode === 'full') {
182
134
  return Object.freeze({
183
135
  kind,
184
136
  mode: 'full',
185
137
  ref: null,
186
- files: new Set(),
187
138
  source: 'config:gateScoping.scope=full',
188
139
  });
189
140
  }
@@ -193,19 +144,17 @@ export function resolveScope(input = {}) {
193
144
  kind,
194
145
  mode: 'diff',
195
146
  ref: cfgRef ?? DEFAULT_DIFF_REF,
196
- files: asFilesSet(cli.changedFiles),
197
147
  source: cfgRef
198
148
  ? 'config:gateScoping.diffRef'
199
149
  : 'config:gateScoping.scope=diff',
200
150
  });
201
151
  }
202
152
 
203
- // ---- Layer 4: Default ------------------------------------------------
153
+ // ---- Layer 3: Default ------------------------------------------------
204
154
  return Object.freeze({
205
155
  kind,
206
156
  mode: 'diff',
207
157
  ref: DEFAULT_DIFF_REF,
208
- files: asFilesSet(cli.changedFiles),
209
158
  source: 'default',
210
159
  });
211
160
  }
@@ -261,21 +261,26 @@ export function writeFile(absPath, envelope, opts = {}) {
261
261
  // across runs and platforms. Per-kind row keys retain their natural
262
262
  // declaration order; the row sort is done by `sortRows()`.
263
263
  //
264
- // Story #4775: the projection is deliberately explicit, so any per-kind
265
- // envelope stamp (`scoringSemantics`, and `tsTranspilerVersion` since Story
266
- // #4866) must be carried through by name or it is silently dropped on the
267
- // way to disk the stamp would then be present in memory, validated, and
268
- // absent from the file it exists to protect.
264
+ // The projection is deliberately explicit (Story #4775): a per-kind envelope
265
+ // stamp reaches disk only if it is named here, so an unlisted one is present
266
+ // in memory, passes validation, and is absent from the file it exists to
267
+ // protect. That is not hypothetical Story #4901 added `provenanceStamped`
268
+ // to `envelopeExtras()` and not to this list, and every baseline written
269
+ // between then and Story #4969 lost it at this boundary, leaving the
270
+ // `provenance-unstamped` axis reading an absence its own writer had
271
+ // manufactured.
272
+ //
273
+ // A stamp a kind does not set is simply `undefined`, and `JSON.stringify`
274
+ // omits an undefined-valued key — so naming all three unconditionally emits
275
+ // exactly what the per-key `undefined` guards used to, with nothing to
276
+ // half-apply when the next stamp is added.
269
277
  const canonical = {
270
278
  $schema: envelope.$schema,
271
279
  kernelVersion: envelope.kernelVersion,
272
280
  generatedAt: envelope.generatedAt,
273
- ...(envelope.scoringSemantics === undefined
274
- ? {}
275
- : { scoringSemantics: envelope.scoringSemantics }),
276
- ...(envelope.tsTranspilerVersion === undefined
277
- ? {}
278
- : { tsTranspilerVersion: envelope.tsTranspilerVersion }),
281
+ scoringSemantics: envelope.scoringSemantics,
282
+ tsTranspilerVersion: envelope.tsTranspilerVersion,
283
+ provenanceStamped: envelope.provenanceStamped,
279
284
  rollup: envelope.rollup,
280
285
  rows: envelope.rows,
281
286
  };
@@ -8,10 +8,17 @@ import { createGitInterface } from './git-utils.js';
8
8
  *
9
9
  * Pure; no I/O.
10
10
  *
11
+ * Module-private since Story #4944. It was exported for
12
+ * `diff-scope-cli.js#resolveDiffScopeFiles`, which the duplication-CLI
13
+ * migration deleted; the three remaining callers all live in this file, so
14
+ * exporting it now would ship a seam only the tests reach. Its behaviour is
15
+ * covered through `diffNameOnly`, which returns this function's output
16
+ * verbatim.
17
+ *
11
18
  * @param {string | null | undefined} stdout
12
19
  * @returns {string[]}
13
20
  */
14
- export function parseNameOnlyStdout(stdout) {
21
+ function parseNameOnlyStdout(stdout) {
15
22
  if (!stdout) return [];
16
23
  return stdout
17
24
  .split('\n')
@@ -62,13 +62,71 @@ function parsePositiveInt(value) {
62
62
  return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
63
63
  }
64
64
 
65
+ /** The only two merge-watch postures `--merge-watch-mode` accepts. */
66
+ const MERGE_WATCH_MODES = ['sync', 'async'];
67
+
68
+ /**
69
+ * Parse `--merge-watch-mode` (Story #4949), the per-invocation override of
70
+ * `delivery.mergeWatch.mode`. Absence is preserved as `undefined` so the
71
+ * caller can distinguish "not supplied" (fall back to config) from an explicit
72
+ * posture — the same contract {@link parsePositiveInt} gives
73
+ * `--max-wait-seconds`.
74
+ *
75
+ * Unlike that sibling, an unrecognized value **throws** rather than degrading
76
+ * to absent. A `--max-wait-seconds` typo falls back to a sane bound; a
77
+ * `--merge-watch-mode` typo would fall back to `sync` and silently return a
78
+ * multi-Story run to a serialized foreground wait per close, with the wall
79
+ * clock as the only evidence. Parsing runs before the first close phase, so
80
+ * failing here costs no mutation.
81
+ *
82
+ * @param {unknown} value
83
+ * @returns {'sync'|'async'|undefined}
84
+ */
85
+ export function parseMergeWatchMode(value) {
86
+ if (value == null) return undefined;
87
+ const mode = String(value).trim().toLowerCase();
88
+ if (MERGE_WATCH_MODES.includes(mode)) return mode;
89
+ throw new Error(
90
+ `--merge-watch-mode must be one of ${MERGE_WATCH_MODES.join('|')} (got "${value}")`,
91
+ );
92
+ }
93
+
94
+ /**
95
+ * {@link parseMergeWatchMode} degraded to the "absent" value instead of
96
+ * throwing — how the tolerant parse below treats a flag that failed
97
+ * validation. Reporting `undefined` is safe there and only there, because a
98
+ * tolerant parse never drives a pipeline: its caller surfaces the rejection
99
+ * as the run's failure and runs no phase at all.
100
+ *
101
+ * @param {unknown} value
102
+ * @returns {'sync'|'async'|undefined}
103
+ */
104
+ function tolerantMergeWatchMode(value) {
105
+ try {
106
+ return parseMergeWatchMode(value);
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }
111
+
65
112
  /**
66
113
  * Standardized CLI argument parser for sprint scripts.
67
114
  * Supports options like --epic, --story, --dry-run, --skip-dashboard.
115
+ *
116
+ * Throws when a *validating* parser rejects a flag value (currently only
117
+ * `--merge-watch-mode`). Callers that must not throw — an error handler
118
+ * needing `storyId` to report an envelope — use {@link parseSprintArgsTolerant}
119
+ * rather than calling this a second time inside their own catch.
120
+ *
68
121
  * @param {string[]} args Array of arguments (defaults to process.argv)
122
+ * @param {{ tolerant?: boolean }} [options] `tolerant` degrades a rejected
123
+ * flag to its absent value instead of throwing. For reporting only.
69
124
  * @returns {object} Parsed and typed argument values
70
125
  */
71
- export function parseSprintArgs(args = process.argv) {
126
+ export function parseSprintArgs(
127
+ args = process.argv,
128
+ { tolerant = false } = {},
129
+ ) {
72
130
  const { values, positionals } = parseArgs({
73
131
  args: args.slice(2),
74
132
  options: {
@@ -85,6 +143,10 @@ export function parseSprintArgs(args = process.argv) {
85
143
  // Story #4543 — per-run override of `delivery.mergeWatch.maxWaitSeconds`
86
144
  // (the merge wait's per-invocation bound). Absent means "use the config".
87
145
  'max-wait-seconds': { type: 'string' },
146
+ // Story #4949 — per-invocation override of `delivery.mergeWatch.mode`.
147
+ // Absent means "use the config"; see `parseMergeWatchMode` for why an
148
+ // unrecognized value fails closed instead of degrading to absent.
149
+ 'merge-watch-mode': { type: 'string' },
88
150
  executor: { type: 'string' },
89
151
  cwd: { type: 'string' },
90
152
  'recut-of': { type: 'string' },
@@ -115,6 +177,12 @@ export function parseSprintArgs(args = process.argv) {
115
177
  // headless caller with no host tool-invocation ceiling, so it lands in
116
178
  // one block instead of returning `pending` at the default 300s.
117
179
  maxWaitSeconds: parsePositiveInt(values['max-wait-seconds']),
180
+ // Story #4949 — per-invocation override of `delivery.mergeWatch.mode`.
181
+ // `undefined` when the flag is absent, which is what lets the merge wait
182
+ // fall back to the config; anything unrecognized throws here.
183
+ mergeWatchMode: tolerant
184
+ ? tolerantMergeWatchMode(values['merge-watch-mode'])
185
+ : parseMergeWatchMode(values['merge-watch-mode']),
118
186
  executor: values.executor ?? null,
119
187
  // Resolve worktree cwd from flag or env. Empty string/whitespace → null.
120
188
  cwd:
@@ -137,6 +205,52 @@ export function parseSprintArgs(args = process.argv) {
137
205
  return parsed;
138
206
  }
139
207
 
208
+ /**
209
+ * Last-resort tolerant parse: the fields, or an empty bag if even the
210
+ * tolerant pass cannot produce one.
211
+ *
212
+ * @param {string[]} args
213
+ * @returns {object}
214
+ */
215
+ function parseSprintArgsOrEmpty(args) {
216
+ try {
217
+ return parseSprintArgs(args, { tolerant: true });
218
+ } catch {
219
+ return {};
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Parse argv **without ever throwing**, returning the fields alongside the
225
+ * rejection rather than in place of it.
226
+ *
227
+ * `parseSprintArgs` gained its first *validating* parser in
228
+ * {@link parseMergeWatchMode} (Story #4949), which made a latent shape in the
229
+ * CLI entries fatal: their catch blocks called
230
+ * `failedTerminalFor(err, parseSprintArgs())` — re-invoking the very parser
231
+ * that had just thrown. The second throw escaped the catch, so an
232
+ * unparseable argv produced a bare stack trace with **no terminal envelope
233
+ * and no friction signal**, on the two surfaces whose whole contract is that
234
+ * they always emit one. An error handler must not depend on an operation
235
+ * already known to fail.
236
+ *
237
+ * So the entries parse **once**, up front, through this wrapper: `args`
238
+ * carries the `storyId` and skip flags the envelope is built from, and
239
+ * `error` is the failure to report. The tolerant re-parse degrades **only**
240
+ * the flag that failed validation; every other field parses normally. Use
241
+ * the result to *report*, never to run a pipeline.
242
+ *
243
+ * @param {string[]} [args] Array of arguments (defaults to `process.argv`)
244
+ * @returns {{ args: object, error: Error|null }}
245
+ */
246
+ export function parseSprintArgsTolerant(args = process.argv) {
247
+ try {
248
+ return { args: parseSprintArgs(args), error: null };
249
+ } catch (error) {
250
+ return { args: parseSprintArgsOrEmpty(args), error };
251
+ }
252
+ }
253
+
140
254
  const SUPPORTED_FLAG_TYPES = new Set([
141
255
  'boolean',
142
256
  'ticket',