mandrel 2.24.0 → 2.25.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 (88) hide show
  1. package/.agents/agents/acceptance-critic.md +13 -19
  2. package/.agents/agents/auditor.md +16 -19
  3. package/.agents/agents/story-worker.md +41 -54
  4. package/.agents/audit-checklists/architecture.md +5 -1
  5. package/.agents/audit-checklists/quality.md +3 -0
  6. package/.agents/docs/agentrc-reference.json +2 -1
  7. package/.agents/docs/configuration.md +2 -1
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/rules/ci-remediation.md +68 -3
  10. package/.agents/schemas/agentrc.schema.json +6 -1
  11. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  12. package/.agents/schemas/baselines/crap.schema.json +5 -1
  13. package/.agents/schemas/crap-report.schema.json +37 -0
  14. package/.agents/scripts/acceptance-eval.js +35 -9
  15. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  16. package/.agents/scripts/audit-to-stories.js +33 -3
  17. package/.agents/scripts/check-context-budget.js +95 -14
  18. package/.agents/scripts/check-test-temp-hygiene.js +83 -0
  19. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  20. package/.agents/scripts/deliver-light.js +3 -4
  21. package/.agents/scripts/deliver-recover.js +13 -0
  22. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  23. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  24. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  25. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  26. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  27. package/.agents/scripts/lib/baseline-loader.js +0 -0
  28. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  29. package/.agents/scripts/lib/baselines/kinds/crap.js +396 -51
  30. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  31. package/.agents/scripts/lib/baselines/reader.js +10 -0
  32. package/.agents/scripts/lib/baselines/writer.js +7 -3
  33. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  34. package/.agents/scripts/lib/crap-engine.js +97 -19
  35. package/.agents/scripts/lib/crap-utils.js +73 -13
  36. package/.agents/scripts/lib/env-loader.js +46 -16
  37. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  38. package/.agents/scripts/lib/findings/severity.js +80 -2
  39. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  40. package/.agents/scripts/lib/label-constants.js +17 -0
  41. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  42. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  43. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  44. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  45. package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
  46. package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
  47. package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
  48. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +7 -0
  49. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +13 -1
  50. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  51. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  52. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  53. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -34
  54. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  55. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +50 -13
  56. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  57. package/.agents/scripts/lib/story-plan.js +137 -42
  58. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  59. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  60. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  61. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  62. package/.agents/scripts/lib/wave-runner/ready-set.js +73 -9
  63. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +6 -3
  64. package/.agents/scripts/lib/workers/crap-worker.js +6 -3
  65. package/.agents/scripts/pr-watch-with-update.js +568 -213
  66. package/.agents/scripts/quality-preview.js +25 -0
  67. package/.agents/scripts/run-tests.js +10 -0
  68. package/.agents/scripts/single-story-close.js +6 -0
  69. package/.agents/scripts/single-story-init.js +7 -0
  70. package/.agents/scripts/stories-wave-tick.js +95 -10
  71. package/.agents/scripts/story-plan.js +65 -9
  72. package/.agents/scripts/update-crap-baseline.js +42 -4
  73. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  74. package/.agents/templates/single-story-body.md +16 -8
  75. package/.agents/workflows/audit-architecture.md +69 -13
  76. package/.agents/workflows/audit-quality.md +26 -0
  77. package/.agents/workflows/audit-to-stories.md +30 -4
  78. package/.agents/workflows/deliver.md +81 -95
  79. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  80. package/.agents/workflows/helpers/deliver-reference.md +18 -3
  81. package/.agents/workflows/helpers/deliver-story-reference.md +127 -46
  82. package/.agents/workflows/helpers/deliver-story.md +104 -119
  83. package/.agents/workflows/helpers/plan-reference.md +34 -0
  84. package/.agents/workflows/plan.md +60 -76
  85. package/docs/CHANGELOG.md +29 -0
  86. package/lib/cli/update.js +14 -11
  87. package/lib/cli/version-check.js +9 -1
  88. package/package.json +1 -1
@@ -48,9 +48,14 @@
48
48
  *
49
49
  * Stdout: a single JSON envelope
50
50
  * { storyId, epicId, decision, round, cap, capReached, totalCriteria,
51
- * metCount, unmetCriteria[], signalEmitted }
51
+ * metCount, unmetCriteria[], signalEmitted, replay, verdictFingerprint }
52
52
  * (`epicId` is retained as a always-null field for envelope stability.)
53
53
  *
