mandrel 2.20.0 → 2.22.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 (46) hide show
  1. package/.agents/README.md +1 -1
  2. package/.agents/agents/story-worker.md +15 -0
  3. package/.agents/instructions.md +14 -17
  4. package/.agents/rules/git-conventions.md +1 -1
  5. package/.agents/rules/known-tooling-behavior.md +114 -0
  6. package/.agents/scripts/check-context-budget.js +134 -2
  7. package/.agents/scripts/deliver-light.js +72 -8
  8. package/.agents/scripts/lib/audit-suite/selector.js +275 -162
  9. package/.agents/scripts/lib/config/temp-paths.js +113 -7
  10. package/.agents/scripts/lib/feedback-loop/graduator-core.js +604 -57
  11. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +72 -21
  12. package/.agents/scripts/lib/label-constants.js +12 -1
  13. package/.agents/scripts/lib/observability/runtime-friction.js +13 -1
  14. package/.agents/scripts/lib/observability/signals-writer.js +133 -14
  15. package/.agents/scripts/lib/observability/source-classifier.js +131 -1
  16. package/.agents/scripts/lib/orchestration/code-review.js +12 -0
  17. package/.agents/scripts/lib/orchestration/complexity-gate.js +119 -52
  18. package/.agents/scripts/lib/orchestration/deliver-recover.js +253 -6
  19. package/.agents/scripts/lib/orchestration/light-suitability.js +194 -11
  20. package/.agents/scripts/lib/orchestration/resolve-stories.js +17 -14
  21. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  22. package/.agents/scripts/lib/orchestration/review-providers/degraded-gates.js +222 -0
  23. package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +18 -3
  24. package/.agents/scripts/lib/orchestration/review-providers/native.js +82 -126
  25. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +10 -0
  26. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +300 -0
  27. package/.agents/scripts/lib/orchestration/run-epilogue.js +51 -1
  28. package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +11 -1
  29. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +18 -8
  30. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-outcome.js +66 -0
  31. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +1 -1
  32. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +5 -1
  33. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +117 -4
  34. package/.agents/scripts/lib/orchestration/story-follow-ups.js +305 -10
  35. package/.agents/scripts/lib/story-body/story-body.js +248 -174
  36. package/.agents/scripts/lib/temp-retention.js +23 -8
  37. package/.agents/scripts/resolve-stories.js +52 -33
  38. package/.agents/scripts/single-story-confirm-merge.js +6 -8
  39. package/.agents/workflows/helpers/deliver-digest.md +8 -6
  40. package/.agents/workflows/helpers/deliver-light.md +45 -5
  41. package/.agents/workflows/helpers/deliver-reference.md +15 -12
  42. package/.agents/workflows/helpers/deliver-story-reference.md +56 -21
  43. package/.agents/workflows/helpers/deliver-story.md +8 -5
  44. package/.agents/workflows/helpers/plan-reference.md +5 -4
  45. package/docs/CHANGELOG.md +28 -0
  46. package/package.json +1 -1
package/.agents/README.md CHANGED
@@ -325,7 +325,7 @@ persists Stories with inline `acceptance[]` / `verify[]` and a folded
325
325
  `main`. There is no `type::epic` / `type::task` label, Epic issue form, or
326
326
  `epic/<id>` integration branch; a ticket carrying an `Epic: #N` footer is
327
327
  refused by `/deliver`. The execution-model contract is owned by
328
- [`instructions.md` § 5.D](instructions.md) and [`docs/SDLC.md`](docs/SDLC.md).
328
+ [`instructions.md` § 5.B](instructions.md) and [`docs/SDLC.md`](docs/SDLC.md).
329
329
 
330
330
  ---
331
331
 
@@ -102,6 +102,11 @@ close pipeline is the authoritative gate. The acceptance self-eval loop may
102
102
  share `lint` / `typecheck` evidence with close via `evidence-gate.js`;
103
103
  never stamp coverage / CRAP fresh that way.
104
104
 
105
+ Before trusting a gate's output — or diagnosing a red one — read
106
+ [`known-tooling-behavior.md`](../rules/known-tooling-behavior.md): measured
107
+ cases where a command prints what it does not mean (lint exits 1 under a
108
+ `0 error(s)` summary; a green `check-baselines.js` is not `baselines`).
109
+
105
110
  ## Acceptance self-eval before close (MUST)
106
111
 
107
112
  After the implementation commits land and **before** flipping to `closing`,
@@ -134,6 +139,16 @@ outside the worktree / branch / PR path — or committing it to local `main`
134
139
  — is expressly **forbidden**; the close pipeline's push
