@event4u/agent-config 12.1.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 (105) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +151 -0
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/cost/report.md +23 -0
  5. package/dist/agent-src/contexts/execution/subagent-routing.md +148 -33
  6. package/dist/agent-src/contexts/subagent-configuration.md +69 -7
  7. package/dist/agent-src/rules/autonomous-execution.md +1 -1
  8. package/dist/agent-src/rules/design-fidelity.md +1 -1
  9. package/dist/agent-src/rules/design-review-after-ui-write.md +10 -5
  10. package/dist/agent-src/rules/evaluator-independence.md +27 -5
  11. package/dist/agent-src/rules/git-history-discipline.md +5 -1
  12. package/dist/agent-src/rules/missing-skill-recovery.md +89 -0
  13. package/dist/agent-src/rules/ui-audit-gate.md +9 -4
  14. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
  15. package/dist/agent-src/scripts/update_roadmap_progress.ts +17 -2
  16. package/dist/agent-src/skills/authz-review/SKILL.md +4 -0
  17. package/dist/agent-src/skills/design-system-capture/SKILL.md +4 -0
  18. package/dist/agent-src/skills/design-system-capture/references/snapshot-preference-order.md +77 -0
  19. package/dist/agent-src/skills/merge-conflicts/SKILL.md +4 -0
  20. package/dist/agent-src/skills/prompt-optimizer/SKILL.md +17 -1
  21. package/dist/agent-src/skills/roadmap-writing/SKILL.md +4 -4
  22. package/dist/agent-src/skills/systematic-debugging/SKILL.md +4 -0
  23. package/dist/agent-src/skills/threat-modeling/SKILL.md +3 -0
  24. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  25. package/dist/cli-delegate/{chunk-O52B4F5S.js → chunk-3B72LSZL.js} +1 -1
  26. package/dist/cli-delegate/{chunk-55WZN5VF.js → chunk-EVTY7ISR.js} +2 -2
  27. package/dist/cli-delegate/{chunk-K34QTSD5.js → chunk-N7FEST6D.js} +17 -17
  28. package/dist/cli-delegate/{chunk-LKZ5DOYW.js → chunk-XURJ4AQC.js} +11 -3
  29. package/dist/cli-delegate/cmd_conformance.js +2 -2
  30. package/dist/cli-delegate/cmd_doctor.js +2 -2
  31. package/dist/cli-delegate/cmd_export.js +2 -2
  32. package/dist/cli-delegate/cmd_handoff.js +1 -1
  33. package/dist/cli-delegate/cmd_prune.js +2 -2
  34. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  35. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  36. package/dist/cli-delegate/cmd_sync.js +2 -2
  37. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  38. package/dist/cli-delegate/cmd_validate.js +2 -2
  39. package/dist/discovery/deprecation-report.md +1 -1
  40. package/dist/discovery/discovery-manifest.json +43 -22
  41. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  42. package/dist/discovery/discovery-manifest.summary.md +3 -3
  43. package/dist/discovery/dormancy-report.md +1 -1
  44. package/dist/discovery/orphan-report.md +1 -1
  45. package/dist/discovery/packs.json +6 -5
  46. package/dist/discovery/trust-report.md +2 -2
  47. package/dist/discovery/workspaces.json +5 -4
  48. package/dist/hooks/dispatch.js +936 -346
  49. package/dist/install/install.mjs +12 -4
  50. package/dist/mcp/registry-manifest.json +3 -3
  51. package/dist/mcp/server.json +2 -2
  52. package/dist/mcp/server.mjs +1192 -160
  53. package/dist/router.json +1 -1
  54. package/dist/server/schemas/settings.js +2 -2
  55. package/dist/server/schemas/settings.js.map +1 -1
  56. package/docs/guidelines/design-fidelity-mechanics.md +21 -0
  57. package/docs/guidelines/design-handover-extraction.md +87 -0
  58. package/docs/guidelines/prompt-templates.md +24 -1
  59. package/package.json +1 -1
  60. package/src/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
  61. package/src/agent-src/scripts/update_roadmap_progress.ts +17 -2
  62. package/src/config/evaluator-budgets.json +5 -4
  63. package/src/config/gate-coverage.yml +33 -11
  64. package/src/config/gate-violation-baselines.json +9 -3
  65. package/src/config/hook-token-budget.json +8 -1
  66. package/src/scripts/_lib/bench_ab_complexity.ts +439 -0
  67. package/src/scripts/_lib/bench_ab_safety_tier.ts +142 -0
  68. package/src/scripts/_lib/bench_ab_scoring_v2.ts +28 -3
  69. package/src/scripts/_lib/bench_ab_search_adherence.ts +423 -0
  70. package/src/scripts/_lib/bench_ab_size_claim.ts +206 -0
  71. package/src/scripts/_lib/bench_ab_workspace.ts +111 -0
  72. package/src/scripts/_lib/skill_catalogue.ts +258 -5
  73. package/src/scripts/_lib/tier_budget_routing.ts +31 -331
  74. package/src/scripts/ai_council/clients.ts +21 -21
  75. package/src/scripts/bench_ab_v2_complexity.ts +252 -0
  76. package/src/scripts/bench_ab_v2_run.ts +3 -42
  77. package/src/scripts/bench_ab_v2_safety.ts +296 -0
  78. package/src/scripts/bench_ab_v2_search.ts +343 -0
  79. package/src/scripts/bench_ab_v2_stats.ts +191 -0
  80. package/src/scripts/build_archive_index.ts +383 -0
  81. package/src/scripts/capture_skill_catalogue.ts +107 -2
  82. package/src/scripts/check_source_size_budget.ts +428 -0
  83. package/src/scripts/check_suppression_hygiene.ts +5 -1
  84. package/src/scripts/cost/backfill_rates.mjs +298 -0
  85. package/src/scripts/cost/budget.mjs +12 -32
  86. package/src/scripts/cost_summary.ts +25 -0
  87. package/src/scripts/external_sources_denylist.json +1 -0
  88. package/src/scripts/hook_manifest.yaml +28 -2
  89. package/src/scripts/hooks/concern_registry.ts +2 -0
  90. package/src/scripts/hooks/skill_route_hook.ts +226 -0
  91. package/src/scripts/hooks/ui_route_nudge_hook.ts +6 -3
  92. package/src/scripts/lint_design_antipattern_parity.ts +40 -6
  93. package/src/scripts/lint_harvest_provenance.ts +35 -1
  94. package/src/scripts/lint_rule_skill_pack_reach.ts +43 -0
  95. package/src/scripts/lint_skill_router_head.ts +21 -0
  96. package/src/scripts/mcp_server/consumer_tool_catalog.json +26 -3
  97. package/src/scripts/mcp_server/tool_catalog_source.ts +6 -24
  98. package/src/scripts/mcp_server/tools.ts +87 -0
  99. package/src/scripts/release.ts +5 -5
  100. package/src/scripts/routing_doctor.ts +50 -13
  101. package/src/scripts/schemas/command.schema.json +1 -1
  102. package/src/scripts/schemas/skill.schema.json +18 -1
  103. package/src/scripts/trigger_coverage.ts +224 -3
  104. package/src/server/schemas/settings.ts +2 -2
  105. package/src/config/budget-routing.json +0 -7
