mandrel 2.53.0 → 2.55.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 (114) hide show
  1. package/.agents/agents/story-worker.md +24 -23
  2. package/.agents/audit-checklists/accessibility.md +0 -3
  3. package/.agents/audit-checklists/mobile.md +0 -4
  4. package/.agents/docs/agentrc-reference.json +4 -2
  5. package/.agents/docs/configuration.md +2 -0
  6. package/.agents/schemas/agentrc.schema.json +15 -1
  7. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  8. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  9. package/.agents/scripts/audit-to-stories.js +158 -7
  10. package/.agents/scripts/check-audit-attribution.js +119 -62
  11. package/.agents/scripts/check-test-portability.js +512 -0
  12. package/.agents/scripts/coverage-capture.js +17 -10
  13. package/.agents/scripts/evidence-gate.js +31 -4
  14. package/.agents/scripts/generate-workflows-doc.js +65 -14
  15. package/.agents/scripts/git-cleanup.js +4 -0
  16. package/.agents/scripts/lib/ITicketingProvider.js +78 -0
  17. package/.agents/scripts/lib/audit-advisories.js +195 -0
  18. package/.agents/scripts/lib/audit-attribution.js +22 -0
  19. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +68 -5
  20. package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
  21. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +60 -114
  22. package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
  23. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
  24. package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
  25. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
  26. package/.agents/scripts/lib/cli-args.js +26 -0
  27. package/.agents/scripts/lib/close-validation/gates.js +113 -7
  28. package/.agents/scripts/lib/close-validation/process.js +7 -3
  29. package/.agents/scripts/lib/close-validation/runner.js +62 -11
  30. package/.agents/scripts/lib/config/ci.js +28 -9
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  32. package/.agents/scripts/lib/config-settings-schema.js +19 -1
  33. package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
  34. package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
  35. package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
  36. package/.agents/scripts/lib/coverage-capture.js +77 -3
  37. package/.agents/scripts/lib/findings/route-finding.js +4 -2
  38. package/.agents/scripts/lib/full-suite-lock.js +232 -6
  39. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  40. package/.agents/scripts/lib/git/sync-from-base.js +130 -13
  41. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  42. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
  43. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
  44. package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
  45. package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
  46. package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
  47. package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
  48. package/.agents/scripts/lib/orchestration/epic-rollup.js +241 -84
  49. package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
  50. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
  51. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
  52. package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
  53. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
  54. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
  55. package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
  56. package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
  57. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
  58. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
  59. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +24 -1
  60. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
  61. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
  62. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +119 -6
  63. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
  64. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
  65. package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
  66. package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
  67. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
  68. package/.agents/scripts/lib/orchestration/run-epilogue.js +59 -38
  69. package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
  70. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
  71. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
  72. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
  73. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
  74. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
  75. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
  76. package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
  77. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
  78. package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
  79. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +70 -6
  80. package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
  81. package/.agents/scripts/lib/pinned-override-notes.js +41 -53
  82. package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
  83. package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
  84. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
  85. package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
  86. package/.agents/scripts/lib/test-temp.js +167 -30
  87. package/.agents/scripts/lib/validation-evidence.js +37 -0
  88. package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
  89. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  90. package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
  91. package/.agents/scripts/merge-baseline.js +175 -21
  92. package/.agents/scripts/providers/github/errors.js +22 -1
  93. package/.agents/scripts/providers/github/issues.js +106 -1
  94. package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
  95. package/.agents/scripts/providers/github.js +6 -0
  96. package/.agents/scripts/resolve-stories.js +44 -34
  97. package/.agents/scripts/single-story-close.js +5 -0
  98. package/.agents/scripts/stories-wave-tick.js +37 -13
  99. package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
  100. package/.agents/workflows/audit-accessibility.md +16 -31
  101. package/.agents/workflows/audit-mobile.md +20 -37
  102. package/.agents/workflows/git-cleanup.md +17 -3
  103. package/.agents/workflows/helpers/audit-lens-core.md +45 -0
  104. package/.agents/workflows/helpers/deliver-digest.md +7 -6
  105. package/.agents/workflows/helpers/deliver-reference.md +40 -16
  106. package/.agents/workflows/helpers/deliver-story-reference.md +7 -4
  107. package/.agents/workflows/helpers/deliver-story.md +15 -12
  108. package/.agents/workflows/helpers/plan-reference.md +8 -1
  109. package/.agents/workflows/mandrel-plan.md +4 -7
  110. package/.agents/workflows/memory-consolidate.md +14 -9
  111. package/docs/CHANGELOG.md +34 -0
  112. package/lib/cli/registry.js +64 -21
  113. package/lib/cli/sync.js +27 -2
  114. package/package.json +7 -4
