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.
Files changed (221) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +185 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/commands/session.md +6 -2
  21. package/docs/USER-GUIDE.md +9 -4
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +6 -6
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +794 -0
  28. package/docs/rule-authoring.md +58 -9
  29. package/docs/session-config-reference.md +245 -50
  30. package/docs/session-config-template.md +39 -26
  31. package/hooks/_lib/guard-source-loader.mjs +680 -0
  32. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  33. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  34. package/hooks/config-protection.mjs +0 -0
  35. package/hooks/enforce-commands.mjs +226 -19
  36. package/hooks/enforce-scope.mjs +133 -9
  37. package/hooks/hooks-codex.json +1 -1
  38. package/hooks/hooks-cursor.json +11 -2
  39. package/hooks/hooks-pi.json +10 -0
  40. package/hooks/hooks.json +21 -1
  41. package/hooks/on-session-end.mjs +178 -18
  42. package/hooks/on-session-start.mjs +30 -4
  43. package/hooks/post-bash-write-verify.mjs +977 -0
  44. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  45. package/hooks/pre-bash-destructive-guard.mjs +616 -164
  46. package/hooks/pre-bash-issue-budget.mjs +167 -0
  47. package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
  48. package/hooks/pre-bash-templates-first.mjs +96 -63
  49. package/hooks/subagent-telemetry.mjs +527 -37
  50. package/package.json +6 -3
  51. package/pi/prompts/contract-version-bump.md +12 -0
  52. package/rules/README.md +32 -0
  53. package/scripts/archive-closed-prds.mjs +12 -22
  54. package/scripts/autopilot-multi.mjs +103 -20
  55. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  56. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  57. package/scripts/check-doc-consistency.sh +17 -1
  58. package/scripts/emit-session.mjs +3 -40
  59. package/scripts/eval-session.mjs +50 -9
  60. package/scripts/fleet-instruction-scan.mjs +141 -0
  61. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  62. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  63. package/scripts/lib/backlog-scan.mjs +39 -6
  64. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  65. package/scripts/lib/ci-status-banner.mjs +75 -12
  66. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  67. package/scripts/lib/command-blocker.mjs +1273 -58
  68. package/scripts/lib/config/config-protection.mjs +2 -1
  69. package/scripts/lib/config/drift-check.mjs +9 -1
  70. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  71. package/scripts/lib/config/issue-budget.mjs +123 -0
  72. package/scripts/lib/config/reconcile.mjs +21 -0
  73. package/scripts/lib/config/section-extractor.mjs +121 -1
  74. package/scripts/lib/config-schema.mjs +23 -3
  75. package/scripts/lib/config.mjs +17 -0
  76. package/scripts/lib/convergence-monitor.mjs +49 -3
  77. package/scripts/lib/description-surface.mjs +535 -0
  78. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  79. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  80. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  81. package/scripts/lib/eval/engine.mjs +47 -5
  82. package/scripts/lib/events.mjs +59 -7
  83. package/scripts/lib/gates/gate-full.mjs +15 -3
  84. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  85. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  86. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  87. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  88. package/scripts/lib/handover-gate.mjs +7 -3
  89. package/scripts/lib/hardening.mjs +9 -9
  90. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  91. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  92. package/scripts/lib/io.mjs +345 -10
  93. package/scripts/lib/issue-budget.mjs +269 -0
  94. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  95. package/scripts/lib/label-scope.mjs +47 -0
  96. package/scripts/lib/learnings/affinity.mjs +434 -0
  97. package/scripts/lib/learnings/candidates.mjs +736 -0
  98. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  99. package/scripts/lib/learnings/judgment.mjs +782 -0
  100. package/scripts/lib/learnings/kebab.mjs +128 -0
  101. package/scripts/lib/learnings/schema.mjs +43 -3
  102. package/scripts/lib/learnings/select.mjs +550 -0
  103. package/scripts/lib/lock-reaper.mjs +1 -2
  104. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  105. package/scripts/lib/peer-discovery.mjs +645 -0
  106. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  107. package/scripts/lib/product-repo-detect.mjs +9 -8
  108. package/scripts/lib/project-hygiene.mjs +432 -0
  109. package/scripts/lib/quality-gate.mjs +167 -0
  110. package/scripts/lib/recommendations-v0.mjs +1 -1
  111. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  112. package/scripts/lib/reconcile/emitter.mjs +128 -24
  113. package/scripts/lib/reconcile/engine.mjs +156 -54
  114. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  115. package/scripts/lib/reconcile/renderer.mjs +141 -25
  116. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  117. package/scripts/lib/reconcile/writer.mjs +95 -1
  118. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  119. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  120. package/scripts/lib/resource-probe.mjs +19 -0
  121. package/scripts/lib/rule-loader.mjs +6 -0
  122. package/scripts/lib/scope-baseline.mjs +564 -0
  123. package/scripts/lib/scope-gate.mjs +568 -145
  124. package/scripts/lib/session-close-backfill.mjs +63 -8
  125. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  126. package/scripts/lib/session-id.mjs +221 -41
  127. package/scripts/lib/session-lock.mjs +304 -6
  128. package/scripts/lib/session-record-repair.mjs +551 -0
  129. package/scripts/lib/session-schema/constants.mjs +22 -3
  130. package/scripts/lib/session-schema/serializer.mjs +54 -0
  131. package/scripts/lib/session-schema/validator.mjs +16 -0
  132. package/scripts/lib/session-schema.mjs +1 -0
  133. package/scripts/lib/session-token-rollup.mjs +68 -6
  134. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  135. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  136. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  137. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  138. package/scripts/lib/soul-resolve.mjs +12 -0
  139. package/scripts/lib/spiral-carryover.mjs +142 -30
  140. package/scripts/lib/state-md/mission-status.mjs +53 -3
  141. package/scripts/lib/subagents-schema.mjs +43 -9
  142. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  143. package/scripts/lib/tests-src-ratio.mjs +484 -0
  144. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  145. package/scripts/lib/validate/check-agents.mjs +56 -0
  146. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  147. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  148. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  149. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  150. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  151. package/scripts/lib/validate/check-rules.mjs +244 -36
  152. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  153. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  154. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  155. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  156. package/scripts/lib/vault-archive.mjs +17 -2
  157. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  158. package/scripts/lib/vault-mirror/process.mjs +30 -0
  159. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  160. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  161. package/scripts/lib/wave-resource-gate.mjs +115 -11
  162. package/scripts/lib/worktree/listing.mjs +44 -7
  163. package/scripts/mcp-server.sh +17 -3
  164. package/scripts/measure-context-overhead.sh +151 -0
  165. package/scripts/memory-propose.mjs +72 -9
  166. package/scripts/print-applicable-rules.mjs +218 -16
  167. package/scripts/print-learnings-index.mjs +474 -0
  168. package/scripts/release.mjs +534 -0
  169. package/scripts/repair-invalid-sessions.mjs +209 -0
  170. package/scripts/run-quality-gate.mjs +123 -5
  171. package/scripts/sweep-expired-learnings.mjs +192 -32
  172. package/scripts/validate-plugin.mjs +21 -0
  173. package/scripts/validate-wave-scope.mjs +182 -17
  174. package/scripts/vault-integration-watcher.mjs +32 -10
  175. package/skills/_shared/config-reading.md +2 -2
  176. package/skills/bootstrap/fast-template.md +1 -1
  177. package/skills/brainstorm/soul.md +47 -1
  178. package/skills/claude-md-drift-check/checker.mjs +145 -28
  179. package/skills/contract-version-bump/SKILL.md +219 -0
  180. package/skills/discovery/SKILL.md +4 -4
  181. package/skills/discovery/issue-templates.md +11 -11
  182. package/skills/discovery/probes-audit.md +1 -1
  183. package/skills/discovery/probes-feature.md +1 -1
  184. package/skills/discovery/probes-session.md +26 -5
  185. package/skills/ecosystem-health/SKILL.md +1 -1
  186. package/skills/ecosystem-health/wizard.md +4 -4
  187. package/skills/evolve/SKILL.md +117 -18
  188. package/skills/gitlab-ops/SKILL.md +25 -12
  189. package/skills/gitlab-portfolio/SKILL.md +2 -2
  190. package/skills/grill/soul.md +44 -1
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +17 -1
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/plan/soul.md +46 -3
  199. package/skills/quality-gates/SKILL.md +1 -1
  200. package/skills/reconcile/SKILL.md +21 -4
  201. package/skills/session-end/SKILL.md +34 -36
  202. package/skills/session-end/discovery-scan.md +4 -2
  203. package/skills/session-end/drift-operations.md +4 -4
  204. package/skills/session-end/metrics-collection.md +13 -0
  205. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  206. package/skills/session-end/phase-3-6-tail.md +32 -2
  207. package/skills/session-end/plan-verification.md +6 -7
  208. package/skills/session-end/session-metrics-write.md +2 -0
  209. package/skills/session-end/vault-operations.md +1 -1
  210. package/skills/session-end/verification-checklist.md +1 -1
  211. package/skills/session-plan/SKILL.md +6 -2
  212. package/skills/session-plan/wave-template.md +2 -0
  213. package/skills/session-start/SKILL.md +75 -7
  214. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  215. package/skills/session-start/soul.md +41 -1
  216. package/skills/test-runner/SKILL.md +2 -2
  217. package/skills/vault-sync/validator.mjs +108 -7
  218. package/skills/wave-executor/SKILL.md +6 -7
  219. package/skills/wave-executor/circuit-breaker.md +2 -0
  220. package/skills/wave-executor/wave-loop.md +198 -80
  221. 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 is delegated to `loadApplicableRules` from
