mandrel 2.8.0 → 2.10.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 (73) hide show
  1. package/.agents/agents/.markdownlint.json +4 -0
  2. package/.agents/agents/acceptance-critic.md +30 -5
  3. package/.agents/agents/auditor.md +36 -19
  4. package/.agents/agents/plan-critic.md +31 -5
  5. package/.agents/agents/story-worker.md +91 -100
  6. package/.agents/docs/configuration.md +39 -1
  7. package/.agents/docs/execution-reference.md +13 -0
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/instructions.md +131 -265
  10. package/.agents/rules/git-conventions.md +47 -83
  11. package/.agents/rules/orchestration-error-handling.md +28 -0
  12. package/.agents/schemas/agentrc.schema.json +44 -1
  13. package/.agents/schemas/validation-evidence.schema.json +3 -1
  14. package/.agents/scripts/acceptance-eval.js +1 -1
  15. package/.agents/scripts/apply-quality-bootstrap.js +1 -1
  16. package/.agents/scripts/audit-to-stories.js +51 -0
  17. package/.agents/scripts/check-test-temp-hygiene.js +438 -0
  18. package/.agents/scripts/deliver-recover.js +23 -6
  19. package/.agents/scripts/lib/audit-suite/index.js +5 -0
  20. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +179 -0
  21. package/.agents/scripts/lib/audit-suite/selector.js +1 -1
  22. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +120 -55
  23. package/.agents/scripts/lib/config/temp-paths.js +121 -1
  24. package/.agents/scripts/lib/config-settings-schema-delivery.js +30 -0
  25. package/.agents/scripts/lib/config-settings-schema.js +32 -0
  26. package/.agents/scripts/lib/findings/semantic-issue-search.js +43 -5
  27. package/.agents/scripts/lib/observability/metrics-ledger.js +217 -0
  28. package/.agents/scripts/lib/observability/runtime-friction.js +7 -0
  29. package/.agents/scripts/lib/observability/terse-result.js +114 -0
  30. package/.agents/scripts/lib/orchestration/complexity-gate.js +318 -0
  31. package/.agents/scripts/lib/orchestration/deliver-recover.js +137 -10
  32. package/.agents/scripts/lib/orchestration/merge-block-class.js +36 -15
  33. package/.agents/scripts/lib/orchestration/merge-poll.js +213 -0
  34. package/.agents/scripts/lib/orchestration/plan-context.js +60 -0
  35. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +182 -9
  36. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +29 -2
  37. package/.agents/scripts/lib/orchestration/plan-metrics.js +31 -82
  38. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +102 -2
  39. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +215 -14
  40. package/.agents/scripts/lib/orchestration/resolve-stories.js +7 -0
  41. package/.agents/scripts/lib/orchestration/review-providers/native.js +34 -16
  42. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +221 -8
  43. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -3
  44. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +230 -79
  45. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +55 -14
  46. package/.agents/scripts/lib/orchestration/story-close/emit-blocked.js +9 -3
  47. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +89 -1
  48. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +73 -0
  49. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -1
  50. package/.agents/scripts/lib/orchestration/task-body-validator.js +13 -40
  51. package/.agents/scripts/lib/story-body/body-format-lints.js +215 -0
  52. package/.agents/scripts/lib/story-body/story-body.js +18 -2
  53. package/.agents/scripts/lib/templates/decomposer-prompts.js +29 -6
  54. package/.agents/scripts/lib/test-env.js +65 -0
  55. package/.agents/scripts/plan-context.js +66 -9
  56. package/.agents/scripts/plan-critics.js +115 -3
  57. package/.agents/scripts/plan-persist.js +11 -1
  58. package/.agents/scripts/plan-run-epilogue.js +1 -1
  59. package/.agents/scripts/providers/github/issues.js +54 -7
  60. package/.agents/scripts/providers/github/search-budget.js +124 -0
  61. package/.agents/scripts/providers/github/search-query.js +71 -0
  62. package/.agents/scripts/single-story-confirm-merge.js +79 -10
  63. package/.agents/scripts/single-story-init.js +19 -3
  64. package/.agents/scripts/stories-wave-tick.js +1 -1
  65. package/.agents/scripts/sync-branch-from-base.js +9 -3
  66. package/.agents/workflows/deliver.md +86 -230
  67. package/.agents/workflows/helpers/deliver-reference.md +167 -0
  68. package/.agents/workflows/helpers/deliver-story-reference.md +203 -0
  69. package/.agents/workflows/helpers/deliver-story.md +114 -422
  70. package/.agents/workflows/helpers/plan-reference.md +211 -0
  71. package/.agents/workflows/plan.md +107 -279
  72. package/docs/CHANGELOG.md +47 -0
  73. package/package.json +1 -1
