@event4u/agent-config 9.17.0 → 9.18.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 (128) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +79 -182
  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 +40 -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/router_match.ts +301 -0
  118. package/src/scripts/annotate_r1_outcomes.ts +258 -0
  119. package/src/scripts/check_completion_review.ts +1140 -0
  120. package/src/scripts/compile_router.ts +20 -3
  121. package/src/scripts/dispatch_r2_reviewer.ts +968 -0
  122. package/src/scripts/eval_ui_triviality.ts +156 -0
  123. package/src/scripts/lint_abstraction_thresholds.ts +3 -1
  124. package/src/scripts/lint_plan_risk_register.ts +931 -0
  125. package/src/scripts/lint_trigger_collisions.ts +257 -0
  126. package/src/scripts/router_telemetry.ts +16 -290
  127. package/src/scripts/schemas/rule.schema.json +10 -0
  128. 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
+ }
@@ -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
  /**
@@ -155,6 +155,16 @@
155
155
  },
156
156
  "description": "ADR-013 trust block. Vocabulary mirrored by scripts/lint_artefact_frontmatter.py."
157
157
  },
158
+ "collision_ok": {
159
+ "type": "object",
160
+ "additionalProperties": {"type": "string", "minLength": 8},
161
+ "description": "Per-trigger collision disposition: maps a trigger VALUE (case-insensitive) shared with >=1 other rule to a one-line reason why this rule also firing on it is correct. Enforced by scripts/lint_trigger_collisions.ts."
162
+ },
163
+ "precedence": {
164
+ "type": "object",
165
+ "additionalProperties": {"type": "integer"},
166
+ "description": "Alternative collision disposition: maps a shared trigger VALUE to an integer ordering; a collision is clean when ALL sharers declare pairwise-distinct integers for it. Enforced by scripts/lint_trigger_collisions.ts."
167
+ },
158
168
  "install": {
159
169
  "type": "object",
160
170
  "additionalProperties": false,
@@ -205,6 +205,24 @@ export const settingsSchema = z.object({
205
205
  'Optional planning horizon (weeks) the agent shows in roadmap framing ("next 4 weeks"). Set 0 to omit the horizon — most teams prefer to ship without a hardcoded window.',
206
206
  ),
207
207
  }),
208
+ planning: z.object({
209
+ challenge_on_create: z.boolean().default(true).describe(
210
+ 'Gate C — plan-confidence gate before authoring. true (default) = a plan-artifact ask (/roadmap:create, roadmap-writing, /feature:plan, /feature:roadmap) first checks the four 95%-confidence conditions from /challenge-me vision; any gap routes into the interview (or the inline degrade protocol) before authoring, and a confident pass emits exactly one marker line. false = inert, plan asks author directly. An explicit user bypass always wins for that turn and is counted.',
211
+ ),
212
+ risk_review: z.boolean().default(true).describe(
213
+ 'Gate R1 — plan-risk review. true (default) = every ready (non-draft) plan must carry a schema-valid "## Risk Register" section (ranked risks, mitigation + anchor per row, freshness marker, exact honest-null grammar), enforced by lint_plan_risk_register at pre-push + CI. false = escape hatch, the validator skips.',
214
+ ),
215
+ completion_review: z.boolean().default(true).describe(
216
+ 'Gate R2 — completion review at 100% roadmap completion / pre-PR. true (default) = a findings-before-fixes review by a fresh reviewer context must exist for the current diff hash (or an exact honest-null / skip declaration) before fix commits and PR creation, enforced by check_completion_review at pre-push + CI (CI authoritative; a crashed validator warns and allows). false = escape hatch, the validator skips.',
217
+ ),
218
+ })
219
+ // `.default({})` is load-bearing, not cosmetic: without it the SECTION is
220
+ // required, so any settings payload that omits `planning` fails with
221
+ // `ZodError: planning Required` — which contradicts this gate family's own
222
+ // contract ("missing key = true" for all three) and reds every server
223
+ // test whose fixture predates the section. With the default, an absent
224
+ // section materialises as the three `true` leaves the contract promises.
225
+ .default({}),
208
226
  quality: z.object({
209
227
  local_auto_run: z.boolean().default(false).describe(
210
228
  'Run quality tools (linters, type-checks, formatters) and the local test suite autonomously after edits. Off by default — the agent never runs quality tools proactively and does not ask; the user runs them manually (e.g. /quality-fix) and remote CI is the authoritative gate. The agent only runs a quality tool on an explicit ask, a concrete CI failure, or the new-gate carve-out. Turn on to restore autonomous pipeline runs.',