mandrel 2.58.0 → 2.60.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 (124) hide show
  1. package/.agents/README.md +17 -12
  2. package/.agents/agents/acceptance-critic.md +24 -43
  3. package/.agents/agents/story-worker.md +18 -19
  4. package/.agents/docs/SDLC.md +12 -13
  5. package/.agents/docs/agentrc-reference.json +1 -2
  6. package/.agents/docs/configuration.md +29 -46
  7. package/.agents/docs/quality-gates.md +9 -5
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/instructions.md +5 -7
  10. package/.agents/rules/ci-remediation.md +41 -8
  11. package/.agents/rules/known-tooling-behavior.md +65 -15
  12. package/.agents/runtime-deps.json +7 -2
  13. package/.agents/schemas/acceptance-eval-verdict.schema.json +1 -1
  14. package/.agents/schemas/agentrc.schema.json +6 -11
  15. package/.agents/schemas/crap-baseline.schema.json +1 -1
  16. package/.agents/schemas/crap-report.schema.json +1 -1
  17. package/.agents/schemas/story-deliver-terminal.schema.json +3 -3
  18. package/.agents/scripts/README.md +11 -1
  19. package/.agents/scripts/acceptance-eval.js +25 -27
  20. package/.agents/scripts/ceremony-derive.js +15 -10
  21. package/.agents/scripts/check-context-budget.js +148 -228
  22. package/.agents/scripts/check-schema-references.js +5 -3
  23. package/.agents/scripts/check-workflow-citations.js +33 -147
  24. package/.agents/scripts/coverage-capture.js +7 -4
  25. package/.agents/scripts/deliver-light.js +41 -100
  26. package/.agents/scripts/deliver-run.js +631 -0
  27. package/.agents/scripts/file-ci-gap.js +59 -11
  28. package/.agents/scripts/install-matrix-assert.js +48 -3
  29. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +51 -33
  30. package/.agents/scripts/lib/baselines/crap-preview-incremental.js +6 -2
  31. package/.agents/scripts/lib/baselines/kinds/_crap-read.js +0 -8
  32. package/.agents/scripts/lib/baselines/kinds/crap.js +35 -18
  33. package/.agents/scripts/lib/changed-files.js +30 -0
  34. package/.agents/scripts/lib/config/delivery-routing.js +5 -4
  35. package/.agents/scripts/lib/config/explain.js +1 -3
  36. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +1 -1
  37. package/.agents/scripts/lib/config-resolver.js +1 -0
  38. package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -21
  39. package/.agents/scripts/lib/coverage-capture-fullscope.js +10 -2
  40. package/.agents/scripts/lib/coverage-capture-incremental.js +3 -2
  41. package/.agents/scripts/lib/coverage-capture-usage.js +4 -1
  42. package/.agents/scripts/lib/crap-engine.js +2 -2
  43. package/.agents/scripts/lib/crap-utils.js +21 -5
  44. package/.agents/scripts/lib/doc-tiers.js +4 -2
  45. package/.agents/scripts/lib/escomplex-ast-compat.js +39 -17
  46. package/.agents/scripts/lib/escomplex-kernel.js +298 -0
  47. package/.agents/scripts/lib/feedback-loop/graduator-core.js +7 -6
  48. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +7 -5
  49. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  50. package/.agents/scripts/lib/gh-exec.js +160 -0
  51. package/.agents/scripts/lib/maintainability-engine.js +3 -3
  52. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  53. package/.agents/scripts/lib/orchestration/ceremony-routing.js +74 -132
  54. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +123 -12
  55. package/.agents/scripts/lib/orchestration/complexity-gate.js +180 -352
  56. package/.agents/scripts/lib/orchestration/light-suitability.js +71 -136
  57. package/.agents/scripts/lib/orchestration/plan-context.js +44 -50
  58. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +8 -6
  59. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +104 -119
  60. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +41 -25
  61. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +11 -11
  62. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -29
  63. package/.agents/scripts/lib/orchestration/plan-persist/wave-collision-gate.js +107 -0
  64. package/.agents/scripts/lib/orchestration/review-depth.js +14 -11
  65. package/.agents/scripts/lib/orchestration/run-epilogue.js +260 -182
  66. package/.agents/scripts/lib/orchestration/run-scoped-config.js +63 -99
  67. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +3 -3
  68. package/.agents/scripts/lib/orchestration/single-story-close/phases/graphql-preflight.js +137 -0
  69. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +105 -18
  70. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -3
  71. package/.agents/scripts/lib/orchestration/story-follow-ups.js +156 -39
  72. package/.agents/scripts/lib/orchestration/story-init-envelope.js +71 -0
  73. package/.agents/scripts/lib/orchestration/task-body-validator.js +8 -17
  74. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +25 -209
  75. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +8 -5
  76. package/.agents/scripts/lib/orchestration/ticket-validator.js +44 -183
  77. package/.agents/scripts/lib/orchestration/ticketing/reads.js +14 -25
  78. package/.agents/scripts/lib/runtime-deps/dep-resolution.js +155 -0
  79. package/.agents/scripts/lib/runtime-deps/ensure-installed.js +44 -9
  80. package/.agents/scripts/lib/runtime-deps/parser-major.js +110 -0
  81. package/.agents/scripts/lib/runtime-deps/preflight.js +6 -25
  82. package/.agents/scripts/lib/runtime-deps/scan-imports.js +46 -1
  83. package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
  84. package/.agents/scripts/lib/story-body/body-format-lints.js +58 -12
  85. package/.agents/scripts/lib/story-body/story-body.js +83 -29
  86. package/.agents/scripts/lib/templates/decomposer-prompts.js +28 -33
  87. package/.agents/scripts/lib/wave-runner/live-probe.js +31 -5
  88. package/.agents/scripts/merge-baseline.js +4 -5
  89. package/.agents/scripts/plan-context.js +117 -28
  90. package/.agents/scripts/plan-persist.js +79 -39
  91. package/.agents/scripts/plan-run-epilogue.js +11 -8
  92. package/.agents/scripts/pr-watch-with-update.js +9 -2
  93. package/.agents/scripts/run-verify.js +13 -6
  94. package/.agents/scripts/single-story-init.js +7 -57
  95. package/.agents/scripts/stories-wave-tick.js +160 -26
  96. package/.agents/skills/core/gates-and-baselines/reference.md +0 -1
  97. package/.agents/skills/skills.index.json +2 -12
  98. package/.agents/skills/stack/qa/playwright/SKILL.md +26 -0
  99. package/.agents/workflows/audit-to-stories.md +14 -11
  100. package/.agents/workflows/helpers/acceptance-self-eval.md +84 -157
  101. package/.agents/workflows/helpers/code-review.md +4 -2
  102. package/.agents/workflows/helpers/deliver-digest.md +31 -24
  103. package/.agents/workflows/helpers/deliver-light.md +92 -101
  104. package/.agents/workflows/helpers/deliver-reference.md +116 -100
  105. package/.agents/workflows/helpers/deliver-story-reference.md +58 -124
  106. package/.agents/workflows/helpers/deliver-story.md +17 -18
  107. package/.agents/workflows/helpers/plan-reference.md +82 -60
  108. package/.agents/workflows/mandrel-deliver.md +47 -31
  109. package/.agents/workflows/mandrel-plan.md +32 -30
  110. package/.agents/workflows/mandrel-update.md +36 -21
  111. package/README.md +3 -3
  112. package/docs/CHANGELOG.md +43 -0
  113. package/lib/cli/registry.js +45 -25
  114. package/lib/cli/update.js +376 -17
  115. package/lib/migrations/index.js +2 -0
  116. package/lib/migrations/steps/2.60.0-retire-audit-results-autofile.js +40 -0
  117. package/package.json +8 -2
  118. package/.agents/schemas/model-attribution.schema.json +0 -53
  119. package/.agents/scripts/lib/orchestration/model-attribution.js +0 -418
  120. package/.agents/scripts/lib/orchestration/split-policy-validator.js +0 -188
  121. package/.agents/scripts/lib/orchestration/story-plan-state.js +0 -33
  122. package/.agents/scripts/lib/orchestration/structured-comment-parser.js +0 -67
  123. package/.agents/scripts/lib/templates/spec-author-prompts.js +0 -76
  124. package/.agents/skills/core/scope-triage/SKILL.md +0 -48