@@ -0,0 +1,438 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Test-temp hygiene guard (Story #4696).
5
+ *
6
+ * The friction / lifecycle / trace NDJSON streams under `temp/` are the
7
+ * substrate every retro, rollup, and loop-health consumer reads. When the
8
+ * test suite appends fixture records to the *real* `temp/` tree (the #4555
9
+ * defect class, re-surfaced for the signal writers), those consumers read
10
+ * noise: at the time this guard shipped, 99% of friction records were
11
+ * test-fixture pollution.
12
+ *
13
+ * The writer-layer fix (`lib/config/temp-paths.js` scratch seam +
14
+ * `lib/test-env.js` bootstrap) redirects stray test writes into an absolute
15
+ * per-process scratch dir. This script is the regression guard that keeps
16
+ * the fix honest, plus a local cleanup mode for the accumulated noise:
17
+ *
18
+ * --snapshot Record a fingerprint (size + sha256) of every stream
19
+ * file under `temp/` to the snapshot baseline. Run this
20
+ * before the suite.
21
+ * --assert Re-scan and fail if any stream file was added or grew
22
+ * relative to the snapshot. Run this after the suite. A
23
+ * missing snapshot is a hard failure ("snapshot missing
24
+ * — guard cannot attest"), never a silent re-baseline:
25
+ * the baseline lives *outside* the protected `temp/`
26
+ * tree (Story #4711), so a test wiping `temp/` can no
27
+ * longer destroy the baseline and fail the guard open.
28
+ * --baseline <path> Explicit snapshot-baseline path (CI sets this to a
29
+ * runner-temp path). Defaults to an OS scratch location
30
+ * keyed by the resolved repo root. Refused when it
31
+ * resolves inside the protected `temp/` tree.
32
+ * --clean List stream directories whose Epic/Story id matches a
33
+ * known fixture id (report-only; nothing is deleted).
34
+ * --clean --yes Delete those directories.
35
+ * --ids 1,2,3 Override the fixture-id list for --clean.
36
+ * --root <dir> Operate against <dir> instead of the repo root
37
+ * (its `temp/` subtree). Used by tests.
38
+ *
39
+ * Exit codes: 1 on an --assert failure (a new / grown stream file, or a
40
+ * missing snapshot baseline); 0 otherwise.
41
+ */
42
+
43
+ import { createHash } from 'node:crypto';
44
+ import {
45
+ existsSync,
46
+ mkdirSync,
47
+ readdirSync,
48
+ readFileSync,
49
+ rmSync,
50
+ writeFileSync,
51
+ } from 'node:fs';
52
+ import os from 'node:os';
53
+ import path from 'node:path';
54
+ import { fileURLToPath } from 'node:url';
55
+ import { runAsCli } from './lib/cli-utils.js';
56
+
57
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
58
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
59
+
60
+ /**
61
+ * Known fixture Epic/Story ids observed polluting the real `temp/` tree
62
+ * (Story #4696). `--clean` targets only these by default so real scratch
63
+ * (a genuine `run-<id>` from a live delivery) is never swept.
64
+ */
65
+ export const KNOWN_FIXTURE_STORY_IDS = Object.freeze([
66
+ 4428, 10, 4242, 5, 42, 100, 555, 2839, 4257, 4258, 4259,
67
+ ]);
68
+
69
+ /**
70
+ * Resolve the `temp/` directory for a given repo root.
71
+ * @param {string} repoRoot
72
+ * @returns {string}
73
+ */
74
+ export function tempDirFor(repoRoot) {
75
+ return path.join(repoRoot, 'temp');
76
+ }
77
+
78
+ /**
79
+ * Default snapshot-baseline path for a repo root — deliberately *outside*
80
+ * the protected `temp/` tree (Story #4711). The pre-#4711 baseline lived at
81
+ * `temp/.test-temp-hygiene-snapshot.json`, inside the very tree the guard
82
+ * protects: a test (or cleanup) that wiped `temp/` destroyed the baseline
83
+ * and the post-test `--assert` silently re-baselined the pollution. The
84
+ * default now lives under the OS scratch dir, keyed by the resolved repo
85
+ * root so parallel checkouts / worktrees never collide.
86
+ *
87
+ * @param {string} repoRoot
88
+ * @returns {string}
89
+ */
90
+ export function defaultBaselinePath(repoRoot) {
91
+ const key = createHash('sha256')
92
+ .update(path.resolve(repoRoot))
93
+ .digest('hex')
94
+ .slice(0, 16);
95
+ return path.join(
96
+ os.tmpdir(),
97
+ 'mandrel-test-temp-hygiene',
98
+ `snapshot-${key}.json`,
99
+ );
100
+ }
101
+
102
+ /**
103
+ * Refuse a baseline path that resolves inside the protected `temp/` tree —
104
+ * storing the attestation inside the tree it attests recreates the
105
+ * fail-open gap this Story closes.
106
+ *
107
+ * @param {string} repoRoot
108
+ * @param {string} baselinePath
109
+ * @returns {string} the resolved baseline path
110
+ */
111
+ function checkedBaselinePath(repoRoot, baselinePath) {
112
+ const resolved = path.resolve(baselinePath);
113
+ const tempDir = path.resolve(tempDirFor(repoRoot));
114
+ if (resolved === tempDir || resolved.startsWith(tempDir + path.sep)) {
115
+ throw new Error(
116
+ `[test-temp-hygiene] baseline path must live outside the protected temp/ tree; got ${resolved}`,
117
+ );
118
+ }
119
+ return resolved;
120
+ }
121
+
122
+ /**
123
+ * Is `rel` (a path relative to `temp/`, POSIX-normalised) a telemetry stream
124
+ * file we guard? Stream files are `*.ndjson` living under a `run-<id>/`
125
+ * subtree or the `standalone/stories/` subtree.
126
+ *
127
+ * @param {string} rel
128
+ * @returns {boolean}
129
+ */
130
+ export function isStreamFile(rel) {
131
+ if (!rel.endsWith('.ndjson')) return false;
132
+ const first = rel.split('/')[0];
133
+ if (/^run-\d+$/.test(first)) return true;
134
+ return rel.startsWith('standalone/stories/');
135
+ }
136
+
137
+ /**
138
+ * Recursively list every stream file under `tempDir`, returned as
139
+ * POSIX-normalised paths relative to `tempDir`, sorted for determinism.
140
+ *
141
+ * @param {string} tempDir
142
+ * @returns {string[]}
143
+ */
144
+ export function listStreamFiles(tempDir) {
145
+ if (!existsSync(tempDir)) return [];
146
+ /** @type {string[]} */
147
+ const out = [];
148
+ const walk = (absDir, relDir) => {
149
+ for (const ent of readdirSync(absDir, { withFileTypes: true })) {
150
+ const abs = path.join(absDir, ent.name);
151
+ const rel = relDir ? `${relDir}/${ent.name}` : ent.name;
152
+ if (ent.isDirectory()) {
153
+ walk(abs, rel);
154
+ } else if (ent.isFile() && isStreamFile(rel)) {
155
+ out.push(rel);
156
+ }
157
+ }
158
+ };
159
+ walk(tempDir, '');
160
+ return out.sort();
161
+ }
162
+
163
+ /**
164
+ * Fingerprint a single file by byte length + sha256 of its contents.
165
+ * @param {string} absPath
166
+ * @returns {{ size: number, sha256: string }}
167
+ */
168
+ export function fingerprintFile(absPath) {
169
+ const buf = readFileSync(absPath);
170
+ return {
171
+ size: buf.length,
172
+ sha256: createHash('sha256').update(buf).digest('hex'),
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Build a `{ [relPath]: fingerprint }` manifest of every stream file under
178
+ * `tempDir`.
179
+ * @param {string} tempDir
180
+ * @returns {Record<string, { size: number, sha256: string }>}
181
+ */
182
+ export function buildManifest(tempDir) {
183
+ /** @type {Record<string, { size: number, sha256: string }>} */
184
+ const manifest = {};
185
+ for (const rel of listStreamFiles(tempDir)) {
186
+ manifest[rel] = fingerprintFile(path.join(tempDir, rel));
187
+ }
188
+ return manifest;
189
+ }
190
+
191
+ /**
192
+ * Persist the current manifest to the baseline path (default: the external
193
+ * `defaultBaselinePath` — never inside `temp/`).
194
+ * @param {string} repoRoot
195
+ * @param {string} [baselinePath]
196
+ * @returns {{ snapshotPath: string, count: number }}
197
+ */
198
+ export function writeSnapshot(repoRoot, baselinePath) {
199
+ const snapshotPath = checkedBaselinePath(
200
+ repoRoot,
201
+ baselinePath ?? defaultBaselinePath(repoRoot),
202
+ );
203
+ const manifest = buildManifest(tempDirFor(repoRoot));
204
+ mkdirSync(path.dirname(snapshotPath), { recursive: true });
205
+ writeFileSync(snapshotPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
206
+ return { snapshotPath, count: Object.keys(manifest).length };
207
+ }
208
+
209
+ /**
210
+ * Load the persisted manifest, or `null` when no snapshot exists.
211
+ * @param {string} repoRoot
212
+ * @param {string} [baselinePath]
213
+ * @returns {Record<string, { size: number, sha256: string }> | null}
214
+ */
215
+ export function readSnapshot(repoRoot, baselinePath) {
216
+ const snapshotPath = checkedBaselinePath(
217
+ repoRoot,
218
+ baselinePath ?? defaultBaselinePath(repoRoot),
219
+ );
220
+ if (!existsSync(snapshotPath)) return null;
221
+ return JSON.parse(readFileSync(snapshotPath, 'utf8'));
222
+ }
223
+
224
+ /**
225
+ * Diff the current stream tree against a snapshot manifest.
226
+ *
227
+ * `added` — stream files present now but absent from the snapshot.
228
+ * `changed` — stream files whose size or sha256 differs from the snapshot
229
+ * (i.e. a test grew or rewrote an existing stream).
230
+ *
231
+ * A file that shrank or vanished is not a pollution signal, so it is ignored.
232
+ *
233
+ * @param {string} tempDir
234
+ * @param {Record<string, { size: number, sha256: string }>} snapshot
235
+ * @returns {{ added: string[], changed: string[] }}
236
+ */
237
+ export function diffAgainstSnapshot(tempDir, snapshot) {
238
+ const current = buildManifest(tempDir);
239
+ const added = [];
240
+ const changed = [];
241
+ for (const [rel, fp] of Object.entries(current)) {
242
+ const prior = snapshot[rel];
243
+ if (!prior) {
244
+ added.push(rel);
245
+ } else if (prior.size !== fp.size || prior.sha256 !== fp.sha256) {
246
+ changed.push(rel);
247
+ }
248
+ }
249
+ return { added: added.sort(), changed: changed.sort() };
250
+ }
251
+
252
+ /**
253
+ * Extract the fixture-matching id from a top-level `temp/` entry name, or
254
+ * `null` when the entry is not an id-keyed stream directory.
255
+ *
256
+ * `run-<id>` maps to `<id>`; the `standalone/stories/story-<id>` shape is
257
+ * handled by the caller (it recurses one level deeper).
258
+ *
259
+ * @param {string} name
260
+ * @returns {number|null}
261
+ */
262
+ function runDirId(name) {
263
+ const m = /^run-(\d+)$/.exec(name);
264
+ return m ? Number(m[1]) : null;
265
+ }
266
+
267
+ /**
268
+ * Locate stream directories whose Epic/Story id is in `ids`.
269
+ *
270
+ * Two shapes are swept: `temp/run-<id>/` (Epic-scoped) and
271
+ * `temp/standalone/stories/story-<id>/` (standalone Story-scoped).
272
+ *
273
+ * @param {string} tempDir
274
+ * @param {ReadonlySet<number>} ids
275
+ * @returns {{ id: number, kind: 'run' | 'standalone-story', rel: string }[]}
276
+ */
277
+ export function findFixtureDirs(tempDir, ids) {
278
+ if (!existsSync(tempDir)) return [];
279
+ const found = [];
280
+ for (const ent of readdirSync(tempDir, { withFileTypes: true })) {
281
+ if (!ent.isDirectory()) continue;
282
+ const id = runDirId(ent.name);
283
+ if (id !== null && ids.has(id)) {
284
+ found.push({ id, kind: 'run', rel: ent.name });
285
+ }
286
+ }
287
+ const storiesDir = path.join(tempDir, 'standalone', 'stories');
288
+ if (existsSync(storiesDir)) {
289
+ for (const ent of readdirSync(storiesDir, { withFileTypes: true })) {
290
+ if (!ent.isDirectory()) continue;
291
+ const m = /^story-(\d+)$/.exec(ent.name);
292
+ const id = m ? Number(m[1]) : null;
293
+ if (id !== null && ids.has(id)) {
294
+ found.push({
295
+ id,
296
+ kind: 'standalone-story',
297
+ rel: `standalone/stories/${ent.name}`,
298
+ });
299
+ }
300
+ }
301
+ }
302
+ return found.sort((a, b) => a.rel.localeCompare(b.rel));
303
+ }
304
+
305
+ /**
306
+ * Report (and optionally delete) fixture-id stream directories.
307
+ *
308
+ * @param {object} opts
309
+ * @param {string} opts.repoRoot
310
+ * @param {Iterable<number>} [opts.ids]
311
+ * @param {boolean} [opts.apply=false] delete when true; report-only otherwise.
312
+ * @returns {{ candidates: { id: number, kind: string, rel: string }[], removed: string[] }}
313
+ */
314
+ export function cleanFixtureDirs({
315
+ repoRoot,
316
+ ids = KNOWN_FIXTURE_STORY_IDS,
317
+ apply = false,
318
+ }) {
319
+ const tempDir = tempDirFor(repoRoot);
320
+ const candidates = findFixtureDirs(tempDir, new Set(ids));
321
+ const removed = [];
322
+ if (apply) {
323
+ for (const c of candidates) {
324
+ rmSync(path.join(tempDir, c.rel), { recursive: true, force: true });
325
+ removed.push(c.rel);
326
+ }
327
+ }
328
+ return { candidates, removed };
329
+ }
330
+
331
+ /**
332
+ * Parse the CLI argv into a normalised options object.
333
+ * @param {string[]} argv
334
+ * @returns {{ mode: 'snapshot'|'assert'|'clean', apply: boolean, ids: number[]|null, repoRoot: string, baseline: string|null }}
335
+ */
336
+ export function parseArgv(argv) {
337
+ let mode = 'assert';
338
+ let apply = false;
339
+ let ids = null;
340
+ let repoRoot = REPO_ROOT;
341
+ let baseline = null;
342
+ for (let i = 0; i < argv.length; i += 1) {
343
+ const arg = argv[i];
344
+ if (arg === '--snapshot') mode = 'snapshot';
345
+ else if (arg === '--assert') mode = 'assert';
346
+ else if (arg === '--clean') mode = 'clean';
347
+ else if (arg === '--yes') apply = true;
348
+ else if (arg === '--ids') {
349
+ i += 1;
350
+ ids = String(argv[i] ?? '')
351
+ .split(',')
352
+ .map((s) => Number(s.trim()))
353
+ .filter((n) => Number.isInteger(n) && n > 0);
354
+ } else if (arg === '--root') {
355
+ i += 1;
356
+ repoRoot = path.resolve(String(argv[i] ?? '.'));
357
+ } else if (arg === '--baseline') {
358
+ i += 1;
359
+ baseline = path.resolve(String(argv[i] ?? '.'));
360
+ }
361
+ }
362
+ return { mode, apply, ids, repoRoot, baseline };
363
+ }
364
+
365
+ /**
366
+ * Execute the guard for a parsed options object. Returns the process exit
367
+ * code (0 = clean / snapshot recorded; 1 = pollution detected under
368
+ * --assert). Printing is done via the injectable `log`.
369
+ *
370
+ * @param {ReturnType<typeof parseArgv>} opts
371
+ * @param {(line: string) => void} [log]
372
+ * @returns {number}
373
+ */
374
+ export function runHygiene(opts, log = (l) => process.stdout.write(`${l}\n`)) {
375
+ const { mode, apply, ids, repoRoot, baseline = null } = opts;
376
+ if (mode === 'snapshot') {
377
+ const { snapshotPath, count } = writeSnapshot(repoRoot, baseline);
378
+ log(
379
+ `[test-temp-hygiene] snapshot recorded (${count} stream file(s)) → ${snapshotPath}`,
380
+ );
381
+ return 0;
382
+ }
383
+ if (mode === 'clean') {
384
+ const { candidates } = cleanFixtureDirs({
385
+ repoRoot,
386
+ ids: ids ?? KNOWN_FIXTURE_STORY_IDS,
387
+ apply,
388
+ });
389
+ if (candidates.length === 0) {
390
+ log('[test-temp-hygiene] no fixture-id stream directories found.');
391
+ return 0;
392
+ }
393
+ log(
394
+ `[test-temp-hygiene] ${candidates.length} fixture-id stream director(y/ies)${
395
+ apply ? ' removed' : ' (report-only; pass --yes to delete)'
396
+ }:`,
397
+ );
398
+ for (const c of candidates) {
399
+ log(` - ${c.rel} (id ${c.id}, ${c.kind})`);
400
+ }
401
+ return 0;
402
+ }
403
+ // mode === 'assert'
404
+ const snapshotPath = baseline ?? defaultBaselinePath(repoRoot);
405
+ const snapshot = readSnapshot(repoRoot, snapshotPath);
406
+ if (snapshot === null) {
407
+ log(
408
+ `[test-temp-hygiene] FAIL — snapshot missing (${path.resolve(snapshotPath)}); guard cannot attest. Run --snapshot before the suite (never re-baseline at assert time).`,
409
+ );
410
+ return 1;
411
+ }
412
+ const { added, changed } = diffAgainstSnapshot(
413
+ tempDirFor(repoRoot),
414
+ snapshot,
415
+ );
416
+ if (added.length === 0 && changed.length === 0) {
417
+ log('[test-temp-hygiene] OK — no new or grown stream files under temp/.');
418
+ return 0;
419
+ }
420
+ log(
421
+ '[test-temp-hygiene] FAIL — the test suite polluted the real temp/ tree:',
422
+ );
423
+ for (const rel of added) log(` + new ${rel}`);
424
+ for (const rel of changed) log(` ~ grew ${rel}`);
425
+ log(
426
+ '[test-temp-hygiene] a writer bypassed the scratch seam. Inject an absolute per-test tempRoot; do not weaken this guard.',
427
+ );
428
+ return 1;
429
+ }
430
+
431
+ runAsCli(
432
+ import.meta.url,
433
+ async () => {
434
+ const code = runHygiene(parseArgv(process.argv.slice(2)));
435
+ return code;
436
+ },
437
+ { source: 'check-test-temp-hygiene', propagateExitCode: true },
438
+ );
@@ -40,17 +40,23 @@ import {
40
40
  import { PROJECT_ROOT } from './lib/project-root.js';
41
41
  import { createProvider } from './lib/provider-factory.js';
42
42
 
43
- const HELP = `Usage: node .agents/scripts/deliver-recover.js --story <id> [--cwd <main-repo>] [--json]
43
+ const HELP = `Usage: node .agents/scripts/deliver-recover.js --story <id> [--cwd <main-repo>] [--json] [--no-reprobe]
44
44
 
45
45
  Probes a Story's live delivery state — labels, lease, branch, worktree, PR
46
46
  state and checks — and prints the single next command that resumes it, with
47
47
  the evidence it was derived from. Read-only: mutates nothing.
48
48
 
49
+ Mid-flight shapes (executing-*/closing-*) get a stability re-probe after a
50
+ short settle window: matching shapes return the fresher verdict; diverging
51
+ shapes report \`in-transition\` (a live delivery process is mutating the
52
+ state) instead of a confidently wrong command.
53
+
49
54
  Flags:
50
- --story GitHub issue number of the Story (required).
51
- --cwd Main-repo checkout to probe (default: project root).
52
- --json Emit the full recovery envelope as JSON instead of prose.
53
- --help Show this message.
55
+ --story GitHub issue number of the Story (required).
56
+ --cwd Main-repo checkout to probe (default: project root).
57
+ --json Emit the full recovery envelope as JSON instead of prose.
58
+ --no-reprobe Skip the stability re-probe (single-probe verdict).
59
+ --help Show this message.
54
60
  `;
55
61
 
56
62
  export function parseArgv(argv) {
@@ -60,6 +66,7 @@ export function parseArgv(argv) {
60
66
  story: { type: 'string' },
61
67
  cwd: { type: 'string' },
62
68
  json: { type: 'boolean', default: false },
69
+ 'no-reprobe': { type: 'boolean', default: false },
63
70
  help: { type: 'boolean', default: false },
64
71
  },
65
72
  strict: false,
@@ -68,6 +75,7 @@ export function parseArgv(argv) {
68
75
  storyId: Number.parseInt(String(values.story ?? ''), 10),
69
76
  cwd: values.cwd ?? null,
70
77
  json: Boolean(values.json),
78
+ reprobe: !values['no-reprobe'],
71
79
  help: Boolean(values.help),
72
80
  };
73
81
  }
@@ -79,15 +87,22 @@ export async function runDeliverRecover({
79
87
  storyId: storyIdParam,
80
88
  cwd: cwdParam,
81
89
  json: jsonParam,
90
+ reprobe: reprobeParam,
82
91
  argv,
83
92
  injectedProvider,
84
93
  injectedConfig,
85
94
  injectedGh,
86
95
  injectedGitSpawn,
96
+ injectedSleepFn,
87
97
  } = {}) {
88
98
  const parsed =
89
99
  storyIdParam !== undefined
90
- ? { storyId: storyIdParam, cwd: cwdParam ?? null, json: !!jsonParam }
100
+ ? {
101
+ storyId: storyIdParam,
102
+ cwd: cwdParam ?? null,
103
+ json: !!jsonParam,
104
+ reprobe: reprobeParam ?? true,
105
+ }
91
106
  : parseArgv(argv ?? process.argv.slice(2));
92
107
 
93
108
  if (parsed.help) {
@@ -111,6 +126,8 @@ export async function runDeliverRecover({
111
126
  config,
112
127
  gh: injectedGh,
113
128
  gitSpawnFn: injectedGitSpawn,
129
+ reprobe: parsed.reprobe,
130
+ ...(injectedSleepFn ? { sleepFn: injectedSleepFn } : {}),
114
131
  });
115
132
 
116
133
  Logger.info(
@@ -20,6 +20,11 @@ export {
20
20
  readAuditRules,
21
21
  } from './checklist-threading.js';
22
22
  export { buildDispatchChecklist } from './dispatch-checklist.js';
23
+ export {
24
+ countChangedLines,
25
+ evaluateLensDiffFloor,
26
+ resolveLensDiffFloor,
27
+ } from './lens-diff-floor.js';
23
28
  export { runAuditSuite } from './runner.js';
24
29
  export {
25
30
  GLOBAL_LENS_ALLOWLIST,
@@ -0,0 +1,179 @@
1
+ /**
2
+ * lib/audit-suite/lens-diff-floor.js — the close-scope lens diff-floor
3
+ * (Story #4699).
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * The Story-scope local-lens pass materialized ~28 KB of lens prompts per
8
+ * close while sampled closes yielded almost zero findings — and none above
9
+ * Suggestion severity. The cheapest correct response is a deterministic
10
+ * floor: a *small* diff that touches *no* sensitive path earns no lens
11
+ * materialization at all. The floor is measured in **changed lines**
12
+ * (additions + deletions across the diff), configured via
13
+ * `delivery.review.lensDiffFloor` (default {@link DEFAULT_LENS_DIFF_FLOOR};
14
+ * `0` disables the skip entirely).
15
+ *
16
+ * ## Fail-open contract
17
+ *
18
+ * The skip only fires on positive evidence that the diff is small and
19
+ * non-sensitive. Every degraded input — an unknown line count, an
20
+ * unreadable manifest, a disabled floor — resolves to "do not skip", so a
21
+ * measurement failure can never buy a change less review. Sensitive-path
22
+ * hits are matched with the same `sensitivePaths` classes the review-depth
23
+ * derivation reads (`selectSensitivePathClasses`), so the floor and the
24
+ * depth tiering can never disagree about what "sensitive" means.
25
+ *
26
+ * All exports are total: no throws, no I/O beyond the injected git spawn in
27
+ * {@link countChangedLines}.
28
+ */
29
+
30
+ import { gitSpawn } from '../git-utils.js';
31
+ import { selectSensitivePathClasses } from './selector.js';
32
+
33
+ /**
34
+ * Default changed-line floor below which a non-sensitive diff skips lens
35
+ * materialization. Chosen from the measured distribution (Story #4699): the
36
+ * sampled zero-yield closes clustered well under this size.
37
+ */
38
+ export const DEFAULT_LENS_DIFF_FLOOR = 40;
39
+
40
+ /**
41
+ * Resolve the configured lens diff-floor from a resolved config wrapper.
42
+ * `delivery.review.lensDiffFloor` must be a non-negative integer; anything
43
+ * else (absent block, wrong type, negative, non-finite) falls back to
44
+ * {@link DEFAULT_LENS_DIFF_FLOOR}. `0` is a valid, deliberate "floor off".
45
+ *
46
+ * @param {object|null|undefined} config Resolved `.agentrc.json` wrapper.
47
+ * @returns {number}
48
+ */
49
+ export function resolveLensDiffFloor(config) {
50
+ const value = config?.delivery?.review?.lensDiffFloor;
51
+ if (typeof value === 'number' && Number.isInteger(value) && value >= 0) {
52
+ return value;
53
+ }
54
+ return DEFAULT_LENS_DIFF_FLOOR;
55
+ }
56
+
57
+ /**
58
+ * Count the changed lines (additions + deletions) in the
59
+ * `baseRef...headRef` diff via `git diff --numstat`.
60
+ *
61
+ * Total — never throws. Returns `null` (the neutral "count unknown" signal
62
+ * the floor fails open on) for any git failure or unparseable output, and
63
+ * `0` for a genuinely empty diff. Binary rows (`-\t-\tpath`) contribute 0
64
+ * text lines but do not poison the parse.
65
+ *
66
+ * @param {{
67
+ * baseRef: string,
68
+ * headRef: string,
69
+ * cwd?: string,
70
+ * gitSpawnFn?: typeof gitSpawn,
71
+ * }} args
72
+ * @returns {number|null}
73
+ */
74
+ export function countChangedLines({
75
+ baseRef,
76
+ headRef,
77
+ cwd = process.cwd(),
78
+ gitSpawnFn = gitSpawn,
79
+ } = {}) {
80
+ if (typeof baseRef !== 'string' || baseRef.length === 0) return null;
81
+ if (typeof headRef !== 'string' || headRef.length === 0) return null;
82
+ try {
83
+ const result = gitSpawnFn(
84
+ cwd,
85
+ 'diff',
86
+ '--numstat',
87
+ `${baseRef}...${headRef}`,
88
+ );
89
+ if (!result || result.status !== 0 || typeof result.stdout !== 'string') {
90
+ return null;
91
+ }
92
+ let total = 0;
93
+ for (const line of result.stdout.split('\n')) {
94
+ const trimmedEnd = line.replace(/\s+$/, '');
95
+ if (trimmedEnd.length === 0) continue;
96
+ const match = /^(\d+|-)\t(\d+|-)\t/.exec(trimmedEnd);
97
+ if (!match) return null; // Unexpected format — the count is not trustworthy.
98
+ if (match[1] !== '-') total += Number(match[1]);
99
+ if (match[2] !== '-') total += Number(match[2]);
100
+ }
101
+ return total;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Decide whether the close-scope lens pass should skip materialization for
109
+ * this diff. Skips **only** when all of the following hold:
110
+ *
111
+ * 1. The floor is enabled (`floor > 0`).
112
+ * 2. The changed-line count is *known* and strictly below the floor.
113
+ * 3. The changed-file set intersects **zero** registered sensitive-path
114
+ * classes (`audit-rules.json#sensitivePaths`).
115
+ *
116
+ * Every other state — floor disabled, unknown count, at-or-above floor, a
117
+ * sensitive-path hit — resolves to `skip: false` with a named reason, so
118
+ * the verdict is auditable in the findings-yield ledger.
119
+ *
120
+ * Pure and total: never throws (a throwing sensitive-path matcher degrades
121
+ * to "not skippable").
122
+ *
123
+ * @param {{
124
+ * changedFiles?: string[]|null,
125
+ * changedLineCount?: number|null,
126
+ * floor?: number,
127
+ * injectedRules?: object,
128
+ * selectSensitivePathClassesFn?: typeof selectSensitivePathClasses,
129
+ * }} [input]
130
+ * @returns {{
131
+ * skip: boolean,
132
+ * reason: 'floor-disabled'|'line-count-unknown'|'at-or-above-floor'|'sensitive-classes-unknown'|'sensitive-path-hit'|'below-floor',
133
+ * floor: number,
134
+ * changedLineCount: number|null,
135
+ * sensitiveClasses: string[],
136
+ * }}
137
+ */
138
+ export function evaluateLensDiffFloor(input = {}) {
139
+ const floorRaw = input.floor;
140
+ const floor =
141
+ typeof floorRaw === 'number' && Number.isInteger(floorRaw) && floorRaw >= 0
142
+ ? floorRaw
143
+ : DEFAULT_LENS_DIFF_FLOOR;
144
+ const count =
145
+ typeof input.changedLineCount === 'number' &&
146
+ Number.isFinite(input.changedLineCount) &&
147
+ input.changedLineCount >= 0
148
+ ? Math.floor(input.changedLineCount)
149
+ : null;
150
+ const verdict = (skip, reason, sensitiveClasses = []) => ({
151
+ skip,
152
+ reason,
153
+ floor,
154
+ changedLineCount: count,
155
+ sensitiveClasses,
156
+ });
157
+
158
+ if (floor <= 0) return verdict(false, 'floor-disabled');
159
+ if (count === null) return verdict(false, 'line-count-unknown');
160
+ if (count >= floor) return verdict(false, 'at-or-above-floor');
161
+
162
+ const select =
163
+ input.selectSensitivePathClassesFn ?? selectSensitivePathClasses;
164
+ let classes;
165
+ try {
166
+ classes = select({
167
+ changedFiles: Array.isArray(input.changedFiles) ? input.changedFiles : [],
168
+ injectedRules: input.injectedRules,
169
+ });
170
+ } catch {
171
+ // An unreadable manifest is not evidence the change is safe to skip.
172
+ return verdict(false, 'sensitive-classes-unknown');
173
+ }
174
+ const matched = Array.isArray(classes) ? classes : [];
175
+ if (matched.length > 0) {
176
+ return verdict(false, 'sensitive-path-hit', matched);
177
+ }
178
+ return verdict(true, 'below-floor');
179
+ }