@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,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
|
+
}
|