mandrel 2.21.0 → 2.23.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 +5 -0
- package/.agents/docs/configuration.md +1 -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/schemas/agentrc.schema.json +6 -0
- package/.agents/scripts/check-context-budget.js +134 -2
- package/.agents/scripts/diagnose-friction.js +95 -4
- package/.agents/scripts/lib/audit-suite/selector.js +275 -162
- package/.agents/scripts/lib/config/temp-paths.js +51 -7
- package/.agents/scripts/lib/config-settings-schema-delivery.js +8 -0
- 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 +41 -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 +51 -46
- 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 +69 -7
- 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/story-close/phases/code-review.js +5 -1
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +380 -13
- package/.agents/scripts/lib/story-body/story-body.js +248 -174
- package/.agents/scripts/lib/templates/decomposer-prompts.js +1 -1
- package/.agents/scripts/resolve-stories.js +52 -33
- package/.agents/scripts/single-story-confirm-merge.js +5 -7
- package/.agents/workflows/helpers/deliver-digest.md +8 -6
- package/.agents/workflows/helpers/deliver-reference.md +15 -12
- package/.agents/workflows/helpers/deliver-story-reference.md +23 -21
- package/.agents/workflows/helpers/deliver-story.md +2 -2
- package/.agents/workflows/helpers/plan-reference.md +45 -4
- package/.agents/workflows/plan.md +21 -16
- package/docs/CHANGELOG.md +34 -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`,
|
|
@@ -291,6 +291,7 @@ top-level keys are validation errors.
|
|
|
291
291
|
| `feedbackLoop` | No | `object` | — | Nested configuration block. |
|
|
292
292
|
| `feedbackLoop.auditResultsAutoFile` | No | `boolean` | `true` | When true (default), the close-time audit-results graduator auto-files non-blocking audit-results findings as follow-up issues routed by source classification. Set to false to suppress auto-filing; findings remain accessible in the structured comments on the Story. |
|
|
293
293
|
| `feedbackLoop.retroProposals` | No | `boolean` | `true` | When true (default), the retro auto-files its actionable routed proposals as meta::<framework-gap\|consumer-improvement> + friction::<category> issues via the graduator pre-parsed-findings seam, and the rendered retro sections list the filed issue numbers instead of paste-ready gh command stanzas. Set to false to fall back to the command stanzas. |
|
|
294
|
+
| `feedbackLoop.frictionWindowDays` | No | `integer` | `30` | How many days back the run-scope friction recurrence window reaches (Story #4850). The window spans every surviving per-Story signal stream rather than the triggering run's own Stories, so that a defect firing once per Story can reach the actionable threshold; this bounds it by age so a defect fixed weeks ago stops re-routing. Rows older than the bound — and rows carrying no readable timestamp — are excluded and counted on the roll-up step result. Default 30. |
|
|
294
295
|
| `auditToStories` | No | `object` | — | Nested configuration block. |
|
|
295
296
|
| `auditToStories.severityFloor` | No | `"critical"` \| `"high"` \| `"medium"` \| `"low"` \| `"all"` | `"high"` | Minimum severity a finding must meet to be proposed as a Story on an unattended `/audit-to-stories --auto` sweep (Story #4626). Default high. |
|
|
296
297
|
| `auditToStories.autoComment` | No | `boolean` | `true` | When true (default), `/audit-to-stories --auto` posts a re-detected comment on an already-open matched Issue instead of silently skipping it. |
|
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.
|
|
@@ -1430,6 +1430,12 @@
|
|
|
1430
1430
|
"type": "boolean",
|
|
1431
1431
|
"default": true,
|
|
1432
1432
|
"description": "When true (default), the retro auto-files its actionable routed proposals as meta::<framework-gap|consumer-improvement> + friction::<category> issues via the graduator pre-parsed-findings seam, and the rendered retro sections list the filed issue numbers instead of paste-ready gh command stanzas. Set to false to fall back to the command stanzas."
|
|
1433
|
+
},
|
|
1434
|
+
"frictionWindowDays": {
|
|
1435
|
+
"type": "integer",
|
|
1436
|
+
"minimum": 1,
|
|
1437
|
+
"default": 30,
|
|
1438
|
+
"description": "How many days back the run-scope friction recurrence window reaches (Story #4850). The window spans every surviving per-Story signal stream rather than the triggering run's own Stories, so that a defect firing once per Story can reach the actionable threshold; this bounds it by age so a defect fixed weeks ago stops re-routing. Rows older than the bound — and rows carrying no readable timestamp — are excluded and counted on the roll-up step result. Default 30."
|
|
1433
1439
|
}
|
|
1434
1440
|
},
|
|
1435
1441
|
"additionalProperties": false
|
|
@@ -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`,
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
*/
|
|
32
32
|
import { spawnSync } from 'node:child_process';
|
|
33
33
|
import crypto from 'node:crypto';
|
|
34
|
+
import { constants as osConstants } from 'node:os';
|
|
34
35
|
import { getLimits, resolveConfig } from './lib/config-resolver.js';
|
|
35
36
|
import { Logger } from './lib/Logger.js';
|
|
36
37
|
import { appendSignal } from './lib/observability/signals-writer.js';
|
|
@@ -97,6 +98,72 @@ function classifyFrictionCategory(errorOutput) {
|
|
|
97
98
|
return { category: matched.category, remediation: matched.remediation };
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
/**
|
|
102
|
+
* `spawnSync`'s own `timeout` option kills the child with `SIGTERM`, so a
|
|
103
|
+
* SIGTERM observed here almost always means the interceptor's configured
|
|
104
|
+
* `executionTimeoutMs` bound fired. Any other signal — a `SIGKILL` from the
|
|
105
|
+
* OOM killer, an operator `kill -9` — originated outside this process.
|
|
106
|
+
*
|
|
107
|
+
* @type {string}
|
|
108
|
+
*/
|
|
109
|
+
const INTERCEPTOR_TIMEOUT_SIGNAL = 'SIGTERM';
|
|
110
|
+
|
|
111
|
+
/** Shell convention for "the process died by signal N": exit `128 + N`. */
|
|
112
|
+
const SIGNAL_EXIT_BASE = 128;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Describe a child that never exited normally — `status === null`, Node's
|
|
116
|
+
* documented representation of "did not exit normally". The raw status must
|
|
117
|
+
* never reach `process.exit`, because `process.exit(null)` exits **0**: the
|
|
118
|
+
* interceptor would report success for a command it just watched get killed
|
|
119
|
+
* (Story #4851).
|
|
120
|
+
*
|
|
121
|
+
* Which signal fired is the diagnostic value: SIGTERM points at the
|
|
122
|
+
* interceptor's own bound, anything else at the host. Recording that plus the
|
|
123
|
+
* bound itself is what makes the row actionable to a consumer who cannot edit
|
|
124
|
+
* the materialized framework tree.
|
|
125
|
+
*
|
|
126
|
+
* Deliberately module-local and pure — exporting it for tests would fail the
|
|
127
|
+
* `--production` dead-exports ratchet, and folding it into `main` would spend
|
|
128
|
+
* the file's per-file maintainability-delta headroom. The CLI contract is the
|
|
129
|
+
* seam the unit tests drive.
|
|
130
|
+
*
|
|
131
|
+
* @param {{signal: (string|null), error?: {message?: string}}} result A
|
|
132
|
+
* `spawnSync` result whose `status` is `null`.
|
|
133
|
+
* @param {number} executionTimeoutMs The resolved interceptor bound, in ms.
|
|
134
|
+
* @returns {{category: string, remediation: string, details: object,
|
|
135
|
+
* preview: string, exitCode: number}}
|
|
136
|
+
*/
|
|
137
|
+
function describeAbnormalExit(result, executionTimeoutMs) {
|
|
138
|
+
const signal = typeof result.signal === 'string' ? result.signal : null;
|
|
139
|
+
if (signal === null) {
|
|
140
|
+
return {
|
|
141
|
+
category: FRICTION_DEFAULT.category,
|
|
142
|
+
remediation: FRICTION_DEFAULT.remediation,
|
|
143
|
+
details: {
|
|
144
|
+
killedBySignal: null,
|
|
145
|
+
killOrigin: 'spawn-failure',
|
|
146
|
+
executionTimeoutMs,
|
|
147
|
+
},
|
|
148
|
+
preview: `Command did not exit normally and reported no signal: ${result.error?.message ?? 'spawn produced no exit status'}.`,
|
|
149
|
+
exitCode: 1,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const timedOut = signal === INTERCEPTOR_TIMEOUT_SIGNAL;
|
|
154
|
+
const killOrigin = timedOut ? 'interceptor-timeout' : 'external';
|
|
155
|
+
const signum = osConstants.signals[signal];
|
|
156
|
+
return {
|
|
157
|
+
category: timedOut ? 'Execution Timeout' : 'Execution Killed',
|
|
158
|
+
remediation: timedOut
|
|
159
|
+
? ` - ${signal} matches the interceptor's own executionTimeoutMs bound (${executionTimeoutMs}ms), so the command was almost certainly cut off rather than broken. Split it into smaller steps, or raise the bound.`
|
|
160
|
+
: ` - ${signal} originated outside the interceptor — the executionTimeoutMs bound (${executionTimeoutMs}ms) did not fire, so suspect an OOM kill or a hard kill from the host. Reduce the command's memory footprint or give the host more headroom.`,
|
|
161
|
+
details: { killedBySignal: signal, killOrigin, executionTimeoutMs },
|
|
162
|
+
preview: `Command terminated by signal ${signal} (${killOrigin}); executionTimeoutMs=${executionTimeoutMs}.`,
|
|
163
|
+
exitCode: Number.isInteger(signum) ? SIGNAL_EXIT_BASE + signum : 1,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
100
167
|
function toIntOrNull(value) {
|
|
101
168
|
if (value == null) return null;
|
|
102
169
|
const n = Number.parseInt(String(value), 10);
|
|
@@ -121,6 +188,7 @@ function buildFrictionSignal({
|
|
|
121
188
|
category,
|
|
122
189
|
commandStr,
|
|
123
190
|
errorPreview,
|
|
191
|
+
terminationDetails = null,
|
|
124
192
|
}) {
|
|
125
193
|
return {
|
|
126
194
|
kind: 'friction',
|
|
@@ -133,11 +201,14 @@ function buildFrictionSignal({
|
|
|
133
201
|
// and always null.
|
|
134
202
|
taskId: null,
|
|
135
203
|
category,
|
|
204
|
+
// `emitter.command` is what `classifySignalSource` step 1 scans, so the
|
|
205
|
+
// command-scan stays authoritative for `source`: a consumer command killed
|
|
206
|
+
// by its own host remains consumer-actionable (Story #4851).
|
|
136
207
|
emitter: {
|
|
137
208
|
tool: 'diagnose-friction.js',
|
|
138
209
|
command: commandStr,
|
|
139
210
|
},
|
|
140
|
-
details: { errorPreview },
|
|
211
|
+
details: { errorPreview, ...(terminationDetails ?? {}) },
|
|
141
212
|
};
|
|
142
213
|
}
|
|
143
214
|
|
|
@@ -176,10 +247,22 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
176
247
|
if (result.stderr) process.stderr.write(result.stderr);
|
|
177
248
|
|
|
178
249
|
if (result.status !== 0) {
|
|
250
|
+
// A `null` status means the child never exited normally; the cause lives in
|
|
251
|
+
// `result.signal`, not in the status.
|
|
252
|
+
const abnormal =
|
|
253
|
+
result.status === null
|
|
254
|
+
? describeAbnormalExit(result, executionTimeoutMs)
|
|
255
|
+
: null;
|
|
256
|
+
// With both streams empty an abnormal termination names its signal; the
|
|
257
|
+
// `Unknown exit code` fallback is therefore reachable only with a real
|
|
258
|
+
// numeric status, never as `Unknown exit code null`.
|
|
259
|
+
const noOutputFallback = abnormal
|
|
260
|
+
? abnormal.preview
|
|
261
|
+
: `Unknown exit code ${result.status}`;
|
|
179
262
|
const errorOutput = (
|
|
180
263
|
result.stderr ||
|
|
181
264
|
result.stdout ||
|
|
182
|
-
|
|
265
|
+
noOutputFallback
|
|
183
266
|
).trim();
|
|
184
267
|
const errorPreview = errorOutput.substring(0, 500);
|
|
185
268
|
|
|
@@ -188,7 +271,12 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
188
271
|
'Command failed. Appending friction signal to NDJSON stream...',
|
|
189
272
|
);
|
|
190
273
|
|
|
191
|
-
|
|
274
|
+
// An abnormal termination classifies itself: the marker scan reads output
|
|
275
|
+
// the kill may have truncated (or never produced), so it cannot name the
|
|
276
|
+
// signal.
|
|
277
|
+
const classified = classifyFrictionCategory(errorOutput);
|
|
278
|
+
const category = abnormal?.category ?? classified.category;
|
|
279
|
+
const remediation = abnormal?.remediation ?? classified.remediation;
|
|
192
280
|
|
|
193
281
|
const { storyId: resolvedStoryId, epicId: resolvedEpicId } =
|
|
194
282
|
resolveContextIds({ storyId, epicId }, config);
|
|
@@ -199,6 +287,7 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
199
287
|
category,
|
|
200
288
|
commandStr,
|
|
201
289
|
errorPreview,
|
|
290
|
+
terminationDetails: abnormal?.details ?? null,
|
|
202
291
|
});
|
|
203
292
|
|
|
204
293
|
// Story #2874 — accept story-only context (no parent Epic). When
|
|
@@ -236,7 +325,9 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
236
325
|
Logger.error(remediation);
|
|
237
326
|
Logger.error('----------------------------------------\n');
|
|
238
327
|
|
|
239
|
-
process.exit(result.status)
|
|
328
|
+
// Never `process.exit(result.status)` on a null status — that exits 0 and
|
|
329
|
+
// reports success for a killed command.
|
|
330
|
+
process.exit(abnormal?.exitCode ?? result.status);
|
|
240
331
|
} else {
|
|
241
332
|
process.exit(0);
|
|
242
333
|
}
|