@@ -5,8 +5,8 @@
5
5
  * Follows the standalone `check-arch-cycles.js` / `check-dead-exports.js`
6
6
  * precedent — a pure-Node, baseline-aware, sub-second checker wired into the
7
7
  * CI `baselines` job — rather than a `baselines/kinds/` metric. It measures the
8
- * live byte total of two documentation read-tiers and compares each against a
9
- * single committed budget in `baselines/context-budget.json`:
8
+ * live byte total of three documentation read-tiers against a single committed
9
+ * budget in `baselines/context-budget.json`:
10
10
  *
11
11
  * - `alwaysLoaded` — the `CLAUDE.md` `@`-import closure re-paid on every
12
12
  * session and every subagent spawn (instructions.md § 4).
@@ -15,40 +15,33 @@
15
15
  * `.agents/workflows/**` entry point plus the transitive
16
16
  * closure of its `mandatoryReads:` frontmatter edges. The
17
17
  * companion **reachable** closure (per entry point) is
18
- * recorded under the top-level `workflowClosure` key as a
19
- * drift signal and never gates — growth there is a
20
- * reading-cost signal, not a contract violation.
18
+ * recorded under the top-level `workflowClosure` key.
21
19
  *
22
- * It additionally enforces a **per-file** ceiling on the role-scoped agent-boot
23
- * tier (`.agents/agents/*.md`, #4478): no single boot context may exceed
24
- * `agentBoot.ceilingBytes` (default 8192). This is a per-agent cap, not a sum
25
- * ratchet — each role def is a standalone system prompt a converted spawn boots
26
- * on, and adding another role def is legitimate.
20
+ * **One tier gates: `alwaysLoaded`.** It is the only tier every session and
21
+ * every subagent spawn pays unconditionally, so growth there is a real tax on
22
+ * every future turn. The other tiers are measured, recorded and printed, and
23
+ * never fail the command (Story #5340). Demoting them is what makes workflow
24
+ * prose editable again: under the old rule a prose fix had to be paid for with
25
+ * an unrelated trim in the same commit, and that is how three reference
26
+ * sections came to describe mechanisms the code had already retired. The
27
+ * measurement is still worth seeing on every change, so it is kept as a
28
+ * report rather than deleted. See `docs/decisions.md`, ADR 20260917-5340.
27
29
  *
28
- * The recorded `agentBoot` rows are additionally held **in sync with the tree**
29
- * (Story #4830), because those rows are what an author sizes an edit against.
30
- * The two drift directions are deliberately asymmetric:
31
- *
32
- * - **permissive** (the row understates the file, or no row exists) — the row
33
- * promises headroom that does not exist, the gate stays green, and the
34
- * shortfall only lands as a ceiling failure once the edit is written. This
35
- * fails the gate.
36
- * - **restrictive** (the row overstates the file) — an author under-spends
37
- * and the ceiling is never surprised, so it is self-correcting. Reported as
38
- * a `-` line; exit stays 0.
39
- *
40
- * Each row also records its `headroomBytes` under the ceiling, so the budget an
41
- * author reads is stated rather than re-derived.
30
+ * The role-scoped agent-boot tier (`.agents/agents/*.md`) is recorded the same
31
+ * way. Its former per-file 8 KB ceiling and the row-vs-tree drift gate are
32
+ * gone with the same ADR — nothing enforces a per-file ceiling or a minimum
33
+ * headroom on a workflow or agent file any more.
42
34
  *
43
35
  * A read-tier that resolves **empty** is skipped silently (the `docsContextFiles`
44
36
  * half skips when unconfigured / its files are absent), so a repo with no
45
37
  * `CLAUDE.md` and no context docs is a clean no-op.
46
38
  *
47
39
  * Ratchet semantics (mirroring the sibling ratchets):
48
- * - A gated tier grows beyond `baseline.tiers.<tier>.totalBytes +
49
- * baseline.toleranceBytes` → exit 1, naming the tier and its delta.
50
- * - A gated tier shrinks below its baseline total → **exit 0**, reported as
51
- * an informational `-` line (Story #5313). This deliberately reverses
40
+ * - The `alwaysLoaded` tier grows beyond `baseline.tiers.alwaysLoaded
41
+ * .totalBytes + baseline.toleranceBytes` → exit 1, naming the tier and its
42
+ * delta. Growth in any other measured tier is printed and exits 0.
43
+ * - A measured tier shrinks below its baseline total → **exit 0**, reported
44
+ * as an informational `-` line (Story #5313). This deliberately reverses
52
45
  * Story #4872's "shrink fails" rule: that rule made every trim a red gate
53
46
  * whose only remedy was a hand-run `--update`, so the gain was paid for
54
47
  * twice. The concern it answered — a stale total silently absorbing the
@@ -58,9 +51,10 @@
58
51
  * gain locks in without a failing gate. Shrinkage stays zero-tolerance
59
52
  * in the *report* (every byte under the total is listed) so a sub-
60
53
  * tolerance gain is never discarded by the write-back either.
61
- * - A recorded row naming a path the measured tier no longer contains →
62
- * exit 1. The row describes a file that has been deleted or de-listed, so
63
- * the bytes it contributes to the recorded total are fiction.
54
+ * - A recorded `alwaysLoaded` row naming a path the measured tier no longer
55
+ * contains → exit 1. The row describes a file that has been deleted or
56
+ * de-listed, so the bytes it contributes to the recorded total are fiction.
57
+ * The same drift in a report-only tier is printed, not failed.
64
58
  * - Within tolerance / clean → exit 0.
65
59
  * - Baseline file absent → warn + exit 0 (no-op; nothing to ratchet against).
66
60
  *
@@ -85,138 +79,29 @@ import { resolveConfig } from './lib/config-resolver.js';
85
79
  import { resolveDocTiers, tierTotalBytes } from './lib/doc-tiers.js';
86
80
 
87
81
  /**
88
- * The tiers this ratchet gates (in report order). `digestVisible`, `onDemand`
89
- * and `workflowOnDemand` are resolved by the tier map for the lens, but the
90
- * byte budget intentionally gates only the tiers a session is *forced* to read.
82
+ * The tiers this command measures and records (in report order).
83
+ * `digestVisible`, `onDemand` and `workflowOnDemand` are resolved by the tier
84
+ * map for the lens, but the byte budget intentionally measures only the tiers
85
+ * a session is *forced* to read.
91
86
  * @type {Array<'alwaysLoaded' | 'mandatoryRead' | 'workflow'>}
92
87
  */
93
- export const GATED_TIERS = ['alwaysLoaded', 'mandatoryRead', 'workflow'];
88
+ export const MEASURED_TIERS = ['alwaysLoaded', 'mandatoryRead', 'workflow'];
94
89
 
95
90
  /**
96
- * Default tolerance (bytes) seeded into a fresh baseline by `--update` when the
97
- * existing baseline carries none.
98
- * @type {number}
91
+ * The tiers whose drift fails the command (Story #5340). Only `alwaysLoaded`
92
+ * is paid by every session and every subagent spawn unconditionally, so it is
93
+ * the one tier where growth is a tax nobody opted into. Everything else in
94
+ * {@link MEASURED_TIERS} is a report.
95
+ * @type {Array<'alwaysLoaded'>}
99
96
  */
100
- export const DEFAULT_TOLERANCE_BYTES = 2048;
97
+ export const ENFORCED_TIERS = ['alwaysLoaded'];
101
98
 
102
99
  /**
103
- * Per-file ceiling (bytes) for the role-scoped agent-boot tier (#4478). Unlike
104
- * the read-tiers (gated by a total-byte ratchet), each `.agents/agents/*.md`
105
- * boot context is a **standalone** system prompt a converted spawn boots on, so
106
- * the meaningful budget is per-agent, not the sum: no single role def may
107
- * exceed this ceiling. Adding another role def is legitimate — a per-file gate
108
- * (rather than a sum ratchet) does not false-positive on that.
100
+ * Default tolerance (bytes) seeded into a fresh baseline by `--update` when the
101
+ * existing baseline carries none.
109
102
  * @type {number}
110
103
  */
111
- export const AGENT_BOOT_CEILING_BYTES = 8192;
112
-
113
- /**
114
- * Return the agent-boot files that exceed the per-file ceiling.
115
- *
116
- * @param {{ tiers: Record<string, Array<{ path: string, bytes: number }>> }} tierMap
117
- * @param {number} ceiling
118
- * @returns {Array<{ path: string, bytes: number, ceiling: number }>}
119
- */
120
- export function agentBootOverflow(tierMap, ceiling = AGENT_BOOT_CEILING_BYTES) {
121
- const files = tierMap?.tiers?.agentBoot ?? [];
122
- return files
123
- .filter((f) => Number.isFinite(f?.bytes) && f.bytes > ceiling)
124
- .map((f) => ({ path: f.path, bytes: f.bytes, ceiling }));
125
- }
126
-
127
- /**
128
- * Classify one agent-boot file against its recorded baseline row (#4830).
129
- * Returns `null` when the row already agrees with the tree.
130
- *
131
- * `permissive` drift is the failure class this gate exists for: the row
132
- * understates the file (or is missing entirely), so the headroom an author
133
- * computes from it is larger than the headroom that exists, and the shortfall
134
- * only surfaces as a ceiling failure *after* the edit is written.
135
- * `restrictive` drift is the benign mirror — the row overstates the file, so an
136
- * author under-spends and the ceiling gate is never surprised.
137
- *
138
- * @param {{ path: string, bytes: number }} file live file measurement
139
- * @param {{ bytes?: number, headroomBytes?: number } | undefined} row recorded row
140
- * @param {number} ceiling
141
- * @returns {{ path: string, recorded: number|null, actual: number, delta: number,
142
- * direction: 'permissive'|'restrictive', recordedHeadroom: number|null,
143
- * headroomBytes: number } | null}
144
- */
145
- function classifyBootRow(file, row, ceiling) {
146
- const actual = file.bytes;
147
- const headroomBytes = ceiling - actual;
148
- const recorded = Number.isFinite(row?.bytes) ? row.bytes : null;
149
- const recordedHeadroom = Number.isFinite(row?.headroomBytes)
150
- ? row.headroomBytes
151
- : recorded === null
152
- ? null
153
- : ceiling - recorded;
154
- // A row is in sync only when both the byte count and the headroom it
155
- // advertises match the tree — a stale headroom misleads on its own.
156
- if (recorded === actual && recordedHeadroom === headroomBytes) return null;
157
- const permissive =
158
- recorded === null ||
159
- recorded < actual ||
160
- (recordedHeadroom !== null && recordedHeadroom > headroomBytes);
161
- return {
162
- path: file.path,
163
- recorded,
164
- actual,
165
- delta: recorded === null ? actual : actual - recorded,
166
- direction: permissive ? 'permissive' : 'restrictive',
167
- recordedHeadroom,
168
- headroomBytes,
169
- };
170
- }
171
-
172
- /**
173
- * Compare every recorded `agentBoot` row against the tree it describes.
174
- *
175
- * @param {{ tiers: Record<string, Array<{ path: string, bytes: number }>> }} tierMap
176
- * @param {{ agentBoot?: { ceilingBytes?: number, files?: Array<{ path: string, bytes: number, headroomBytes?: number }> } } | null} baseline
177
- * @param {number} [ceiling]
178
- * @returns {Array<ReturnType<typeof classifyBootRow>>} drift rows (empty = in sync)
179
- */
180
- export function agentBootDrift(tierMap, baseline, ceiling) {
181
- const recordedCeiling = Number.isFinite(baseline?.agentBoot?.ceilingBytes)
182
- ? baseline.agentBoot.ceilingBytes
183
- : AGENT_BOOT_CEILING_BYTES;
184
- const effective = Number.isFinite(ceiling) ? ceiling : recordedCeiling;
185
- const rows = new Map(
186
- (baseline?.agentBoot?.files ?? []).map((f) => [f.path, f]),
187
- );
188
- const drift = [];
189
- for (const file of tierMap?.tiers?.agentBoot ?? []) {
190
- if (!Number.isFinite(file?.bytes)) continue;
191
- const row = classifyBootRow(file, rows.get(file.path), effective);
192
- if (row) drift.push(row);
193
- }
194
- return drift;
195
- }
196
-
197
- /**
198
- * Render the agent-boot drift lines. `+` lines are permissive drift (gate
199
- * fail); `-` lines are restrictive drift (informational). Each line states the
200
- * **real** remaining headroom, so the author sizing the next edit reads the
201
- * true number rather than re-deriving it from a row that just proved stale.
202
- *
203
- * @param {Array<ReturnType<typeof classifyBootRow>>} drift
204
- * @returns {string[]}
205
- */
206
- export function renderBootDrift(drift) {
207
- return drift.map((d) => {
208
- const marker = d.direction === 'permissive' ? '+' : '-';
209
- const recorded =
210
- d.recorded === null
211
- ? 'has no recorded row'
212
- : `records ${d.recorded} bytes but the file is ${d.actual}`;
213
- const note =
214
- d.direction === 'permissive'
215
- ? 'the row overstates the headroom an author would size an edit against'
216
- : 'the row is conservative — refresh at leisure';
217
- return `${marker} agentBoot drift: ${d.path} ${recorded} — ${note} (real headroom ${d.headroomBytes})`;
218
- });
219
- }
104
+ export const DEFAULT_TOLERANCE_BYTES = 2048;
220
105
 
221
106
  /**
222
107
  * Parse argv for `--baseline <path>`, `--root <path>`, `--update`, `--json`.
@@ -273,7 +158,7 @@ export function loadBaseline(baselinePath) {
273
158
 
274
159
  /**
275
160
  * Build the committed-baseline envelope from a resolved tier map. Only the
276
- * gated tiers are recorded (each as `{ totalBytes, files }`).
161
+ * measured tiers are recorded (each as `{ totalBytes, files }`).
277
162
  *
278
163
  * @param {{ tiers: Record<string, Array<{ path: string, bytes: number }>> }} tierMap
279
164
  * @param {number} toleranceBytes
@@ -281,20 +166,17 @@ export function loadBaseline(baselinePath) {
281
166
  */
282
167
  export function buildBaseline(tierMap, toleranceBytes) {
283
168
  const tiers = {};
284
- for (const name of GATED_TIERS) {
169
+ for (const name of MEASURED_TIERS) {
285
170
  const files = tierMap.tiers[name] ?? [];
286
171
  tiers[name] = { totalBytes: tierTotalBytes(files), files };
287
172
  }
288
- // The agent-boot tier is recorded top-level (not under `tiers`) because it is
289
- // gated by a per-file ceiling, not the total-byte ratchet the `tiers` entries
290
- // use — keeping it out of `tiers` keeps the ratchet diff loop unambiguous.
291
- // Each row carries the headroom it leaves under the ceiling, so an author
292
- // sizing an edit reads the remaining budget straight off the row (#4830)
293
- // instead of re-deriving it — and `agentBootDrift` keeps both numbers honest.
173
+ // The agent-boot tier is recorded top-level (not under `tiers`) because it
174
+ // carries no recorded total to diff against — it is a per-file size record
175
+ // the audit instruments read as hotspot rows. Keeping it out of `tiers`
176
+ // keeps the ratchet diff loop unambiguous.
294
177
  const agentBootFiles = (tierMap.tiers.agentBoot ?? []).map((f) => ({
295
178
  path: f.path,
296
179
  bytes: f.bytes,
297
- headroomBytes: AGENT_BOOT_CEILING_BYTES - f.bytes,
298
180
  }));
299
181
  return {
300
182
  $schema: 'https://mandrel.dev/baselines/context-budget.schema.json',
@@ -302,11 +184,10 @@ export function buildBaseline(tierMap, toleranceBytes) {
302
184
  toleranceBytes,
303
185
  tiers,
304
186
  agentBoot: {
305
- ceilingBytes: AGENT_BOOT_CEILING_BYTES,
306
187
  files: agentBootFiles,
307
188
  },
308
189
  // Recorded, never gated (#4752): the total reachable closure per workflow
309
- // entry point. It is a drift signal — the gate is `tiers.workflow`.
190
+ // entry point.
310
191
  workflowClosure: {
311
192
  reachableTotalBytes: tierMap.workflowClosure?.reachableTotalBytes ?? 0,
312
193
  entryPoints: tierMap.workflowClosure?.entryPoints ?? [],
@@ -315,7 +196,7 @@ export function buildBaseline(tierMap, toleranceBytes) {
315
196
  }
316
197
 
317
198
  /**
318
- * Collect the recorded rows of one gated tier that name a path the measured
199
+ * Collect the recorded rows of one measured tier that name a path the measured
319
200
  * tier no longer contains (Story #4872). A deleted file drops out of the
320
201
  * resolved tier, and so does one that has been de-listed from the read set —
321
202
  * either way the row's bytes are counted into a recorded total that no live
@@ -342,10 +223,11 @@ function absentRows(tier, files, baseTier) {
342
223
 
343
224
  /**
344
225
  * Pure diff: compare the current tier map against the committed baseline. A
345
- * gated tier with no current files is skipped; a tier absent from the baseline
346
- * is skipped. `grown` and `absent` entries fail the gate; `shrunk` entries are
347
- * reported and written back by the close (Story #5313) — see the ratchet
348
- * semantics in the module header.
226
+ * measured tier with no current files is skipped; a tier absent from the
227
+ * baseline is skipped. `grown` and `absent` entries in an {@link
228
+ * ENFORCED_TIERS} tier fail the gate; every other entry — and every `shrunk`
229
+ * entry — is reported (Story #5340, Story #5313) and `shrunk` is what the
230
+ * close writes back. See the ratchet semantics in the module header.
349
231
  *
350
232
  * @param {{ tiers: Record<string, Array<{ path: string, bytes: number }>> }} tierMap
351
233
  * @param {{ toleranceBytes?: number, tiers?: Record<string, { totalBytes: number }> }} baseline
@@ -364,7 +246,7 @@ export function diffBudget(tierMap, baseline) {
364
246
  const shrunk = [];
365
247
  const absent = [];
366
248
  const skipped = [];
367
- for (const tier of GATED_TIERS) {
249
+ for (const tier of MEASURED_TIERS) {
368
250
  const files = tierMap.tiers[tier] ?? [];
369
251
  const current = tierTotalBytes(files);
370
252
  const baseTier = baseline?.tiers?.[tier];
@@ -401,34 +283,68 @@ export function diffBudget(tierMap, baseline) {
401
283
  return { grown, shrunk, absent, skipped };
402
284
  }
403
285
 
286
+ /**
287
+ * True when a diff entry belongs to a tier whose drift still fails the gate.
288
+ *
289
+ * @param {{ tier?: string }} entry
290
+ * @returns {boolean}
291
+ */
292
+ function isEnforced(entry) {
293
+ return ENFORCED_TIERS.includes(entry?.tier);
294
+ }
295
+
404
296
  /**
405
297
  * Count the drift entries that fail the gate: growth past tolerance and a
406
- * recorded row the tree no longer backs. Shrinkage is not in the set (Story
407
- * #5313 — the close writes it back instead). This is the one place the
408
- * failure set is defined and both the summary tag and the exit code read it.
298
+ * recorded row the tree no longer backs, **in an {@link ENFORCED_TIERS} tier
299
+ * only** (Story #5340). Shrinkage is not in the set (Story #5313 — the close
300
+ * writes it back instead). This is the one place the failure set is defined
301
+ * and both the summary tag and the exit code read it.
409
302
  *
410
303
  * @param {ReturnType<typeof diffBudget>} diff
411
304
  * @returns {number}
412
305
  */
413
306
  export function budgetFailureCount(diff) {
414
- return (diff?.grown?.length ?? 0) + (diff?.absent?.length ?? 0);
307
+ const grown = (diff?.grown ?? []).filter(isEnforced).length;
308
+ const absent = (diff?.absent ?? []).filter(isEnforced).length;
309
+ return grown + absent;
415
310
  }
416
311
 
417
312
  /**
418
- * Render the human-readable diff. `+` lines are tiers that grew beyond
419
- * tolerance; `-` lines are tiers that shrank below their recorded total
313
+ * Render the human-readable diff. `+` lines are enforced tiers that grew
314
+ * beyond tolerance; `-` lines are tiers that shrank below their recorded total
420
315
  * (informational — the close writes the lower total back) or rows naming a
421
- * path the tree no longer carries (a gate failure). A one-line summary
422
- * always follows.
316
+ * path the tree no longer carries. Drift in a report-only tier is prefixed
317
+ * with `~` and says so on the line, so a reader never has to cross-reference
318
+ * {@link ENFORCED_TIERS} to know whether it broke the build. A one-line
319
+ * summary always follows.
423
320
  *
424
321
  * @param {ReturnType<typeof diffBudget>} diff
425
322
  * @returns {string}
426
323
  */
324
+ const REPORT_ONLY_NOTE = ' — reported, never gated';
325
+
326
+ /**
327
+ * Marker prefix and trailing note for one diff row. An enforced tier keeps the
328
+ * caller's `+` / `-` marker and adds no note; a report-only tier is prefixed
329
+ * with `~` and says so inline, so a reader never has to cross-reference
330
+ * {@link ENFORCED_TIERS} to know whether the line broke the build.
331
+ *
332
+ * @param {{ tier: string }} row
333
+ * @param {string} enforcedPrefix
334
+ * @returns {{ prefix: string, note: string }}
335
+ */
336
+ function gateMarks(row, enforcedPrefix) {
337
+ return isEnforced(row)
338
+ ? { prefix: enforcedPrefix, note: '' }
339
+ : { prefix: '~', note: REPORT_ONLY_NOTE };
340
+ }
341
+
427
342
  export function renderDiff(diff) {
428
343
  const lines = [];
429
344
  for (const g of diff.grown) {
345
+ const { prefix, note } = gateMarks(g, '+');
430
346
  lines.push(
431
- `+ ${g.tier}: ${g.current} bytes exceeds budget ${g.baseline} + tolerance ${g.tolerance} (delta +${g.delta})`,
347
+ `${prefix} ${g.tier}: ${g.current} bytes exceeds budget ${g.baseline} + tolerance ${g.tolerance} (delta +${g.delta})${note}`,
432
348
  );
433
349
  }
434
350
  for (const s of diff.shrunk) {
@@ -437,8 +353,9 @@ export function renderDiff(diff) {
437
353
  );
438
354
  }
439
355
  for (const a of diff.absent ?? []) {
356
+ const { prefix, note } = gateMarks(a, '-');
440
357
  lines.push(
441
- `- ${a.tier}: recorded row ${a.path} names a path the measured tier no longer contains — refresh baselines/context-budget.json`,
358
+ `${prefix} ${a.tier}: recorded row ${a.path} names a path the measured tier no longer contains — refresh baselines/context-budget.json${note}`,
442
359
  );
443
360
  }
444
361
  const tag = budgetFailureCount(diff) > 0 ? '(gate fail)' : '(ok)';
@@ -467,6 +384,23 @@ export function renderReachable(tierMap, baseline) {
467
384
  return ` workflow reachable closure: ${current} bytes across ${entries} entry points${against} — drift signal, never gated`;
468
385
  }
469
386
 
387
+ /**
388
+ * Render the role-scoped agent-boot line — a pure size report since Story
389
+ * #5340 removed the per-file ceiling. It names the largest boot context
390
+ * because that is the number an author sizing a role-def edit wants, and the
391
+ * total because that is what the whole role surface costs. Returns `''` when
392
+ * the tree carries no role defs.
393
+ *
394
+ * @param {{ tiers?: { agentBoot?: Array<{ path: string, bytes: number }> } }} tierMap
395
+ * @returns {string}
396
+ */
397
+ function renderAgentBoot(tierMap) {
398
+ const files = tierMap?.tiers?.agentBoot ?? [];
399
+ if (files.length === 0) return '';
400
+ const largest = files.reduce((a, b) => (b.bytes > a.bytes ? b : a));
401
+ return ` agentBoot: ${tierTotalBytes(files)} bytes across ${files.length} role defs, largest ${largest.path} at ${largest.bytes} — reported, never gated`;
402
+ }
403
+
470
404
  /**
471
405
  * Top-level CLI entry. Exported so tests can drive the full pipeline against a
472
406
  * tmpdir fixture with an injected config and sinks.
@@ -479,7 +413,8 @@ export function renderReachable(tierMap, baseline) {
479
413
  * stderr?: { write: (s: string) => void },
480
414
  * }} [opts]
481
415
  * @returns {Promise<number>} 0 = clean / within tolerance / shrink-only / no-op;
482
- * 1 = a gated tier grew beyond tolerance or a recorded row is unbacked
416
+ * 1 = the always-loaded tier grew beyond tolerance or one of its recorded
417
+ * rows is unbacked
483
418
  */
484
419
  /**
485
420
  * Write a fresh budget, preserving the recorded tolerance so `--update` never
@@ -527,7 +462,7 @@ function reportMissingBaseline({
527
462
  }) {
528
463
  if (json) {
529
464
  stdout.write(
530
- `${JSON.stringify({ kind: 'context-budget-report', baselinePath: resolvedBaselinePath, tiers: tierMap.tiers, grown: [], shrunk: [], absent: [], skipped: GATED_TIERS, exitCode: 0, noBaseline: true }, null, 2)}\n`,
465
+ `${JSON.stringify({ kind: 'context-budget-report', baselinePath: resolvedBaselinePath, tiers: tierMap.tiers, grown: [], shrunk: [], absent: [], skipped: MEASURED_TIERS, exitCode: 0, noBaseline: true }, null, 2)}\n`,
531
466
  );
532
467
  } else {
533
468
  stderr.write(
@@ -543,23 +478,11 @@ function reportMissingBaseline({
543
478
  * failed or drift apart in which fields they surface.
544
479
  *
545
480
  * @param {{ tierMap: object, baseline: object }} params
546
- * @returns {{ diff: object, ceiling: number, bootOverflow: object[], bootDrift: object[], permissiveDrift: object[], exitCode: 0 | 1 }}
481
+ * @returns {{ diff: object, exitCode: 0 | 1 }}
547
482
  */
548
483
  function evaluateBudget({ tierMap, baseline }) {
549
484
  const diff = diffBudget(tierMap, baseline);
550
- const ceiling = Number.isFinite(baseline?.agentBoot?.ceilingBytes)
551
- ? baseline.agentBoot.ceilingBytes
552
- : AGENT_BOOT_CEILING_BYTES;
553
- const bootOverflow = agentBootOverflow(tierMap, ceiling);
554
- const bootDrift = agentBootDrift(tierMap, baseline, ceiling);
555
- const permissiveDrift = bootDrift.filter((d) => d.direction === 'permissive');
556
- const exitCode =
557
- budgetFailureCount(diff) > 0 ||
558
- bootOverflow.length > 0 ||
559
- permissiveDrift.length > 0
560
- ? 1
561
- : 0;
562
- return { diff, ceiling, bootOverflow, bootDrift, permissiveDrift, exitCode };
485
+ return { diff, exitCode: budgetFailureCount(diff) > 0 ? 1 : 0 };
563
486
  }
564
487
 
565
488
  /**
@@ -573,7 +496,7 @@ function renderJsonReport({
573
496
  report,
574
497
  stdout,
575
498
  }) {
576
- const { diff, ceiling, bootOverflow, bootDrift, exitCode } = report;
499
+ const { diff, exitCode } = report;
577
500
  const envelope = {
578
501
  kind: 'context-budget-report',
579
502
  baselinePath: resolvedBaselinePath,
@@ -581,15 +504,14 @@ function renderJsonReport({
581
504
  ? baseline.toleranceBytes
582
505
  : 0,
583
506
  current: Object.fromEntries(
584
- GATED_TIERS.map((t) => [t, tierTotalBytes(tierMap.tiers[t] ?? [])]),
507
+ MEASURED_TIERS.map((t) => [t, tierTotalBytes(tierMap.tiers[t] ?? [])]),
585
508
  ),
586
509
  grown: diff.grown,
587
510
  shrunk: diff.shrunk,
588
511
  absent: diff.absent,
589
512
  skipped: diff.skipped,
590
- agentBootCeilingBytes: ceiling,
591
- agentBootOverflow: bootOverflow,
592
- agentBootDrift: bootDrift,
513
+ enforcedTiers: ENFORCED_TIERS,
514
+ agentBoot: tierMap.tiers?.agentBoot ?? [],
593
515
  workflowReachableBytes: tierMap.workflowClosure?.reachableTotalBytes ?? 0,
594
516
  exitCode,
595
517
  };
@@ -598,32 +520,23 @@ function renderJsonReport({
598
520
 
599
521
  /**
600
522
  * Each failing condition gets its own remediation line: they are fixed
601
- * differently (trim a role def vs refresh the budget), so a single generic
602
- * message would leave the author guessing which applies.
523
+ * differently, so a single generic message would leave the author guessing
524
+ * which applies. Only {@link ENFORCED_TIERS} drift speaks here — the report-
525
+ * only lines are already marked `~` in the preview above.
603
526
  *
604
527
  * @param {object} params
605
528
  * @returns {void}
606
529
  */
607
530
  function renderFailureDiagnostics({ report, stderr }) {
608
- const { diff, ceiling, bootOverflow, permissiveDrift } = report;
609
- if (permissiveDrift.length > 0) {
610
- stderr.write(
611
- `[context-budget] ❌ a recorded agentBoot row understates the file it describes, so it overstates the headroom an author would size an edit against — refresh it with \`node .agents/scripts/check-context-budget.js --update\` (the ceiling is unchanged)\n`,
612
- );
613
- }
614
- if (bootOverflow.length > 0) {
531
+ const { diff } = report;
532
+ if (diff.grown.some(isEnforced)) {
615
533
  stderr.write(
616
- `[context-budget] ❌ a role-agent boot context exceeds the ${ceiling}-byte per-agent ceiling — trim the role def (the ceiling is a hard cap, not a starve target)\n`,
534
+ `[context-budget] ❌ the always-loaded documentation tier grew beyond tolerance — every session and every subagent spawn re-pays it. Refresh the budget consciously with \`node .agents/scripts/check-context-budget.js --update\` once the growth is intentional\n`,
617
535
  );
618
536
  }
619
- if (diff.grown.length > 0) {
537
+ if (diff.absent.some(isEnforced)) {
620
538
  stderr.write(
621
- `[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`,
622
- );
623
- }
624
- if (diff.absent.length > 0) {
625
- stderr.write(
626
- `[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`,
539
+ `[context-budget] ❌ a recorded always-loaded 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`,
627
540
  );
628
541
  }
629
542
  }
@@ -632,18 +545,25 @@ function renderFailureDiagnostics({ report, stderr }) {
632
545
  * @param {object} params
633
546
  * @returns {void}
634
547
  */
548
+ /**
549
+ * The optional closure lines, in print order, with the empty ones dropped.
550
+ * Both renderers return `''` when they have nothing to say, so filtering here
551
+ * keeps {@link renderTextReport} free of one branch per optional line.
552
+ *
553
+ * @param {{ tierMap: object, baseline: object | null }} params
554
+ * @returns {string[]}
555
+ */
556
+ function optionalReportLines({ tierMap, baseline }) {
557
+ return [renderReachable(tierMap, baseline), renderAgentBoot(tierMap)].filter(
558
+ Boolean,
559
+ );
560
+ }
561
+
635
562
  function renderTextReport({ tierMap, baseline, report, stdout, stderr }) {
636
- const { diff, bootOverflow, bootDrift, exitCode } = report;
563
+ const { diff, exitCode } = report;
637
564
  stdout.write(`\n--- context-budget preview ---\n`);
638
565
  stdout.write(`${renderDiff(diff)}\n`);
639
- const reachable = renderReachable(tierMap, baseline);
640
- if (reachable) stdout.write(`${reachable}\n`);
641
- for (const o of bootOverflow) {
642
- stdout.write(
643
- `+ agentBoot: ${o.path} is ${o.bytes} bytes, over the ${o.ceiling}-byte per-agent ceiling\n`,
644
- );
645
- }
646
- for (const line of renderBootDrift(bootDrift)) {
566
+ for (const line of optionalReportLines({ tierMap, baseline })) {
647
567
  stdout.write(`${line}\n`);
648
568
  }
649
569
  if (exitCode === 1) renderFailureDiagnostics({ report, stderr });
@@ -69,11 +69,13 @@
69
69
  * that same reader, in that same document. An allowlist elsewhere in the repo
70
70
  * would leave the file itself still lying.
71
71
  *
72
- * Story #4938 left the tree with exactly one: `friction-event.schema.json` was
73
- * deleted outright (its shape preserved field-for-field in
72
+ * Story #4938 left the tree with two: `friction-event.schema.json` was deleted
73
+ * outright (its shape preserved field-for-field in
74
74
  * `docs/archive/data-dictionary-2026-08.md`), and
75
75
  * `model-attribution.schema.json` — a documented SSOT with a hand-rolled
76
- * validator behind it — declared the marker.
76
+ * validator behind it — declared the marker. Story #5367 deleted that one too,
77
+ * with the module whose validator was the gate, so the marker is now a
78
+ * facility with no current user rather than a practice with a live example.
77
79
  *
78
80
  * The gate answers "is anything compiling this?", not "is what compiles it
79
81
  * faithful to it?". A schema whose hand-rolled mirror has silently drifted