mandrel 1.57.0 → 1.59.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/README.md +89 -87
  2. package/.agents/docs/SDLC.md +11 -7
  3. package/.agents/docs/workflows.md +2 -1
  4. package/.agents/schemas/audit-rules.json +20 -0
  5. package/.agents/scripts/acceptance-eval.js +20 -3
  6. package/.agents/scripts/assert-branch.js +1 -3
  7. package/.agents/scripts/bootstrap.js +1 -1
  8. package/.agents/scripts/check-arch-cycles.js +360 -0
  9. package/.agents/scripts/coverage-capture.js +24 -3
  10. package/.agents/scripts/epic-deliver-preflight.js +5 -3
  11. package/.agents/scripts/epic-deliver-prepare.js +12 -4
  12. package/.agents/scripts/epic-execute-record-wave.js +1 -1
  13. package/.agents/scripts/evidence-gate.js +1 -1
  14. package/.agents/scripts/git-rebase-and-resolve.js +1 -1
  15. package/.agents/scripts/hierarchy-gate.js +34 -14
  16. package/.agents/scripts/lib/baselines/kinds/coverage.js +33 -149
  17. package/.agents/scripts/lib/baselines/kinds/duplication.js +27 -116
  18. package/.agents/scripts/lib/baselines/kinds/kind-factory.js +192 -0
  19. package/.agents/scripts/lib/baselines/kinds/lighthouse.js +34 -133
  20. package/.agents/scripts/lib/baselines/kinds/maintainability.js +31 -124
  21. package/.agents/scripts/lib/baselines/kinds/mutation.js +25 -111
  22. package/.agents/scripts/lib/baselines/maintainability-baseline-io.js +59 -0
  23. package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +37 -0
  24. package/.agents/scripts/lib/baselines/writer.js +1 -1
  25. package/.agents/scripts/lib/close-validation/commands.js +188 -0
  26. package/.agents/scripts/lib/close-validation/gates.js +235 -0
  27. package/.agents/scripts/lib/close-validation/process.js +101 -0
  28. package/.agents/scripts/lib/close-validation/projections/maintainability.js +1 -1
  29. package/.agents/scripts/lib/close-validation/runner.js +325 -0
  30. package/.agents/scripts/lib/close-validation/telemetry.js +70 -0
  31. package/.agents/scripts/lib/config/quality.js +6 -6
  32. package/.agents/scripts/lib/config-resolver.js +2 -5
  33. package/.agents/scripts/lib/coverage-capture.js +147 -4
  34. package/.agents/scripts/lib/cpu-pool.js +14 -0
  35. package/.agents/scripts/lib/crap-utils.js +6 -11
  36. package/.agents/scripts/lib/dynamic-workflow/documentation-report-contract.js +87 -0
  37. package/.agents/scripts/lib/git-utils.js +24 -22
  38. package/.agents/scripts/lib/maintainability-engine.js +1 -1
  39. package/.agents/scripts/lib/maintainability-utils.js +4 -187
  40. package/.agents/scripts/lib/observability/perf-report-readers.js +32 -23
  41. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +80 -6
  42. package/.agents/scripts/lib/orchestration/code-review.js +90 -77
  43. package/.agents/scripts/lib/orchestration/dispatch-pipeline.js +5 -12
  44. package/.agents/scripts/lib/orchestration/epic-deliver-lease-guard.js +14 -14
  45. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/planning-artifacts.js +2 -2
  46. package/.agents/scripts/lib/orchestration/epic-plan-lease-guard.js +184 -49
  47. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/drain.js +1 -1
  48. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/plan-epic.js +26 -2
  49. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +26 -6
  50. package/.agents/scripts/lib/orchestration/epic-runner/phases/build-wave-dag.js +7 -20
  51. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +1 -2
  52. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +0 -6
  53. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +103 -0
  54. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +22 -64
  55. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +38 -76
  56. package/.agents/scripts/lib/orchestration/epic-runner/story-run-progress-writer.js +2 -2
  57. package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +4 -16
  58. package/.agents/scripts/lib/orchestration/file-assumptions.js +4 -3
  59. package/.agents/scripts/lib/orchestration/lease-guard-shared.js +144 -0
  60. package/.agents/scripts/lib/orchestration/lifecycle/emit-story-heartbeat.js +2 -2
  61. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +7 -7
  62. package/.agents/scripts/lib/orchestration/post-merge/phases/notification.js +3 -3
  63. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +7 -7
  64. package/.agents/scripts/lib/orchestration/preflight-cache.js +35 -12
  65. package/.agents/scripts/lib/orchestration/review-providers/codex.js +5 -60
  66. package/.agents/scripts/lib/orchestration/review-providers/native.js +7 -6
  67. package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +105 -0
  68. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +7 -59
  69. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +2 -4
  70. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +241 -0
  71. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +1 -1
  72. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +16 -3
  73. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -4
  74. package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +32 -35
  75. package/.agents/scripts/lib/orchestration/skill-capsule-loader.js +1 -2
  76. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +451 -503
  77. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/pre-merge-attribution.js +8 -2
  78. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/refresh-commit.js +47 -2
  79. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +2 -2
  80. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +358 -54
  81. package/.agents/scripts/lib/orchestration/story-close/phases/close.js +1 -1
  82. package/.agents/scripts/lib/orchestration/story-close/phases/gates.js +3 -2
  83. package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +30 -3
  84. package/.agents/scripts/lib/orchestration/story-close/post-merge-close.js +5 -18
  85. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +3 -3
  86. package/.agents/scripts/lib/orchestration/story-close-recovery.js +33 -16
  87. package/.agents/scripts/lib/orchestration/story-reachability.js +47 -0
  88. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +2 -33
  89. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +42 -64
  90. package/.agents/scripts/lib/orchestration/ticketing/reads.js +9 -0
  91. package/.agents/scripts/lib/orchestration/ticketing/state.js +50 -436
  92. package/.agents/scripts/lib/orchestration/ticketing/transition.js +471 -0
  93. package/.agents/scripts/lib/orchestration/ticketing.js +0 -1
  94. package/.agents/scripts/lib/orchestration/wave-record-notifications.js +1 -1
  95. package/.agents/scripts/lib/orchestration/wave-record-projection.js +1 -7
  96. package/.agents/scripts/lib/project-root.js +17 -0
  97. package/.agents/scripts/lib/story-adjacency.js +76 -0
  98. package/.agents/scripts/lib/story-lifecycle.js +1 -1
  99. package/.agents/scripts/lib/transpile.js +93 -0
  100. package/.agents/scripts/lib/wave-runner/tick.js +4 -153
  101. package/.agents/scripts/lib/workers/crap-worker.js +1 -1
  102. package/.agents/scripts/lib/workers/maintainability-report-worker.js +1 -1
  103. package/.agents/scripts/lib/worktree/lifecycle/creation.js +20 -2
  104. package/.agents/scripts/lib/worktree/lifecycle/force-drain.js +90 -0
  105. package/.agents/scripts/lib/worktree/lifecycle/reap.js +26 -8
  106. package/.agents/scripts/lib/worktree/node-modules-strategy.js +74 -0
  107. package/.agents/scripts/providers/github/tickets.js +110 -6
  108. package/.agents/scripts/run-lint.js +9 -0
  109. package/.agents/scripts/run-tests.js +24 -4
  110. package/.agents/scripts/stories-wave-tick.js +8 -5
  111. package/.agents/scripts/story-init.js +149 -10
  112. package/.agents/scripts/sync-branch-from-base.js +1 -1
  113. package/.agents/skills/stack/qa/lighthouse-baseline/SKILL.md +1 -1
  114. package/.agents/workflows/audit-documentation.md +226 -0
  115. package/.agents/workflows/epic-deliver.md +16 -23
  116. package/.agents/workflows/epic-plan.md +1 -1
  117. package/.agents/workflows/helpers/epic-deliver-story.md +17 -28
  118. package/.agents/workflows/helpers/single-story-deliver.md +2 -1
  119. package/.agents/workflows/onboard.md +4 -3
  120. package/.agents/workflows/story-deliver.md +1 -1
  121. package/README.md +21 -8
  122. package/lib/cli/init.js +336 -0
  123. package/package.json +2 -1
  124. package/.agents/scripts/lib/auto-refresh-baselines.js +0 -308
  125. package/.agents/scripts/lib/close-validation.js +0 -897
  126. package/.agents/scripts/lib/orchestration/cascade-grouping.js +0 -275
  127. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter.js +0 -69
  128. package/.agents/scripts/lib/orchestration/story-close/format-autofix-scoped.js +0 -221
  129. package/.agents/scripts/lib/orchestration/story-close/format-autofix-shared.js +0 -123
  130. package/.agents/scripts/lib/task-utils.js +0 -26
  131. package/.agents/scripts/story-deliver-prepare.js +0 -267
