@event4u/agent-config 12.0.0 → 13.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 (148) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +273 -0
  3. package/README.md +1 -1
  4. package/agents/templates/.ai-council.yml.example +46 -3
  5. package/dist/agent-src/commands/cost/report.md +23 -0
  6. package/dist/agent-src/contexts/execution/subagent-routing.md +148 -33
  7. package/dist/agent-src/contexts/model-recommendations.md +6 -4
  8. package/dist/agent-src/contexts/subagent-configuration.md +69 -7
  9. package/dist/agent-src/rules/autonomous-execution.md +1 -1
  10. package/dist/agent-src/rules/design-fidelity.md +1 -1
  11. package/dist/agent-src/rules/design-review-after-ui-write.md +10 -5
  12. package/dist/agent-src/rules/evaluator-independence.md +27 -5
  13. package/dist/agent-src/rules/git-history-discipline.md +5 -1
  14. package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +1 -0
  15. package/dist/agent-src/rules/missing-skill-recovery.md +89 -0
  16. package/dist/agent-src/rules/ui-audit-gate.md +9 -4
  17. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
  18. package/dist/agent-src/scripts/roadmap_gates.ts +30 -2
  19. package/dist/agent-src/scripts/update_roadmap_progress.ts +47 -1
  20. package/dist/agent-src/skills/authz-review/SKILL.md +4 -0
  21. package/dist/agent-src/skills/design-system-capture/SKILL.md +4 -0
  22. package/dist/agent-src/skills/design-system-capture/references/snapshot-preference-order.md +77 -0
  23. package/dist/agent-src/skills/merge-conflicts/SKILL.md +4 -0
  24. package/dist/agent-src/skills/prompt-optimizer/SKILL.md +17 -1
  25. package/dist/agent-src/skills/roadmap-writing/SKILL.md +11 -11
  26. package/dist/agent-src/skills/systematic-debugging/SKILL.md +4 -0
  27. package/dist/agent-src/skills/threat-modeling/SKILL.md +3 -0
  28. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  29. package/dist/agent-src/templates/roadmaps.md +41 -3
  30. package/dist/cli/registry.js +1 -0
  31. package/dist/cli/registry.js.map +1 -1
  32. package/dist/cli-delegate/{chunk-NN4KW4HZ.js → chunk-3B72LSZL.js} +123 -2338
  33. package/dist/cli-delegate/{chunk-55WZN5VF.js → chunk-EVTY7ISR.js} +2 -2
  34. package/dist/cli-delegate/chunk-N7FEST6D.js +3895 -0
  35. package/dist/cli-delegate/{chunk-L565YFXH.js → chunk-XURJ4AQC.js} +566 -298
  36. package/dist/cli-delegate/cmd_conformance.js +4 -4
  37. package/dist/cli-delegate/cmd_doctor.js +2 -2
  38. package/dist/cli-delegate/cmd_export.js +2 -2
  39. package/dist/cli-delegate/cmd_handoff.js +1 -1
  40. package/dist/cli-delegate/cmd_prune.js +2 -2
  41. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  42. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  43. package/dist/cli-delegate/cmd_sync.js +2 -2
  44. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  45. package/dist/cli-delegate/cmd_validate.js +2 -2
  46. package/dist/discovery/deprecation-report.md +1 -1
  47. package/dist/discovery/discovery-manifest.json +44 -23
  48. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  49. package/dist/discovery/discovery-manifest.summary.md +3 -3
  50. package/dist/discovery/dormancy-report.md +1 -1
  51. package/dist/discovery/orphan-report.md +1 -1
  52. package/dist/discovery/packs.json +6 -5
  53. package/dist/discovery/trust-report.md +2 -2
  54. package/dist/discovery/workspaces.json +5 -4
  55. package/dist/hooks/dispatch.js +10625 -8541
  56. package/dist/install/install.mjs +754 -484
  57. package/dist/mcp/registry-manifest.json +3 -3
  58. package/dist/mcp/server.json +2 -2
  59. package/dist/mcp/server.mjs +1302 -167
  60. package/dist/router.json +1 -1
  61. package/dist/scripts/_lib/model_tier.js +9 -0
  62. package/dist/scripts/_lib/model_tier.js.map +1 -1
  63. package/dist/scripts/condense.js +50 -0
  64. package/dist/scripts/condense.js.map +1 -1
  65. package/dist/server/schemas/settings.js +2 -2
  66. package/dist/server/schemas/settings.js.map +1 -1
  67. package/docs/MIGRATION.md +20 -1
  68. package/docs/guidelines/design-fidelity-mechanics.md +21 -0
  69. package/docs/guidelines/design-handover-extraction.md +87 -0
  70. package/docs/guidelines/prompt-templates.md +24 -1
  71. package/package.json +1 -1
  72. package/src/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
  73. package/src/agent-src/scripts/roadmap_gates.ts +30 -2
  74. package/src/agent-src/scripts/update_roadmap_progress.ts +47 -1
  75. package/src/config/evaluator-budgets.json +9 -8
  76. package/src/config/gate-coverage.yml +63 -11
  77. package/src/config/gate-violation-baselines.json +12 -0
  78. package/src/config/hook-token-budget.json +15 -2
  79. package/src/config/preamble-payload-budget.json +28 -0
  80. package/src/scripts/_dispatch.bash +10 -0
  81. package/src/scripts/_lib/bench_ab_complexity.ts +439 -0
  82. package/src/scripts/_lib/bench_ab_safety_tier.ts +142 -0
  83. package/src/scripts/_lib/bench_ab_scoring_v2.ts +28 -3
  84. package/src/scripts/_lib/bench_ab_search_adherence.ts +423 -0
  85. package/src/scripts/_lib/bench_ab_size_claim.ts +206 -0
  86. package/src/scripts/_lib/bench_ab_workspace.ts +111 -0
  87. package/src/scripts/_lib/git_common_dir.ts +173 -0
  88. package/src/scripts/_lib/model_tier.ts +9 -0
  89. package/src/scripts/_lib/release_highlights.ts +108 -7
  90. package/src/scripts/_lib/skill_catalogue.ts +1115 -0
  91. package/src/scripts/_lib/tier_budget_routing.ts +31 -331
  92. package/src/scripts/ai_council/clients.ts +197 -18
  93. package/src/scripts/ai_council/config.ts +16 -1
  94. package/src/scripts/ai_council/low_impact.ts +14 -1
  95. package/src/scripts/ai_council/orchestrator.ts +14 -1
  96. package/src/scripts/ai_council/transport_resolver.ts +32 -2
  97. package/src/scripts/bench_ab_v2_complexity.ts +252 -0
  98. package/src/scripts/bench_ab_v2_run.ts +3 -42
  99. package/src/scripts/bench_ab_v2_safety.ts +296 -0
  100. package/src/scripts/bench_ab_v2_search.ts +343 -0
  101. package/src/scripts/bench_ab_v2_stats.ts +191 -0
  102. package/src/scripts/build_archive_index.ts +383 -0
  103. package/src/scripts/capture_skill_catalogue.ts +279 -317
  104. package/src/scripts/check_branch_freshness.ts +7 -1
  105. package/src/scripts/check_release_published.ts +6 -2
  106. package/src/scripts/check_release_trunk_sync.ts +18 -2
  107. package/src/scripts/check_source_size_budget.ts +428 -0
  108. package/src/scripts/check_suppression_hygiene.ts +5 -1
  109. package/src/scripts/condense.ts +50 -1
  110. package/src/scripts/cost/backfill_rates.mjs +298 -0
  111. package/src/scripts/cost/budget.mjs +12 -32
  112. package/src/scripts/cost_summary.ts +25 -0
  113. package/src/scripts/dispatch_r2_reviewer.ts +15 -5
  114. package/src/scripts/evidence_report.ts +15 -4
  115. package/src/scripts/external_sources_denylist.json +1 -0
  116. package/src/scripts/hook_manifest.yaml +75 -15
  117. package/src/scripts/hooks/concern_registry.ts +4 -0
  118. package/src/scripts/hooks/orchestration_record_hook.ts +21 -0
  119. package/src/scripts/hooks/skill_route_hook.ts +226 -0
  120. package/src/scripts/hooks/tool_result_bytes_hook.ts +219 -0
  121. package/src/scripts/hooks/ui_route_nudge_hook.ts +6 -3
  122. package/src/scripts/hot_context_hook.ts +13 -1
  123. package/src/scripts/install.ts +129 -0
  124. package/src/scripts/janitor.ts +77 -2
  125. package/src/scripts/lint_design_antipattern_parity.ts +40 -6
  126. package/src/scripts/lint_harvest_provenance.ts +35 -1
  127. package/src/scripts/lint_plan_risk_register.ts +22 -9
  128. package/src/scripts/lint_roadmap_blockers.ts +109 -6
  129. package/src/scripts/lint_rule_skill_pack_reach.ts +43 -0
  130. package/src/scripts/lint_scheduled_deprecations.ts +738 -0
  131. package/src/scripts/lint_skill_router_head.ts +21 -0
  132. package/src/scripts/mcp_server/consumer_tool_catalog.json +26 -3
  133. package/src/scripts/mcp_server/tool_catalog_source.ts +6 -24
  134. package/src/scripts/mcp_server/tools.ts +87 -0
  135. package/src/scripts/migration_status.ts +17 -1
  136. package/src/scripts/probe_review_binding_drift.ts +731 -0
  137. package/src/scripts/release.ts +100 -6
  138. package/src/scripts/routing_doctor.ts +50 -13
  139. package/src/scripts/schemas/command.schema.json +3 -3
  140. package/src/scripts/schemas/skill.schema.json +20 -2
  141. package/src/scripts/schemas/subagent.schema.json +2 -2
  142. package/src/scripts/session_eol_report.ts +81 -0
  143. package/src/scripts/trigger_coverage.ts +224 -3
  144. package/src/scripts/workspace_doctor.ts +464 -0
  145. package/src/scripts/worktree_cleanup_check.ts +2 -1
  146. package/src/server/schemas/settings.ts +2 -2
  147. package/dist/cli-delegate/chunk-AYKQGIBG.js +0 -1594
  148. package/src/config/budget-routing.json +0 -7
