session-orchestrator 4.2.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.
- package/.agents/skills/session-start/SKILL.md +1 -1
- package/.agents/skills/ux-grill/SKILL.md +22 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +3 -2
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/session-start/SKILL.md +1 -1
- package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
- package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
- package/.cursor/commands/ux-grill.md +14 -0
- package/.cursor/skills/session-start/SKILL.md +1 -1
- package/.cursor/skills/ux-grill/SKILL.md +13 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +94 -1
- package/README.md +98 -86
- package/agents/dialectic-deriver.md +11 -0
- package/commands/ux-grill.md +51 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/codex-setup.md +8 -0
- package/docs/components.md +7 -7
- package/docs/events-schema.md +9 -5
- package/docs/github-mirror-protection.md +61 -20
- package/docs/migration-v5.md +62 -0
- package/docs/scope-collision-guard.md +14 -0
- package/hooks/_lib/hook-import-set.json +44 -2
- package/hooks/_lib/lock-bootstrap.mjs +84 -1
- package/hooks/_lib/vcs-create-matcher.mjs +190 -3
- package/hooks/enforce-scope.mjs +201 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks.json +7 -2
- package/hooks/on-session-start.mjs +171 -49
- package/hooks/post-bash-issue-budget-refund.mjs +375 -0
- package/hooks/pre-auq-clarity.mjs +70 -18
- package/hooks/pre-bash-issue-budget.mjs +51 -4
- package/package.json +2 -1
- package/pi/prompts/ux-grill.md +12 -0
- package/scripts/ci/assert-vitest-green.mjs +4 -2
- package/scripts/dialectic-deriver.mjs +32 -8
- package/scripts/emit-session.mjs +72 -1
- package/scripts/lib/agent-status.mjs +441 -9
- package/scripts/lib/auq/schema.mjs +10 -3
- package/scripts/lib/ci-status-banner.mjs +29 -6
- package/scripts/lib/claude-md-budget-lint.mjs +52 -2
- package/scripts/lib/config.mjs +12 -1
- package/scripts/lib/eval/engine.mjs +7 -1
- package/scripts/lib/file-lock.mjs +114 -13
- package/scripts/lib/git-porcelain.mjs +113 -0
- package/scripts/lib/instruction-budget-guard.mjs +415 -47
- package/scripts/lib/io.mjs +29 -4
- package/scripts/lib/issue-budget.mjs +336 -6
- package/scripts/lib/learnings/sizing-subject.mjs +44 -0
- package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
- package/scripts/lib/locks/state-md-lock.mjs +19 -41
- package/scripts/lib/maintenance-due-banner.mjs +11 -1
- package/scripts/lib/peer-cards/merger.mjs +143 -0
- package/scripts/lib/pre-dispatch-check.mjs +20 -14
- package/scripts/lib/project-hygiene.mjs +81 -30
- package/scripts/lib/quality-gate.mjs +14 -65
- package/scripts/lib/reconcile/engine.mjs +19 -1
- package/scripts/lib/reconcile/writer.mjs +278 -11
- package/scripts/lib/scope-echo.mjs +346 -0
- package/scripts/lib/session-lock.mjs +62 -2
- package/scripts/lib/session-record-repair.mjs +91 -0
- package/scripts/lib/session-schema/filters.mjs +26 -1
- package/scripts/lib/session-start-probes.mjs +419 -53
- package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
- package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
- package/scripts/lib/tmux-layout/layouts.mjs +62 -4
- package/scripts/lib/ux-grill/collect.mjs +1163 -0
- package/scripts/lib/ux-grill/compare.mjs +285 -0
- package/scripts/lib/ux-grill/manifest.mjs +618 -0
- package/scripts/lib/ux-grill/measures.mjs +431 -0
- package/scripts/lib/ux-grill/paths.mjs +224 -0
- package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
- package/scripts/lib/ux-grill/reconcile.mjs +344 -0
- package/scripts/lib/ux-grill/run-record.mjs +316 -0
- package/scripts/lib/ux-grill/schema.mjs +321 -0
- package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
- package/scripts/lib/validate/check-unwired-features.mjs +48 -20
- package/scripts/lib/vault-status/board-lock.mjs +18 -0
- package/scripts/lib/vault-status/board-writer.mjs +8 -0
- package/scripts/mcp-server.sh +16 -1
- package/scripts/release.mjs +7 -2
- package/skills/bootstrap/SKILL.md +12 -209
- package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
- package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -2
- package/skills/claude-md-drift-check/checker.mjs +213 -21
- package/skills/discovery/SKILL.md +6 -173
- package/skills/discovery/probes/vault-staleness.mjs +35 -5
- package/skills/discovery/probes-docs.md +8 -4
- package/skills/discovery/probes-supply-chain.md +4 -2
- package/skills/discovery/probes-ui.md +7 -3
- package/skills/discovery/probes-vault.md +12 -4
- package/skills/discovery/references/discovery-interactive-triage.md +139 -0
- package/skills/discovery/references/discovery-triage-state.md +54 -0
- package/skills/eval/rubric-v1.md +13 -0
- package/skills/evolve/SKILL.md +2 -458
- package/skills/evolve/references/evolve-analyze-mode.md +360 -0
- package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
- package/skills/plan/mode-retro.md +4 -3
- package/skills/reconcile/SKILL.md +10 -0
- package/skills/session-end/drift-operations.md +20 -5
- package/skills/session-end/metrics-collection.md +1 -0
- package/skills/session-end/phase-3-6-tail.md +4 -2
- package/skills/session-end/references/phase-2-quality-gate.md +3 -3
- package/skills/session-end/references/phase-5-issue-cleanup.md +6 -1
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-plan/SKILL.md +2 -144
- package/skills/session-plan/references/session-plan-task-classification.md +152 -0
- package/skills/session-start/SKILL.md +24 -6
- package/skills/session-start/references/operations-contract.md +114 -0
- package/skills/session-start/references/phase-4-ssot-environment-check.md +22 -20
- package/skills/session-start/soul.md +2 -2
- package/skills/test-runner/SKILL.md +1 -1
- package/skills/tmux-layout/SKILL.md +3 -1
- package/skills/ux-grill/SKILL.md +211 -0
- package/skills/ux-grill/rubric-v2.md +201 -0
- package/skills/ux-grill/soul.md +76 -0
- package/skills/wave-executor/SKILL.md +3 -128
- package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
- package/skills/wave-executor/references/wave-loop-review.md +18 -5
- package/templates/_shared/ux-manifest.template.md +149 -0
- 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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
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
|
|
507
|
-
*
|
|
508
|
-
*
|
|
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
|
|
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 {
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
|
713
|
-
// siblings ({bytes, files} vs. a bare byte number):
|
|
714
|
-
//
|
|
715
|
-
// the shape this axis exists to catch — and a bare number would
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
|
|
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
|
-
|
|
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 /
|
|
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({
|
|
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
|
-
`
|
|
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
|