54
+ * Reading is free (Story #4874): re-invoking the gate over a verdict the
55
+ * ledger has already scored replays that round (`replay: true`,
56
+ * `signalEmitted: false`) instead of consuming one, so an unchanged verdict
57
+ * can never escalate from `redraft` to `block` by being looked at twice.
58
+ *
54
59
  * @see .agents/scripts/lib/orchestration/acceptance-eval-decision.js
55
60
  * @see .agents/schemas/acceptance-eval-verdict.schema.json
56
61
  */
@@ -69,8 +74,9 @@ import { Logger } from './lib/Logger.js';
69
74
  import { appendSignal } from './lib/observability/signals-writer.js';
70
75
  import {
71
76
  buildAcceptanceEvalSignal,
77
+ computeVerdictFingerprint,
72
78
  decideAcceptanceEval,
73
- deriveAcceptanceEvalRound,
79
+ resolveAcceptanceEvalRound,
74
80
  } from './lib/orchestration/acceptance-eval-decision.js';
75
81
 
76
82
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -166,7 +172,8 @@ function parseCliArgs(argv) {
166
172
  * self-reported `round` is never load-bearing for the cap.
167
173
  * @param {object} [deps]
168
174
  * @param {Function} [deps.appendSignalFn]
169
- * @param {Function} [deps.deriveRoundFn]
175
+ * @param {Function} [deps.resolveRoundFn]
176
+ * @param {Function} [deps.fingerprintFn]
170
177
  * @returns {Promise<{ envelope: object, exitCode: number }>}
171
178
  */
172
179
  export async function runAcceptanceEval(
@@ -175,13 +182,23 @@ export async function runAcceptanceEval(
175
182
  ) {
176
183
  const {
177
184
  appendSignalFn = appendSignal,
178
- deriveRoundFn = deriveAcceptanceEvalRound,
185
+ resolveRoundFn = resolveAcceptanceEvalRound,
186
+ fingerprintFn = computeVerdictFingerprint,
179
187
  } = deps;
180
188
  const { maxRounds } = getAcceptanceEval(config);
189
+ const verdictFingerprint = fingerprintFn(verdict);
190
+ // Story #4874: re-reading an already-scored verdict is a replay — it
191
+ // reports the round that verdict was scored under and appends nothing, so
192
+ // observation alone can never advance the counter or escalate a redraft.
193
+ const resolved = resolveRoundFn({
194
+ epicId: null,
195
+ storyId,
196
+ config,
197
+ verdictFingerprint,
198
+ });
199
+ const replay = resolved.replay === true;
181
200
  const resolvedRound =
182
- Number.isInteger(round) && round >= 1
183
- ? round
184
- : deriveRoundFn({ epicId: null, storyId, config });
201
+ Number.isInteger(round) && round >= 1 ? round : resolved.round;
185
202
  const outcome = decideAcceptanceEval({
186
203
  verdict,
187
204
  maxRounds,
@@ -189,9 +206,14 @@ export async function runAcceptanceEval(
189
206
  });
190
207
 
191
208
  let signalEmitted = false;
192
- if (emitSignal) {
209
+ if (emitSignal && !replay) {
193
210
  const signal = {
194
- ...buildAcceptanceEvalSignal({ storyId, epicId: null, outcome }),
211
+ ...buildAcceptanceEvalSignal({
212
+ storyId,
213
+ epicId: null,
214
+ outcome,
215
+ verdictFingerprint,
216
+ }),
195
217
  ts: new Date().toISOString(),
196
218
  };
197
219
  try {
@@ -228,6 +250,10 @@ export async function runAcceptanceEval(
228
250
  evidence: c.evidence,
229
251
  })),
230
252
  signalEmitted,
253
+ // True when this invocation re-read a verdict the ledger had already
254
+ // scored: the round was replayed, not advanced, and nothing was appended.
255
+ replay,
256
+ verdictFingerprint,
231
257
  };
232
258
 
233
259
  // `block` is the only non-zero exit: the loop has exhausted its bounded
@@ -1,21 +1,20 @@
1
1
  /**
2
- * audit-labels-bootstrap.js — Idempotently create the `audit::<lens>`
3
- * label taxonomy in the configured GitHub repo.
2
+ * audit-labels-bootstrap.js — Idempotently create the audit label taxonomy in
3
+ * the configured GitHub repo.
4
4
  *
5
5
  * Run this once per repo before `/audit-to-stories` opens its first
6
6
  * Story. Re-runs are safe — existing labels are skipped, only missing
7
7
  * ones are created. Story #2583 acceptance criterion #6.
8
8
  *
9
- * The lens list is the shared SSOT `AUDIT_LENSES`
10
- * (`lib/audit-to-stories/audit-lenses.js`), one per `/audit-<lens>` workflow
11
- * under `.agents/workflows/`. Sourcing the list from the same module that
12
- * `build-story-body.js` derives `audit::<lens>` labels from guarantees the
13
- * label producer (this bootstrap) and the label deriver (story-body) cannot
14
- * drift a finding from `audit-documentation-results.md` derives
15
- * `audit::documentation`, and this bootstrap creates exactly that label
16
- * (Story #4195). The per-lens colour/description metadata lives in
17
- * `LENS_META` below; adding a new `audit-*` workflow means adding its lens to
18
- * `AUDIT_LENSES` and (optionally) a `LENS_META` entry.
9
+ * This CLI is a thin creator over
10
+ * [`lib/audit-to-stories/audit-label-taxonomy.js`](lib/audit-to-stories/audit-label-taxonomy.js),
11
+ * which is the SSOT for **every** label an audit sweep creates or generates:
12
+ * the `audit::<lens>` set (derived from the shared `AUDIT_LENSES` list, one per
13
+ * `/audit-<lens>` workflow) plus the story-axis labels the filer applies. The
14
+ * creator and the generator (`build-story-body.js`) read that one list, so the
15
+ * bootstrap cannot fall behind the filer — the drift that left `risk::high`
16
+ * generated but defined nowhere (Story #4877), and that made `audit::<dimension>`
17
+ * labels mint from free-form prose before Story #4195.
19
18
  *
20
19
  * Delegates to `gh label create` so the script works without any
21
20
  * provider plumbing — `gh auth status` is the only prerequisite. Per
@@ -26,88 +25,11 @@
26
25
  import process from 'node:process';
27
26
  import { parseArgs } from 'node:util';
28
27
 
29
- import { AUDIT_LENSES } from './lib/audit-to-stories/audit-lenses.js';
28
+ import { AUDIT_LABEL_TAXONOMY } from './lib/audit-to-stories/audit-label-taxonomy.js';
30
29
  import { runAsCli } from './lib/cli-utils.js';
31
30
  import { resolveConfig } from './lib/config-resolver.js';
32
31
  import { gh as defaultGh, GhExecError } from './lib/gh-exec.js';
33
32
 
34
- /**
35
- * Per-lens label presentation. Keyed by canonical lens name. A lens absent
36
- * from this map falls back to {@link DEFAULT_LENS_META} so a newly-added
37
- * `AUDIT_LENSES` entry still gets a label without a hard requirement to
38
- * register colour/description here first.
39
- */
40
- const LENS_META = Object.freeze({
41
- accessibility: {
42
- color: 'c5def5',
43
- description: 'Audit-sourced finding: WCAG accessibility conformance',
44
- },
45
- architecture: {
46
- color: '6f42c1',
47
- description: 'Audit-sourced finding: architectural concerns',
48
- },
49
- 'clean-code': {
50
- color: '0e8a16',
51
- description: 'Audit-sourced finding: clean-code / maintainability',
52
- },
53
- dependencies: {
54
- color: 'd4c5f9',
55
- description: 'Audit-sourced finding: dependencies / supply chain',
56
- },
57
- devops: {
58
- color: 'fbca04',
59
- description: 'Audit-sourced finding: DevOps / CI / CD',
60
- },
61
- documentation: {
62
- color: '1d76db',
63
- description: 'Audit-sourced finding: documentation staleness / gaps',
64
- },
65
- navigability: {
66
- color: 'bfdadc',
67
- description: 'Audit-sourced finding: route / nav reachability',
68
- },
69
- performance: {
70
- color: 'b60205',
71
- description: 'Audit-sourced finding: performance / latency',
72
- },
73
- privacy: {
74
- color: 'fef2c0',
75
- description: 'Audit-sourced finding: privacy / data handling',
76
- },
77
- quality: {
78
- color: '0052cc',
79
- description: 'Audit-sourced finding: test quality / coverage gaps',
80
- },
81
- security: {
82
- color: 'b60205',
83
- description: 'Audit-sourced finding: security / OWASP',
84
- },
85
- seo: {
86
- color: 'fbca04',
87
- description: 'Audit-sourced finding: SEO / discoverability',
88
- },
89
- sre: {
90
- color: '0052cc',
91
- description: 'Audit-sourced finding: SRE / observability / reliability',
92
- },
93
- 'ux-ui': {
94
- color: 'd4c5f9',
95
- description: 'Audit-sourced finding: UX / UI concerns',
96
- },
97
- });
98
-
99
- const DEFAULT_LENS_META = Object.freeze({
100
- color: 'ededed',
101
- description: 'Audit-sourced finding',
102
- });
103
-
104
- const DIMENSIONS = Object.freeze(
105
- AUDIT_LENSES.map((name) => ({
106
- name,
107
- ...(LENS_META[name] ?? DEFAULT_LENS_META),
108
- })),
109
- );
110
-
111
33
  async function labelExists(gh, owner, repo, name) {
112
34
  try {
113
35
  const list = await gh.label.list(
@@ -166,9 +88,8 @@ export async function bootstrapAuditLabels({
166
88
  const skipped = [];
167
89
  const failed = [];
168
90
 
169
- for (const dim of DIMENSIONS) {
170
- const labelName = `audit::${dim.name}`;
171
- const candidate = { ...dim, name: labelName };
91
+ for (const candidate of AUDIT_LABEL_TAXONOMY) {
92
+ const labelName = candidate.name;
172
93
 
173
94
  if (dryRun) {
174
95
  created.push(labelName);
@@ -190,7 +111,7 @@ export async function bootstrapAuditLabels({
190
111
  }
191
112
  }
192
113
 
193
- return { created, skipped, failed, total: DIMENSIONS.length };
114
+ return { created, skipped, failed, total: AUDIT_LABEL_TAXONOMY.length };
194
115
  }
195
116
 
196
117
  /**
@@ -241,7 +162,7 @@ export function formatBootstrapReport(result) {
241
162
  };
242
163
  }
243
164
 
244
- export const __testing = { DIMENSIONS };
165
+ export const __testing = { AUDIT_LABEL_TAXONOMY };
245
166
 
246
167
  async function main() {
247
168
  const { values } = parseArgs({
@@ -47,17 +47,34 @@ import { parseAuditReports } from './lib/audit-to-stories/parse-audit-md.js';
47
47
  import { buildPlanSeedMarkdown } from './lib/audit-to-stories/seed-from-findings.js';
48
48
  import { runAsCli } from './lib/cli-utils.js';
49
49
  import { searchSemanticCandidates } from './lib/findings/semantic-issue-search.js';
50
+ import { SEVERITIES, SEVERITY_RANK } from './lib/findings/severity.js';
50
51
  import { Logger } from './lib/Logger.js';
51
52
  import { parse as parseStoryBody } from './lib/story-body/story-body.js';
52
53
 
53
- const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1 };
54
54
  const DEFAULT_GLOB = 'temp/audits/audit-*-results.md';
55
55
  const FAN_OUT_REPORT = 'audit-fan-out-results.md';
56
56
 
57
+ /**
58
+ * Does `finding` clear the `threshold` severity floor?
59
+ *
60
+ * `SEVERITY_RANK` is imported from the canonical scale rather than declared
61
+ * here (Story #4877). The local copy this replaces ranked only four levels
62
+ * (`critical|high|medium|low`), so `info` — the canonical floor — ranked `0`,
63
+ * below even `--severity low`, and every informational finding was silently
64
+ * dropped from every filtered run. Sourcing the ranking from the SSOT means a
65
+ * level cannot exist in the vocabulary and be invisible to the filter.
66
+ *
67
+ * An unrecognised or absent severity still ranks below every real floor: it
68
+ * failed to parse, so it is not evidence that a threshold was met.
69
+ *
70
+ * @param {{ severity?: string }} finding
71
+ * @param {string} [threshold] — a canonical level, `'all'`, or falsy for no floor.
72
+ * @returns {boolean}
73
+ */
57
74
  function meetsSeverity(finding, threshold) {
58
75
  if (!threshold || threshold === 'all') return true;
59
76
  const minRank = SEVERITY_RANK[threshold] ?? 0;
60
- const fRank = SEVERITY_RANK[finding.severity] ?? 0;
77
+ const fRank = SEVERITY_RANK[finding.severity] ?? -1;
61
78
  return fRank >= minRank;
62
79
  }
63
80
 
@@ -77,8 +94,21 @@ function readReports(paths) {
77
94
  }));
78
95
  }
79
96
 
97
+ /**
98
+ * Count findings per severity bucket. The buckets are the canonical levels plus
99
+ * `unknown` for a finding whose severity did not parse — kept as a visible
100
+ * bucket so an unparseable severity is reported rather than absorbed into a
101
+ * real level. Derived from `SEVERITIES` so a new level appears in the tally
102
+ * automatically instead of falling into `unknown` (Story #4877).
103
+ *
104
+ * @param {Array<{ severity?: string }>} findings
105
+ * @returns {Record<string, number>}
106
+ */
80
107
  function tallyBySeverity(findings) {
81
- const t = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
108
+ const t = {
109
+ ...Object.fromEntries(SEVERITIES.map((s) => [s, 0])),
110
+ unknown: 0,
111
+ };
82
112
  for (const f of findings) {
83
113
  if (Object.hasOwn(t, f.severity)) t[f.severity] += 1;
84
114
  else t.unknown += 1;
@@ -47,9 +47,18 @@
47
47
  * Ratchet semantics (mirroring the sibling ratchets):
48
48
  * - A gated tier grows beyond `baseline.tiers.<tier>.totalBytes +
49
49
  * baseline.toleranceBytes` → exit 1, naming the tier and its delta.
50
- * - A gated tier shrinks below its baseline total → printed as a `-`
51
- * (removal) note, warning the baseline can be refreshed downward.
52
- * Shrink-only exits 0.
50
+ * - A gated tier shrinks below its baseline total → exit 1 (Story #4872).
51
+ * A ratchet that only tightens in one direction lets every measured
52
+ * improvement evaporate: the recorded total keeps promising headroom the
53
+ * tree no longer spends, so the next growth is absorbed by stale slack
54
+ * instead of being reported. Shrinkage is therefore **actionable** —
55
+ * refresh the baseline down and the gain is locked in. Unlike growth this
56
+ * is deliberately **zero-tolerance**: `toleranceBytes` exists to keep a
57
+ * trivial addition from churning the file, and applying it downward would
58
+ * silently discard every sub-tolerance gain.
59
+ * - A recorded row naming a path the measured tier no longer contains →
60
+ * exit 1. The row describes a file that has been deleted or de-listed, so
61
+ * the bytes it contributes to the recorded total are fiction.
53
62
  * - Within tolerance / clean → exit 0.
54
63
  * - Baseline file absent → warn + exit 0 (no-op; nothing to ratchet against).
55
64
  *
@@ -303,16 +312,45 @@ export function buildBaseline(tierMap, toleranceBytes) {
303
312
  };
304
313
  }
305
314
 
315
+ /**
316
+ * Collect the recorded rows of one gated tier that name a path the measured
317
+ * tier no longer contains (Story #4872). A deleted file drops out of the
318
+ * resolved tier, and so does one that has been de-listed from the read set —
319
+ * either way the row's bytes are counted into a recorded total that no live
320
+ * file backs, so the row is drift and not a detail.
321
+ *
322
+ * @param {string} tier
323
+ * @param {Array<{ path: string, bytes: number }>} files live tier measurement
324
+ * @param {{ files?: Array<{ path: string, bytes?: number }> }} baseTier recorded tier
325
+ * @returns {Array<{ tier: string, path: string, bytes: number|null }>}
326
+ */
327
+ function absentRows(tier, files, baseTier) {
328
+ const live = new Set(files.map((f) => f.path));
329
+ const out = [];
330
+ for (const row of baseTier?.files ?? []) {
331
+ if (typeof row?.path !== 'string' || live.has(row.path)) continue;
332
+ out.push({
333
+ tier,
334
+ path: row.path,
335
+ bytes: Number.isFinite(row.bytes) ? row.bytes : null,
336
+ });
337
+ }
338
+ return out;
339
+ }
340
+
306
341
  /**
307
342
  * Pure diff: compare the current tier map against the committed baseline. A
308
343
  * gated tier with no current files is skipped; a tier absent from the baseline
309
- * is skipped. `grown` entries fail the gate; `shrunk` entries are informational.
344
+ * is skipped. `grown`, `shrunk` and `absent` entries all fail the gate see
345
+ * the ratchet semantics in the module header for why shrinkage is actionable
346
+ * rather than informational (Story #4872).
310
347
  *
311
348
  * @param {{ tiers: Record<string, Array<{ path: string, bytes: number }>> }} tierMap
312
349
  * @param {{ toleranceBytes?: number, tiers?: Record<string, { totalBytes: number }> }} baseline
313
350
  * @returns {{
314
351
  * grown: Array<{ tier: string, current: number, baseline: number, tolerance: number, delta: number }>,
315
- * shrunk: Array<{ tier: string, current: number, baseline: number }>,
352
+ * shrunk: Array<{ tier: string, current: number, baseline: number, delta: number }>,
353
+ * absent: Array<{ tier: string, path: string, bytes: number|null }>,
316
354
  * skipped: string[],
317
355
  * }}
318
356
  */
@@ -322,6 +360,7 @@ export function diffBudget(tierMap, baseline) {
322
360
  : 0;
323
361
  const grown = [];
324
362
  const shrunk = [];
363
+ const absent = [];
325
364
  const skipped = [];
326
365
  for (const tier of GATED_TIERS) {
327
366
  const files = tierMap.tiers[tier] ?? [];
@@ -345,16 +384,42 @@ export function diffBudget(tierMap, baseline) {
345
384
  delta: current - baselineBytes,
346
385
  });
347
386
  } else if (current < baselineBytes) {
348
- shrunk.push({ tier, current, baseline: baselineBytes });
387
+ // Deliberately zero-tolerance: `tolerance` guards against churn from a
388
+ // trivial *addition*; mirroring it downward would discard every gain
389
+ // smaller than the tolerance, which is the leak this branch closes.
390
+ shrunk.push({
391
+ tier,
392
+ current,
393
+ baseline: baselineBytes,
394
+ delta: baselineBytes - current,
395
+ });
349
396
  }
397
+ absent.push(...absentRows(tier, files, baseTier));
350
398
  }
351
- return { grown, shrunk, skipped };
399
+ return { grown, shrunk, absent, skipped };
400
+ }
401
+
402
+ /**
403
+ * Count the drift entries that fail the gate. Every direction is actionable
404
+ * (Story #4872), so this is the one place the failure set is defined and both
405
+ * the summary tag and the exit code read it.
406
+ *
407
+ * @param {ReturnType<typeof diffBudget>} diff
408
+ * @returns {number}
409
+ */
410
+ export function budgetFailureCount(diff) {
411
+ return (
412
+ (diff?.grown?.length ?? 0) +
413
+ (diff?.shrunk?.length ?? 0) +
414
+ (diff?.absent?.length ?? 0)
415
+ );
352
416
  }
353
417
 
354
418
  /**
355
419
  * Render the human-readable diff. `+` lines are tiers that grew beyond
356
- * tolerance (gate fail); `-` lines are tiers that shrank (refreshable
357
- * baseline). A one-line summary always follows.
420
+ * tolerance; `-` lines are tiers that shrank below their recorded total or
421
+ * rows naming a path the tree no longer carries. All three fail the gate. A
422
+ * one-line summary always follows.
358
423
  *
359
424
  * @param {ReturnType<typeof diffBudget>} diff
360
425
  * @returns {string}
@@ -368,12 +433,17 @@ export function renderDiff(diff) {
368
433
  }
369
434
  for (const s of diff.shrunk) {
370
435
  lines.push(
371
- `- ${s.tier}: ${s.current} bytes below baseline ${s.baseline} — refresh baselines/context-budget.json`,
436
+ `- ${s.tier}: ${s.current} bytes is under the recorded ${s.baseline} (delta -${s.delta}) the ratchet is holding slack the tree no longer spends; refresh baselines/context-budget.json`,
437
+ );
438
+ }
439
+ for (const a of diff.absent ?? []) {
440
+ lines.push(
441
+ `- ${a.tier}: recorded row ${a.path} names a path the measured tier no longer contains — refresh baselines/context-budget.json`,
372
442
  );
373
443
  }
374
- const tag = diff.grown.length > 0 ? '(gate fail)' : '(ok)';
444
+ const tag = budgetFailureCount(diff) > 0 ? '(gate fail)' : '(ok)';
375
445
  lines.push(
376
- `[context-budget] grown=${diff.grown.length} shrunk=${diff.shrunk.length} skipped=${diff.skipped.length} ${tag}`,
446
+ `[context-budget] grown=${diff.grown.length} shrunk=${diff.shrunk.length} absent=${diff.absent?.length ?? 0} skipped=${diff.skipped.length} ${tag}`,
377
447
  );
378
448
  return lines.join('\n');
379
449
  }
@@ -454,7 +524,7 @@ export async function runCli({
454
524
  if (!baseline) {
455
525
  if (json) {
456
526
  stdout.write(
457
- `${JSON.stringify({ kind: 'context-budget-report', baselinePath: resolvedBaselinePath, tiers: tierMap.tiers, grown: [], shrunk: [], skipped: GATED_TIERS, exitCode: 0, noBaseline: true }, null, 2)}\n`,
527
+ `${JSON.stringify({ kind: 'context-budget-report', baselinePath: resolvedBaselinePath, tiers: tierMap.tiers, grown: [], shrunk: [], absent: [], skipped: GATED_TIERS, exitCode: 0, noBaseline: true }, null, 2)}\n`,
458
528
  );
459
529
  } else {
460
530
  stderr.write(
@@ -472,7 +542,7 @@ export async function runCli({
472
542
  const bootDrift = agentBootDrift(tierMap, baseline, ceiling);
473
543
  const permissiveDrift = bootDrift.filter((d) => d.direction === 'permissive');
474
544
  const exitCode =
475
- diff.grown.length > 0 ||
545
+ budgetFailureCount(diff) > 0 ||
476
546
  bootOverflow.length > 0 ||
477
547
  permissiveDrift.length > 0
478
548
  ? 1
@@ -490,6 +560,7 @@ export async function runCli({
490
560
  ),
491
561
  grown: diff.grown,
492
562
  shrunk: diff.shrunk,
563
+ absent: diff.absent,
493
564
  skipped: diff.skipped,
494
565
  agentBootCeilingBytes: ceiling,
495
566
  agentBootOverflow: bootOverflow,
@@ -527,6 +598,16 @@ export async function runCli({
527
598
  `[context-budget] ❌ a documentation tier grew beyond tolerance — refresh the budget consciously with \`node .agents/scripts/check-context-budget.js --update\` once the growth is intentional\n`,
528
599
  );
529
600
  }
601
+ if (diff.shrunk.length > 0) {
602
+ stderr.write(
603
+ `[context-budget] ❌ a documentation tier came in under its recorded total — the ratchet is holding slack the tree no longer spends, so the next growth would be absorbed silently. Lock the gain in with \`node .agents/scripts/check-context-budget.js --update\`\n`,
604
+ );
605
+ }
606
+ if (diff.absent.length > 0) {
607
+ stderr.write(
608
+ `[context-budget] ❌ a recorded row names a path the measured tier no longer contains — its bytes inflate the recorded total against nothing. Refresh with \`node .agents/scripts/check-context-budget.js --update\`\n`,
609
+ );
610
+ }
530
611
  }
531
612
  }
532
613
 
@@ -78,6 +78,11 @@ import os from 'node:os';
78
78
  import path from 'node:path';
79
79
  import { fileURLToPath } from 'node:url';
80
80
  import { runAsCli } from './lib/cli-utils.js';
81
+ import { mainCheckoutRoot } from './lib/config/temp-paths.js';
82
+ import {
83
+ isReservedTestId,
84
+ RESERVED_TEST_ID_BAND,
85
+ } from './lib/reserved-test-ids.js';
81
86
  import {
82
87
  findRawTmpdirMkdtemp,
83
88
  listSuiteTempRoots,
@@ -369,6 +374,84 @@ export function cleanFixtureDirs({
369
374
  return { candidates, removed };
370
375
  }
371
376
 
377
+ /**
378
+ * Every Epic / Story id a stream file's own path attributes it to.
379
+ *
380
+ * Both canonical layouts are read, and a nested Epic-attached stream yields
381
+ * both ids (`run-<eid>/stories/story-<sid>/…`): either half being a fixture id
382
+ * makes the stream fixture-owned, and taking only the outer one is how a
383
+ * fixture Story under a real run would slip past.
384
+ *
385
+ * @param {string} rel POSIX-normalised path relative to `temp/`.
386
+ * @returns {number[]}
387
+ */
388
+ function streamOwnerIds(rel) {
389
+ const ids = [];
390
+ const run = /^run-(\d+)$/.exec(rel.split('/')[0]);
391
+ if (run) ids.push(Number(run[1]));
392
+ const story = /(?:^|\/)story-(\d+)\//.exec(rel);
393
+ if (story) ids.push(Number(story[1]));
394
+ return ids;
395
+ }
396
+
397
+ /**
398
+ * Stream files under `tempDir` owned by a **reserved test-fixture id**
399
+ * (Story #4892).
400
+ *
401
+ * This is the residual-pollution dimension the snapshot/assert bracket cannot
402
+ * cover: the bracket only runs in CI, where `temp/` starts empty, so a local
403
+ * run that appends fixture telemetry to the operator's live ledger was only
404
+ * ever discovered from the ticket the retro graduator filed off it (issue
405
+ * #4870 cited `#999999`, a `--story 999999` CLI spawn from the suite).
406
+ *
407
+ * Unlike the snapshot diff this needs no baseline and is immune to a
408
+ * concurrent delivery in another checkout: a reserved id is reserved *from*
409
+ * real work, so nothing but a test can own one of these files.
410
+ *
411
+ * @param {string} tempDir
412
+ * @returns {string[]} POSIX-normalised paths relative to `tempDir`, sorted.
413
+ */
414
+ export function findReservedIdStreamFiles(tempDir) {
415
+ return listStreamFiles(tempDir).filter((rel) =>
416
+ streamOwnerIds(rel).some(isReservedTestId),
417
+ );
418
+ }
419
+
420
+ /**
421
+ * Post-run guard: fail when a test run left a fixture-id telemetry stream in
422
+ * the **repository-root** temp tree (Story #4892).
423
+ *
424
+ * Resolution matters more than it looks: every writer anchors a relative
425
+ * `tempRoot` to the *main checkout* (so a Story worktree and its `/deliver`
426
+ * host converge on one ledger), so a guard that scanned `cwd` would scan an
427
+ * empty worktree tree and pass vacuously on the very tree it is meant to
428
+ * protect. `resolveRoot` is the injection seam for tests.
429
+ *
430
+ * @param {object} [opts]
431
+ * @param {string} [opts.cwd=process.cwd()]
432
+ * @param {(line: string) => void} [opts.log]
433
+ * @param {(cwd: string) => string|null} [opts.resolveRoot]
434
+ * @returns {number} exit code (0 clean, 1 polluted).
435
+ */
436
+ export function assertNoReservedIdStreams({
437
+ cwd = process.cwd(),
438
+ log = (l) => process.stderr.write(`${l}\n`),
439
+ resolveRoot = mainCheckoutRoot,
440
+ } = {}) {
441
+ const root = resolveRoot(cwd) ?? cwd;
442
+ const tempDir = tempDirFor(root);
443
+ const found = findReservedIdStreamFiles(tempDir);
444
+ if (found.length === 0) return 0;
445
+ log(
446
+ `[test-temp-hygiene] FAIL — ${found.length} fixture-id telemetry stream(s) in the real temp tree (${tempDir}):`,
447
+ );
448
+ for (const rel of found) log(` + fixture ${rel}`);
449
+ log(
450
+ `[test-temp-hygiene] ids ${RESERVED_TEST_ID_BAND} are reserved for fixtures, so a test wrote to the live ledger — the retro graduator reads these streams and files tickets off them. Inject an absolute per-test tempRoot on the offending spawn, then remove the stream(s) with --clean --ids <id> --yes.`,
451
+ );
452
+ return 1;
453
+ }
454
+
372
455
  /**
373
456
  * Parse the CLI argv into a normalised options object.
374
457
  * @param {string[]} argv
@@ -3,9 +3,9 @@
3
3
 
4
4
  /**
5
5
  * Removes **test-reserved** per-Epic trees under `<repo>/temp/epic-<id>/` after
6
- * test runs. Only Epic IDs in the band defined by
7
- * `lib/test-reserved-epic-temp-ids.js` are deleted (999000–999999). Real Epic
8
- * scratch (e.g. `temp/epic-1143/`) is never touched.
6
+ * test runs. Only Epic IDs in the band declared by `lib/reserved-test-ids.js`
7
+ * are deleted (999000–999999). Real Epic scratch (e.g. `temp/epic-1143/`) is
8
+ * never touched.
9
9
  *
10
10
  * Keeps sibling dirs like `temp/epic-runner-logs/`, loose files, and
11
11
  * `temp/epic-<id>/` when `<id>` is outside that reserved band.
@@ -20,11 +20,13 @@
20
20
  import { existsSync, readdirSync, rmSync } from 'node:fs';
21
21
  import path from 'node:path';
22
22
  import { runAsCli } from './lib/cli-utils.js';
23
+ import { isReservedTestId } from './lib/reserved-test-ids.js';
23
24
 
24
- const RESERVED_TEST_EPIC_TEMP_DIR = /^epic-999\d{3}$/;
25
+ const EPIC_TEMP_DIR = /^epic-(\d+)$/;
25
26
 
26
27
  function isReservedTestEpicTempDirName(name) {
27
- return RESERVED_TEST_EPIC_TEMP_DIR.test(name);
28
+ const match = EPIC_TEMP_DIR.exec(name);
29
+ return match !== null && isReservedTestId(Number(match[1]));
28
30
  }
29
31
 
30
32
  /**
@@ -337,10 +337,9 @@ async function runBackstopMode(values, deps = {}) {
337
337
  process.stderr.write(HELP);
338
338
  throw new Error('[deliver-light] --backstop requires --story <id>');
339
339
  }
340
- const { result, nextCommand, exitCode, message } = await resolveFn({
341
- storyId,
342
- });
343
- const extra = nextCommand === null ? {} : { nextCommand };
340
+ const { result, nextCommand, preservation, exitCode, message } =
341
+ await resolveFn({ storyId });
342
+ const extra = nextCommand === null ? {} : { nextCommand, preservation };
344
343
  emit({ mode: 'backstop', storyId, ...result, ...extra }, values.pretty);
345
344
  if (result.blocked) Logger.warn(message);
346
345
  else Logger.info(message);
@@ -16,6 +16,14 @@
16
16
  * `single-story-init.js` hard-errors on an already-closed Story. Before this
17
17
  * surface, that Story had no automated way back.
18
18
  *
19
+ * Every command it prints **resumes** — it re-enters the in-flight worker or
20
+ * close, or closes the branch that is already pushed. None of them re-dispatch
21
+ * the Story, and that is deliberate: since Story #4876 the close-and-land tail
22
+ * belongs to the dispatching orchestrator, so a worker returning without a
23
+ * terminal envelope is the expected shape rather than evidence the Story never
24
+ * ran. Answering it with a fresh dispatch re-runs `single-story-init.js`
25
+ * underneath live work and puts a second close on one PR.
26
+ *
19
27
  * Usage:
20
28
  * node .agents/scripts/deliver-recover.js --story <STORY_ID> [--cwd <main-repo>]
21
29
  * [--json]
@@ -46,6 +54,11 @@ Probes a Story's live delivery state — labels, lease, branch, worktree, PR
46
54
  state and checks — and prints the single next command that resumes it, with
47
55
  the evidence it was derived from. Read-only: mutates nothing.
48
56
 
57
+ The command always resumes the existing worker or close (or closes an already
58
+ pushed branch); it never re-dispatches the Story, which would re-run init
59
+ underneath live work. A worker that returned no terminal envelope is expected —
60
+ the orchestrator owns the close-and-land tail.
61
+
49
62
  Mid-flight shapes (executing-*/closing-*) get a stability re-probe after a
50
63
  short settle window: matching shapes return the fresher verdict; diverging
51
64
  shapes report \`in-transition\` (a live delivery process is mutating the