@@ -0,0 +1,325 @@
1
+ /**
2
+ * close-validation/runner.js — The `runCloseValidation` orchestrator.
3
+ *
4
+ * Runs typecheck, lint, test, format check, and maintainability/coverage/
5
+ * CRAP regression checks before the story merge so drift is caught in the
6
+ * worktree rather than at pre-push time on the Epic branch. All gates
7
+ * inherit stdio so the operator sees the raw output; the returned summary
8
+ * surfaces actionable hints on failure.
9
+ */
10
+
11
+ import {
12
+ recordPass as defaultRecordPass,
13
+ shouldSkip as defaultShouldSkip,
14
+ hashCommandConfig,
15
+ } from '../validation-evidence.js';
16
+ import {
17
+ isFormatterEligible,
18
+ listChangedFilesForFormatGate,
19
+ } from './commands.js';
20
+ import { DEFAULT_GATES, partitionGates } from './gates.js';
21
+ import { defaultGateRunner } from './process.js';
22
+ import { defaultGetHeadSha } from './projections/head-sha.js';
23
+
24
+ /** @typedef {import('./gates.js').Gate} Gate */
25
+
26
+ function applyChangedFileScope({ gate, spawnCwd, log }) {
27
+ if (!gate.changedFileScope) {
28
+ return { gate, cmd: gate.cmd, args: gate.args, skip: false };
29
+ }
30
+ const changedFiles = listChangedFilesForFormatGate({
31
+ cwd: spawnCwd,
32
+ baseRef: gate.changedFileScope.baseRef,
33
+ });
34
+ // Filter to the formatter-eligible subset before deciding to skip. A
35
+ // non-empty diff that contains zero formatter-eligible files (e.g. a
36
+ // docs-only Story) must take the skip path, not invoke biome with only
37
+ // ineligible paths — biome reports "No files were processed" and exits 1
38
+ // in that case (Story #3410).
39
+ const eligibleFiles = changedFiles.filter(isFormatterEligible);
40
+ if (eligibleFiles.length === 0) {
41
+ log(
42
+ `[close-validation] ⏭ ${gate.name} skipped (no formatter-eligible changed files)`,
43
+ );
44
+ return { gate, cmd: gate.cmd, args: gate.args, skip: true };
45
+ }
46
+ const args =
47
+ gate.args[gate.args.length - 1] === '.'
48
+ ? gate.args.slice(0, -1)
49
+ : gate.args;
50
+ log(
51
+ `[close-validation] ↳ ${gate.name} scoped to ${eligibleFiles.length} formatter-eligible changed file(s) from ${gate.changedFileScope.baseRef}...HEAD`,
52
+ );
53
+ return {
54
+ gate,
55
+ cmd: gate.cmd,
56
+ args: [...args, ...eligibleFiles],
57
+ skip: false,
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Run every gate sequentially. Stops collecting after the first failure but
63
+ * still returns a summary so the caller decides how to surface the result.
64
+ *
65
+ * Worktree locality (Story #1120): when `worktreePath` is supplied, every
66
+ * gate runner is spawned with `cwd: worktreePath` so the gate sees the
67
+ * Story branch's post-rebase tree. Evidence reads/writes still key against
68
+ * `cwd` (the main checkout) because the per-Epic temp tree lives under
69
+ * the main `.git/`. Failure messages name the worktree path.
70
+ *
71
+ * Evidence-aware: when both `storyId` and `epicId` are provided and
72
+ * `useEvidence !== false`, each gate consults `validation-evidence
73
+ * .shouldSkip()` against current HEAD + the gate's command-config hash. A
74
+ * matching record skips the gate; a successful run is recorded so the
75
+ * next caller in the local hot path can skip in turn.
76
+ *
77
+ * `onGateStart` is invoked immediately before each gate's runner spawn.
78
+ * story-close uses it to drive `phaseTimer.mark(...)` for per-gate
79
+ * wall-clock telemetry. Errors thrown from the hook propagate.
80
+ *
81
+ * @param {{
82
+ * cwd: string,
83
+ * worktreePath?: string,
84
+ * gates?: Gate[],
85
+ * runner?: (cmd: string, args: string[], opts: { cwd: string, signal?: AbortSignal, gateName?: string, log?: (m: string) => void }) => Promise<{ status: number }> | { status: number },
86
+ * log?: (m: string) => void,
87
+ * onGateStart?: (gate: Gate) => void,
88
+ * storyId?: number|null,
89
+ * epicId?: number|null,
90
+ * useEvidence?: boolean,
91
+ * evidenceClock?: () => number,
92
+ * getHeadSha?: (cwd: string) => string|null,
93
+ * recordPass?: typeof defaultRecordPass,
94
+ * shouldSkip?: typeof defaultShouldSkip,
95
+ * }} opts
96
+ * @returns {{ ok: boolean, failed: Array<{ gate: Gate, status: number, cwd: string }>, skipped: Array<{ gate: Gate, reason: string }> }}
97
+ */
98
+ export async function runCloseValidation({
99
+ cwd,
100
+ worktreePath,
101
+ gates = DEFAULT_GATES,
102
+ runner = defaultGateRunner,
103
+ log = () => {},
104
+ onGateStart,
105
+ storyId = null,
106
+ epicId = null,
107
+ useEvidence = true,
108
+ evidenceClock = () => Date.now(),
109
+ getHeadSha = (resolvedCwd) => defaultGetHeadSha(resolvedCwd),
110
+ recordPass = defaultRecordPass,
111
+ shouldSkip = defaultShouldSkip,
112
+ } = {}) {
113
+ const failed = [];
114
+ const skipped = [];
115
+ const evidenceActive = useEvidence && storyId != null && epicId != null;
116
+ // Evidence keys against the main checkout's HEAD because the per-Epic
117
+ // evidence file lives under the main `.git/`. Gate spawn, in contrast,
118
+ // runs in the worktree when one is supplied — that's the whole point of
119
+ // Story #1120.
120
+ const spawnCwd = worktreePath ?? cwd;
121
+ const headSha = evidenceActive ? getHeadSha(spawnCwd) : null;
122
+
123
+ // Helper closures so the parallel and serial passes share evidence
124
+ // bookkeeping bit-for-bit.
125
+
126
+ /** Returns a `{ skip: true }` verdict when evidence makes the gate redundant. */
127
+ const evidenceVerdict = (gate, configHash) => {
128
+ if (!(evidenceActive && headSha)) return { skip: false };
129
+ const verdict = shouldSkip(
130
+ {
131
+ storyId,
132
+ gateName: gate.name,
133
+ currentSha: headSha,
134
+ configHash,
135
+ inputFingerprint: gate.inputFingerprint ?? null,
136
+ },
137
+ { cwd, epicId },
138
+ );
139
+ if (verdict.skip) {
140
+ const tsHint = verdict.record?.timestamp
141
+ ? ` recorded ${verdict.record.timestamp}`
142
+ : '';
143
+ log(
144
+ `[close-validation] ⏭ ${gate.name} skipped (${verdict.reason}: SHA=${headSha.slice(0, 7)}${tsHint})`,
145
+ );
146
+ }
147
+ return verdict;
148
+ };
149
+
150
+ const recordIfActive = (gate, configHash, durationMs) => {
151
+ if (!(evidenceActive && headSha)) return;
152
+ try {
153
+ recordPass(
154
+ {
155
+ storyId,
156
+ gateName: gate.name,
157
+ sha: headSha,
158
+ configHash,
159
+ exitCode: 0,
160
+ durationMs,
161
+ inputFingerprint: gate.inputFingerprint ?? null,
162
+ },
163
+ { cwd, epicId },
164
+ );
165
+ } catch (err) {
166
+ log(
167
+ `[close-validation] ⚠ failed to record evidence for ${gate.name}: ${err?.message ?? err}`,
168
+ );
169
+ }
170
+ };
171
+
172
+ /**
173
+ * Run a single gate. When `gate.run` is a function the gate executes
174
+ * **in process** (Story #1973 / Task #1984 — per-kind baseline gates
175
+ * removed their `child_process.spawn(node check-<kind>.js)` arm and
176
+ * call `compare(head, base)` directly). The `run` callable receives
177
+ * the same `(cmd, args, opts)` argv shape as `runner` so it slots into
178
+ * the existing contract without churn at the runner boundary.
179
+ * Otherwise the supplied `runner` is used (default: spawn).
180
+ *
181
+ * @returns {Promise<{ status: number }>}
182
+ */
183
+ const dispatchGate = async (gate, signal) => {
184
+ log(
185
+ `[close-validation] ▶ ${gate.name}${worktreePath ? ` (cwd=${worktreePath})` : ''}`,
186
+ );
187
+ if (typeof onGateStart === 'function') onGateStart(gate);
188
+ const dispatcher = typeof gate.run === 'function' ? gate.run : runner;
189
+ const result = await dispatcher(gate.cmd, gate.args, {
190
+ cwd: spawnCwd,
191
+ gateName: gate.name,
192
+ log,
193
+ signal,
194
+ ...(gate.env ? { env: gate.env } : {}),
195
+ });
196
+ return { status: result?.status ?? 1 };
197
+ };
198
+
199
+ const { independent, serial } = partitionGates(gates);
200
+
201
+ // ── Phase 1: independent gates in parallel ──────────────────────────
202
+ // First non-zero exit pins `firstFailure` and aborts every in-flight
203
+ // sibling via SIGTERM. Other gates' results are still awaited (so we
204
+ // never leak children) but their non-zero status is intentionally
205
+ // dropped: only one error surfaces.
206
+ const ac = new AbortController();
207
+ let firstIndepFailure = null;
208
+
209
+ const indepTasks = independent.map(async (gate) => {
210
+ let execution;
211
+ try {
212
+ execution = applyChangedFileScope({ gate, spawnCwd, log });
213
+ } catch (err) {
214
+ if (!firstIndepFailure) {
215
+ firstIndepFailure = { gate, status: 1, cwd: spawnCwd };
216
+ log(
217
+ `[close-validation] ✖ ${gate.name} failed to resolve changed-file scope: ${err?.message ?? err}`,
218
+ );
219
+ ac.abort();
220
+ }
221
+ return;
222
+ }
223
+ if (execution.skip) {
224
+ skipped.push({ gate, reason: 'no-changed-files' });
225
+ return;
226
+ }
227
+ const configHash = hashCommandConfig({
228
+ cmd: execution.cmd,
229
+ args: execution.args,
230
+ cwd: spawnCwd,
231
+ });
232
+ const verdict = evidenceVerdict(gate, configHash);
233
+ if (verdict.skip) {
234
+ skipped.push({ gate, reason: verdict.reason });
235
+ return;
236
+ }
237
+ const startedAt = evidenceActive ? evidenceClock() : 0;
238
+ let result;
239
+ try {
240
+ result = await dispatchGate(
241
+ { ...gate, cmd: execution.cmd, args: execution.args },
242
+ ac.signal,
243
+ );
244
+ } catch (err) {
245
+ result = { status: 1, error: err };
246
+ }
247
+ if (result.status !== 0) {
248
+ if (!firstIndepFailure) {
249
+ firstIndepFailure = { gate, status: result.status, cwd: spawnCwd };
250
+ ac.abort();
251
+ }
252
+ return;
253
+ }
254
+ log(`[close-validation] ✓ ${gate.name}`);
255
+ recordIfActive(
256
+ gate,
257
+ configHash,
258
+ evidenceActive ? evidenceClock() - startedAt : 0,
259
+ );
260
+ });
261
+
262
+ await Promise.all(indepTasks);
263
+
264
+ if (firstIndepFailure) {
265
+ failed.push(firstIndepFailure);
266
+ log(
267
+ `[close-validation] ✖ ${firstIndepFailure.gate.name} failed (exit ${firstIndepFailure.status}) in ${spawnCwd}`,
268
+ );
269
+ if (firstIndepFailure.gate.hint) {
270
+ log(`[close-validation] hint: ${firstIndepFailure.gate.hint}`);
271
+ }
272
+ return { ok: false, failed, skipped };
273
+ }
274
+
275
+ // ── Phase 2: serial gates in declared order ─────────────────────────
276
+ for (const gate of serial) {
277
+ let execution;
278
+ try {
279
+ execution = applyChangedFileScope({ gate, spawnCwd, log });
280
+ } catch (err) {
281
+ failed.push({ gate, status: 1, cwd: spawnCwd });
282
+ log(
283
+ `[close-validation] ✖ ${gate.name} failed to resolve changed-file scope: ${err?.message ?? err}`,
284
+ );
285
+ if (gate.hint) log(`[close-validation] hint: ${gate.hint}`);
286
+ break;
287
+ }
288
+ if (execution.skip) {
289
+ skipped.push({ gate, reason: 'no-changed-files' });
290
+ continue;
291
+ }
292
+ const configHash = hashCommandConfig({
293
+ cmd: execution.cmd,
294
+ args: execution.args,
295
+ cwd: spawnCwd,
296
+ });
297
+ const verdict = evidenceVerdict(gate, configHash);
298
+ if (verdict.skip) {
299
+ skipped.push({ gate, reason: verdict.reason });
300
+ continue;
301
+ }
302
+ const startedAt = evidenceActive ? evidenceClock() : 0;
303
+ const result = await dispatchGate({
304
+ ...gate,
305
+ cmd: execution.cmd,
306
+ args: execution.args,
307
+ });
308
+ if (result.status !== 0) {
309
+ failed.push({ gate, status: result.status, cwd: spawnCwd });
310
+ log(
311
+ `[close-validation] ✖ ${gate.name} failed (exit ${result.status}) in ${spawnCwd}`,
312
+ );
313
+ if (gate.hint) log(`[close-validation] hint: ${gate.hint}`);
314
+ break;
315
+ }
316
+ log(`[close-validation] ✓ ${gate.name}`);
317
+ recordIfActive(
318
+ gate,
319
+ configHash,
320
+ evidenceActive ? evidenceClock() - startedAt : 0,
321
+ );
322
+ }
323
+
324
+ return { ok: failed.length === 0, failed, skipped };
325
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * close-validation/telemetry.js — gh-spawn telemetry emitter.
3
+ */
4
+
5
+ import { writeFile as defaultWriteFile } from 'node:fs/promises';
6
+ import { storyArtifactPath } from '../config/temp-paths.js';
7
+ import { getSpawnCount as defaultGetSpawnCount } from '../gh-exec.js';
8
+
9
+ /**
10
+ * Throw-away ghSpawnCount emitter (Story #1795 / Epic #1788).
11
+ *
12
+ * Writes the current `gh-exec` spawn counter to
13
+ * `temp/epic-<eid>/stories/story-<sid>/gh-spawn-count.json` so the
14
+ * `analyze-execution.js` child process can read it and emit a
15
+ * `ghSpawnCount` field on the `story-perf-summary` payload. The Story-
16
+ * close orchestrator calls this inside `runPostMergeClose` right before
17
+ * the perf-summary phase, capturing every `gh` invocation from preflight
18
+ * through the merge in one counter snapshot.
19
+ *
20
+ * @param {object} opts
21
+ * @param {number|string} opts.epicId
22
+ * @param {number|string} opts.storyId
23
+ * @param {object} [opts.config] - Resolved config bag so `tempRoot`
24
+ * resolution honours the consumer's configured path.
25
+ * @param {() => number} [opts.getSpawnCountFn=defaultGetSpawnCount] - Test seam.
26
+ * @param {typeof defaultWriteFile} [opts.writeFileFn=defaultWriteFile] - Test seam.
27
+ * @param {{ warn?: (s: string) => void }} [opts.logger] - Best-effort
28
+ * failure-path logger; never throws.
29
+ * @returns {Promise<{ status: 'ok'|'failed', path?: string, ghSpawnCount?: number, reason?: string }>}
30
+ */
31
+ export async function emitGhSpawnCount({
32
+ epicId,
33
+ storyId,
34
+ config,
35
+ getSpawnCountFn = defaultGetSpawnCount,
36
+ writeFileFn = defaultWriteFile,
37
+ logger,
38
+ } = {}) {
39
+ const eid = Number(epicId);
40
+ const sid = Number(storyId);
41
+ if (!Number.isInteger(eid) || eid < 1 || !Number.isInteger(sid) || sid < 1) {
42
+ return { status: 'failed', reason: 'invalid-ids' };
43
+ }
44
+ let ghSpawnCount;
45
+ try {
46
+ ghSpawnCount = getSpawnCountFn();
47
+ } catch (err) {
48
+ logger?.warn?.(
49
+ `[close-validation] gh-spawn-count read failed: ${err?.message ?? err}`,
50
+ );
51
+ return { status: 'failed', reason: 'counter-read-failed' };
52
+ }
53
+ const targetPath = storyArtifactPath(eid, sid, 'gh-spawn-count.json', config);
54
+ const payload = {
55
+ kind: 'gh-spawn-count',
56
+ epicId: eid,
57
+ storyId: sid,
58
+ ghSpawnCount,
59
+ capturedAt: new Date().toISOString(),
60
+ };
61
+ try {
62
+ await writeFileFn(targetPath, JSON.stringify(payload, null, 2));
63
+ return { status: 'ok', path: targetPath, ghSpawnCount };
64
+ } catch (err) {
65
+ logger?.warn?.(
66
+ `[close-validation] gh-spawn-count emit failed: ${err?.message ?? err}`,
67
+ );
68
+ return { status: 'failed', reason: 'write-failed' };
69
+ }
70
+ }
@@ -121,7 +121,7 @@ export const MAINTAINABILITY_GATE_DEFAULTS = Object.freeze({
121
121
  * --write` autofix spawn. The SIGKILL → exit 124 mapping mirrors
122
122
  * `gates.coverage.timeoutMs` (Story #2142).
123
123
  */
124
- export const FORMAT_AUTOFIX_DEFAULTS = Object.freeze({
124
+ const FORMAT_AUTOFIX_DEFAULTS = Object.freeze({
125
125
  timeoutMs: 60_000,
126
126
  });
127
127
 
@@ -259,7 +259,7 @@ export function resolveMaintainabilityCrap(
259
259
  * `targetDirs` + a scalar `tolerance` (when set) + scoping inherited
260
260
  * from `gateScoping`.
261
261
  */
262
- export function resolveMaintainabilityQuality(userBlock, gateScoping) {
262
+ function resolveMaintainabilityQuality(userBlock, gateScoping) {
263
263
  const defaults = MAINTAINABILITY_GATE_DEFAULTS;
264
264
  const scoping = {
265
265
  defaultScope: gateScoping?.scope ?? DEFAULT_GATE_SCOPING.scope,
@@ -321,7 +321,7 @@ function resolvePositiveIntegerMs(value, defaultMs) {
321
321
  */
322
322
  const FORMAT_AUTOFIX_KEYS = new Set(['timeoutMs']);
323
323
 
324
- export function resolveFormatAutofix(userBlock) {
324
+ function resolveFormatAutofix(userBlock) {
325
325
  const defaults = FORMAT_AUTOFIX_DEFAULTS;
326
326
  if (userBlock == null || typeof userBlock !== 'object') {
327
327
  return { timeoutMs: defaults.timeoutMs };
@@ -336,7 +336,7 @@ export function resolveFormatAutofix(userBlock) {
336
336
  }
337
337
 
338
338
  /** Resolve the coverage gate. Owns `coveragePath` and `timeoutMs`. */
339
- export function resolveCoverageGate(userBlock) {
339
+ function resolveCoverageGate(userBlock) {
340
340
  const defaults = COVERAGE_GATE_DEFAULTS;
341
341
  if (userBlock == null || typeof userBlock !== 'object') {
342
342
  return {
@@ -402,7 +402,7 @@ export function resolveCodingGuardrails(userBlock) {
402
402
  };
403
403
  }
404
404
 
405
- export const AUTO_REFRESH_DEFAULTS = Object.freeze({
405
+ const AUTO_REFRESH_DEFAULTS = Object.freeze({
406
406
  enabled: true,
407
407
  miDropCap: 1.5,
408
408
  crapJumpCap: 5,
@@ -411,7 +411,7 @@ export const AUTO_REFRESH_DEFAULTS = Object.freeze({
411
411
 
412
412
  const AUTO_REFRESH_KEYS = new Set(Object.keys(AUTO_REFRESH_DEFAULTS));
413
413
 
414
- export function resolveAutoRefresh(userBlock) {
414
+ function resolveAutoRefresh(userBlock) {
415
415
  const defaults = AUTO_REFRESH_DEFAULTS;
416
416
  if (userBlock == null || typeof userBlock !== 'object') {
417
417
  return {
@@ -24,7 +24,6 @@
24
24
 
25
25
  import fs from 'node:fs';
26
26
  import path from 'node:path';
27
- import { fileURLToPath } from 'node:url';
28
27
  import { getCiDelivery } from './config/ci.js';
29
28
  import { getCommands } from './config/commands.js';
30
29
  import { getGitHub } from './config/github.js';
@@ -34,6 +33,7 @@ import { validateOrchestrationConfig } from './config/validate-orchestration.js'
34
33
  import { getWorktreeIsolation } from './config/worktree-isolation.js';
35
34
  import { getAgentrcValidator } from './config-schema.js';
36
35
  import { loadEnv } from './env-loader.js';
36
+ import { PROJECT_ROOT } from './project-root.js';
37
37
 
38
38
  export { getAcceptanceEval } from './config/acceptance-eval.js';
39
39
  export { BASELINES_DEFAULTS, getBaselines } from './config/baselines.js';
@@ -64,10 +64,7 @@ export {
64
64
  export { resolveListValue } from './config/shared.js';
65
65
  export { validateOrchestrationConfig } from './config/validate-orchestration.js';
66
66
  export { WORKTREE_ISOLATION_DEFAULTS } from './config/worktree-isolation.js';
67
-
68
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
69
- // scripts/lib/ → scripts/ → .agents/ → project root
70
- export const PROJECT_ROOT = path.resolve(__dirname, '../../..');
67
+ export { PROJECT_ROOT } from './project-root.js';
71
68
 
72
69
  // Cache keyed by absolute root path so callers passing different cwds
73
70
  // (e.g. per-worktree) each get their own resolved config.
@@ -11,6 +11,7 @@
11
11
  * `isCoverageFresh` and decide whether to delegate to `runCapture`.
12
12
  */
13
13
  import { spawnSync } from 'node:child_process';
14
+ import crypto from 'node:crypto';
14
15
  import fs from 'node:fs';
15
16
  import path from 'node:path';
16
17
 
@@ -65,10 +66,130 @@ export function newestSourceMtime(cwd, targetDirs, io = {}) {
65
66
  }
66
67
 
67
68
  /**
68
- * Decide whether the existing coverage artifact is "fresh" present and at
69
- * least as new as the newest source file under `targetDirs`. Missing files,
70
- * missing target dirs, or any IO error resolve to `false` so the caller
71
- * captures rather than trusting stale data.
69
+ * Resolve the capture-stamp path that sits next to the coverage artifact.
70
+ * The stamp persists the content digest of the CRAP-target sources at the
71
+ * moment coverage was last captured, so freshness can be decided by content
72
+ * rather than mtime (mtime churns on branch switches / checkouts even when
73
+ * content is unchanged).
74
+ *
75
+ * @param {string} cwd Absolute repo root.
76
+ * @param {string} coveragePath Repo-relative coverage artifact path.
77
+ * @returns {string} Absolute stamp path (`<coverage-dir>/.capture-stamp.json`).
78
+ */
79
+ export function captureStampPath(cwd, coveragePath) {
80
+ return path.join(
81
+ path.dirname(path.resolve(cwd, coveragePath)),
82
+ '.capture-stamp.json',
83
+ );
84
+ }
85
+
86
+ const SOURCE_EXT_RE = /\.(?:js|mjs)$/;
87
+
88
+ /**
89
+ * Compute a stable content digest of the `.js`/`.mjs` sources under
90
+ * `targetDirs`: the `git ls-files -s` listing (mode + blob SHA + path) of
91
+ * tracked content, plus the on-disk bytes of any dirty working-tree files.
92
+ * Checkout/branch churn leaves blob SHAs untouched, so the digest only moves
93
+ * when content actually changes.
94
+ *
95
+ * Returns `null` when the digest cannot be computed (git unavailable, not a
96
+ * repo, empty target list) so callers can fall back to the mtime heuristic.
97
+ *
98
+ * @param {string} cwd Absolute repo root.
99
+ * @param {string[]} targetDirs Repo-relative directories to digest.
100
+ * @param {{ spawnSync?: typeof spawnSync, readFileSync?: typeof fs.readFileSync }} [io]
101
+ * @returns {string | null} Hex SHA-256 digest, or null when unavailable.
102
+ */
103
+ export function computeContentDigest(cwd, targetDirs, io = {}) {
104
+ const spawn = io.spawnSync ?? spawnSync;
105
+ const readFileSync = io.readFileSync ?? fs.readFileSync;
106
+ const dirs = (targetDirs ?? []).filter(
107
+ (d) => typeof d === 'string' && d.length > 0,
108
+ );
109
+ if (dirs.length === 0) return null;
110
+
111
+ const git = (...args) => {
112
+ const res = spawn('git', args, { cwd, encoding: 'utf8' });
113
+ if (res?.error || res?.status !== 0) {
114
+ throw res?.error ?? new Error(res?.stderr || `git ${args[0]} failed`);
115
+ }
116
+ return res.stdout ?? '';
117
+ };
118
+
119
+ try {
120
+ const hash = crypto.createHash('sha256');
121
+ const tracked = git('ls-files', '-s', '--', ...dirs)
122
+ .split('\n')
123
+ .filter((line) => SOURCE_EXT_RE.test(line.trimEnd()));
124
+ hash.update(tracked.join('\n'));
125
+
126
+ // Dirty working-tree files are not represented by their index blob SHA,
127
+ // so fold in their on-disk bytes (or absence) explicitly.
128
+ const dirty = git('status', '--porcelain', '--', ...dirs)
129
+ .split('\n')
130
+ .filter((line) => line.length > 3);
131
+ for (const line of dirty) {
132
+ let file = line.slice(3).trim();
133
+ if (file.includes(' -> ')) file = file.split(' -> ').pop();
134
+ file = file.replace(/^"|"$/g, '');
135
+ if (!SOURCE_EXT_RE.test(file)) continue;
136
+ hash.update(`\0${file}\0`);
137
+ try {
138
+ hash.update(readFileSync(path.resolve(cwd, file)));
139
+ } catch {
140
+ hash.update('<absent>');
141
+ }
142
+ }
143
+ return hash.digest('hex');
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Persist the capture stamp next to the coverage artifact. Best-effort: a
151
+ * write failure returns `false` rather than throwing — the worst case is a
152
+ * fall back to the mtime heuristic on the next freshness check.
153
+ *
154
+ * @param {{
155
+ * cwd: string,
156
+ * coveragePath: string,
157
+ * digest: string,
158
+ * writeFileSync?: typeof fs.writeFileSync,
159
+ * }} opts
160
+ * @returns {boolean} True when the stamp was written.
161
+ */
162
+ export function writeCaptureStamp({
163
+ cwd,
164
+ coveragePath,
165
+ digest,
166
+ writeFileSync = fs.writeFileSync,
167
+ }) {
168
+ if (typeof digest !== 'string' || digest.length === 0) return false;
169
+ try {
170
+ writeFileSync(
171
+ captureStampPath(cwd, coveragePath),
172
+ `${JSON.stringify({ digest, capturedAt: new Date().toISOString() }, null, 2)}\n`,
173
+ );
174
+ return true;
175
+ } catch {
176
+ return false;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Decide whether the existing coverage artifact is "fresh".
182
+ *
183
+ * Primary test (content-aware, Story #3982): when a capture stamp exists
184
+ * next to the artifact, compare its persisted digest against the current
185
+ * content digest of `targetDirs`. Equal digests → fresh; different → stale.
186
+ * Branch switches and checkouts that bump mtimes without changing content
187
+ * no longer invalidate coverage.
188
+ *
189
+ * Fallback (stamp absent / unreadable / digest unavailable): the original
190
+ * mtime heuristic — artifact at least as new as the newest source file
191
+ * under `targetDirs`. Missing files, missing target dirs, or any IO error
192
+ * resolve to `false` so the caller captures rather than trusting stale data.
72
193
  *
73
194
  * @param {{
74
195
  * coveragePath: string,
@@ -77,6 +198,8 @@ export function newestSourceMtime(cwd, targetDirs, io = {}) {
77
198
  * statSync?: typeof fs.statSync,
78
199
  * readdirSync?: typeof fs.readdirSync,
79
200
  * existsSync?: typeof fs.existsSync,
201
+ * readFileSync?: typeof fs.readFileSync,
202
+ * computeDigest?: typeof computeContentDigest,
80
203
  * }} opts
81
204
  * @returns {{ fresh: boolean, reason: 'missing' | 'stale' | 'fresh' | 'no-sources' }}
82
205
  */
@@ -87,10 +210,30 @@ export function isCoverageFresh({
87
210
  statSync = fs.statSync,
88
211
  readdirSync = fs.readdirSync,
89
212
  existsSync = fs.existsSync,
213
+ readFileSync = fs.readFileSync,
214
+ computeDigest = computeContentDigest,
90
215
  }) {
91
216
  const absCoverage = path.resolve(cwd, coveragePath);
92
217
  if (!existsSync(absCoverage)) return { fresh: false, reason: 'missing' };
93
218
 
219
+ const stampPath = captureStampPath(cwd, coveragePath);
220
+ if (existsSync(stampPath)) {
221
+ let stamp = null;
222
+ try {
223
+ stamp = JSON.parse(readFileSync(stampPath, 'utf8'));
224
+ } catch {
225
+ // Corrupt/unreadable stamp → fall through to the mtime heuristic.
226
+ }
227
+ if (typeof stamp?.digest === 'string' && stamp.digest.length > 0) {
228
+ const current = computeDigest(cwd, targetDirs);
229
+ if (typeof current === 'string' && current.length > 0) {
230
+ return current === stamp.digest
231
+ ? { fresh: true, reason: 'fresh' }
232
+ : { fresh: false, reason: 'stale' };
233
+ }
234
+ }
235
+ }
236
+
94
237
  let coverageMtime;
95
238
  try {
96
239
  coverageMtime = statSync(absCoverage).mtimeMs;
@@ -54,6 +54,20 @@ import { Worker } from 'node:worker_threads';
54
54
  /** Default factory: spawn a real `worker_threads.Worker`. */
55
55
  const defaultWorkerFactory = (script, options) => new Worker(script, options);
56
56
 
57
+ /**
58
+ * Pool-vs-serial cutover for `runOnPool` callers.
59
+ *
60
+ * Below this batch size the pool's worker spawn overhead dominates, so
61
+ * callers fall back to in-process serial scoring. Tuned against the test
62
+ * suite's tmpdir fixtures (n=2 stays serial; the full repo n≈200–470
63
+ * takes the pool path). Single-sourced here so the maintainability
64
+ * baseline scan (`maintainability-utils.js`), the CRAP scanner
65
+ * (`crap-utils.js`), and the native review provider
66
+ * (`review-providers/native.js`) cannot silently desynchronize on a
67
+ * retune.
68
+ */
69
+ export const POOL_SERIAL_THRESHOLD = 8;
70
+
57
71
  /**
58
72
  * @template TItem, TResult
59
73
  * @param {string|URL} workerScript - File URL or path to the worker entry.