@event4u/agent-config 9.17.0 → 9.18.1

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 (131) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +122 -171
  3. package/dist/agent-src/commands/challenge-me/vision.md +5 -0
  4. package/dist/agent-src/commands/evals/rule-compliance-audit.json +46 -0
  5. package/dist/agent-src/commands/feature/plan.md +20 -0
  6. package/dist/agent-src/commands/feature/roadmap.md +19 -0
  7. package/dist/agent-src/commands/pr/create.md +29 -0
  8. package/dist/agent-src/commands/roadmap/create.md +29 -0
  9. package/dist/agent-src/commands/rule-compliance-audit.md +28 -5
  10. package/dist/agent-src/contexts/execution/plan-confidence-gate.md +133 -0
  11. package/dist/agent-src/rules/active-remediation.md +2 -0
  12. package/dist/agent-src/rules/architecture.md +2 -0
  13. package/dist/agent-src/rules/augment-edit-discipline.md +2 -0
  14. package/dist/agent-src/rules/brand-consistency.md +10 -62
  15. package/dist/agent-src/rules/brand-source-of-truth.md +62 -11
  16. package/dist/agent-src/rules/broken-access-control.md +4 -0
  17. package/dist/agent-src/rules/cli-output-handling.md +5 -0
  18. package/dist/agent-src/rules/code-comment-discipline.md +3 -0
  19. package/dist/agent-src/rules/commit-conventions.md +2 -0
  20. package/dist/agent-src/rules/cross-source-consistency.md +3 -0
  21. package/dist/agent-src/rules/design-fidelity.md +2 -0
  22. package/dist/agent-src/rules/doc-screenshot-hygiene.md +2 -0
  23. package/dist/agent-src/rules/docker-commands.md +4 -0
  24. package/dist/agent-src/rules/domain-adoption-policy.md +2 -0
  25. package/dist/agent-src/rules/domain-safety-disclaimer.md +5 -2
  26. package/dist/agent-src/rules/engineering-safety-floor.md +2 -0
  27. package/dist/agent-src/rules/framework-neutrality-in-generic-skills.md +9 -0
  28. package/dist/agent-src/rules/image-likeness-and-rights.md +4 -0
  29. package/dist/agent-src/rules/improve-before-implement.md +4 -0
  30. package/dist/agent-src/rules/laravel-routing.md +4 -0
  31. package/dist/agent-src/rules/legal-safety-floor.md +3 -0
  32. package/dist/agent-src/rules/lethal-trifecta-guard.md +3 -0
  33. package/dist/agent-src/rules/media-governance-routing.md +5 -0
  34. package/dist/agent-src/rules/minimal-safe-diff.md +2 -0
  35. package/dist/agent-src/rules/no-roadmap-references.md +2 -0
  36. package/dist/agent-src/rules/notes-first-reasoning.md +2 -0
  37. package/dist/agent-src/rules/php-coding.md +2 -0
  38. package/dist/agent-src/rules/prefer-enums-over-literals.md +3 -0
  39. package/dist/agent-src/rules/provider-lifecycle-discipline.md +5 -0
  40. package/dist/agent-src/rules/roadmap-ci-steps-policy.md +2 -0
  41. package/dist/agent-src/rules/roadmap-progress-sync.md +2 -0
  42. package/dist/agent-src/rules/rule-type-governance.md +2 -0
  43. package/dist/agent-src/rules/scale-discipline.md +3 -0
  44. package/dist/agent-src/rules/secret-vcs-guard.md +2 -0
  45. package/dist/agent-src/rules/security-sensitive-stop.md +9 -2
  46. package/dist/agent-src/rules/senior-engineering-discipline.md +6 -0
  47. package/dist/agent-src/rules/skill-quality.md +2 -0
  48. package/dist/agent-src/rules/source-confidentiality.md +4 -0
  49. package/dist/agent-src/rules/source-discovery-gate.md +3 -0
  50. package/dist/agent-src/rules/source-of-truth.md +2 -0
  51. package/dist/agent-src/rules/strategy-safety-floor.md +2 -0
  52. package/dist/agent-src/rules/think-before-action.md +5 -0
  53. package/dist/agent-src/rules/ui-audit-gate.md +2 -0
  54. package/dist/agent-src/skills/design-intelligence/SKILL.md +6 -180
  55. package/dist/agent-src/skills/design-intelligence/references/context-and-registers.md +100 -0
  56. package/dist/agent-src/skills/design-intelligence/references/integration-mapping.md +91 -0
  57. package/dist/agent-src/skills/design-review/SKILL.md +4 -141
  58. package/dist/agent-src/skills/design-review/references/review-communication.md +92 -0
  59. package/dist/agent-src/skills/design-review/references/verification-automation.md +61 -0
  60. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +8 -27
  61. package/dist/agent-src/skills/existing-ui-audit/references/anti-slop-cross-reference.md +31 -0
  62. package/dist/agent-src/skills/fe-design/SKILL.md +4 -289
  63. package/dist/agent-src/skills/fe-design/references/design-patterns.md +227 -0
  64. package/dist/agent-src/skills/fe-design/references/design-read-and-memory.md +75 -0
  65. package/dist/agent-src/skills/roadmap-management/SKILL.md +10 -0
  66. package/dist/agent-src/skills/roadmap-writing/SKILL.md +12 -1
  67. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  68. package/dist/agent-src/templates/roadmaps.md +12 -0
  69. package/dist/agent-src/templates/scripts/work_engine/directives/ui/_fix_lane.ts +52 -0
  70. package/dist/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +8 -0
  71. package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +6 -0
  72. package/dist/agent-src/templates/scripts/work_engine/intent/classify.ts +56 -4
  73. package/dist/cli/registry.js +2 -0
  74. package/dist/cli/registry.js.map +1 -1
  75. package/dist/cli-delegate/chunk-AIIUU73N.js +2040 -0
  76. package/dist/cli-delegate/{chunk-IMSXPRVH.js → chunk-ELMFOQPW.js} +11 -0
  77. package/dist/cli-delegate/chunk-LF2KKJ4Q.js +183 -0
  78. package/dist/cli-delegate/cmd_explain.js +36 -36
  79. package/dist/cli-delegate/cmd_export.js +1 -1
  80. package/dist/cli-delegate/cmd_handoff.js +203 -2228
  81. package/dist/cli-delegate/cmd_prune.js +1 -1
  82. package/dist/cli-delegate/cmd_route_audit.js +249 -0
  83. package/dist/cli-delegate/cmd_route_explain.js +285 -0
  84. package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
  85. package/dist/cli-delegate/cmd_sync.js +1 -1
  86. package/dist/cli-delegate/cmd_uninstall.js +1 -1
  87. package/dist/cli-delegate/cmd_validate.js +1 -1
  88. package/dist/discovery/deprecation-report.md +1 -1
  89. package/dist/discovery/discovery-manifest.json +57 -57
  90. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  91. package/dist/discovery/discovery-manifest.summary.md +1 -1
  92. package/dist/discovery/orphan-report.md +1 -1
  93. package/dist/discovery/packs.json +2 -2
  94. package/dist/discovery/trust-report.md +1 -1
  95. package/dist/discovery/workspaces.json +2 -2
  96. package/dist/install/install.mjs +11 -0
  97. package/dist/mcp/registry-manifest.json +1 -1
  98. package/dist/mcp/server.json +2 -2
  99. package/dist/router.json +1 -1
  100. package/dist/server/schemas/settings.js +12 -0
  101. package/dist/server/schemas/settings.js.map +1 -1
  102. package/package.json +1 -1
  103. package/src/agent-src/templates/scripts/work_engine/directives/ui/_fix_lane.ts +52 -0
  104. package/src/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +8 -0
  105. package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +6 -0
  106. package/src/agent-src/templates/scripts/work_engine/intent/classify.ts +56 -4
  107. package/src/config/agent-settings.template.yml +46 -0
  108. package/src/config/evaluator-budgets.json +3 -3
  109. package/src/config/gate-coverage.yml +44 -0
  110. package/src/scripts/_cli/cmd_explain.ts +41 -36
  111. package/src/scripts/_cli/cmd_route_audit.ts +290 -0
  112. package/src/scripts/_cli/cmd_route_explain.ts +269 -0
  113. package/src/scripts/_dispatch.bash +23 -0
  114. package/src/scripts/_lib/git_env.ts +40 -0
  115. package/src/scripts/_lib/md_table.ts +27 -0
  116. package/src/scripts/_lib/planning_settings.ts +43 -0
  117. package/src/scripts/_lib/release_highlights.ts +279 -0
  118. package/src/scripts/_lib/router_match.ts +301 -0
  119. package/src/scripts/annotate_r1_outcomes.ts +258 -0
  120. package/src/scripts/check_completion_review.ts +1322 -0
  121. package/src/scripts/check_release_highlights.ts +60 -152
  122. package/src/scripts/compile_router.ts +20 -3
  123. package/src/scripts/dispatch_r2_reviewer.ts +969 -0
  124. package/src/scripts/eval_ui_triviality.ts +156 -0
  125. package/src/scripts/lint_abstraction_thresholds.ts +3 -1
  126. package/src/scripts/lint_plan_risk_register.ts +931 -0
  127. package/src/scripts/lint_trigger_collisions.ts +257 -0
  128. package/src/scripts/release.ts +47 -13
  129. package/src/scripts/router_telemetry.ts +16 -290
  130. package/src/scripts/schemas/rule.schema.json +10 -0
  131. package/src/server/schemas/settings.ts +18 -0
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * lint_trigger_collisions — every shared trigger carries a written-down
4
+ * disposition, or the build is red.
5
+ *
6
+ * Defect measured: a trigger string shared by ≥2 routed rules makes both fire
7
+ * on the same prompt. Many collisions are correct (`refactor` firing both the
8
+ * analyze-first and the diff-shape floor is complementary), but before this
9
+ * gate nothing forced that argument to be WRITTEN DOWN — so accidental
10
+ * co-firing (the brand pair, secret/password, valuation/DCF) accumulated
11
+ * silently. This gate makes every collision either dispositioned or an error.
12
+ *
13
+ * Why this gate shape: the existing `tests/scripts/rule_trigger_collisions.test.ts`
14
+ * is a pair-level Jaccard SIMILARITY ratchet (near-duplicate trigger sets);
15
+ * it is kept untouched. This lint is per-trigger-VALUE disposition coverage —
16
+ * a different axis: one shared value between otherwise-different rules never
17
+ * moves Jaccard past 0.4, but still needs an argument.
18
+ *
19
+ * Disposition, in the sharing rule's frontmatter (both optional keys are in
20
+ * `schemas/rule.schema.json`):
21
+ *
22
+ * collision_ok:
23
+ * "refactor": "one-line reason why this rule also firing here is correct"
24
+ * precedence:
25
+ * "refactor": 1 # all sharers declare pairwise-distinct integers
26
+ *
27
+ * A collision is clean when EVERY sharer has `collision_ok[<value>]`, OR all
28
+ * sharers carry pairwise-distinct `precedence[<value>]` integers.
29
+ *
30
+ * Scope: `src/rules/*.md`, `type: manual` excluded (reference-only, no router
31
+ * emission — ADR-004), rules without `triggers:` excluded (kernel).
32
+ * Same-value-different-kind never collides (a `keyword` and a `path_prefix`
33
+ * match different surfaces).
34
+ *
35
+ * Modes: default lint (exit 0 clean, 2 findings/dead-scope, 1 internal error;
36
+ * emits one machine-readable `scanned: <N>` line for gate-coverage), plus
37
+ * `--report <path>` writing the collision-census markdown, `--json`, `--quiet`.
38
+ */
39
+
40
+ import * as fs from 'node:fs';
41
+ import * as path from 'node:path';
42
+ import { fileURLToPath, pathToFileURL } from 'node:url';
43
+ import { parse as parseYaml } from 'yaml';
44
+
45
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
46
+
47
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
48
+ export const RULES_ROOT = 'src/rules';
49
+ const TRIGGER_KINDS = ['keyword', 'phrase', 'command', 'path_prefix', 'file_pattern'] as const;
50
+
51
+ export interface RuleTriggers {
52
+ id: string;
53
+ type: string;
54
+ triggers: { kind: string; value: string }[];
55
+ collision_ok: Record<string, string>;
56
+ precedence: Record<string, number>;
57
+ }
58
+
59
+ export interface Collision {
60
+ kind: string;
61
+ value: string; // case-folded
62
+ sharers: string[]; // rule ids, sorted
63
+ undispositioned: string[]; // rule ids missing a valid disposition
64
+ }
65
+
66
+ function _parse_frontmatter(text: string): Record<string, unknown> {
67
+ if (!text.startsWith('---\n')) return {};
68
+ const end = text.indexOf('\n---', 4);
69
+ if (end < 0) return {};
70
+ const data = parseYaml(text.slice(4, end), { version: '1.1' }) ?? {};
71
+ return data !== null && typeof data === 'object' && !Array.isArray(data)
72
+ ? (data as Record<string, unknown>)
73
+ : {};
74
+ }
75
+
76
+ function _as_string_map(v: unknown): Record<string, string> {
77
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) return {};
78
+ const out: Record<string, string> = {};
79
+ for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
80
+ out[k.toLowerCase()] = String(val);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ function _as_int_map(v: unknown): Record<string, number> {
86
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) return {};
87
+ const out: Record<string, number> = {};
88
+ for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
89
+ const n = Number(val);
90
+ if (Number.isInteger(n)) out[k.toLowerCase()] = n;
91
+ }
92
+ return out;
93
+ }
94
+
95
+ export function load_rules(rules_dir: string): RuleTriggers[] {
96
+ const out: RuleTriggers[] = [];
97
+ let files: string[] = [];
98
+ try {
99
+ files = fs.readdirSync(rules_dir).filter((f) => f.endsWith('.md')).sort();
100
+ } catch {
101
+ return [];
102
+ }
103
+ for (const f of files) {
104
+ const fm = _parse_frontmatter(fs.readFileSync(path.join(rules_dir, f), 'utf-8'));
105
+ if (Object.keys(fm).length === 0) continue;
106
+ const type = String(fm['type'] ?? 'auto');
107
+ const rawTriggers = Array.isArray(fm['triggers']) ? (fm['triggers'] as unknown[]) : [];
108
+ const triggers: { kind: string; value: string }[] = [];
109
+ for (const t of rawTriggers) {
110
+ if (t === null || typeof t !== 'object' || Array.isArray(t)) continue;
111
+ for (const kind of TRIGGER_KINDS) {
112
+ if (kind in (t as Record<string, unknown>)) {
113
+ triggers.push({ kind, value: String((t as Record<string, unknown>)[kind]).toLowerCase() });
114
+ break;
115
+ }
116
+ }
117
+ }
118
+ out.push({
119
+ id: f.replace(/\.md$/, ''),
120
+ type,
121
+ triggers,
122
+ collision_ok: _as_string_map(fm['collision_ok']),
123
+ precedence: _as_int_map(fm['precedence']),
124
+ });
125
+ }
126
+ return out;
127
+ }
128
+
129
+ export function find_collisions(rules: RuleTriggers[]): Collision[] {
130
+ const routed = rules.filter((r) => r.type !== 'manual' && r.triggers.length > 0);
131
+ const byKey = new Map<string, Set<string>>();
132
+ const ruleById = new Map(routed.map((r) => [r.id, r]));
133
+ for (const r of routed) {
134
+ for (const t of r.triggers) {
135
+ const key = `${t.kind}\u0000${t.value}`;
136
+ const set = byKey.get(key) ?? new Set<string>();
137
+ set.add(r.id);
138
+ byKey.set(key, set);
139
+ }
140
+ }
141
+ const collisions: Collision[] = [];
142
+ for (const [key, sharerSet] of byKey) {
143
+ if (sharerSet.size < 2) continue;
144
+ const [kind, value] = key.split('\u0000') as [string, string];
145
+ const sharers = [...sharerSet].sort();
146
+ const withOk = sharers.filter((id) => value in (ruleById.get(id)?.collision_ok ?? {}));
147
+ const precedences = sharers
148
+ .map((id) => (ruleById.get(id)?.precedence ?? {})[value])
149
+ .filter((n): n is number => n !== undefined);
150
+ const precedence_ok =
151
+ precedences.length === sharers.length && new Set(precedences).size === sharers.length;
152
+ const undispositioned = precedence_ok ? [] : sharers.filter((id) => !withOk.includes(id));
153
+ collisions.push({ kind, value, sharers, undispositioned });
154
+ }
155
+ collisions.sort(
156
+ (a, b) => b.sharers.length - a.sharers.length || (a.value < b.value ? -1 : a.value > b.value ? 1 : 0),
157
+ );
158
+ return collisions;
159
+ }
160
+
161
+ export function render_report(rules: RuleTriggers[], collisions: Collision[], sha: string): string {
162
+ const routed = rules.filter((r) => r.type !== 'manual' && r.triggers.length > 0);
163
+ const totalTriggers = routed.reduce((s, r) => s + r.triggers.length, 0);
164
+ const distinct = new Set(routed.flatMap((r) => r.triggers.map((t) => `${t.kind}\u0000${t.value}`))).size;
165
+ const undisp = collisions.filter((c) => c.undispositioned.length > 0);
166
+ const lines: string[] = [
167
+ '# Trigger-collision census',
168
+ '',
169
+ `> Generated by \`./scripts-run src/scripts/lint_trigger_collisions --report agents/evidence/reports/trigger-collision-census.md\` on 2026-08-04 at ${sha}.`,
170
+ '> Do not hand-edit — re-run the command.',
171
+ '',
172
+ `- Routed rules with triggers: ${routed.length} (of ${rules.length} rule files; \`type: manual\` and trigger-less kernel rules excluded)`,
173
+ `- Trigger entries: ${totalTriggers} · distinct (kind, value) pairs: ${distinct}`,
174
+ `- Colliding values (shared by ≥2 rules): ${collisions.length}`,
175
+ `- Undispositioned collisions: ${undisp.length} (the lint is red unless this is 0)`,
176
+ '',
177
+ '| # | Kind | Trigger | Rules | Disposition |',
178
+ '|---|------|---------|-------|-------------|',
179
+ ];
180
+ collisions.forEach((c, i) => {
181
+ const status = c.undispositioned.length === 0 ? 'dispositioned' : `MISSING: ${c.undispositioned.join(', ')}`;
182
+ lines.push(`| ${i + 1} | ${c.kind} | \`${c.value}\` | ${c.sharers.join(', ')} | ${status} |`);
183
+ });
184
+ lines.push('');
185
+ return lines.join('\n');
186
+ }
187
+
188
+ export function main(argv: string[]): number {
189
+ const quiet = argv.includes('--quiet');
190
+ const as_json = argv.includes('--json');
191
+ let reportPath: string | null = null;
192
+ const ri = argv.indexOf('--report');
193
+ if (ri >= 0) reportPath = argv[ri + 1] ?? null;
194
+
195
+ const rulesDir = path.join(REPO_ROOT, RULES_ROOT);
196
+ const rules = load_rules(rulesDir);
197
+ try {
198
+ assertScanned({
199
+ gate: 'lint_trigger_collisions',
200
+ scanned: rules.length,
201
+ units: 'rule file(s)',
202
+ roots: [RULES_ROOT],
203
+ });
204
+ } catch (exc) {
205
+ if (!(exc instanceof DeadScopeError)) throw exc;
206
+ process.stderr.write(`❌ ${exc.message}\n`);
207
+ return 2;
208
+ }
209
+ process.stdout.write(`scanned: ${rules.length}\n`);
210
+
211
+ const collisions = find_collisions(rules);
212
+ const findings = collisions.filter((c) => c.undispositioned.length > 0);
213
+
214
+ if (reportPath) {
215
+ let sha = 'HEAD';
216
+ try {
217
+ sha = fs.readFileSync(path.join(REPO_ROOT, '.git', 'HEAD'), 'utf-8').trim().slice(0, 9);
218
+ } catch {
219
+ // detached info unavailable — keep the placeholder
220
+ }
221
+ const target = path.isAbsolute(reportPath) ? reportPath : path.join(REPO_ROOT, reportPath);
222
+ fs.mkdirSync(path.dirname(target), { recursive: true });
223
+ fs.writeFileSync(target, render_report(rules, collisions, sha));
224
+ if (!quiet) process.stdout.write(`report → ${reportPath}\n`);
225
+ }
226
+
227
+ if (as_json) {
228
+ process.stdout.write(JSON.stringify({ collisions, findings: findings.length }, null, 2) + '\n');
229
+ }
230
+
231
+ if (findings.length > 0) {
232
+ process.stderr.write(
233
+ `❌ ${findings.length} trigger collision(s) without a written disposition:\n`,
234
+ );
235
+ for (const c of findings) {
236
+ process.stderr.write(
237
+ ` ${c.kind} "${c.value}" shared by [${c.sharers.join(', ')}] — missing collision_ok/precedence on: ${c.undispositioned.join(', ')}\n`,
238
+ );
239
+ }
240
+ process.stderr.write(
241
+ ' Remedy: add `collision_ok: {"<value>": "<one-line reason>"}` (or pairwise-distinct\n' +
242
+ ' `precedence: {"<value>": <int>}`) to EVERY sharer’s frontmatter, or disjoin the trigger sets.\n',
243
+ );
244
+ return 2;
245
+ }
246
+ if (!quiet) {
247
+ process.stdout.write(
248
+ `✅ ${collisions.length} trigger collision(s), all dispositioned (${rules.length} rules scanned)\n`,
249
+ );
250
+ }
251
+ return 0;
252
+ }
253
+
254
+ const _HERE = fileURLToPath(import.meta.url);
255
+ if (process.argv[1] && (import.meta.url === pathToFileURL(process.argv[1]).href || path.resolve(process.argv[1]) === _HERE)) {
256
+ process.exitCode = main(process.argv.slice(2));
257
+ }
@@ -118,6 +118,12 @@ import {
118
118
  perform_split,
119
119
  plan_split,
120
120
  } from './_lib/changelog_eras.js';
