mandrel 2.25.0 → 2.27.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 (132) hide show
  1. package/.agents/agents/acceptance-critic.md +10 -6
  2. package/.agents/audit-checklists/baselines.md +21 -0
  3. package/.agents/docs/quality-gates.md +80 -18
  4. package/.agents/docs/workflows.md +3 -1
  5. package/.agents/instructions.md +1 -1
  6. package/.agents/schemas/audit-rules.json +15 -0
  7. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  8. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  9. package/.agents/schemas/baselines/crap.schema.json +8 -0
  10. package/.agents/schemas/model-attribution.schema.json +4 -0
  11. package/.agents/scripts/acceptance-eval.js +89 -6
  12. package/.agents/scripts/audit-baselines.js +136 -0
  13. package/.agents/scripts/check-arch-cycles.js +12 -93
  14. package/.agents/scripts/check-baseline-drift.js +16 -3
  15. package/.agents/scripts/check-baselines.js +19 -3
  16. package/.agents/scripts/check-cyclomatic.js +214 -0
  17. package/.agents/scripts/check-schema-references.js +392 -0
  18. package/.agents/scripts/check-test-temp-hygiene.js +38 -1
  19. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  20. package/.agents/scripts/diagnose-friction.js +85 -19
  21. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  22. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  23. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  24. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  25. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  26. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  27. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  28. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  29. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  30. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  31. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  32. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  33. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  34. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  35. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  36. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  37. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  38. package/.agents/scripts/lib/baselines/git-base.js +26 -4
  39. package/.agents/scripts/lib/baselines/kinds/crap.js +112 -15
  40. package/.agents/scripts/lib/baselines/reader.js +52 -38
  41. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  42. package/.agents/scripts/lib/baselines/scope.js +39 -90
  43. package/.agents/scripts/lib/baselines/writer.js +16 -11
  44. package/.agents/scripts/lib/changed-files.js +8 -1
  45. package/.agents/scripts/lib/cli-args.js +115 -1
  46. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  47. package/.agents/scripts/lib/crap-engine.js +32 -13
  48. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  49. package/.agents/scripts/lib/crap-utils.js +13 -0
  50. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  51. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  52. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  53. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  54. package/.agents/scripts/lib/git-utils.js +136 -80
  55. package/.agents/scripts/lib/import-graph.js +156 -0
  56. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  57. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  58. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  59. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  60. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  61. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  62. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  63. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  64. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  65. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  66. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  67. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +209 -109
  68. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
  69. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  70. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  71. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  72. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  73. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  74. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  75. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
  76. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  77. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  78. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  79. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
  80. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  81. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  82. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  83. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  84. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  85. package/.agents/scripts/lib/story-adjacency.js +3 -3
  86. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  87. package/.agents/scripts/lib/test-tiers.js +11 -2
  88. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  89. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  90. package/.agents/scripts/lib/wave-runner/live-probe.js +24 -14
  91. package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
  92. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
  93. package/.agents/scripts/lib/workers/crap-worker.js +2 -10
  94. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  95. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  96. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  97. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  98. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  99. package/.agents/scripts/lib/worktree-manager.js +14 -0
  100. package/.agents/scripts/plan-run-epilogue.js +17 -5
  101. package/.agents/scripts/providers/github/tickets.js +33 -10
  102. package/.agents/scripts/provision-git-hooks.js +85 -0
  103. package/.agents/scripts/quality-preview.js +112 -28
  104. package/.agents/scripts/resolve-stories.js +4 -1
  105. package/.agents/scripts/run-coverage.js +86 -35
  106. package/.agents/scripts/run-lint.js +20 -0
  107. package/.agents/scripts/run-tests.js +26 -36
  108. package/.agents/scripts/single-story-close.js +28 -2
  109. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  110. package/.agents/scripts/stories-wave-tick.js +214 -38
  111. package/.agents/scripts/update-coverage-baseline.js +34 -4
  112. package/.agents/scripts/update-duplication-baseline.js +209 -83
  113. package/.agents/scripts/validate-docs-freshness.js +1 -0
  114. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  115. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  116. package/.agents/skills/skills.index.json +2 -2
  117. package/.agents/workflows/audit-baselines.md +289 -0
  118. package/.agents/workflows/audit-navigability.md +5 -4
  119. package/.agents/workflows/deliver.md +13 -4
  120. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  121. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  122. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  123. package/.agents/workflows/helpers/deliver-reference.md +77 -1
  124. package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
  125. package/.agents/workflows/helpers/plan-reference.md +15 -5
  126. package/.agents/workflows/memory-consolidate.md +116 -0
  127. package/.agents/workflows/plan.md +3 -0
  128. package/README.md +13 -6
  129. package/docs/CHANGELOG.md +71 -0
  130. package/package.json +9 -4
  131. package/.agents/schemas/friction-event.schema.json +0 -56
  132. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -0,0 +1,291 @@
