session-orchestrator 3.17.0 → 3.20.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +185 -412
- package/README.md +12 -9
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +9 -4
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +794 -0
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +245 -50
- package/docs/session-config-template.md +39 -26
- package/hooks/_lib/guard-source-loader.mjs +680 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +226 -19
- package/hooks/enforce-scope.mjs +133 -9
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +30 -4
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +616 -164
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +6 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/emit-session.mjs +3 -40
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1273 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +17 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +26 -40
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/learnings/select.mjs +550 -0
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +128 -24
- package/scripts/lib/reconcile/engine.mjs +156 -54
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +95 -1
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +568 -145
- package/scripts/lib/session-close-backfill.mjs +63 -8
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
- package/scripts/lib/validate/check-rules.mjs +244 -36
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +549 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +17 -3
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +218 -16
- package/scripts/print-learnings-index.mjs +474 -0
- package/scripts/release.mjs +534 -0
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +21 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/brainstorm/soul.md +47 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +117 -18
- package/skills/gitlab-ops/SKILL.md +25 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/grill/soul.md +44 -1
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +17 -1
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/plan/soul.md +46 -3
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +34 -36
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +32 -2
- package/skills/session-end/plan-verification.md +6 -7
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +75 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/session-start/soul.md +41 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +108 -7
- package/skills/wave-executor/SKILL.md +6 -7
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +198 -80
- package/templates/_shared/loop.md +4 -4
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* instruction-budget-guard.mjs — #687
|
|
2
|
+
* instruction-budget-guard.mjs — #687 / #877 (FA2)
|
|
3
3
|
*
|
|
4
4
|
* Lightweight directive-budget guard for always-on `.claude/rules/*.md`.
|
|
5
5
|
*
|
|
@@ -11,19 +11,69 @@
|
|
|
11
11
|
* budget audit recommends this as a silent-now growth ratchet that only
|
|
12
12
|
* fires when NEW always-on directives are added.
|
|
13
13
|
*
|
|
14
|
+
* #877 FA2 extends this additively with a BYTE dimension (`totalBytes` /
|
|
15
|
+
* `perFile[].bytes`) and a per-tier surface split (`bySurface`). The
|
|
16
|
+
* original directive-line heuristic only inspects bullet/digit/`##` lines —
|
|
17
|
+
* the majority of an always-on rule file's payload (prose paragraphs, code
|
|
18
|
+
* fences) never contributes to `totalDirectives`, so that count alone
|
|
19
|
+
* understates the real instruction-budget cost. `bySurface` further splits
|
|
20
|
+
* the always-on corpus by `entry.tier` (issue #692) so a coordinator-only
|
|
21
|
+
* file (never reaches a wave agent) does not silently inflate what a wave
|
|
22
|
+
* agent's own budget looks like.
|
|
23
|
+
*
|
|
24
|
+
* #893 correction: `bySurface.coordinator` mirrors
|
|
25
|
+
* `loadApplicableRules({context:'coordinator'})` exactly — the REAL
|
|
26
|
+
* coordinator delivery path (`print-applicable-rules.mjs --context
|
|
27
|
+
* coordinator`) — which EXCLUDES `tier: wave-only` content, not "the entire
|
|
28
|
+
* always-on corpus regardless of tier" as a pre-#893 doc revision claimed.
|
|
29
|
+
* `always` is a strict subset of both `wave` and `coordinator` (neither tier
|
|
30
|
+
* gate touches `tier: 'always'`), but `wave` and `coordinator` are each other's
|
|
31
|
+
* SIBLING projections, not nested — one excludes `coordinator-only`, the other
|
|
32
|
+
* excludes `wave-only`, so neither is guaranteed to be `⊆` the other. Do NOT
|
|
33
|
+
* assume `wave ⊆ coordinator` (that only held under the pre-#893 bug where
|
|
34
|
+
* `context: 'coordinator'` silently meant "untiered"). The additive
|
|
35
|
+
* `coordinator + wave === totalBytes` identity is separately never
|
|
36
|
+
* guaranteed either — it double-counts the `always` tier that sits in both
|
|
37
|
+
* surfaces; see #877 issue discussion.
|
|
38
|
+
*
|
|
14
39
|
* Plain-JS — no Zod dependency. Never throws.
|
|
15
40
|
* - `computeInstructionBudget` always returns the full shape (never null).
|
|
16
41
|
* - `checkInstructionBudget` returns a banner object or null (session-start
|
|
17
42
|
* Phase 4 convention, mirroring checkQgCommandDrift / checkCiStatus).
|
|
18
43
|
*
|
|
19
|
-
* Always-on membership
|
|
20
|
-
* `./rule-loader.mjs` (single SSOT) — we do NOT
|
|
44
|
+
* Always-on membership AND tier-surface gating are both delegated to
|
|
45
|
+
* `loadApplicableRules` from `./rule-loader.mjs` (single SSOT) — we do NOT
|
|
46
|
+
* hard-code the file list, and we do NOT hand-roll a second copy of the
|
|
47
|
+
* tier-gate conditionals `applyGates` already implements (the `context`
|
|
48
|
+
* param below is the exact mechanism rule-loader exposes for this).
|
|
21
49
|
*
|
|
22
50
|
* Cross-references:
|
|
23
51
|
* - "2026-06-20 instruction-budget audit" (#668 / #687; archived in the private Meta-Vault)
|
|
24
|
-
* - scripts/lib/rule-loader.mjs (always-on classification SSOT)
|
|
52
|
+
* - scripts/lib/rule-loader.mjs (always-on classification + tier-gate SSOT)
|
|
25
53
|
* - scripts/lib/qg-command-drift-banner.mjs (banner-shape convention)
|
|
26
54
|
* - scripts/lib/ci-status-banner.mjs (never-throws convention)
|
|
55
|
+
* - issue #877 (FA2 — byte dimension + surface split)
|
|
56
|
+
* - SISTER GUARD / KNOWN DIVERGENCE (#906.3): the projects-baseline repo
|
|
57
|
+
* (resolved via `plan-baseline-path` / owner.yaml `baseline-path`) ships
|
|
58
|
+
* `scripts/check-instruction-budget.sh` under rule CCU-009c. It measures the
|
|
59
|
+
* SAME `.claude/rules/*.md` corpus and its total is NOT comparable to ours —
|
|
60
|
+
* it diverges on BOTH axes, in OPPOSITE directions:
|
|
61
|
+
* (a) MEMBERSHIP — it classifies a file as path-scoped only on a `paths:`
|
|
62
|
+
* frontmatter key, so this repo's `globs:`-scoped rules stay inside ITS
|
|
63
|
+
* always-on set: 26 files where `loadApplicableRules` yields 12 here.
|
|
64
|
+
* (b) HEURISTIC — it counts only rule-ID anchors and imperative-keyword
|
|
65
|
+
* BULLET lines outside code fences, where `countDirectives` below
|
|
66
|
+
* counts every bullet, ordered-list item and `##`-or-deeper heading.
|
|
67
|
+
* The narrower heuristic outweighs the wider file set, so its total runs
|
|
68
|
+
* LOWER than ours: measured 2026-07-30 against this repo's corpus, 263 (its
|
|
69
|
+
* heuristic) vs 471 (ours) ≈ 1.79x; its own header reports a ~2-3x spread on
|
|
70
|
+
* the baseline repo's corpus, so the factor is corpus-dependent, not a
|
|
71
|
+
* constant. Never diff or reconcile the two totals — each is only meaningful
|
|
72
|
+
* against its OWN ceiling, and on that same corpus the two already disagree
|
|
73
|
+
* on the verdict (263 > its max of 200 → over budget; 471 <= our 480 → ok).
|
|
74
|
+
* Their ceilings are not the same kind of number either: its 200 is an
|
|
75
|
+
* unvalidated placeholder it explicitly retracts in its own header, ours is a
|
|
76
|
+
* self-relative growth ratchet calibrated just above our own baseline.
|
|
27
77
|
*/
|
|
28
78
|
|
|
29
79
|
import { existsSync, readFileSync } from 'node:fs';
|
|
@@ -33,6 +83,33 @@ import { loadApplicableRules } from './rule-loader.mjs';
|
|
|
33
83
|
/** Default directive ceiling (operator-chosen growth ratchet just above the ~457 baseline). */
|
|
34
84
|
export const DEFAULT_CEILING = 480;
|
|
35
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Default BYTE ceiling (#931a) — the second axis of the same growth ratchet.
|
|
88
|
+
*
|
|
89
|
+
* Derived from measurement, not from a feeling. Measured 2026-07-30 against
|
|
90
|
+
* this repo's own always-on corpus:
|
|
91
|
+
*
|
|
92
|
+
* node -e "import('./scripts/lib/instruction-budget-guard.mjs').then(m =>
|
|
93
|
+
* console.log(m.computeInstructionBudget({repoRoot: process.cwd()}).totalBytes))"
|
|
94
|
+
* → 108589 (12 always-on rules, untiered surface)
|
|
95
|
+
*
|
|
96
|
+
* 108589 x 1.05 = 114018 → rounded DOWN to 114000. The +5% headroom is not
|
|
97
|
+
* arbitrary either: it is the SAME relative headroom the directive ceiling
|
|
98
|
+
* already carries (480 over its ~457 baseline = +5.03%), so the two axes are
|
|
99
|
+
* calibrated identically rather than one being tighter than the other by
|
|
100
|
+
* accident. In absolute terms +5% ≈ 5.4 KB ≈ one medium always-on rule file
|
|
101
|
+
* (verification-before-completion.md is 6.1 KB) — i.e. the ratchet fires when
|
|
102
|
+
* a genuinely NEW always-on surface is added, not when an existing rule is
|
|
103
|
+
* edited. A default that reddens the current state would be switched off
|
|
104
|
+
* within one session and measure nothing thereafter.
|
|
105
|
+
*
|
|
106
|
+
* Note the byte axis is materially LOOSER than the directive axis in practice:
|
|
107
|
+
* the live repo sits at 471/480 directives (98.1% of ceiling) but 108589/114000
|
|
108
|
+
* bytes (95.3%). That asymmetry is inherited from the pre-existing directive
|
|
109
|
+
* ratchet, not introduced here.
|
|
110
|
+
*/
|
|
111
|
+
export const DEFAULT_BYTE_CEILING = 114000;
|
|
112
|
+
|
|
36
113
|
/**
|
|
37
114
|
* Read the `instruction-budget:` nested block from the `## Session Config`
|
|
38
115
|
* section of CLAUDE.md (or AGENTS.md) at `repoRoot`. Synchronous + never throws.
|
|
@@ -42,20 +119,33 @@ export const DEFAULT_CEILING = 480;
|
|
|
42
119
|
* instruction-budget:
|
|
43
120
|
* enabled: true
|
|
44
121
|
* ceiling: 480
|
|
122
|
+
* byte-ceiling: 114000
|
|
45
123
|
* mode: warn
|
|
46
124
|
*
|
|
47
125
|
* Behaviour:
|
|
48
126
|
* - Config-load failure (no instruction file / unreadable) → returns the
|
|
49
|
-
* graceful fallback `{ enabled: true, ceiling: DEFAULT_CEILING,
|
|
50
|
-
*
|
|
127
|
+
* graceful fallback `{ enabled: true, ceiling: DEFAULT_CEILING,
|
|
128
|
+
* 'byte-ceiling': DEFAULT_BYTE_CEILING, mode: 'warn' }` so the probe still
|
|
129
|
+
* computes (mirrors the other session-start probes).
|
|
51
130
|
* - Absent block → same fallback (the feature is on-by-default, growth-ratchet).
|
|
52
131
|
* - Malformed individual values silently fall back to the per-key default.
|
|
53
132
|
*
|
|
133
|
+
* The `byte-ceiling` key keeps its KEBAB form in the returned object, matching
|
|
134
|
+
* how every other config loader in `scripts/lib/config/` mirrors a multi-word
|
|
135
|
+
* YAML key (`'due-days'`, `'timeout-ms'`, `'confidence-floor'`). The camelCase
|
|
136
|
+
* `byteCeiling` spelling appears only on the `opts`/result surfaces of
|
|
137
|
+
* `computeInstructionBudget`, which are plain JS objects, not config mirrors.
|
|
138
|
+
*
|
|
54
139
|
* @param {string} repoRoot
|
|
55
|
-
* @returns {{ enabled: boolean, ceiling: number, mode: 'warn' | 'off' }}
|
|
140
|
+
* @returns {{ enabled: boolean, ceiling: number, 'byte-ceiling': number, mode: 'warn' | 'off' }}
|
|
56
141
|
*/
|
|
57
142
|
export function loadInstructionBudgetConfig(repoRoot) {
|
|
58
|
-
const fallback = {
|
|
143
|
+
const fallback = {
|
|
144
|
+
enabled: true,
|
|
145
|
+
ceiling: DEFAULT_CEILING,
|
|
146
|
+
'byte-ceiling': DEFAULT_BYTE_CEILING,
|
|
147
|
+
mode: 'warn',
|
|
148
|
+
};
|
|
59
149
|
|
|
60
150
|
let content = null;
|
|
61
151
|
for (const name of ['CLAUDE.md', 'AGENTS.md']) {
|
|
@@ -82,13 +172,26 @@ export function loadInstructionBudgetConfig(repoRoot) {
|
|
|
82
172
|
* Parse the `instruction-budget:` block out of raw markdown content.
|
|
83
173
|
* Independent helper (testable without disk IO).
|
|
84
174
|
*
|
|
175
|
+
* A `defaults` object that predates the `byte-ceiling` key (#931a) is tolerated:
|
|
176
|
+
* the missing entry falls back to `DEFAULT_BYTE_CEILING` rather than yielding
|
|
177
|
+
* `undefined`, so an older caller can never disable the byte axis by omission.
|
|
178
|
+
*
|
|
85
179
|
* @param {string} content - full file contents
|
|
86
|
-
* @param {{ enabled: boolean, ceiling: number, mode: 'warn' | 'off' }} [defaults]
|
|
87
|
-
* @returns {{ enabled: boolean, ceiling: number, mode: 'warn' | 'off' }}
|
|
180
|
+
* @param {{ enabled: boolean, ceiling: number, 'byte-ceiling'?: number, mode: 'warn' | 'off' }} [defaults]
|
|
181
|
+
* @returns {{ enabled: boolean, ceiling: number, 'byte-ceiling': number, mode: 'warn' | 'off' }}
|
|
88
182
|
*/
|
|
89
183
|
export function _parseInstructionBudget(content, defaults) {
|
|
90
|
-
const base = defaults ?? {
|
|
91
|
-
|
|
184
|
+
const base = defaults ?? {
|
|
185
|
+
enabled: true,
|
|
186
|
+
ceiling: DEFAULT_CEILING,
|
|
187
|
+
'byte-ceiling': DEFAULT_BYTE_CEILING,
|
|
188
|
+
mode: 'warn',
|
|
189
|
+
};
|
|
190
|
+
const baseByteCeiling =
|
|
191
|
+
typeof base['byte-ceiling'] === 'number' ? base['byte-ceiling'] : DEFAULT_BYTE_CEILING;
|
|
192
|
+
if (typeof content !== 'string' || content === '') {
|
|
193
|
+
return { ...base, 'byte-ceiling': baseByteCeiling };
|
|
194
|
+
}
|
|
92
195
|
|
|
93
196
|
const lines = content.split(/\r?\n/);
|
|
94
197
|
let inBlock = false;
|
|
@@ -119,10 +222,11 @@ export function _parseInstructionBudget(content, defaults) {
|
|
|
119
222
|
blockLines.push(line);
|
|
120
223
|
}
|
|
121
224
|
|
|
122
|
-
if (blockLines.length === 0) return { ...base };
|
|
225
|
+
if (blockLines.length === 0) return { ...base, 'byte-ceiling': baseByteCeiling };
|
|
123
226
|
|
|
124
227
|
let enabled = base.enabled;
|
|
125
228
|
let ceiling = base.ceiling;
|
|
229
|
+
let byteCeiling = baseByteCeiling;
|
|
126
230
|
let mode = base.mode;
|
|
127
231
|
|
|
128
232
|
for (const rawLine of blockLines) {
|
|
@@ -149,6 +253,15 @@ export function _parseInstructionBudget(content, defaults) {
|
|
|
149
253
|
}
|
|
150
254
|
break;
|
|
151
255
|
}
|
|
256
|
+
case 'byte-ceiling': {
|
|
257
|
+
// Same shape as `ceiling` above — integer, strictly positive, malformed
|
|
258
|
+
// or non-positive values silently keep the default (#931a).
|
|
259
|
+
if (/^-?\d+$/.test(v)) {
|
|
260
|
+
const n = Number.parseInt(v, 10);
|
|
261
|
+
if (Number.isFinite(n) && n > 0) byteCeiling = n;
|
|
262
|
+
}
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
152
265
|
case 'mode':
|
|
153
266
|
// Only `off` silences; any other value (incl. `warn`) surfaces the banner.
|
|
154
267
|
mode = v.toLowerCase() === 'off' ? 'off' : 'warn';
|
|
@@ -156,25 +269,21 @@ export function _parseInstructionBudget(content, defaults) {
|
|
|
156
269
|
}
|
|
157
270
|
}
|
|
158
271
|
|
|
159
|
-
return { enabled, ceiling, mode };
|
|
272
|
+
return { enabled, ceiling, 'byte-ceiling': byteCeiling, mode };
|
|
160
273
|
}
|
|
161
274
|
|
|
162
275
|
/**
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
* - ordered items: /^\s*\d+[.)]\s/
|
|
168
|
-
* - headings ≥2: /^#{2,}\s/
|
|
169
|
-
*
|
|
170
|
-
* Fenced code blocks (``` … ```) are excluded entirely, and a leading
|
|
171
|
-
* `---` … `---` YAML frontmatter block is skipped before counting.
|
|
276
|
+
* Skips a leading YAML frontmatter block (`---` … `---`) and returns the
|
|
277
|
+
* remaining lines. Shared frontmatter classification for BOTH the directive
|
|
278
|
+
* counter and the byte-walk (#877) — a single SSOT so the two dimensions
|
|
279
|
+
* can never drift on "where does the file's body actually start".
|
|
172
280
|
*
|
|
173
281
|
* @param {string} content - raw file contents
|
|
174
|
-
* @returns {
|
|
282
|
+
* @returns {string[]} lines after the frontmatter block (or all lines when
|
|
283
|
+
* there is no leading frontmatter / it never closes)
|
|
175
284
|
*/
|
|
176
|
-
function
|
|
177
|
-
if (typeof content !== 'string' || content === '') return
|
|
285
|
+
function stripFrontmatterLines(content) {
|
|
286
|
+
if (typeof content !== 'string' || content === '') return [];
|
|
178
287
|
|
|
179
288
|
const lines = content.split(/\r?\n/);
|
|
180
289
|
let i = 0;
|
|
@@ -187,12 +296,36 @@ function countDirectives(content) {
|
|
|
187
296
|
if (j < lines.length) i = j + 1;
|
|
188
297
|
}
|
|
189
298
|
|
|
299
|
+
return lines.slice(i);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Count always-on directives in a single rule file's content.
|
|
304
|
+
*
|
|
305
|
+
* Deterministic heuristic — counts lines that represent a directive:
|
|
306
|
+
* - bullets: /^\s*[-*+]\s/
|
|
307
|
+
* - ordered items: /^\s*\d+[.)]\s/
|
|
308
|
+
* - headings ≥2: /^#{2,}\s/
|
|
309
|
+
*
|
|
310
|
+
* Fenced code blocks (``` … ```) are excluded entirely, and a leading
|
|
311
|
+
* `---` … `---` YAML frontmatter block is skipped before counting (shared
|
|
312
|
+
* skip logic with the byte-walk below — see `stripFrontmatterLines`).
|
|
313
|
+
*
|
|
314
|
+
* Exported (#877) so `countContentBytes` reuses this exact classification
|
|
315
|
+
* instead of a second hand-rolled copy — see the module doc's "Guard &
|
|
316
|
+
* Threshold Design" cross-reference in `.claude/rules/development.md` on
|
|
317
|
+
* why a duplicated classifier is a drift hazard, not a convenience.
|
|
318
|
+
*
|
|
319
|
+
* @param {string} content - raw file contents
|
|
320
|
+
* @returns {number}
|
|
321
|
+
*/
|
|
322
|
+
export function countDirectives(content) {
|
|
323
|
+
const lines = stripFrontmatterLines(content);
|
|
324
|
+
|
|
190
325
|
let count = 0;
|
|
191
326
|
let inFence = false;
|
|
192
327
|
|
|
193
|
-
for (
|
|
194
|
-
const line = lines[i];
|
|
195
|
-
|
|
328
|
+
for (const line of lines) {
|
|
196
329
|
// Toggle code-fence state on any line that opens/closes a fence.
|
|
197
330
|
if (/^\s*```/.test(line)) {
|
|
198
331
|
inFence = !inFence;
|
|
@@ -212,6 +345,43 @@ function countDirectives(content) {
|
|
|
212
345
|
return count;
|
|
213
346
|
}
|
|
214
347
|
|
|
348
|
+
/**
|
|
349
|
+
* Byte-walk companion to `countDirectives` (#877 FA2). Sums the UTF-8 byte
|
|
350
|
+
* length of a rule file's BODY (everything after a leading YAML frontmatter
|
|
351
|
+
* block, reusing `stripFrontmatterLines` — the exact same frontmatter
|
|
352
|
+
* classification `countDirectives` uses, so the two dimensions can never
|
|
353
|
+
* disagree on where the body starts).
|
|
354
|
+
*
|
|
355
|
+
* Deliberately UNLIKE `countDirectives`: fenced code blocks are NOT
|
|
356
|
+
* excluded here. That divergence is the entire point of the byte
|
|
357
|
+
* dimension — the #877 audit measured that fenced-code and prose bytes
|
|
358
|
+
* (both invisible to the directive-line heuristic) still consume real
|
|
359
|
+
* instruction-budget payload. Frontmatter is excluded from both dimensions
|
|
360
|
+
* identically because it is metadata, not instructional content.
|
|
361
|
+
*
|
|
362
|
+
* @param {string} content - raw file contents
|
|
363
|
+
* @returns {number} UTF-8 byte length of the body (0 for empty/non-string input)
|
|
364
|
+
*/
|
|
365
|
+
function countContentBytes(content) {
|
|
366
|
+
const lines = stripFrontmatterLines(content);
|
|
367
|
+
if (lines.length === 0) return 0;
|
|
368
|
+
return Buffer.byteLength(lines.join('\n'), 'utf8');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Sums `countContentBytes` over an entry list already filtered to
|
|
373
|
+
* `alwaysOn === true`. Small private helper so the three surface totals
|
|
374
|
+
* below (`coordinator` / `wave` / `always`) share one summation shape.
|
|
375
|
+
*
|
|
376
|
+
* @param {Array<{content: string}>} entries
|
|
377
|
+
* @returns {number}
|
|
378
|
+
*/
|
|
379
|
+
function sumBytes(entries) {
|
|
380
|
+
let bytes = 0;
|
|
381
|
+
for (const entry of entries) bytes += countContentBytes(entry.content);
|
|
382
|
+
return bytes;
|
|
383
|
+
}
|
|
384
|
+
|
|
215
385
|
/**
|
|
216
386
|
* Pure computation — always returns the full shape (never null, never throws).
|
|
217
387
|
*
|
|
@@ -219,87 +389,244 @@ function countDirectives(content) {
|
|
|
219
389
|
* @param {string} [opts.repoRoot] project root (defaults to process.cwd()).
|
|
220
390
|
* @param {string} [opts.rulesDir] rules directory (defaults to <repoRoot>/.claude/rules).
|
|
221
391
|
* @param {number} [opts.ceiling] directive ceiling (defaults to DEFAULT_CEILING).
|
|
392
|
+
* @param {number} [opts.byteCeiling] byte ceiling (defaults to DEFAULT_BYTE_CEILING).
|
|
393
|
+
* #931a: the byte dimension shipped in #877 as DATA ONLY — nothing read it,
|
|
394
|
+
* so a rule file could grow without limit as long as it added few bullet
|
|
395
|
+
* lines. It is now a verdict axis alongside the directive count.
|
|
396
|
+
* @param {'wave'|'coordinator'|null} [opts.context] (#877; corrected #893)
|
|
397
|
+
* narrows the PRIMARY totals (`totalDirectives`/`totalBytes`/`perFile`) to
|
|
398
|
+
* what a given surface actually receives, via rule-loader's own tier gate
|
|
399
|
+
* (`loadApplicableRules({context})`) — no hand-rolled tier conditionals
|
|
400
|
+
* here:
|
|
401
|
+
* - `'wave'`: excludes `tier: coordinator-only` (what a WAVE agent
|
|
402
|
+
* receives).
|
|
403
|
+
* - `'coordinator'` (#893 fix — previously silently coerced to `null`,
|
|
404
|
+
* i.e. untiered): excludes `tier: wave-only` — mirrors
|
|
405
|
+
* `loadApplicableRules({context:'coordinator'})` exactly, the REAL
|
|
406
|
+
* coordinator delivery path (`print-applicable-rules.mjs --context
|
|
407
|
+
* coordinator`).
|
|
408
|
+
* - `null` (default) OR any unrecognised value (`undefined`, `'bogus'`,
|
|
409
|
+
* …): the pre-#877 tier-agnostic shape — every always-on rule,
|
|
410
|
+
* regardless of tier. This ALSO matches rule-loader's own
|
|
411
|
+
* `context: null` semantics (no tier gating at all — see
|
|
412
|
+
* `rule-loader.mjs`'s `applyGates`), so `null` is not a special case
|
|
413
|
+
* invented by this module; it is the same "no tier gate" behaviour
|
|
414
|
+
* rule-loader itself defines. Fail-open: an unrecognised string never
|
|
415
|
+
* throws, it just falls back to this same untiered shape.
|
|
416
|
+
* This `context` param is independent of `bySurface`, which is ALWAYS
|
|
417
|
+
* computed the same way for all three surfaces regardless of `context`
|
|
418
|
+
* (see the `bySurface` doc below).
|
|
222
419
|
* @returns {{
|
|
223
420
|
* totalDirectives: number,
|
|
224
|
-
*
|
|
421
|
+
* totalBytes: number,
|
|
422
|
+
* perFile: Array<{ file: string, count: number, bytes: number }>,
|
|
225
423
|
* ceiling: number,
|
|
424
|
+
* byteCeiling: number,
|
|
425
|
+
* overDirectiveBudget: boolean,
|
|
426
|
+
* overByteBudget: boolean,
|
|
226
427
|
* overBudget: boolean,
|
|
227
428
|
* severity: 'ok' | 'warn',
|
|
429
|
+
* bySurface: { coordinator: number, wave: number, always: number },
|
|
228
430
|
* }}
|
|
229
431
|
* perFile is sorted DESC by count. On missing/unreadable dir →
|
|
230
|
-
* { totalDirectives: 0, perFile: [], ceiling,
|
|
432
|
+
* { totalDirectives: 0, totalBytes: 0, perFile: [], ceiling, byteCeiling,
|
|
433
|
+
* overDirectiveBudget: false, overByteBudget: false, overBudget: false,
|
|
434
|
+
* severity: 'ok', bySurface: { coordinator: 0, wave: 0, always: 0 } }.
|
|
435
|
+
*
|
|
436
|
+
* #931a verdict rule — `overBudget` is the OR of the two axes
|
|
437
|
+
* (`overDirectiveBudget || overByteBudget`), NOT a per-axis severity split:
|
|
438
|
+
* - Both axes measure the SAME quantity (the cost of the always-on
|
|
439
|
+
* instruction corpus) on different scales, and either one breaching is
|
|
440
|
+
* equally actionable. A 9 KB prose-only rule with three bullets is
|
|
441
|
+
* invisible to the directive axis while consuming real payload — that
|
|
442
|
+
* gap is precisely what #877 measured and what this OR closes.
|
|
443
|
+
* - A per-axis severity would need a third value in the banner-shape
|
|
444
|
+
* vocabulary (`{severity:'warn', message}` is a fixed convention shared
|
|
445
|
+
* with checkQgCommandDrift / checkCiStatus), i.e. a protocol change for
|
|
446
|
+
* every Phase-4 banner consumer — disproportionate to the gain.
|
|
447
|
+
* - Alarm fatigue is governed by the CEILING CHOICE, not by the severity
|
|
448
|
+
* label: a ceiling calibrated above the current state (see
|
|
449
|
+
* DEFAULT_BYTE_CEILING) fires rarely, whereas a too-tight ceiling
|
|
450
|
+
* produces a line at every session start no matter how it is labelled.
|
|
451
|
+
* The two sub-flags are exported so a consumer can discriminate WHICH axis
|
|
452
|
+
* broke without re-deriving the comparison (the banner below does exactly
|
|
453
|
+
* this to choose its Top-files sort key).
|
|
454
|
+
*
|
|
455
|
+
* bySurface definition (#877; corrected #893 — NOT the additive
|
|
456
|
+
* `coordinator + wave === totalBytes` identity, which double-counts the
|
|
457
|
+
* `always` tier):
|
|
458
|
+
* bySurface.wave === bytes of every always-on rule whose tier is not
|
|
459
|
+
* 'coordinator-only' (i.e. what `loadApplicableRules({context:'wave'})`
|
|
460
|
+
* returns) — equivalently "always + wave-only" bytes.
|
|
461
|
+
* bySurface.coordinator === bytes of every always-on rule whose tier is
|
|
462
|
+
* not 'wave-only' (i.e. what `loadApplicableRules({context:'coordinator'})`
|
|
463
|
+
* returns — the REAL coordinator delivery path,
|
|
464
|
+
* `print-applicable-rules.mjs --context coordinator`) —
|
|
465
|
+
* equivalently "always + coordinator-only" bytes. #893 fix: this is
|
|
466
|
+
* NOT a tier-agnostic alias of `totalBytes` — a pre-#893 doc revision
|
|
467
|
+
* claimed the coordinator "structurally sees the entire always-on
|
|
468
|
+
* corpus regardless of tier", which does not match rule-loader's own
|
|
469
|
+
* tier gate and is corrected here.
|
|
470
|
+
* bySurface.always === bytes of always-on rules with `tier === 'always'` only.
|
|
471
|
+
*
|
|
472
|
+
* `always` is a strict subset of BOTH `wave` and `coordinator` (neither
|
|
473
|
+
* tier gate excludes `tier: 'always'`), but `wave` and `coordinator` are
|
|
474
|
+
* SIBLING projections of the same corpus, not nested in each other — each
|
|
475
|
+
* excludes a DIFFERENT tier, so their relative size depends on how much
|
|
476
|
+
* content actually carries `tier: wave-only` vs. `tier: coordinator-only`.
|
|
477
|
+
* Do NOT assume `wave ⊆ coordinator` or `coordinator ⊆ wave`.
|
|
478
|
+
*
|
|
479
|
+
* `bySurface` is computed identically regardless of what `opts.context`
|
|
480
|
+
* was requested for the PRIMARY totals above — e.g. a `context: 'wave'`
|
|
481
|
+
* call still reports the FULL coordinator-surface byte sum in
|
|
482
|
+
* `bySurface.coordinator`, not the wave-narrowed `totalBytes`.
|
|
231
483
|
*/
|
|
232
484
|
export function computeInstructionBudget(opts = {}) {
|
|
233
485
|
const repoRoot = opts.repoRoot ?? process.cwd();
|
|
234
486
|
const rulesDir = opts.rulesDir ?? join(repoRoot, '.claude/rules');
|
|
235
487
|
const ceiling = typeof opts.ceiling === 'number' ? opts.ceiling : DEFAULT_CEILING;
|
|
488
|
+
const byteCeiling =
|
|
489
|
+
typeof opts.byteCeiling === 'number' ? opts.byteCeiling : DEFAULT_BYTE_CEILING;
|
|
490
|
+
// #893 fix: 'coordinator' used to fall through to the `null` (untiered)
|
|
491
|
+
// branch below — silently measuring the WRONG rule set for a coordinator
|
|
492
|
+
// context (it never excluded `tier: wave-only`). Now explicitly recognised
|
|
493
|
+
// alongside 'wave'; any other value (incl. `undefined`/'bogus') still
|
|
494
|
+
// fails open to the untiered `null` shape — see the param doc above.
|
|
495
|
+
const context =
|
|
496
|
+
opts.context === 'wave' ? 'wave' : opts.context === 'coordinator' ? 'coordinator' : null;
|
|
236
497
|
|
|
237
498
|
const empty = {
|
|
238
499
|
totalDirectives: 0,
|
|
500
|
+
totalBytes: 0,
|
|
239
501
|
perFile: [],
|
|
240
502
|
ceiling,
|
|
503
|
+
byteCeiling,
|
|
504
|
+
overDirectiveBudget: false,
|
|
505
|
+
overByteBudget: false,
|
|
241
506
|
overBudget: false,
|
|
242
507
|
severity: 'ok',
|
|
508
|
+
bySurface: { coordinator: 0, wave: 0, always: 0 },
|
|
243
509
|
};
|
|
244
510
|
|
|
245
|
-
let
|
|
511
|
+
let allEntries;
|
|
512
|
+
let waveEntries;
|
|
513
|
+
let coordinatorEntries;
|
|
246
514
|
try {
|
|
247
|
-
// Empty scopePaths → only always-on rules (no glob matches) are
|
|
248
|
-
|
|
515
|
+
// Empty scopePaths → only always-on rules (no glob matches) are
|
|
516
|
+
// returned by any of the three calls. `context: null` is the pre-#877
|
|
517
|
+
// shape (tier-agnostic — no tier gating at all, matching rule-loader's
|
|
518
|
+
// own `context: null` semantics); `context: 'wave'` / `context:
|
|
519
|
+
// 'coordinator'` each apply rule-loader's own tier gate (`applyGates`)
|
|
520
|
+
// — reused, not reimplemented. All three lists are loaded unconditionally
|
|
521
|
+
// (not just the one matching `opts.context`) because `bySurface` reports
|
|
522
|
+
// all three surfaces regardless of which `context` was requested for the
|
|
523
|
+
// PRIMARY totals (see doc above).
|
|
524
|
+
allEntries = loadApplicableRules({ rulesDir, scopePaths: [] });
|
|
525
|
+
waveEntries = loadApplicableRules({ rulesDir, scopePaths: [], context: 'wave' });
|
|
526
|
+
coordinatorEntries = loadApplicableRules({ rulesDir, scopePaths: [], context: 'coordinator' });
|
|
249
527
|
} catch {
|
|
250
528
|
return empty;
|
|
251
529
|
}
|
|
252
530
|
|
|
253
|
-
if (
|
|
531
|
+
if (
|
|
532
|
+
!Array.isArray(allEntries) ||
|
|
533
|
+
!Array.isArray(waveEntries) ||
|
|
534
|
+
!Array.isArray(coordinatorEntries)
|
|
535
|
+
) {
|
|
536
|
+
return empty;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const alwaysOnAll = allEntries.filter((e) => e && e.alwaysOn === true);
|
|
540
|
+
const alwaysOnWave = waveEntries.filter((e) => e && e.alwaysOn === true);
|
|
541
|
+
const alwaysOnCoordinator = coordinatorEntries.filter((e) => e && e.alwaysOn === true);
|
|
542
|
+
|
|
543
|
+
const bySurface = {
|
|
544
|
+
coordinator: sumBytes(alwaysOnCoordinator),
|
|
545
|
+
wave: sumBytes(alwaysOnWave),
|
|
546
|
+
always: sumBytes(alwaysOnAll.filter((e) => e.tier === 'always')),
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
// Surface-selected entry set for the PRIMARY totals. `context: null`
|
|
550
|
+
// (default, or any unrecognised value) preserves pre-#877 behaviour —
|
|
551
|
+
// every always-on rule, tier-agnostic. `context: 'wave'` / `context:
|
|
552
|
+
// 'coordinator'` each narrow to the exact same filtered list their
|
|
553
|
+
// `bySurface` counterpart sums (no second, separately computed entry list).
|
|
554
|
+
const selectedEntries =
|
|
555
|
+
context === 'wave' ? alwaysOnWave : context === 'coordinator' ? alwaysOnCoordinator : alwaysOnAll;
|
|
254
556
|
|
|
255
557
|
const perFile = [];
|
|
256
558
|
let totalDirectives = 0;
|
|
559
|
+
let totalBytes = 0;
|
|
257
560
|
|
|
258
|
-
for (const entry of
|
|
259
|
-
if (!entry || entry.alwaysOn !== true) continue;
|
|
561
|
+
for (const entry of selectedEntries) {
|
|
260
562
|
const count = countDirectives(entry.content);
|
|
563
|
+
const bytes = countContentBytes(entry.content);
|
|
261
564
|
totalDirectives += count;
|
|
262
|
-
|
|
565
|
+
totalBytes += bytes;
|
|
566
|
+
perFile.push({ file: basename(entry.path), count, bytes });
|
|
263
567
|
}
|
|
264
568
|
|
|
265
569
|
// Sort DESC by count; tie-break by filename for deterministic output.
|
|
266
570
|
perFile.sort((a, b) => b.count - a.count || a.file.localeCompare(b.file));
|
|
267
571
|
|
|
268
|
-
|
|
572
|
+
// #931a: both axes are evaluated; `overBudget` is their OR (rationale in the
|
|
573
|
+
// returns-doc above). Strict `>` on both, so a total sitting exactly ON its
|
|
574
|
+
// ceiling is still "ok" — the byte axis inherits the directive axis's
|
|
575
|
+
// long-standing boundary semantics rather than inventing a second rule.
|
|
576
|
+
const overDirectiveBudget = totalDirectives > ceiling;
|
|
577
|
+
const overByteBudget = totalBytes > byteCeiling;
|
|
578
|
+
const overBudget = overDirectiveBudget || overByteBudget;
|
|
269
579
|
|
|
270
580
|
return {
|
|
271
581
|
totalDirectives,
|
|
582
|
+
totalBytes,
|
|
272
583
|
perFile,
|
|
273
584
|
ceiling,
|
|
585
|
+
byteCeiling,
|
|
586
|
+
overDirectiveBudget,
|
|
587
|
+
overByteBudget,
|
|
274
588
|
overBudget,
|
|
275
589
|
severity: overBudget ? 'warn' : 'ok',
|
|
590
|
+
bySurface,
|
|
276
591
|
};
|
|
277
592
|
}
|
|
278
593
|
|
|
279
594
|
/**
|
|
280
595
|
* Banner wrapper — session-start Phase 4 convention.
|
|
281
596
|
*
|
|
282
|
-
* Reads `instruction-budget.{enabled,ceiling,mode}` from Session
|
|
283
|
-
* (CLAUDE.md / AGENTS.md at `opts.repoRoot`, default process.cwd()):
|
|
597
|
+
* Reads `instruction-budget.{enabled,ceiling,byte-ceiling,mode}` from Session
|
|
598
|
+
* Config (CLAUDE.md / AGENTS.md at `opts.repoRoot`, default process.cwd()):
|
|
284
599
|
* - `enabled: false` OR `mode: off` → returns null (silent no-op).
|
|
285
|
-
* - The config `ceiling`
|
|
286
|
-
* (an explicit opt wins, keeping
|
|
600
|
+
* - The config `ceiling` / `byte-ceiling` are used unless `opts.ceiling` /
|
|
601
|
+
* `opts.byteCeiling` are explicitly supplied (an explicit opt wins, keeping
|
|
602
|
+
* callers that pin a ceiling deterministic).
|
|
287
603
|
* - Config-load failure → graceful fallback `{enabled:true, ceiling:480,
|
|
288
|
-
* mode:warn}` so the probe still computes (mirrors
|
|
604
|
+
* 'byte-ceiling':114000, mode:warn}` so the probe still computes (mirrors
|
|
605
|
+
* the other probes).
|
|
289
606
|
* Never throws.
|
|
290
607
|
*
|
|
608
|
+
* The message names WHICH axis breached (#931a) — a banner that only said
|
|
609
|
+
* "over budget" would leave the operator guessing whether to prune bullets or
|
|
610
|
+
* prose. It stays at three lines because it renders at every session start.
|
|
611
|
+
*
|
|
291
612
|
* @param {object} [opts] forwarded to computeInstructionBudget.
|
|
292
613
|
* @param {string} [opts.repoRoot] project root for the config read.
|
|
293
|
-
* @param {number} [opts.ceiling] explicit ceiling override (wins over config).
|
|
614
|
+
* @param {number} [opts.ceiling] explicit directive-ceiling override (wins over config).
|
|
615
|
+
* @param {number} [opts.byteCeiling] explicit byte-ceiling override (wins over config).
|
|
294
616
|
* @returns {{ severity: 'warn', message: string } | null}
|
|
295
|
-
* null when disabled / off / at-or-under ceiling OR on any read failure.
|
|
617
|
+
* null when disabled / off / both axes at-or-under ceiling OR on any read failure.
|
|
296
618
|
*/
|
|
297
619
|
export function checkInstructionBudget(opts = {}) {
|
|
298
620
|
let cfg;
|
|
299
621
|
try {
|
|
300
622
|
cfg = loadInstructionBudgetConfig(opts.repoRoot);
|
|
301
623
|
} catch {
|
|
302
|
-
cfg = {
|
|
624
|
+
cfg = {
|
|
625
|
+
enabled: true,
|
|
626
|
+
ceiling: DEFAULT_CEILING,
|
|
627
|
+
'byte-ceiling': DEFAULT_BYTE_CEILING,
|
|
628
|
+
mode: 'warn',
|
|
629
|
+
};
|
|
303
630
|
}
|
|
304
631
|
|
|
305
632
|
// Opt-out gates — return null without computing.
|
|
@@ -307,23 +634,47 @@ export function checkInstructionBudget(opts = {}) {
|
|
|
307
634
|
|
|
308
635
|
// An explicit ceiling opt wins over the config ceiling; otherwise use config.
|
|
309
636
|
const ceiling = typeof opts.ceiling === 'number' ? opts.ceiling : cfg.ceiling;
|
|
637
|
+
const byteCeiling =
|
|
638
|
+
typeof opts.byteCeiling === 'number'
|
|
639
|
+
? opts.byteCeiling
|
|
640
|
+
: typeof cfg['byte-ceiling'] === 'number'
|
|
641
|
+
? cfg['byte-ceiling']
|
|
642
|
+
: DEFAULT_BYTE_CEILING;
|
|
310
643
|
|
|
311
644
|
let budget;
|
|
312
645
|
try {
|
|
313
|
-
budget = computeInstructionBudget({ ...opts, ceiling });
|
|
646
|
+
budget = computeInstructionBudget({ ...opts, ceiling, byteCeiling });
|
|
314
647
|
} catch {
|
|
315
648
|
return null; // never throw out of the banner wrapper
|
|
316
649
|
}
|
|
317
650
|
|
|
318
651
|
if (!budget || !budget.overBudget) return null;
|
|
319
652
|
|
|
320
|
-
|
|
653
|
+
// Name only the breached axes — listing a healthy axis would pad the line
|
|
654
|
+
// without telling the operator anything they must act on.
|
|
655
|
+
const axes = [];
|
|
656
|
+
if (budget.overDirectiveBudget) {
|
|
657
|
+
axes.push(`directives ${budget.totalDirectives} > ${budget.ceiling}`);
|
|
658
|
+
}
|
|
659
|
+
if (budget.overByteBudget) {
|
|
660
|
+
axes.push(`bytes ${budget.totalBytes} > ${budget.byteCeiling}`);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// `perFile` arrives sorted DESC by directive count. When ONLY the byte axis
|
|
664
|
+
// broke, that ordering points at the wrong files — re-sort by bytes so the
|
|
665
|
+
// Top-files line lists the ones actually responsible for the breach.
|
|
666
|
+
const ranked =
|
|
667
|
+
budget.overByteBudget && !budget.overDirectiveBudget
|
|
668
|
+
? [...budget.perFile].sort((a, b) => b.bytes - a.bytes || a.file.localeCompare(b.file))
|
|
669
|
+
: budget.perFile;
|
|
670
|
+
|
|
671
|
+
const top = ranked
|
|
321
672
|
.slice(0, 3)
|
|
322
|
-
.map((f) => `${f.file} (${f.count})`)
|
|
673
|
+
.map((f) => `${f.file} (${f.count} dir, ${f.bytes} B)`)
|
|
323
674
|
.join(', ');
|
|
324
675
|
|
|
325
676
|
const message = [
|
|
326
|
-
`⚠ Instruction budget
|
|
677
|
+
`⚠ Instruction budget over — ${axes.join(' · ')} across ${budget.perFile.length} always-on rules.`,
|
|
327
678
|
` Top files: ${top}`,
|
|
328
679
|
' See the instruction-budget audit (#687; archived in the private Meta-Vault) for the prune/demote list.',
|
|
329
680
|
].join('\n');
|