@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,279 @@
1
+ /**
2
+ * Shared release-highlight classifier — one definition of the curated head's
3
+ * labels and of the span → category derivation.
4
+ *
5
+ * Used by BOTH sides on purpose:
6
+ *
7
+ * - the generator (`release.ts`) pre-fills the curated head with what it can
8
+ * substantiate from the span;
9
+ * - the gate (`check_release_highlights.ts`) blocks a `_none_` the span
10
+ * contradicts.
11
+ *
12
+ * Why shared instead of duplicated. Before this module the generator emitted
13
+ * `_none_` for all five fields while the gate rejected exactly that default
14
+ * the moment the span carried evidence. Every release of this package touches
15
+ * `src/rules/` or `src/scripts/schemas/`, so "Behaviour changes" is always
16
+ * substantiated and every release PR was red on its FIRST run by construction
17
+ * — 9.17.0 (run 30871194277) and 9.18.0 (run 30909511315) both hit it, on the
18
+ * same step, for the same reason.
19
+ *
20
+ * This is not a weaker gate. The generator no longer manufactures a claim it
21
+ * cannot support, and the gate keeps full teeth for the failure it was built
22
+ * for: a human editing a substantiated line back down to `_none_`, which is
23
+ * exactly what produced the false 9.13.0 and 9.14.0 heads. Machine-written
24
+ * `_none_` was never that failure; it was noise standing in front of it.
25
+ */
26
+ import { spawnSync } from 'node:child_process';
27
+
28
+ /** The five curated labels, in the order an operator reads them. */
29
+ export const HEAD_LABELS: readonly string[] = [
30
+ 'Behaviour changes',
31
+ 'Default changes + migration',
32
+ 'Security and correctness',
33
+ 'Honest nulls',
34
+ 'Known limitations',
35
+ ];
36
+
37
+ export const HEAD_NONE = '_none_';
38
+
39
+ /**
40
+ * Prefix marking a head line the generator derived rather than a human wrote.
41
+ *
42
+ * Machine-checkable so the gate can warn when one survives to merge, and
43
+ * readable so the line still says something true to anyone who ships it
44
+ * unedited. It is not a placeholder token: the line it introduces carries real
45
+ * evidence (the reason plus the citing SHAs), so nothing here is
46
+ * wrong-if-shipped — only unpolished-if-unedited.
47
+ */
48
+ export const DERIVED_MARKER = '_auto-derived, rewrite before merge:_';
49
+
50
+ /** Cap on cited SHAs per line, so a wide release cannot unbound the head. */
51
+ export const DERIVED_SHA_CAP = 6;
52
+
53
+ export interface SpanCommit {
54
+ sha: string;
55
+ subject: string;
56
+ body: string;
57
+ /** `A` / `M` / `D` / `R…` status per touched path. */
58
+ files: ReadonlyArray<{ status: string; path: string }>;
59
+ breaking: boolean;
60
+ }
61
+
62
+ /** One derived hit: the commit that substantiates a label. */
63
+ export interface CategoryHit {
64
+ sha: string;
65
+ /** Rendered detail — the subject, plus a removed-surface note when any. */
66
+ text: string;
67
+ }
68
+
69
+ /** Public artefact trees whose deletions count as removed public surface. */
70
+ const _PUBLIC_SURFACE_PREFIXES = [
71
+ 'src/skills/',
72
+ 'src/rules/',
73
+ 'src/agent-src/commands/',
74
+ 'src/domains/',
75
+ ];
76
+
77
+ const _RULE_OR_SCHEMA_PREFIXES = ['src/rules/', 'src/scripts/schemas/'];
78
+
79
+ /**
80
+ * Why a label fired, in generator-facing prose. Keyed by label; a label absent
81
+ * here is never derived (`Known limitations` is pure prose, not checkable).
82
+ */
83
+ const _DERIVED_REASON: Readonly<Record<string, string>> = {
84
+ 'Behaviour changes': 'rule/schema diffs, breaking commits or removed public surface',
85
+ 'Default changes + migration': 'commits naming a default, migration or migrate',
86
+ 'Security and correctness': 'security-scoped commits',
87
+ 'Honest nulls': 'commits carrying an honest-null marker',
88
+ };
89
+
90
+ /**
91
+ * Derive evidence per curated label from the span. Rules per label:
92
+ *
93
+ * - Security and correctness: conventional scope matching /secur/i, or the
94
+ * whole word "security" in the subject.
95
+ * - Behaviour changes: breaking (`!` / BREAKING CHANGE) commits, diffs
96
+ * touching `src/rules/` or `src/scripts/schemas/`, and deletions under the
97
+ * public artefact trees (removed public surface).
98
+ * - Default changes + migration: subject carrying the whole word "default",
99
+ * "migration", or "migrate".
100
+ * - Honest nulls: subject or body carrying an "honest null" marker.
101
+ * - Known limitations: never derived — pure prose, not gate-checkable.
102
+ *
103
+ * Derivation is deliberately conservative: a false red makes every release
104
+ * annoying, a miss only returns the head to the pre-gate state.
105
+ */
106
+ export function derive_category_hits(
107
+ commits: readonly SpanCommit[],
108
+ ): Record<string, CategoryHit[]> {
109
+ const out: Record<string, CategoryHit[]> = {};
110
+ for (const label of HEAD_LABELS) {
111
+ out[label] = [];
112
+ }
113
+ for (const c of commits) {
114
+ const scopeMatch = /^\w+\(([^)]*)\)!?:/u.exec(c.subject);
115
+ const scope = scopeMatch ? scopeMatch[1]! : '';
116
+ if (/secur/iu.test(scope) || /\bsecurity\b/iu.test(c.subject)) {
117
+ out['Security and correctness']!.push({ sha: c.sha, text: c.subject });
118
+ }
119
+ const removedPublic = c.files.filter(
120
+ (f) =>
121
+ f.status.startsWith('D') &&
122
+ _PUBLIC_SURFACE_PREFIXES.some((p) => f.path.startsWith(p)),
123
+ );
124
+ const ruleOrSchema = c.files.some((f) =>
125
+ _RULE_OR_SCHEMA_PREFIXES.some((p) => f.path.startsWith(p)),
126
+ );
127
+ if (c.breaking || ruleOrSchema || removedPublic.length > 0) {
128
+ const text =
129
+ removedPublic.length > 0
130
+ ? `${c.subject} (removes ${removedPublic.map((f) => f.path).join(', ')})`
131
+ : c.subject;
132
+ out['Behaviour changes']!.push({ sha: c.sha, text });
133
+ }
134
+ if (/\b(default|migration|migrate)\b/iu.test(c.subject)) {
135
+ out['Default changes + migration']!.push({ sha: c.sha, text: c.subject });
136
+ }
137
+ if (/honest[ -]null/iu.test(`${c.subject}\n${c.body}`)) {
138
+ out['Honest nulls']!.push({ sha: c.sha, text: c.subject });
139
+ }
140
+ }
141
+ return out;
142
+ }
143
+
144
+ /**
145
+ * String-rendered evidence per label (`<sha7> <detail>`), the shape the gate
146
+ * reports to a human. Derived from the structured hits so the two can never
147
+ * disagree.
148
+ */
149
+ export function derive_categories(commits: readonly SpanCommit[]): Record<string, string[]> {
150
+ const hits = derive_category_hits(commits);
151
+ const out: Record<string, string[]> = {};
152
+ for (const label of HEAD_LABELS) {
153
+ out[label] = (hits[label] ?? []).map((h) => `${h.sha.slice(0, 7)} ${h.text}`);
154
+ }
155
+ return out;
156
+ }
157
+
158
+ /**
159
+ * Pre-fill values for the curated head, one line per SUBSTANTIATED label.
160
+ *
161
+ * A label with no evidence is omitted, so the caller's `_none_` default still
162
+ * applies where `_none_` is the true answer. One line per label keeps the
163
+ * rendered head inside its cap; SHA citations are capped at `DERIVED_SHA_CAP`
164
+ * with an explicit remainder count rather than a silent truncation.
165
+ */
166
+ export function render_derived_head_values(
167
+ hits: Readonly<Record<string, readonly CategoryHit[]>>,
168
+ ): Record<string, string> {
169
+ const out: Record<string, string> = {};
170
+ for (const label of HEAD_LABELS) {
171
+ const reason = _DERIVED_REASON[label];
172
+ const ev = hits[label] ?? [];
173
+ if (!reason || ev.length === 0) {
174
+ continue;
175
+ }
176
+ const shas = ev.map((h) => h.sha.slice(0, 7));
177
+ const shown = shas.slice(0, DERIVED_SHA_CAP);
178
+ const remainder = shas.length - shown.length;
179
+ const more = remainder > 0 ? ` +${remainder} more` : '';
180
+ out[label] = `${DERIVED_MARKER} ${reason} in ${shown.join(', ')}${more}.`;
181
+ }
182
+ return out;
183
+ }
184
+
185
+ /** Labels whose curated value is still the generator's unedited draft. */
186
+ export function stale_draft_labels(
187
+ curated: Readonly<Record<string, string>>,
188
+ ): string[] {
189
+ return HEAD_LABELS.filter((l) => (curated[l] ?? '').includes(DERIVED_MARKER));
190
+ }
191
+
192
+ // ─── git span collection ────────────────────────────────────────────────────
193
+
194
+ const _RECORD = '\u001e';
195
+ const _FIELD = '\u001f';
196
+
197
+ export function parse_git_log(raw: string): SpanCommit[] {
198
+ const commits: SpanCommit[] = [];
199
+ for (const record of raw.split(_RECORD)) {
200
+ if (!record.trim()) {
201
+ continue;
202
+ }
203
+ const [head, ...restLines] = record.split('\n');
204
+ const parts = (head ?? '').split(_FIELD);
205
+ if (parts.length < 3) {
206
+ continue;
207
+ }
208
+ const sha = parts[0]!.trim();
209
+ const subject = parts[1]!;
210
+ const bodyParts: string[] = [parts.slice(2).join(_FIELD)];
211
+ const files: Array<{ status: string; path: string }> = [];
212
+ for (const line of restLines) {
213
+ const m = /^([A-Z]\d*)\t(.+)$/u.exec(line);
214
+ if (m) {
215
+ // Renames carry two paths; the last tab field is the new path,
216
+ // the first the old — record both so a rename out of a public
217
+ // tree still counts its origin path.
218
+ const segs = m[2]!.split('\t');
219
+ for (const p of segs) {
220
+ files.push({ status: m[1]!, path: p });
221
+ }
222
+ } else {
223
+ // %b is multi-line — everything that is not a name-status
224
+ // line is body continuation (honest-null markers live there).
225
+ bodyParts.push(line);
226
+ }
227
+ }
228
+ const body = bodyParts.join('\n');
229
+ commits.push({
230
+ sha,
231
+ subject,
232
+ body,
233
+ files,
234
+ breaking: /^\w+(\([^)]*\))?!:/u.test(subject) || /BREAKING CHANGE/u.test(body),
235
+ });
236
+ }
237
+ return commits;
238
+ }
239
+
240
+ /** `git log` arguments for a span, shared so both callers read one format. */
241
+ export function span_log_args(from: string | null, to: string): string[] {
242
+ return [
243
+ 'log',
244
+ from ? `${from}..${to}` : to,
245
+ '--no-merges',
246
+ '--name-status',
247
+ '--format=%x1e%H%x1f%s%x1f%b',
248
+ ];
249
+ }
250
+
251
+ /**
252
+ * Collect the span. Throws on git failure so a caller can decide whether the
253
+ * derivation is load-bearing (the gate) or best-effort (the generator).
254
+ */
255
+ export function collect_span_commits(from: string | null, to: string, cwd: string): SpanCommit[] {
256
+ const r = spawnSync('git', span_log_args(from, to), {
257
+ encoding: 'utf-8',
258
+ cwd,
259
+ maxBuffer: 64 * 1024 * 1024,
260
+ });
261
+ if (r.status !== 0) {
262
+ throw new Error(`git ${span_log_args(from, to).join(' ')} failed: ${r.stderr}`);
263
+ }
264
+ return parse_git_log(r.stdout);
265
+ }
266
+
267
+ /** Latest reachable release tag before `ref`, or null when there is none. */
268
+ export function previous_release_tag(ref: string, cwd: string): string | null {
269
+ const r = spawnSync(
270
+ 'git',
271
+ ['describe', '--tags', '--abbrev=0', '--match', '[0-9]*.[0-9]*.[0-9]*', `${ref}^`],
272
+ { encoding: 'utf-8', cwd },
273
+ );
274
+ if (r.status !== 0) {
275
+ return null;
276
+ }
277
+ const tag = r.stdout.trim();
278
+ return tag === '' ? null : tag;
279
+ }
@@ -0,0 +1,301 @@
1
+ /**
2
+ * THE single trigger-matcher implementation for `dist/router.json`.
3
+ *
4
+ * Parity by construction: every surface that answers "which rules fire on
5
+ * this prompt?" — router replay (`router_telemetry.ts`), trigger-coverage
6
+ * CI (`trigger_coverage.ts`), `agent-config explain route`, and
7
+ * `agent-config route:explain` — MUST import the matchers from this module.
8
+ * A second implementation of these semantics anywhere in the tree is a
9
+ * violation (enforced by `tests/scripts/router_match_parity.test.ts`); the
10
+ * historical divergence this kills was `cmd_explain.ts` shipping unanchored
11
+ * keyword matching while the replay path was anchored.
12
+ *
13
+ * Canonical semantics: `docs/contracts/rule-router.md` § `triggers:` shape
14
+ * (the trigger-semantics table — anchored `keyword`, unanchored `phrase`,
15
+ * `command` prefix, `path_prefix` / `file_pattern` over open files).
16
+ *
17
+ * Extracted verbatim from `router_telemetry.ts` (which re-exports the same
18
+ * names for existing importers). Behaviour is pinned by the routing-matrix
19
+ * and trigger-coverage suites — do not "fix" quirks here.
20
+ */
21
+
22
+ export type JsonValue =
23
+ | string
24
+ | number
25
+ | boolean
26
+ | null
27
+ | JsonValue[]
28
+ | { [key: string]: JsonValue };
29
+
30
+ export type Trigger = Record<string, JsonValue>;
31
+
32
+ export interface Rule {
33
+ id?: JsonValue;
34
+ triggers?: JsonValue;
35
+ [key: string]: JsonValue;
36
+ }
37
+
38
+ export interface Router {
39
+ kernel?: JsonValue;
40
+ tier_1?: JsonValue;
41
+ tier_2?: JsonValue;
42
+ [key: string]: JsonValue;
43
+ }
44
+
45
+ // ── fnmatch (Python fnmatch.fnmatch) ─────────────────────────────────────
46
+
47
+ /**
48
+ * Translate a Unix shell pattern into a RegExp, matching CPython's
49
+ * `fnmatch.translate`. `*` → `.*`, `?` → `.`, `[...]` char classes
50
+ * preserved (with `!` → `^` negation), everything else escaped.
51
+ * Matching is case-sensitive on POSIX (the corpus paths are POSIX).
52
+ */
53
+ function _fnmatchToRegExp(pat: string): RegExp {
54
+ let res = "";
55
+ let i = 0;
56
+ const n = pat.length;
57
+ while (i < n) {
58
+ const c = pat[i] as string;
59
+ i += 1;
60
+ if (c === "*") {
61
+ res += ".*";
62
+ } else if (c === "?") {
63
+ res += ".";
64
+ } else if (c === "[") {
65
+ let j = i;
66
+ if (j < n && pat[j] === "!") {
67
+ j += 1;
68
+ }
69
+ if (j < n && pat[j] === "]") {
70
+ j += 1;
71
+ }
72
+ while (j < n && pat[j] !== "]") {
73
+ j += 1;
74
+ }
75
+ if (j >= n) {
76
+ res += "\\[";
77
+ } else {
78
+ let stuff = pat.slice(i, j);
79
+ if (!stuff.includes("-")) {
80
+ stuff = stuff.replace(/\\/g, "\\\\");
81
+ } else {
82
+ // Faithful port of CPython's dash-handling in fnmatch.translate:
83
+ // split the class body on `-` (bounded to [i, j)), escaping each
84
+ // chunk, then re-join with `-` so ranges survive.
85
+ const chunks: string[] = [];
86
+ let k = pat[i] === "!" ? i + 2 : i + 1;
87
+ let start = i;
88
+ while (true) {
89
+ const rel = pat.slice(k, j).indexOf("-");
90
+ if (rel < 0) {
91
+ break;
92
+ }
93
+ const idx = k + rel;
94
+ chunks.push(pat.slice(start, idx));
95
+ start = idx + 1;
96
+ k = idx + 3;
97
+ }
98
+ chunks.push(pat.slice(start, j));
99
+ stuff = chunks
100
+ .map((s) => s.replace(/\\/g, "\\\\").replace(/-/g, "\\-"))
101
+ .join("-");
102
+ }
103
+ // Escape regex set-meta chars except backslash/dash already handled.
104
+ stuff = stuff.replace(/([&~|])/g, "\\$1");
105
+ i = j + 1;
106
+ if (stuff.startsWith("!")) {
107
+ stuff = "^" + stuff.slice(1);
108
+ } else if (stuff.startsWith("^") || stuff.startsWith("[")) {
109
+ stuff = "\\" + stuff;
110
+ }
111
+ res += `[${stuff}]`;
112
+ }
113
+ } else {
114
+ res += c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
115
+ }
116
+ }
117
+ return new RegExp(`^(?:${res})$`, "s");
118
+ }
119
+
120
+ export function _fnmatch(name: string, pat: string): boolean {
121
+ return _fnmatchToRegExp(pat).test(name);
122
+ }
123
+
124
+ // ── Trigger matching ─────────────────────────────────────────────────────
125
+
126
+ /** Apply one trigger to a prompt + context; return True on match. */
127
+ /** Unicode word character — letters, digits, underscore (incl. umlauts). */
128
+ const _WORD_CHAR = /[\p{L}\p{N}_]/u;
129
+
130
+ function _isWordChar(c: string | undefined): boolean {
131
+ return c !== undefined && _WORD_CHAR.test(c);
132
+ }
133
+
134
+ /**
135
+ * Word-boundary-anchored keyword match (road-to-tested-routing Phase 3).
136
+ *
137
+ * A `keyword` occurrence counts only when its word-character edges sit on
138
+ * word boundaries: `ac` no longer fires inside "black", `plan` no longer
139
+ * fires inside "planet". Two deliberate reliefs:
140
+ *
141
+ * - An edge that is itself a NON-word character (e.g. `__()`, `/image:`,
142
+ * `trans(`, an emoji) carries no boundary requirement on that side —
143
+ * punctuation-shaped keywords keep their exact substring semantics.
144
+ * - The right boundary accepts one optional plural `s` ("icons", "options",
145
+ * "secrets" still fire their singular keyword). Richer inflection (German
146
+ * verb endings: "implementiere", "committen") is a DOCUMENTED accepted
147
+ * recall cost of anchoring — the matrices carry standalone-token phrasings
148
+ * for those languages instead.
149
+ *
150
+ * `phrase` deliberately stays unanchored substring; since this change the
151
+ * two trigger kinds genuinely differ.
152
+ */
153
+ export function keyword_matches_anchored(prompt_lower: string, kw_lower: string): boolean {
154
+ if (kw_lower === "") {
155
+ return false;
156
+ }
157
+ const needLeft = _isWordChar(kw_lower[0]);
158
+ const needRight = _isWordChar(kw_lower[kw_lower.length - 1]);
159
+ let idx = prompt_lower.indexOf(kw_lower);
160
+ while (idx !== -1) {
161
+ const leftOk = !needLeft || !_isWordChar(prompt_lower[idx - 1]);
162
+ let rightOk = true;
163
+ if (needRight) {
164
+ let end = idx + kw_lower.length;
165
+ if (prompt_lower[end] === "s") {
166
+ end += 1; // optional plural `s`
167
+ }
168
+ rightOk = !_isWordChar(prompt_lower[end]);
169
+ }
170
+ if (leftOk && rightOk) {
171
+ return true;
172
+ }
173
+ idx = prompt_lower.indexOf(kw_lower, idx + 1);
174
+ }
175
+ return false;
176
+ }
177
+
178
+ export function trigger_matches(
179
+ trigger: Trigger,
180
+ prompt: string,
181
+ open_files?: Iterable<string> | null,
182
+ command?: string | null,
183
+ ): boolean {
184
+ const prompt_lower = prompt.toLowerCase();
185
+ if ("keyword" in trigger) {
186
+ return keyword_matches_anchored(prompt_lower, String(trigger["keyword"]).toLowerCase());
187
+ }
188
+ if ("phrase" in trigger) {
189
+ return prompt_lower.includes(String(trigger["phrase"]).toLowerCase());
190
+ }
191
+ if ("command" in trigger) {
192
+ if (!command) {
193
+ return false;
194
+ }
195
+ return command.startsWith(String(trigger["command"]));
196
+ }
197
+ if ("path_prefix" in trigger) {
198
+ if (!open_files) {
199
+ return false;
200
+ }
201
+ const pref = String(trigger["path_prefix"]);
202
+ for (const p of open_files) {
203
+ if (String(p).startsWith(pref)) {
204
+ return true;
205
+ }
206
+ }
207
+ return false;
208
+ }
209
+ if ("file_pattern" in trigger) {
210
+ if (!open_files) {
211
+ return false;
212
+ }
213
+ const pat = String(trigger["file_pattern"]);
214
+ for (const p of open_files) {
215
+ if (_fnmatch(String(p), pat)) {
216
+ return true;
217
+ }
218
+ }
219
+ return false;
220
+ }
221
+ return false;
222
+ }
223
+
224
+ export interface MatchedTrigger {
225
+ tier: string;
226
+ rule: JsonValue | undefined;
227
+ trigger: Trigger;
228
+ }
229
+
230
+ export interface ActivatedRule {
231
+ tier: string;
232
+ rule: JsonValue | undefined;
233
+ }
234
+
235
+ export interface MatchResult {
236
+ matched_triggers: MatchedTrigger[];
237
+ activated_rules: ActivatedRule[];
238
+ }
239
+
240
+ export function _asRuleList(value: JsonValue | undefined): Rule[] {
241
+ if (!Array.isArray(value)) {
242
+ return [];
243
+ }
244
+ return value as unknown as Rule[];
245
+ }
246
+
247
+ export function _asTriggerList(value: JsonValue | undefined): Trigger[] {
248
+ if (!Array.isArray(value)) {
249
+ return [];
250
+ }
251
+ return value as unknown as Trigger[];
252
+ }
253
+
254
+ /**
255
+ * Return the matched-triggers + activated-rules for one prompt.
256
+ *
257
+ * Kernel rules are always active. tier_1 always considered. tier_2
258
+ * only considered when `profile == 'full'`.
259
+ */
260
+ export function match_prompt(
261
+ router: Router,
262
+ prompt: string,
263
+ profile = "full",
264
+ open_files?: Iterable<string> | null,
265
+ command?: string | null,
266
+ ): MatchResult {
267
+ const tiers: Array<[string, Rule[]]> = [
268
+ ["tier_1", _asRuleList(router["tier_1"])],
269
+ ];
270
+ if (profile === "full") {
271
+ tiers.push(["tier_2", _asRuleList(router["tier_2"])]);
272
+ }
273
+
274
+ const matched_triggers: MatchedTrigger[] = [];
275
+ const activated_rules: ActivatedRule[] = [];
276
+
277
+ for (const [tier_name, rules] of tiers) {
278
+ for (const rule of rules) {
279
+ const rule_id = rule["id"];
280
+ const rule_triggers = _asTriggerList(rule["triggers"]);
281
+ let rule_hit = false;
282
+ for (const trig of rule_triggers) {
283
+ if (trigger_matches(trig, prompt, open_files, command)) {
284
+ matched_triggers.push({ tier: tier_name, rule: rule_id, trigger: trig });
285
+ rule_hit = true;
286
+ }
287
+ }
288
+ if (rule_hit) {
289
+ activated_rules.push({ tier: tier_name, rule: rule_id });
290
+ }
291
+ }
292
+ }
293
+
294
+ // Kernel rules are always active.
295
+ const kernel = Array.isArray(router["kernel"]) ? router["kernel"] : [];
296
+ for (const kid of kernel) {
297
+ activated_rules.push({ tier: "kernel", rule: kid });
298
+ }
299
+
300
+ return { matched_triggers, activated_rules };
301
+ }