1
+ /**
2
+ * CLI: GitHub Actions job-timeout gate.
3
+ *
4
+ * Story #4936. Every job in this repository inherited GitHub's 360-minute
5
+ * default because not one of the four workflow files set `timeout-minutes`.
6
+ * That is not a theoretical cost: a deadlocked `Windows Smoke` job burned 44
7
+ * minutes of a `windows-latest` runner, and — because the run stayed
8
+ * `in_progress` — GitHub withheld the logs of the *required* check that had
9
+ * already failed on the same run, so the real merge blocker could not be
10
+ * diagnosed until the run was cancelled by hand. An unbounded job costs
11
+ * runner time and diagnosability at once.
12
+ *
13
+ * A ceiling only helps if it is bounded on both ends, so this gate fails on
14
+ * two shapes:
15
+ *
16
+ * - a job with no `timeout-minutes` at all (unbounded, 360-minute default);
17
+ * - a job whose `timeout-minutes` exceeds `MAX_TIMEOUT_MINUTES`, i.e. a
18
+ * number generous enough to be useless as a deadlock detector.
19
+ *
20
+ * `MIN_TIMEOUT_MINUTES` is the floor a value must clear to be plausible
21
+ * headroom over a job that takes a couple of minutes today; below it, a
22
+ * transient slow runner reds the build for no defect.
23
+ *
24
+ * Contract:
25
+ * - Scans the workflows directory (default `.github/workflows`, override
26
+ * with `--dir <path>`), parsing each `*.yml` / `*.yaml` with js-yaml and
27
+ * enumerating every `jobs.<id>` key.
28
+ * - Prints `<file> job <id> — <reason>` per violation, then a one-line
29
+ * summary even on a clean scan so the "ok" signal is visible in CI.
30
+ * - With `--json`: writes a structured envelope to stdout and skips the
31
+ * human summary.
32
+ * - Exit codes: 0 = every job bounded in range; 1 = at least one violation.
33
+ * A missing / empty workflows directory exits 0 (nothing to gate).
34
+ */
35
+
36
+ import fs from 'node:fs';
37
+ import path from 'node:path';
38
+ import process from 'node:process';
39
+ import yaml from 'js-yaml';
40
+ import { runAsCli } from './lib/cli-utils.js';
41
+
42
+ /**
43
+ * Inclusive bounds every job's `timeout-minutes` must sit within.
44
+ *
45
+ * The ceiling is the load-bearing half — it is what turns a deadlock into a
46
+ * bounded loss instead of a six-hour one. The floor keeps a well-meaning
47
+ * `timeout-minutes: 2` from reddening the build on a slow runner.
48
+ */
49
+ export const MIN_TIMEOUT_MINUTES = 10;
50
+ export const MAX_TIMEOUT_MINUTES = 30;
51
+
52
+ /**
53
+ * Enumerate workflow files (`*.yml` / `*.yaml`) directly under `dir`.
54
+ * Returns absolute paths sorted for deterministic output. A missing directory
55
+ * yields an empty list.
56
+ *
57
+ * @param {string} dir Absolute workflows directory.
58
+ * @param {typeof fs} [fsLike]
59
+ * @returns {string[]}
60
+ */
61
+ export function listWorkflowFiles(dir, fsLike = fs) {
62
+ let entries;
63
+ try {
64
+ entries = fsLike.readdirSync(dir, { withFileTypes: true });
65
+ } catch {
66
+ return [];
67
+ }
68
+ return entries
69
+ .filter((e) => e.isFile() && /\.ya?ml$/i.test(e.name))
70
+ .map((e) => path.join(dir, e.name))
71
+ .sort();
72
+ }
73
+
74
+ /**
75
+ * Read the `jobs` mapping out of one workflow document.
76
+ *
77
+ * The single parse point for both the violation scan and the job count, so
78
+ * the two can never disagree about what a workflow's job set is.
79
+ *
80
+ * @param {string} text The file contents.
81
+ * @param {(s: string) => unknown} parse YAML parser; injected in tests.
82
+ * @returns {{ jobs: Record<string, unknown> | null, error: Error | null }}
83
+ */
84
+ function readJobs(text, parse) {
85
+ let doc;
86
+ try {
87
+ doc = parse(text);
88
+ } catch (err) {
89
+ return { jobs: null, error: err };
90
+ }
91
+ const jobs = doc && typeof doc === 'object' ? doc.jobs : null;
92
+ return {
93
+ jobs: jobs && typeof jobs === 'object' ? jobs : null,
94
+ error: null,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Judge one job body's `timeout-minutes`, returning the violation shape or
100
+ * `null` when the job is bounded in range.
101
+ *
102
+ * Reusable-workflow calls (`jobs.<id>.uses:`) are exempt: `timeout-minutes`
103
+ * is not a valid key on a job that delegates to another workflow — the
104
+ * callee's own jobs carry their timeouts, and setting one here is a schema
105
+ * error rather than a fix.
106
+ *
107
+ * @param {unknown} body The `jobs.<id>` value.
108
+ * @returns {{ timeout: number | null, reason: string } | null}
109
+ */
110
+ function judgeJobTimeout(body) {
111
+ if (!body || typeof body !== 'object') return null;
112
+ if (typeof body.uses === 'string') return null;
113
+
114
+ const timeout = body['timeout-minutes'];
115
+ if (timeout === undefined || timeout === null) {
116
+ return {
117
+ timeout: null,
118
+ reason: `no timeout-minutes — the job inherits GitHub's 360-minute default`,
119
+ };
120
+ }
121
+ if (typeof timeout !== 'number' || !Number.isFinite(timeout)) {
122
+ return {
123
+ timeout: null,
124
+ reason: `timeout-minutes is not a number (${JSON.stringify(timeout)})`,
125
+ };
126
+ }
127
+ if (timeout > MAX_TIMEOUT_MINUTES) {
128
+ return {
129
+ timeout,
130
+ reason: `timeout-minutes ${timeout} exceeds the ${MAX_TIMEOUT_MINUTES}-minute ceiling`,
131
+ };
132
+ }
133
+ if (timeout < MIN_TIMEOUT_MINUTES) {
134
+ return {
135
+ timeout,
136
+ reason: `timeout-minutes ${timeout} is below the ${MIN_TIMEOUT_MINUTES}-minute floor`,
137
+ };
138
+ }
139
+ return null;
140
+ }
141
+
142
+ /**
143
+ * Pure helper: enumerate every `jobs.<id>` key in one workflow document and
144
+ * return the violations.
145
+ *
146
+ * An unparseable document is itself a violation: a workflow whose jobs cannot
147
+ * be enumerated cannot be certified as bounded, and failing open would let
148
+ * the very drift this gate exists to catch through.
149
+ *
150
+ * @param {string} file Relative file label used in violation rows.
151
+ * @param {string} text The file contents.
152
+ * @param {(s: string) => unknown} [parse] YAML parser; injected in tests.
153
+ * @returns {Array<{ file: string, job: string, timeout: number | null, reason: string }>}
154
+ */
155
+ export function scanWorkflowText(file, text, parse = yaml.load) {
156
+ const { jobs, error } = readJobs(text, parse);
157
+ if (error) {
158
+ return [
159
+ {
160
+ file,
161
+ job: '(document)',
162
+ timeout: null,
163
+ reason: `workflow could not be parsed as YAML: ${error.message}`,
164
+ },
165
+ ];
166
+ }
167
+ if (!jobs) return [];
168
+
169
+ const violations = [];
170
+ for (const [job, body] of Object.entries(jobs)) {
171
+ const verdict = judgeJobTimeout(body);
172
+ if (verdict) violations.push({ file, job, ...verdict });
173
+ }
174
+ return violations;
175
+ }
176
+
177
+ /**
178
+ * Pure helper: render the human-readable report. One line per violation
179
+ * followed by a one-line summary carrying a `(gate fail)` / `(ok)` marker.
180
+ *
181
+ * @param {Array<{ file: string, job: string, timeout: number | null, reason: string }>} violations
182
+ * @param {number} jobsScanned
183
+ * @returns {string}
184
+ */
185
+ export function renderReport(violations, jobsScanned) {
186
+ const lines = violations.map((v) => `${v.file} job ${v.job} — ${v.reason}`);
187
+ const tag = violations.length > 0 ? '(gate fail)' : '(ok)';
188
+ lines.push(
189
+ `[workflow-timeouts] jobs=${jobsScanned} violations=${violations.length} ${tag}`,
190
+ );
191
+ return lines.join('\n');
192
+ }
193
+
194
+ /**
195
+ * Count every `jobs.<id>` key in a parsed workflow document, including the
196
+ * reusable-workflow calls the violation scan exempts — the summary reports
197
+ * how many jobs were enumerated, not how many were eligible.
198
+ *
199
+ * @param {string} text
200
+ * @param {(s: string) => unknown} [parse]
201
+ * @returns {number}
202
+ */
203
+ export function countJobs(text, parse = yaml.load) {
204
+ const { jobs } = readJobs(text, parse);
205
+ return jobs ? Object.keys(jobs).length : 0;
206
+ }
207
+
208
+ /**
209
+ * Top-level CLI entry. Exported so tests can drive the full pipeline against
210
+ * a fixture workflows directory without touching the repo's real workflows.
211
+ *
212
+ * @param {{
213
+ * argv?: string[],
214
+ * cwd?: string,
215
+ * stdout?: { write: (s: string) => void },
216
+ * stderr?: { write: (s: string) => void },
217
+ * }} [opts]
218
+ * @returns {Promise<number>} exit code: 0 = every job bounded; 1 = violation
219
+ */
220
+ export async function runCli({
221
+ argv = process.argv.slice(2),
222
+ cwd = process.cwd(),
223
+ stdout = process.stdout,
224
+ stderr = process.stderr,
225
+ } = {}) {
226
+ const dirIdx = argv.indexOf('--dir');
227
+ const dir =
228
+ dirIdx !== -1 && argv[dirIdx + 1] && !argv[dirIdx + 1].startsWith('--')
229
+ ? argv[dirIdx + 1]
230
+ : null;
231
+ const json = argv.includes('--json');
232
+ const resolvedDir = path.resolve(
233
+ cwd,
234
+ dir ?? path.join('.github', 'workflows'),
235
+ );
236
+
237
+ const files = listWorkflowFiles(resolvedDir);
238
+ const violations = [];
239
+ let jobsScanned = 0;
240
+ for (const file of files) {
241
+ let text;
242
+ try {
243
+ text = fs.readFileSync(file, 'utf-8');
244
+ } catch {
245
+ continue;
246
+ }
247
+ const label = path.relative(cwd, file);
248
+ jobsScanned += countJobs(text);
249
+ violations.push(...scanWorkflowText(label, text));
250
+ }
251
+
252
+ const exitCode = violations.length > 0 ? 1 : 0;
253
+
254
+ if (json) {
255
+ stdout.write(
256
+ `${JSON.stringify(
257
+ {
258
+ kind: 'workflow-timeouts-report',
259
+ dir: resolvedDir,
260
+ filesScanned: files.length,
261
+ jobsScanned,
262
+ minTimeoutMinutes: MIN_TIMEOUT_MINUTES,
263
+ maxTimeoutMinutes: MAX_TIMEOUT_MINUTES,
264
+ violations,
265
+ exitCode,
266
+ },
267
+ null,
268
+ 2,
269
+ )}\n`,
270
+ );
271
+ } else {
272
+ if (files.length === 0) {
273
+ stderr.write(
274
+ `[workflow-timeouts] ⚠ no workflow files found under ${resolvedDir}\n`,
275
+ );
276
+ }
277
+ stdout.write(`${renderReport(violations, jobsScanned)}\n`);
278
+ }
279
+
280
+ return exitCode;
281
+ }
282
+
283
+ async function main() {
284
+ return runCli();
285
+ }
286
+
287
+ runAsCli(import.meta.url, main, {
288
+ source: 'workflow-timeouts',
289
+ propagateExitCode: true,
290
+ errorPrefix: '[workflow-timeouts] ❌ Fatal error',
291
+ });
@@ -16,7 +16,11 @@
16
16
  *
17
17
  * Usage:
18
18
  * node diagnose-friction.js [--story <STORY_ID>] \
19
- * [--epic <EPIC_ID>] --cmd <command with args...>
19
+ * [--epic <EPIC_ID>] --cmd <cmd> <args...>
20
+ *
21
+ * `--cmd` consumes the remaining argv as separate words and spawns them with
22
+ * no shell. Quoting the whole command as one string is a usage error, not
23
+ * friction — it is refused loudly and writes no ledger row.
20
24
  *
21
25
  * Story/Epic resolution order:
22
26
  * 1. CLI flags (--story, --epic).
@@ -108,6 +112,16 @@ function classifyFrictionCategory(errorOutput) {
108
112
  */
109
113
  const INTERCEPTOR_TIMEOUT_SIGNAL = 'SIGTERM';
110
114
 
115
+ /**
116
+ * A `maxBuffer` overflow presents identically to a timeout — `status: null`,
117
+ * `signal: 'SIGTERM'` — because Node kills the child the same way. The only
118
+ * discriminator is `result.error.code`, so a SIGTERM carrying this code is a
119
+ * buffer overflow and must never be reported as a timeout (Story #4915).
120
+ *
121
+ * @type {string}
122
+ */
123
+ const OVERFLOW_ERROR_CODE = 'ENOBUFS';
124
+
111
125
  /** Shell convention for "the process died by signal N": exit `128 + N`. */
112
126
  const SIGNAL_EXIT_BASE = 128;
113
127
 
@@ -118,8 +132,10 @@ const SIGNAL_EXIT_BASE = 128;
118
132
  * interceptor would report success for a command it just watched get killed
119
133
  * (Story #4851).
120
134
  *
121
- * Which signal fired is the diagnostic value: SIGTERM points at the
122
- * interceptor's own bound, anything else at the host. Recording that plus the
135
+ * Which signal fired is the diagnostic value: SIGTERM points at one of the
136
+ * interceptor's own bounds, anything else at the host. A SIGTERM splits again
137
+ * on `error.code`: `ENOBUFS` means the output blew past `executionMaxBuffer`,
138
+ * anything else means `executionTimeoutMs` fired. Recording that plus the
123
139
  * bound itself is what makes the row actionable to a consumer who cannot edit
124
140
  * the materialized framework tree.
125
141
  *
@@ -128,13 +144,17 @@ const SIGNAL_EXIT_BASE = 128;
128
144
  * the file's per-file maintainability-delta headroom. The CLI contract is the
129
145
  * seam the unit tests drive.
130
146
  *
131
- * @param {{signal: (string|null), error?: {message?: string}}} result A
132
- * `spawnSync` result whose `status` is `null`.
133
- * @param {number} executionTimeoutMs The resolved interceptor bound, in ms.
147
+ * @param {{signal: (string|null), error?: {message?: string, code?: string}}}
148
+ * result A `spawnSync` result whose `status` is `null`.
149
+ * @param {{executionTimeoutMs: number, executionMaxBuffer: number}} bounds The
150
+ * resolved interceptor bounds — the timeout in ms, the buffer in bytes.
134
151
  * @returns {{category: string, remediation: string, details: object,
135
152
  * preview: string, exitCode: number}}
136
153
  */
137
- function describeAbnormalExit(result, executionTimeoutMs) {
154
+ function describeAbnormalExit(
155
+ result,
156
+ { executionTimeoutMs, executionMaxBuffer },
157
+ ) {
138
158
  const signal = typeof result.signal === 'string' ? result.signal : null;
139
159
  if (signal === null) {
140
160
  return {
@@ -150,15 +170,42 @@ function describeAbnormalExit(result, executionTimeoutMs) {
150
170
  };
151
171
  }
152
172
 
153
- const timedOut = signal === INTERCEPTOR_TIMEOUT_SIGNAL;
154
- const killOrigin = timedOut ? 'interceptor-timeout' : 'external';
173
+ const sentByInterceptor = signal === INTERCEPTOR_TIMEOUT_SIGNAL;
174
+ const overflowed =
175
+ sentByInterceptor && result.error?.code === OVERFLOW_ERROR_CODE;
176
+ let killOrigin = 'external';
177
+ if (overflowed) killOrigin = 'buffer-overflow';
178
+ else if (sentByInterceptor) killOrigin = 'interceptor-timeout';
179
+
180
+ const shapes = {
181
+ 'buffer-overflow': {
182
+ category: 'Tool Limitation',
183
+ remediation: ` - ${signal} was sent because the command's output overflowed the interceptor's executionMaxBuffer bound (${executionMaxBuffer} bytes / 10 MiB) — the executionTimeoutMs bound (${executionTimeoutMs}ms) did not fire. Do NOT split the command into smaller steps: quieten or redirect its output, or raise the buffer bound.`,
184
+ extraDetails: { executionMaxBuffer },
185
+ },
186
+ 'interceptor-timeout': {
187
+ category: 'Execution Timeout',
188
+ remediation: ` - ${signal} matches the interceptor's own executionTimeoutMs bound (${executionTimeoutMs}ms), so the command was almost certainly cut off rather than broken. Split it into smaller steps, or raise the bound.`,
189
+ extraDetails: {},
190
+ },
191
+ external: {
192
+ category: 'Execution Killed',
193
+ remediation: ` - ${signal} originated outside the interceptor — the executionTimeoutMs bound (${executionTimeoutMs}ms) did not fire, so suspect an OOM kill or a hard kill from the host. Reduce the command's memory footprint or give the host more headroom.`,
194
+ extraDetails: {},
195
+ },
196
+ };
197
+
198
+ const shape = shapes[killOrigin];
155
199
  const signum = osConstants.signals[signal];
156
200
  return {
157
- category: timedOut ? 'Execution Timeout' : 'Execution Killed',
158
- remediation: timedOut
159
- ? ` - ${signal} matches the interceptor's own executionTimeoutMs bound (${executionTimeoutMs}ms), so the command was almost certainly cut off rather than broken. Split it into smaller steps, or raise the bound.`
160
- : ` - ${signal} originated outside the interceptor — the executionTimeoutMs bound (${executionTimeoutMs}ms) did not fire, so suspect an OOM kill or a hard kill from the host. Reduce the command's memory footprint or give the host more headroom.`,
161
- details: { killedBySignal: signal, killOrigin, executionTimeoutMs },
201
+ category: shape.category,
202
+ remediation: shape.remediation,
203
+ details: {
204
+ killedBySignal: signal,
205
+ killOrigin,
206
+ executionTimeoutMs,
207
+ ...shape.extraDetails,
208
+ },
162
209
  preview: `Command terminated by signal ${signal} (${killOrigin}); executionTimeoutMs=${executionTimeoutMs}.`,
163
210
  exitCode: Number.isInteger(signum) ? SIGNAL_EXIT_BASE + signum : 1,
164
211
  };
@@ -221,7 +268,23 @@ export async function main(args = process.argv.slice(2)) {
221
268
 
222
269
  if (cmdArgs.length === 0) {
223
270
  throw new Error(
224
- 'Usage: node diagnose-friction.js [--story <STORY_ID>] [--epic <EPIC_ID>] --cmd <command with args...>',
271
+ 'Usage: node diagnose-friction.js [--story <STORY_ID>] [--epic <EPIC_ID>] --cmd <cmd> <args...>',
272
+ );
273
+ }
274
+
275
+ // Story #4915 — the interceptor spawns `cmdArgs[0]` directly, with no shell.
276
+ // A single argument containing whitespace therefore names an executable that
277
+ // cannot exist, and the resulting ENOENT is a usage error in the
278
+ // interceptor's OWN invocation, not friction in the wrapped command. It must
279
+ // be reported as one and MUST NOT reach the ledger — otherwise the real
280
+ // friction is discarded and the roll-up eventually auto-files a framework-gap
281
+ // ticket about the framework's own instrumentation being misused. The
282
+ // discriminator is this argv shape, never the ENOENT result: a correctly
283
+ // split command whose binary is genuinely absent yields the identical
284
+ // `spawnSync` result and stays real friction.
285
+ if (cmdArgs.length === 1 && /\s/.test(cmdArgs[0])) {
286
+ throw new Error(
287
+ `Usage: --cmd takes the command as separate argv words, not one quoted string. Received a single quoted argument: "${cmdArgs[0]}". Drop the quotes so each word is its own argv entry — \`--cmd ${cmdArgs[0]}\`. No friction signal was recorded.`,
225
288
  );
226
289
  }
227
290
 
@@ -251,7 +314,10 @@ export async function main(args = process.argv.slice(2)) {
251
314
  // `result.signal`, not in the status.
252
315
  const abnormal =
253
316
  result.status === null
254
- ? describeAbnormalExit(result, executionTimeoutMs)
317
+ ? describeAbnormalExit(result, {
318
+ executionTimeoutMs,
319
+ executionMaxBuffer,
320
+ })
255
321
  : null;
256
322
  // With both streams empty an abnormal termination names its signal; the
257
323
  // `Unknown exit code` fallback is therefore reachable only with a real
@@ -343,15 +409,15 @@ runAsCli(import.meta.url, main, {
343
409
  source: 'DiagnoseFriction',
344
410
  usage: {
345
411
  invocation:
346
- 'node .agents/scripts/diagnose-friction.js [--story <id>] [--epic <id>] --cmd <command with args...>',
412
+ 'node .agents/scripts/diagnose-friction.js [--story <id>] [--epic <id>] --cmd <cmd> <args...>',
347
413
  summary:
348
414
  'Run a command through the diagnostic interceptor: stream its output, then append a local friction signal describing the failure. Never posts to the ticket.',
349
415
  flags: [
350
416
  ['--story <id>', 'Story the friction belongs to.'],
351
417
  ['--epic <id>', 'Epic the friction belongs to.'],
352
418
  [
353
- '--cmd <command...>',
354
- 'The command to execute; everything after it is the argv (required).',
419
+ '--cmd <cmd> <args...>',
420
+ 'The command to execute; everything after it is the argv, as separate words — never one quoted string (required).',
355
421
  ],
356
422
  ],
357
423
  },
@@ -0,0 +1,177 @@
1
+ /**
2
+ * engine.js — assemble the `/audit-baselines` evidence envelope
3
+ * (Story #4902).
4
+ *
5
+ * Strictly read-only and strictly offline: it reads committed baselines, the
6
+ * resolved config, git history, the static import graph, and any friction
7
+ * ledger it finds. It never writes under `baselines/`, never refreshes a
8
+ * baseline, and never runs a test, coverage, or mutation suite — the whole
9
+ * point is that a baseline review costs a file read, not a CI run.
10
+ *
11
+ * Findings are evidence, not a verdict: assembling the envelope is success,
12
+ * however alarming its contents, so the CLI exits 0 whenever it got this far.
13
+ * Judgment belongs to the lens that reads the envelope.
14
+ *
15
+ * @module lib/audit-baselines/engine
16
+ */
17
+
18
+ import path from 'node:path';
19
+ import { mainCheckoutRoot, tempRootFrom } from '../config/temp-paths.js';
20
+ import { getQuality, resolveConfig } from '../config-resolver.js';
21
+ import { buildGateSurface } from './gate-surface.js';
22
+ import { buildHeadroom } from './headroom.js';
23
+ import { buildHotspots } from './hotspots.js';
24
+ import { ALL_KINDS, baselinePathFor, GATE_KINDS } from './kinds.js';
25
+ import { DEFAULT_TOP_N, extractOutliers } from './outliers.js';
26
+ import { buildTrend } from './trend.js';
27
+ import {
28
+ makeWeightResolver,
29
+ readCentrality,
30
+ readChurn,
31
+ readFriction,
32
+ } from './weights.js';
33
+
34
+ /** Envelope `kind` discriminator; matches the shipped schema's const. */
35
+ const ENVELOPE_KIND = 'audit-baselines-envelope';
36
+
37
+ /** Envelope schema version — bumped on any breaking shape change. */
38
+ const ENVELOPE_SCHEMA_VERSION = '1';
39
+
40
+ /** Cap on emitted hotspot clusters, independent of the per-gate `topN`. */
41
+ export const DEFAULT_HOTSPOT_LIMIT = 50;
42
+
43
+ /**
44
+ * Resolve the repository config without letting a broken `.agentrc.json`
45
+ * abort the run — an unreadable config still leaves the baseline files
46
+ * themselves readable at their default paths.
47
+ *
48
+ * @param {string} cwd
49
+ * @returns {{ quality: object, configError: string | null }}
50
+ */
51
+ function resolveQualityBlock(cwd) {
52
+ try {
53
+ return { quality: getQuality(resolveConfig({ cwd })), configError: null };
54
+ } catch (err) {
55
+ return { quality: { gates: {} }, configError: err?.message ?? String(err) };
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Absolute temp root the friction ledger is searched under, anchored to the
61
+ * **analysed** repository rather than the process cwd. `resolvedTempRoot()`
62
+ * anchors to whichever checkout the current process sits in, which is the
63
+ * right answer for a writer and the wrong one here: an engine pointed at
64
+ * another repo with `--cwd` must read that repo's ledger, not this one's.
65
+ *
66
+ * @param {string} cwd
67
+ * @returns {string}
68
+ */
69
+ function tempRootFor(cwd) {
70
+ let relative = 'temp';
71
+ try {
72
+ relative = tempRootFrom(resolveConfig({ cwd }));
73
+ } catch {
74
+ // Unreadable config — the framework default root is still worth probing.
75
+ }
76
+ if (path.isAbsolute(relative)) return relative;
77
+ return path.join(mainCheckoutRoot(cwd) ?? cwd, relative);
78
+ }
79
+
80
+ /**
81
+ * Run the engine and return the envelope object. Pure with respect to the
82
+ * filesystem apart from the reads named in the module docstring — writing
83
+ * the result is the caller's job.
84
+ *
85
+ * @param {{
86
+ * cwd: string,
87
+ * topN?: number,
88
+ * hotspotLimit?: number,
89
+ * trendDepth?: number,
90
+ * now?: Date,
91
+ * }} args
92
+ * @returns {object} the `audit-baselines-envelope`
93
+ */
94
+ export function runEngine({
95
+ cwd,
96
+ topN = DEFAULT_TOP_N,
97
+ hotspotLimit = DEFAULT_HOTSPOT_LIMIT,
98
+ trendDepth = 5,
99
+ now = new Date(),
100
+ }) {
101
+ const { quality, configError } = resolveQualityBlock(cwd);
102
+ const { entries, baselines } = buildGateSurface({ cwd, quality, now });
103
+
104
+ const outliers = ALL_KINDS.flatMap((kind) =>
105
+ extractOutliers({ kind, baseline: baselines.get(kind) ?? null, topN }),
106
+ );
107
+
108
+ const churn = readChurn({ cwd });
109
+ const centrality = readCentrality({ cwd });
110
+ const friction = readFriction({ tempRootAbs: tempRootFor(cwd) });
111
+
112
+ return {
113
+ kind: ENVELOPE_KIND,
114
+ schemaVersion: ENVELOPE_SCHEMA_VERSION,
115
+ generatedAt: now.toISOString(),
116
+ cwd,
117
+ topN,
118
+ configError,
119
+ degradations: {
120
+ gitHistory: churn.degraded,
121
+ importGraph: centrality.degraded,
122
+ frictionLedger: friction.degraded,
123
+ },
124
+ gateSurface: entries,
125
+ hotspots: buildHotspots({
126
+ outliers,
127
+ weightsFor: makeWeightResolver({ churn, centrality, friction }),
128
+ limit: hotspotLimit,
129
+ }),
130
+ trend: buildTrend({
131
+ cwd,
132
+ kinds: ALL_KINDS,
133
+ pathFor: (kind) => baselinePathFor(kind, quality),
134
+ depth: trendDepth,
135
+ }),
136
+ headroom: buildHeadroom({ kinds: GATE_KINDS, quality, baselines }),
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Condense an envelope into the pure-JSON stdout summary. Small enough to
142
+ * read in a terminal, and never carrying a row set.
143
+ *
144
+ * @param {object} envelope
145
+ * @param {string} outPath absolute path the full envelope was written to
146
+ * @returns {object}
147
+ */
148
+ export function summarize(envelope, outPath) {
149
+ return {
150
+ kind: 'audit-baselines-summary',
151
+ schemaVersion: ENVELOPE_SCHEMA_VERSION,
152
+ out: outPath,
153
+ generatedAt: envelope.generatedAt,
154
+ gateSurface: {
155
+ total: envelope.gateSurface.length,
156
+ configured: envelope.gateSurface.filter((g) => g.configured).length,
157
+ missingBaseline: envelope.gateSurface
158
+ .filter((g) => !g.baselineExists)
159
+ .map((g) => g.kind),
160
+ stubs: envelope.gateSurface.filter((g) => g.stub).map((g) => g.kind),
161
+ deadIgnoreGlobs: envelope.gateSurface.reduce(
162
+ (n, g) => n + g.deadIgnoreGlobs.length,
163
+ 0,
164
+ ),
165
+ },
166
+ hotspots: {
167
+ total: envelope.hotspots.length,
168
+ multiGate: envelope.hotspots.filter((h) => h.gateCount > 1).length,
169
+ top: envelope.hotspots
170
+ .slice(0, 5)
171
+ .map((h) => ({ path: h.path, gates: h.gateKinds, rank: h.rank })),
172
+ },
173
+ trend: { kinds: envelope.trend.length },
174
+ headroom: { axes: envelope.headroom.length },
175
+ degradations: envelope.degradations,
176
+ };
177
+ }