@@ -36,15 +36,80 @@ const BASELINE_MERGE_ATTRIBUTE = `baselines/*.json merge=${BASELINE_MERGE_DRIVER
36
36
  /** Git config key holding the driver command. */
37
37
  export const BASELINE_MERGE_DRIVER_CONFIG_KEY = `merge.${BASELINE_MERGE_DRIVER_NAME}.driver`;
38
38
 
39
+ /** Script path the driver command invokes, relative to the worktree root. */
40
+ const DRIVER_SCRIPT = '.agents/scripts/merge-baseline.js';
41
+
42
+ /** Git's merge-driver placeholders, in the order the driver's argv expects. */
43
+ const DRIVER_PLACEHOLDERS = '%O %A %B %P';
44
+
39
45
  /**
40
- * The driver command. Relative to the worktree root, which is where git runs
41
- * a merge driver from, and where `mandrel sync` materializes `.agents/`.
46
+ * Build the driver command for a given node binary.
47
+ *
48
+ * The interpreter is the RESOLVED ABSOLUTE path (`process.execPath`), not the
49
+ * bare word `node`, and it is quoted. Git runs a merge driver through the
50
+ * shell, and that shell's `PATH` is whatever launched git — a GUI client, a
51
+ * Finder-launched editor, a `launchd` job — none of which necessarily carry
52
+ * the nvm/volta shim that puts `node` on an interactive shell's `PATH`. A
53
+ * driver that cannot start is not a loud failure: git reports the driver
54
+ * exited non-zero and falls back to leaving the file conflicted, which reads
55
+ * as "baselines conflict again" — the exact symptom the driver exists to
56
+ * remove. Quoting covers the installation whose node lives under a path with
57
+ * a space (`/Users/a b/.nvm/...`).
58
+ *
59
+ * The script path stays RELATIVE: git runs the driver from the worktree root,
60
+ * and that is also where `mandrel sync` materializes `.agents/`, so one
61
+ * command is correct in the main checkout and in every linked worktree.
62
+ *
63
+ * @param {string} [execPath] Absolute path to the node binary.
64
+ * @returns {string}
42
65
  */
43
- const BASELINE_MERGE_DRIVER_COMMAND =
44
- 'node .agents/scripts/merge-baseline.js %O %A %B %P';
66
+ function buildBaselineMergeDriverCommand(execPath = process.execPath) {
67
+ return `"${execPath}" ${DRIVER_SCRIPT} ${DRIVER_PLACEHOLDERS}`;
68
+ }
45
69
 
46
- /** The exact command an operator runs to complete registration. */
47
- export const BASELINE_MERGE_DRIVER_REMEDY = `git config ${BASELINE_MERGE_DRIVER_CONFIG_KEY} "${BASELINE_MERGE_DRIVER_COMMAND}"`;
70
+ /** The driver command this process would install. */
71
+ const BASELINE_MERGE_DRIVER_COMMAND = buildBaselineMergeDriverCommand();
72
+
73
+ /**
74
+ * The exact command an operator runs to complete registration. Single-quoted
75
+ * because the command itself carries the double quotes around the node path.
76
+ */
77
+ export const BASELINE_MERGE_DRIVER_REMEDY = `git config ${BASELINE_MERGE_DRIVER_CONFIG_KEY} '${BASELINE_MERGE_DRIVER_COMMAND}'`;
78
+
79
+ /**
80
+ * Split a configured driver command into an executable + argv pair, dropping
81
+ * git's `%O %A %B %P` placeholders.
82
+ *
83
+ * Used by the `mandrel doctor` check to actually RUN the configured command
84
+ * (with `--help`) rather than only assert the config key is non-empty. A key
85
+ * pointing at a node that no longer exists — the ordinary outcome of an nvm
86
+ * version bump after the driver was installed — is set, non-empty, and
87
+ * completely broken, and the pre-Story-#5277 check called that healthy.
88
+ *
89
+ * Tokenising here rather than handing the string to a shell is deliberate:
90
+ * the value is per-clone git config, and executing it through `shell: true`
91
+ * would turn any write to that key into arbitrary command execution
92
+ * (`security-baseline.md` § Output & Rendering — never concatenate into a
93
+ * shell command). Only double quotes are honoured, which is exactly the
94
+ * quoting {@link buildBaselineMergeDriverCommand} emits.
95
+ *
96
+ * @param {string|null|undefined} command
97
+ * @returns {{ file: string, args: string[] }|null} null when there is no
98
+ * runnable first token.
99
+ */
100
+ export function parseBaselineMergeDriverCommand(command) {
101
+ const tokens = String(command ?? '').match(/"[^"]*"|\S+/g) ?? [];
102
+ const cleaned = tokens
103
+ .map((token) =>
104
+ token.startsWith('"') && token.endsWith('"') && token.length >= 2
105
+ ? token.slice(1, -1)
106
+ : token,
107
+ )
108
+ .filter((token) => token.length > 0 && !/^%[A-Za-z]$/.test(token));
109
+ if (cleaned.length === 0) return null;
110
+ const [file, ...args] = cleaned;
111
+ return { file, args };
112
+ }
48
113
 
49
114
  /**
50
115
  * Does this `.gitattributes` content route baselines through the driver?
@@ -53,7 +118,7 @@ export const BASELINE_MERGE_DRIVER_REMEDY = `git config ${BASELINE_MERGE_DRIVER_
53
118
  * @param {string|null|undefined} gitattributes
54
119
  * @returns {boolean}
55
120
  */
56
- export function declaresBaselineMergeDriver(gitattributes) {
121
+ function declaresBaselineMergeDriver(gitattributes) {
57
122
  return String(gitattributes ?? '')
58
123
  .split('\n')
59
124
  .some((line) => {
@@ -63,6 +128,55 @@ export function declaresBaselineMergeDriver(gitattributes) {
63
128
  });
64
129
  }
65
130
 
131
+ /**
132
+ * Is the driver DECLARED by this project and REGISTERED in this clone?
133
+ *
134
+ * Two callers ask this exact question of two different clones — the
135
+ * `mandrel doctor` check, and the pre-push base-sync guard — and both need
136
+ * the same two-part answer, because the two halves fail differently:
137
+ *
138
+ * - **Not declared** → the project never opted in. Both callers stay
139
+ * silent; telling a consumer to register a driver for files they do not
140
+ * route through it is noise. An unreadable `.gitattributes` reads the
141
+ * same way, deliberately.
142
+ * - **Declared, empty command** → the silent case. Git reports nothing at
143
+ * all: it falls back to its own line-based merge, which on a generated
144
+ * baseline either conflicts on the `generatedAt` stamp or splices both
145
+ * sides' rows into a set neither side scored.
146
+ *
147
+ * The `runGit` seam takes argv tokens and returns `{ status, stdout }`, which
148
+ * is the shape both callers' own git surfaces already produce.
149
+ *
150
+ * @param {{
151
+ * projectRoot: string,
152
+ * fsImpl?: typeof fs,
153
+ * runGit: (args: string[]) => { status?: number|null, stdout?: unknown },
154
+ * }} ctx
155
+ * @returns {{ declared: boolean, command: string }}
156
+ */
157
+ export function probeBaselineMergeDriver({ projectRoot, fsImpl = fs, runGit }) {
158
+ let attributes = '';
159
+ try {
160
+ attributes = fsImpl.readFileSync(
161
+ path.join(projectRoot, '.gitattributes'),
162
+ 'utf8',
163
+ );
164
+ } catch {
165
+ attributes = '';
166
+ }
167
+ if (!declaresBaselineMergeDriver(attributes)) {
168
+ return { declared: false, command: '' };
169
+ }
170
+ const configured = runGit([
171
+ 'config',
172
+ '--get',
173
+ BASELINE_MERGE_DRIVER_CONFIG_KEY,
174
+ ]);
175
+ const command =
176
+ configured?.status === 0 ? String(configured.stdout ?? '').trim() : '';
177
+ return { declared: true, command };
178
+ }
179
+
66
180
  /**
67
181
  * Add the attribute line, preserving every existing line verbatim.
68
182
  *
@@ -144,32 +258,84 @@ function ensureDriverGitConfig(projectRoot, spawnImpl = spawnCapture) {
144
258
  }
145
259
 
146
260
  /**
147
- * Install both halves. Idempotent: a second run reports `already-present`
148
- * and changes no bytes.
261
+ * Read `.gitattributes` without writing it — the `configOnly` half of
262
+ * {@link ensureBaselineMergeDriver}.
263
+ *
264
+ * @param {string} projectRoot
265
+ * @param {typeof fs} fsImpl
266
+ * @returns {{ action: 'already-present'|'absent', path: string }}
267
+ */
268
+ function probeGitattributesLine(projectRoot, fsImpl) {
269
+ const target = path.join(projectRoot, '.gitattributes');
270
+ let existing = '';
271
+ try {
272
+ existing = fsImpl.readFileSync(target, 'utf8');
273
+ } catch {
274
+ existing = '';
275
+ }
276
+ return {
277
+ action: declaresBaselineMergeDriver(existing)
278
+ ? 'already-present'
279
+ : 'absent',
280
+ path: target,
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Install the registration. Idempotent: a second run reports
286
+ * `already-present` and changes no bytes.
287
+ *
288
+ * Two modes, because the two callers mean different things by "install":
289
+ *
290
+ * - **Full (default)** — `mandrel init --with-quality` and this repo's own
291
+ * `prepare`. Writes the `.gitattributes` line as well as the config key:
292
+ * the caller has opted the repository into the quality surface, so
293
+ * declaring the attribute is part of what it asked for.
294
+ * - **`configOnly: true`** — consumer `mandrel sync`. Writes ONLY the
295
+ * per-clone config key, and only when `.gitattributes` already declares the
296
+ * attribute. `sync` materializes `.agents/`; it is not an opt-in to the
297
+ * quality surface, and creating a `.gitattributes` in a project that never
298
+ * asked for one would be `sync` changing git's behaviour on files it has no
299
+ * business touching. What it DOES fix is the real gap: the attribute is
300
+ * tracked and therefore ships with the repo, while the config key is
301
+ * per-clone and silently absent in every fresh clone — so the half that
302
+ * cannot travel is installed on the one command every consumer runs.
149
303
  *
150
304
  * @param {object} ctx
151
305
  * @param {string} ctx.projectRoot
306
+ * @param {boolean} [ctx.configOnly]
152
307
  * @param {typeof spawnCapture} [ctx.spawnImpl]
153
308
  * @param {typeof fs} [ctx.fsImpl]
154
309
  * @returns {{
155
- * action: 'already-present'|'updated',
310
+ * action: 'already-present'|'updated'|'skipped',
156
311
  * attributes: string,
157
312
  * config: string,
158
313
  * path: string,
159
314
  * line: string,
315
+ * command: string,
160
316
  * }}
161
317
  */
162
318
  export function ensureBaselineMergeDriver(ctx) {
163
- const attributes = ensureGitattributesLine(ctx.projectRoot, ctx.fsImpl ?? fs);
319
+ const fsImpl = ctx.fsImpl ?? fs;
320
+ const attributes = ctx.configOnly
321
+ ? probeGitattributesLine(ctx.projectRoot, fsImpl)
322
+ : ensureGitattributesLine(ctx.projectRoot, fsImpl);
323
+ const base = {
324
+ attributes: attributes.action,
325
+ path: attributes.path,
326
+ line: BASELINE_MERGE_ATTRIBUTE,
327
+ command: BASELINE_MERGE_DRIVER_COMMAND,
328
+ };
329
+ if (attributes.action === 'absent') {
330
+ return { ...base, action: 'skipped', config: 'skipped' };
331
+ }
164
332
  const config = ensureDriverGitConfig(ctx.projectRoot, ctx.spawnImpl);
165
333
  const settled =
166
334
  attributes.action === 'already-present' &&
167
335
  (config.action === 'already-present' || config.action === 'not-a-repo');
168
336
  return {
337
+ ...base,
169
338
  action: settled ? 'already-present' : 'updated',
170
- attributes: attributes.action,
171
339
  config: config.action,
172
- path: attributes.path,
173
- line: BASELINE_MERGE_ATTRIBUTE,
174
340
  };
175
341
  }
@@ -62,6 +62,22 @@ function parsePositiveInt(value) {
62
62
  return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
63
63
  }
64
64
 
65
+ /**
66
+ * The same contract for a flag whose ZERO is meaningful (Story #5266):
67
+ * `--rerun-advisory 0` is an explicit "spend nothing", not an absent flag.
68
+ * A negative or non-numeric value is still treated as absent rather than
69
+ * coerced, so a typo falls back to the config default instead of inventing an
70
+ * allowance that spends the consumer's CI minutes.
71
+ *
72
+ * @param {unknown} value
73
+ * @returns {number|undefined}
74
+ */
75
+ function parseNonNegativeInt(value) {
76
+ if (value === undefined || value === null) return undefined;
77
+ const parsed = Number.parseInt(String(value), 10);
78
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined;
79
+ }
80
+
65
81
  /** The only two merge-watch postures `--merge-watch-mode` accepts. */
66
82
  const MERGE_WATCH_MODES = ['sync', 'async'];
67
83
 
@@ -200,6 +216,10 @@ export function parseSprintArgs(
200
216
  // Absent means "use the config"; see `parseMergeWatchMode` for why an
201
217
  // unrecognized value fails closed instead of degrading to absent.
202
218
  'merge-watch-mode': { type: 'string' },
219
+ // Story #5266 — per-invocation override of `delivery.ci.rerunAdvisory`.
220
+ // Absent means "use the config", whose default is 0: close re-runs
221
+ // nothing and mutates no GitHub state on an advisory red unless asked.
222
+ 'rerun-advisory': { type: 'string' },
203
223
  // Sanctioned override of a code-review critical blocker.
204
224
  // Absent means "the blocker blocks"; see `parseOverrideReviewBlock` for
205
225
  // why a bare or too-short reason fails closed instead of arming a silent
@@ -240,6 +260,12 @@ export function parseSprintArgs(
240
260
  mergeWatchMode: tolerant
241
261
  ? tolerantMergeWatchMode(values['merge-watch-mode'])
242
262
  : parseMergeWatchMode(values['merge-watch-mode']),
263
+ // Story #5266 — how many times close may re-run a failed ADVISORY run
264
+ // before blocking on it. `undefined` when absent, which is what lets the
265
+ // merge wait fall back to `delivery.ci.rerunAdvisory` (default 0).
266
+ // `parseNonNegativeInt` degrades a junk value to undefined rather than
267
+ // guessing an allowance that would spend the consumer's CI minutes.
268
+ rerunAdvisory: parseNonNegativeInt(values['rerun-advisory']),
243
269
  // The operator's recorded reason for overriding a review
244
270
  // blocker. `undefined` when the flag is absent, which is what keeps the
245
271
  // blocker blocking by default.
@@ -9,7 +9,9 @@
9
9
  import { existsSync } from 'node:fs';
10
10
 
11
11
  import { _internals as baselineReaderInternals } from '../baselines/reader.js';
12
+ import { getChangedFiles } from '../changed-files.js';
12
13
  import { getQuality } from '../config/quality.js';
14
+ import { filterFilesUnderTargets } from '../coverage-capture.js';
13
15
  import { hasNpmScript, readPackageScripts } from '../npm-scripts.js';
14
16
  import { KNOWN_KINDS } from '../orchestration/check-baselines/phases/parse-args.js';
15
17
  import {
@@ -28,6 +30,11 @@ import {
28
30
  * @property {string[]} args - Arguments passed to `cmd`.
29
31
  * @property {string} [hint] - Remediation hint shown on failure.
30
32
  * @property {{ baseRef: string }} [changedFileScope] - Optional Story-diff scope.
33
+ * @property {{ reason: string }} [skip] - Pre-decided skip (Story #5278). The
34
+ * runner records the gate as skipped with this reason and never spawns it.
35
+ * Used for the `coverage-capture` gate when the incremental-coverage skip
36
+ * is already known to fire, so the gate list can register a real `npm test`
37
+ * gate in its place instead of the close silently running no test gate.
31
38
  * @property {Record<string, string>} [env] - Optional per-gate environment
32
39
  * overlay. Merged over `process.env` for this gate's spawned child only.
33
40
  * Used to thread the epic baseRef into the `check-baselines` gate via
@@ -112,12 +119,18 @@ function isCrapGateEnabled(config) {
112
119
  * has NO working test gate at all. Splitting this out keeps
113
120
  * `buildDefaultGates` flat for the CRAP-cyclomatic gate.
114
121
  *
115
- * @param {boolean} coverageCaptureActive - Whether the coverage-capture gate
116
- * is registered as the test runner for this build.
122
+ * Story #5278 adds a third way for coverage-capture to stop being the test
123
+ * runner: it is registered, but its own incremental-coverage skip is already
124
+ * known to fire (nothing changed under `crap.targetDirs`), so it will exit 0
125
+ * without running anything. A tests-only Story hits that on every close, and
126
+ * before #5278 the close then recorded a suite it never ran as `passed`.
127
+ *
128
+ * @param {boolean} coverageCaptureRunsSuite - Whether the coverage-capture
129
+ * gate will actually run the suite for this build.
117
130
  * @returns {Gate[]}
118
131
  */
119
- function buildTestGateEntry(coverageCaptureActive) {
120
- if (coverageCaptureActive) return [];
132
+ function buildTestGateEntry(coverageCaptureRunsSuite) {
133
+ if (coverageCaptureRunsSuite) return [];
121
134
  // Story #5173 — `fullSuiteLock` marks the one gate here that spawns a whole
122
135
  // suite, so `defaultGateRunner` serializes it behind the host lock. It is
123
136
  // set on this entry alone precisely because the two full-suite gates are
@@ -350,6 +363,78 @@ function splitCommand(commandString) {
350
363
  return { cmd, args };
351
364
  }
352
365
 
366
+ /**
367
+ * Will the `coverage-capture` gate skip its own capture before running
368
+ * anything? (Story #5278.)
369
+ *
370
+ * `coverage-capture-incremental.js` exits 0 without a suite when no changed
371
+ * file lives under `crap.targetDirs` — the saving that makes incremental mode
372
+ * worth having. The gate list has to know that in advance, because the
373
+ * consequence is not "coverage-capture is cheap today" but "there is no test
374
+ * gate in this close at all": the plain `test` gate is dropped precisely
375
+ * because coverage-capture was going to carry test-failure signalling. A
376
+ * tests-only Story therefore closed green over a red suite.
377
+ *
378
+ * Predicting the skip is safe in one direction only, so every uncertainty
379
+ * resolves to `false` (coverage-capture runs, no extra `test` gate — the
380
+ * pre-#5278 shape): an unresolvable ref, a missing cwd, a git error, or the
381
+ * mode being off. A wrong `false` costs one redundant capture; a wrong `true`
382
+ * would register a `test` gate beside a coverage-capture that also runs the
383
+ * suite, which is the double-spend the credit economy exists to prevent.
384
+ *
385
+ * @param {{
386
+ * config?: object,
387
+ * cwd?: string,
388
+ * baseBranch?: string,
389
+ * getChangedFilesImpl?: typeof getChangedFiles,
390
+ * }} opts
391
+ * @returns {boolean}
392
+ */
393
+ function predictsIncrementalCaptureSkip({
394
+ config,
395
+ cwd,
396
+ baseBranch,
397
+ getChangedFilesImpl = getChangedFiles,
398
+ }) {
399
+ // No cwd is the module-load `DEFAULT_GATES` case: never spawn git at import
400
+ // time just to answer a question that caller cannot act on.
401
+ if (typeof cwd !== 'string' || cwd.length === 0) return false;
402
+ const { crap } = getQuality(config);
403
+ if (crap?.incrementalCoverage?.skipWhenUnchanged !== true) return false;
404
+ const ref = crap.incrementalCoverage.baseRef || baseBranch;
405
+ if (typeof ref !== 'string' || ref.length === 0) return false;
406
+ try {
407
+ const changed = getChangedFilesImpl({ ref, cwd });
408
+ // Not an array is "the change set is unknown", not "the change set is
409
+ // empty" — and `filterFilesUnderTargets` flattens both to `[]`, so the
410
+ // shape has to be checked here or an unknown diff reads as a skip.
411
+ if (!Array.isArray(changed)) return false;
412
+ return filterFilesUnderTargets(changed, crap.targetDirs).length === 0;
413
+ } catch {
414
+ return false;
415
+ }
416
+ }
417
+
418
+ /**
419
+ * The `coverage-capture` gate's argv.
420
+ *
421
+ * Story #5278 — `--require-credited` is passed here, and only here, when the
422
+ * consumer has set `delivery.execution.requireCreditedCapture`. The CLI no
423
+ * longer reads that key, so the worker's pre-push deposit invocation always
424
+ * runs while the close gate refuses to pay for a suite the worker should
425
+ * already have banked.
426
+ *
427
+ * @param {object} [config]
428
+ * @returns {string[]}
429
+ */
430
+ function buildCoverageCaptureArgs(config) {
431
+ const args = ['.agents/scripts/coverage-capture.js'];
432
+ if (config?.delivery?.execution?.requireCreditedCapture === true) {
433
+ args.push('--require-credited');
434
+ }
435
+ return args;
436
+ }
437
+
353
438
  /**
354
439
  * Build the canonical close-validation gate list.
355
440
  *
@@ -402,7 +487,7 @@ function splitCommand(commandString) {
402
487
  * none are required, the gate is skipped with a logged reason (via `log`)
403
488
  * instead of a blocking failure.
404
489
  *
405
- * @param {{ config?: object, baseBranch?: string, cwd?: string, packageScripts?: Record<string, string>, presentBaselines?: string[]|Set<string>, log?: (message: string) => void }} [opts]
490
+ * @param {{ config?: object, baseBranch?: string, cwd?: string, packageScripts?: Record<string, string>, presentBaselines?: string[]|Set<string>, log?: (message: string) => void, getChangedFilesImpl?: typeof getChangedFiles }} [opts]
406
491
  * `config` is the canonical resolved config (`{ project, delivery, ... }`);
407
492
  * gate commands resolve from `project.commands` and the CRAP toggle from
408
493
  * `delivery.quality.gates.crap.enabled`. `baseBranch` is the close run's
@@ -424,10 +509,28 @@ export function buildDefaultGates({
424
509
  packageScripts,
425
510
  presentBaselines,
426
511
  log,
512
+ getChangedFilesImpl,
427
513
  } = {}) {
428
514
  const scripts = packageScripts ?? readPackageScripts(cwd);
429
515
  const coverageCaptureActive =
430
516
  isCrapGateEnabled(config) && hasNpmScript(scripts, 'test:coverage');
517
+ // Story #5278 — a registered coverage-capture gate that is going to take
518
+ // its own incremental skip is not the test runner for this close, so the
519
+ // plain `test` gate comes back beside it and the capture gate registers as
520
+ // a pre-decided skip rather than as a suite that silently did not run.
521
+ const captureSkipPredicted =
522
+ coverageCaptureActive &&
523
+ predictsIncrementalCaptureSkip({
524
+ config,
525
+ cwd,
526
+ baseBranch,
527
+ ...(getChangedFilesImpl ? { getChangedFilesImpl } : {}),
528
+ });
529
+ if (captureSkipPredicted) {
530
+ log?.(
531
+ '[close-validation] coverage-capture will take the incremental skip (no changed file under the CRAP target dirs) — registering the plain `test` gate so this close still runs the suite.',
532
+ );
533
+ }
431
534
  const typecheck = splitCommand(resolveTypecheckCommand(config));
432
535
  const lint = splitCommand(resolveLintCommand(config));
433
536
  const formatCheckString = resolveFormatCheckCommand(config);
@@ -460,7 +563,7 @@ export function buildDefaultGates({
460
563
  // scoped pair does not shift the close-orchestrator log line, the
461
564
  // evidence keyspace, or the parallel-partition membership below.
462
565
  { name: 'lint', cmd: lint.cmd, args: lint.args },
463
- ...buildTestGateEntry(coverageCaptureActive),
566
+ ...buildTestGateEntry(coverageCaptureActive && !captureSkipPredicted),
464
567
  {
465
568
  // Gate name kept generic ("format") so the close-orchestrator log line
466
569
  // doesn't shift when a repo swaps biome for Prettier / dprint via
@@ -479,8 +582,11 @@ export function buildDefaultGates({
479
582
  {
480
583
  name: 'coverage-capture',
481
584
  cmd: 'node',
482
- args: ['.agents/scripts/coverage-capture.js'],
585
+ args: buildCoverageCaptureArgs(config),
483
586
  hint: 'Coverage capture failed — `npm run test:coverage` exited non-zero. Fix failing tests or coverage-threshold breaches, then re-run close.',
587
+ ...(captureSkipPredicted
588
+ ? { skip: { reason: 'incremental-no-crap-changes' } }
589
+ : {}),
484
590
  },
485
591
  ]
486
592
  : []),
@@ -153,15 +153,19 @@ function isBiomeNoFilesProcessed(output) {
153
153
  *
154
154
  * @param {string} cmd
155
155
  * @param {string[]} args
156
- * @param {{ cwd: string, signal?: AbortSignal, gateName?: string, log?: (m: string) => void, env?: Record<string, string>, tolerateNoFilesProcessed?: boolean, fullSuiteLock?: boolean }} opts
156
+ * @param {{ cwd: string, signal?: AbortSignal, gateName?: string, log?: (m: string) => void, env?: Record<string, string>, tolerateNoFilesProcessed?: boolean, fullSuiteLock?: boolean, skipIfSatisfied?: () => {status: number}|undefined }} opts
157
157
  * @returns {Promise<{ status: number }>}
158
158
  */
159
159
  export function defaultGateRunner(cmd, args, opts = {}) {
160
160
  if (!opts.fullSuiteLock) return spawnGate(cmd, args, opts);
161
161
  // `log` is passed through as-is: `withFullSuiteLockAsync` supplies its own
162
162
  // no-op default, so a second fallback here would be an untestable branch.
163
- return withFullSuiteLockAsync({ cwd: opts.cwd, log: opts.log }, () =>
164
- spawnGate(cmd, args, opts),
163
+ // `skipIfSatisfied` (Story #5278) is the caller's post-wait re-probe: after
164
+ // queueing behind another full suite, the gate re-asks whether its evidence
165
+ // has since been deposited and returns that verdict instead of spawning.
166
+ return withFullSuiteLockAsync(
167
+ { cwd: opts.cwd, log: opts.log, skipIfSatisfied: opts.skipIfSatisfied },
168
+ () => spawnGate(cmd, args, opts),
165
169
  );
166
170
  }
167
171
 
@@ -8,9 +8,11 @@
8
8
  * surfaces actionable hints on failure.
9
9
  */
10
10
 
11
+ import { gitSpawn } from '../git-utils.js';
11
12
  import {
12
13
  recordPass as defaultRecordPass,
13
14
  shouldSkip as defaultShouldSkip,
15
+ treeFingerprint as defaultTreeFingerprint,
14
16
  hashCommandConfig,
15
17
  } from '../validation-evidence.js';
16
18
  import {
@@ -25,6 +27,20 @@ import { defaultGetHeadSha } from './projections/head-sha.js';
25
27
  /** @typedef {import('./gates.js').Gate} Gate */
26
28
 
27
29
  function applyChangedFileScope({ gate, spawnCwd, log }) {
30
+ // Story #5278 — a skip the gate list already decided (the coverage-capture
31
+ // gate whose incremental skip is known to fire). Honoured before anything
32
+ // else so the gate is recorded as `skipped` with its real reason rather
33
+ // than spawned to discover the same thing minutes later.
34
+ if (gate.skip) {
35
+ log(`[close-validation] ⏭ ${gate.name} skipped (${gate.skip.reason})`);
36
+ return {
37
+ gate,
38
+ cmd: gate.cmd,
39
+ args: gate.args,
40
+ skip: true,
41
+ skipReason: gate.skip.reason,
42
+ };
43
+ }
28
44
  if (!gate.changedFileScope) {
29
45
  return { gate, cmd: gate.cmd, args: gate.args, skip: false };
30
46
  }
@@ -116,6 +132,7 @@ function applyChangedFileScope({ gate, spawnCwd, log }) {
116
132
  * useEvidence?: boolean,
117
133
  * evidenceClock?: () => number,
118
134
  * getHeadSha?: (cwd: string) => string|null,
135
+ * getTreeFingerprint?: (cwd: string) => string|null,
119
136
  * recordPass?: typeof defaultRecordPass,
120
137
  * shouldSkip?: typeof defaultShouldSkip,
121
138
  * }} opts
@@ -137,6 +154,8 @@ export async function runCloseValidation({
137
154
  useEvidence = true,
138
155
  evidenceClock = () => Date.now(),
139
156
  getHeadSha = (resolvedCwd) => defaultGetHeadSha(resolvedCwd),
157
+ getTreeFingerprint = (resolvedCwd) =>
158
+ defaultTreeFingerprint(resolvedCwd, gitSpawn),
140
159
  recordPass = defaultRecordPass,
141
160
  shouldSkip = defaultShouldSkip,
142
161
  } = {}) {
@@ -153,6 +172,13 @@ export async function runCloseValidation({
153
172
  // Story #1120.
154
173
  const spawnCwd = worktreePath ?? cwd;
155
174
  const headSha = evidenceActive ? getHeadSha(spawnCwd) : null;
175
+ // Story #5278 — one tree fingerprint for the whole run, not one per gate.
176
+ // Every gate here reads the same working tree, so an identical tree means
177
+ // identical inputs for all of them; a per-gate scope would be narrower but
178
+ // would have to model each gate's read set, and being wrong about that
179
+ // grants a skip the gate did not earn. A gate that carries its own
180
+ // `inputFingerprint` still wins.
181
+ const treeSha = evidenceActive ? getTreeFingerprint(spawnCwd) : null;
156
182
 
157
183
  // Helper closures so the parallel and serial passes share evidence
158
184
  // bookkeeping bit-for-bit.
@@ -166,7 +192,7 @@ export async function runCloseValidation({
166
192
  gateName: gate.name,
167
193
  currentSha: headSha,
168
194
  configHash,
169
- inputFingerprint: gate.inputFingerprint ?? null,
195
+ inputFingerprint: gate.inputFingerprint ?? treeSha,
170
196
  },
171
197
  evidenceStoreOpts,
172
198
  );
@@ -192,7 +218,7 @@ export async function runCloseValidation({
192
218
  configHash,
193
219
  exitCode: 0,
194
220
  durationMs,
195
- inputFingerprint: gate.inputFingerprint ?? null,
221
+ inputFingerprint: gate.inputFingerprint ?? treeSha,
196
222
  },
197
223
  evidenceStoreOpts,
198
224
  );
@@ -214,7 +240,7 @@ export async function runCloseValidation({
214
240
  *
215
241
  * @returns {Promise<{ status: number }>}
216
242
  */
217
- const dispatchGate = async (gate, signal) => {
243
+ const dispatchGate = async (gate, signal, configHash) => {
218
244
  log(
219
245
  `[close-validation] ▶ ${gate.name}${worktreePath ? ` (cwd=${worktreePath})` : ''}`,
220
246
  );
@@ -226,6 +252,20 @@ export async function runCloseValidation({
226
252
  log,
227
253
  signal,
228
254
  ...(gate.env ? { env: gate.env } : {}),
255
+ // Story #5278 — only the full-suite gate can end up *waiting* on the
256
+ // host lock, and only it is expensive enough for the wait to change the
257
+ // answer: whoever we queued behind may have deposited this gate's
258
+ // evidence while we sat there. Re-asking the same question the runner
259
+ // asked before the wait is the whole mechanism; a `{ status: 0 }`
260
+ // stands in for the spawn.
261
+ ...(gate.fullSuiteLock && configHash
262
+ ? {
263
+ skipIfSatisfied: () =>
264
+ evidenceVerdict(gate, configHash).skip
265
+ ? { status: 0 }
266
+ : undefined,
267
+ }
268
+ : {}),
229
269
  // Story #5173 — forwarded unconditionally (never a conditional spread
230
270
  // like the two below): `defaultGateRunner` already treats a falsy value
231
271
  // as "no lock", so a branch here would only add a decision point to the
@@ -263,7 +303,10 @@ export async function runCloseValidation({
263
303
  return;
264
304
  }
265
305
  if (execution.skip) {
266
- skipped.push({ gate, reason: 'no-changed-files' });
306
+ skipped.push({
307
+ gate,
308
+ reason: execution.skipReason ?? 'no-changed-files',
309
+ });
267
310
  return;
268
311
  }
269
312
  const configHash = hashCommandConfig({
@@ -287,6 +330,7 @@ export async function runCloseValidation({
287
330
  tolerateNoFilesProcessed: execution.tolerateNoFilesProcessed,
288
331
  },
289
332
  ac.signal,
333
+ configHash,
290
334
  );
291
335
  } catch (err) {
292
336
  result = { status: 1, error: err };
@@ -395,7 +439,10 @@ async function runSerialGates(
395
439
  return;
396
440
  }
397
441
  if (execution.skip) {
398
- skipped.push({ gate, reason: 'no-changed-files' });
442
+ skipped.push({
443
+ gate,
444
+ reason: execution.skipReason ?? 'no-changed-files',
445
+ });
399
446
  continue;
400
447
  }
401
448
  const configHash = hashCommandConfig({
@@ -409,12 +456,16 @@ async function runSerialGates(
409
456
  continue;
410
457
  }
411
458
  const startedAt = evidenceActive ? evidenceClock() : 0;
412
- const result = await dispatchGate({
413
- ...gate,
414
- cmd: execution.cmd,
415
- args: execution.args,
416
- tolerateNoFilesProcessed: execution.tolerateNoFilesProcessed,
417
- });
459
+ const result = await dispatchGate(
460
+ {
461
+ ...gate,
462
+ cmd: execution.cmd,
463
+ args: execution.args,
464
+ tolerateNoFilesProcessed: execution.tolerateNoFilesProcessed,
465
+ },
466
+ undefined,
467
+ configHash,
468
+ );
418
469
  if (result.status !== 0) {
419
470
  failGate(
420
471
  gate,