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
@@ -24,6 +24,7 @@
24
24
  *
25
25
  * Usage:
26
26
  * node .agents/scripts/resolve-stories.js --ids 101,102
27
+ * node .agents/scripts/resolve-stories.js --ids 101-104 # inclusive range
27
28
  * node .agents/scripts/resolve-stories.js --ids 101,102 --pretty
28
29
  * node .agents/scripts/resolve-stories.js --ids 101 --no-native # skip the dependencies API
29
30
  *
@@ -65,7 +66,9 @@ blocked_by edges, with every blocker (in-set or foreign) resolved against its
65
66
  real issue state.
66
67
 
67
68
  Options:
68
- --ids <csv> Comma-separated Story issue numbers. Required.
69
+ --ids <csv> Comma-separated Story issue numbers. Required. A token may be
70
+ a single id (4922) or an inclusive dash range (4922-4926);
71
+ ranges expand in place and dedupe against the rest.
69
72
  --pretty Pretty-print the JSON envelope.
70
73
  --no-native Skip the native blocked_by read (body edges only).
71
74
  --help Show this help.
@@ -10,8 +10,16 @@
10
10
  * Node script keeps the env injection portable.
11
11
  *
12
12
  * Pipeline:
13
+ * 0. Run the full tier's preflight (`runTierPreflight`). Story #4936: this
14
+ * used to be a `pretest:coverage` npm script that CI had to name
15
+ * explicitly, because `.npmrc`'s `ignore-scripts=true` (CWE-1357
16
+ * defence, which stays) suppresses every `pre*` hook. The runner
17
+ * invokes it now, so it executes however the coverage run is started.
13
18
  * 1. Run the test suite under `NODE_V8_COVERAGE` so each worker writes
14
- * raw V8 dumps under `coverage/tmp/`.
19
+ * raw V8 dumps under `coverage/tmp/`. The suite targets are the
20
+ * shared `FULL_TIER_GLOBS` from `lib/test-tiers.js` — the same set
21
+ * `run-tests.js` walks — so the measured surface and `npm test`'s
22
+ * surface are the same set by construction.
15
23
  * 2. `c8 report` post-processes the dumps into `coverage/coverage-final.json`
16
24
  * plus the printed text table. Include/exclude scope from `.c8rc.cjs`
17
25
  * is passed explicitly because `c8 report` does not auto-load the
@@ -28,13 +36,15 @@
28
36
  * while producing an identical `coverage-final.json` artifact for the
29
37
  * CRAP gate.
30
38
  *
31
- * Test-runner concurrency: the suite spawn reuses `TEST_RUNNER_FLAGS`
32
- * from `run-tests.js` — the single source of truth for the
33
- * `--test-concurrency` value, derived at startup from the host's
34
- * available parallelism and clamped to `[TEST_CONCURRENCY_MIN,
35
- * TEST_CONCURRENCY_MAX]`. This keeps the coverage gate (which runs the
36
- * suite at every story close on both delivery paths) host-aware instead
37
- * of pinned to the historical literal of 8.
39
+ * Test-runner flags: the suite spawn reuses `TEST_RUNNER_FLAGS` from
40
+ * `lib/test-runner-contract.js` — the single source of truth for the
41
+ * `node --test` flag set, including the host-derived
42
+ * `--test-concurrency` value clamped to `[TEST_CONCURRENCY_MIN,
43
+ * TEST_CONCURRENCY_MAX]`. Story #4936: sharing the whole flag set (not
44
+ * just the concurrency number) is what stops this runner and
45
+ * `run-tests.js` disagreeing about whether a test can execute at all —
46
+ * `--experimental-test-module-mocks` decides whether `t.mock.module`
47
+ * works, and this is the *required* CI job.
38
48
  */
39
49
 
40
50
  import { spawnSync } from 'node:child_process';
@@ -46,56 +56,90 @@ import { fileURLToPath } from 'node:url';
46
56
  import { cleanupRepoTestTempArtifacts } from './cleanup-repo-test-temp.js';
47
57
  import { C8_CLI } from './lib/c8-cli-path.js';
48
58
  import { buildWebhookSafeTestEnv } from './lib/test-env.js';
49
- import { TEST_RUNNER_FLAGS } from './run-tests.js';
59
+ import {
60
+ runTierPreflight,
61
+ TEST_RUNNER_FLAGS,
62
+ } from './lib/test-runner-contract.js';
63
+ import { FULL_TIER_GLOBS } from './lib/test-tiers.js';
50
64
 