121
+ import {
122
+ HEAD_LABELS,
123
+ collect_span_commits,
124
+ derive_category_hits,
125
+ render_derived_head_values,
126
+ } from './_lib/release_highlights.js';
121
127
  import {
122
128
  extract_changelog_section,
123
129
  pr_body_from_section,
@@ -383,13 +389,7 @@ const SECTIONS: ReadonlyArray<readonly [string, string | null, readonly string[]
383
389
  * so the package does not end up with two competing definitions of a curated
384
390
  * head.
385
391
  */
386
- const RELEASE_HEAD_SECTIONS: ReadonlyArray<string> = [
387
- 'Behaviour changes',
388
- 'Default changes + migration',
389
- 'Security and correctness',
390
- 'Honest nulls',
391
- 'Known limitations',
392
- ];
392
+ const RELEASE_HEAD_SECTIONS: ReadonlyArray<string> = HEAD_LABELS;
393
393
 
394
394
  /**
395
395
  * Cap on the head, in rendered lines.
@@ -402,16 +402,44 @@ const RELEASE_HEAD_SECTIONS: ReadonlyArray<string> = [
402
402
  export const RELEASE_HEAD_CAP_LINES = 10;
403
403
 
404
404
  /**
405
- * The default value of every head line.
405
+ * The fallback value of a head line the span does not substantiate.
406
+ *
407
+ * `_none_` and not a placeholder token: where nothing was derived it is the
408
+ * **true** answer, and a release that genuinely changed no defaults should say
409
+ * so rather than carry an unfilled marker.
406
410
  *
407
- * `_none_` and not a placeholder token: for most releases it is the **true**
408
- * answer, and a release that genuinely changed no defaults should say so
409
- * rather than carry an unfilled marker. That also keeps the skeleton free of
410
- * the placeholder prose `output-discipline` bans — there is nothing here that
411
- * is wrong-if-shipped, only something that is uninformative-if-unedited.
411
+ * It is a fallback and no longer a blanket default. Writing `_none_` into
412
+ * every field made the generator assert five things it had not checked, and
413
+ * `check_release_highlights` rejected exactly that assertion the moment the
414
+ * span contradicted it — which for this package is every release, because every
415
+ * release touches `src/rules/` or `src/scripts/schemas/`. Substantiated labels
416
+ * are now pre-filled from the span (`_derive_head_prefill`), so the tool states
417
+ * only what it can support and `_none_` means what it says.
412
418
  */
413
419
  const HEAD_DEFAULT = '_none_';
414
420
 
421
+ /**
422
+ * Pre-fill values for the curated head from the release span.
423
+ *
424
+ * Best-effort by design, same stance as the test-trend footer: a git failure
425
+ * degrades to the `_none_` skeleton rather than blocking a release. The gate
426
+ * that reads the result runs later, on the branch head, and derives from the
427
+ * same shared classifier — so a degraded run surfaces as the gate's ordinary
428
+ * "fill the head" message instead of a crash mid-release.
429
+ */
430
+ function _derive_head_prefill(prev: string | null): Record<string, string> {
431
+ try {
432
+ const span = collect_span_commits(prev, 'HEAD', REPO_ROOT);
433
+ return render_derived_head_values(derive_category_hits(span));
434
+ } catch (err) {
435
+ process.stderr.write(
436
+ `warning: could not derive release-head highlights (${(err as Error).message}); ` +
437
+ 'the head ships as the `_none_` skeleton\n',
438
+ );
439
+ return {};
440
+ }
441
+ }
442
+
415
443
  /**
416
444
  * Render the curated head. Emitted by the generator on every release so it
417
445
  * cannot be forgotten; edited by the maintainer before merge.
@@ -2344,8 +2372,14 @@ function main(argv: readonly string[] | null = null): number {
2344
2372
  // (~15s+), which `--dry-run exits 0 without needing …` contracts (and
2345
2373
  // the release test) assume never runs. Real releases compute it.
2346
2374
  const test_trend_line = args.dry_run ? null : _render_test_trend_line(prev);
2375
+ // Derived unconditionally, dry-run included: it is one `git log` (~25 ms),
2376
+ // not the trend line's full vitest collection. Skipping it on `--dry-run`
2377
+ // made the preview print the `_none_` skeleton while the real run wrote
2378
+ // pre-filled lines — a preview that contradicts its own output, which is
2379
+ // the exact class of surface disagreement this pre-fill exists to end.
2347
2380
  const [full, body] = render_changelog_entry(target, prev, commits, today, {
2348
2381
  test_trend_line,
2382
+ head: _derive_head_prefill(prev),
2349
2383
  });
2350
2384
 
2351
2385
  // Era-split planning — gate on the POST-release view (2026-07-07 fix).
@@ -17,15 +17,10 @@
17
17
  * - tier_1 + tier_2 rules: active iff any trigger matches the prompt
18
18
  * text (plus optional open-files / command context).
19
19
  *
20
- * Trigger semantics implemented:
21
- *
22
- * | Type | Match rule |
23
- * |----------------|------------------------------------------------------------------|
24
- * | `keyword` | case-insensitive substring inside the prompt text |
25
- * | `phrase` | case-insensitive substring (multi-word) inside the prompt text |
26
- * | `command` | case-sensitive prefix on `command:` field (optional context) |
27
- * | `path_prefix` | prefix match against any path in `open_files` (optional context) |
28
- * | `file_pattern` | fnmatch against any path in `open_files` (optional context) |
20
+ * Trigger semantics live in `_lib/router_match.ts` — the single matcher
21
+ * implementation (anchored `keyword`, unanchored `phrase`, `command`
22
+ * prefix, `path_prefix` / `file_pattern` over `open_files`); this module
23
+ * re-exports its names for existing importers.
29
24
  *
30
25
  * Reports go to `internal/bench/reports/router-telemetry/<UTC>.json`.
31
26
  *
@@ -36,6 +31,18 @@ import path from "node:path";
36
31
  import { fileURLToPath } from "node:url";
37
32
  import { parse as parseYaml, YAMLError } from "yaml";
38
33
 
34
+ import {
35
+ _asRuleList,
36
+ keyword_matches_anchored,
37
+ match_prompt,
38
+ trigger_matches,
39
+ } from "./_lib/router_match.js";
40
+ import type { JsonValue, Router } from "./_lib/router_match.js";
41
+
42
+ // The matcher lives in `_lib/router_match.ts` (the single implementation —
43
+ // see its header). Re-exported here so existing importers keep working.
44
+ export { keyword_matches_anchored, match_prompt, trigger_matches };
45
+
39
46
  // src/scripts/router_telemetry.ts → two levels up is the repo root
40
47
  // (mirrors the Python module's parent.parent.parent resolution from
41
48
  // src/scripts/router_telemetry.py).
@@ -58,29 +65,6 @@ const DEFAULT_SAMPLE_CAP = 200;
58
65
  // tasks are the attribution map and become the untouchable set.
59
66
  const TRACK_B_CORPUS_REL = "internal/bench/corpora/ab-trackb.yaml";
60
67
 
61
- type JsonValue =
62
- | string
63
- | number
64
- | boolean
65
- | null
66
- | JsonValue[]
67
- | { [key: string]: JsonValue };
68
-
69
- type Trigger = Record<string, JsonValue>;
70
-
71
- interface Rule {
72
- id?: JsonValue;
73
- triggers?: JsonValue;
74
- [key: string]: JsonValue;
75
- }
76
-
77
- interface Router {
78
- kernel?: JsonValue;
79
- tier_1?: JsonValue;
80
- tier_2?: JsonValue;
81
- [key: string]: JsonValue;
82
- }
83
-
84
68
  function _log(msg: string, quiet: boolean, err = false): void {
85
69
  if (err) {
86
70
  process.stderr.write(`${msg}\n`);
@@ -101,264 +85,6 @@ function _utc_iso(): string {
101
85
  return iso.slice(0, 19) + "+00:00";
102
86
  }
103
87
 
104
- // ── fnmatch (Python fnmatch.fnmatch) ─────────────────────────────────────
105
-
106
- /**
107
- * Translate a Unix shell pattern into a RegExp, matching CPython's
108
- * `fnmatch.translate`. `*` → `.*`, `?` → `.`, `[...]` char classes
109
- * preserved (with `!` → `^` negation), everything else escaped.
110
- * Matching is case-sensitive on POSIX (the corpus paths are POSIX).
111
- */
112
- function _fnmatchToRegExp(pat: string): RegExp {
113
- let res = "";
114
- let i = 0;
115
- const n = pat.length;
116
- while (i < n) {
117
- const c = pat[i] as string;
118
- i += 1;
119
- if (c === "*") {
120
- res += ".*";
121
- } else if (c === "?") {
122
- res += ".";
123
- } else if (c === "[") {
124
- let j = i;
125
- if (j < n && pat[j] === "!") {
126
- j += 1;
127
- }
128
- if (j < n && pat[j] === "]") {
129
- j += 1;
130
- }
131
- while (j < n && pat[j] !== "]") {
132
- j += 1;
133
- }
134
- if (j >= n) {
135
- res += "\\[";
136
- } else {
137
- let stuff = pat.slice(i, j);
138
- if (!stuff.includes("-")) {
139
- stuff = stuff.replace(/\\/g, "\\\\");
140
- } else {
141
- // Faithful port of CPython's dash-handling in fnmatch.translate:
142
- // split the class body on `-` (bounded to [i, j)), escaping each
143
- // chunk, then re-join with `-` so ranges survive.
144
- const chunks: string[] = [];
145
- let k = pat[i] === "!" ? i + 2 : i + 1;
146
- let start = i;
147
- while (true) {
148
- const rel = pat.slice(k, j).indexOf("-");
149
- if (rel < 0) {
150
- break;
151
- }
152
- const idx = k + rel;
153
- chunks.push(pat.slice(start, idx));
154
- start = idx + 1;
155
- k = idx + 3;
156
- }
157
- chunks.push(pat.slice(start, j));
158
- stuff = chunks
159
- .map((s) => s.replace(/\\/g, "\\\\").replace(/-/g, "\\-"))
160
- .join("-");
161
- }
162
- // Escape regex set-meta chars except backslash/dash already handled.
163
- stuff = stuff.replace(/([&~|])/g, "\\$1");
164
- i = j + 1;
165
- if (stuff.startsWith("!")) {
166
- stuff = "^" + stuff.slice(1);
167
- } else if (stuff.startsWith("^") || stuff.startsWith("[")) {
168
- stuff = "\\" + stuff;
169
- }
170
- res += `[${stuff}]`;
171
- }
172
- } else {
173
- res += c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
174
- }
175
- }
176
- return new RegExp(`^(?:${res})$`, "s");
177
- }
178
-
179
- function _fnmatch(name: string, pat: string): boolean {
180
- return _fnmatchToRegExp(pat).test(name);
181
- }
182
-
183
- // ── Trigger matching ─────────────────────────────────────────────────────
184
-
185
- /** Apply one trigger to a prompt + context; return True on match. */
186
- /** Unicode word character — letters, digits, underscore (incl. umlauts). */
187
- const _WORD_CHAR = /[\p{L}\p{N}_]/u;
188
-
189
- function _isWordChar(c: string | undefined): boolean {
190
- return c !== undefined && _WORD_CHAR.test(c);
191
- }
192
-
193
- /**
194
- * Word-boundary-anchored keyword match (road-to-tested-routing Phase 3).
195
- *
196
- * A `keyword` occurrence counts only when its word-character edges sit on
197
- * word boundaries: `ac` no longer fires inside "black", `plan` no longer
198
- * fires inside "planet". Two deliberate reliefs:
199
- *
200
- * - An edge that is itself a NON-word character (e.g. `__()`, `/image:`,
201
- * `trans(`, an emoji) carries no boundary requirement on that side —
202
- * punctuation-shaped keywords keep their exact substring semantics.
203
- * - The right boundary accepts one optional plural `s` ("icons", "options",
204
- * "secrets" still fire their singular keyword). Richer inflection (German
205
- * verb endings: "implementiere", "committen") is a DOCUMENTED accepted
206
- * recall cost of anchoring — the matrices carry standalone-token phrasings
207
- * for those languages instead.
208
- *
209
- * `phrase` deliberately stays unanchored substring; since this change the
210
- * two trigger kinds genuinely differ.
211
- */
212
- export function keyword_matches_anchored(prompt_lower: string, kw_lower: string): boolean {
213
- if (kw_lower === "") {
214
- return false;
215
- }
216
- const needLeft = _isWordChar(kw_lower[0]);
217
- const needRight = _isWordChar(kw_lower[kw_lower.length - 1]);
218
- let idx = prompt_lower.indexOf(kw_lower);
219
- while (idx !== -1) {
220
- const leftOk = !needLeft || !_isWordChar(prompt_lower[idx - 1]);
221
- let rightOk = true;
222
- if (needRight) {
223
- let end = idx + kw_lower.length;
224
- if (prompt_lower[end] === "s") {
225
- end += 1; // optional plural `s`
226
- }
227
- rightOk = !_isWordChar(prompt_lower[end]);
228
- }
229
- if (leftOk && rightOk) {
230
- return true;
231
- }
232
- idx = prompt_lower.indexOf(kw_lower, idx + 1);
233
- }
234
- return false;
235
- }
236
-
237
- export function trigger_matches(
238
- trigger: Trigger,
239
- prompt: string,
240
- open_files?: Iterable<string> | null,
241
- command?: string | null,
242
- ): boolean {
243
- const prompt_lower = prompt.toLowerCase();
244
- if ("keyword" in trigger) {
245
- return keyword_matches_anchored(prompt_lower, String(trigger["keyword"]).toLowerCase());
246
- }
247
- if ("phrase" in trigger) {
248
- return prompt_lower.includes(String(trigger["phrase"]).toLowerCase());
249
- }
250
- if ("command" in trigger) {
251
- if (!command) {
252
- return false;
253
- }
254
- return command.startsWith(String(trigger["command"]));
255
- }
256
- if ("path_prefix" in trigger) {
257
- if (!open_files) {
258
- return false;
259
- }
260
- const pref = String(trigger["path_prefix"]);
261
- for (const p of open_files) {
262
- if (String(p).startsWith(pref)) {
263
- return true;
264
- }
265
- }
266
- return false;
267
- }
268
- if ("file_pattern" in trigger) {
269
- if (!open_files) {
270
- return false;
271
- }
272
- const pat = String(trigger["file_pattern"]);
273
- for (const p of open_files) {
274
- if (_fnmatch(String(p), pat)) {
275
- return true;
276
- }
277
- }
278
- return false;
279
- }
280
- return false;
281
- }
282
-
283
- interface MatchedTrigger {
284
- tier: string;
285
- rule: JsonValue | undefined;
286
- trigger: Trigger;
287
- }
288
-
289
- interface ActivatedRule {
290
- tier: string;
291
- rule: JsonValue | undefined;
292
- }
293
-
294
- interface MatchResult {
295
- matched_triggers: MatchedTrigger[];
296
- activated_rules: ActivatedRule[];
297
- }
298
-
299
- function _asRuleList(value: JsonValue | undefined): Rule[] {
300
- if (!Array.isArray(value)) {
301
- return [];
302
- }
303
- return value as unknown as Rule[];
304
- }
305
-
306
- function _asTriggerList(value: JsonValue | undefined): Trigger[] {
307
- if (!Array.isArray(value)) {
308
- return [];
309
- }
310
- return value as unknown as Trigger[];
311
- }
312
-
313
- /**
314
- * Return the matched-triggers + activated-rules for one prompt.
315
- *
316
- * Kernel rules are always active. tier_1 always considered. tier_2
317
- * only considered when `profile == 'full'`.
318
- */
319
- export function match_prompt(
320
- router: Router,
321
- prompt: string,
322
- profile = "full",
323
- open_files?: Iterable<string> | null,
324
- command?: string | null,
325
- ): MatchResult {
326
- const tiers: Array<[string, Rule[]]> = [
327
- ["tier_1", _asRuleList(router["tier_1"])],
328
- ];
329
- if (profile === "full") {
330
- tiers.push(["tier_2", _asRuleList(router["tier_2"])]);
331
- }
332
-
333
- const matched_triggers: MatchedTrigger[] = [];
334
- const activated_rules: ActivatedRule[] = [];
335
-
336
- for (const [tier_name, rules] of tiers) {
337
- for (const rule of rules) {
338
- const rule_id = rule["id"];
339
- const rule_triggers = _asTriggerList(rule["triggers"]);
340
- let rule_hit = false;
341
- for (const trig of rule_triggers) {
342
- if (trigger_matches(trig, prompt, open_files, command)) {
343
- matched_triggers.push({ tier: tier_name, rule: rule_id, trigger: trig });
344
- rule_hit = true;
345
- }
346
- }
347
- if (rule_hit) {
348
- activated_rules.push({ tier: tier_name, rule: rule_id });
349
- }
350
- }
351
- }
352
-
353
- // Kernel rules are always active.
354
- const kernel = Array.isArray(router["kernel"]) ? router["kernel"] : [];
355
- for (const kid of kernel) {
356
- activated_rules.push({ tier: "kernel", rule: kid });
357
- }
358
-
359
- return { matched_triggers, activated_rules };
360
- }
361
-
362
88
  // ── JSON serialization (Python-faithful) ─────────────────────────────────
363
89
 
364
90
  /**