@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +79 -182
- 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 +40 -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/router_match.ts +301 -0
- package/src/scripts/annotate_r1_outcomes.ts +258 -0
- package/src/scripts/check_completion_review.ts +1140 -0
- package/src/scripts/compile_router.ts +20 -3
- package/src/scripts/dispatch_r2_reviewer.ts +968 -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/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,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
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Gate R1 outcome annotation helper (quarterly cadence — see
|
|
4
|
+
* docs/contracts/plan-review-gates.md § 7).
|
|
5
|
+
*
|
|
6
|
+
* Walks the `## Risk Register` sections of ARCHIVED roadmaps
|
|
7
|
+
* (agents/roadmaps/archive/*.md), and for every register row that has no
|
|
8
|
+
* recorded outcome yet, prompts the operator:
|
|
9
|
+
*
|
|
10
|
+
* helped — the mitigation prevented the risk (or materially reduced it)
|
|
11
|
+
* fired — the risk fired despite (or without) the mitigation
|
|
12
|
+
* unknown — cannot be judged (yet)
|
|
13
|
+
*
|
|
14
|
+
* Each answer appends one `r1_mitigation_outcome` event to the tracked
|
|
15
|
+
* metrics JSONL (agents/evidence/metrics/gate-metrics.jsonl) — ids +
|
|
16
|
+
* counters only, PII-free by construction. Already-annotated rows
|
|
17
|
+
* (same file + rank in the JSONL) are skipped, so re-runs are idempotent.
|
|
18
|
+
*
|
|
19
|
+
* Not a gate: no `scanned:` line, no gate-coverage entry. Exit 0 on
|
|
20
|
+
* completion (including nothing-to-annotate), 2 on internal error.
|
|
21
|
+
*
|
|
22
|
+
* Usage:
|
|
23
|
+
* ./scripts-run src/scripts/annotate_r1_outcomes # interactive
|
|
24
|
+
* ./scripts-run src/scripts/annotate_r1_outcomes --list # show pending only
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import * as fs from 'node:fs';
|
|
28
|
+
import * as path from 'node:path';
|
|
29
|
+
import * as readline from 'node:readline';
|
|
30
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
31
|
+
|
|
32
|
+
import { splitMarkdownRow } from './_lib/md_table.js';
|
|
33
|
+
|
|
34
|
+
const ARCHIVE_DIR = 'agents/roadmaps/archive';
|
|
35
|
+
const METRICS_FILE = 'agents/evidence/metrics/gate-metrics.jsonl';
|
|
36
|
+
const OUTCOMES = new Set(['helped', 'fired', 'unknown']);
|
|
37
|
+
|
|
38
|
+
interface RegisterRow {
|
|
39
|
+
file: string;
|
|
40
|
+
rank: number;
|
|
41
|
+
item: string;
|
|
42
|
+
mitigation: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** § 1.2 register columns: Rank | Item | Risk type | Description | Mitigation | Anchored under. */
|
|
46
|
+
const REGISTER_CELLS = 6;
|
|
47
|
+
const ITEM_CELL = 1;
|
|
48
|
+
const MITIGATION_CELL = 4;
|
|
49
|
+
|
|
50
|
+
export function extractRegisterRows(file: string, content: string): RegisterRow[] {
|
|
51
|
+
const rows: RegisterRow[] = [];
|
|
52
|
+
const lines = content.split(/\r?\n/);
|
|
53
|
+
let inRegister = false;
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
if (/^##\s+Risk Register\s*$/.test(line)) {
|
|
56
|
+
inRegister = true;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (inRegister && /^##\s+/.test(line)) break;
|
|
60
|
+
if (!inRegister) continue;
|
|
61
|
+
if (!line.trim().startsWith('|')) continue;
|
|
62
|
+
// Escape-aware split, shared with both validators: a per-column
|
|
63
|
+
// `[^|]*` regex mis-aligns on a `\|` inside a cell — the shape the
|
|
64
|
+
// contract's own §1.2 example uses (`product \| implementation`) —
|
|
65
|
+
// and then records the Description as the mitigation.
|
|
66
|
+
const cells = splitMarkdownRow(line);
|
|
67
|
+
if (cells.length < REGISTER_CELLS) continue;
|
|
68
|
+
const rank = cells[0] ?? '';
|
|
69
|
+
if (!/^\d+$/.test(rank)) continue; // header / separator / prose row
|
|
70
|
+
rows.push({
|
|
71
|
+
file,
|
|
72
|
+
rank: Number(rank),
|
|
73
|
+
item: (cells[ITEM_CELL] ?? '').trim(),
|
|
74
|
+
mitigation: (cells[MITIGATION_CELL] ?? '').trim(),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return rows;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function annotatedKeys(metricsText: string): Set<string> {
|
|
81
|
+
const keys = new Set<string>();
|
|
82
|
+
for (const line of metricsText.split('\n')) {
|
|
83
|
+
if (line.trim() === '') continue;
|
|
84
|
+
try {
|
|
85
|
+
const ev = JSON.parse(line) as { event?: string; file?: string; rank?: number };
|
|
86
|
+
if (ev.event === 'r1_mitigation_outcome' && ev.file !== undefined && ev.rank !== undefined) {
|
|
87
|
+
keys.add(`${ev.file}#${String(ev.rank)}`);
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
// tolerate foreign lines — the JSONL is append-only and shared
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return keys;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Ask one question. Resolves `null` on EOF instead of hanging or throwing:
|
|
98
|
+
* `rl.question`'s callback never fires once the stream ends, so a piped or
|
|
99
|
+
* scheduled invocation would otherwise block forever — and an interface that is
|
|
100
|
+
* ALREADY closed (the input ran out between two prompts) throws
|
|
101
|
+
* `ERR_USE_AFTER_CLOSE` from `question`, which is the same end-of-input state
|
|
102
|
+
* and must end the pass just as cleanly.
|
|
103
|
+
*/
|
|
104
|
+
function ask(rl: readline.Interface, q: string): Promise<string | null> {
|
|
105
|
+
return new Promise((resolve) => {
|
|
106
|
+
if ((rl as { closed?: boolean }).closed === true) {
|
|
107
|
+
resolve(null);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
let settled = false;
|
|
111
|
+
const onClose = (): void => {
|
|
112
|
+
if (!settled) {
|
|
113
|
+
settled = true;
|
|
114
|
+
resolve(null);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
rl.once('close', onClose);
|
|
118
|
+
try {
|
|
119
|
+
rl.question(q, (answer) => {
|
|
120
|
+
settled = true;
|
|
121
|
+
rl.removeListener('close', onClose);
|
|
122
|
+
resolve(answer);
|
|
123
|
+
});
|
|
124
|
+
} catch {
|
|
125
|
+
rl.removeListener('close', onClose);
|
|
126
|
+
if (!settled) {
|
|
127
|
+
settled = true;
|
|
128
|
+
resolve(null);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface AnnotateIo {
|
|
135
|
+
/**
|
|
136
|
+
* readline input; defaults to `process.stdin`.
|
|
137
|
+
*
|
|
138
|
+
* Injected by the tests to drive the EOF path deterministically — a
|
|
139
|
+
* caller-supplied stream is a deliberate non-TTY answer channel, so the
|
|
140
|
+
* TTY refusal below applies to `process.stdin` only.
|
|
141
|
+
*/
|
|
142
|
+
input?: NodeJS.ReadableStream;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function main(argv?: readonly string[], io: AnnotateIo = {}): Promise<number> {
|
|
146
|
+
const args = argv ?? process.argv.slice(2);
|
|
147
|
+
const listOnly = args.includes('--list');
|
|
148
|
+
|
|
149
|
+
if (!fs.existsSync(ARCHIVE_DIR)) {
|
|
150
|
+
process.stdout.write(`No archive directory at ${ARCHIVE_DIR} — nothing to annotate.\n`);
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const files = fs
|
|
155
|
+
.readdirSync(ARCHIVE_DIR)
|
|
156
|
+
.filter((f) => f.endsWith('.md'))
|
|
157
|
+
.map((f) => path.join(ARCHIVE_DIR, f));
|
|
158
|
+
|
|
159
|
+
const metricsText = fs.existsSync(METRICS_FILE) ? fs.readFileSync(METRICS_FILE, 'utf8') : '';
|
|
160
|
+
const done = annotatedKeys(metricsText);
|
|
161
|
+
|
|
162
|
+
const pending: RegisterRow[] = [];
|
|
163
|
+
for (const f of files) {
|
|
164
|
+
const rows = extractRegisterRows(f, fs.readFileSync(f, 'utf8'));
|
|
165
|
+
for (const r of rows) {
|
|
166
|
+
if (!done.has(`${r.file}#${String(r.rank)}`)) pending.push(r);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (pending.length === 0) {
|
|
171
|
+
process.stdout.write('✅ No un-annotated Risk-Register rows in archived roadmaps.\n');
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
process.stdout.write(`${String(pending.length)} un-annotated mitigation(s) in archived roadmaps:\n`);
|
|
176
|
+
if (listOnly) {
|
|
177
|
+
for (const r of pending) {
|
|
178
|
+
process.stdout.write(` ${r.file} #${String(r.rank)} — ${r.item}\n`);
|
|
179
|
+
}
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Interactive-only by construction: the annotation pass is a human quarterly
|
|
184
|
+
// cadence, so a non-TTY invocation is refused up front with the read-only
|
|
185
|
+
// alternative rather than left to block on a prompt nobody can answer.
|
|
186
|
+
if (io.input === undefined && process.stdin.isTTY !== true) {
|
|
187
|
+
process.stderr.write(
|
|
188
|
+
'❌ annotate_r1_outcomes needs an interactive terminal (stdin is not a TTY).\n' +
|
|
189
|
+
' Use --list for the read-only pending inventory.\n',
|
|
190
|
+
);
|
|
191
|
+
return 2;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const rl = readline.createInterface({ input: io.input ?? process.stdin, output: process.stdout });
|
|
195
|
+
try {
|
|
196
|
+
for (const r of pending) {
|
|
197
|
+
process.stdout.write(`\n${r.file} #${String(r.rank)}\n risk: ${r.item}\n mitigation: ${r.mitigation}\n`);
|
|
198
|
+
let answer = '';
|
|
199
|
+
let eof = false;
|
|
200
|
+
while (!OUTCOMES.has(answer)) {
|
|
201
|
+
const raw = await ask(rl, ' outcome [helped/fired/unknown, s=skip]: ');
|
|
202
|
+
if (raw === null) {
|
|
203
|
+
eof = true;
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
answer = raw.trim().toLowerCase();
|
|
207
|
+
if (answer === 's' || answer === 'skip') break;
|
|
208
|
+
}
|
|
209
|
+
if (eof) {
|
|
210
|
+
process.stdout.write('\n⚠️ stdin closed — ending the annotation pass early.\n');
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
if (!OUTCOMES.has(answer)) continue;
|
|
214
|
+
const event = {
|
|
215
|
+
event: 'r1_mitigation_outcome',
|
|
216
|
+
date: new Date().toISOString().slice(0, 10),
|
|
217
|
+
file: r.file,
|
|
218
|
+
rank: r.rank,
|
|
219
|
+
outcome: answer,
|
|
220
|
+
};
|
|
221
|
+
// The metrics dir need not exist yet (a repo whose first recorded
|
|
222
|
+
// event is this one). Creating it here rather than letting
|
|
223
|
+
// `appendFileSync` throw matters because the throw would land AFTER
|
|
224
|
+
// the operator answered — an ENOENT crash that discards answers a
|
|
225
|
+
// human just typed. `recursive: true` is a no-op once it exists.
|
|
226
|
+
fs.mkdirSync(path.dirname(METRICS_FILE), { recursive: true });
|
|
227
|
+
fs.appendFileSync(METRICS_FILE, `${JSON.stringify(event)}\n`);
|
|
228
|
+
}
|
|
229
|
+
} finally {
|
|
230
|
+
rl.close();
|
|
231
|
+
}
|
|
232
|
+
process.stdout.write('\n✅ Annotation pass complete.\n');
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
237
|
+
function _isCliEntry(): boolean {
|
|
238
|
+
if (process.argv[1] === undefined) return false;
|
|
239
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
240
|
+
if (import.meta.url === argvUrl) return true;
|
|
241
|
+
try {
|
|
242
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
243
|
+
const argv1 = fs.realpathSync(path.resolve(process.argv[1]));
|
|
244
|
+
return here === argv1;
|
|
245
|
+
} catch {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (_isCliEntry() || process.argv[1] === _HERE) {
|
|
251
|
+
main()
|
|
252
|
+
.then((code) => process.exit(code))
|
|
253
|
+
.catch((exc: unknown) => {
|
|
254
|
+
const msg = exc instanceof Error ? exc.message : String(exc);
|
|
255
|
+
process.stderr.write(`❌ Internal error: ${msg}\n`);
|
|
256
|
+
process.exit(2);
|
|
257
|
+
});
|
|
258
|
+
}
|