@@ -66,6 +66,7 @@ import path from "node:path";
66
66
  import { fileURLToPath } from "node:url";
67
67
 
68
68
  import { runGateCli, runSelfTest, type SelfTestCase } from "./_lib/gate_self_test.js";
69
+ import { workspaceIdentity } from "./_lib/git_common_dir.js";
69
70
  import { reportScanned } from "./_lib/scan_scope.js";
70
71
 
71
72
  // ledger-exempt: single remote-ref probe — the entire scope is ONE ls-remote answer (0 or 1 refs) resolved to one aggregate ancestor verdict, and every empty path already publishes its reason via reportScanned allowEmpty; there is no per-target collection to account.
@@ -548,7 +549,12 @@ export function main(
548
549
  return 0;
549
550
  }
550
551
 
551
- const branch = git(["rev-parse", "--abbrev-ref", "HEAD"]);
552
+ // Census row B4. The file-based resolver is immune to an inherited `GIT_DIR`
553
+ // (hooks export one, and this gate runs at push time). Behaviour-identical
554
+ // here by construction: the guard below already collapses `null` and the
555
+ // detached-HEAD literal onto the same skip, and unresolved maps to `null`.
556
+ const branchField = workspaceIdentity().branch;
557
+ const branch = branchField.resolved ? branchField.value : null;
552
558
  if (branch === null || branch === "HEAD") {
553
559
  scanReport(0, "detached HEAD — there is no branch whose freshness could be asked");
554
560
  return 0;
@@ -26,6 +26,7 @@ import * as fs from 'node:fs';
26
26
  import * as path from 'node:path';
27
27
  import { fileURLToPath, pathToFileURL } from 'node:url';
28
28
 
29
+ import { workspaceIdentity } from './_lib/git_common_dir.js';
29
30
  import { assertWatchlistResolves, DeadScopeError } from './_lib/scan_scope.js';
30
31
 
31
32
  const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/;
@@ -65,8 +66,11 @@ function _on_main(): boolean {
65
66
  if (ref === 'refs/heads/main' || ref === 'refs/heads/master') {
66
67
  return true;
67
68
  }
68
- const [rc, head] = _git('rev-parse', '--abbrev-ref', 'HEAD');
69
- return rc === 0 && head === MAIN_BRANCH;
69
+ // Census row B5. Behaviour-identical: a detached HEAD used to compare the
70
+ // literal `HEAD` against MAIN_BRANCH and fail; unresolved fails the same
71
+ // way, without a subprocess an inherited `GIT_DIR` could redirect.
72
+ const branch = workspaceIdentity().branch;
73
+ return branch.resolved && branch.value === MAIN_BRANCH;
70
74
  }
71
75
 
72
76
  function _npm_latest(pkg: string): string | null {
@@ -21,6 +21,7 @@ import * as fs from 'node:fs';
21
21
  import * as path from 'node:path';
22
22
  import { fileURLToPath, pathToFileURL } from 'node:url';
23
23
 
24
+ import { workspaceIdentity } from './_lib/git_common_dir.js';
24
25
  import { assertScanned } from './_lib/scan_scope.js';
25
26
 
26
27
  const RELEASE_BRANCH_RE = /^release\/(\d+)\.(\d+)\.(\d+)$/;
@@ -37,8 +38,19 @@ function _git(...args: string[]): string {
37
38
  return (proc.stdout ?? '').trim();
38
39
  }
39
40
 
41
+ /**
42
+ * Census row B3 — one of four private `_current_branch` implementations that
43
+ * existed beside the shared, file-based one.
44
+ *
45
+ * This one spawned `rev-parse --abbrev-ref HEAD` with **no `cwd` and no env
46
+ * scrub**, so an inherited `GIT_DIR` answered about the hook's repository.
47
+ * The migration is behaviour-identical at the only call site: `main()` treats
48
+ * `'HEAD'` and `''` the same ("detached HEAD — gate skipped"), and the
49
+ * resolver's unresolved case maps onto `''`.
50
+ */
40
51
  function _current_branch(): string {
41
- return _git('rev-parse', '--abbrev-ref', 'HEAD');
52
+ const branch = workspaceIdentity().branch;
53
+ return branch.resolved ? branch.value : '';
42
54
  }
43
55
 
44
56
  function _parse_semver(text: string): SemVer | null {
@@ -148,7 +160,11 @@ function main(): number {
148
160
  gate: 'check_release_trunk_sync',
149
161
  scanned: 0,
150
162
  units: 'release-prep branch(es)',
151
- roots: ['git rev-parse --abbrev-ref HEAD'],
163
+ // Names what the gate actually reads. It stopped spawning
164
+ // `rev-parse --abbrev-ref HEAD` when B3 moved to the file-based
165
+ // resolver; a scan root naming a command the gate does not run is
166
+ // a false scope declaration.
167
+ roots: ['<git-dir>/HEAD via workspaceIdentity().branch'],
152
168
  allowEmpty:
153
169
  'OPTIONAL_INPUT: the checked-out branch is not release/X.Y.Z, so there is no ' +
154
170
  'target version to compare main against — an absent question, not an empty ' +
@@ -0,0 +1,428 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * check_source_size_budget — a growth ratchet over oversized TypeScript sources.
4
+ *
5
+ * ## The gap
6
+ *
7
+ * Every other size budget in this suite guards prose. Rules carry a char cap
8
+ * (`lint_load_context.cap_for`). Skills carry a token budget class
9
+ * (`lint_token_budget_discipline`). The always-loaded set carries two budgets
10
+ * (`check_always_budget`). The on-demand depth layer carries a per-file char
11
+ * ceiling (`check_depth_budget`). The 1,063 hand-written `.ts` files this
12
+ * package ships carry **none** — which is where the largest artefacts sit:
13
+ * `install.ts` at 5,461 lines, `skill_linter.ts` at 4,742, `council_cli.ts` at
14
+ * 4,058.
15
+ *
16
+ * Fifteen files are over 1,500 lines. Nothing measured that, so nothing stopped
17
+ * it, and nothing would notice a sixteenth.
18
+ *
19
+ * ## Why a ratchet BEFORE a split — the ordering is the decision
20
+ *
21
+ * The obvious move is to split the god-files. Doing that first is how a refactor
22
+ * becomes unreviewable: a 5,461-line file broken into six is a diff nobody can
23
+ * read, with nothing asserting it improved anything. A ratchet freezes the
24
+ * number where it is, which makes every later split a **lowering commit** whose
25
+ * gain is visible in one integer. That ordering is the deliberate call carried
26
+ * from the roadmap this gate closes, not an implementation detail.
27
+ *
28
+ * ## What the ratchet counts, and why it is NOT a file count
29
+ *
30
+ * The metric is **total lines above the ceiling**, summed across the tree:
31
+ *
32
+ * Σ max(0, lines(f) − CEILING) for every .ts under src/
33
+ *
34
+ * A file *count* was the obvious shape and it is the weak one: `install.ts`
35
+ * could grow 5,461 → 9,000 and the count of over-ceiling files would not move,
36
+ * so the gate would stay green while the exact defect it exists for got worse.
37
+ * That objection was raised against the count shape and it is correct. Summing
38
+ * the excess costs the same one integer and closes it:
39
+ *
40
+ * - a listed file growing by one line raises the total → **regressed**, fails;
41
+ * - a new file over the ceiling raises the total → **regressed**, fails;
42
+ * - splitting any god-file lowers the total → **improved**, tighten the ratchet;
43
+ * - the 56-day expiry in `_lib/gate_baseline` fails a total that never drops,
44
+ * so "freeze it and forget it" is not a stable state.
45
+ *
46
+ * That last clause is the one that separates a control from a number that feels
47
+ * like progress. A ceiling with no forcing function would leave these fifteen
48
+ * files at these fifteen sizes indefinitely with the gate green the whole time.
49
+ *
50
+ * ## What the ceiling is — and what it is NOT
51
+ *
52
+ * 1,500 lines is a **growth ratchet, not a quality threshold**, and the same
53
+ * honesty its sibling `check_depth_budget` states applies here: nothing claims a
54
+ * 1,499-line module is comprehensible and a 1,501-line one is not. The number
55
+ * was chosen by measurement of this tree and of the ledger it produces — 15
56
+ * files over 1,500, versus 44 over 1,000 and 75 over 800. A ceiling that lists
57
+ * 75 files is an allowlist, and this repository's own rules call an allowlist
58
+ * past 20 entries evidence that the linter is wrong. 1,500 is the tightest
59
+ * ceiling whose ledger stays signal.
60
+ *
61
+ * Exit codes: 0 = at or under baseline, 1 = the excess grew (or the baseline
62
+ * went stale), 2 = usage.
63
+ */
64
+ import * as fs from 'node:fs';
65
+ import * as os from 'node:os';
66
+ import * as path from 'node:path';
67
+ import { fileURLToPath, pathToFileURL } from 'node:url';
68
+
69
+ import { checkRatchet } from './_lib/gate_baseline.js';
70
+ import { GateLedger } from './_lib/gate_ledger.js';
71
+ import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
72
+ import { reportScanned } from './_lib/scan_scope.js';
73
+
74
+ const SCRIPTS_DIR = path.dirname(fileURLToPath(import.meta.url));
75
+ const REAL_REPO_ROOT = path.dirname(path.dirname(SCRIPTS_DIR));
76
+
77
+ /**
78
+ * The ceiling, in lines.
79
+ *
80
+ * Lines rather than chars — unlike the prose layers, a source file's reviewable
81
+ * unit is the line, and a line count is what every reader, editor and diff
82
+ * already reports.
83
+ */
84
+ export const SOURCE_CEILING_LINES = 1_500;
85
+
86
+ /** The root this gate walks. */
87
+ export const SOURCE_ROOTS = ['src'] as const;
88
+
89
+ export interface SourceFile {
90
+ readonly file: string;
91
+ readonly lines: number;
92
+ }
93
+
94
+ function* walk(dir: string): Generator<string> {
95
+ let entries: fs.Dirent[];
96
+ try {
97
+ entries = fs.readdirSync(dir, { withFileTypes: true });
98
+ } catch {
99
+ return;
100
+ }
101
+ for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
102
+ const full = path.join(dir, e.name);
103
+ if (e.isDirectory()) {
104
+ if (e.name === 'node_modules') continue;
105
+ yield* walk(full);
106
+ } else if (e.isFile() && e.name.endsWith('.ts')) {
107
+ // Same two exclusions this repository's own gate-population
108
+ // classifier makes (`_lib/gate_population.ts`), for the same stated
109
+ // reasons: a type declaration has no runtime behaviour, and a test
110
+ // file is not the reviewable code this ceiling is aimed at. Latent
111
+ // rather than cosmetic — a GENERATED `.d.ts` over the ceiling would
112
+ // otherwise red a ratchet that exists to bound hand-written code.
113
+ if (e.name.endsWith('.d.ts') || e.name.endsWith('.test.ts')) continue;
114
+ yield full;
115
+ }
116
+ }
117
+ }
118
+
119
+ export function collectSourceFiles(repoRoot: string): string[] {
120
+ const out: string[] = [];
121
+ for (const root of SOURCE_ROOTS) {
122
+ for (const f of walk(path.join(repoRoot, root))) {
123
+ out.push(f);
124
+ }
125
+ }
126
+ return [...new Set(out)].sort();
127
+ }
128
+
129
+ /**
130
+ * Count lines the way `wc -l` does — newline characters.
131
+ *
132
+ * Stated rather than left implicit: a file with no trailing newline reads one
133
+ * lower than an editor would show. Every file in this tree ends with one, and
134
+ * pinning the convention to `wc -l` means a contributor can reproduce the
135
+ * gate's number with a command they already know.
136
+ */
137
+ export function countLines(text: string): number {
138
+ let n = 0;
139
+ for (let i = 0; i < text.length; i += 1) {
140
+ if (text[i] === '\n') n += 1;
141
+ }
142
+ return n;
143
+ }
144
+
145
+ export function measure(repoRoot: string, files: readonly string[]): SourceFile[] {
146
+ const out: SourceFile[] = [];
147
+ for (const f of files) {
148
+ // Deliberately NOT wrapped in a try/catch that `continue`s. A file this
149
+ // walker just enumerated and cannot read is a real problem, and a bare
150
+ // `continue` is the silent skip `_lib/gate_ledger` exists to catch: the
151
+ // target is already planned, so swallowing the error only defers the
152
+ // failure into an `UnaccountedTargetsError` naming a file whose read
153
+ // nobody can connect to it. Letting the read throw names the file and
154
+ // the reason. The ledger's skip vocabulary carries no code for
155
+ // "unreadable", and widening that shared union is not this change.
156
+ const text = fs.readFileSync(f, 'utf-8');
157
+ out.push({ file: path.relative(repoRoot, f).split(path.sep).join('/'), lines: countLines(text) });
158
+ }
159
+ return out;
160
+ }
161
+
162
+ /** Lines a single file carries above the ceiling; 0 when it is within budget. */
163
+ export function excessOf(file: SourceFile, ceiling: number = SOURCE_CEILING_LINES): number {
164
+ return Math.max(0, file.lines - ceiling);
165
+ }
166
+
167
+ /** The ratchet value: total lines above the ceiling across the whole tree. */
168
+ export function totalExcess(
169
+ files: readonly SourceFile[],
170
+ ceiling: number = SOURCE_CEILING_LINES,
171
+ ): number {
172
+ let sum = 0;
173
+ for (const f of files) sum += excessOf(f, ceiling);
174
+ return sum;
175
+ }
176
+
177
+ interface Args {
178
+ readonly json: boolean;
179
+ readonly quiet: boolean;
180
+ }
181
+
182
+ function parseArgs(argv: readonly string[]): Args {
183
+ let json = false;
184
+ let quiet = false;
185
+ for (const arg of argv) {
186
+ if (arg === '--json') { json = true; }
187
+ else if (arg === '--quiet') { quiet = true; }
188
+ else if (arg === '-h' || arg === '--help') {
189
+ process.stdout.write('usage: check_source_size_budget [--json] [--quiet]\n');
190
+ process.exit(0);
191
+ } else {
192
+ process.stderr.write(`check_source_size_budget: unrecognized argument: ${arg}\n`);
193
+ process.exit(2);
194
+ }
195
+ }
196
+ return { json, quiet };
197
+ }
198
+
199
+ export function selfTest(): number {
200
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cssb-selftest-'));
201
+ const mkRepo = (bodies: Record<string, number>, baseline?: number): string => {
202
+ const root = fs.mkdtempSync(path.join(tmp, 'repo-'));
203
+ for (const [rel, lines] of Object.entries(bodies)) {
204
+ const p = path.join(root, rel);
205
+ fs.mkdirSync(path.dirname(p), { recursive: true });
206
+ fs.writeFileSync(p, '//\n'.repeat(lines), 'utf-8');
207
+ }
208
+ const cfg = path.join(root, 'src', 'config');
209
+ fs.mkdirSync(cfg, { recursive: true });
210
+ // Absent baseline ⇒ any excess is a violation, which is the
211
+ // discrimination most cases need. A supplied baseline is how the
212
+ // per-file-growth case proves the metric is not a file count.
213
+ const gates =
214
+ baseline === undefined
215
+ ? {}
216
+ : {
217
+ check_source_size_budget: {
218
+ count: baseline,
219
+ landed: new Date().toISOString().slice(0, 10),
220
+ note: 'self-test fixture',
221
+ },
222
+ };
223
+ fs.writeFileSync(
224
+ path.join(cfg, 'gate-violation-baselines.json'),
225
+ JSON.stringify({ gates }),
226
+ 'utf-8',
227
+ );
228
+ return root;
229
+ };
230
+ const run = (root: string): number => {
231
+ process.env['CHECK_SOURCE_SIZE_BUDGET_ROOT'] = root;
232
+ try {
233
+ return runGateCli(
234
+ REAL_REPO_ROOT,
235
+ 'src/scripts/check_source_size_budget.ts',
236
+ ['--quiet'],
237
+ root,
238
+ );
239
+ } finally {
240
+ delete process.env['CHECK_SOURCE_SIZE_BUDGET_ROOT'];
241
+ }
242
+ };
243
+
244
+ try {
245
+ const over = mkRepo({ 'src/big.ts': SOURCE_CEILING_LINES + 1 });
246
+ const under = mkRepo({ 'src/ok.ts': SOURCE_CEILING_LINES - 1 });
247
+ const exact = mkRepo({ 'src/edge.ts': SOURCE_CEILING_LINES });
248
+ const empty = mkRepo({ 'README.md': 10 });
249
+ // The discriminator against a file-count ratchet: ONE over-ceiling file
250
+ // in both trees, so a count would read 1 = 1 and pass. The excess grew
251
+ // by 100 lines, so this metric fails.
252
+ const grown = mkRepo({ 'src/big.ts': SOURCE_CEILING_LINES + 200 }, 100);
253
+ const shrunk = mkRepo({ 'src/big.ts': SOURCE_CEILING_LINES + 50 }, 100);
254
+ return runSelfTest({
255
+ gate: 'check_source_size_budget',
256
+ minCases: 4,
257
+ minRejectCases: 3,
258
+ cases: [
259
+ {
260
+ name: 'a file one line over the ceiling is rejected',
261
+ expect: 'reject',
262
+ run: () => run(over),
263
+ },
264
+ {
265
+ name: 'a file one line under the ceiling passes',
266
+ expect: 'accept',
267
+ run: () => run(under),
268
+ },
269
+ {
270
+ name: 'the ceiling itself is inclusive — exactly at the limit passes',
271
+ expect: 'accept',
272
+ run: () => run(exact),
273
+ },
274
+ {
275
+ name: 'a dead scan root is rejected, not reported as clean',
276
+ expect: 'reject',
277
+ run: () => run(empty),
278
+ },
279
+ {
280
+ name: 'an existing over-ceiling file that GREW is rejected — the metric is excess lines, not a file count',
281
+ expect: 'reject',
282
+ run: () => run(grown),
283
+ },
284
+ {
285
+ name: 'an over-ceiling file that shrank passes and asks for a tighter ratchet',
286
+ expect: 'accept',
287
+ run: () => run(shrunk),
288
+ },
289
+ ],
290
+ });
291
+ } finally {
292
+ fs.rmSync(tmp, { recursive: true, force: true });
293
+ }
294
+ }
295
+
296
+ export function main(argv?: readonly string[]): number {
297
+ const raw = argv ?? process.argv.slice(2);
298
+ if (raw.includes('--self-test')) {
299
+ return selfTest();
300
+ }
301
+ const args = parseArgs(raw);
302
+ // Root derived from this file's own location, so the gate is robust to the
303
+ // cwd a wrapper or hook invokes it from. The env override exists so the
304
+ // self-test can drive the REAL binary against a synthetic tree; deliberately
305
+ // NOT a cwd fallback, because a "fall back to the real repo when the given
306
+ // root looks empty" rule would make the dead-scan-root case silently pass.
307
+ const root = process.env['CHECK_SOURCE_SIZE_BUDGET_ROOT'] ?? REAL_REPO_ROOT;
308
+
309
+ const files = collectSourceFiles(root);
310
+ const ledger = new GateLedger('check_source_size_budget');
311
+ ledger.plan(files.map((f) => path.relative(root, f).split(path.sep).join('/')));
312
+
313
+ const measured = measure(root, files);
314
+ const over = measured
315
+ .filter((m) => excessOf(m) > 0)
316
+ .sort((a, b) => b.lines - a.lines);
317
+ const overSet = new Set(over.map((o) => o.file));
318
+ for (const m of measured) {
319
+ if (overSet.has(m.file)) {
320
+ ledger.fail(
321
+ m.file,
322
+ `${String(m.lines)} lines, ${String(excessOf(m))} over ceiling ${String(SOURCE_CEILING_LINES)}`,
323
+ );
324
+ } else {
325
+ ledger.complete(m.file);
326
+ }
327
+ }
328
+ const tally = ledger.finalize();
329
+ const excess = totalExcess(measured);
330
+
331
+ // `--json` must emit JSON and nothing else, so a caller can pipe it to a
332
+ // JSON reader. Both of the calls below normally append prose to the same
333
+ // stdout, which would make the document unparseable — so under `--json` the
334
+ // scan report is routed to a no-op writer (the `assertScanned` dead-root
335
+ // check inside it still runs, it just does not print) and the ratchet
336
+ // verdict is carried as a FIELD rather than as a trailing paragraph.
337
+ if (args.json) {
338
+ reportScanned(
339
+ {
340
+ gate: 'check_source_size_budget',
341
+ scanned: measured.length,
342
+ units: 'source file(s)',
343
+ roots: [...SOURCE_ROOTS],
344
+ },
345
+ () => true,
346
+ );
347
+ const verdict = checkRatchet({
348
+ gate: 'check_source_size_budget',
349
+ actual: excess,
350
+ repoRoot: root,
351
+ });
352
+ process.stdout.write(
353
+ `${JSON.stringify(
354
+ {
355
+ version: 1,
356
+ ceiling: SOURCE_CEILING_LINES,
357
+ excess_lines: excess,
358
+ over: over.map((o) => ({ ...o, excess: excessOf(o) })),
359
+ scanned: measured.length,
360
+ ledger: tally,
361
+ verdict: {
362
+ status: verdict.status,
363
+ ok: verdict.ok,
364
+ baseline: verdict.baseline,
365
+ message: verdict.message,
366
+ },
367
+ },
368
+ null,
369
+ 2,
370
+ )}\n`,
371
+ );
372
+ return verdict.ok ? 0 : 1;
373
+ }
374
+
375
+ if (!args.quiet && over.length > 0) {
376
+ process.stdout.write(
377
+ `\nOver the ${String(SOURCE_CEILING_LINES)}-line source ceiling ` +
378
+ '(a growth ratchet, NOT a measured quality threshold):\n',
379
+ );
380
+ for (const o of over) {
381
+ process.stdout.write(
382
+ ` ${String(o.lines).padStart(6, ' ')} (+${String(excessOf(o)).padStart(5, ' ')}) ${o.file}\n`,
383
+ );
384
+ }
385
+ process.stdout.write(` ${'—'.repeat(6)}\n total excess: ${String(excess)} line(s)\n`);
386
+ }
387
+
388
+ reportScanned({
389
+ gate: 'check_source_size_budget',
390
+ scanned: measured.length,
391
+ units: 'source file(s)',
392
+ roots: [...SOURCE_ROOTS],
393
+ });
394
+
395
+ const verdict = checkRatchet({
396
+ gate: 'check_source_size_budget',
397
+ actual: excess,
398
+ repoRoot: root,
399
+ });
400
+ if (verdict.ok) {
401
+ process.stdout.write(`\n⚠️ ${verdict.message}\n`);
402
+ return 0;
403
+ }
404
+ process.stderr.write(`\n❌ ${verdict.message}\n`);
405
+ return 1;
406
+ }
407
+
408
+ const _HERE = fileURLToPath(import.meta.url);
409
+ function _isCliEntry(): boolean {
410
+ if (process.argv[1] === undefined) {
411
+ return false;
412
+ }
413
+ if (import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
414
+ return true;
415
+ }
416
+ try {
417
+ return (
418
+ fs.realpathSync(fileURLToPath(import.meta.url)) ===
419
+ fs.realpathSync(path.resolve(process.argv[1]))
420
+ );
421
+ } catch {
422
+ return false;
423
+ }
424
+ }
425
+
426
+ if (_isCliEntry() || process.argv[1] === _HERE) {
427
+ process.exit(main());
428
+ }
@@ -238,7 +238,11 @@ export const SUPPRESSION_INVENTORY: readonly SuppressionSpec[] = [
238
238
  listKey: 'entries',
239
239
  tier: 'object',
240
240
  keyFields: ['slug'],
241
- newInThisChange: true,
241
+ // `newInThisChange` removed: the baseline now resolves at the base ref,
242
+ // so the bootstrap window this flag exists for has closed. That is the
243
+ // self-closing contract below working as designed — the gate reported
244
+ // the stale flag rather than leaving it to silently accept a future
245
+ // mistyped path as a fresh baseline.
242
246
  what: 'review prompts whose recorded prompt_hash does not re-derive',
243
247
  },
244
248
  ];
@@ -1450,7 +1450,31 @@ export interface ClaudePathsPlan {
1450
1450
  /** Patterns to emit under `paths:`, in declaration order. */
1451
1451
  globs: string[];
1452
1452
  /** Patterns deliberately not emitted, with the reason a reader can act on. */
1453
- dropped: { pattern: string; reason: 'unresolved-placeholder' | 'over-budget' }[];
1453
+ dropped: { pattern: string; reason: 'unresolved-placeholder' | 'over-budget' | 'mixed-triggers' }[];
1454
+ }
1455
+
1456
+ /**
1457
+ * Does this rule declare any trigger that is NOT path-shaped?
1458
+ *
1459
+ * Load-bearing for `_claude_paths_plan` below, because one `triggers:` list
1460
+ * means two different things on two hosts. Cursor and Windsurf treat globs as
1461
+ * ADDITIVE — a rule with empty globs still reaches the agent through its
1462
+ * description (see `_emit_cursor_mdc`'s own comment). Claude Code treats
1463
+ * `paths:` as EXCLUSIVE: with it the rule loads on a path match; without it the
1464
+ * rule loads unconditionally. The emitter fed both semantics from one list, so
1465
+ * a rule that declared keywords *and* one path glob silently became
1466
+ * path-gated-only on Claude.
1467
+ */
1468
+ export function _has_non_path_trigger(meta: Record<string, unknown>): boolean {
1469
+ const triggers = meta['triggers'];
1470
+ if (!Array.isArray(triggers)) return false;
1471
+ for (const t of triggers) {
1472
+ if (t === null || typeof t !== 'object' || Array.isArray(t)) continue;
1473
+ const obj = t as Record<string, unknown>;
1474
+ if (typeof obj['keyword'] === 'string' && obj['keyword']) return true;
1475
+ if (typeof obj['phrase'] === 'string' && obj['phrase']) return true;
1476
+ }
1477
+ return false;
1454
1478
  }
1455
1479
 
1456
1480
  /**
@@ -1470,6 +1494,31 @@ export function _claude_paths_plan(meta: Record<string, unknown>): ClaudePathsPl
1470
1494
  }
1471
1495
  const globs: string[] = [];
1472
1496
  const dropped: ClaudePathsPlan['dropped'] = [];
1497
+
1498
+ // A rule that ALSO declares keyword / phrase triggers gets no `paths:` at
1499
+ // all, and therefore keeps loading unconditionally on Claude Code.
1500
+ //
1501
+ // Emitting `paths:` here would narrow the rule to a path match and discard
1502
+ // every keyword the author wrote, because this host reads the list as the
1503
+ // whole gate rather than as one way in. Measured before this guard: 25 of
1504
+ // 110 projected rules carried `paths:` and 19 of them also carried keyword
1505
+ // triggers — `design-fidelity` lost 21 of them, in exactly the pasted-
1506
+ // screenshot and capability-URL handovers its own routing section names as
1507
+ // the classes it exists to catch. Nothing in the tree recorded a decision
1508
+ // to make that trade, which is why it is treated as a defect.
1509
+ //
1510
+ // The drop is REPORTED rather than silent, so a rule whose obligation
1511
+ // really is path-bound is visible as a candidate for removing its keyword
1512
+ // triggers — the narrowing then becomes an authoring decision instead of an
1513
+ // emitter side effect. Cursor and Windsurf are untouched: globs are
1514
+ // additive there, so the same rule keeps both routes.
1515
+ if (_has_non_path_trigger(meta)) {
1516
+ for (const raw of derive_trigger_globs(meta)) {
1517
+ dropped.push({ pattern: raw, reason: 'mixed-triggers' });
1518
+ }
1519
+ return { globs, dropped };
1520
+ }
1521
+
1473
1522
  let spent = 0;
1474
1523
  for (const raw of derive_trigger_globs(meta)) {
1475
1524
  if (_is_unresolved_placeholder(raw)) {