51
65
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
52
66
  const ROOT = path.resolve(__dirname, '..', '..');
53
- const COVERAGE_DIR = path.join(ROOT, 'coverage');
54
67
  const require = createRequire(import.meta.url);
55
68
  const C8_CONFIG = require('../../.c8rc.cjs');
56
- const V8_TMP = path.join(COVERAGE_DIR, 'tmp');
57
69
 
58
70
  /**
59
71
  * Build the `node --test` argv for the coverage suite spawn.
60
72
  *
61
- * Reuses the shared `TEST_RUNNER_FLAGS` (the single source of truth for
62
- * the host-aware, clamped `--test-concurrency` value) so the coverage
63
- * path never drifts from `run-tests.js`. The `runnerFlags` parameter is
64
- * injected in tests so the argv can be asserted without touching the OS.
73
+ * Reuses two shared constants so the coverage path cannot drift from
74
+ * `run-tests.js`:
75
+ *
76
+ * - `TEST_RUNNER_FLAGS` the single source of truth for the host-aware,
77
+ * clamped `--test-concurrency` value.
78
+ * - `FULL_TIER_GLOBS` — the single source of truth for *which files the
79
+ * full tier runs*. Story #4922: this script used to restate
80
+ * `tests/**\/*.test.js` on its own, so the 47 colocated `__tests__`
81
+ * suites under `lib/` and `.agents/scripts/` executed under `npm test`
82
+ * but never under the measuring run — the coverage and CRAP numbers
83
+ * were computed over code the coverage run had not executed.
84
+ *
85
+ * Both parameters are injected in tests so the argv can be asserted without
86
+ * touching the OS.
65
87
  *
66
88
  * @param {object} [opts]
67
89
  * @param {readonly string[]} [opts.runnerFlags]
68
- * @param {string} [opts.testGlob]
90
+ * @param {readonly string[]} [opts.testGlobs]
69
91
  * @returns {string[]}
70
92
  */
71
93
  export function buildCoverageTestArgs({
72
94
  runnerFlags = TEST_RUNNER_FLAGS,
73
- testGlob = 'tests/**/*.test.js',
95
+ testGlobs = FULL_TIER_GLOBS,
74
96
  } = {}) {
75
- return [...runnerFlags, testGlob];
97
+ return [...runnerFlags, ...testGlobs];
76
98
  }
77
99
 
78
100
  /**
79
- * Execute the coverage pipeline: run the suite under `NODE_V8_COVERAGE`,
80
- * post-process the dumps with `c8 report`, then gate on the coverage
81
- * baseline. Returns the first non-zero exit code across the three stages
82
- * (or the baseline check's status when both prior stages pass).
101
+ * Execute the coverage pipeline: run the full tier's preflight, run the
102
+ * suite under `NODE_V8_COVERAGE`, post-process the dumps with `c8 report`,
103
+ * then gate on the coverage baseline. Returns the first non-zero exit code
104
+ * across the stages (or the baseline check's status when the prior stages
105
+ * pass); a refused preflight short-circuits before anything is spawned or
106
+ * removed.
83
107
  *
108
+ * Every collaborator is injected so the pipeline's wiring — above all
109
+ * *that the preflight actually runs, and runs first* — is assertable
110
+ * without spawning the real suite.
111
+ *
112
+ * @param {object} [opts]
113
+ * @param {typeof spawnSync} [opts.spawn]
114
+ * @param {(opts: { tier: string, repoRoot: string }) => number} [opts.preflight]
115
+ * @param {(opts: { repoRoot: string }) => unknown} [opts.cleanup]
116
+ * @param {string} [opts.repoRoot]
84
117
  * @returns {number}
85
118
  */
