mandrel 2.35.0 → 2.37.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/docs/agentrc-reference.json +3 -1
- package/.agents/docs/configuration.md +27 -0
- package/.agents/rules/testing-standards.md +56 -0
- package/.agents/schemas/agentrc.schema.json +11 -0
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
- package/.agents/scripts/boot-sweep.js +7 -1
- package/.agents/scripts/check-context-budget.js +195 -103
- package/.agents/scripts/check-cyclomatic.js +112 -42
- package/.agents/scripts/check-doc-links.js +23 -2
- package/.agents/scripts/check-generated-validator.js +202 -0
- package/.agents/scripts/check-lifecycle-lint.js +2 -68
- package/.agents/scripts/check-schema-references.js +2 -28
- package/.agents/scripts/drain-pending-cleanup.js +18 -1
- package/.agents/scripts/git-cleanup.js +2 -0
- package/.agents/scripts/lib/baselines/components.js +32 -2
- package/.agents/scripts/lib/baselines/env-overrides.js +1 -1
- package/.agents/scripts/lib/bootstrap/branch-protection.js +1 -1
- package/.agents/scripts/lib/bootstrap/install-ledger.js +1 -1
- package/.agents/scripts/lib/bootstrap/prompt.js +1 -1
- package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +2 -2
- package/.agents/scripts/lib/checks/loop-health.js +1 -1
- package/.agents/scripts/lib/cli-args.js +0 -2
- package/.agents/scripts/lib/close-validation/gates.js +1 -1
- package/.agents/scripts/lib/close-validation/process.js +1 -1
- package/.agents/scripts/lib/config/ci.js +18 -0
- package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +13 -0
- package/.agents/scripts/lib/config-settings-schema.js +46 -6
- package/.agents/scripts/lib/coverage-baseline.js +2 -2
- package/.agents/scripts/lib/cpu-pool.js +90 -10
- package/.agents/scripts/lib/crap-utils.js +6 -2
- package/.agents/scripts/lib/cyclomatic-ceiling.js +28 -6
- package/.agents/scripts/lib/cyclomatic-scope.js +144 -0
- package/.agents/scripts/lib/dependency-version.js +86 -0
- package/.agents/scripts/lib/duplicate-search.js +1 -1
- package/.agents/scripts/lib/errors/index.js +1 -1
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +2 -2
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +1 -1
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +1 -1
- package/.agents/scripts/lib/generated/agentrc-validator.js +16 -0
- package/.agents/scripts/lib/install-cmd-parser.js +1 -1
- package/.agents/scripts/lib/knip-entry-sync.js +1 -57
- package/.agents/scripts/lib/maintainability-utils.js +108 -10
- package/.agents/scripts/lib/observability/source-classifier.js +1 -1
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +22 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -14
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/merged-tip.js +132 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +56 -11
- package/.agents/scripts/lib/orchestration/merge-block-class.js +10 -1
- package/.agents/scripts/lib/orchestration/merge-poll.js +164 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +70 -9
- package/.agents/scripts/lib/orchestration/pr-watch.js +1 -30
- package/.agents/scripts/lib/orchestration/reassert-status-column.js +2 -2
- package/.agents/scripts/lib/orchestration/review-providers/codex.js +3 -3
- package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +1 -1
- package/.agents/scripts/lib/orchestration/review-providers/native.js +9 -2
- package/.agents/scripts/lib/orchestration/review-providers/review-depth.js +2 -2
- package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +145 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -5
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +52 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +9 -1
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +2 -5
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +1 -1
- package/.agents/scripts/lib/orchestration/ticket-lease.js +34 -9
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +3 -1
- package/.agents/scripts/lib/preflight-runner.js +3 -3
- package/.agents/scripts/lib/qa/qa-session.js +1 -1
- package/.agents/scripts/lib/runtime-deps/manifest.js +1 -7
- package/.agents/scripts/lib/runtime-deps/scan-imports.js +3 -58
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +284 -25
- package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
- package/.agents/scripts/lib/source-text/strip-js-comments.js +115 -0
- package/.agents/scripts/lib/test-isolate/runner.js +3 -3
- package/.agents/scripts/lib/test-runner-contract.js +14 -6
- package/.agents/scripts/lib/test-tiers.js +135 -18
- package/.agents/scripts/lib/transpile.js +16 -3
- package/.agents/scripts/lib/worktree/lifecycle/pending-cleanup.js +169 -31
- package/.agents/scripts/lib/worktree/node-modules-strategy.js +2 -5
- package/.agents/scripts/notify.js +4 -10
- package/.agents/scripts/pr-watch-with-update.js +305 -137
- package/.agents/scripts/providers/github/tickets.js +109 -16
- package/.agents/scripts/quality-preview.js +162 -70
- package/.agents/scripts/run-test-profile.js +8 -5
- package/.agents/scripts/run-tests.js +79 -14
- package/.agents/scripts/single-story-close.js +0 -2
- package/.agents/scripts/single-story-init.js +5 -1
- package/.agents/workflows/audit-documentation.md +5 -6
- package/docs/CHANGELOG.md +50 -0
- package/lib/cli/migrate.js +100 -94
- package/lib/migrations/helpers/retire-agentrc-key.js +200 -0
- package/lib/migrations/index.js +32 -33
- package/lib/migrations/steps/2.1.0-retire-mi-drop-knobs.js +21 -80
- package/lib/migrations/steps/2.1.0-retire-verify-concurrency-cap.js +15 -87
- package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +13 -71
- package/lib/migrations/steps/2.20.0-retire-codebase-snapshot.js +13 -101
- package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +13 -100
- package/package.json +9 -5
- package/.agents/scripts/generate-lifecycle-docs.js +0 -237
- package/.agents/scripts/lib/audit-suite/frontmatter-lint.js +0 -32
- package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +0 -37
- package/.agents/scripts/lib/cli/parse-numeric.js +0 -60
- package/.agents/scripts/lib/close-validation/telemetry.js +0 -79
- package/.agents/scripts/lib/orchestration/label-transitions.js +0 -44
- package/.agents/scripts/lib/orchestration/parked-follow-ons.js +0 -147
- package/.agents/scripts/lib/orchestration/phase-runner.js +0 -88
- package/.agents/scripts/lib/orchestration/recut.js +0 -56
|
@@ -320,6 +320,8 @@ Everything `/deliver` and `single-story-close` consume: execution timeouts, work
|
|
|
320
320
|
| `ci.watch.maxResumes` | No | `integer` | `3` | How many times the watcher may resume after a transient stall. 0 disables resuming. |
|
|
321
321
|
| `ci.watch.attachWindowMs` | No | `integer` | `1200000` | Story #4890. How long (ms) the watch keeps re-resolving an EMPTY `gh pr checks --required` set before it stops waiting for a required context to attach. A ruleset attaches its contexts asynchronously and the arrival latency is set by the slowest one, so a required context that is an aggregator job gated on every other tier is the last to appear — measured at 16m52s on this repository. Default 1200000 (20 minutes). Raise it for a repository whose contexts arrive later still; exhausting the window is never reported as a red check (the watch exits 2, not-yet-started). |
|
|
322
322
|
| `ci.autoMerge` | No | `"trust-ci"` \| `"strict"` | `"trust-ci"` | Story #4356 (Epic #4355). Merge posture. 'trust-ci' (default) merges once required checks pass; 'strict' additionally requires a clean review gate. |
|
|
323
|
+
| `ci.blockOnAdvisoryFailure` | No | `boolean` | `true` | Story #5096. When true (default), delivery refuses to arm — and disarms — GitHub native auto-merge while a non-required (advisory) check is genuinely red on the PR head and GitHub reports the PR mergeable anyway (mergeStateStatus=UNSTABLE). `--auto` waits on REQUIRED contexts only, so without this a red advisory quality gate merges unattended. Set false to restore the pre-#5096 behaviour verbatim. |
|
|
324
|
+
| `ci.advisoryAllowlist` | No | `array<string>` | `[]` | Story #5096. Check-run names exempt from blockOnAdvisoryFailure — a red run whose name matches exactly never blocks arming. Matching is exact; an unnamed run can never match and always blocks. |
|
|
323
325
|
| `routing` | No | `object` | — | v2 delivery-spawn routing: role-scoped boot contexts and maker-checker sampling. The v1 singleDelivery epic-route kill-switch was removed in Stage 6. |
|
|
324
326
|
| `routing.roleScopedAgents` | No | `boolean` | `true` | Epic #4478 (M7-B). Kill-switch for the role-scoped boot contexts. When true (default), a converted delivery spawn (`story-worker`, `acceptance-critic`) boots on its own `.claude/agents/<role>.md` system prompt instead of re-paying the full CLAUDE.md @-import closure. When false, every converted spawn falls back to `subagent_type: general-purpose` — the instant, code-rollback-free per-consumer revert, and the universal escape for hosts that ignore `.claude/agents/`. The fallback is the full-closure agent that ran before M7-B, so flipping it off never drops a gate. |
|
|
325
327
|
| `routing.freshCriticSampleRate` | No | `number` | `0.2` | Epic #4478 (M7-B, Part 2). Maker-checker sampling floor. Under the standard profile, a change set touching no sensitive path routes its acceptance clusters down the contract-identical inline critic path, but this fraction of them is still forced through a fresh-context critic so a low derived level never means zero independent checking. Clamped to [0, 1]; 0 disables the floor, 1 forces every cluster fresh. Consumed by resolveCeremonyForRisk (lib/orchestration/ceremony-routing.js). |
|
|
@@ -681,6 +683,31 @@ Claude Code web environment-variables UI for web sessions.
|
|
|
681
683
|
| `NOTIFICATION_WEBHOOK_URL` | No | POST target for in-band Notifier events (Make.com / Slack / Discord). Unset disables the webhook channel; `log` and `epic-comment` channels still fire. |
|
|
682
684
|
| `WEBHOOK_SECRET` | No | Shared secret used to sign outbound webhook payloads as `X-Signature-256: sha256=<hmac>`. Unset ships unsigned payloads. |
|
|
683
685
|
| `MANDREL_ALLOW_TEST_WEBHOOKS` | No | Set to `1` to keep `NOTIFICATION_WEBHOOK_URL` live inside `npm test` / `npm run test:profile`. Default behaviour scrubs the env var from the test child so no URL resolves and the webhook never fires (see below). |
|
|
686
|
+
| `MANDREL_POOL_CONCURRENCY` | No | Upper bound on the width of every `runOnPool` worker pool in the process (the MI and CRAP scan pools). Precedence is: a caller's explicit `concurrency` → this variable → a clamp of 4 under `node:test` → `os.availableParallelism()`. Set it on a constrained or shared runner where one pool per core oversubscribes the host; a non-numeric value is ignored rather than collapsing the pool. |
|
|
687
|
+
| `MANDREL_AGENTRC_VALIDATOR` | No | Set to `dynamic` to compile the `.agentrc.json` AJV validator at runtime instead of loading the committed precompiled one (see below). Costs ~35 ms per process; the escape hatch exists for a hand-edited schema or a host where the generated module will not load. |
|
|
688
|
+
|
|
689
|
+
### The `.agentrc` validator is precompiled
|
|
690
|
+
|
|
691
|
+
`getAgentrcValidator()` returns AJV's **standalone emit** for
|
|
692
|
+
`AGENTRC_SCHEMA`, committed at
|
|
693
|
+
[`.agents/scripts/lib/generated/agentrc-validator.js`](../scripts/lib/generated/agentrc-validator.js).
|
|
694
|
+
Compiling that schema costs ~35 ms, and 36 of the framework's entry scripts
|
|
695
|
+
resolve configuration, so the compile was being paid over and over to
|
|
696
|
+
re-derive a function from a literal that is already in the repository.
|
|
697
|
+
|
|
698
|
+
The artifact is generated — never hand-edited — by
|
|
699
|
+
[`check-generated-validator.js`](../scripts/check-generated-validator.js):
|
|
700
|
+
|
|
701
|
+
```bash
|
|
702
|
+
npm run validator:gen # rewrite it after a deliberate schema change
|
|
703
|
+
npm run validator:check # fail when it is stale (also run by `npm run lint`)
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
`npm run lint` runs the `--check` mode, so a schema edit that skips
|
|
707
|
+
regeneration is refused rather than shipping a validator that accepts the
|
|
708
|
+
wrong config. Validation behaviour is unchanged in either mode: the generated
|
|
709
|
+
code is AJV's own, emitted with the same `allErrors: true` option the dynamic
|
|
710
|
+
path uses, and returns the same verdict and the same `errors` array.
|
|
684
711
|
|
|
685
712
|
### Test-mode webhook isolation
|
|
686
713
|
|
|
@@ -79,6 +79,13 @@ of the pyramid.
|
|
|
79
79
|
- **Location.** `tests/features/**/*.feature` with step definitions in
|
|
80
80
|
`tests/steps/**` (or the project's equivalent). The companion skill is
|
|
81
81
|
[`stack/qa/playwright-bdd`](../skills/stack/qa/playwright-bdd/SKILL.md).
|
|
82
|
+
- **Run tier.** This tier MUST NOT ride inside the default suite. It is slow
|
|
83
|
+
by construction — real installs, real browsers, real stacks — and the
|
|
84
|
+
default suite is what a pre-push hook and every local iteration pay for.
|
|
85
|
+
Give it its own runner tier and its own CI job, so its cost is charged to
|
|
86
|
+
the surface whose signal it is. In this repository that is `tests/e2e/**`,
|
|
87
|
+
the `e2e` tier (`npm run test:e2e`), and the per-PR `e2e` job; the coverage
|
|
88
|
+
run still measures those files, so nothing leaves the measured surface.
|
|
82
89
|
|
|
83
90
|
## Assertion Placement Rule {#assertion-placement}
|
|
84
91
|
|
|
@@ -236,3 +243,52 @@ Hypothesis keeps a failure DB, proptest writes `proptest-regressions/` — pin o
|
|
|
236
243
|
commit whichever the stack provides. Once shrinking surfaces a minimal
|
|
237
244
|
counterexample, **add it as an example-based regression test** alongside the
|
|
238
245
|
property: the property guards the domain, the pinned example guards the bug.
|
|
246
|
+
|
|
247
|
+
## The suite's child-process budget
|
|
248
|
+
|
|
249
|
+
`npm test` forks one process per test file, and the spawns those leaves make
|
|
250
|
+
dominate the suite's system time. Two instruments and one rule keep that
|
|
251
|
+
budget visible and honest (Story #5121).
|
|
252
|
+
|
|
253
|
+
### Measure it with `npm run test:census`
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
npm run test:census # writes temp/census.json
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
`tests/fixtures/spawn-census.cjs` is a `--require` preload that counts every
|
|
260
|
+
`child_process` call per binary, aggregates across all ~700 processes, and
|
|
261
|
+
reports `nodeInSuite` (node children spawned *by test files*, excluding the
|
|
262
|
+
runner's own fan-out), `git`, `gh`, `npm`, and any standalone
|
|
263
|
+
`git config user.*` spawns. Read the numbers from the census rather than
|
|
264
|
+
re-deriving them; two audits hand-rolled this measurement and lost it both
|
|
265
|
+
times with the gitignored temp tree.
|
|
266
|
+
|
|
267
|
+
The script interpolates `$PWD` deliberately. A **relative** `--require` path is
|
|
268
|
+
inherited by children that run with a different `cwd`, where it fails to
|
|
269
|
+
resolve and kills the child before it runs a line — measured as 15 spurious
|
|
270
|
+
failures in one file.
|
|
271
|
+
|
|
272
|
+
### Build a fixture repo once, then copy it
|
|
273
|
+
|
|
274
|
+
A multi-commit fixture repo rebuilt in `beforeEach` is the costly shape. Build
|
|
275
|
+
it once in `before()` and hand each test an `fs` copy via
|
|
276
|
+
`copyGitRepo(pristine)` from `tests/fixtures/git-fixture.js`: each test still
|
|
277
|
+
gets a private directory it may freely mutate, for **no subprocess at all**.
|
|
278
|
+
One file went from 92 `git` spawns to 27 this way.
|
|
279
|
+
|
|
280
|
+
`copyGitRepo` is safe only for a locally-`git init`ed repo, whose
|
|
281
|
+
`.git/config` holds no absolute paths. Do **not** copy a **clone** (its
|
|
282
|
+
`remote.origin.url` is absolute, so the copy would fetch from the original) or a
|
|
283
|
+
linked worktree (its `gitdir:` / `commondir` pointers would dangle).
|
|
284
|
+
|
|
285
|
+
### Never trade coverage for a spawn count
|
|
286
|
+
|
|
287
|
+
Most of the suite's remaining `git` spawns are integration tests exercising
|
|
288
|
+
real git against git-manipulating production code — the spawn **is** the
|
|
289
|
+
subject under test, and so is a CLI's exit code in an exit-code contract test.
|
|
290
|
+
Those are not fixture waste and must not be converted to in-process calls or
|
|
291
|
+
mocks to make a number smaller. Hoist shared setup; leave the assertions alone.
|
|
292
|
+
A spawn census also records argv, not what the binary resolved to: a
|
|
293
|
+
`gh pr view 4890` line may well be a fake `gh` the test put on `PATH`, so
|
|
294
|
+
verify resolution before calling a spawn a network call.
|
|
@@ -1913,6 +1913,17 @@
|
|
|
1913
1913
|
"enum": ["trust-ci", "strict"],
|
|
1914
1914
|
"description": "Story #4356 (Epic #4355). Merge posture. 'trust-ci' (default) merges once required checks pass; 'strict' additionally requires a clean review gate.",
|
|
1915
1915
|
"default": "trust-ci"
|
|
1916
|
+
},
|
|
1917
|
+
"blockOnAdvisoryFailure": {
|
|
1918
|
+
"type": "boolean",
|
|
1919
|
+
"description": "Story #5096. When true (default), delivery refuses to arm — and disarms — GitHub native auto-merge while a non-required (advisory) check is genuinely red on the PR head and GitHub reports the PR mergeable anyway (mergeStateStatus=UNSTABLE). `--auto` waits on REQUIRED contexts only, so without this a red advisory quality gate merges unattended. Set false to restore the pre-#5096 behaviour verbatim.",
|
|
1920
|
+
"default": true
|
|
1921
|
+
},
|
|
1922
|
+
"advisoryAllowlist": {
|
|
1923
|
+
"type": "array",
|
|
1924
|
+
"items": { "type": "string" },
|
|
1925
|
+
"description": "Story #5096. Check-run names exempt from blockOnAdvisoryFailure — a red run whose name matches exactly never blocks arming. Matching is exact; an unnamed run can never match and always blocks.",
|
|
1926
|
+
"default": []
|
|
1916
1927
|
}
|
|
1917
1928
|
},
|
|
1918
1929
|
"additionalProperties": false
|
|
@@ -45,6 +45,7 @@ import { PROJECT_ROOT, resolveConfig } from './lib/config-resolver.js';
|
|
|
45
45
|
import { Logger } from './lib/Logger.js';
|
|
46
46
|
import { createProvider } from './lib/provider-factory.js';
|
|
47
47
|
import { buildProtectionCtx } from './lib/single-story-sweep/protection-ctx.js';
|
|
48
|
+
import { resolveSweepLockPath } from './lib/single-story-sweep/sweep-lock.js';
|
|
48
49
|
import { sweepMergedBranches } from './lib/single-story-sweep.js';
|
|
49
50
|
import { sweepTempRetention } from './lib/temp-retention.js';
|
|
50
51
|
|
|
@@ -141,8 +142,13 @@ export async function runBootSweep({
|
|
|
141
142
|
excludeGlobs.push(current);
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
// Story #5112 — one critical section, one lock. `single-story-init.js`
|
|
146
|
+
// reaps the same merged `story-*` branches through the same engine; when
|
|
147
|
+
// the two surfaces held differently named lockfiles they could run
|
|
148
|
+
// concurrently, each deleting branches the other had already planned.
|
|
149
|
+
// Both now resolve the path through `resolveSweepLockPath`.
|
|
144
150
|
const tempRoot = config?.project?.paths?.tempRoot ?? 'temp';
|
|
145
|
-
const lockPath =
|
|
151
|
+
const lockPath = resolveSweepLockPath({ cwd: root, tempRoot });
|
|
146
152
|
const lockTimeoutMs =
|
|
147
153
|
config.delivery?.worktreeIsolation?.sweepLockMs ?? 60_000;
|
|
148
154
|
|
|
@@ -481,6 +481,179 @@ export function renderReachable(tierMap, baseline) {
|
|
|
481
481
|
* @returns {Promise<number>} 0 = clean / within tolerance / shrink-only / no-op;
|
|
482
482
|
* 1 = a gated tier grew beyond tolerance
|
|
483
483
|
*/
|
|
484
|
+
/**
|
|
485
|
+
* Write a fresh budget, preserving the recorded tolerance so `--update` never
|
|
486
|
+
* silently widens the gate it is refreshing.
|
|
487
|
+
*
|
|
488
|
+
* @param {object} params
|
|
489
|
+
* @returns {0}
|
|
490
|
+
*/
|
|
491
|
+
function writeUpdatedBaseline({ tierMap, resolvedBaselinePath, json, stdout }) {
|
|
492
|
+
const existing = loadBaseline(resolvedBaselinePath);
|
|
493
|
+
const tolerance = Number.isFinite(existing?.toleranceBytes)
|
|
494
|
+
? existing.toleranceBytes
|
|
495
|
+
: DEFAULT_TOLERANCE_BYTES;
|
|
496
|
+
const envelope = buildBaseline(tierMap, tolerance);
|
|
497
|
+
fs.mkdirSync(path.dirname(resolvedBaselinePath), { recursive: true });
|
|
498
|
+
fs.writeFileSync(
|
|
499
|
+
resolvedBaselinePath,
|
|
500
|
+
`${JSON.stringify(envelope, null, 2)}\n`,
|
|
501
|
+
);
|
|
502
|
+
if (!json) {
|
|
503
|
+
stdout.write(
|
|
504
|
+
`[context-budget] wrote baseline ${resolvedBaselinePath} (tolerance ${tolerance} bytes)\n`,
|
|
505
|
+
);
|
|
506
|
+
} else {
|
|
507
|
+
stdout.write(
|
|
508
|
+
`${JSON.stringify({ kind: 'context-budget-update', baselinePath: resolvedBaselinePath, envelope }, null, 2)}\n`,
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
return 0;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* An absent budget is a no-op, not a failure: a consumer that has never
|
|
516
|
+
* recorded one has nothing to regress against.
|
|
517
|
+
*
|
|
518
|
+
* @param {object} params
|
|
519
|
+
* @returns {0}
|
|
520
|
+
*/
|
|
521
|
+
function reportMissingBaseline({
|
|
522
|
+
tierMap,
|
|
523
|
+
resolvedBaselinePath,
|
|
524
|
+
json,
|
|
525
|
+
stdout,
|
|
526
|
+
stderr,
|
|
527
|
+
}) {
|
|
528
|
+
if (json) {
|
|
529
|
+
stdout.write(
|
|
530
|
+
`${JSON.stringify({ kind: 'context-budget-report', baselinePath: resolvedBaselinePath, tiers: tierMap.tiers, grown: [], shrunk: [], absent: [], skipped: GATED_TIERS, exitCode: 0, noBaseline: true }, null, 2)}\n`,
|
|
531
|
+
);
|
|
532
|
+
} else {
|
|
533
|
+
stderr.write(
|
|
534
|
+
`[context-budget] ⚠ budget not found at ${resolvedBaselinePath} — skipping (no-op)\n`,
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
return 0;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Score the tree against the recorded budget. Pure — every verdict the two
|
|
542
|
+
* renderers below present is decided here, so they cannot disagree about what
|
|
543
|
+
* failed or drift apart in which fields they surface.
|
|
544
|
+
*
|
|
545
|
+
* @param {{ tierMap: object, baseline: object }} params
|
|
546
|
+
* @returns {{ diff: object, ceiling: number, bootOverflow: object[], bootDrift: object[], permissiveDrift: object[], exitCode: 0 | 1 }}
|
|
547
|
+
*/
|
|
548
|
+
function evaluateBudget({ tierMap, baseline }) {
|
|
549
|
+
const diff = diffBudget(tierMap, baseline);
|
|
550
|
+
const ceiling = Number.isFinite(baseline?.agentBoot?.ceilingBytes)
|
|
551
|
+
? baseline.agentBoot.ceilingBytes
|
|
552
|
+
: AGENT_BOOT_CEILING_BYTES;
|
|
553
|
+
const bootOverflow = agentBootOverflow(tierMap, ceiling);
|
|
554
|
+
const bootDrift = agentBootDrift(tierMap, baseline, ceiling);
|
|
555
|
+
const permissiveDrift = bootDrift.filter((d) => d.direction === 'permissive');
|
|
556
|
+
const exitCode =
|
|
557
|
+
budgetFailureCount(diff) > 0 ||
|
|
558
|
+
bootOverflow.length > 0 ||
|
|
559
|
+
permissiveDrift.length > 0
|
|
560
|
+
? 1
|
|
561
|
+
: 0;
|
|
562
|
+
return { diff, ceiling, bootOverflow, bootDrift, permissiveDrift, exitCode };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* @param {object} params
|
|
567
|
+
* @returns {void}
|
|
568
|
+
*/
|
|
569
|
+
function renderJsonReport({
|
|
570
|
+
tierMap,
|
|
571
|
+
baseline,
|
|
572
|
+
resolvedBaselinePath,
|
|
573
|
+
report,
|
|
574
|
+
stdout,
|
|
575
|
+
}) {
|
|
576
|
+
const { diff, ceiling, bootOverflow, bootDrift, exitCode } = report;
|
|
577
|
+
const envelope = {
|
|
578
|
+
kind: 'context-budget-report',
|
|
579
|
+
baselinePath: resolvedBaselinePath,
|
|
580
|
+
toleranceBytes: Number.isFinite(baseline.toleranceBytes)
|
|
581
|
+
? baseline.toleranceBytes
|
|
582
|
+
: 0,
|
|
583
|
+
current: Object.fromEntries(
|
|
584
|
+
GATED_TIERS.map((t) => [t, tierTotalBytes(tierMap.tiers[t] ?? [])]),
|
|
585
|
+
),
|
|
586
|
+
grown: diff.grown,
|
|
587
|
+
shrunk: diff.shrunk,
|
|
588
|
+
absent: diff.absent,
|
|
589
|
+
skipped: diff.skipped,
|
|
590
|
+
agentBootCeilingBytes: ceiling,
|
|
591
|
+
agentBootOverflow: bootOverflow,
|
|
592
|
+
agentBootDrift: bootDrift,
|
|
593
|
+
workflowReachableBytes: tierMap.workflowClosure?.reachableTotalBytes ?? 0,
|
|
594
|
+
exitCode,
|
|
595
|
+
};
|
|
596
|
+
stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Each failing condition gets its own remediation line: they are fixed
|
|
601
|
+
* differently (trim a role def vs refresh the budget), so a single generic
|
|
602
|
+
* message would leave the author guessing which applies.
|
|
603
|
+
*
|
|
604
|
+
* @param {object} params
|
|
605
|
+
* @returns {void}
|
|
606
|
+
*/
|
|
607
|
+
function renderFailureDiagnostics({ report, stderr }) {
|
|
608
|
+
const { diff, ceiling, bootOverflow, permissiveDrift } = report;
|
|
609
|
+
if (permissiveDrift.length > 0) {
|
|
610
|
+
stderr.write(
|
|
611
|
+
`[context-budget] ❌ a recorded agentBoot row understates the file it describes, so it overstates the headroom an author would size an edit against — refresh it with \`node .agents/scripts/check-context-budget.js --update\` (the ceiling is unchanged)\n`,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
if (bootOverflow.length > 0) {
|
|
615
|
+
stderr.write(
|
|
616
|
+
`[context-budget] ❌ a role-agent boot context exceeds the ${ceiling}-byte per-agent ceiling — trim the role def (the ceiling is a hard cap, not a starve target)\n`,
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
if (diff.grown.length > 0) {
|
|
620
|
+
stderr.write(
|
|
621
|
+
`[context-budget] ❌ a documentation tier grew beyond tolerance — refresh the budget consciously with \`node .agents/scripts/check-context-budget.js --update\` once the growth is intentional\n`,
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
if (diff.shrunk.length > 0) {
|
|
625
|
+
stderr.write(
|
|
626
|
+
`[context-budget] ❌ a documentation tier came in under its recorded total — the ratchet is holding slack the tree no longer spends, so the next growth would be absorbed silently. Lock the gain in with \`node .agents/scripts/check-context-budget.js --update\`\n`,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
if (diff.absent.length > 0) {
|
|
630
|
+
stderr.write(
|
|
631
|
+
`[context-budget] ❌ a recorded row names a path the measured tier no longer contains — its bytes inflate the recorded total against nothing. Refresh with \`node .agents/scripts/check-context-budget.js --update\`\n`,
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* @param {object} params
|
|
638
|
+
* @returns {void}
|
|
639
|
+
*/
|
|
640
|
+
function renderTextReport({ tierMap, baseline, report, stdout, stderr }) {
|
|
641
|
+
const { diff, bootOverflow, bootDrift, exitCode } = report;
|
|
642
|
+
stdout.write(`\n--- context-budget preview ---\n`);
|
|
643
|
+
stdout.write(`${renderDiff(diff)}\n`);
|
|
644
|
+
const reachable = renderReachable(tierMap, baseline);
|
|
645
|
+
if (reachable) stdout.write(`${reachable}\n`);
|
|
646
|
+
for (const o of bootOverflow) {
|
|
647
|
+
stdout.write(
|
|
648
|
+
`+ agentBoot: ${o.path} is ${o.bytes} bytes, over the ${o.ceiling}-byte per-agent ceiling\n`,
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
for (const line of renderBootDrift(bootDrift)) {
|
|
652
|
+
stdout.write(`${line}\n`);
|
|
653
|
+
}
|
|
654
|
+
if (exitCode === 1) renderFailureDiagnostics({ report, stderr });
|
|
655
|
+
}
|
|
656
|
+
|
|
484
657
|
export async function runCli({
|
|
485
658
|
argv = process.argv.slice(2),
|
|
486
659
|
cwd = process.cwd(),
|
|
@@ -498,120 +671,39 @@ export async function runCli({
|
|
|
498
671
|
const tierMap = resolveDocTiers(resolvedConfig, { root });
|
|
499
672
|
|
|
500
673
|
if (update) {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
? existing.toleranceBytes
|
|
504
|
-
: DEFAULT_TOLERANCE_BYTES;
|
|
505
|
-
const envelope = buildBaseline(tierMap, tolerance);
|
|
506
|
-
fs.mkdirSync(path.dirname(resolvedBaselinePath), { recursive: true });
|
|
507
|
-
fs.writeFileSync(
|
|
674
|
+
return writeUpdatedBaseline({
|
|
675
|
+
tierMap,
|
|
508
676
|
resolvedBaselinePath,
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
stdout.write(
|
|
513
|
-
`[context-budget] wrote baseline ${resolvedBaselinePath} (tolerance ${tolerance} bytes)\n`,
|
|
514
|
-
);
|
|
515
|
-
} else {
|
|
516
|
-
stdout.write(
|
|
517
|
-
`${JSON.stringify({ kind: 'context-budget-update', baselinePath: resolvedBaselinePath, envelope }, null, 2)}\n`,
|
|
518
|
-
);
|
|
519
|
-
}
|
|
520
|
-
return 0;
|
|
677
|
+
json,
|
|
678
|
+
stdout,
|
|
679
|
+
});
|
|
521
680
|
}
|
|
522
681
|
|
|
523
682
|
const baseline = loadBaseline(resolvedBaselinePath);
|
|
524
683
|
if (!baseline) {
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
stderr
|
|
531
|
-
|
|
532
|
-
);
|
|
533
|
-
}
|
|
534
|
-
return 0;
|
|
684
|
+
return reportMissingBaseline({
|
|
685
|
+
tierMap,
|
|
686
|
+
resolvedBaselinePath,
|
|
687
|
+
json,
|
|
688
|
+
stdout,
|
|
689
|
+
stderr,
|
|
690
|
+
});
|
|
535
691
|
}
|
|
536
692
|
|
|
537
|
-
const
|
|
538
|
-
const ceiling = Number.isFinite(baseline?.agentBoot?.ceilingBytes)
|
|
539
|
-
? baseline.agentBoot.ceilingBytes
|
|
540
|
-
: AGENT_BOOT_CEILING_BYTES;
|
|
541
|
-
const bootOverflow = agentBootOverflow(tierMap, ceiling);
|
|
542
|
-
const bootDrift = agentBootDrift(tierMap, baseline, ceiling);
|
|
543
|
-
const permissiveDrift = bootDrift.filter((d) => d.direction === 'permissive');
|
|
544
|
-
const exitCode =
|
|
545
|
-
budgetFailureCount(diff) > 0 ||
|
|
546
|
-
bootOverflow.length > 0 ||
|
|
547
|
-
permissiveDrift.length > 0
|
|
548
|
-
? 1
|
|
549
|
-
: 0;
|
|
550
|
-
|
|
693
|
+
const report = evaluateBudget({ tierMap, baseline });
|
|
551
694
|
if (json) {
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
GATED_TIERS.map((t) => [t, tierTotalBytes(tierMap.tiers[t] ?? [])]),
|
|
560
|
-
),
|
|
561
|
-
grown: diff.grown,
|
|
562
|
-
shrunk: diff.shrunk,
|
|
563
|
-
absent: diff.absent,
|
|
564
|
-
skipped: diff.skipped,
|
|
565
|
-
agentBootCeilingBytes: ceiling,
|
|
566
|
-
agentBootOverflow: bootOverflow,
|
|
567
|
-
agentBootDrift: bootDrift,
|
|
568
|
-
workflowReachableBytes: tierMap.workflowClosure?.reachableTotalBytes ?? 0,
|
|
569
|
-
exitCode,
|
|
570
|
-
};
|
|
571
|
-
stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
|
|
695
|
+
renderJsonReport({
|
|
696
|
+
tierMap,
|
|
697
|
+
baseline,
|
|
698
|
+
resolvedBaselinePath,
|
|
699
|
+
report,
|
|
700
|
+
stdout,
|
|
701
|
+
});
|
|
572
702
|
} else {
|
|
573
|
-
|
|
574
|
-
stdout.write(`${renderDiff(diff)}\n`);
|
|
575
|
-
const reachable = renderReachable(tierMap, baseline);
|
|
576
|
-
if (reachable) stdout.write(`${reachable}\n`);
|
|
577
|
-
for (const o of bootOverflow) {
|
|
578
|
-
stdout.write(
|
|
579
|
-
`+ agentBoot: ${o.path} is ${o.bytes} bytes, over the ${o.ceiling}-byte per-agent ceiling\n`,
|
|
580
|
-
);
|
|
581
|
-
}
|
|
582
|
-
for (const line of renderBootDrift(bootDrift)) {
|
|
583
|
-
stdout.write(`${line}\n`);
|
|
584
|
-
}
|
|
585
|
-
if (exitCode === 1) {
|
|
586
|
-
if (permissiveDrift.length > 0) {
|
|
587
|
-
stderr.write(
|
|
588
|
-
`[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`,
|
|
589
|
-
);
|
|
590
|
-
}
|
|
591
|
-
if (bootOverflow.length > 0) {
|
|
592
|
-
stderr.write(
|
|
593
|
-
`[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`,
|
|
594
|
-
);
|
|
595
|
-
}
|
|
596
|
-
if (diff.grown.length > 0) {
|
|
597
|
-
stderr.write(
|
|
598
|
-
`[context-budget] ❌ a documentation tier grew beyond tolerance — refresh the budget consciously with \`node .agents/scripts/check-context-budget.js --update\` once the growth is intentional\n`,
|
|
599
|
-
);
|
|
600
|
-
}
|
|
601
|
-
if (diff.shrunk.length > 0) {
|
|
602
|
-
stderr.write(
|
|
603
|
-
`[context-budget] ❌ a documentation tier came in under its recorded total — the ratchet is holding slack the tree no longer spends, so the next growth would be absorbed silently. Lock the gain in with \`node .agents/scripts/check-context-budget.js --update\`\n`,
|
|
604
|
-
);
|
|
605
|
-
}
|
|
606
|
-
if (diff.absent.length > 0) {
|
|
607
|
-
stderr.write(
|
|
608
|
-
`[context-budget] ❌ a recorded row names a path the measured tier no longer contains — its bytes inflate the recorded total against nothing. Refresh with \`node .agents/scripts/check-context-budget.js --update\`\n`,
|
|
609
|
-
);
|
|
610
|
-
}
|
|
611
|
-
}
|
|
703
|
+
renderTextReport({ tierMap, baseline, report, stdout, stderr });
|
|
612
704
|
}
|
|
613
705
|
|
|
614
|
-
return exitCode;
|
|
706
|
+
return report.exitCode;
|
|
615
707
|
}
|
|
616
708
|
|
|
617
709
|
async function main() {
|