session-orchestrator 4.1.0 → 5.0.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 (230) hide show
  1. package/.agents/skills/session-plan/SKILL.md +1 -1
  2. package/.agents/skills/session-start/SKILL.md +1 -1
  3. package/.agents/skills/ux-grill/SKILL.md +22 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +3 -2
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
  8. package/.codex-plugin/skills/session-start/SKILL.md +1 -1
  9. package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
  10. package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
  11. package/.cursor/commands/ux-grill.md +14 -0
  12. package/.cursor/skills/session-plan/SKILL.md +1 -1
  13. package/.cursor/skills/session-start/SKILL.md +1 -1
  14. package/.cursor/skills/ux-grill/SKILL.md +13 -0
  15. package/.cursor-plugin/plugin.json +1 -1
  16. package/AGENTS.md +2 -1
  17. package/CHANGELOG.md +128 -1
  18. package/README.md +98 -86
  19. package/agents/dialectic-deriver.md +11 -0
  20. package/agents/ux-evaluator.md +1 -1
  21. package/commands/close.md +3 -3
  22. package/commands/go.md +2 -0
  23. package/commands/memory-cleanup.md +4 -3
  24. package/commands/persona-panel.md +1 -1
  25. package/commands/session.md +3 -2
  26. package/commands/ux-grill.md +51 -0
  27. package/docs/README.md +4 -4
  28. package/docs/USER-GUIDE.md +117 -50
  29. package/docs/agent-authoring.md +2 -2
  30. package/docs/baseline.md +55 -1
  31. package/docs/ci-setup.md +1 -1
  32. package/docs/codex-setup.md +9 -0
  33. package/docs/components.md +9 -9
  34. package/docs/cursor-setup.md +1 -0
  35. package/docs/events-schema.md +13 -6
  36. package/docs/github-mirror-protection.md +61 -20
  37. package/docs/instruction-delivery.md +1 -1
  38. package/docs/memory-proposal-flow.md +3 -3
  39. package/docs/migration-v4.md +2 -2
  40. package/docs/migration-v5.md +62 -0
  41. package/docs/owner-config-schema.md +74 -90
  42. package/docs/persona-panel.md +4 -4
  43. package/docs/pi-setup.md +1 -0
  44. package/docs/rule-authoring.md +13 -6
  45. package/docs/scope-collision-guard.md +16 -0
  46. package/docs/session-config-reference.md +55 -22
  47. package/docs/session-config-template.md +9 -5
  48. package/docs/vault-docs-architecture.md +4 -2
  49. package/hooks/_lib/hook-import-set.json +70 -3
  50. package/hooks/_lib/lock-bootstrap.mjs +84 -1
  51. package/hooks/_lib/vcs-create-matcher.mjs +401 -16
  52. package/hooks/enforce-scope.mjs +201 -0
  53. package/hooks/hooks-codex.json +1 -1
  54. package/hooks/hooks-cursor.json +5 -0
  55. package/hooks/hooks.json +7 -2
  56. package/hooks/on-session-start.mjs +171 -49
  57. package/hooks/post-bash-issue-budget-refund.mjs +375 -0
  58. package/hooks/pre-auq-clarity.mjs +70 -18
  59. package/hooks/pre-bash-issue-budget.mjs +170 -26
  60. package/hooks/subagent-telemetry.mjs +106 -20
  61. package/package.json +5 -4
  62. package/pi/prompts/ux-grill.md +12 -0
  63. package/scripts/baseline-archetypes.mjs +28 -0
  64. package/scripts/ci/assert-vitest-green.mjs +4 -2
  65. package/scripts/dialectic-deriver.mjs +32 -8
  66. package/scripts/emit-session.mjs +72 -1
  67. package/scripts/lib/agent-status.mjs +441 -9
  68. package/scripts/lib/auq/schema.mjs +10 -3
  69. package/scripts/lib/auto-dialectic.mjs +0 -68
  70. package/scripts/lib/baseline-archetypes.mjs +439 -0
  71. package/scripts/lib/build-live-signals.mjs +5 -6
  72. package/scripts/lib/ci-status-banner.mjs +29 -6
  73. package/scripts/lib/claude-md-budget-lint.mjs +52 -2
  74. package/scripts/lib/config/issue-budget.mjs +68 -8
  75. package/scripts/lib/config/private-config-dir.mjs +3 -2
  76. package/scripts/lib/config/remote-hosts.mjs +2 -2
  77. package/scripts/lib/config-schema.mjs +79 -0
  78. package/scripts/lib/config.mjs +12 -1
  79. package/scripts/lib/eval/engine.mjs +7 -1
  80. package/scripts/lib/file-lock.mjs +151 -8
  81. package/scripts/lib/git-porcelain.mjs +113 -0
  82. package/scripts/lib/instruction-budget-guard.mjs +415 -47
  83. package/scripts/lib/io.mjs +29 -4
  84. package/scripts/lib/issue-budget-reconcile.mjs +392 -0
  85. package/scripts/lib/issue-budget.mjs +412 -9
  86. package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
  87. package/scripts/lib/learnings/sizing-subject.mjs +44 -0
  88. package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
  89. package/scripts/lib/locks/state-md-lock.mjs +19 -41
  90. package/scripts/lib/maintenance-due-banner.mjs +450 -0
  91. package/scripts/lib/owner-config.example.yaml +29 -46
  92. package/scripts/lib/owner-yaml.mjs +14 -13
  93. package/scripts/lib/peer-cards/merger.mjs +143 -0
  94. package/scripts/lib/pre-dispatch-check.mjs +20 -14
  95. package/scripts/lib/project-hygiene.mjs +81 -30
  96. package/scripts/lib/quality-gate.mjs +27 -71
  97. package/scripts/lib/reconcile/engine.mjs +19 -1
  98. package/scripts/lib/reconcile/writer.mjs +278 -11
  99. package/scripts/lib/resource-probe/evaluate.mjs +19 -21
  100. package/scripts/lib/rules-sync.mjs +34 -4
  101. package/scripts/lib/scope-echo.mjs +346 -0
  102. package/scripts/lib/session-close-backfill.mjs +182 -40
  103. package/scripts/lib/session-end/phase-skip.mjs +85 -86
  104. package/scripts/lib/session-end/tail-runner.mjs +178 -0
  105. package/scripts/lib/session-lock.mjs +62 -2
  106. package/scripts/lib/session-record-repair.mjs +91 -0
  107. package/scripts/lib/session-schema/constants.mjs +6 -0
  108. package/scripts/lib/session-schema/filters.mjs +26 -1
  109. package/scripts/lib/session-schema/validator.mjs +20 -0
  110. package/scripts/lib/session-shape.mjs +558 -0
  111. package/scripts/lib/session-start-probes.mjs +429 -56
  112. package/scripts/lib/session-token-rollup.mjs +95 -10
  113. package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
  114. package/scripts/lib/state-md.mjs +1 -0
  115. package/scripts/lib/subagents-schema.mjs +77 -9
  116. package/scripts/lib/telemetry/pricing.mjs +197 -0
  117. package/scripts/lib/telemetry/sync.mjs +50 -1
  118. package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
  119. package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
  120. package/scripts/lib/tmux-layout/layouts.mjs +62 -4
  121. package/scripts/lib/ux-grill/collect.mjs +1163 -0
  122. package/scripts/lib/ux-grill/compare.mjs +285 -0
  123. package/scripts/lib/ux-grill/manifest.mjs +618 -0
  124. package/scripts/lib/ux-grill/measures.mjs +431 -0
  125. package/scripts/lib/ux-grill/paths.mjs +224 -0
  126. package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
  127. package/scripts/lib/ux-grill/reconcile.mjs +344 -0
  128. package/scripts/lib/ux-grill/run-record.mjs +316 -0
  129. package/scripts/lib/ux-grill/schema.mjs +321 -0
  130. package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
  131. package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
  132. package/scripts/lib/validate/check-unwired-features.mjs +56 -27
  133. package/scripts/lib/vault-mirror/process.mjs +2 -1
  134. package/scripts/lib/vault-status/board-lock.mjs +18 -0
  135. package/scripts/lib/vault-status/board-writer.mjs +8 -0
  136. package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
  137. package/scripts/lib/wave-resource-gate.mjs +23 -27
  138. package/scripts/lib/wave-sizing.mjs +10 -3
  139. package/scripts/materialize-wave-scope.mjs +68 -14
  140. package/scripts/mcp-server.sh +16 -1
  141. package/scripts/print-applicable-rules.mjs +7 -6
  142. package/scripts/print-learnings-index.mjs +3 -2
  143. package/scripts/release.mjs +7 -2
  144. package/scripts/session-shape.mjs +266 -0
  145. package/skills/_shared/config-reading.md +15 -9
  146. package/skills/_shared/private-capability-context.md +89 -0
  147. package/skills/bootstrap/SKILL.md +60 -209
  148. package/skills/bootstrap/_shared-template.md +99 -14
  149. package/skills/bootstrap/deep-template.md +36 -26
  150. package/skills/bootstrap/fast-template.md +44 -8
  151. package/skills/bootstrap/intensity-heuristic.md +10 -4
  152. package/skills/bootstrap/private-contract.md +119 -0
  153. package/skills/bootstrap/public-fallback.md +30 -18
  154. package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
  155. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
  156. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
  157. package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
  158. package/skills/bootstrap/standard-template.md +39 -24
  159. package/skills/claude-md-drift-check/SKILL.md +9 -2
  160. package/skills/claude-md-drift-check/checker.mjs +213 -21
  161. package/skills/discovery/SKILL.md +6 -173
  162. package/skills/discovery/probes/vault-staleness.mjs +35 -5
  163. package/skills/discovery/probes-docs.md +8 -4
  164. package/skills/discovery/probes-supply-chain.md +4 -2
  165. package/skills/discovery/probes-ui.md +8 -4
  166. package/skills/discovery/probes-vault.md +12 -4
  167. package/skills/discovery/references/discovery-interactive-triage.md +139 -0
  168. package/skills/discovery/references/discovery-triage-state.md +54 -0
  169. package/skills/docs-orchestrator/audience-mapping.md +1 -1
  170. package/skills/eval/rubric-v1.md +13 -0
  171. package/skills/evolve/SKILL.md +2 -458
  172. package/skills/evolve/references/evolve-analyze-mode.md +360 -0
  173. package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
  174. package/skills/gitlab-ops/SKILL.md +3 -3
  175. package/skills/grill/SKILL.md +1 -1
  176. package/skills/memory-cleanup/SKILL.md +2 -2
  177. package/skills/plan/mode-new.md +9 -0
  178. package/skills/plan/mode-retro.md +4 -3
  179. package/skills/reconcile/SKILL.md +11 -1
  180. package/skills/session-end/SKILL.md +3 -2
  181. package/skills/session-end/drift-operations.md +20 -5
  182. package/skills/session-end/metrics-collection.md +1 -0
  183. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  184. package/skills/session-end/phase-3-6-tail.md +27 -67
  185. package/skills/session-end/phase-3-7a-recommendations.md +2 -2
  186. package/skills/session-end/references/phase-2-quality-gate.md +3 -3
  187. package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
  188. package/skills/session-end/references/phase-5-issue-cleanup.md +32 -1
  189. package/skills/session-end/session-metrics-write.md +33 -12
  190. package/skills/session-plan/SKILL.md +46 -180
  191. package/skills/session-plan/references/session-plan-task-classification.md +152 -0
  192. package/skills/session-plan/wave-template.md +8 -15
  193. package/skills/session-start/SKILL.md +41 -7
  194. package/skills/session-start/phase-2-5-docs-planning.md +1 -1
  195. package/skills/session-start/phase-8-5-express-path.md +12 -9
  196. package/skills/session-start/references/operations-contract.md +114 -0
  197. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
  198. package/skills/session-start/references/phase-4-ssot-environment-check.md +42 -24
  199. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
  200. package/skills/session-start/soul.md +2 -2
  201. package/skills/test-runner/SKILL.md +1 -1
  202. package/skills/test-runner/rubric-v1.md +2 -2
  203. package/skills/tmux-layout/SKILL.md +3 -1
  204. package/skills/ux-grill/SKILL.md +211 -0
  205. package/skills/ux-grill/rubric-v2.md +201 -0
  206. package/skills/ux-grill/soul.md +76 -0
  207. package/skills/wave-executor/SKILL.md +32 -127
  208. package/skills/wave-executor/circuit-breaker.md +3 -1
  209. package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
  210. package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
  211. package/skills/wave-executor/references/wave-loop-dispatch.md +12 -2
  212. package/skills/wave-executor/references/wave-loop-review.md +19 -6
  213. package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
  214. package/templates/_shared/ux-manifest.template.md +149 -0
  215. package/templates/nextjs-minimal/package.json +1 -1
  216. package/templates/node-minimal/package.json +1 -1
  217. package/scripts/lib/multi-provider-build/providers.mjs +0 -64
  218. package/scripts/lib/multi-provider-build/templating.mjs +0 -130
  219. package/scripts/lib/owner-config/coerce.mjs +0 -29
  220. package/scripts/lib/owner-config/constants.mjs +0 -21
  221. package/scripts/lib/owner-config/defaults.mjs +0 -50
  222. package/scripts/lib/owner-config/error.mjs +0 -19
  223. package/scripts/lib/owner-config/index.mjs +0 -13
  224. package/scripts/lib/owner-config/merge.mjs +0 -52
  225. package/scripts/lib/owner-config/validate.mjs +0 -259
  226. package/scripts/lib/owner-config-loader.mjs +0 -170
  227. package/scripts/lib/owner-config.mjs +0 -28
  228. package/scripts/lib/soul-resolve.mjs +0 -130
  229. package/scripts/lib/vault-mirror/render.mjs +0 -8
  230. package/templates/_shared/journey-manifest.md +0 -114