86
- function runCoveragePipeline() {
87
- rmSync(COVERAGE_DIR, { recursive: true, force: true });
88
- mkdirSync(V8_TMP, { recursive: true });
119
+ export function runCoveragePipeline({
120
+ spawn = spawnSync,
121
+ preflight = runTierPreflight,
122
+ cleanup = cleanupRepoTestTempArtifacts,
123
+ repoRoot = ROOT,
124
+ } = {}) {
125
+ const coverageDir = path.join(repoRoot, 'coverage');
126
+ const v8Tmp = path.join(coverageDir, 'tmp');
127
+
128
+ const preflightStatus = preflight({ tier: 'full', repoRoot });
129
+ if (preflightStatus !== 0) return preflightStatus;
89
130
 
90
- const testRun = spawnSync(process.execPath, buildCoverageTestArgs(), {
91
- cwd: ROOT,
131
+ rmSync(coverageDir, { recursive: true, force: true });
132
+ mkdirSync(v8Tmp, { recursive: true });
133
+
134
+ const testRun = spawn(process.execPath, buildCoverageTestArgs(), {
135
+ cwd: repoRoot,
92
136
  stdio: 'inherit',
93
137
  // GIT_*-scrubbed: under a husky pre-push from a linked worktree the
94
138
  // inherited GIT_DIR poisons fixture `git init` runs (#4580).
95
- env: { ...buildWebhookSafeTestEnv(process.env), NODE_V8_COVERAGE: V8_TMP },
139
+ env: { ...buildWebhookSafeTestEnv(process.env), NODE_V8_COVERAGE: v8Tmp },
96
140
  });
97
141
 
98
- cleanupRepoTestTempArtifacts({ repoRoot: ROOT });
142
+ cleanup({ repoRoot });
99
143
 
100
144
  const includeArgs = (C8_CONFIG.include ?? []).flatMap((p) => [
101
145
  '--include',
@@ -106,29 +150,36 @@ function runCoveragePipeline() {
106
150
  p,
107
151
  ]);
108
152
 
109
- const reportRun = spawnSync(
153
+ // `c8 report` does not auto-load `.c8rc.cjs`, so every scope knob it needs
154
+ // is forwarded explicitly from the config object — including `all`, which
155
+ // is what puts a 0 % row on a source file no test ever loaded (Story
156
+ // #4922). Reading it off the config keeps `.c8rc.cjs` the one declaration.
157
+ const allArgs = C8_CONFIG.all ? ['--all'] : [];
158
+
159
+ const reportRun = spawn(
110
160
  process.execPath,
111
161
  [
112
162
  C8_CLI,
113
163
  'report',
114
164
  '--reporter=json',
115
165
  '--reporter=text',
166
+ ...allArgs,
116
167
  '--temp-directory',
117
- V8_TMP,
168
+ v8Tmp,
118
169
  ...includeArgs,
119
170
  ...excludeArgs,
120
171
  ],
121
- { cwd: ROOT, stdio: 'inherit', shell: false },
172
+ { cwd: repoRoot, stdio: 'inherit', shell: false },
122
173
  );
123
174
 
124
- const checkRun = spawnSync(
175
+ const checkRun = spawn(
125
176
  process.execPath,
126
177
  [
127
- path.join(ROOT, '.agents', 'scripts', 'check-baselines.js'),
178
+ path.join(repoRoot, '.agents', 'scripts', 'check-baselines.js'),
128
179
  '--gate',
129
180
  'coverage',
130
181
  ],
131
- { cwd: ROOT, stdio: 'inherit' },
182
+ { cwd: repoRoot, stdio: 'inherit' },
132
183
  );
133
184
 
134
185
  return testRun.status !== 0
@@ -28,11 +28,20 @@ const tasks = [
28
28
  args: ['biome', 'ci', '.'],
29
29
  },
30
30
  {
31
+ // `docs/**/*.md` sat outside these globs until PR #4970's follow-up,
32
+ // so `npm run lint` reported "0 error(s)" while the close-time
33
+ // code-review lens — which lints the whole changed surface, not just
34
+ // what this driver globs — raised pre-existing `docs/` violations
35
+ // against whichever Story happened to touch the file. Keep `docs/`
36
+ // here so the two surfaces agree. `docs/CHANGELOG.md` is linted too;
37
+ // the generator-owned rules it can never satisfy are exempted by a
38
+ // `markdownlint-disable-file` directive in its own header.
31
39
  name: 'markdownlint',
32
40
  cmd: 'npx',
33
41
  args: [
34
42
  'markdownlint-cli2',
35
43
  '.agents/**/*.md',
44
+ 'docs/**/*.md',
36
45
  '*.md',
37
46
  '!node_modules/**',
38
47
  '!.worktrees/**',
@@ -79,6 +88,17 @@ const tasks = [
79
88
  cmd: 'node',
80
89
  args: ['.agents/scripts/lint-label-vocabulary.js'],
81
90
  },
91
+ {
92
+ // GitHub Actions job-timeout gate (Story #4936). Enumerates every
93
+ // `jobs.<id>` key across `.github/workflows/*.yml` and fails when one
94
+ // sets no `timeout-minutes` (inheriting GitHub's 360-minute default) or
95
+ // sets one above the ceiling. A deadlocked Windows job burned 44 minutes
96
+ // of a runner and withheld the failing required check's logs for the
97
+ // whole time; nothing but this check would have caught the gap.
98
+ name: 'workflow-timeouts',
99
+ cmd: 'node',
100
+ args: ['.agents/scripts/check-workflow-timeouts.js'],
101
+ },
82
102
  {
83
103
  // Architecture cycle ratchet (Story #3991). Detects directed import
84
104
  // cycles under `.agents/scripts/` and fails on any cycle not in the
@@ -29,53 +29,34 @@
29
29
  */
30
30
 
31
31
  import { spawnSync } from 'node:child_process';
32
- import os from 'node:os';
33
32
  import path from 'node:path';
34
33
  import { fileURLToPath } from 'node:url';
35
34
  import { assertNoReservedIdStreams } from './check-test-temp-hygiene.js';
36
35
  import { cleanupRepoTestTempArtifacts } from './cleanup-repo-test-temp.js';
37
36
  import { runAsCli } from './lib/cli-utils.js';
38
37
  import { buildWebhookSafeTestEnv } from './lib/test-env.js';
38
+ import {
39
+ resolveTestConcurrency,
40
+ runTierPreflight,
41
+ TEST_CONCURRENCY_MAX,
42
+ TEST_CONCURRENCY_MIN,
43
+ TEST_RUNNER_FLAGS,
44
+ } from './lib/test-runner-contract.js';
39
45
  import { listTestFilesForTier, parseTierArgv } from './lib/test-tiers.js';
40
46
 
41
47
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
42
48
  const ROOT = path.resolve(__dirname, '..', '..');
43
49
 
44
- /** Minimum and maximum bounds for `--test-concurrency`. */
45
- export const TEST_CONCURRENCY_MIN = 1;
46
- export const TEST_CONCURRENCY_MAX = 16;
47
-
48
- /**
49
- * Resolve the `--test-concurrency` value for the current host.
50
- *
51
- * Uses `os.availableParallelism()` (Node ≥18.14 / ≥20.0) clamped to the
52
- * range `[TEST_CONCURRENCY_MIN, TEST_CONCURRENCY_MAX]`. The `parallelism`
53
- * parameter is injected in tests so the clamping logic is verifiable
54
- * without touching the OS.
55
- *
56
- * @param {number} [parallelism] - defaults to `os.availableParallelism()`
57
- * @returns {number}
58
- */
59
- export function resolveTestConcurrency(
60
- parallelism = os.availableParallelism(),
61
- ) {
62
- return Math.min(
63
- TEST_CONCURRENCY_MAX,
64
- Math.max(TEST_CONCURRENCY_MIN, parallelism),
65
- );
66
- }
67
-
68
- /**
69
- * Fixed `node --test` flags applied to every spawn (every chunk).
70
- * `--test-concurrency` is derived at startup from the host's available
71
- * parallelism so the value is appropriate for the machine running the suite
72
- * rather than being pinned to the historical constant of 8.
73
- */
74
- export const TEST_RUNNER_FLAGS = Object.freeze([
75
- '--experimental-test-module-mocks',
76
- '--test',
77
- `--test-concurrency=${resolveTestConcurrency()}`,
78
- ]);
50
+ // The runner contract (flag set, concurrency clamp, tier preflight) is
51
+ // declared once in `lib/test-runner-contract.js` so `run-coverage.js` shares
52
+ // it by construction. Re-exported here because this module is the runner's
53
+ // public face for its own unit tests.
54
+ export {
55
+ resolveTestConcurrency,
56
+ TEST_CONCURRENCY_MAX,
57
+ TEST_CONCURRENCY_MIN,
58
+ TEST_RUNNER_FLAGS,
59
+ };
79
60
 
80
61
  /**
81
62
  * Per-spawn character budget for the joined *targets* portion of the argv.
@@ -167,8 +148,17 @@ export function runTestSuite({
167
148
  listTargets = listTestFilesForTier,
168
149
  maxTargetChars = resolveMaxTargetChars(),
169
150
  fixtureStreamGuard = assertNoReservedIdStreams,
151
+ preflight = runTierPreflight,
170
152
  } = {}) {
171
153
  const { tier, rest } = parseTierArgv(argv);
154
+
155
+ // Preflight first, and abort on refusal — the tier's checks exist to stop
156
+ // the suite starting from a known-bad state, so running them after the
157
+ // spawn would be pointless. Nothing has been created yet, so there is
158
+ // nothing to clean up on this path.
159
+ const preflightStatus = preflight({ tier, repoRoot: cwd });
160
+ if (preflightStatus !== 0) return preflightStatus;
161
+
172
162
  const targets = listTargets(tier, cwd);
173
163
  const chunks = chunkTestTargets(targets, maxTargetChars);
174
164
 
@@ -43,6 +43,16 @@
43
43
  * [--skip-validation] [--skip-sync]
44
44
  * [--no-auto-merge]
45
45
  * [--wait-merge | --no-wait-merge]
46
+ * [--merge-watch-mode <sync|async>]
47
+ *
48
+ * `--merge-watch-mode` (Story #4949) overrides `delivery.mergeWatch.mode` for
49
+ * one invocation, on the same explicit-wins-over-config precedence
50
+ * `--max-wait-seconds` uses, and the two compose. It exists because run
51
+ * topology is invisible from inside close: a solo delivery is cheapest waiting
52
+ * in the foreground, while the Nth close of a wave pays that wait as
53
+ * serialized dead time. The config default therefore stays `sync` and the
54
+ * orchestrator — the only party that knows N — passes `async` per close. An
55
+ * unrecognized value fails during option parsing, before any phase runs.
46
56
  *
47
57
  * Close-and-land is the DEFAULT for every run (Story #4428 introduced it as
48
58
  * `--wait-merge`; `delivery.routing.closeAndLand` — default `true` — made it
@@ -85,7 +95,7 @@
85
95
  * @see .agents/schemas/story-deliver-terminal.schema.json
86
96
  */
87
97
 
88
- import { parseSprintArgs } from './lib/cli-args.js';
98
+ import { parseSprintArgsTolerant } from './lib/cli-args.js';
89
99
  import { runAsCli } from './lib/cli-utils.js';
90
100
  import { formatCliError } from './lib/error-redactor.js';
91
101
  import { Logger } from './lib/Logger.js';
@@ -151,13 +161,25 @@ export async function runSingleStoryClose(opts) {
151
161
  * status rather than from a thrown/not-thrown distinction, so `pending`
152
162
  * (resumable) is distinguishable from `blocked` (come look) without parsing
153
163
  * stdout.
164
+ *
165
+ * The catch parses argv through the **non-throwing** wrapper (Story #4959).
166
+ * It used to call `parseSprintArgs()` — re-invoking the very parser that had
167
+ * just thrown, since `parseMergeWatchMode` made argv parsing fallible. The
168
+ * second throw escaped the handler, so an unparseable argv produced a bare
169
+ * stack trace with no envelope and no friction signal, on the surface whose
170
+ * whole contract is that every invocation emits exactly one envelope. An
171
+ * error handler may not depend on an operation already known to fail.
172
+ *
173
+ * A parse rejection carries no `closePhase`, so the envelope reports `init` —
174
+ * accurate: the runner rejected the flag before any phase ran, and nothing
175
+ * was mutated.
154
176
  */
155
177
  async function main() {
156
178
  try {
157
179
  const outcome = await runSingleStoryClose();
158
180
  return exitCodeForTerminal(outcome?.terminal ?? { status: 'failed' });
159
181
  } catch (err) {
160
- const terminal = failedTerminalFor(err, parseSprintArgs());
182
+ const terminal = failedTerminalFor(err, parseSprintArgsTolerant().args);
161
183
  if (!terminal) throw err;
162
184
  // Mirror runAsCli's default error line (which this catch pre-empts) so the
163
185
  // human-facing failure text is unchanged, then emit the envelope.
@@ -191,6 +213,10 @@ runAsCli(import.meta.url, main, {
191
213
  ['--wait-merge', 'Force the in-close merge wait.'],
192
214
  ['--no-wait-merge', 'Return as soon as the PR is open; do not wait.'],
193
215
  ['--max-wait-seconds <n>', 'Per-invocation merge-wait bound.'],
216
+ [
217
+ '--merge-watch-mode <sync|async>',
218
+ 'Override delivery.mergeWatch.mode for this invocation only. `async` caps the merge wait to a short probe window and returns the resumable `pending` terminal instead of holding the foreground slot — pass it on every close of a multi-Story run. An invalid value exits non-zero before any phase runs.',
219
+ ],
194
220
  ['--no-evidence', 'Do not reuse or write gate evidence stamps.'],
195
221
  ['--dry-run', 'Report the plan; mutate nothing.'],
196
222
  ],
@@ -37,7 +37,7 @@
37
37
  */
38
38
 
39
39
  import { parseArgs } from 'node:util';
40
- import { parseSprintArgs } from './lib/cli-args.js';
40
+ import { parseSprintArgsTolerant } from './lib/cli-args.js';
41
41
  import { runAsCli } from './lib/cli-utils.js';
42
42
  import { resolveConfig } from './lib/config-resolver.js';
43
43
  import { formatCliError } from './lib/error-redactor.js';
@@ -66,6 +66,15 @@ const progress = Logger.createProgress('single-story-confirm-merge', {
66
66
  stderr: true,
67
67
  });
68
68
 
69
+ /**
70
+ * This CLI's own surface name — the `runAsCli` source, and the `emitter.tool`
71
+ * every friction record it emits carries. Single-homed so the two cannot
72
+ * drift: `emitTerminalFriction` defaults to the CLOSE CLI's name, so a record
73
+ * emitted from here without it is attributed to a CLI that never ran, and the
74
+ * retro roll-up sends the follow-up to the wrong surface.
75
+ */
76
+ const CLI_SOURCE = 'single-story-confirm-merge';
77
+
69
78
  /**
70
79
  * Default `gh` facade for this CLI, bound to the merge wait's spawn-level
71
80
  * timeout (Story #4710). This CLI is the resume surface async mode hands the
@@ -204,7 +213,7 @@ async function logConfirmResult(result, terminal, config) {
204
213
  },
205
214
  });
206
215
  emitTerminalEnvelope(terminal, { config });
207
- await emitTerminalFriction({ envelope: terminal, config });
216
+ await emitTerminalFriction({ envelope: terminal, tool: CLI_SOURCE, config });
208
217
  return { success: terminal.status !== 'failed', result, terminal };
209
218
  }
210
219
 
@@ -512,14 +521,21 @@ async function main() {
512
521
  const outcome = await runConfirmMerge();
513
522
  return exitCodeForTerminal(outcome?.terminal ?? { status: 'failed' });
514
523
  } catch (err) {
515
- const storyId = Number(parseSprintArgs().storyId);
524
+ // Non-throwing by construction (Story #4959): this used to call
525
+ // `parseSprintArgs()`, so a rejected `--merge-watch-mode` threw a second
526
+ // time here and escaped the handler — no envelope, no friction. Close
527
+ // carries the identical shape; both landing surfaces behave the same.
528
+ const { args, error: argvError } = parseSprintArgsTolerant();
529
+ const storyId = Number(args.storyId);
516
530
  // No story id → a usage error; there is nothing to report an envelope
517
531
  // about, so let runAsCli surface it as a plain fatal.
518
532
  if (!Number.isInteger(storyId) || storyId <= 0) throw err;
519
533
  const terminal = buildTerminalEnvelope({
520
534
  storyId,
521
535
  status: 'failed',
522
- phase: 'confirm-merge',
536
+ // An argv rejection happens before any phase runs, so it reports at
537
+ // `init` rather than claiming a confirm-merge attempt that never began.
538
+ phase: argvError ? 'init' : 'confirm-merge',
523
539
  failure: { reason: String(err?.message ?? err) },
524
540
  nextCommand: NEXT_COMMANDS.recover(storyId),
525
541
  elapsedSeconds: 0,
@@ -528,13 +544,13 @@ async function main() {
528
544
  `[single-story-confirm-merge] Fatal error: ${formatCliError(err)}`,
529
545
  );
530
546
  emitTerminalEnvelope(terminal);
531
- await emitTerminalFriction({ envelope: terminal });
547
+ await emitTerminalFriction({ envelope: terminal, tool: CLI_SOURCE });
532
548
  return exitCodeForTerminal(terminal);
533
549
  }
534
550
  }
535
551
 
536
552
  runAsCli(import.meta.url, main, {
537
- source: 'single-story-confirm-merge',
553
+ source: CLI_SOURCE,
538
554
  propagateExitCode: true,
539
555
  usage: {
540
556
  invocation: