@event4u/agent-config 9.17.0 → 9.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +79 -182
  3. package/dist/agent-src/commands/challenge-me/vision.md +5 -0
  4. package/dist/agent-src/commands/evals/rule-compliance-audit.json +46 -0
  5. package/dist/agent-src/commands/feature/plan.md +20 -0
  6. package/dist/agent-src/commands/feature/roadmap.md +19 -0
  7. package/dist/agent-src/commands/pr/create.md +29 -0
  8. package/dist/agent-src/commands/roadmap/create.md +29 -0
  9. package/dist/agent-src/commands/rule-compliance-audit.md +28 -5
  10. package/dist/agent-src/contexts/execution/plan-confidence-gate.md +133 -0
  11. package/dist/agent-src/rules/active-remediation.md +2 -0
  12. package/dist/agent-src/rules/architecture.md +2 -0
  13. package/dist/agent-src/rules/augment-edit-discipline.md +2 -0
  14. package/dist/agent-src/rules/brand-consistency.md +10 -62
  15. package/dist/agent-src/rules/brand-source-of-truth.md +62 -11
  16. package/dist/agent-src/rules/broken-access-control.md +4 -0
  17. package/dist/agent-src/rules/cli-output-handling.md +5 -0
  18. package/dist/agent-src/rules/code-comment-discipline.md +3 -0
  19. package/dist/agent-src/rules/commit-conventions.md +2 -0
  20. package/dist/agent-src/rules/cross-source-consistency.md +3 -0
  21. package/dist/agent-src/rules/design-fidelity.md +2 -0
  22. package/dist/agent-src/rules/doc-screenshot-hygiene.md +2 -0
  23. package/dist/agent-src/rules/docker-commands.md +4 -0
  24. package/dist/agent-src/rules/domain-adoption-policy.md +2 -0
  25. package/dist/agent-src/rules/domain-safety-disclaimer.md +5 -2
  26. package/dist/agent-src/rules/engineering-safety-floor.md +2 -0
  27. package/dist/agent-src/rules/framework-neutrality-in-generic-skills.md +9 -0
  28. package/dist/agent-src/rules/image-likeness-and-rights.md +4 -0
  29. package/dist/agent-src/rules/improve-before-implement.md +4 -0
  30. package/dist/agent-src/rules/laravel-routing.md +4 -0
  31. package/dist/agent-src/rules/legal-safety-floor.md +3 -0
  32. package/dist/agent-src/rules/lethal-trifecta-guard.md +3 -0
  33. package/dist/agent-src/rules/media-governance-routing.md +5 -0
  34. package/dist/agent-src/rules/minimal-safe-diff.md +2 -0
  35. package/dist/agent-src/rules/no-roadmap-references.md +2 -0
  36. package/dist/agent-src/rules/notes-first-reasoning.md +2 -0
  37. package/dist/agent-src/rules/php-coding.md +2 -0
  38. package/dist/agent-src/rules/prefer-enums-over-literals.md +3 -0
  39. package/dist/agent-src/rules/provider-lifecycle-discipline.md +5 -0
  40. package/dist/agent-src/rules/roadmap-ci-steps-policy.md +2 -0
  41. package/dist/agent-src/rules/roadmap-progress-sync.md +2 -0
  42. package/dist/agent-src/rules/rule-type-governance.md +2 -0
  43. package/dist/agent-src/rules/scale-discipline.md +3 -0
  44. package/dist/agent-src/rules/secret-vcs-guard.md +2 -0
  45. package/dist/agent-src/rules/security-sensitive-stop.md +9 -2
  46. package/dist/agent-src/rules/senior-engineering-discipline.md +6 -0
  47. package/dist/agent-src/rules/skill-quality.md +2 -0
  48. package/dist/agent-src/rules/source-confidentiality.md +4 -0
  49. package/dist/agent-src/rules/source-discovery-gate.md +3 -0
  50. package/dist/agent-src/rules/source-of-truth.md +2 -0
  51. package/dist/agent-src/rules/strategy-safety-floor.md +2 -0
  52. package/dist/agent-src/rules/think-before-action.md +5 -0
  53. package/dist/agent-src/rules/ui-audit-gate.md +2 -0
  54. package/dist/agent-src/skills/design-intelligence/SKILL.md +6 -180
  55. package/dist/agent-src/skills/design-intelligence/references/context-and-registers.md +100 -0
  56. package/dist/agent-src/skills/design-intelligence/references/integration-mapping.md +91 -0
  57. package/dist/agent-src/skills/design-review/SKILL.md +4 -141
  58. package/dist/agent-src/skills/design-review/references/review-communication.md +92 -0
  59. package/dist/agent-src/skills/design-review/references/verification-automation.md +61 -0
  60. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +8 -27
  61. package/dist/agent-src/skills/existing-ui-audit/references/anti-slop-cross-reference.md +31 -0
  62. package/dist/agent-src/skills/fe-design/SKILL.md +4 -289
  63. package/dist/agent-src/skills/fe-design/references/design-patterns.md +227 -0
  64. package/dist/agent-src/skills/fe-design/references/design-read-and-memory.md +75 -0
  65. package/dist/agent-src/skills/roadmap-management/SKILL.md +10 -0
  66. package/dist/agent-src/skills/roadmap-writing/SKILL.md +12 -1
  67. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  68. package/dist/agent-src/templates/roadmaps.md +12 -0
  69. package/dist/agent-src/templates/scripts/work_engine/directives/ui/_fix_lane.ts +52 -0
  70. package/dist/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +8 -0
  71. package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +6 -0
  72. package/dist/agent-src/templates/scripts/work_engine/intent/classify.ts +56 -4
  73. package/dist/cli/registry.js +2 -0
  74. package/dist/cli/registry.js.map +1 -1
  75. package/dist/cli-delegate/chunk-AIIUU73N.js +2040 -0
  76. package/dist/cli-delegate/{chunk-IMSXPRVH.js → chunk-ELMFOQPW.js} +11 -0
  77. package/dist/cli-delegate/chunk-LF2KKJ4Q.js +183 -0
  78. package/dist/cli-delegate/cmd_explain.js +36 -36
  79. package/dist/cli-delegate/cmd_export.js +1 -1
  80. package/dist/cli-delegate/cmd_handoff.js +203 -2228
  81. package/dist/cli-delegate/cmd_prune.js +1 -1
  82. package/dist/cli-delegate/cmd_route_audit.js +249 -0
  83. package/dist/cli-delegate/cmd_route_explain.js +285 -0
  84. package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
  85. package/dist/cli-delegate/cmd_sync.js +1 -1
  86. package/dist/cli-delegate/cmd_uninstall.js +1 -1
  87. package/dist/cli-delegate/cmd_validate.js +1 -1
  88. package/dist/discovery/deprecation-report.md +1 -1
  89. package/dist/discovery/discovery-manifest.json +57 -57
  90. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  91. package/dist/discovery/discovery-manifest.summary.md +1 -1
  92. package/dist/discovery/orphan-report.md +1 -1
  93. package/dist/discovery/packs.json +2 -2
  94. package/dist/discovery/trust-report.md +1 -1
  95. package/dist/discovery/workspaces.json +2 -2
  96. package/dist/install/install.mjs +11 -0
  97. package/dist/mcp/registry-manifest.json +1 -1
  98. package/dist/mcp/server.json +2 -2
  99. package/dist/router.json +1 -1
  100. package/dist/server/schemas/settings.js +12 -0
  101. package/dist/server/schemas/settings.js.map +1 -1
  102. package/package.json +1 -1
  103. package/src/agent-src/templates/scripts/work_engine/directives/ui/_fix_lane.ts +52 -0
  104. package/src/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +8 -0
  105. package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +6 -0
  106. package/src/agent-src/templates/scripts/work_engine/intent/classify.ts +56 -4
  107. package/src/config/agent-settings.template.yml +46 -0
  108. package/src/config/evaluator-budgets.json +3 -3
  109. package/src/config/gate-coverage.yml +40 -0
  110. package/src/scripts/_cli/cmd_explain.ts +41 -36
  111. package/src/scripts/_cli/cmd_route_audit.ts +290 -0
  112. package/src/scripts/_cli/cmd_route_explain.ts +269 -0
  113. package/src/scripts/_dispatch.bash +23 -0
  114. package/src/scripts/_lib/git_env.ts +40 -0
  115. package/src/scripts/_lib/md_table.ts +27 -0
  116. package/src/scripts/_lib/planning_settings.ts +43 -0
  117. package/src/scripts/_lib/router_match.ts +301 -0
  118. package/src/scripts/annotate_r1_outcomes.ts +258 -0
  119. package/src/scripts/check_completion_review.ts +1140 -0
  120. package/src/scripts/compile_router.ts +20 -3
  121. package/src/scripts/dispatch_r2_reviewer.ts +968 -0
  122. package/src/scripts/eval_ui_triviality.ts +156 -0
  123. package/src/scripts/lint_abstraction_thresholds.ts +3 -1
  124. package/src/scripts/lint_plan_risk_register.ts +931 -0
  125. package/src/scripts/lint_trigger_collisions.ts +257 -0
  126. package/src/scripts/router_telemetry.ts +16 -290
  127. package/src/scripts/schemas/rule.schema.json +10 -0
  128. package/src/server/schemas/settings.ts +18 -0
@@ -0,0 +1,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
+ }