20
- * `./rule-loader.mjs` (single SSOT) — we do NOT hard-code the file list.
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, mode: 'warn' }`
50
- * so the probe still computes (mirrors the other session-start probes).
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 = { enabled: true, ceiling: DEFAULT_CEILING, mode: 'warn' };
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 ?? { enabled: true, ceiling: DEFAULT_CEILING, mode: 'warn' };
91
- if (typeof content !== 'string' || content === '') return { ...base };
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
- * Count always-on directives in a single rule file's content.
164
- *
165
- * Deterministic heuristiccounts lines that represent a directive:
166
- * - bullets: /^\s*[-*+]\s/
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 {number}
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 countDirectives(content) {
177
- if (typeof content !== 'string' || content === '') return 0;
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 (; i < lines.length; i++) {
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
- * perFile: Array<{ file: string, count: number }>,
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, overBudget: false, severity: 'ok' }.
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 entries;
511
+ let allEntries;
512
+ let waveEntries;
513
+ let coordinatorEntries;
246
514
  try {
247
- // Empty scopePaths → only always-on rules (no glob matches) are returned.
248
- entries = loadApplicableRules({ rulesDir, scopePaths: [] });
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 (!Array.isArray(entries)) return empty;
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 entries) {
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
- perFile.push({ file: basename(entry.path), count });
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
- const overBudget = totalDirectives > ceiling;
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 Config
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` is used unless `opts.ceiling` is explicitly supplied
286
- * (an explicit opt wins, keeping callers that pin a ceiling deterministic).
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 the other probes).
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 = { enabled: true, ceiling: DEFAULT_CEILING, mode: 'warn' };
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
- const top = budget.perFile
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: ${budget.totalDirectives} always-on directives across ${budget.perFile.length} rules — over ceiling ${budget.ceiling}.`,
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');