@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +122 -171
- package/dist/agent-src/commands/challenge-me/vision.md +5 -0
- package/dist/agent-src/commands/evals/rule-compliance-audit.json +46 -0
- package/dist/agent-src/commands/feature/plan.md +20 -0
- package/dist/agent-src/commands/feature/roadmap.md +19 -0
- package/dist/agent-src/commands/pr/create.md +29 -0
- package/dist/agent-src/commands/roadmap/create.md +29 -0
- package/dist/agent-src/commands/rule-compliance-audit.md +28 -5
- package/dist/agent-src/contexts/execution/plan-confidence-gate.md +133 -0
- package/dist/agent-src/rules/active-remediation.md +2 -0
- package/dist/agent-src/rules/architecture.md +2 -0
- package/dist/agent-src/rules/augment-edit-discipline.md +2 -0
- package/dist/agent-src/rules/brand-consistency.md +10 -62
- package/dist/agent-src/rules/brand-source-of-truth.md +62 -11
- package/dist/agent-src/rules/broken-access-control.md +4 -0
- package/dist/agent-src/rules/cli-output-handling.md +5 -0
- package/dist/agent-src/rules/code-comment-discipline.md +3 -0
- package/dist/agent-src/rules/commit-conventions.md +2 -0
- package/dist/agent-src/rules/cross-source-consistency.md +3 -0
- package/dist/agent-src/rules/design-fidelity.md +2 -0
- package/dist/agent-src/rules/doc-screenshot-hygiene.md +2 -0
- package/dist/agent-src/rules/docker-commands.md +4 -0
- package/dist/agent-src/rules/domain-adoption-policy.md +2 -0
- package/dist/agent-src/rules/domain-safety-disclaimer.md +5 -2
- package/dist/agent-src/rules/engineering-safety-floor.md +2 -0
- package/dist/agent-src/rules/framework-neutrality-in-generic-skills.md +9 -0
- package/dist/agent-src/rules/image-likeness-and-rights.md +4 -0
- package/dist/agent-src/rules/improve-before-implement.md +4 -0
- package/dist/agent-src/rules/laravel-routing.md +4 -0
- package/dist/agent-src/rules/legal-safety-floor.md +3 -0
- package/dist/agent-src/rules/lethal-trifecta-guard.md +3 -0
- package/dist/agent-src/rules/media-governance-routing.md +5 -0
- package/dist/agent-src/rules/minimal-safe-diff.md +2 -0
- package/dist/agent-src/rules/no-roadmap-references.md +2 -0
- package/dist/agent-src/rules/notes-first-reasoning.md +2 -0
- package/dist/agent-src/rules/php-coding.md +2 -0
- package/dist/agent-src/rules/prefer-enums-over-literals.md +3 -0
- package/dist/agent-src/rules/provider-lifecycle-discipline.md +5 -0
- package/dist/agent-src/rules/roadmap-ci-steps-policy.md +2 -0
- package/dist/agent-src/rules/roadmap-progress-sync.md +2 -0
- package/dist/agent-src/rules/rule-type-governance.md +2 -0
- package/dist/agent-src/rules/scale-discipline.md +3 -0
- package/dist/agent-src/rules/secret-vcs-guard.md +2 -0
- package/dist/agent-src/rules/security-sensitive-stop.md +9 -2
- package/dist/agent-src/rules/senior-engineering-discipline.md +6 -0
- package/dist/agent-src/rules/skill-quality.md +2 -0
- package/dist/agent-src/rules/source-confidentiality.md +4 -0
- package/dist/agent-src/rules/source-discovery-gate.md +3 -0
- package/dist/agent-src/rules/source-of-truth.md +2 -0
- package/dist/agent-src/rules/strategy-safety-floor.md +2 -0
- package/dist/agent-src/rules/think-before-action.md +5 -0
- package/dist/agent-src/rules/ui-audit-gate.md +2 -0
- package/dist/agent-src/skills/design-intelligence/SKILL.md +6 -180
- package/dist/agent-src/skills/design-intelligence/references/context-and-registers.md +100 -0
- package/dist/agent-src/skills/design-intelligence/references/integration-mapping.md +91 -0
- package/dist/agent-src/skills/design-review/SKILL.md +4 -141
- package/dist/agent-src/skills/design-review/references/review-communication.md +92 -0
- package/dist/agent-src/skills/design-review/references/verification-automation.md +61 -0
- package/dist/agent-src/skills/existing-ui-audit/SKILL.md +8 -27
- package/dist/agent-src/skills/existing-ui-audit/references/anti-slop-cross-reference.md +31 -0
- package/dist/agent-src/skills/fe-design/SKILL.md +4 -289
- package/dist/agent-src/skills/fe-design/references/design-patterns.md +227 -0
- package/dist/agent-src/skills/fe-design/references/design-read-and-memory.md +75 -0
- package/dist/agent-src/skills/roadmap-management/SKILL.md +10 -0
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +12 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +12 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/_fix_lane.ts +52 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +8 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +6 -0
- package/dist/agent-src/templates/scripts/work_engine/intent/classify.ts +56 -4
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-AIIUU73N.js +2040 -0
- package/dist/cli-delegate/{chunk-IMSXPRVH.js → chunk-ELMFOQPW.js} +11 -0
- package/dist/cli-delegate/chunk-LF2KKJ4Q.js +183 -0
- package/dist/cli-delegate/cmd_explain.js +36 -36
- package/dist/cli-delegate/cmd_export.js +1 -1
- package/dist/cli-delegate/cmd_handoff.js +203 -2228
- package/dist/cli-delegate/cmd_prune.js +1 -1
- package/dist/cli-delegate/cmd_route_audit.js +249 -0
- package/dist/cli-delegate/cmd_route_explain.js +285 -0
- package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +1 -1
- package/dist/cli-delegate/cmd_uninstall.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +57 -57
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/install/install.mjs +11 -0
- package/dist/mcp/registry-manifest.json +1 -1
- package/dist/mcp/server.json +2 -2
- package/dist/router.json +1 -1
- package/dist/server/schemas/settings.js +12 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/package.json +1 -1
- package/src/agent-src/templates/scripts/work_engine/directives/ui/_fix_lane.ts +52 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +8 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +6 -0
- package/src/agent-src/templates/scripts/work_engine/intent/classify.ts +56 -4
- package/src/config/agent-settings.template.yml +46 -0
- package/src/config/evaluator-budgets.json +3 -3
- package/src/config/gate-coverage.yml +44 -0
- package/src/scripts/_cli/cmd_explain.ts +41 -36
- package/src/scripts/_cli/cmd_route_audit.ts +290 -0
- package/src/scripts/_cli/cmd_route_explain.ts +269 -0
- package/src/scripts/_dispatch.bash +23 -0
- package/src/scripts/_lib/git_env.ts +40 -0
- package/src/scripts/_lib/md_table.ts +27 -0
- package/src/scripts/_lib/planning_settings.ts +43 -0
- package/src/scripts/_lib/release_highlights.ts +279 -0
- package/src/scripts/_lib/router_match.ts +301 -0
- package/src/scripts/annotate_r1_outcomes.ts +258 -0
- package/src/scripts/check_completion_review.ts +1322 -0
- package/src/scripts/check_release_highlights.ts +60 -152
- package/src/scripts/compile_router.ts +20 -3
- package/src/scripts/dispatch_r2_reviewer.ts +969 -0
- package/src/scripts/eval_ui_triviality.ts +156 -0
- package/src/scripts/lint_abstraction_thresholds.ts +3 -1
- package/src/scripts/lint_plan_risk_register.ts +931 -0
- package/src/scripts/lint_trigger_collisions.ts +257 -0
- package/src/scripts/release.ts +47 -13
- package/src/scripts/router_telemetry.ts +16 -290
- package/src/scripts/schemas/rule.schema.json +10 -0
- 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
|
+
}
|
package/src/scripts/release.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
/**
|