@@ -207,9 +207,206 @@ export const DEFAULT_BYTE_CEILING = 121000;
207
207
  * hand-written scoped rules; this axis strips frontmatter and counts all 46.
208
208
  * Two different populations — see `.claude/rules/measurement-discipline.md`
209
209
  * § "the unnamed population".)
210
+ *
211
+ * ────────────────────────────────────────────────────────────────────────────
212
+ * POPULATION CORRECTED 2026-09-11 (#1297). Everything above this line measured
213
+ * the PATH-SCOPED corpus and called it `generated`. It is not the same set, and
214
+ * the difference was not marginal: measured at `c73c094f`,
215
+ *
216
+ * path-scoped: 11 files / 123,747 B (what this axis used to judge)
217
+ * generated: 8 files / 76,114 B (what it always claimed to judge)
218
+ *
219
+ * — a 47,633 B gap carried by exactly three HAND-WRITTEN rules that happen to
220
+ * be `globs:`-scoped (`testing.md` 36,252 B, `bash-harness-pitfalls.md` 7,705 B,
221
+ * `cli-design.md` 3,676 B). `testing.md` alone was 29.3 % of a ceiling meant for
222
+ * machine output. Consequence at `c73c094f`: 253 B of headroom, so the next
223
+ * /reconcile run that materialized a single rule would have turned
224
+ * `tests/rules/receiving-review.test.mjs` red — and no diet of the generated
225
+ * corpus could have fixed it, because the dominant file is not generated.
226
+ * Textbook `.claude/rules/measurement-discipline.md` § "the unnamed population":
227
+ * the measurement was right, the set was wrong.
228
+ *
229
+ * Membership is now decided by the reconciliation PROVENANCE marker
230
+ * ({@link isMachineGeneratedRule}), mirroring `reconcile/writer.mjs` Tier 3.
231
+ * The old number survives as `bySurface.pathScoped` — a real quantity, just not
232
+ * this one — so nothing that wants it has to re-derive it.
233
+ *
234
+ * The ceiling itself is DELIBERATELY LEFT AT 124,000 in this change. Correcting
235
+ * a population and re-calibrating a threshold are two decisions, and only the
236
+ * first one is measured here. What the correction does expose, and what the
237
+ * operator should decide separately, is that 124,000 is now decorative on this
238
+ * axis (HR-105 — a rule you cannot falsify is not a rule):
239
+ *
240
+ * commit `c73c094f`: 76,114 B / 8 files → x1.63
241
+ * live corpus (2026-09-11, working tree): 87,336 B / 8 files → x1.42
242
+ * pre-consolidation peak (`e4674109`, replayed
243
+ * with the corrected predicate): 89,763 B / 43 files → x1.38
244
+ *
245
+ * NUMBERS RE-MEASURED 2026-09-11 (same day, one wave later). The first line
246
+ * above previously read `live corpus (2026-09-11, c73c094f): … x1.63` — the
247
+ * date and the SHA contradicted each other, and the SHA lost: the SAME session
248
+ * that corrected the population then absorbed 10 learnings into
249
+ * `.claude/rules/`, adding +11,222 B to the generated corpus (87,336 − 76,114).
250
+ * x1.63 was already stale when it was written down. Commands, all run at
251
+ * 2026-09-11 against the working tree / the named SHAs:
252
+ *
253
+ * `node -e "computeInstructionBudget({repoRoot}).bySurface.generated"`
254
+ * → working tree: { bytes: 87336, files: 8 }
255
+ * → rulesDir extracted from `c73c094f`: { bytes: 76114, files: 8 }
256
+ * → rulesDir extracted from `e4674109`: { bytes: 89763, files: 43 }
257
+ *
258
+ * The CONCLUSION stands, on a wider base: firing rate is 0 of 3, not 0 of 2 —
259
+ * the guard would not have fired at any of the three states this repo has
260
+ * recorded. What does NOT stand is the headroom claim it rested on. The live
261
+ * corpus is **2,427 B** below the historical peak, not 13,649 B: a single
262
+ * /reconcile wave of the size this one just absorbed puts the generated corpus
263
+ * back at its worst recorded state, and the ceiling still would not notice.
264
+ * "Decorative" is therefore an understatement of the gap, not an overstatement
265
+ * — which strengthens the case for the operator decision this block defers,
266
+ * and changes none of its terms. Still tracked rather than silently patched:
267
+ * raising OR lowering a threshold inside a population fix is exactly the
268
+ * conflation this comment exists to end.
269
+ *
270
+ * FIRING-RATE AUDIT — widened from 3 states to 89 (#1308, 2026-09-11T17:26Z,
271
+ * clean tree @ `4a49adc6`). HR-105 forbids a rule nothing records; "0 of 3"
272
+ * was too small a base to tell "genuinely rare" from "silently broken", so the
273
+ * guard was replayed over EVERY commit that touched `.claude/rules/`:
274
+ * `git log --format=%h -- .claude/rules/` → 89 commits, each extracted with
275
+ * `git archive <sha> .claude/rules | tar -x -C <tmp>` and fed to
276
+ * `computeInstructionBudget({rulesDir})`:
277
+ *
278
+ * directive axis (480) → fires 4/89 = 4.5 % rare, healthy
279
+ * byte axis (121,000) → fires 0/89 = 0.0 % live: 586 B headroom
280
+ * generated axis (124,000) → fires 0/89 = 0.0 % peak ever 89,763 B
281
+ * pathScoped axis (124,000) → fires 4/89 = 4.5 % falsifiable
282
+ *
283
+ * "Silently broken" is now RULED OUT: `pathScoped` runs the same code path off
284
+ * the same walk and fires on 4 of the same 89 states. What remains is the
285
+ * calibration gap — the all-time peak of this population is 89,763 B, i.e.
286
+ * 72.4 % of the ceiling, so no state this repo has ever recorded could have
287
+ * breached it, and none plausibly will before the corpus grows ~38 %.
288
+ * A falsifiable ceiling for this axis would sit just above the recorded peak
289
+ * (≈ 92,000 B, peak + 2.5 %); this module does NOT move it, because
290
+ * `.claude/rules/development.md` § Guard & Threshold Design makes re-aiming a
291
+ * threshold an operator decision, separate from measuring it. Revisit trigger:
292
+ * re-run the 89-state replay above whenever the generated corpus passes
293
+ * 92,000 B, or when this axis first fires (then it is calibrated, not
294
+ * decorative).
295
+ *
296
+ * That trigger FIRED: the generated corpus stood at 99,774 B at `c16fb518`
297
+ * (2026-09-12), past 92,000. It was answered by consolidation rather than by
298
+ * a replay of this axis — the 8 generated files were merged down (prose only,
299
+ * provenance unchanged) to 75,130 B / 8 files on the 2026-09-12 working tree
300
+ * (#1316), back under the 92,000 mark. This ceiling is unchanged and the
301
+ * 89-state replay of THIS axis was not re-run; the trigger stays armed for the
302
+ * next pass of 92,000 B.
210
303
  */
211
304
  export const DEFAULT_GENERATED_BYTE_CEILING = 124000;
212
305
 
306
+ /**
307
+ * Default byte ceiling for the PATH-SCOPED rule surface — the fourth axis,
308
+ * and a RESTORATION of coverage rather than a new threshold (#1297 follow-up).
309
+ *
310
+ * Why this exists. The #1297 population fix above moved
311
+ * {@link DEFAULT_GENERATED_BYTE_CEILING} from "every `globs:`-scoped rule" to
312
+ * "every provenance-marked rule" — the right correction, on the right
313
+ * evidence. What it did NOT notice is that the population it moved AWAY from
314
+ * kept its measurement (`bySurface.pathScoped`) and lost its ceiling. Measured
315
+ * 2026-09-11 on the working tree:
316
+ *
317
+ * `node -e "computeInstructionBudget({repoRoot}).bySurface"`
318
+ * generated: 87,336 B / 8 files judged against 124,000 → ok
319
+ * pathScoped: 134,969 B / 11 files judged against NOTHING → no verdict
320
+ *
321
+ * `rg -n "pathScoped" scripts/ tests/ CHANGELOG.md` → 23 hits at that
322
+ * moment, not one of them a ceiling comparison.
323
+ *
324
+ * 134,969 B is **10,969 B OVER** the 124,000 this exact population was checked
325
+ * against until that commit. Replayed literally — `git show
326
+ * HEAD:scripts/lib/instruction-budget-guard.mjs` (`c73c094f`) run against
327
+ * TODAY's rule corpus, in a tmp dir:
328
+ *
329
+ * OLD code, today's corpus → generated { bytes: 134969, files: 11 },
330
+ * overGeneratedBudget: true, severity: 'warn'
331
+ * NEW code, today's corpus → overGeneratedBudget: false, severity: 'ok'
332
+ *
333
+ * The diff that swapped the ceiling's predicate is the same diff that would
334
+ * have breached the old ceiling — the learnings this session absorbed put
335
+ * **+11,222 B** on the path-scoped corpus (134,969 today − 123,747 B / 11
336
+ * files measured at `c73c094f`; the same +11,222 B the generated corpus
337
+ * gained, since all of it landed in provenance-marked files). A category
338
+ * split gives each split
339
+ * category its OWN counter AND its own threshold
340
+ * (`.claude/rules/development.md` § Guard & Threshold Design). Here one
341
+ * category got a name and no threshold, which is precisely the state
342
+ * `.claude/rules/host-resources.md` HR-105 forbids: a rule you cannot falsify
343
+ * is not a rule.
344
+ *
345
+ * VALUE: 124,000, unchanged — this is the number this population was always
346
+ * judged against, so restoring it restores coverage and invents nothing.
347
+ * Measured firing rate over the three states of `.claude/rules/` this repo has
348
+ * recorded (`bySurface.pathScoped`, each rulesDir extracted from the named
349
+ * tree, 2026-09-11):
350
+ *
351
+ * `e4674109` (pre-consolidation): 137,410 B / 46 files → FIRES
352
+ * `c73c094f` (population fix): 123,747 B / 11 files → silent (253 B left)
353
+ * working tree (2026-09-11): 134,969 B / 11 files → FIRES
354
+ *
355
+ * Firing rate 2 of 3, falsifiable in both directions — the condition
356
+ * {@link DEFAULT_GENERATED_BYTE_CEILING} does NOT currently meet (0 of 3).
357
+ * Confirmed on a wider base (#1308, 2026-09-11T17:26Z @ `4a49adc6`): replayed
358
+ * over all 89 commits that touched `.claude/rules/`, this axis fires 4/89
359
+ * (4.5 %) against the generated axis's 0/89 — inside HR-101's rare band, and
360
+ * falsifiable, which is exactly the property the sibling ceiling lacks.
361
+ * This is also why it is not re-derived upward off the live number: a ceiling
362
+ * placed above 134,969 would be silent on all three states, i.e. the same
363
+ * unfalsifiable shape, obtained by the threshold-patch move
364
+ * `development.md` § Guard & Threshold Design forbids.
365
+ *
366
+ * FOLDED INTO `overBudget` (#1316, 2026-09-12). Until then the flag was
367
+ * computed and reported but did not flip the aggregate verdict, on two stated
368
+ * conditions: the corpus stood over this ceiling (folding it in would have
369
+ * turned the live verdict red and reported a corpus fact as a code defect),
370
+ * and whether the corpus or the ceiling had to move was an operator decision.
371
+ * The operator decided "consolidate, then gate", and fold-in condition 1 — the
372
+ * corpus back under 124,000 — is now met: the 8 generated rule files were
373
+ * consolidated (prose only; every provenance pair, heading and evidence line
374
+ * unchanged), taking the path-scoped corpus from 147,407 B at `c16fb518` to
375
+ * 122,763 B / 11 files on the 2026-09-12 working tree (generated 99,774 →
376
+ * 75,130 B). A breach on this axis alone now sets `overBudget` and raises the
377
+ * session-start banner.
378
+ *
379
+ * Calibration (Discovery replay, 2026-09-12): `bySurface.pathScoped` over
380
+ * every commit touching `.claude/rules/` (`git log --format=%h --
381
+ * ./.claude/rules/` → 90 states): median 84,752 B, peak 147,407 B; 124,000
382
+ * fires on 5/90 = 5.6 %, inside HR-101's rare band. The peak + 2.5 % method
383
+ * the generated axis uses does NOT carry over: this population's peak is HEAD
384
+ * itself, so peak + 2.5 % (151,092 B) fires on 0/90 — an unfalsifiable raise,
385
+ * the threshold-patch move `development.md` § Guard & Threshold Design
386
+ * forbids.
387
+ *
388
+ * Headroom is 1,237 B. The next `/reconcile` rule (~2.5 KB written
389
+ * standalone) trips this axis unless it is absorbed into a thematic file in
390
+ * the SAME write step — an obligation written in the /reconcile-overshoot
391
+ * learning of `.claude/rules/measurement-discipline.md` and checked by the
392
+ * reconcile writer's budget pre-flight (`scripts/lib/reconcile/writer.mjs`).
393
+ * A breach sets `overBudget` AND turns `tests/rules/receiving-review.test.mjs`
394
+ * (which asserts the live repo is not over budget) red at an otherwise green
395
+ * gate. That is the intended, rare signal — a cue to consolidate, never to
396
+ * raise the ceiling.
397
+ *
398
+ * Revisit trigger (BV-004): re-run the 90-state replay when (a) the
399
+ * hand-written scoped share (`pathScoped − generated`, 47,633 B on the
400
+ * 2026-09-12 tree) moves — consolidating generated rules cannot shrink it, so
401
+ * growth there eats the headroom that lever creates — or (b) the replay's
402
+ * firing rate exceeds 10 % (HR-101: then the instrument is re-aimed, neither
403
+ * obeyed nor silenced).
404
+ *
405
+ * Re-derive (never merely raise) with
406
+ * `computeInstructionBudget({repoRoot}).bySurface.pathScoped`.
407
+ */
408
+ export const DEFAULT_PATH_SCOPED_BYTE_CEILING = 124000;
409
+
213
410
  /**
214
411
  * Read the `instruction-budget:` nested block from the `## Session Config`
215
412
  * section of CLAUDE.md (or AGENTS.md) at `repoRoot`. Synchronous + never throws.
@@ -220,6 +417,8 @@ export const DEFAULT_GENERATED_BYTE_CEILING = 124000;
220
417
  * enabled: true
221
418
  * ceiling: 480
222
419
  * byte-ceiling: 114000
420
+ * generated-byte-ceiling: 124000 # optional (#1309)
421
+ * path-scoped-byte-ceiling: 124000 # optional (#1309)
223
422
  * mode: warn
224
423
  *
225
424
  * Behaviour:
@@ -276,9 +475,18 @@ export function loadInstructionBudgetConfig(repoRoot) {
276
475
  * the missing entry falls back to `DEFAULT_BYTE_CEILING` rather than yielding
277
476
  * `undefined`, so an older caller can never disable the byte axis by omission.
278
477
  *
478
+ * The two SURFACE ceilings (`generated-byte-ceiling`, `path-scoped-byte-ceiling`)
479
+ * are parsed with the same validation as `byte-ceiling` but emitted ONLY when
480
+ * present (in the block or in `defaults`) — see the inline note in the body.
481
+ * Until #1309 they were parsed by nothing at all, so the Session Config
482
+ * override for both surface axes was dead from the day the axes were added:
483
+ * `checkInstructionBudget` read `cfg['generated-byte-ceiling']` /
484
+ * `cfg['path-scoped-byte-ceiling']`, and the only producer of `cfg` never put
485
+ * either key on the object.
486
+ *
279
487
  * @param {string} content - full file contents
280
- * @param {{ enabled: boolean, ceiling: number, 'byte-ceiling'?: number, mode: 'warn' | 'off' }} [defaults]
281
- * @returns {{ enabled: boolean, ceiling: number, 'byte-ceiling': number, mode: 'warn' | 'off' }}
488
+ * @param {{ enabled: boolean, ceiling: number, 'byte-ceiling'?: number, 'generated-byte-ceiling'?: number, 'path-scoped-byte-ceiling'?: number, mode: 'warn' | 'off' }} [defaults]
489
+ * @returns {{ enabled: boolean, ceiling: number, 'byte-ceiling': number, 'generated-byte-ceiling'?: number, 'path-scoped-byte-ceiling'?: number, mode: 'warn' | 'off' }}
282
490
  */
283
491
  export function _parseInstructionBudget(content, defaults) {
284
492
  const base = defaults ?? {
@@ -327,6 +535,19 @@ export function _parseInstructionBudget(content, defaults) {
327
535
  let enabled = base.enabled;
328
536
  let ceiling = base.ceiling;
329
537
  let byteCeiling = baseByteCeiling;
538
+ // The two SURFACE ceilings are OPTIONAL on this shape, unlike the two above:
539
+ // `checkInstructionBudget` already falls back to the module default when the
540
+ // key is absent, and `loadInstructionBudgetConfig`'s own fallback object
541
+ // never carried them. So they are emitted only when a value actually
542
+ // resolves — emitting `undefined` would be indistinguishable from "set to
543
+ // nothing", and emitting the module default here would duplicate a default
544
+ // that already lives at the consumer (#1309).
545
+ let generatedByteCeiling =
546
+ typeof base['generated-byte-ceiling'] === 'number' ? base['generated-byte-ceiling'] : undefined;
547
+ let pathScopedByteCeiling =
548
+ typeof base['path-scoped-byte-ceiling'] === 'number'
549
+ ? base['path-scoped-byte-ceiling']
550
+ : undefined;
330
551
  let mode = base.mode;
331
552
 
332
553
  for (const rawLine of blockLines) {
@@ -362,6 +583,26 @@ export function _parseInstructionBudget(content, defaults) {
362
583
  }
363
584
  break;
364
585
  }
586
+ case 'generated-byte-ceiling': {
587
+ // Identical validation to `byte-ceiling` above — integer, strictly
588
+ // positive; malformed or non-positive silently keeps whatever was
589
+ // already resolved (#1309).
590
+ if (/^-?\d+$/.test(v)) {
591
+ const n = Number.parseInt(v, 10);
592
+ if (Number.isFinite(n) && n > 0) generatedByteCeiling = n;
593
+ }
594
+ break;
595
+ }
596
+ case 'path-scoped-byte-ceiling': {
597
+ // Same shape again — the sibling surface axis (#1297 follow-up); both
598
+ // config keys were read by `checkInstructionBudget` and emitted by
599
+ // nothing until #1309.
600
+ if (/^-?\d+$/.test(v)) {
601
+ const n = Number.parseInt(v, 10);
602
+ if (Number.isFinite(n) && n > 0) pathScopedByteCeiling = n;
603
+ }
604
+ break;
605
+ }
365
606
  case 'mode':
366
607
  // Only `off` silences; any other value (incl. `warn`) surfaces the banner.
367
608
  mode = v.toLowerCase() === 'off' ? 'off' : 'warn';
@@ -369,7 +610,14 @@ export function _parseInstructionBudget(content, defaults) {
369
610
  }
370
611
  }
371
612
 
372
- return { enabled, ceiling, 'byte-ceiling': byteCeiling, mode };
613
+ const parsed = { enabled, ceiling, 'byte-ceiling': byteCeiling, mode };
614
+ if (typeof generatedByteCeiling === 'number') {
615
+ parsed['generated-byte-ceiling'] = generatedByteCeiling;
616
+ }
617
+ if (typeof pathScopedByteCeiling === 'number') {
618
+ parsed['path-scoped-byte-ceiling'] = pathScopedByteCeiling;
619
+ }
620
+ return parsed;
373
621
  }
374
622
 
375
623
  /**
@@ -483,43 +731,81 @@ function sumBytes(entries) {
483
731
  }
484
732
 
485
733
  /**
486
- * Measure the PATH-SCOPED rule surface: every `.claude/rules/*.md` whose
487
- * frontmatter carries `globs:` (or its `paths:` alias — issue #795), i.e.
488
- * exactly the complement of the always-on set the three tier surfaces above
489
- * measure.
734
+ * Is this rule file MACHINE-GENERATED by the reconciliation engine?
490
735
  *
491
- * This cannot reuse `loadApplicableRules`: that loader takes a `scopePaths`
736
+ * The predicate mirrors `scripts/lib/reconcile/writer.mjs` § "Tier 3: binds on
737
+ * any machine-provenance-bearing document" VERBATIM — `auto-generated: true`,
738
+ * OR a `learning-key`, OR an `expires-at`. That is deliberately the writer's
739
+ * own definition and not a fourth copy of it: the writer is what STAMPS these
740
+ * keys (`reconcile/renderer.mjs` emits `learning-key` + `expires-at` on every
741
+ * rule it renders, and the consolidated files additionally carry
742
+ * `auto-generated: true`), so a rule the writer would hold to the invariant is
743
+ * exactly a rule this ceiling should judge.
744
+ *
745
+ * The `meta` object is whatever {@link parseGlobsFrontmatter} surfaced — all
746
+ * three keys are in rule-loader's known-meta set, so no second frontmatter
747
+ * parser is introduced here.
748
+ *
749
+ * @param {Record<string, unknown>} meta
750
+ * @returns {boolean}
751
+ */
752
+ function isMachineGeneratedRule(meta) {
753
+ if (!meta || typeof meta !== 'object') return false;
754
+ return (
755
+ meta['auto-generated'] === true ||
756
+ Object.prototype.hasOwnProperty.call(meta, 'learning-key') ||
757
+ Object.prototype.hasOwnProperty.call(meta, 'expires-at')
758
+ );
759
+ }
760
+
761
+ /**
762
+ * Single-pass scan of `.claude/rules/*.md` producing the TWO corpus measures
763
+ * this guard's third axis needs, which #1297 proved are NOT the same set:
764
+ *
765
+ * - `generated` — rules carrying a reconciliation provenance marker
766
+ * ({@link isMachineGeneratedRule}). This is the population
767
+ * {@link DEFAULT_GENERATED_BYTE_CEILING} is a ceiling FOR: the thing that
768
+ * grows on its own, without anyone deciding to add a rule.
769
+ * - `pathScoped` — every rule with `globs:` (or its `paths:` alias, #795),
770
+ * i.e. the complement of the always-on set the three tier surfaces measure.
771
+ * A real quantity, and the one this axis USED to report under the name
772
+ * `generated` — see the ceiling's docblock for what that cost.
773
+ *
774
+ * The two overlap but neither contains the other by construction: a
775
+ * hand-written rule can be path-scoped (3 of them are here), and a generated
776
+ * rule could in principle carry a `host-class` activation axis instead of
777
+ * `globs:`.
778
+ *
779
+ * Neither can reuse `loadApplicableRules`: that loader takes a `scopePaths`
492
780
  * list and returns the rules APPLICABLE to it, so with `scopePaths: []` it
493
- * yields always-on rules only, and with a non-empty list it yields a
494
- * scope-dependent subset. Neither answers "how big is the path-scoped corpus",
495
- * which is a property of the DIRECTORY, not of any one wave's file scope. The
496
- * frontmatter reading is still delegated (`parseGlobsFrontmatter`), so the
497
- * always-on/path-scoped split stays decided in exactly one place.
498
- *
499
- * Named `generated` at the call site because reconciliation output is what
500
- * grows here, but it deliberately measures every path-scoped file, including
501
- * the hand-written ones — a ceiling that skipped them would report a number
502
- * the operator cannot reproduce from `ls .claude/rules/` (HR-106: the banner
503
- * reports what the rule judges).
781
+ * yields always-on rules only. Neither question is about a wave's file scope
782
+ * both are properties of the DIRECTORY. The frontmatter reading is still
783
+ * delegated (`parseGlobsFrontmatter`), so the always-on/path-scoped split and
784
+ * the provenance keys stay decided in exactly one place.
504
785
  *
505
786
  * Bytes are counted with {@link countContentBytes} — frontmatter stripped —
506
- * so this axis is directly comparable to `totalBytes` and to the tier
507
- * surfaces. Never throws: an unreadable dir or file yields `{bytes:0,files:0}`
508
- * / is skipped, matching this module's never-throw posture.
787
+ * so both are directly comparable to `totalBytes` and to the tier surfaces.
788
+ * Note what that implies for the generated corpus specifically: its frontmatter
789
+ * and `## Provenance` FRONTMATTER is excluded, its provenance BODY bullets are
790
+ * not (they are body text).
791
+ *
792
+ * Never throws: an unreadable dir or file yields zeros / is skipped, matching
793
+ * this module's never-throw posture.
509
794
  *
510
795
  * @param {string} rulesDir
511
- * @returns {{ bytes: number, files: number }}
796
+ * @returns {{ generated: { bytes: number, files: number }, pathScoped: { bytes: number, files: number } }}
512
797
  */
513
- function measurePathScopedSurface(rulesDir) {
798
+ function measureRuleCorpora(rulesDir) {
799
+ const generated = { bytes: 0, files: 0 };
800
+ const pathScoped = { bytes: 0, files: 0 };
801
+
514
802
  let names;
515
803
  try {
516
804
  names = readdirSync(rulesDir);
517
805
  } catch {
518
- return { bytes: 0, files: 0 };
806
+ return { generated, pathScoped };
519
807
  }
520
808
 
521
- let bytes = 0;
522
- let files = 0;
523
809
  for (const name of names) {
524
810
  if (!name.endsWith('.md')) continue;
525
811
  let content;
@@ -529,16 +815,25 @@ function measurePathScopedSurface(rulesDir) {
529
815
  continue; // unreadable file — skip, never throw
530
816
  }
531
817
  let globs;
818
+ let meta;
532
819
  try {
533
- ({ globs } = parseGlobsFrontmatter(content));
820
+ ({ globs, meta } = parseGlobsFrontmatter(content));
534
821
  } catch {
535
822
  continue;
536
823
  }
537
- if (globs === null) continue; // always-on — already counted by the tier surfaces
538
- files += 1;
539
- bytes += countContentBytes(content);
824
+ const bytes = countContentBytes(content);
825
+ if (isMachineGeneratedRule(meta)) {
826
+ generated.files += 1;
827
+ generated.bytes += bytes;
828
+ }
829
+ // `globs === null` → always-on, already counted by the tier surfaces.
830
+ if (globs !== null) {
831
+ pathScoped.files += 1;
832
+ pathScoped.bytes += bytes;
833
+ }
540
834
  }
541
- return { bytes, files };
835
+
836
+ return { generated, pathScoped };
542
837
  }
543
838
 
544
839
  /**
@@ -583,14 +878,19 @@ function measurePathScopedSurface(rulesDir) {
583
878
  * byteCeiling: number,
584
879
  * overDirectiveBudget: boolean,
585
880
  * overByteBudget: boolean,
881
+ * overGeneratedBudget: boolean,
882
+ * overPathScopedBudget: boolean,
586
883
  * overBudget: boolean,
587
884
  * severity: 'ok' | 'warn',
588
- * bySurface: { coordinator: number, wave: number, always: number },
885
+ * bySurface: { coordinator: number, wave: number, always: number,
886
+ * generated: {bytes: number, files: number},
887
+ * pathScoped: {bytes: number, files: number} },
589
888
  * }}
590
889
  * perFile is sorted DESC by count. On missing/unreadable dir →
591
890
  * { totalDirectives: 0, totalBytes: 0, perFile: [], ceiling, byteCeiling,
592
891
  * overDirectiveBudget: false, overByteBudget: false, overBudget: false,
593
- * severity: 'ok', bySurface: { coordinator: 0, wave: 0, always: 0 } }.
892
+ * severity: 'ok', bySurface: { coordinator: 0, wave: 0, always: 0,
893
+ * generated: {bytes:0,files:0}, pathScoped: {bytes:0,files:0} } }.
594
894
  *
595
895
  * #931a verdict rule — `overBudget` is the OR of the two axes
596
896
  * (`overDirectiveBudget || overByteBudget`), NOT a per-axis severity split:
@@ -609,7 +909,9 @@ function measurePathScopedSurface(rulesDir) {
609
909
  * produces a line at every session start no matter how it is labelled.
610
910
  * The two sub-flags are exported so a consumer can discriminate WHICH axis
611
911
  * broke without re-deriving the comparison (the banner below does exactly
612
- * this to choose its Top-files sort key).
912
+ * this to choose its Top-files sort key). The same OR later took two more
913
+ * terms — `overGeneratedBudget` (#1297) and `overPathScopedBudget` (#1316) —
914
+ * so today `overBudget` is true when ANY of the four axes breaches.
613
915
  *
614
916
  * bySurface definition (#877; corrected #893 — NOT the additive
615
917
  * `coordinator + wave === totalBytes` identity, which double-counts the
@@ -627,6 +929,16 @@ function measurePathScopedSurface(rulesDir) {
627
929
  * corpus regardless of tier", which does not match rule-loader's own
628
930
  * tier gate and is corrected here.
629
931
  * bySurface.always === bytes of always-on rules with `tier === 'always'` only.
932
+ * bySurface.generated === {bytes, files} of every rule carrying a
933
+ * reconciliation PROVENANCE marker (`auto-generated: true` /
934
+ * `learning-key` / `expires-at`) — the corpus
935
+ * {@link DEFAULT_GENERATED_BYTE_CEILING} judges (#1297).
936
+ * bySurface.pathScoped === {bytes, files} of every rule with `globs:`
937
+ * (or its `paths:` alias) — the complement of the always-on set, and
938
+ * the number an operator reproduces from `ls .claude/rules/` (HR-106).
939
+ * Judged against {@link DEFAULT_PATH_SCOPED_BYTE_CEILING} into
940
+ * `overPathScopedBudget`, an `overBudget` term since #1316 (see that
941
+ * constant's docblock for why it was held out until then).
630
942
  *
631
943
  * `always` is a strict subset of BOTH `wave` and `coordinator` (neither
632
944
  * tier gate excludes `tier: 'always'`), but `wave` and `coordinator` are
@@ -650,6 +962,10 @@ export function computeInstructionBudget(opts = {}) {
650
962
  typeof opts.generatedByteCeiling === 'number'
651
963
  ? opts.generatedByteCeiling
652
964
  : DEFAULT_GENERATED_BYTE_CEILING;
965
+ const pathScopedByteCeiling =
966
+ typeof opts.pathScopedByteCeiling === 'number'
967
+ ? opts.pathScopedByteCeiling
968
+ : DEFAULT_PATH_SCOPED_BYTE_CEILING;
653
969
  // #893 fix: 'coordinator' used to fall through to the `null` (untiered)
654
970
  // branch below — silently measuring the WRONG rule set for a coordinator
655
971
  // context (it never excluded `tier: wave-only`). Now explicitly recognised
@@ -665,12 +981,20 @@ export function computeInstructionBudget(opts = {}) {
665
981
  ceiling,
666
982
  byteCeiling,
667
983
  generatedByteCeiling,
984
+ pathScopedByteCeiling,
668
985
  overDirectiveBudget: false,
669
986
  overByteBudget: false,
670
987
  overGeneratedBudget: false,
988
+ overPathScopedBudget: false,
671
989
  overBudget: false,
672
990
  severity: 'ok',
673
- bySurface: { coordinator: 0, wave: 0, always: 0, generated: { bytes: 0, files: 0 } },
991
+ bySurface: {
992
+ coordinator: 0,
993
+ wave: 0,
994
+ always: 0,
995
+ generated: { bytes: 0, files: 0 },
996
+ pathScoped: { bytes: 0, files: 0 },
997
+ },
674
998
  };
675
999
 
676
1000
  let allEntries;
@@ -709,13 +1033,19 @@ export function computeInstructionBudget(opts = {}) {
709
1033
  coordinator: sumBytes(alwaysOnCoordinator),
710
1034
  wave: sumBytes(alwaysOnWave),
711
1035
  always: sumBytes(alwaysOnAll.filter((e) => e.tier === 'always')),
712
- // The fourth surface is deliberately a different SHAPE from its three
713
- // siblings ({bytes, files} vs. a bare byte number): a path-scoped corpus
714
- // grows by FILE COUNT as much as by size — 43 files averaging 2.6 kB is
715
- // the shape this axis exists to catch — and a bare number would hide that.
716
- // It is also the only surface disjoint from `totalBytes`, which counts
717
- // always-on rules exclusively.
718
- generated: measurePathScopedSurface(rulesDir),
1036
+ // The fourth and fifth surfaces are deliberately a different SHAPE from
1037
+ // their three siblings ({bytes, files} vs. a bare byte number): these
1038
+ // corpora grow by FILE COUNT as much as by size — 43 files averaging
1039
+ // 2.1 kB is the shape this axis exists to catch — and a bare number would
1040
+ // hide that.
1041
+ //
1042
+ // #1297: `generated` is the PROVENANCE-marked corpus (what /reconcile
1043
+ // materializes), NOT "every path-scoped file" as it was through #1240.
1044
+ // `pathScoped` keeps the old measurement under its honest name. Both are
1045
+ // disjoint from `totalBytes`, which counts always-on rules exclusively —
1046
+ // except that a generated rule activated by `host-class` rather than
1047
+ // `globs:` would be always-on and therefore counted in both.
1048
+ ...measureRuleCorpora(rulesDir),
719
1049
  };
720
1050
 
721
1051
  // Surface-selected entry set for the PRIMARY totals. `context: null`
@@ -749,7 +1079,12 @@ export function computeInstructionBudget(opts = {}) {
749
1079
  const overByteBudget = totalBytes > byteCeiling;
750
1080
  // Third axis, same strict `>` boundary semantics as the two above.
751
1081
  const overGeneratedBudget = bySurface.generated.bytes > generatedByteCeiling;
752
- const overBudget = overDirectiveBudget || overByteBudget || overGeneratedBudget;
1082
+ // Fourth axis, same strict `>` boundary. Folded into `overBudget` since
1083
+ // #1316, once consolidation brought the corpus back under its ceiling — see
1084
+ // DEFAULT_PATH_SCOPED_BYTE_CEILING's docblock for the calibration and the
1085
+ // revisit trigger.
1086
+ const overPathScopedBudget = bySurface.pathScoped.bytes > pathScopedByteCeiling;
1087
+ const overBudget = overDirectiveBudget || overByteBudget || overGeneratedBudget || overPathScopedBudget;
753
1088
 
754
1089
  return {
755
1090
  totalDirectives,
@@ -758,9 +1093,11 @@ export function computeInstructionBudget(opts = {}) {
758
1093
  ceiling,
759
1094
  byteCeiling,
760
1095
  generatedByteCeiling,
1096
+ pathScopedByteCeiling,
761
1097
  overDirectiveBudget,
762
1098
  overByteBudget,
763
1099
  overGeneratedBudget,
1100
+ overPathScopedBudget,
764
1101
  overBudget,
765
1102
  severity: overBudget ? 'warn' : 'ok',
766
1103
  bySurface,
@@ -789,8 +1126,12 @@ export function computeInstructionBudget(opts = {}) {
789
1126
  * @param {string} [opts.repoRoot] project root for the config read.
790
1127
  * @param {number} [opts.ceiling] explicit directive-ceiling override (wins over config).
791
1128
  * @param {number} [opts.byteCeiling] explicit byte-ceiling override (wins over config).
1129
+ * @param {number} [opts.pathScopedByteCeiling] explicit path-scoped-ceiling override.
792
1130
  * @returns {{ severity: 'warn', message: string } | null}
793
- * null when disabled / off / both axes at-or-under ceiling OR on any read failure.
1131
+ * null when disabled / off / every axis at-or-under its ceiling OR on any
1132
+ * read failure. Since #1316 each of the four axes — directives, bytes,
1133
+ * generated, path-scoped — raises the banner on its own (see
1134
+ * DEFAULT_PATH_SCOPED_BYTE_CEILING).
794
1135
  */
795
1136
  export function checkInstructionBudget(opts = {}) {
796
1137
  let cfg;
@@ -828,9 +1169,24 @@ export function checkInstructionBudget(opts = {}) {
828
1169
  ? cfg['generated-byte-ceiling']
829
1170
  : DEFAULT_GENERATED_BYTE_CEILING;
830
1171
 
1172
+ // Identical precedence chain to the generated axis above: explicit opt >
1173
+ // Session Config `path-scoped-byte-ceiling` > module default.
1174
+ const pathScopedByteCeiling =
1175
+ typeof opts.pathScopedByteCeiling === 'number'
1176
+ ? opts.pathScopedByteCeiling
1177
+ : typeof cfg['path-scoped-byte-ceiling'] === 'number'
1178
+ ? cfg['path-scoped-byte-ceiling']
1179
+ : DEFAULT_PATH_SCOPED_BYTE_CEILING;
1180
+
831
1181
  let budget;
832
1182
  try {
833
- budget = computeInstructionBudget({ ...opts, ceiling, byteCeiling, generatedByteCeiling });
1183
+ budget = computeInstructionBudget({
1184
+ ...opts,
1185
+ ceiling,
1186
+ byteCeiling,
1187
+ generatedByteCeiling,
1188
+ pathScopedByteCeiling,
1189
+ });
834
1190
  } catch {
835
1191
  return null; // never throw out of the banner wrapper
836
1192
  }
@@ -851,10 +1207,22 @@ export function checkInstructionBudget(opts = {}) {
851
1207
  // is consolidating files, not trimming prose inside them (HR-106: the
852
1208
  // banner reports the number the rule judged).
853
1209
  axes.push(
854
- `path-scoped ${budget.bySurface.generated.bytes} B over ` +
1210
+ `generated rules ${budget.bySurface.generated.bytes} B over ` +
855
1211
  `${budget.bySurface.generated.files} files > ${budget.generatedByteCeiling} B`,
856
1212
  );
857
1213
  }
1214
+ // A trigger in its own right since #1316 — the corpus sits under this
1215
+ // ceiling now, so the line is rare (HR-101), not a fixture of every session
1216
+ // start. It carries its remedy inline because the lever is not obvious from
1217
+ // the number: consolidate generated rules; raising the ceiling is the
1218
+ // unfalsifiable move DEFAULT_PATH_SCOPED_BYTE_CEILING's docblock rules out.
1219
+ if (budget.overPathScopedBudget) {
1220
+ axes.push(
1221
+ `path-scoped rules ${budget.bySurface.pathScoped.bytes} B over ` +
1222
+ `${budget.bySurface.pathScoped.files} files > ${budget.pathScopedByteCeiling} B ` +
1223
+ '(consolidate generated rules per docs/rule-authoring.md § Consolidated rules; never raise the ceiling)',
1224
+ );
1225
+ }
858
1226
 
859
1227
  // `perFile` arrives sorted DESC by directive count. When ONLY the byte axis
860
1228
  // broke, that ordering points at the wrong files — re-sort by bytes so the