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.
- package/.agents/README.md +17 -12
- package/.agents/agents/acceptance-critic.md +24 -43
- package/.agents/agents/story-worker.md +18 -19
- package/.agents/docs/SDLC.md +12 -13
- package/.agents/docs/agentrc-reference.json +1 -2
- package/.agents/docs/configuration.md +29 -46
- package/.agents/docs/quality-gates.md +9 -5
- package/.agents/docs/workflows.md +1 -1
- package/.agents/instructions.md +5 -7
- package/.agents/rules/ci-remediation.md +41 -8
- package/.agents/rules/known-tooling-behavior.md +65 -15
- package/.agents/runtime-deps.json +7 -2
- package/.agents/schemas/acceptance-eval-verdict.schema.json +1 -1
- package/.agents/schemas/agentrc.schema.json +6 -11
- package/.agents/schemas/crap-baseline.schema.json +1 -1
- package/.agents/schemas/crap-report.schema.json +1 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +3 -3
- package/.agents/scripts/README.md +11 -1
- package/.agents/scripts/acceptance-eval.js +25 -27
- package/.agents/scripts/ceremony-derive.js +15 -10
- package/.agents/scripts/check-context-budget.js +148 -228
- package/.agents/scripts/check-schema-references.js +5 -3
- package/.agents/scripts/check-workflow-citations.js +33 -147
- package/.agents/scripts/coverage-capture.js +7 -4
- package/.agents/scripts/deliver-light.js +41 -100
- package/.agents/scripts/deliver-run.js +631 -0
- package/.agents/scripts/file-ci-gap.js +59 -11
- package/.agents/scripts/install-matrix-assert.js +48 -3
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +51 -33
- package/.agents/scripts/lib/baselines/crap-preview-incremental.js +6 -2
- package/.agents/scripts/lib/baselines/kinds/_crap-read.js +0 -8
- package/.agents/scripts/lib/baselines/kinds/crap.js +35 -18
- package/.agents/scripts/lib/changed-files.js +30 -0
- package/.agents/scripts/lib/config/delivery-routing.js +5 -4
- package/.agents/scripts/lib/config/explain.js +1 -3
- package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +1 -1
- package/.agents/scripts/lib/config-resolver.js +1 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -21
- package/.agents/scripts/lib/coverage-capture-fullscope.js +10 -2
- package/.agents/scripts/lib/coverage-capture-incremental.js +3 -2
- package/.agents/scripts/lib/coverage-capture-usage.js +4 -1
- package/.agents/scripts/lib/crap-engine.js +2 -2
- package/.agents/scripts/lib/crap-utils.js +21 -5
- package/.agents/scripts/lib/doc-tiers.js +4 -2
- package/.agents/scripts/lib/escomplex-ast-compat.js +39 -17
- package/.agents/scripts/lib/escomplex-kernel.js +298 -0
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +7 -6
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +7 -5
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/gh-exec.js +160 -0
- package/.agents/scripts/lib/maintainability-engine.js +3 -3
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +74 -132
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +123 -12
- package/.agents/scripts/lib/orchestration/complexity-gate.js +180 -352
- package/.agents/scripts/lib/orchestration/light-suitability.js +71 -136
- package/.agents/scripts/lib/orchestration/plan-context.js +44 -50
- package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +8 -6
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +104 -119
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +41 -25
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +11 -11
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -29
- package/.agents/scripts/lib/orchestration/plan-persist/wave-collision-gate.js +107 -0
- package/.agents/scripts/lib/orchestration/review-depth.js +14 -11
- package/.agents/scripts/lib/orchestration/run-epilogue.js +260 -182
- package/.agents/scripts/lib/orchestration/run-scoped-config.js +63 -99
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +3 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/graphql-preflight.js +137 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +105 -18
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -3
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +156 -39
- package/.agents/scripts/lib/orchestration/story-init-envelope.js +71 -0
- package/.agents/scripts/lib/orchestration/task-body-validator.js +8 -17
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +25 -209
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +8 -5
- package/.agents/scripts/lib/orchestration/ticket-validator.js +44 -183
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +14 -25
- package/.agents/scripts/lib/runtime-deps/dep-resolution.js +155 -0
- package/.agents/scripts/lib/runtime-deps/ensure-installed.js +44 -9
- package/.agents/scripts/lib/runtime-deps/parser-major.js +110 -0
- package/.agents/scripts/lib/runtime-deps/preflight.js +6 -25
- package/.agents/scripts/lib/runtime-deps/scan-imports.js +46 -1
- package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
- package/.agents/scripts/lib/story-body/body-format-lints.js +58 -12
- package/.agents/scripts/lib/story-body/story-body.js +83 -29
- package/.agents/scripts/lib/templates/decomposer-prompts.js +28 -33
- package/.agents/scripts/lib/wave-runner/live-probe.js +31 -5
- package/.agents/scripts/merge-baseline.js +4 -5
- package/.agents/scripts/plan-context.js +117 -28
- package/.agents/scripts/plan-persist.js +79 -39
- package/.agents/scripts/plan-run-epilogue.js +11 -8
- package/.agents/scripts/pr-watch-with-update.js +9 -2
- package/.agents/scripts/run-verify.js +13 -6
- package/.agents/scripts/single-story-init.js +7 -57
- package/.agents/scripts/stories-wave-tick.js +160 -26
- package/.agents/skills/core/gates-and-baselines/reference.md +0 -1
- package/.agents/skills/skills.index.json +2 -12
- package/.agents/skills/stack/qa/playwright/SKILL.md +26 -0
- package/.agents/workflows/audit-to-stories.md +14 -11
- package/.agents/workflows/helpers/acceptance-self-eval.md +84 -157
- package/.agents/workflows/helpers/code-review.md +4 -2
- package/.agents/workflows/helpers/deliver-digest.md +31 -24
- package/.agents/workflows/helpers/deliver-light.md +92 -101
- package/.agents/workflows/helpers/deliver-reference.md +116 -100
- package/.agents/workflows/helpers/deliver-story-reference.md +58 -124
- package/.agents/workflows/helpers/deliver-story.md +17 -18
- package/.agents/workflows/helpers/plan-reference.md +82 -60
- package/.agents/workflows/mandrel-deliver.md +47 -31
- package/.agents/workflows/mandrel-plan.md +32 -30
- package/.agents/workflows/mandrel-update.md +36 -21
- package/README.md +3 -3
- package/docs/CHANGELOG.md +43 -0
- package/lib/cli/registry.js +45 -25
- package/lib/cli/update.js +376 -17
- package/lib/migrations/index.js +2 -0
- package/lib/migrations/steps/2.60.0-retire-audit-results-autofile.js +40 -0
- package/package.json +8 -2
- package/.agents/schemas/model-attribution.schema.json +0 -53
- package/.agents/scripts/lib/orchestration/model-attribution.js +0 -418
- package/.agents/scripts/lib/orchestration/split-policy-validator.js +0 -188
- package/.agents/scripts/lib/orchestration/story-plan-state.js +0 -33
- package/.agents/scripts/lib/orchestration/structured-comment-parser.js +0 -67
- package/.agents/scripts/lib/templates/spec-author-prompts.js +0 -76
- 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
|
|
9
|
-
*
|
|
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
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
* -
|
|
49
|
-
* baseline.toleranceBytes` → exit 1, naming the tier and its
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
|
62
|
-
* exit 1. The row describes a file that has been deleted or
|
|
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
|
|
89
|
-
* and `workflowOnDemand` are resolved by the tier
|
|
90
|
-
*
|
|
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
|
|
88
|
+
export const MEASURED_TIERS = ['alwaysLoaded', 'mandatoryRead', 'workflow'];
|
|
94
89
|
|
|
95
90
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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
|
|
97
|
+
export const ENFORCED_TIERS = ['alwaysLoaded'];
|
|
101
98
|
|
|
102
99
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
346
|
-
* is skipped. `grown` and `absent` entries
|
|
347
|
-
*
|
|
348
|
-
*
|
|
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
|
|
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
|
|
407
|
-
* #
|
|
408
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
422
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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,
|
|
481
|
+
* @returns {{ diff: object, exitCode: 0 | 1 }}
|
|
547
482
|
*/
|
|
548
483
|
function evaluateBudget({ tierMap, baseline }) {
|
|
549
484
|
const diff = diffBudget(tierMap, baseline);
|
|
550
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
591
|
-
|
|
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
|
|
602
|
-
*
|
|
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
|
|
609
|
-
if (
|
|
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] ❌
|
|
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.
|
|
537
|
+
if (diff.absent.some(isEnforced)) {
|
|
620
538
|
stderr.write(
|
|
621
|
-
`[context-budget] ❌ a
|
|
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,
|
|
563
|
+
const { diff, exitCode } = report;
|
|
637
564
|
stdout.write(`\n--- context-budget preview ---\n`);
|
|
638
565
|
stdout.write(`${renderDiff(diff)}\n`);
|
|
639
|
-
const
|
|
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
|
|
73
|
-
*
|
|
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
|