mandrel 2.36.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.
Files changed (88) hide show
  1. package/.agents/docs/configuration.md +25 -0
  2. package/.agents/rules/testing-standards.md +56 -0
  3. package/.agents/scripts/boot-sweep.js +7 -1
  4. package/.agents/scripts/check-context-budget.js +195 -103
  5. package/.agents/scripts/check-cyclomatic.js +112 -42
  6. package/.agents/scripts/check-generated-validator.js +202 -0
  7. package/.agents/scripts/check-lifecycle-lint.js +2 -68
  8. package/.agents/scripts/check-schema-references.js +2 -28
  9. package/.agents/scripts/drain-pending-cleanup.js +18 -1
  10. package/.agents/scripts/lib/baselines/components.js +32 -2
  11. package/.agents/scripts/lib/baselines/env-overrides.js +1 -1
  12. package/.agents/scripts/lib/bootstrap/branch-protection.js +1 -1
  13. package/.agents/scripts/lib/bootstrap/install-ledger.js +1 -1
  14. package/.agents/scripts/lib/bootstrap/prompt.js +1 -1
  15. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +2 -2
  16. package/.agents/scripts/lib/checks/loop-health.js +1 -1
  17. package/.agents/scripts/lib/cli-args.js +0 -2
  18. package/.agents/scripts/lib/close-validation/gates.js +1 -1
  19. package/.agents/scripts/lib/close-validation/process.js +1 -1
  20. package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
  21. package/.agents/scripts/lib/config-settings-schema.js +46 -6
  22. package/.agents/scripts/lib/coverage-baseline.js +2 -2
  23. package/.agents/scripts/lib/cpu-pool.js +90 -10
  24. package/.agents/scripts/lib/crap-utils.js +6 -2
  25. package/.agents/scripts/lib/cyclomatic-ceiling.js +28 -6
  26. package/.agents/scripts/lib/cyclomatic-scope.js +144 -0
  27. package/.agents/scripts/lib/dependency-version.js +86 -0
  28. package/.agents/scripts/lib/duplicate-search.js +1 -1
  29. package/.agents/scripts/lib/errors/index.js +1 -1
  30. package/.agents/scripts/lib/feedback-loop/graduator-core.js +2 -2
  31. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +1 -1
  32. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +1 -1
  33. package/.agents/scripts/lib/generated/agentrc-validator.js +16 -0
  34. package/.agents/scripts/lib/install-cmd-parser.js +1 -1
  35. package/.agents/scripts/lib/knip-entry-sync.js +1 -57
  36. package/.agents/scripts/lib/maintainability-utils.js +108 -10
  37. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  38. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +70 -9
  39. package/.agents/scripts/lib/orchestration/pr-watch.js +1 -30
  40. package/.agents/scripts/lib/orchestration/reassert-status-column.js +2 -2
  41. package/.agents/scripts/lib/orchestration/review-providers/codex.js +3 -3
  42. package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +1 -1
  43. package/.agents/scripts/lib/orchestration/review-providers/native.js +9 -2
  44. package/.agents/scripts/lib/orchestration/review-providers/review-depth.js +2 -2
  45. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -3
  46. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +52 -1
  47. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +2 -5
  48. package/.agents/scripts/lib/orchestration/story-follow-ups.js +1 -1
  49. package/.agents/scripts/lib/orchestration/ticket-lease.js +34 -9
  50. package/.agents/scripts/lib/orchestration/ticketing/reads.js +3 -1
  51. package/.agents/scripts/lib/preflight-runner.js +3 -3
  52. package/.agents/scripts/lib/qa/qa-session.js +1 -1
  53. package/.agents/scripts/lib/runtime-deps/manifest.js +1 -7
  54. package/.agents/scripts/lib/runtime-deps/scan-imports.js +3 -58
  55. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +284 -25
  56. package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
  57. package/.agents/scripts/lib/source-text/strip-js-comments.js +115 -0
  58. package/.agents/scripts/lib/test-isolate/runner.js +3 -3
  59. package/.agents/scripts/lib/test-runner-contract.js +14 -6
  60. package/.agents/scripts/lib/test-tiers.js +135 -18
  61. package/.agents/scripts/lib/transpile.js +16 -3
  62. package/.agents/scripts/lib/worktree/lifecycle/pending-cleanup.js +169 -31
  63. package/.agents/scripts/lib/worktree/node-modules-strategy.js +2 -5
  64. package/.agents/scripts/pr-watch-with-update.js +305 -137
  65. package/.agents/scripts/providers/github/tickets.js +109 -16
  66. package/.agents/scripts/quality-preview.js +162 -70
  67. package/.agents/scripts/run-test-profile.js +8 -5
  68. package/.agents/scripts/run-tests.js +79 -14
  69. package/.agents/scripts/single-story-close.js +0 -2
  70. package/.agents/scripts/single-story-init.js +5 -1
  71. package/docs/CHANGELOG.md +32 -0
  72. package/lib/cli/migrate.js +100 -94
  73. package/lib/migrations/helpers/retire-agentrc-key.js +200 -0
  74. package/lib/migrations/index.js +32 -33
  75. package/lib/migrations/steps/2.1.0-retire-mi-drop-knobs.js +21 -80
  76. package/lib/migrations/steps/2.1.0-retire-verify-concurrency-cap.js +15 -87
  77. package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +13 -71
  78. package/lib/migrations/steps/2.20.0-retire-codebase-snapshot.js +13 -101
  79. package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +13 -100
  80. package/package.json +7 -3
  81. package/.agents/scripts/lib/audit-suite/frontmatter-lint.js +0 -32
  82. package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +0 -37
  83. package/.agents/scripts/lib/cli/parse-numeric.js +0 -60
  84. package/.agents/scripts/lib/close-validation/telemetry.js +0 -79
  85. package/.agents/scripts/lib/orchestration/label-transitions.js +0 -44
  86. package/.agents/scripts/lib/orchestration/parked-follow-ons.js +0 -147
  87. package/.agents/scripts/lib/orchestration/phase-runner.js +0 -88
  88. package/.agents/scripts/lib/orchestration/recut.js +0 -56