@@ -879,14 +879,14 @@ function watch_pr_checks(branch: string): void {
879
879
  *
880
880
  * git's wording for this is stable across versions: the hint is `fetch first`
881
881
  * / `Updates were rejected`, and the ref line carries `[rejected]` with
882
- * `non-fast-forward` or `fetch first`.
882
+ * `non-fast-forward` or `fetch first`. A stale lease is a SECOND wording —
883
+ * `[remote rejected]` + `cannot lock ref … but expected`; pinned in the tests.
883
884
  */
884
885
  export function _is_non_fast_forward(stderr: string, stdout: string): boolean {
885
886
  const text = `${stderr}\n${stdout}`;
886
- return (
887
- /\[rejected\]/i.test(text) &&
888
- /(non-fast-forward|fetch first|behind its remote counterpart)/i.test(text)
889
- );
887
+ if (!/\[(?:remote )?rejected\]/i.test(text)) return false;
888
+ if (/cannot lock ref[^\n]*but expected/i.test(text)) return true;
889
+ return /(non-fast-forward|fetch first|behind its remote counterpart)/i.test(text);
890
890
  }
891
891
 
892
892
  function push_release_branch(branch: string): void {
@@ -63,7 +63,7 @@ import {
63
63
  classifyTask,
64
64
  type ActivationInputs,
65
65
  } from "./_lib/auto_dispatch.js";
66
- import { TIER_ORDER, readCooldowns } from "./_lib/tier_budget_routing.js";
66
+ import { COOLDOWN_FILE, TIER_ORDER, readCooldowns } from "./_lib/tier_budget_routing.js";
67
67
  import {
68
68
  instructionsLoadedRecord,
69
69
  measureStandingDelivery,
@@ -111,6 +111,13 @@ export interface OrchestrationReport {
111
111
  downshift: boolean;
112
112
  /** cost.budgets.per_tier ceilings + live cool-down state per tier. */
113
113
  tier_budgets: Record<string, { ceiling_usd: number | null; cooldown_until_ms: number }>;
114
+ /**
115
+ * Whether the cool-down store exists on disk. `readCooldowns` returns all
116
+ * zeros both when no tier is cooling AND when nothing has ever written the
117
+ * file, so a bare zero cannot distinguish "measured: live" from "no reading
118
+ * exists". This flag is what separates them.
119
+ */
120
+ cooldown_store_present: boolean;
114
121
  host_manifest: HostCapabilityManifest;
115
122
  /**
116
123
  * The host identifier the capability registry was keyed on, and whether it
@@ -158,14 +165,23 @@ export interface OrchestrationReport {
158
165
  }
159
166
 
160
167
  /**
161
- * Adherence ≠ delivery: budget-aware tier routing has no on/off setting any
162
- * more (always-on orchestration removed `subagents.budget_routing`), so the
163
- * doctor always checks the DELIVERY evidence — orchestration audit lines
164
- * that carry a tier decision. Dispatches recorded with ZERO tier-carrying
165
- * lines → WARN with the exact reason. Read-only over
166
- * agents/runtime/state/audit/*.jsonl; no new state. This closes the same
167
- * blind-spot class the session-canary incident exposed (policy present,
168
- * delivery silently absent).
168
+ * Tier-decision coverage in the orchestration record.
169
+ *
170
+ * REPOINTED 2026-08-16. This used to check "delivery evidence" for budget-aware
171
+ * tier routing, on the reasoning that policy can be present while delivery is
172
+ * silently absent — the session-canary blind-spot class. That layer is now
173
+ * ARCHIVED (`docs/contracts/budget-routing.md`), so the old rationale no longer
174
+ * holds and the old warning would have been permanently true and un-actionable:
175
+ * nothing dispatches through a tier decision by design, so telling the operator
176
+ * "the relation may not be running" is a dead advisory of exactly the shape this
177
+ * repository keeps removing elsewhere.
178
+ *
179
+ * What it measures now is narrower and still worth having: how many recorded
180
+ * dispatches carry a `tier` at all. That is the SAME field the archived layer's
181
+ * revisit-if depends on — a saving can only be computed once both the chosen and
182
+ * the realized tier are recorded — so this is the instrument that would show the
183
+ * reopen condition arriving. It reports coverage rather than warning about it.
184
+ * Read-only over agents/runtime/state/audit/*.jsonl; no new state.
169
185
  */
170
186
  export function check_budget_delivery(
171
187
  workspace_root: string,
@@ -202,9 +218,10 @@ export function check_budget_delivery(
202
218
  }
203
219
  if (out.eligible_dispatches > 0 && out.budget_evidence_lines === 0) {
204
220
  out.warning =
205
- `budget-aware tier routing shows no delivery evidence: ` +
206
- `${out.eligible_dispatches} orchestration dispatch(es) recorded, 0 carry a tier decision — ` +
207
- `the relation may not be running (adherence != delivery)`;
221
+ `tier-decision coverage is 0 of ${out.eligible_dispatches} recorded dispatch(es) — ` +
222
+ `expected while budget routing stays archived (docs/contracts/budget-routing.md). ` +
223
+ `This is the counter its revisit-if reads: a non-zero value here is the signal ` +
224
+ `that per-tier dispatch has reappeared and the decision can be reopened`;
208
225
  }
209
226
  return out;
210
227
  }
@@ -356,6 +373,7 @@ export function collect_orchestration(
356
373
  const ledger_present = fs.existsSync(path.join(tracking_dir, "sessions.jsonl"));
357
374
  const per_tier = (cost_budgets["per_tier"] ?? {}) as Record<string, unknown>;
358
375
  const cooldowns = readCooldowns(tracking_dir);
376
+ const cooldown_store_present = fs.existsSync(path.join(tracking_dir, COOLDOWN_FILE));
359
377
  const tier_budgets: OrchestrationReport["tier_budgets"] = {};
360
378
  for (const t of TIER_ORDER) {
361
379
  const v = per_tier[t];
@@ -382,6 +400,7 @@ export function collect_orchestration(
382
400
  halted,
383
401
  downshift,
384
402
  tier_budgets,
403
+ cooldown_store_present,
385
404
  host_manifest,
386
405
  host_platform: platform,
387
406
  host_platform_assumed: platform_assumed,
@@ -636,10 +655,28 @@ function _render(report: DoctorReport): string {
636
655
  );
637
656
  const tierBits = Object.entries(o.tier_budgets).map(([t, s]) => {
638
657
  const cap = s.ceiling_usd === null ? "no cap" : `$${s.ceiling_usd}`;
639
- const cool = s.cooldown_until_ms > 0 ? " COOLING" : "";
658
+ // `> Date.now()`, not `> 0`. The purger went with the archived permit
659
+ // lifecycle (`tripCooldown` rewrote the map on every write), so a stale
660
+ // entry now has nothing to clear it and `> 0` would render COOLING for
661
+ // ever. Latent rather than live — there is no writer either — but the
662
+ // comparison should be the one that stays correct if a writer returns.
663
+ const cool = s.cooldown_until_ms > Date.now() ? " COOLING" : "";
640
664
  return `${t}=${cap}${cool}`;
641
665
  });
642
666
  lines.push(` tier budgets (rolling-24h): ${tierBits.join(" · ")}`);
667
+ // `readCooldowns` returns all zeros both when no tier is cooling and when
668
+ // nothing has ever written the store, so a missing COOLING marker alone
669
+ // cannot be read as a measured "this tier is live". Report which of the two
670
+ // it is — the same unavailable-vs-false distinction the capability-
671
+ // provenance line above makes two lines earlier. Derived from the store's
672
+ // existence rather than from a caller count, so it stays true if the
673
+ // cool-down producer is ever wired.
674
+ if (!o.cooldown_store_present) {
675
+ lines.push(
676
+ ` cool-down state: no reading on disk (${COOLDOWN_FILE} absent) — ` +
677
+ `absence of COOLING above is not evidence of a live tier`,
678
+ );
679
+ }
643
680
  if (o.delivery.warning) {
644
681
  lines.push(` ⚠️ ${o.delivery.warning}`);
645
682
  } else if (o.delivery.eligible_dispatches > 0) {
@@ -254,7 +254,7 @@
254
254
  },
255
255
  "requires_confirmation": {
256
256
  "type": "boolean",
257
- "description": "Optional, additive: the command's action STAGES rather than executes, so an irreversible step waits for an explicit human confirmation naming the exact object (non-destructive-by-default § Never act while asking). Twin of the same key in skill.schema.json's execution block; commands carry no execution block, so it sits top-level. A declaration only — it names the action class, never the object, which exists at stage time and not in frontmatter. Mechanism: stageAction/confirmOnce in work_engine/hooks/builtin/staged_confirmation.ts; enumerate with `agent-config gates --pending`. Nothing BINDS on it yet (blocker confirmation-degraded-host-semantics: 5 of 8 hosts have no pre_tool_use slot)."
257
+ "description": "Optional, additive: the command's action STAGES rather than executes, so an irreversible step waits for an explicit human confirmation naming the exact object (non-destructive-by-default § Never act while asking). Twin of the same key in skill.schema.json's execution block; commands carry no execution block, so it sits top-level. A declaration only — it names the action class, never the object, which exists at stage time and not in frontmatter. Mechanism: stageAction/confirmOnce in work_engine/hooks/builtin/staged_confirmation.ts; enumerate with `agent-config gates --pending`. Nothing BINDS on it yet (blocker confirmation-degraded-host-semantics: only claude both binds pre_tool_use and honours a deny — docs/contracts/hook-architecture-v1.md, section Which hosts carry pre_tool_use)."
258
258
  },
259
259
  "auto_detect": {
260
260
  "type": "boolean",
@@ -276,7 +276,7 @@
276
276
  },
277
277
  "requires_confirmation": {
278
278
  "type": "boolean",
279
- "description": "Optional, additive: the skill's action STAGES rather than executes, so an irreversible step waits for an explicit human confirmation naming the exact object (non-destructive-by-default § Never act while asking). A declaration only — it names the action class, never the object, which exists at stage time and not in frontmatter. The mechanism is stageAction/confirmOnce in work_engine/hooks/builtin/staged_confirmation.ts; enumerate what is pending with `agent-config gates --pending`. Nothing BINDS on this flag yet: on 5 of 8 hosts there is no pre_tool_use slot, so a guard would degrade to prose while the flag reads like a control — that decision is deferred behind blocker confirmation-degraded-host-semantics."
279
+ "description": "Optional, additive: the skill's action STAGES rather than executes, so an irreversible step waits for an explicit human confirmation naming the exact object (non-destructive-by-default § Never act while asking). A declaration only — it names the action class, never the object, which exists at stage time and not in frontmatter. The mechanism is stageAction/confirmOnce in work_engine/hooks/builtin/staged_confirmation.ts; enumerate what is pending with `agent-config gates --pending`. Nothing BINDS on this flag yet: only claude both binds pre_tool_use and honours a deny (docs/contracts/hook-architecture-v1.md, section Which hosts carry pre_tool_use — 2 further hosts bind it and ignore the verdict, 3 alias it unbound, 2 have no pre-tool surface), so a guard would degrade to prose on 7 of 8 hosts while the flag reads like a control — that decision is deferred behind blocker confirmation-degraded-host-semantics."
280
280
  },
281
281
  "command": {
282
282
  "type": "array",
@@ -287,6 +287,23 @@
287
287
  }
288
288
  }
289
289
  },
290
+ "triggers": {
291
+ "type": "array",
292
+ "minItems": 1,
293
+ "items": {
294
+ "type": "object",
295
+ "additionalProperties": false,
296
+ "properties": {
297
+ "keyword": {"type": "string", "minLength": 1},
298
+ "phrase": {"type": "string", "minLength": 1},
299
+ "file_pattern": {"type": "string", "minLength": 1},
300
+ "path_prefix": {"type": "string", "minLength": 1},
301
+ "command": {"type": "string", "minLength": 1},
302
+ "reason": {"type": "string"}
303
+ }
304
+ },
305
+ "description": "Activation triggers for the SKILL catalogue, shaped after the rule-side field so the two stay legible together — same five match keys (keyword, phrase, file_pattern, path_prefix, command), same additionalProperties:false, and deliberately no `intent` key, which was removed rule-side for giving authors false confidence that an activation path existed. OPTIONAL and empty of adopters on the day it ships: the capability becomes declarable before anything declares it, so a seeded tranche is measurable against the router corpus before it is written. Read by scripts/trigger_coverage.ts --scope skill through the same router_match.ts matcher the rule scope uses — not a fork. Schema: docs/contracts/rule-router.md."
306
+ },
290
307
  "workspaces": {
291
308
  "type": "array",
292
309
  "minItems": 1,
@@ -40,6 +40,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
40
40
  import { parse as parseYaml } from 'yaml';
41
41
 
42
42
  import { keyword_matches_anchored } from './router_telemetry.js';
43
+ import { match_prompt, type Router as MatchRouter } from './_lib/router_match.js';
43
44
 
44
45
  const _HERE = fileURLToPath(import.meta.url);
45
46
 
@@ -48,6 +49,11 @@ export const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
48
49
  export const ROUTER = path.join(REPO_ROOT, 'dist', 'router.json');
49
50
  export const CORPUS = path.join(REPO_ROOT, 'tests', 'eval', 'trigger-coverage.yaml');
50
51
 
52
+ /** Skill tree the `skill` scope reads `triggers:` frontmatter from. */
53
+ export const SKILLS_DIR = path.join(REPO_ROOT, 'src', 'skills');
54
+ /** The matrix corpus the unintended-activation census is defined over. */
55
+ export const MATRIX_DIR = path.join(REPO_ROOT, 'tests', 'eval', 'routing-matrix');
56
+
51
57
  type Router = {
52
58
  kernel?: string[];
53
59
  tier_1?: Array<{ id: string; triggers?: Array<Record<string, string>> }>;
@@ -100,6 +106,168 @@ export function fired_rules(prompt: string, router: Router): Set<string> {
100
106
  return fired;
101
107
  }
102
108
 
109
+ /* ------------------------------------------------------------------ *
110
+ * The SKILL scope.
111
+ *
112
+ * It reuses `match_prompt` from `_lib/router_match.ts` — the same matcher the
113
+ * rule scope's census runs on — by handing it a router-SHAPED view of the
114
+ * skill catalogue. Forking the matcher would let the two scopes disagree about
115
+ * what "fires" means, which is the exact drift the shared helper exists to
116
+ * prevent.
117
+ *
118
+ * WHAT THIS SCOPE CANNOT REGRESS, stated because the roadmap's own risk
119
+ * register assumed otherwise. Risk 2 reads "the corpus counts activations
120
+ * across the whole router, so a skill-side tranche can regress a rule-side
121
+ * number". Verified against the tree: `compile_router.ts` contains zero
122
+ * references to skills, and `dist/router.json` carries exactly
123
+ * `kernel` + `tier_1` + `tier_2` rule entries. Skill triggers are compiled
124
+ * into nothing and are matched here over a SEPARATE catalogue, so the rule-side
125
+ * 433 census is structurally untouched by any skill tranche. The risk it names
126
+ * is real for a shared router and does not exist in this one — which makes the
127
+ * skill-side count below a NEW number needing its own ceiling, not a threat to
128
+ * the old one.
129
+ * ------------------------------------------------------------------ */
130
+
131
+ /** One skill that declares activation triggers. */
132
+ export interface SkillTriggerEntry {
133
+ id: string;
134
+ triggers: Array<Record<string, string>>;
135
+ }
136
+
137
+ /** `triggers:` list items from a SKILL.md frontmatter block. */
138
+ function _parseTriggerBlock(frontmatter: string): Array<Record<string, string>> {
139
+ const out: Array<Record<string, string>> = [];
140
+ const lines = frontmatter.split('\n');
141
+ let inside = false;
142
+ for (const line of lines) {
143
+ if (/^triggers:\s*$/.test(line)) {
144
+ inside = true;
145
+ continue;
146
+ }
147
+ if (inside && /^\S/.test(line)) break; // next top-level key ends the block
148
+ if (!inside) continue;
149
+ const item = /^\s*-\s*(keyword|phrase|file_pattern|path_prefix|command):\s*(.+?)\s*$/.exec(line);
150
+ if (item) {
151
+ out.push({ [item[1]!]: item[2]!.replace(/^["']|["']$/g, '') });
152
+ }
153
+ }
154
+ return out;
155
+ }
156
+
157
+ /** Every skill under `skillsDir` that declares a non-empty `triggers:` list. */
158
+ export function loadSkillTriggers(skillsDir: string): SkillTriggerEntry[] {
159
+ let names: string[];
160
+ try {
161
+ names = fs.readdirSync(skillsDir);
162
+ } catch {
163
+ return [];
164
+ }
165
+ const out: SkillTriggerEntry[] = [];
166
+ for (const name of names.sort()) {
167
+ const file = path.join(skillsDir, name, 'SKILL.md');
168
+ let content: string;
169
+ try {
170
+ content = fs.readFileSync(file, 'utf-8');
171
+ } catch {
172
+ continue;
173
+ }
174
+ if (!content.startsWith('---')) continue;
175
+ const end = content.indexOf('\n---', 3);
176
+ if (end === -1) continue;
177
+ const triggers = _parseTriggerBlock(content.slice(3, end));
178
+ if (triggers.length > 0) out.push({ id: name, triggers });
179
+ }
180
+ return out;
181
+ }
182
+
183
+ /**
184
+ * Every prompt in the matrix corpus, positives and near-misses alike.
185
+ *
186
+ * Parsed with the YAML reader this module already imports, not with a
187
+ * line regex. A regex anchored on `- prompt: "…"` silently drops any entry
188
+ * written unquoted or in single quotes, and a corpus that shrinks without
189
+ * saying so moves every rate computed against it — the denominator would
190
+ * change while the number kept its old name.
191
+ */
192
+ export function loadMatrixPrompts(matrixDir: string): string[] {
193
+ let files: string[];
194
+ try {
195
+ files = fs.readdirSync(matrixDir).filter((n) => n.endsWith('.yaml')).sort();
196
+ } catch {
197
+ return [];
198
+ }
199
+ const out: string[] = [];
200
+ for (const f of files) {
201
+ let doc: unknown;
202
+ try {
203
+ doc = parseYaml(fs.readFileSync(path.join(matrixDir, f), 'utf-8'), { version: '1.1' });
204
+ } catch {
205
+ continue; // an unparseable corpus file is skipped, never guessed at
206
+ }
207
+ if (typeof doc !== 'object' || doc === null) continue;
208
+ const record = doc as Record<string, unknown>;
209
+ for (const section of ['positives', 'near_misses']) {
210
+ const entries = record[section];
211
+ if (!Array.isArray(entries)) continue;
212
+ for (const raw of entries) {
213
+ const prompt = (raw as Record<string, unknown> | null)?.['prompt'];
214
+ if (typeof prompt === 'string' && prompt.trim() !== '') out.push(prompt);
215
+ }
216
+ }
217
+ }
218
+ return out;
219
+ }
220
+
221
+ export interface SkillScopeReport {
222
+ /** Skills declaring at least one trigger. Zero on the day the schema ships. */
223
+ skills_with_triggers: number;
224
+ prompts: number;
225
+ /** Total (prompt, skill) activations — the skill-side census. */
226
+ activations: number;
227
+ /** Prompts that activated at least one skill. */
228
+ prompts_activating: number;
229
+ /** Per-skill activation counts, descending then by id. */
230
+ histogram: Array<[string, number]>;
231
+ }
232
+
233
+ /**
234
+ * Run the skill catalogue over the matrix corpus with the shared matcher.
235
+ *
236
+ * `tier_2` is the carrier for the router-shaped view purely because
237
+ * `match_prompt` reads its rule lists from `tier_1`/`tier_2`; skills have no
238
+ * tier and none is implied by the placement.
239
+ */
240
+ export function runSkillScope(
241
+ entries: readonly SkillTriggerEntry[],
242
+ prompts: readonly string[],
243
+ ): SkillScopeReport {
244
+ // `kernel: []` is load-bearing: `match_prompt` appends every kernel id to
245
+ // `activated_rules` unconditionally, so a non-empty kernel here would count
246
+ // rule activations as skill ones on every prompt.
247
+ const view = { kernel: [], tier_1: [], tier_2: entries } as unknown as MatchRouter;
248
+ const histogram: Record<string, number> = {};
249
+ let activations = 0;
250
+ let promptsActivating = 0;
251
+ for (const prompt of prompts) {
252
+ const result = match_prompt(view, prompt, 'full', null, null);
253
+ const ids = result.activated_rules.map((a) => String(a.rule));
254
+ if (ids.length > 0) promptsActivating += 1;
255
+ for (const id of ids) {
256
+ histogram[id] = (histogram[id] ?? 0) + 1;
257
+ activations += 1;
258
+ }
259
+ }
260
+ return {
261
+ skills_with_triggers: entries.length,
262
+ prompts: prompts.length,
263
+ activations,
264
+ prompts_activating: promptsActivating,
265
+ histogram: Object.entries(histogram).sort((a, b) =>
266
+ b[1] !== a[1] ? b[1] - a[1] : a[0] < b[0] ? -1 : 1,
267
+ ),
268
+ };
269
+ }
270
+
103
271
  export function run(corpus: Case[], router: Router): [CaseResult[], number] {
104
272
  const results: CaseResult[] = [];
105
273
  let misses = 0;
@@ -168,23 +336,76 @@ function _jsonDumps(obj: unknown): string {
168
336
 
169
337
  interface ParsedArgs {
170
338
  json: boolean;
339
+ /** `rule` keeps the pinned byte-identical contract; `skill` is additive. */
340
+ scope: 'rule' | 'skill';
171
341
  }
172
342
 
173
343
  function parse_args(argv: string[]): ParsedArgs {
174
- const out: ParsedArgs = { json: false };
175
- for (const a of argv) {
344
+ const out: ParsedArgs = { json: false, scope: 'rule' };
345
+ for (let i = 0; i < argv.length; i += 1) {
346
+ const a = argv[i]!;
176
347
  if (a === '--json') {
177
348
  out.json = true;
349
+ } else if (a === '--scope') {
350
+ const v = argv[i + 1];
351
+ if (v !== 'rule' && v !== 'skill') {
352
+ process.stderr.write(`error: --scope must be rule or skill, got: ${v ?? '(missing)'}\n`);
353
+ process.exit(2);
354
+ }
355
+ out.scope = v;
356
+ i += 1;
178
357
  } else if (a === '-h' || a === '--help') {
179
- process.stdout.write('usage: trigger_coverage [-h] [--json]\n');
358
+ process.stdout.write('usage: trigger_coverage [-h] [--json] [--scope rule|skill]\n');
180
359
  process.exit(0);
181
360
  }
182
361
  }
183
362
  return out;
184
363
  }
185
364
 
365
+ /**
366
+ * `--scope skill` — report only, never a gate.
367
+ *
368
+ * Exit 0 regardless of the count: no ceiling is committed here. The skill-side
369
+ * census is a NEW number (see the scope's header note on why the rule-side 433
370
+ * is structurally out of reach), and this package does not commit a threshold
371
+ * before it has a reading. A tranche is judged against a published baseline in
372
+ * its own change, not against a number invented on the day the instrument
373
+ * shipped.
374
+ */
375
+ function runSkillScopeCli(json: boolean): number {
376
+ const report = runSkillScope(loadSkillTriggers(SKILLS_DIR), loadMatrixPrompts(MATRIX_DIR));
377
+ if (json) {
378
+ process.stdout.write(_jsonDumps(report) + '\n');
379
+ return 0;
380
+ }
381
+ process.stdout.write(
382
+ `skill trigger coverage over ${report.prompts} matrix prompts\n` +
383
+ ` skills declaring triggers: ${report.skills_with_triggers}\n` +
384
+ ` activations: ${report.activations}\n` +
385
+ ` prompts activating ≥1: ${report.prompts_activating}\n`,
386
+ );
387
+ if (report.skills_with_triggers === 0) {
388
+ process.stdout.write(
389
+ '\nNo skill declares a trigger yet. That is the schema landing empty of\n' +
390
+ 'adopters, not a failure: the capability is declarable before anything\n' +
391
+ 'declares it, so a seeded tranche is measurable before it is written.\n',
392
+ );
393
+ return 0;
394
+ }
395
+ process.stdout.write('\nper skill:\n');
396
+ for (const [id, n] of report.histogram) {
397
+ process.stdout.write(` ${String(n).padStart(4)} ${id}\n`);
398
+ }
399
+ process.stdout.write(
400
+ '\nReport only — no ceiling is enforced here. The rule-side 433 census is a\n' +
401
+ 'different catalogue and cannot move from this scope.\n',
402
+ );
403
+ return 0;
404
+ }
405
+
186
406
  export function main(argv: string[] | null = null): number {
187
407
  const args = parse_args(argv ?? process.argv.slice(2));
408
+ if (args.scope === 'skill') return runSkillScopeCli(args.json);
188
409
 
189
410
  let isFile = false;
190
411
  try {
@@ -80,13 +80,13 @@ export const settingsSchema = z.object({
80
80
  ),
81
81
  per_tier: z.object({
82
82
  cheap: z.number().min(0).nullable().default(null).describe(
83
- 'USD ceiling for the cheap model tier under budget-aware delegation (docs/contracts/budget-routing.md). null = no separate tier cap; global ceilings still apply.',
83
+ 'USD ceiling for the cheap model tier. Budget-aware delegation was ARCHIVED 2026-08-16 (docs/contracts/budget-routing.md), so no code routes on this today; the cap is still summed and reported by `budget.mjs tier`. null = no separate tier cap; global ceilings still apply.',
84
84
  ),
85
85
  medium: z.number().min(0).nullable().default(null).describe(
86
86
  'USD ceiling for the medium model tier. null = no separate tier cap.',
87
87
  ),
88
88
  strong: z.number().min(0).nullable().default(null).describe(
89
- 'USD ceiling for the strong model tier. null = no separate tier cap — with cheap exhausted and strong funded, budget routing uses the strong tier rather than blocking work.',
89
+ 'USD ceiling for the strong model tier. null = no separate tier cap. The never-block-to-save-money relation this used to describe went with the archived budget-routing layer (docs/contracts/budget-routing.md); nothing routes between tiers today.',
90
90
  ),
91
91
  }).default({ cheap: null, medium: null, strong: null }),
92
92
  }),
@@ -1,7 +0,0 @@
1
- {
2
- "$comment": "Single source of truth for the budget-routing reserve lifecycle (docs/contracts/budget-routing.md). Read by BOTH reserve readers — src/scripts/_lib/tier_budget_routing.ts (acquireBudgetPermit) and src/scripts/cost/budget.mjs (tier subcommand) — so the reserve window can never diverge between them. reserve_ttl_ms: reserves are RACE PROTECTION, not spend accounting — a pending entry older than this is dead and is dropped at the next compaction. lock_break_ms: an .lock file older than this is a crash leftover and is broken (unlink + one retry).",
3
- "owner": "maintainer",
4
- "review_by": "2027-08-03",
5
- "reserve_ttl_ms": 600000,
6
- "lock_break_ms": 30000
7
- }