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.
- package/.agents/README.md +1 -1
- package/.agents/agents/story-worker.md +15 -0
- package/.agents/instructions.md +14 -17
- package/.agents/rules/git-conventions.md +1 -1
- package/.agents/rules/known-tooling-behavior.md +114 -0
- package/.agents/scripts/check-context-budget.js +134 -2
- package/.agents/scripts/deliver-light.js +72 -8
- package/.agents/scripts/lib/audit-suite/selector.js +275 -162
- package/.agents/scripts/lib/config/temp-paths.js +113 -7
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +604 -57
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +72 -21
- package/.agents/scripts/lib/label-constants.js +12 -1
- package/.agents/scripts/lib/observability/runtime-friction.js +13 -1
- package/.agents/scripts/lib/observability/signals-writer.js +133 -14
- package/.agents/scripts/lib/observability/source-classifier.js +131 -1
- package/.agents/scripts/lib/orchestration/code-review.js +12 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +119 -52
- package/.agents/scripts/lib/orchestration/deliver-recover.js +253 -6
- package/.agents/scripts/lib/orchestration/light-suitability.js +194 -11
- package/.agents/scripts/lib/orchestration/resolve-stories.js +17 -14
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/review-providers/degraded-gates.js +222 -0
- package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +18 -3
- package/.agents/scripts/lib/orchestration/review-providers/native.js +82 -126
- package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +10 -0
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +300 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +51 -1
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +11 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +18 -8
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-outcome.js +66 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +1 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +5 -1
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +117 -4
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +305 -10
- package/.agents/scripts/lib/story-body/story-body.js +248 -174
- package/.agents/scripts/lib/temp-retention.js +23 -8
- package/.agents/scripts/resolve-stories.js +52 -33
- package/.agents/scripts/single-story-confirm-merge.js +6 -8
- package/.agents/workflows/helpers/deliver-digest.md +8 -6
- package/.agents/workflows/helpers/deliver-light.md +45 -5
- package/.agents/workflows/helpers/deliver-reference.md +15 -12
- package/.agents/workflows/helpers/deliver-story-reference.md +56 -21
- package/.agents/workflows/helpers/deliver-story.md +8 -5
- package/.agents/workflows/helpers/plan-reference.md +5 -4
- package/docs/CHANGELOG.md +28 -0
- 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.
|
|
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
|
package/.agents/instructions.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
Fallbacks: (1) in-repo
|
|
39
|
-
(2) web fetch/search.
|
|
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`, `
|
|
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
|
|
155
|
-
|
|
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
|
-
###
|
|
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
|
-
###
|
|
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.
|
|
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
|
-
|
|
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
|
|
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({
|
|
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({
|
|
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
|
-
*
|
|
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' },
|