@@ -683,6 +683,31 @@ Claude Code web environment-variables UI for web sessions.
683
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. |
684
684
  | `WEBHOOK_SECRET` | No | Shared secret used to sign outbound webhook payloads as `X-Signature-256: sha256=<hmac>`. Unset ships unsigned payloads. |
685
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.
686
711
 
687
712
  ### Test-mode webhook isolation
688
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.
@@ -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 = path.resolve(root, tempRoot, 'boot-sweep.lock');
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
- const existing = loadBaseline(resolvedBaselinePath);
502
- const tolerance = Number.isFinite(existing?.toleranceBytes)
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
- `${JSON.stringify(envelope, null, 2)}\n`,
510
- );
511
- if (!json) {
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
- if (json) {
526
- stdout.write(
527
- `${JSON.stringify({ kind: 'context-budget-report', baselinePath: resolvedBaselinePath, tiers: tierMap.tiers, grown: [], shrunk: [], absent: [], skipped: GATED_TIERS, exitCode: 0, noBaseline: true }, null, 2)}\n`,
528
- );
529
- } else {
530
- stderr.write(
531
- `[context-budget] ⚠ budget not found at ${resolvedBaselinePath} — skipping (no-op)\n`,
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 diff = diffBudget(tierMap, baseline);
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
- const envelope = {
553
- kind: 'context-budget-report',
554
- baselinePath: resolvedBaselinePath,
555
- toleranceBytes: Number.isFinite(baseline.toleranceBytes)
556
- ? baseline.toleranceBytes
557
- : 0,
558
- current: Object.fromEntries(
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
- stdout.write(`\n--- context-budget preview ---\n`);
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() {
@@ -35,6 +35,7 @@ import {
35
35
  resolveCyclomaticPolicy,
36
36
  scanCyclomatic,
37
37
  } from './lib/cyclomatic-ceiling.js';
38
+ import { resolveScanScope } from './lib/cyclomatic-scope.js';
38
39
 
39
40
  /**
40
41
  * Parse `--baseline <path>`, `--json`, and `--update`.
@@ -82,6 +83,86 @@ function loadCyclomaticBaseline(baselinePath) {
82
83
  }
83
84
  }
84
85
 
86
+ /**
87
+ * `--update`: rewrite the baseline from the current (whole-tree) scan. The
88
+ * sanctioned motion after a deliberate refactor lands, and the only way the
89
+ * recorded breach count is allowed to rise.
90
+ *
91
+ * @param {{ scan: object, ceiling: number, baselinePath: string, writeFileImpl: Function, stdout: { write: (s: string) => void } }} args
92
+ * @returns {number} Always 0 — writing a baseline cannot fail the ratchet.
93
+ */
94
+ function writeUpdatedBaseline({
95
+ scan,
96
+ ceiling,
97
+ baselinePath,
98
+ writeFileImpl,
99
+ stdout,
100
+ }) {
101
+ const envelope = buildCyclomaticEnvelope({ rows: scan.rows, ceiling });
102
+ writeFileImpl(baselinePath, `${JSON.stringify(envelope, null, 2)}\n`);
103
+ stdout.write(
104
+ `[cyclomatic] wrote ${scan.rows.length} breach row(s) at ceiling c=${ceiling} to ${baselinePath}\n`,
105
+ );
106
+ return 0;
107
+ }
108
+
109
+ /**
110
+ * `--json`: the machine-readable comparison envelope.
111
+ *
112
+ * @param {{ policy: object, baseline: object|null, baselineRows: Array<object>, baselinePath: string, scan: object, diff: object, exitCode: number }} args
113
+ * @returns {string}
114
+ */
115
+ function renderJsonReport({
116
+ policy,
117
+ baseline,
118
+ baselineRows,
119
+ baselinePath,
120
+ scan,
121
+ diff,
122
+ exitCode,
123
+ }) {
124
+ return `${JSON.stringify(
125
+ {
126
+ kind: 'cyclomatic-report',
127
+ ceiling: policy.mustFix,
128
+ flag: policy.flag,
129
+ baselinePath,
130
+ baselineCeiling: baseline?.ceiling ?? null,
131
+ scannedFiles: scan.scannedFiles,
132
+ parseErrors: scan.parseErrors,
133
+ baselineRows,
134
+ currentRows: scan.rows,
135
+ ...diff,
136
+ exitCode,
137
+ },
138
+ null,
139
+ 2,
140
+ )}\n`;
141
+ }
142
+
143
+ /**
144
+ * Announce a baseline the comparison cannot fully trust: absent, or recorded
145
+ * at a ceiling the config no longer uses. Both are warnings rather than
146
+ * failures — the diff still runs, and staying silent is what would let an
147
+ * operator read a meaningless verdict as a clean one.
148
+ *
149
+ * @param {{ baseline: object|null, mustFix: number, baselinePath: string, stderr: { write: (s: string) => void } }} args
150
+ * @returns {void}
151
+ */
152
+ function warnAboutBaseline({ baseline, mustFix, baselinePath, stderr }) {
153
+ if (!baseline) {
154
+ stderr.write(
155
+ `[cyclomatic] ⚠ baseline not found at ${baselinePath} — treating as empty\n`,
156
+ );
157
+ return;
158
+ }
159
+ if (typeof baseline.ceiling === 'number' && baseline.ceiling !== mustFix) {
160
+ stderr.write(
161
+ `[cyclomatic] ⚠ baseline was recorded at ceiling c=${baseline.ceiling} but the configured cyclomaticMustFix is c=${mustFix} — re-run with --update\n`,
162
+ );
163
+ }
164
+ }
165
+
85
166
  /**
86
167
  * Top-level CLI entry. Exported so tests can drive the whole pipeline through
87
168
  * the injected seams below without spawning a process.
@@ -109,75 +190,64 @@ export async function runCli({
109
190
  writeFileImpl = (p, data) => fs.writeFileSync(p, data),
110
191
  } = {}) {
111
192
  const { baselinePath, json, update } = parseArgv(argv);
112
- const quality = getQuality(resolveConfigImpl({ cwd }));
193
+ const config = resolveConfigImpl({ cwd });
194
+ const quality = getQuality(config);
113
195
  const policy = resolveCyclomaticPolicy(quality);
114
196
  const resolvedBaselinePath = path.resolve(
115
197
  cwd,
116
198
  baselinePath ?? DEFAULT_CYCLOMATIC_BASELINE,
117
199
  );
118
200
 
201
+ // Read the baseline before scanning: its rows are half the diff scope
202
+ // (Story #5109). `--update` rewrites the baseline from the whole tree, and
203
+ // `BASELINE_SCOPE=full` is the operator's explicit "re-derive everything",
204
+ // so both opt out of scoping entirely.
205
+ const baseline = loadBaselineImpl(resolvedBaselinePath);
206
+ const baselineRows = Array.isArray(baseline?.rows) ? baseline.rows : [];
207
+ const scopeFiles = resolveScanScope({ cwd, config, update, baselineRows });
208
+
119
209
  const scan = scanImpl({
120
210
  targetDirs: policy.targetDirs,
121
211
  ignoreGlobs: policy.ignoreGlobs,
122
212
  ceiling: policy.mustFix,
123
213
  cwd,
214
+ scopeFiles,
124
215
  });
125
216
 
126
217
  if (update) {
127
- const envelope = buildCyclomaticEnvelope({
128
- rows: scan.rows,
218
+ return writeUpdatedBaseline({
219
+ scan,
129
220
  ceiling: policy.mustFix,
221
+ baselinePath: resolvedBaselinePath,
222
+ writeFileImpl,
223
+ stdout,
130
224
  });
131
- writeFileImpl(
132
- resolvedBaselinePath,
133
- `${JSON.stringify(envelope, null, 2)}\n`,
134
- );
135
- stdout.write(
136
- `[cyclomatic] wrote ${scan.rows.length} breach row(s) at ceiling c=${policy.mustFix} to ${resolvedBaselinePath}\n`,
137
- );
138
- return 0;
139
225
  }
140
226
 
141
- const baseline = loadBaselineImpl(resolvedBaselinePath);
142
- const baselineRows = Array.isArray(baseline?.rows) ? baseline.rows : [];
143
227
  const diff = diffCyclomaticRows(baselineRows, scan.rows);
144
228
  const exitCode = diff.added.length + diff.worsened.length > 0 ? 1 : 0;
145
229
 
146
230
  if (json) {
147
231
  stdout.write(
148
- `${JSON.stringify(
149
- {
150
- kind: 'cyclomatic-report',
151
- ceiling: policy.mustFix,
152
- flag: policy.flag,
153
- baselinePath: resolvedBaselinePath,
154
- baselineCeiling: baseline?.ceiling ?? null,
155
- scannedFiles: scan.scannedFiles,
156
- parseErrors: scan.parseErrors,
157
- baselineRows,
158
- currentRows: scan.rows,
159
- ...diff,
160
- exitCode,
161
- },
162
- null,
163
- 2,
164
- )}\n`,
232
+ renderJsonReport({
233
+ policy,
234
+ baseline,
235
+ baselineRows,
236
+ baselinePath: resolvedBaselinePath,
237
+ scan,
238
+ diff,
239
+ exitCode,
240
+ }),
165
241
  );
166
242
  return exitCode;
167
243
  }
168
244
 
169
- if (!baseline) {
170
- stderr.write(
171
- `[cyclomatic] ⚠ baseline not found at ${resolvedBaselinePath} — treating as empty\n`,
172
- );
173
- } else if (
174
- typeof baseline.ceiling === 'number' &&
175
- baseline.ceiling !== policy.mustFix
176
- ) {
177
- stderr.write(
178
- `[cyclomatic] ⚠ baseline was recorded at ceiling c=${baseline.ceiling} but the configured cyclomaticMustFix is c=${policy.mustFix} — re-run with --update\n`,
179
- );
180
- }
245
+ warnAboutBaseline({
246
+ baseline,
247
+ mustFix: policy.mustFix,
248
+ baselinePath: resolvedBaselinePath,
249
+ stderr,
250
+ });
181
251
  stdout.write('\n--- cyclomatic preview ---\n');
182
252
  stdout.write(`${renderCyclomaticDiff(diff, policy.mustFix)}\n`);
183
253
  return exitCode;