135
140
  (`single-story-close.js`) is the only sanctioned landing.
136
141
 
142
+ ## Hold the turn until the envelope arrives (MUST)
143
+
144
+ Run close in the **foreground** and wait for it. Never background it,
145
+ never delegate it to a child, and never end your turn while it is still
146
+ running — "close is running" is not a return value. Ending early strands
147
+ the envelope in a turn nobody reads and costs your caller a recovery
148
+ cycle plus a full resume of you. Close does persist a copy to
149
+ `temp/orchestration/story-deliver-terminal-<id>.json`; that is your
150
+ caller's fallback, not your licence to return before the verdict.
151
+
137
152
  ## Return schema
138
153
 
139
154
  The return contract is
@@ -14,10 +14,10 @@ and is read when the task engages it.
14
14
  ### A. Role Framing
15
15
 
16
16
  No persona packs, no `persona::*` labels — constraints come from this
17
- file, the rules, and skills. Role-scoped spawn contexts live under
17
+ file, the rules, and skills; "act as [role]" means apply the matching
18
+ skill or workflow guidance. Role-scoped spawn contexts live under
18
19
  `.agents/agents/` (`delivery.routing.roleScopedAgents`); `qa.personas`
19
- are a separate fixture concept. "Act as [role]" = apply the matching
20
- skill / workflow guidance.
20
+ is a separate fixture concept.
21
21
 
22
22
  ### B. Skill Activation
23
23
 
@@ -32,11 +32,11 @@ Unsure? Match against the `description`s in
32
32
 
33
33
  ### C. Proactive Documentation
34
34
 
35
- For any code involving third-party libraries, fetch the latest official
36
- docs **before** writing code via the host's best live-documentation
37
- mechanism (docs MCP server, IDE lookup) do not ask permission.
38
- Fallbacks: (1) in-repo docs and the package's bundled README/CHANGELOG,
39
- (2) web fetch/search. Note the channel you used.
35
+ Before writing against a third-party API you are unsure of — or one
36
+ that moves fast enough that recall may be stale — fetch the current
37
+ official docs via the host's best live-documentation mechanism (docs
38
+ MCP server, IDE lookup); do not ask permission. Fallbacks: (1) in-repo
39
+ docs and the package's bundled README/CHANGELOG, (2) web fetch/search.
40
40
 
41
41
  ### D. Error Handling & Degradation
42
42
 
@@ -64,7 +64,8 @@ read **before** the matching work (each opens with a one-line "applies
64
64
  when…" scope header): `git-conventions-reference.md`,
65
65
  `shell-conventions.md`, `testing-standards.md`,
66
66
  `orchestration-error-handling.md` (scripts under `.agents/scripts/**`),
67
- `ci-remediation.md`, `api-conventions.md`, `gherkin-standards.md`,
67
+ `ci-remediation.md`, `known-tooling-behavior.md`,
68
+ `api-conventions.md`, `gherkin-standards.md`,
68
69
  `changelog-style.md`, `test-seams.md`. Read when unsure (on-demand
69
70
  loading does not lower a rule's authority — § 1.K).
70
71
 
@@ -138,8 +139,6 @@ sizing) **fail closed** naming what to trim:
138
139
  3. **Artifacts over Chat.** Write test/build/debug output to log
139
140
  files, not into chat.
140
141
  4. **Idempotency.** Scripts must be safe to run repeatedly.
141
- 5. **Security First.** Never hardcode secrets; use environment variables
142
- and secret scanning.
143
142
 
144
143
  ---
145
144
 
@@ -151,11 +150,9 @@ sizing) **fail closed** naming what to trim:
151
150
  spawn only when the work justifies replicating context. One objective
152
151
  per subagent; depth compounds the cost (every nested level re-pays).
153
152
  - **Anti-Laziness / No Dead Code.** NEVER use placeholder comments like
154
- `// ... existing code ...` output the ENTIRE file or complete
155
- function; remove unused imports, commented-out code, and dead branches
153
+ `// ... existing code ...`; every edit must leave complete, runnable
154
+ code. Remove unused imports, commented-out code, and dead branches
156
155
  before finalizing.
157
- - **Lint Compliance.** Adhere strictly to project linters and
158
- formatters.
159
156
  - **Verification.** Include explicit verification steps in every plan.
160
157
 
161
158
  ---
@@ -167,7 +164,7 @@ reference: `story-<storyId>` branches seeded by `single-story-init.js`,
167
164
  every Story reaching `main` via its own PR
