mandrel 2.56.0 → 2.58.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/plan-critic.md +13 -18
  2. package/.agents/agents/story-worker.md +25 -33
  3. package/.agents/docs/agentrc-reference.json +0 -30
  4. package/.agents/docs/configuration.md +8 -28
  5. package/.agents/docs/execution-reference.md +5 -5
  6. package/.agents/docs/quality-gates.md +8 -7
  7. package/.agents/instructions.md +9 -10
  8. package/.agents/schemas/agentrc.schema.json +9 -185
  9. package/.agents/schemas/story-deliver-terminal.schema.json +1 -1
  10. package/.agents/scripts/acceptance-eval.js +107 -17
  11. package/.agents/scripts/ceremony-derive.js +191 -0
  12. package/.agents/scripts/check-context-budget.js +28 -33
  13. package/.agents/scripts/check-cyclomatic.js +4 -3
  14. package/.agents/scripts/deliver-light.js +31 -94
  15. package/.agents/scripts/evidence-gate.js +17 -1
  16. package/.agents/scripts/lib/audit-suite/checklist-threading.js +15 -2
  17. package/.agents/scripts/lib/baselines/coverage-updater-cli.js +110 -0
  18. package/.agents/scripts/lib/baselines/crap-preview-scan.js +25 -0
  19. package/.agents/scripts/lib/baselines/crap-updater-cli.js +223 -0
  20. package/.agents/scripts/lib/bdd-scenario-budget.js +21 -3
  21. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +0 -1
  22. package/.agents/scripts/lib/close-validation/gates.js +52 -1
  23. package/.agents/scripts/lib/config/acceptance-eval.js +25 -57
  24. package/.agents/scripts/lib/config/delivery-routing.js +7 -33
  25. package/.agents/scripts/lib/config/explain.js +0 -19
  26. package/.agents/scripts/lib/config/limits.js +18 -78
  27. package/.agents/scripts/lib/config/quality.js +6 -3
  28. package/.agents/scripts/lib/config/runners.js +3 -2
  29. package/.agents/scripts/lib/config-settings-schema-delivery.js +15 -68
  30. package/.agents/scripts/lib/config-settings-schema-quality.js +0 -14
  31. package/.agents/scripts/lib/config-settings-schema.js +16 -143
  32. package/.agents/scripts/lib/crap-engine.js +35 -4
  33. package/.agents/scripts/lib/crap-utils.js +17 -1
  34. package/.agents/scripts/lib/cyclomatic-ceiling.js +19 -7
  35. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  36. package/.agents/scripts/lib/observability/runtime-friction.js +1 -1
  37. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  38. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +5 -4
  39. package/.agents/scripts/lib/orchestration/ceremony-routing.js +19 -73
  40. package/.agents/scripts/lib/orchestration/code-review.js +7 -3
  41. package/.agents/scripts/lib/orchestration/complexity-gate.js +46 -212
  42. package/.agents/scripts/lib/orchestration/file-assumptions.js +32 -17
  43. package/.agents/scripts/lib/orchestration/light-escalation.js +3 -3
  44. package/.agents/scripts/lib/orchestration/light-suitability.js +66 -233
  45. package/.agents/scripts/lib/orchestration/pinned-identifier-lint.js +137 -0
  46. package/.agents/scripts/lib/orchestration/plan-context.js +189 -387
  47. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +42 -153
  48. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +14 -70
  49. package/.agents/scripts/lib/orchestration/plan-persist/acceptance-handle-repair.js +107 -0
  50. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +305 -0
  51. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +138 -170
  52. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +128 -297
  53. package/.agents/scripts/lib/orchestration/plan-persist/soft-findings.js +55 -0
  54. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +16 -65
  55. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +22 -35
  56. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +36 -135
  57. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +61 -223
  58. package/.agents/scripts/lib/orchestration/review-base-ref.js +138 -0
  59. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -0
  60. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +37 -5
  61. package/.agents/scripts/lib/orchestration/single-story-close/phases/pre-gate-steps.js +46 -16
  62. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +6 -1
  63. package/.agents/scripts/lib/orchestration/story-close/context-budget-writeback.js +213 -0
  64. package/.agents/scripts/lib/orchestration/task-body-validator.js +10 -63
  65. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +33 -539
  66. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +21 -414
  67. package/.agents/scripts/lib/orchestration/ticket-validator.js +54 -118
  68. package/.agents/scripts/lib/orchestration/verify-credit.js +69 -24
  69. package/.agents/scripts/lib/story-body/body-format-lints.js +15 -85
  70. package/.agents/scripts/lib/story-body/story-body.js +54 -240
  71. package/.agents/scripts/lib/templates/decomposer-prompts.js +133 -121
  72. package/.agents/scripts/lib/test-isolate/cli-options.js +93 -0
  73. package/.agents/scripts/lib/test-isolate/progress-log.js +45 -0
  74. package/.agents/scripts/lib/test-isolate/render-report.js +97 -0
  75. package/.agents/scripts/lib/test-isolate/run-isolate.js +87 -0
  76. package/.agents/scripts/lib/test-run-credit.js +277 -0
  77. package/.agents/scripts/lib/wave-runner/footprint.js +48 -358
  78. package/.agents/scripts/lib/wave-runner/ready-set.js +6 -5
  79. package/.agents/scripts/lib/workers/crap-worker.js +32 -41
  80. package/.agents/scripts/plan-context.js +7 -9
  81. package/.agents/scripts/plan-critics.js +28 -54
  82. package/.agents/scripts/plan-persist.js +25 -68
  83. package/.agents/scripts/quality-preview.js +51 -0
  84. package/.agents/scripts/run-tests.js +12 -0
  85. package/.agents/scripts/stories-wave-tick.js +23 -45
  86. package/.agents/scripts/test-isolate.js +13 -180
  87. package/.agents/scripts/update-coverage-baseline.js +25 -70
  88. package/.agents/scripts/update-crap-baseline.js +19 -123
  89. package/.agents/skills/core/scope-triage/SKILL.md +3 -3
  90. package/.agents/workflows/audit-clean-code.md +4 -3
  91. package/.agents/workflows/helpers/acceptance-self-eval.md +41 -41
  92. package/.agents/workflows/helpers/code-quality-guardrails.md +4 -4
  93. package/.agents/workflows/helpers/code-review.md +2 -3
  94. package/.agents/workflows/helpers/deliver-digest.md +46 -55
  95. package/.agents/workflows/helpers/deliver-light.md +40 -105
  96. package/.agents/workflows/helpers/deliver-reference.md +1 -1
  97. package/.agents/workflows/helpers/deliver-story-reference.md +54 -55
  98. package/.agents/workflows/helpers/deliver-story.md +10 -13
  99. package/.agents/workflows/helpers/plan-reference.md +163 -221
  100. package/.agents/workflows/mandrel-plan.md +31 -40
  101. package/.agents/workflows/memory-consolidate.md +9 -13
  102. package/docs/CHANGELOG.md +36 -0
  103. package/lib/cli/registry.js +98 -2
  104. package/lib/migrations/index.js +4 -0
  105. package/lib/migrations/steps/2.57.0-retire-delivery-limit-knobs.js +45 -0
  106. package/lib/migrations/steps/2.57.0-retire-planning-limit-knobs.js +59 -0
  107. package/package.json +1 -1
  108. package/.agents/scripts/lib/framework-version.js +0 -39
  109. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +0 -223
  110. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +0 -97
  111. package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +0 -26
  112. package/.agents/scripts/lib/orchestration/spec-budget.js +0 -89
  113. package/.agents/scripts/lib/orchestration/spec-spill.js +0 -74
  114. package/.agents/scripts/lib/orchestration/verify-tier-repair.js +0 -107