168
165
  (`helpers/deliver-story` / `single-story-close.js`).
169
166
 
170
- ### B. Status Tracking & Commit Standards
167
+ ### A. Status Tracking & Commit Standards
171
168
 
172
169
  State mutations are GitHub labels (`agent::ready`, `agent::executing`,
173
170
  `agent::done`) via
@@ -175,7 +172,7 @@ State mutations are GitHub labels (`agent::ready`, `agent::executing`,
175
172
  Do NOT manually update issue descriptions or status fields unless
176
173
  prompted.
177
174
 
178
- ### D. Ticket hierarchy (Story-only)
175
+ ### B. Ticket hierarchy (Story-only)
179
176
 
180
177
  The v2 ticket model is Story-only: `acceptance[]` / `verify[]` live
181
178
  inline plus the folded Tech Spec in `## Spec` (over-budget Specs fail
@@ -19,7 +19,7 @@ commit on that branch only. Close opens a PR against `main` (squash +
19
19
  required checks). No `epic/<id>` integration branch, no `--no-ff` wave
20
20
  merge, no child tickets: commits land on `story-<storyId>` directly, the
21
21
  subject referencing the Story via `(refs #<storyId>)` — see
22
- [`.agents/instructions.md` § 5.D](../instructions.md).
22
+ [`.agents/instructions.md` § 5.B](../instructions.md).
23
23
 
24
24
  ## Conventional Commits
25
25
 
@@ -0,0 +1,114 @@
1
+ # Known Tooling Behavior
2
+
3
+ This rule applies when you are about to trust the output of a gate, a
4
+ baseline ratchet, or a local stand-in for a CI check — before pushing,
5
+ before declaring a check green, and before diagnosing a red one.
6
+
7
+ Each entry below records a **measured** behavior of this repository's own
8
+ tooling: a place where a command's visible output does not mean what it
9
+ looks like it means. Entries exist because each one has already cost a
10
+ delivery cycle.
11
+
12
+ ## The entry bar
13
+
14
+ - An entry states behavior that was **measured against this repo**, never
15
+ recalled and never assumed.
16
+ - Every entry carries a **reproduction command** that was actually run. If
17
+ the command stops reproducing the behavior, **delete the entry** — do not
18
+ annotate it. A stale entry is worse than a missing one, because it is
19
+ trusted.
20
+ - Entries describe the observable behavior and the safe move. They never
21
+ describe a way to bypass a gate, and recording a behavior here is not a
22
+ decision to keep it.
23
+
24
+ ## 1. `npm run lint` prints `Summary: 0 error(s)` and can still exit 1
25
+
26
+ **Behavior.** `npm run lint` is `run-lint.js`, which spawns six tools
27
+ concurrently with inherited stdio and exits with the first non-zero code.
28
+ `Summary: 0 error(s)` is **markdownlint-cli2's own verdict**, not the
29
+ aggregate — it is printed whether or not Biome, the lifecycle lint, the
30
+ workflow-CLI lint, the label-vocabulary lint, or the arch-cycle ratchet
31
+ failed. Because the tools run in parallel, that line can land anywhere in
32
+ the output, including last, so the tail of a failing run reads green.
33
+ Biome's format diagnostics are `error`-severity, so a file that only needs
34
+ reformatting fails the check while emitting no lint rule name at all.
35
+
36
+ **Reproduce.**
37
+
38
+ ```bash
39
+ # a format-only diff is an error, not a warning
40
+ printf 'export const x = {a:1, b:2};\n' | npx biome check --stdin-file-path=probe.js
41
+ echo "biome exit=$?" # 1 — "The contents aren't fixed"
42
+
43
+ # and markdownlint's Summary line is unconditional
44
+ npm run lint 2>&1 | grep -c 'Summary: 0 error(s)'
45
+ ```
46
+
47
+ **Safe move.** Trust the exit code, never the last line. When `npm run lint`
48
+ exits non-zero and you cannot see why, re-run `npx biome ci .` on its own —
49
+ `.agents/scripts/run-lint.js` lists every tool it fans out to. Fix formatting
50
+ with `npm run format`; never reach for `--no-verify`.
51
+
52
+ ## 2. `check-baselines.js` is not the whole `baselines` check
53
+
54
+ **Behavior.** `.agentrc.json` declares the `baselines` required check as
55
+ `node .agents/scripts/check-baselines.js`, but that script only runs the
56
+ gates configured under `delivery.quality.gates` — currently **crap,
57
+ maintainability, and duplication**. CI's job named `baselines` in
58
+ `.github/workflows/ci.yml` runs that script **and then five standalone
59
+ ratchets** the script knows nothing about, so a locally green
60
+ `check-baselines.js` is not evidence that the `baselines` check will pass.
61
+
62
+ | Ratchet CI's `baselines` job runs | Covered by `check-baselines.js` | Covered by `npm run lint` | Covered by `npm run verify` |
63
+ | --- | --- | --- | --- |
64
+ | `.agents/scripts/check-arch-cycles.js` | no | **yes** | via `lint` |
65
+ | `.agents/scripts/check-dead-exports.js` | no | no | **yes** |
66
+ | `.agents/scripts/check-dead-exports.js --production` | no | no | **yes** |
67
+ | `.agents/scripts/check-context-budget.js` | no | no | **yes** |
68
+ | `.agents/scripts/check-workflow-citations.js` | no | no | **no** |
69
+
70
+ `check-workflow-citations.js` is currently in **no** local aggregate command
71
+ — it is reachable only as `npm run check:workflow-citations` or a direct
72
+ invocation.
73
+
74
+ **Reproduce.**
75
+
76
+ ```bash
77
+ node .agents/scripts/check-baselines.js --format text # names the 3 gates it ran
78
+ sed -n '/name: baselines/,/windows-smoke/p' .github/workflows/ci.yml | grep 'check-'
79
+ grep "label: '" .agents/scripts/run-verify.js # the 7 steps verify covers
80
+ ```
81
+
82
+ **Safe move.** `npm run verify` is the closest local mirror; run
83
+ `node .agents/scripts/check-workflow-citations.js` alongside it when the
84
+ change touches workflow prose under `.agents/workflows/`. Reproducing only
85
+ the `.agentrc.json` command before a push is a false green.
86
+
87
+ ## 3. The two dead-export passes disagree, and the production pass is silent without `!`
88
+
89
+ **Behavior.** `check-dead-exports.js` runs twice with two separate
90
+ baselines. The default pass treats `tests/**` as knip entry points, so an
91
+ export whose only importer is a test still reads as *used*; the
92
+ `--production` pass discounts test importers and therefore sees a much
93
+ larger surface — `baselines/dead-exports.json` carries 165 rows against
94
+ `baselines/dead-exports-production.json`'s 667. A new export that is only
95
+ imported by its test passes the default pass and fails the production one.
96
+
97
+ The production pass depends entirely on the `!` suffix on the `entry` and
98
+ `project` patterns in `knip.json`: `!` marks a pattern as
99
+ production-relevant. Strip the suffixes and `knip --production` reports
100
+ **zero** export rows and exits clean — a green that means "nothing was
101
+ analyzed", not "nothing is dead".
102
+
103
+ **Reproduce.**
104
+
105
+ ```bash
106
+ node .agents/scripts/check-dead-exports.js # default pass
107
+ node .agents/scripts/check-dead-exports.js --production # strictly larger row set
108
+ grep -c '!"' knip.json # the production markers
109
+ ```
110
+
111
+ **Safe move.** Run both passes before pushing. When an export is genuinely
112
+ test-only, keep it and refresh the production baseline deliberately —
113
+ `.agents/rules/test-seams.md` governs which seams are sanctioned. Never
114
+ remove the `!` suffixes from `knip.json` to quieten the production pass.
@@ -25,6 +25,21 @@
25
25
  * ratchet — each role def is a standalone system prompt a converted spawn boots
26
26
  * on, and adding another role def is legitimate.
27
27
  *
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.
42
+ *
28
43
  * A read-tier that resolves **empty** is skipped silently (the `docsContextFiles`
29
44
  * half skips when unconfigured / its files are absent), so a repo with no
30
45
  * `CLAUDE.md` and no context docs is a clean no-op.
@@ -98,6 +113,100 @@ export function agentBootOverflow(tierMap, ceiling = AGENT_BOOT_CEILING_BYTES) {
98
113
  .map((f) => ({ path: f.path, bytes: f.bytes, ceiling }));
99
114
  }
100
115
 
116
+ /**
117
+ * Classify one agent-boot file against its recorded baseline row (#4830).
118
+ * Returns `null` when the row already agrees with the tree.
119
+ *
120
+ * `permissive` drift is the failure class this gate exists for: the row
121
+ * understates the file (or is missing entirely), so the headroom an author
122
+ * computes from it is larger than the headroom that exists, and the shortfall
123
+ * only surfaces as a ceiling failure *after* the edit is written.
124
+ * `restrictive` drift is the benign mirror — the row overstates the file, so an
125
+ * author under-spends and the ceiling gate is never surprised.
126
+ *
127
+ * @param {{ path: string, bytes: number }} file live file measurement
128
+ * @param {{ bytes?: number, headroomBytes?: number } | undefined} row recorded row
129
+ * @param {number} ceiling
130
+ * @returns {{ path: string, recorded: number|null, actual: number, delta: number,
131
+ * direction: 'permissive'|'restrictive', recordedHeadroom: number|null,
132
+ * headroomBytes: number } | null}
133
+ */
134
+ function classifyBootRow(file, row, ceiling) {
135
+ const actual = file.bytes;
136
+ const headroomBytes = ceiling - actual;
137
+ const recorded = Number.isFinite(row?.bytes) ? row.bytes : null;
138
+ const recordedHeadroom = Number.isFinite(row?.headroomBytes)
139
+ ? row.headroomBytes
140
+ : recorded === null
141
+ ? null
142
+ : ceiling - recorded;
143
+ // A row is in sync only when both the byte count and the headroom it
144
+ // advertises match the tree — a stale headroom misleads on its own.
145
+ if (recorded === actual && recordedHeadroom === headroomBytes) return null;
146
+ const permissive =
147
+ recorded === null ||
148
+ recorded < actual ||
149
+ (recordedHeadroom !== null && recordedHeadroom > headroomBytes);
150
+ return {
151
+ path: file.path,
152
+ recorded,
153
+ actual,
154
+ delta: recorded === null ? actual : actual - recorded,
155
+ direction: permissive ? 'permissive' : 'restrictive',
156
+ recordedHeadroom,
157
+ headroomBytes,
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Compare every recorded `agentBoot` row against the tree it describes.
163
+ *
164
+ * @param {{ tiers: Record<string, Array<{ path: string, bytes: number }>> }} tierMap
165
+ * @param {{ agentBoot?: { ceilingBytes?: number, files?: Array<{ path: string, bytes: number, headroomBytes?: number }> } } | null} baseline
166
+ * @param {number} [ceiling]
167
+ * @returns {Array<ReturnType<typeof classifyBootRow>>} drift rows (empty = in sync)
168
+ */
169
+ export function agentBootDrift(tierMap, baseline, ceiling) {
170
+ const recordedCeiling = Number.isFinite(baseline?.agentBoot?.ceilingBytes)
171
+ ? baseline.agentBoot.ceilingBytes
172
+ : AGENT_BOOT_CEILING_BYTES;
173
+ const effective = Number.isFinite(ceiling) ? ceiling : recordedCeiling;
174
+ const rows = new Map(
175
+ (baseline?.agentBoot?.files ?? []).map((f) => [f.path, f]),
176
+ );
177
+ const drift = [];
178
+ for (const file of tierMap?.tiers?.agentBoot ?? []) {
179
+ if (!Number.isFinite(file?.bytes)) continue;
180
+ const row = classifyBootRow(file, rows.get(file.path), effective);
181
+ if (row) drift.push(row);
182
+ }
183
+ return drift;
184
+ }
185
+
186
+ /**
187
+ * Render the agent-boot drift lines. `+` lines are permissive drift (gate
188
+ * fail); `-` lines are restrictive drift (informational). Each line states the
189
+ * **real** remaining headroom, so the author sizing the next edit reads the
190
+ * true number rather than re-deriving it from a row that just proved stale.
191
+ *
192
+ * @param {Array<ReturnType<typeof classifyBootRow>>} drift
193
+ * @returns {string[]}
194
+ */
195
+ export function renderBootDrift(drift) {
196
+ return drift.map((d) => {
197
+ const marker = d.direction === 'permissive' ? '+' : '-';
198
+ const recorded =
199
+ d.recorded === null
200
+ ? 'has no recorded row'
201
+ : `records ${d.recorded} bytes but the file is ${d.actual}`;
202
+ const note =
203
+ d.direction === 'permissive'
204
+ ? 'the row overstates the headroom an author would size an edit against'
205
+ : 'the row is conservative — refresh at leisure';
206
+ return `${marker} agentBoot drift: ${d.path} ${recorded} — ${note} (real headroom ${d.headroomBytes})`;
207
+ });
208
+ }
209
+
101
210
  /**
102
211
  * Parse argv for `--baseline <path>`, `--root <path>`, `--update`, `--json`.
103
212
  * Exported so unit tests can pin the parser.
@@ -168,7 +277,14 @@ export function buildBaseline(tierMap, toleranceBytes) {
168
277
  // The agent-boot tier is recorded top-level (not under `tiers`) because it is
169
278
  // gated by a per-file ceiling, not the total-byte ratchet the `tiers` entries
170
279
  // use — keeping it out of `tiers` keeps the ratchet diff loop unambiguous.
171
- const agentBootFiles = tierMap.tiers.agentBoot ?? [];
280
+ // Each row carries the headroom it leaves under the ceiling, so an author
281
+ // sizing an edit reads the remaining budget straight off the row (#4830)
282
+ // instead of re-deriving it — and `agentBootDrift` keeps both numbers honest.
283
+ const agentBootFiles = (tierMap.tiers.agentBoot ?? []).map((f) => ({
284
+ path: f.path,
285
+ bytes: f.bytes,
286
+ headroomBytes: AGENT_BOOT_CEILING_BYTES - f.bytes,
287
+ }));
172
288
  return {
173
289
  $schema: 'https://mandrel.dev/baselines/context-budget.schema.json',
174
290
  generatedAt: new Date().toISOString(),
@@ -353,7 +469,14 @@ export async function runCli({
353
469
  ? baseline.agentBoot.ceilingBytes
354
470
  : AGENT_BOOT_CEILING_BYTES;
355
471
  const bootOverflow = agentBootOverflow(tierMap, ceiling);
356
- const exitCode = diff.grown.length > 0 || bootOverflow.length > 0 ? 1 : 0;
472
+ const bootDrift = agentBootDrift(tierMap, baseline, ceiling);
473
+ const permissiveDrift = bootDrift.filter((d) => d.direction === 'permissive');
474
+ const exitCode =
475
+ diff.grown.length > 0 ||
476
+ bootOverflow.length > 0 ||
477
+ permissiveDrift.length > 0
478
+ ? 1
479
+ : 0;
357
480
 
358
481
  if (json) {
359
482
  const envelope = {
@@ -370,6 +493,7 @@ export async function runCli({
370
493
  skipped: diff.skipped,
371
494
  agentBootCeilingBytes: ceiling,
372
495
  agentBootOverflow: bootOverflow,
496
+ agentBootDrift: bootDrift,
373
497
  workflowReachableBytes: tierMap.workflowClosure?.reachableTotalBytes ?? 0,
374
498
  exitCode,
375
499
  };
@@ -384,7 +508,15 @@ export async function runCli({
384
508
  `+ agentBoot: ${o.path} is ${o.bytes} bytes, over the ${o.ceiling}-byte per-agent ceiling\n`,
385
509
  );
386
510
  }
511
+ for (const line of renderBootDrift(bootDrift)) {
512
+ stdout.write(`${line}\n`);
513
+ }
387
514
  if (exitCode === 1) {
515
+ if (permissiveDrift.length > 0) {
516
+ stderr.write(
517
+ `[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`,
518
+ );
519
+ }
388
520
  if (bootOverflow.length > 0) {
389
521
  stderr.write(
390
522
  `[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`,
@@ -24,7 +24,10 @@
24
24
  * `proceed-light` it authors the receipt Story (via the plan-persist
25
25
  * `createStoryIssues` surface) and prints the init/close hand-off. On
26
26
  * over-scope it prints `ask-operator` (attended) or emits an `escalated`
27
- * terminal envelope (`--yes`), never landing silently.
27
+ * terminal envelope (`--yes`), never landing silently. An attended
28
+ * `ask-operator` is answerable **either** way: `--operator-proceed-light`
29
+ * records the operator's proceed answer (Story #4815), which the gate
30
+ * applies only to a coarse size prediction and never to a risk rule.
28
31
  * - **backstop** (`--backstop --story <id>`) — re-check the ACTUAL diff of
29
32
  * the Story branch after implementation; exit non-zero when it exceeds the
30
33
  * light ceilings, so an over-scope diff is blocked rather than landed.
@@ -81,7 +84,7 @@ Usage:
81
84
  deliver-light.js --prompt <text> [--creates csv] [--refactors csv]
82
85
  [--acceptance n] [--kinds csv] [--magnitude m]
83
86
  [--uncertainty u] [--route lite|full] [--reason <text>]
84
- [--amends '#id'] [--yes]
87
+ [--amends '#id'] [--operator-proceed-light <text>] [--yes]
85
88
  deliver-light.js --backstop --story <id>
86
89
 
87
90
  The thin /deliver-light entry point: suitability gate → inline receipt Story →
@@ -105,6 +108,15 @@ Gate options:
105
108
  --route <r> Ledgered model verdict route: lite | full.
106
109
  --reason <text> Recorded reason for a lite verdict (required for lite).
107
110
  --amends <#id> Mark this as an amendment of an existing issue.
111
+ --operator-proceed-light <text>
112
+ Record the operator's "proceed light" answer to an
113
+ ask-operator gate, with their reason. Attended-only:
114
+ refused with --yes. Waives a coarse SIZE prediction
115
+ (change kinds, magnitude, uncertainty, deployable span)
116
+ only — sensitivity, migration span, and an unknown
117
+ footprint stay non-negotiable, the ledgered --route lite
118
+ verdict is still required, and the --backstop pass still
119
+ bounds the actual diff. Recorded in the receipt Story.
108
120
  --yes Unattended: over-scope emits an escalated terminal
109
121
  envelope and ENDS the session (no prompt, no fallback).
110
122
 
@@ -182,11 +194,14 @@ export function synthesizeAcceptance(count) {
182
194
  * uncertainty?: string,
183
195
  * route?: string,
184
196
  * reason?: string,
197
+ * operatorProceedLight?: string,
185
198
  * yes?: boolean,
186
199
  * injectedRules?: object,
187
200
  * }} args `kinds` / `magnitude` / `uncertainty` are the declared effort-and-risk
188
201
  * axes the gate judges (Story #4764); omitting them declares no signal, not a
189
- * small one — an unrecognized bucket fails closed.
202
+ * small one — an unrecognized bucket fails closed. `operatorProceedLight`
203
+ * carries the operator's recorded answer to an `ask-operator` outcome
204
+ * (Story #4815) and is adjudicated inside the gate, never applied here.
190
205
  * @returns {{ action: string, suitability: object, outcome: object }}
191
206
  */
192
207
  export function runLightGate({
@@ -198,6 +213,7 @@ export function runLightGate({
198
213
  uncertainty,
199
214
  route,
200
215
  reason,
216
+ operatorProceedLight,
201
217
  yes = false,
202
218
  injectedRules,
203
219
  } = {}) {
@@ -211,7 +227,11 @@ export function runLightGate({
211
227
  verdict: { route, reason },
212
228
  injectedRules,
213
229
  });
214
- const outcome = resolveLightGateOutcome({ suitability, yes });
230
+ const outcome = resolveLightGateOutcome({
231
+ suitability,
232
+ yes,
233
+ operatorOverride: operatorProceedLight,
234
+ });
215
235
  return { action: outcome.action, suitability, outcome };
216
236
  }
217
237
 
@@ -224,9 +244,11 @@ export function runLightGate({
224
244
  * prompt: string,
225
245
  * changedFiles?: string[],
226
246
  * amends?: string|number|null,
247
+ * override?: object|null,
227
248
  * assembleFn?: typeof assemblePlanStories,
228
249
  * createFn?: typeof createStoryIssues,
229
- * }} args
250
+ * }} args `override` is the applied operator scope override (Story #4815),
251
+ * recorded in the receipt body so the decision is auditable from the ticket.
230
252
  * @returns {Promise<{ storyId: number, url: string|undefined, title: string }>}
231
253
  */
232
254
  export async function createLightReceipt({
@@ -234,10 +256,16 @@ export async function createLightReceipt({
234
256
  prompt,
235
257
  changedFiles = [],
236
258
  amends = null,
259
+ override = null,
237
260
  assembleFn = assemblePlanStories,
238
261
  createFn = createStoryIssues,
239
262
  } = {}) {
240
- const ticket = buildReceiptStoryTicket({ prompt, changedFiles, amends });
263
+ const ticket = buildReceiptStoryTicket({
264
+ prompt,
265
+ changedFiles,
266
+ amends,
267
+ override,
268
+ });
241
269
  const { stories } = assembleFn([ticket]);
242
270
  const { created } = await createFn({ provider, stories });
243
271
  const receipt = created[0];
@@ -291,6 +319,19 @@ export function runDiffBackstop({
291
319
  return checkLightDiffBackstop({ changedFiles: files, injectedRules });
292
320
  }
293
321
 
322
+ /**
323
+ * Was a non-blank `--operator-proceed-light` supplied? The gate core decides
324
+ * whether it *applies*; this only asks whether the operator typed one, so the
325
+ * attended-only refusal can fire before any adjudication.
326
+ *
327
+ * @param {{ 'operator-proceed-light'?: unknown }} values Parsed CLI values.
328
+ * @returns {boolean}
329
+ */
330
+ export function hasOperatorOverride(values = {}) {
331
+ const raw = values['operator-proceed-light'];
332
+ return typeof raw === 'string' && raw.trim() !== '';
333
+ }
334
+
294
335
  /**
295
336
  * Emit a JSON envelope on stdout (the machine surface) so a headless caller can
296
337
  * branch on it. Human-readable log lines stay on stderr.
@@ -341,8 +382,10 @@ async function runBackstopMode(values) {
341
382
  * control flow rather than a claim the envelope makes about itself.
342
383
  * - **`ask-operator`** is unchanged: the plain gate envelope and exit 2. It
343
384
  * is not terminal — the operator has a choice to make, and manufacturing a
344
- * terminal for it would end a session that is supposed to be waiting.
345
- * - **`proceed-light`** authors the receipt Story and prints the hand-off.
385
+ * terminal for it would end a session that is supposed to be waiting. The
386
+ * operator's proceed answer comes back as `--operator-proceed-light`.
387
+ * - **`proceed-light`** authors the receipt Story and prints the hand-off,
388
+ * carrying any applied `override` into both the receipt and the envelope.
346
389
  *
347
390
  * The injectable seams exist so the no-side-effect guarantee is testable
348
391
  * without a network: a test asserts the escalate path never reaches them.
@@ -371,6 +414,17 @@ export async function runGateMode(values, deps = {}) {
371
414
  throw new Error('[deliver-light] --prompt <text> is required for the gate');
372
415
  }
373
416
 
417
+ // Attended-only, enforced loudly (Story #4815). Silently ignoring the flag
418
+ // under --yes would let an automated caller pass it as a hopeful no-op and
419
+ // read the resulting escalation as a bug; a usage error says which of the
420
+ // two the caller has to give up.
421
+ if (values.yes === true && hasOperatorOverride(values)) {
422
+ process.stderr.write(HELP);
423
+ throw new Error(
424
+ '[deliver-light] --operator-proceed-light is attended-only and cannot be combined with --yes: an unattended run has no operator whose answer this is, and over-scope must fail closed to /plan',
425
+ );
426
+ }
427
+
374
428
  const gate = runLightGate({
375
429
  creates: parseCsvPaths(values.creates),
376
430
  refactors: parseCsvPaths(values.refactors),
@@ -382,6 +436,7 @@ export async function runGateMode(values, deps = {}) {
382
436
  uncertainty: values.uncertainty,
383
437
  route: values.route,
384
438
  reason: values.reason,
439
+ operatorProceedLight: values['operator-proceed-light'],
385
440
  yes: values.yes === true,
386
441
  });
387
442
 
@@ -408,6 +463,7 @@ export async function runGateMode(values, deps = {}) {
408
463
  return EXIT_NOT_PROCEED;
409
464
  }
410
465
 
466
+ const override = gate.outcome.override ?? null;
411
467
  const provider = createProviderFn(resolveConfigFn());
412
468
  const receipt = await createReceiptFn({
413
469
  provider,
@@ -417,6 +473,7 @@ export async function runGateMode(values, deps = {}) {
417
473
  ...parseCsvPaths(values.refactors),
418
474
  ],
419
475
  amends: values.amends ?? null,
476
+ override,
420
477
  });
421
478
  emitFn(
422
479
  {
@@ -424,11 +481,17 @@ export async function runGateMode(values, deps = {}) {
424
481
  action: 'proceed-light',
425
482
  storyId: receipt.storyId,
426
483
  url: receipt.url,
484
+ ...(override === null ? {} : { override }),
427
485
  nextCommands: buildNextCommands(receipt.storyId),
428
486
  outcome: gate.outcome,
429
487
  },
430
488
  values.pretty,
431
489
  );
490
+ if (override !== null) {
491
+ Logger.warn(
492
+ `[deliver-light] operator scope override recorded on Story #${receipt.storyId}: waived "${override.overriddenCode}" — ${override.recordedReason}`,
493
+ );
494
+ }
432
495
  Logger.info(
433
496
  `[deliver-light] receipt Story #${receipt.storyId} created — hand off to single-story-init.js.`,
434
497
  );
@@ -448,6 +511,7 @@ async function main() {
448
511
  route: { type: 'string' },
449
512
  reason: { type: 'string' },
450
513
  amends: { type: 'string' },
514
+ 'operator-proceed-light': { type: 'string' },
451
515
  yes: { type: 'boolean', default: false },
452
516
  backstop: { type: 'boolean', default: false },
453
517
  story: { type: 'string' },