@@ -0,0 +1,97 @@
1
+ /**
2
+ * lib/test-isolate/render-report.js — the human-readable `test-isolate` report.
3
+ *
4
+ * Story #5316: extracted verbatim from `.agents/scripts/test-isolate.js`,
5
+ * where no test could reach it (CRAP 72 at cyclomatic 8, 0% coverage). Pure
6
+ * string building — no I/O, no clock — so the whole surface is assertable.
7
+ *
8
+ * The three sections it renders are split into helpers so each is
9
+ * independently readable and none of them alone approaches the cyclomatic
10
+ * ceiling; `renderReport` is left as the composition.
11
+ */
12
+
13
+ /**
14
+ * The flipper section: files that passed alone and failed in the suite, plus
15
+ * the bisection suspects for each.
16
+ *
17
+ * @param {string[]} lines Accumulator, appended in place.
18
+ * @param {import('./runner.js').IsolateReport} report
19
+ */
20
+ function pushFlipperSection(lines, report) {
21
+ if (report.flippers.length === 0) {
22
+ lines.push('✓ No flippers detected — every file that passed alone');
23
+ lines.push(' also passed in the full suite run.');
24
+ return;
25
+ }
26
+ lines.push(`✗ ${report.flippers.length} flipper(s) detected:`);
27
+ for (const f of report.flippers) lines.push(` - ${f}`);
28
+ lines.push('');
29
+ if (report.bisections.length === 0) return;
30
+ lines.push('Likely polluters (bisection suspects):');
31
+ for (const b of report.bisections) {
32
+ const tag = b.inconclusive ? ' [inconclusive]' : '';
33
+ lines.push(` ${b.file}${tag}`);
34
+ for (const s of b.suspects) lines.push(` ← ${s}`);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * One env-mutating file's added/removed/changed summary. Empty parts are
40
+ * omitted, so a file that only added a var reads as `added=[...]` alone.
41
+ *
42
+ * @param {{added: string[], removed: string[], changed: string[]}} envDiff
43
+ * @returns {string}
44
+ */
45
+ function formatEnvDiff(envDiff) {
46
+ const parts = [];
47
+ if (envDiff.added.length > 0)
48
+ parts.push(`added=[${envDiff.added.join(', ')}]`);
49
+ if (envDiff.removed.length > 0) {
50
+ parts.push(`removed=[${envDiff.removed.join(', ')}]`);
51
+ }
52
+ if (envDiff.changed.length > 0) {
53
+ parts.push(`changed=[${envDiff.changed.join(', ')}]`);
54
+ }
55
+ return parts.join(' ');
56
+ }
57
+
58
+ /**
59
+ * The env-leak section: files whose process exited with `process.env` still
60
+ * mutated, called out even when no failure cascade has manifested yet.
61
+ *
62
+ * @param {string[]} lines Accumulator, appended in place.
63
+ * @param {import('./runner.js').IsolateReport} report
64
+ */
65
+ function pushEnvSection(lines, report) {
66
+ if (report.envMutators.length === 0) {
67
+ lines.push('✓ No env-var leaks detected across isolated runs.');
68
+ return;
69
+ }
70
+ lines.push(
71
+ `⚠ ${report.envMutators.length} file(s) left process.env mutated:`,
72
+ );
73
+ for (const m of report.envMutators) {
74
+ lines.push(` ${m.file}`);
75
+ lines.push(` ${formatEnvDiff(m.envDiff)}`);
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Render the diagnostic report as text.
81
+ *
82
+ * @param {import('./runner.js').IsolateReport} report
83
+ * @returns {string}
84
+ */
85
+ export function renderReport(report) {
86
+ const lines = [];
87
+ lines.push('');
88
+ lines.push('=== test-isolate diagnostic report ===');
89
+ lines.push(`Files scanned: ${report.files.length}`);
90
+ lines.push(`Wall duration: ${(report.durationMs / 1000).toFixed(1)}s`);
91
+ lines.push('');
92
+ pushFlipperSection(lines, report);
93
+ lines.push('');
94
+ pushEnvSection(lines, report);
95
+ lines.push('');
96
+ return lines.join('\n');
97
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * lib/test-isolate/run-isolate.js — the `test-isolate` orchestration.
3
+ *
4
+ * Story #5316: moved out of `.agents/scripts/test-isolate.js` (CRAP 56 at
5
+ * cyclomatic 7, 0% coverage) so it can be driven from a test. The CLI shell
6
+ * that remains does nothing but call this and translate the exit code.
7
+ *
8
+ * `resolveFiles` and `diagnose` are named seams defaulting to the real
9
+ * implementations, per [`rules/test-seams.md`](../../../rules/test-seams.md):
10
+ * a unit test substitutes them rather than running real suites in child
11
+ * processes, and the production caller passes neither.
12
+ */
13
+
14
+ import { parseIsolateArgv } from './cli-options.js';
15
+ import { resolveTestFiles as defaultResolveTestFiles } from './list-files.js';
16
+ import { createProgressLogger } from './progress-log.js';
17
+ import { renderReport } from './render-report.js';
18
+ import { diagnoseIsolation as defaultDiagnoseIsolation } from './runner.js';
19
+
20
+ /**
21
+ * The report shape returned when no file matched the pattern — a real report
22
+ * with nothing in it, so callers never branch on null.
23
+ *
24
+ * @returns {import('./runner.js').IsolateReport}
25
+ */
26
+ function emptyReport() {
27
+ return {
28
+ pattern: null,
29
+ files: [],
30
+ isolated: [],
31
+ suite: [],
32
+ flippers: [],
33
+ bisections: [],
34
+ envMutators: [],
35
+ durationMs: 0,
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Run the isolation diagnosis end to end.
41
+ *
42
+ * Exit code is 1 when the run found anything worth an operator's attention —
43
+ * a flipper OR an env mutator — and 0 otherwise. An empty match set is not a
44
+ * failure: nothing was asked of the suite, so nothing can be wrong with it.
45
+ *
46
+ * @param {object} [opts]
47
+ * @param {string[]} [opts.argv]
48
+ * @param {string} [opts.repoRoot]
49
+ * @param {(line: string) => void} [opts.onLog]
50
+ * @param {(args: {pattern: string|undefined, repoRoot: string}) => string[]} [opts.resolveFiles]
51
+ * @param {(args: object) => Promise<import('./runner.js').IsolateReport>} [opts.diagnose]
52
+ * @returns {Promise<{ exitCode: number, report: import('./runner.js').IsolateReport }>}
53
+ */
54
+ export async function runTestIsolate({
55
+ argv = [],
56
+ repoRoot,
57
+ onLog = (s) => process.stdout.write(`${s}\n`),
58
+ resolveFiles = defaultResolveTestFiles,
59
+ diagnose = defaultDiagnoseIsolation,
60
+ } = {}) {
61
+ const options = parseIsolateArgv(argv);
62
+ const files = resolveFiles({ pattern: options.pattern, repoRoot });
63
+ if (files.length === 0) {
64
+ onLog(
65
+ `[test-isolate] no test files matched pattern: ${options.pattern ?? '<default>'}`,
66
+ );
67
+ return { exitCode: 0, report: emptyReport() };
68
+ }
69
+
70
+ if (!options.quiet) {
71
+ onLog(`[test-isolate] scanning ${files.length} file(s)...`);
72
+ }
73
+ const report = await diagnose({
74
+ repoRoot,
75
+ files,
76
+ workers: options.workers,
77
+ suiteConcurrency: options.suiteConcurrency,
78
+ maxBisectDepth: options.maxBisectDepth,
79
+ maxBisectTargets: options.maxBisectTargets,
80
+ onProgress: options.quiet ? undefined : createProgressLogger(onLog),
81
+ });
82
+
83
+ onLog(options.json ? JSON.stringify(report, null, 2) : renderReport(report));
84
+
85
+ const clean = report.flippers.length === 0 && report.envMutators.length === 0;
86
+ return { exitCode: clean ? 0 : 1, report };
87
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * lib/test-run-credit.js — let a green `npm test` **that routes through
3
+ * mandrel's own runner** earn the credit close reads (Story #5313, scoped by
4
+ * Story #5324).
5
+ *
6
+ * This is a **bonus, not the contract.** The deposit every project can rely
7
+ * on is `evidence-gate.js --standalone --scope-id <id> --gate test --worktree
8
+ * <workCwd> -- npm test`: it spawns whatever `npm test` resolves to and
9
+ * stamps what it just ran, so it is honest on any runner. What this module
10
+ * adds is that a repo whose `test` script *is* `run-tests.js` need not type
11
+ * that wrapper — the runner already knows the tree it ran against, whether
12
+ * the run was green, and whether it ran the whole suite, so it deposits on
13
+ * the way out.
14
+ *
15
+ * The reach is therefore exactly one call site: `run-tests.js`. A consumer
16
+ * whose `npm test` is `vitest run` or `jest` never loads this module, so it
17
+ * deposits nothing **and prints nothing** — silence is not a signal, and no
18
+ * delivery surface may tell an agent to confirm credit by reading for the
19
+ * line below. `mandrel doctor`'s `test-credit-path` check reports which of
20
+ * the two shapes a project is and names the wrapper as the remedy.
21
+ *
22
+ * On a green **full-tier** run inside a `story-<id>` checkout the runner
23
+ * records the `test` gate's evidence in the same keyspace
24
+ * `close-validation/runner.js` consults — keyed on HEAD and the tree
25
+ * fingerprint, hashed on the exact `{ cmd: 'npm', args: ['test'], cwd }`
26
+ * close will spawn — so close's `test` gate short-circuits at unchanged HEAD.
27
+ * The freshness keying is untouched: a later commit voids the record exactly
28
+ * as it voids one `evidence-gate.js` wrote.
29
+ *
30
+ * What it deliberately does **not** do is write the coverage capture stamp:
31
+ * that stamp is a claim that `coverage/coverage-final.json` describes this
32
+ * tree, and a bare `npm test` produces no such artifact. The CRAP gate still
33
+ * runs `coverage-capture.js` when it needs one.
34
+ *
35
+ * Total: every failure — not a Story branch, no git, an unwritable evidence
36
+ * file — is reported by reason and never fails the test run that earned it.
37
+ *
38
+ * @module lib/test-run-credit
39
+ */
40
+
41
+ import path from 'node:path';
42
+
43
+ import { gitSpawn as defaultGitSpawn } from './git-utils.js';
44
+ import {
45
+ recordPass as defaultRecordPass,
46
+ shouldSkip as defaultShouldSkip,
47
+ hashCommandConfig,
48
+ treeFingerprint,
49
+ } from './validation-evidence.js';
50
+
51
+ /** The gate name close's runner looks the record up under. */
52
+ const GATE_NAME = 'test';
53
+
54
+ /** The exact command close spawns for that gate — the hash must match it. */
55
+ const GATE_COMMAND = Object.freeze({
56
+ cmd: 'npm',
57
+ args: Object.freeze(['test']),
58
+ });
59
+
60
+ /**
61
+ * Read one trimmed git stdout line, or `null` on any failure.
62
+ *
63
+ * @param {Function} gitSpawnFn
64
+ * @param {string} cwd
65
+ * @param {string[]} args
66
+ * @returns {string|null}
67
+ */
68
+ function gitLine(gitSpawnFn, cwd, args) {
69
+ try {
70
+ const res = gitSpawnFn(cwd, ...args);
71
+ if (res?.status !== 0) return null;
72
+ const line = String(res.stdout ?? '').trim();
73
+ return line.length > 0 ? line : null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * The Story id a checkout's branch names, or `null` off a Story branch.
81
+ *
82
+ * @param {string|null} branch
83
+ * @returns {number|null}
84
+ */
85
+ export function storyIdFromBranch(branch) {
86
+ const match = /^story-(\d+)$/.exec(branch ?? '');
87
+ if (!match) return null;
88
+ const id = Number.parseInt(match[1], 10);
89
+ return Number.isInteger(id) && id > 0 ? id : null;
90
+ }
91
+
92
+ /**
93
+ * The checkout whose temp tree holds the evidence keyspace: the **main**
94
+ * checkout, which is where close runs with `--cwd <main-repo>`. From a
95
+ * worktree `git rev-parse --git-common-dir` names the main `.git`; from the
96
+ * main checkout it names its own. Either way the parent is the checkout.
97
+ *
98
+ * @param {string} cwd
99
+ * @param {Function} gitSpawnFn
100
+ * @returns {string|null}
101
+ */
102
+ export function resolveEvidenceRoot(cwd, gitSpawnFn) {
103
+ const common = gitLine(gitSpawnFn, cwd, ['rev-parse', '--git-common-dir']);
104
+ if (!common) return null;
105
+ const absolute = path.resolve(cwd, common);
106
+ return path.basename(absolute) === '.git' ? path.dirname(absolute) : null;
107
+ }
108
+
109
+ /**
110
+ * Deposit the `test` gate's evidence for a green full-suite run.
111
+ *
112
+ * @param {{
113
+ * cwd: string,
114
+ * tier?: string,
115
+ * status?: number,
116
+ * durationMs?: number|null,
117
+ * gitSpawnFn?: typeof defaultGitSpawn,
118
+ * recordPassFn?: typeof defaultRecordPass,
119
+ * }} args
120
+ * @returns {{ deposited: boolean, reason: string, storyId?: number, sha?: string }}
121
+ */
122
+ export function depositTestRunCredit({
123
+ cwd,
124
+ tier = 'full',
125
+ status = 0,
126
+ durationMs = null,
127
+ gitSpawnFn = defaultGitSpawn,
128
+ recordPassFn = defaultRecordPass,
129
+ } = {}) {
130
+ if (status !== 0) return { deposited: false, reason: 'run-not-green' };
131
+ if (tier !== 'full') return { deposited: false, reason: 'not-full-tier' };
132
+ const storyId = storyIdFromBranch(
133
+ gitLine(gitSpawnFn, cwd, ['rev-parse', '--abbrev-ref', 'HEAD']),
134
+ );
135
+ if (storyId === null)
136
+ return { deposited: false, reason: 'not-a-story-branch' };
137
+ const sha = gitLine(gitSpawnFn, cwd, ['rev-parse', 'HEAD']);
138
+ const evidenceRoot = resolveEvidenceRoot(cwd, gitSpawnFn);
139
+ if (!sha || !evidenceRoot) {
140
+ return { deposited: false, reason: 'tree-unreadable', storyId };
141
+ }
142
+ return writeRecord({
143
+ storyId,
144
+ sha,
145
+ cwd,
146
+ evidenceRoot,
147
+ durationMs,
148
+ gitSpawnFn,
149
+ recordPassFn,
150
+ });
151
+ }
152
+
153
+ /**
154
+ * Deposit and say so on stderr — the runner's one-line hook, printed only
155
+ * when this runner is the one running. It names the outcome by reason, so a
156
+ * green run that deposited nothing (wrong branch, partial tier) says so
157
+ * rather than passing silently; a project on another runner prints no line
158
+ * at all, which is why absence of this line is never evidence either way.
159
+ *
160
+ * @param {Parameters<typeof depositTestRunCredit>[0] & { log?: (line: string) => void }} args
161
+ * @returns {ReturnType<typeof depositTestRunCredit>}
162
+ */
163
+ export function reportTestRunCredit({
164
+ log = (line) => process.stderr.write(`${line}\n`),
165
+ ...args
166
+ } = {}) {
167
+ const credit = depositTestRunCredit(args);
168
+ log(
169
+ credit.deposited
170
+ ? `[run-tests] ✓ deposited the close test credit for story #${credit.storyId} at ${credit.sha.slice(0, 7)}`
171
+ : `[run-tests] no close test credit deposited (${credit.reason})`,
172
+ );
173
+ return credit;
174
+ }
175
+
176
+ /**
177
+ * Is the `test` gate already credited for this tree — did a green bare
178
+ * `npm test` in the Story worktree deposit its evidence? The read side of
179
+ * {@link depositTestRunCredit}, consulted by `close-validation/gates.js`.
180
+ *
181
+ * When it did, close registers the plain `test` gate beside the capture gate
182
+ * even though coverage-capture is the active test runner: the runner's
183
+ * evidence check then skips it as credited, so close REPORTS the suite the
184
+ * worker already ran instead of silently folding it into the capture. It is
185
+ * never a second spend — an uncredited tree resolves `false` and the
186
+ * pre-#5313 shape (capture alone runs the suite) is unchanged. Every
187
+ * uncertainty resolves `false`.
188
+ *
189
+ * @param {{
190
+ * storyId?: number|null,
191
+ * cwd?: string,
192
+ * evidenceCwd?: string|null,
193
+ * gitSpawnImpl?: typeof defaultGitSpawn,
194
+ * shouldSkipImpl?: typeof defaultShouldSkip,
195
+ * log?: (line: string) => void,
196
+ * }} opts
197
+ * @returns {boolean}
198
+ */
199
+ export function predictsTestEvidenceCredit({
200
+ storyId,
201
+ cwd,
202
+ evidenceCwd,
203
+ gitSpawnImpl = defaultGitSpawn,
204
+ shouldSkipImpl = defaultShouldSkip,
205
+ log,
206
+ } = {}) {
207
+ if (!Number.isInteger(storyId) || storyId <= 0) return false;
208
+ if (typeof cwd !== 'string' || cwd.length === 0) return false;
209
+ try {
210
+ const sha = gitLine(gitSpawnImpl, cwd, ['rev-parse', 'HEAD']);
211
+ if (!sha) return false;
212
+ const verdict = shouldSkipImpl(
213
+ {
214
+ storyId,
215
+ gateName: GATE_NAME,
216
+ currentSha: sha,
217
+ configHash: hashCommandConfig({
218
+ cmd: GATE_COMMAND.cmd,
219
+ args: [...GATE_COMMAND.args],
220
+ cwd: path.resolve(cwd),
221
+ }),
222
+ inputFingerprint: treeFingerprint(cwd, gitSpawnImpl),
223
+ },
224
+ { cwd: evidenceCwd ?? cwd, standalone: true },
225
+ );
226
+ if (verdict.skip !== true) return false;
227
+ log?.(
228
+ '[close-validation] a green `npm test` already deposited the test credit for this tree — registering the plain `test` gate so close reports it as credited.',
229
+ );
230
+ return true;
231
+ } catch {
232
+ return false;
233
+ }
234
+ }
235
+
236
+ /**
237
+ * The write itself, split out so the guard chain above stays flat.
238
+ *
239
+ * @param {{ storyId: number, sha: string, cwd: string, evidenceRoot: string, durationMs: number|null, gitSpawnFn: Function, recordPassFn: Function }} args
240
+ * @returns {{ deposited: boolean, reason: string, storyId: number, sha: string }}
241
+ */
242
+ function writeRecord({
243
+ storyId,
244
+ sha,
245
+ cwd,
246
+ evidenceRoot,
247
+ durationMs,
248
+ gitSpawnFn,
249
+ recordPassFn,
250
+ }) {
251
+ try {
252
+ recordPassFn(
253
+ {
254
+ storyId,
255
+ gateName: GATE_NAME,
256
+ sha,
257
+ configHash: hashCommandConfig({
258
+ cmd: GATE_COMMAND.cmd,
259
+ args: [...GATE_COMMAND.args],
260
+ cwd: path.resolve(cwd),
261
+ }),
262
+ exitCode: 0,
263
+ durationMs,
264
+ inputFingerprint: treeFingerprint(cwd, gitSpawnFn),
265
+ },
266
+ { cwd: evidenceRoot, standalone: true },
267
+ );
268
+ return { deposited: true, reason: 'recorded', storyId, sha };
269
+ } catch (err) {
270
+ return {
271
+ deposited: false,
272
+ reason: `record-failed: ${err?.message ?? err}`,
273
+ storyId,
274
+ sha,
275
+ };
276
+ }
277
+ }