@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,931 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Gate R1 validator — plan-risk Risk Register grammar.
4
+ *
5
+ * Implements exactly `docs/contracts/plan-review-gates.md` § 1 (Risk Register
6
+ * grammar incl. the grandfather clause), § 3 (substantial-change heuristic)
7
+ * and § 6 (exit-code contract). A divergence from that contract is a
8
+ * validator bug, never a contract reinterpretation.
9
+ *
10
+ * Corpus: ready (non-draft) roadmap files directly under `agents/roadmaps/`
11
+ * (top level only — `archive/`, `skipped/`, `later/`, `stubs/` excluded, as
12
+ * are `template.md` and `dashboard*`). `status: draft` frontmatter exempts a
13
+ * file (reported as `draft-exempt`, still counted as scanned).
14
+ *
15
+ * Checks per ready file:
16
+ * 1. `## Risk Register` section exists — missing = fail unless the
17
+ * grandfather clause applies (no substantial change since the gate
18
+ * activation date, measured against the last pre-activation commit).
19
+ * 2. Marker line `<!-- risk-review: v1 | reviewed: YYYY-MM-DD | reviewer: <id> -->`
20
+ * is the first non-blank line after the heading.
21
+ * 3. Body is EITHER the exact honest-null line OR the six-column risk
22
+ * table (Rank ascending from 1, Risk type ∈ {product, implementation},
23
+ * Mitigation non-empty, every `Anchored under` anchor resolving to a
24
+ * heading / `**Step N:**` bullet in the same document).
25
+ * 4. Staleness: `reviewed:` may not predate the last substantial change
26
+ * (feature comparison against the newest commit on/before the reviewed
27
+ * date). Skipped while the file has uncommitted modifications — there is
28
+ * no committed baseline for the current content to compare against.
29
+ *
30
+ * Exit codes (contract § 6): 0 = pass, 1 = policy violation,
31
+ * 2 = internal error (degraded advisory mode for callers).
32
+ */
33
+
34
+ import { execFileSync } from 'node:child_process';
35
+ import * as crypto from 'node:crypto';
36
+ import * as fs from 'node:fs';
37
+ import * as path from 'node:path';
38
+ import { fileURLToPath, pathToFileURL } from 'node:url';
39
+
40
+ import { parse as parseYaml } from 'yaml';
41
+
42
+ import { gitEnv } from './_lib/git_env.js';
43
+ import { splitMarkdownRow } from './_lib/md_table.js';
44
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
45
+
46
+ /**
47
+ * Gate activation date — committed source:
48
+ * docs/contracts/plan-review-gates.md § 1 (grandfather clause, council
49
+ * 2026-08-04). Ready roadmaps without a register are exempt while their
50
+ * content has had no substantial change since this date.
51
+ */
52
+ export const RISK_REGISTER_GATE_ACTIVATION = '2026-08-04';
53
+
54
+ const MARKER_RE =
55
+ /^<!--\s*risk-review:\s*v1\s*\|\s*reviewed:\s*(\d{4}-\d{2}-\d{2})\s*\|\s*reviewer:\s*(.+?)\s*-->\s*$/;
56
+ const HONEST_NULL_RE = /^\*\*Honest-null:\*\*(.*)$/;
57
+ const CHECKBOX_RE = /^\s*- \[( |x|~|-)\]/;
58
+ const PHASE_HEADING_RE = /^##\s+(Phase|Milestone)\b.*$/;
59
+ const FENCE_RE = /^\s*```/;
60
+ const STEP_BULLET_RE = /\*\*Step\s+([0-9]+[a-z]?):\*\*/i;
61
+
62
+ export interface Violation {
63
+ file: string;
64
+ line: number;
65
+ kind: string;
66
+ detail: string;
67
+ }
68
+
69
+ export interface PlanFeatures {
70
+ /** Sorted `## Phase …` / `## Milestone …` heading texts. */
71
+ phaseHeadings: string[];
72
+ /** Total count of deliverable checkbox lines (state-insensitive). */
73
+ checkboxCount: number;
74
+ /** sha256 of the `## Acceptance Criteria` section body (checkbox state normalized). */
75
+ acHash: string;
76
+ }
77
+
78
+ function _splitLines(text: string): string[] {
79
+ return text.split(/\r?\n/);
80
+ }
81
+
82
+ /** Lines of the document with an outside-code-fence flag per line. */
83
+ function _linesOutsideFences(text: string): Array<{ line: string; outside: boolean }> {
84
+ const out: Array<{ line: string; outside: boolean }> = [];
85
+ let inFence = false;
86
+ for (const line of _splitLines(text)) {
87
+ if (FENCE_RE.test(line)) {
88
+ inFence = !inFence;
89
+ out.push({ line, outside: false });
90
+ continue;
91
+ }
92
+ out.push({ line, outside: !inFence });
93
+ }
94
+ return out;
95
+ }
96
+
97
+ /**
98
+ * Extract the three substantial-change features of contract § 3:
99
+ * (a) phase/milestone heading set, (b) checkbox line count,
100
+ * (c) sha256 of the Acceptance Criteria section body. Checkbox STATE is
101
+ * normalized everywhere (state flips are never substantial).
102
+ */
103
+ export function extractFeatures(text: string): PlanFeatures {
104
+ const lines = _linesOutsideFences(text);
105
+ const phaseHeadings: string[] = [];
106
+ let checkboxCount = 0;
107
+ let acStart = -1;
108
+ for (let i = 0; i < lines.length; i++) {
109
+ const entry = lines[i] as { line: string; outside: boolean };
110
+ if (!entry.outside) continue;
111
+ if (PHASE_HEADING_RE.test(entry.line)) {
112
+ phaseHeadings.push(entry.line.trim());
113
+ }
114
+ if (CHECKBOX_RE.test(entry.line)) {
115
+ checkboxCount += 1;
116
+ }
117
+ if (/^##\s+Acceptance Criteria\s*$/.test(entry.line)) {
118
+ acStart = i;
119
+ }
120
+ }
121
+ let acBody = '';
122
+ if (acStart !== -1) {
123
+ const bodyLines: string[] = [];
124
+ for (let i = acStart + 1; i < lines.length; i++) {
125
+ const entry = lines[i] as { line: string; outside: boolean };
126
+ if (entry.outside && /^##\s/.test(entry.line)) break;
127
+ bodyLines.push(entry.line);
128
+ }
129
+ // Checkbox state flips inside AC are never substantial — normalize.
130
+ acBody = bodyLines.join('\n').replace(/^(\s*- )\[( |x|~|-)\]/gm, '$1[ ]');
131
+ }
132
+ const acHash = crypto.createHash('sha256').update(acBody, 'utf-8').digest('hex');
133
+ return { phaseHeadings: [...phaseHeadings].sort(), checkboxCount, acHash };
134
+ }
135
+
136
+ export function featuresEqual(a: PlanFeatures, b: PlanFeatures): boolean {
137
+ return (
138
+ a.checkboxCount === b.checkboxCount &&
139
+ a.acHash === b.acHash &&
140
+ a.phaseHeadings.length === b.phaseHeadings.length &&
141
+ a.phaseHeadings.every((h, i) => h === b.phaseHeadings[i])
142
+ );
143
+ }
144
+
145
+ /** Contract § 3 — a plan diff is substantial iff the feature sets differ. */
146
+ export function isSubstantialChange(oldText: string, newText: string): boolean {
147
+ return !featuresEqual(extractFeatures(oldText), extractFeatures(newText));
148
+ }
149
+
150
+ export interface Marker {
151
+ reviewed: string;
152
+ reviewer: string;
153
+ }
154
+
155
+ /** Parse the § 1.1 marker line; null when malformed. */
156
+ export function parseMarker(line: string): Marker | null {
157
+ const m = MARKER_RE.exec(line.trim());
158
+ if (!m) return null;
159
+ const reviewed = m[1] as string;
160
+ const reviewer = (m[2] as string).trim();
161
+ if (reviewer === '') return null;
162
+ return { reviewed, reviewer };
163
+ }
164
+
165
+ /** § 1.3 — exact honest-null grammar: `**Honest-null:** … because: <non-empty>.` */
166
+ export function isValidHonestNull(line: string): boolean {
167
+ const m = HONEST_NULL_RE.exec(line.trim());
168
+ if (!m) return false;
169
+ const rest = m[1] as string;
170
+ const because = /\bbecause:\s*(.*)$/.exec(rest);
171
+ if (!because) return false;
172
+ const reason = (because[1] as string).replace(/\.\s*$/, '').trim();
173
+ return reason !== '';
174
+ }
175
+
176
+ function _escapeRe(s: string): string {
177
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
178
+ }
179
+
180
+ /** § 1.2 — does an `Anchored under` anchor resolve inside the same document? */
181
+ export function anchorResolves(anchor: string, docText: string): boolean {
182
+ const a = anchor.trim();
183
+ if (a === '') return false;
184
+ const aLower = a.toLowerCase();
185
+ const lines = _linesOutsideFences(docText);
186
+ const headingLines: string[] = [];
187
+ const stepBulletLines: string[] = [];
188
+ for (const entry of lines) {
189
+ if (!entry.outside) continue;
190
+ if (/^#{1,6}\s/.test(entry.line)) headingLines.push(entry.line.toLowerCase());
191
+ if (STEP_BULLET_RE.test(entry.line)) stepBulletLines.push(entry.line.toLowerCase());
192
+ }
193
+ if (headingLines.some((h) => h.includes(aLower))) return true;
194
+ if (stepBulletLines.some((b) => b.includes(aLower))) return true;
195
+ // `Phase X Step Y` / `Phase X` compound form: "Phase X" must appear in a
196
+ // heading; a given "Step Y" must appear as a `**Step Y:**` bullet.
197
+ const compound = /^phase\s+(\S+?)(?:\s+step\s+([0-9]+[a-z]?))?$/i.exec(a);
198
+ if (compound) {
199
+ const phaseRe = new RegExp(`\\bphase\\s+${_escapeRe(compound[1] as string)}\\b`, 'i');
200
+ if (headingLines.some((h) => phaseRe.test(h))) {
201
+ const step = compound[2];
202
+ if (step === undefined) return true;
203
+ const stepRe = new RegExp(`\\*\\*step\\s+${_escapeRe(step)}:\\*\\*`, 'i');
204
+ if (stepRe.test(docText)) return true;
205
+ }
206
+ }
207
+ return false;
208
+ }
209
+
210
+ const TABLE_HEADER_CELLS = ['Rank', 'Item', 'Risk type', 'Description', 'Mitigation', 'Anchored under'];
211
+
212
+ function _splitRow(line: string): string[] {
213
+ return splitMarkdownRow(line);
214
+ }
215
+
216
+ function _isHeaderRow(line: string): boolean {
217
+ const cells = _splitRow(line);
218
+ return (
219
+ cells.length === TABLE_HEADER_CELLS.length &&
220
+ cells.every((c, i) => c === TABLE_HEADER_CELLS[i])
221
+ );
222
+ }
223
+
224
+ function _isSeparatorRow(line: string): boolean {
225
+ const cells = _splitRow(line);
226
+ return cells.length > 0 && cells.every((c) => /^:?-{2,}:?$/.test(c) || /^-+$/.test(c));
227
+ }
228
+
229
+ /**
230
+ * § 1.2 — validate the risk table rows found in `bodyLines` (section body
231
+ * after the marker). `startLine` is the 1-based document line of the first
232
+ * body line, used for violation line numbers. Returns violations; the caller
233
+ * decides emptiness handling.
234
+ */
235
+ export function validateTable(
236
+ file: string,
237
+ docText: string,
238
+ bodyLines: readonly string[],
239
+ startLine: number,
240
+ ): { headerFound: boolean; rowCount: number; violations: Violation[] } {
241
+ const violations: Violation[] = [];
242
+ let headerFound = false;
243
+ let rowCount = 0;
244
+ let prevRank = 0;
245
+ for (let i = 0; i < bodyLines.length; i++) {
246
+ const line = bodyLines[i] as string;
247
+ const lineNo = startLine + i;
248
+ if (!line.trim().startsWith('|')) {
249
+ if (headerFound && line.trim() !== '') break; // table ended
250
+ continue;
251
+ }
252
+ if (!headerFound) {
253
+ if (_isHeaderRow(line)) headerFound = true;
254
+ continue;
255
+ }
256
+ if (_isSeparatorRow(line)) continue;
257
+ const cells = _splitRow(line);
258
+ if (cells.length < TABLE_HEADER_CELLS.length) {
259
+ violations.push({
260
+ file,
261
+ line: lineNo,
262
+ kind: 'malformed_row',
263
+ detail: `risk row has ${cells.length} cell(s), expected ${TABLE_HEADER_CELLS.length}`,
264
+ });
265
+ continue;
266
+ }
267
+ rowCount += 1;
268
+ const [rankCell, , riskType, , mitigation, anchoredUnder] = cells as [
269
+ string,
270
+ string,
271
+ string,
272
+ string,
273
+ string,
274
+ string,
275
+ ];
276
+ const rank = /^\d+$/.test(rankCell) ? Number(rankCell) : NaN;
277
+ if (Number.isNaN(rank) || (prevRank === 0 && rank !== 1) || (prevRank !== 0 && rank <= prevRank)) {
278
+ violations.push({
279
+ file,
280
+ line: lineNo,
281
+ kind: 'bad_rank_order',
282
+ detail: `Rank \`${rankCell}\` — ranks must be strictly ascending integers starting at 1 (previous: ${prevRank})`,
283
+ });
284
+ }
285
+ if (!Number.isNaN(rank)) prevRank = rank;
286
+ if (riskType !== 'product' && riskType !== 'implementation') {
287
+ violations.push({
288
+ file,
289
+ line: lineNo,
290
+ kind: 'bad_risk_type',
291
+ detail: `Risk type \`${riskType}\` — must be \`product\` or \`implementation\``,
292
+ });
293
+ }
294
+ if (mitigation === '') {
295
+ violations.push({
296
+ file,
297
+ line: lineNo,
298
+ kind: 'empty_mitigation',
299
+ detail: 'Mitigation cell is empty — every risk row needs a mitigation',
300
+ });
301
+ }
302
+ if (anchoredUnder === '') {
303
+ violations.push({
304
+ file,
305
+ line: lineNo,
306
+ kind: 'empty_anchor',
307
+ detail: '`Anchored under` cell is empty — every risk must anchor to a plan section',
308
+ });
309
+ continue;
310
+ }
311
+ for (const anchor of anchoredUnder.split(',')) {
312
+ const a = anchor.trim();
313
+ if (a === '') continue;
314
+ if (!anchorResolves(a, docText)) {
315
+ violations.push({
316
+ file,
317
+ line: lineNo,
318
+ kind: 'dangling_anchor',
319
+ detail: `\`Anchored under\` reference \`${a}\` does not resolve to any heading or \`**Step N:**\` bullet in this document`,
320
+ });
321
+ }
322
+ }
323
+ }
324
+ return { headerFound, rowCount, violations };
325
+ }
326
+
327
+ /** `status: draft` in the YAML frontmatter block exempts a file. */
328
+ export function hasDraftStatus(text: string): boolean {
329
+ const lines = _splitLines(text);
330
+ if ((lines[0] ?? '').trim() !== '---') return false;
331
+ for (let i = 1; i < lines.length; i++) {
332
+ const line = lines[i] as string;
333
+ if (line.trim() === '---') return false;
334
+ if (/^status:\s*draft\s*$/.test(line)) return true;
335
+ }
336
+ return false;
337
+ }
338
+
339
+ export interface ContentResult {
340
+ draftExempt: boolean;
341
+ registerMissing: boolean;
342
+ /** Parsed `reviewed:` date when the marker is valid; null otherwise. */
343
+ reviewed: string | null;
344
+ violations: Violation[];
345
+ }
346
+
347
+ /** Pure content-level Gate-R1 check (no git — grandfather/staleness live in checkFile). */
348
+ export function checkContent(file: string, text: string): ContentResult {
349
+ const result: ContentResult = {
350
+ draftExempt: false,
351
+ registerMissing: false,
352
+ reviewed: null,
353
+ violations: [],
354
+ };
355
+ if (hasDraftStatus(text)) {
356
+ result.draftExempt = true;
357
+ return result;
358
+ }
359
+ const lines = _linesOutsideFences(text);
360
+ let regIdx = -1;
361
+ for (let i = 0; i < lines.length; i++) {
362
+ const entry = lines[i] as { line: string; outside: boolean };
363
+ if (entry.outside && /^##\s+Risk Register\s*$/.test(entry.line)) {
364
+ regIdx = i;
365
+ break;
366
+ }
367
+ }
368
+ if (regIdx === -1) {
369
+ result.registerMissing = true;
370
+ return result;
371
+ }
372
+ let endIdx = lines.length;
373
+ for (let i = regIdx + 1; i < lines.length; i++) {
374
+ const entry = lines[i] as { line: string; outside: boolean };
375
+ if (entry.outside && /^##\s/.test(entry.line)) {
376
+ endIdx = i;
377
+ break;
378
+ }
379
+ }
380
+ const sectionLines = lines.slice(regIdx + 1, endIdx).map((e) => e.line);
381
+ // Marker: first non-blank line after the heading.
382
+ let markerOffset = -1;
383
+ for (let i = 0; i < sectionLines.length; i++) {
384
+ if ((sectionLines[i] as string).trim() !== '') {
385
+ markerOffset = i;
386
+ break;
387
+ }
388
+ }
389
+ if (markerOffset === -1) {
390
+ result.violations.push({
391
+ file,
392
+ line: regIdx + 1,
393
+ kind: 'missing_marker',
394
+ detail: 'Risk Register section is empty — missing `<!-- risk-review: v1 | reviewed: YYYY-MM-DD | reviewer: <id> -->` marker',
395
+ });
396
+ return result;
397
+ }
398
+ const markerLine = sectionLines[markerOffset] as string;
399
+ const marker = parseMarker(markerLine);
400
+ let bodyStartOffset = markerOffset; // body starts after the marker when valid
401
+ if (marker === null) {
402
+ result.violations.push({
403
+ file,
404
+ line: regIdx + 2 + markerOffset,
405
+ kind: 'malformed_marker',
406
+ detail:
407
+ 'first non-blank line after `## Risk Register` must be ' +
408
+ '`<!-- risk-review: v1 | reviewed: YYYY-MM-DD | reviewer: <id> -->` ' +
409
+ `(got: \`${markerLine.trim()}\`)`,
410
+ });
411
+ } else {
412
+ result.reviewed = marker.reviewed;
413
+ bodyStartOffset = markerOffset + 1;
414
+ }
415
+ const bodyLines = sectionLines.slice(bodyStartOffset);
416
+ const bodyStartLine = regIdx + 2 + bodyStartOffset;
417
+ // Honest-null branch.
418
+ let honestNullSeen = false;
419
+ for (let i = 0; i < bodyLines.length; i++) {
420
+ const line = (bodyLines[i] as string).trim();
421
+ if (!HONEST_NULL_RE.test(line)) continue;
422
+ honestNullSeen = true;
423
+ if (!isValidHonestNull(line)) {
424
+ result.violations.push({
425
+ file,
426
+ line: bodyStartLine + i,
427
+ kind: 'malformed_honest_null',
428
+ detail:
429
+ 'honest-null line must be exactly ' +
430
+ '`**Honest-null:** no material product or implementation risks identified because: <reason>.` ' +
431
+ 'with a non-empty reason after `because:`',
432
+ });
433
+ }
434
+ break;
435
+ }
436
+ // The table is validated whenever one is PRESENT — never suppressed by the
437
+ // honest-null line. § 1.3 makes honest-null valid "only as exactly this
438
+ // shape", so honest-null + table is contradictory: gating validateTable on
439
+ // `!honestNullSeen` let such a register pass with zero row checks (bad
440
+ // ranks, bad risk types, empty mitigations, dangling anchors all
441
+ // unreported) — one honest-null line anywhere in the body disabled the whole
442
+ // table check. Gate R2's `evaluate` already reports the analogous
443
+ // skip-declaration-plus-table combination and still validates the rows; this
444
+ // is the same rule on the R1 side.
445
+ const { headerFound, rowCount, violations } = validateTable(file, text, bodyLines, bodyStartLine);
446
+ if (honestNullSeen) {
447
+ if (headerFound || rowCount > 0) {
448
+ result.violations.push(...violations);
449
+ result.violations.push({
450
+ file,
451
+ line: regIdx + 1,
452
+ kind: 'contradictory_register',
453
+ detail:
454
+ 'Risk Register carries BOTH the honest-null line and a risk table — § 1.3 admits ' +
455
+ 'honest-null only as the sole body content. Remove one: the table if there are ' +
456
+ 'genuinely no risks, the honest-null line otherwise',
457
+ });
458
+ }
459
+ return result;
460
+ }
461
+ result.violations.push(...violations);
462
+ if (!headerFound || rowCount === 0) {
463
+ result.violations.push({
464
+ file,
465
+ line: regIdx + 1,
466
+ kind: 'empty_register',
467
+ detail:
468
+ 'Risk Register is empty or prose-only — needs the six-column risk table ' +
469
+ 'or the exact honest-null line (prose like "no risks" does not count)',
470
+ });
471
+ }
472
+ return result;
473
+ }
474
+
475
+ // --------------------------------------------------------------------------
476
+ // Git layer — grandfather clause + staleness.
477
+ // --------------------------------------------------------------------------
478
+
479
+ /**
480
+ * Node's default `maxBuffer` is 1 MiB. `_git` reads whole file blobs
481
+ * (`git show <sha>:<path>`), and a plan larger than that would throw ENOBUFS →
482
+ * `null` → **no staleness baseline** → the check silently passes. Same
483
+ * self-disabling shape as the Gate-R2 scope diff, and here it fails OPEN, so the
484
+ * ceiling is raised rather than relied on.
485
+ */
486
+ const GIT_MAX_BUFFER = 256 * 1024 * 1024;
487
+
488
+ function _git(cwd: string, args: readonly string[]): string | null {
489
+ try {
490
+ return execFileSync('git', [...args], {
491
+ cwd,
492
+ encoding: 'utf-8',
493
+ maxBuffer: GIT_MAX_BUFFER,
494
+ stdio: ['ignore', 'pipe', 'pipe'],
495
+ // cwd decides, never an inherited GIT_DIR (hook environments).
496
+ env: gitEnv(),
497
+ });
498
+ } catch {
499
+ return null;
500
+ }
501
+ }
502
+
503
+ interface HistoryEntry {
504
+ sha: string;
505
+ date: string; // %cs — YYYY-MM-DD
506
+ }
507
+
508
+ /** Newest-first commit history of a file; empty for untracked / non-repo paths. */
509
+ export function fileHistory(absPath: string): HistoryEntry[] {
510
+ const dir = path.dirname(absPath);
511
+ const top = _git(dir, ['rev-parse', '--show-toplevel']);
512
+ if (top === null) return [];
513
+ const root = top.trim();
514
+ // Normalize separators: git pathspecs use `/` on every platform, so a raw
515
+ // Windows `path.relative()` result matches nothing and would silently yield
516
+ // an empty history — which the grandfather clause reads as "no baseline".
517
+ const rel = path.relative(root, absPath).split(path.sep).join('/');
518
+ const out = _git(root, ['log', '--format=%H %cs', '--', rel]);
519
+ if (out === null) return [];
520
+ const entries: HistoryEntry[] = [];
521
+ for (const line of out.trim().split('\n')) {
522
+ if (line === '') continue;
523
+ const [sha, date] = line.split(' ');
524
+ if (sha !== undefined && date !== undefined) entries.push({ sha, date });
525
+ }
526
+ return entries;
527
+ }
528
+
529
+ /**
530
+ * Does `absPath` differ from committed state in the working tree?
531
+ *
532
+ * Staleness (§ 1.1) compares the working-tree text against the blob of the
533
+ * newest commit dated `<= reviewed:`. While a substantial edit AND its freshly
534
+ * written register are both uncommitted, that baseline is the *previous* commit,
535
+ * whose features differ — so a register written the same minute is reported
536
+ * stale. The gate judges committed history; with uncommitted modifications there
537
+ * is no committed history to judge yet, so staleness is not decidable and the
538
+ * check is skipped rather than answered wrongly. Untracked / non-repo paths
539
+ * report `false` (they have no history at all, so staleness never fires there).
540
+ */
541
+ export function hasUncommittedChanges(absPath: string): boolean {
542
+ const dir = path.dirname(absPath);
543
+ const top = _git(dir, ['rev-parse', '--show-toplevel']);
544
+ if (top === null) return false;
545
+ const root = top.trim();
546
+ const rel = path.relative(root, absPath).split(path.sep).join('/');
547
+ const out = _git(root, ['status', '--porcelain', '--', rel]);
548
+ if (out === null) return false;
549
+ return out.trim() !== '';
550
+ }
551
+
552
+ function _blobAt(absPath: string, sha: string): string | null {
553
+ const dir = path.dirname(absPath);
554
+ const top = _git(dir, ['rev-parse', '--show-toplevel']);
555
+ if (top === null) return null;
556
+ const root = top.trim();
557
+ const rel = path.relative(root, absPath).split(path.sep).join('/');
558
+ return _git(root, ['show', `${sha}:${rel}`]);
559
+ }
560
+
561
+ export type FileStatus = 'ok' | 'draft-exempt' | 'grandfathered' | 'fail';
562
+
563
+ export interface FileResult {
564
+ file: string;
565
+ status: FileStatus;
566
+ violations: Violation[];
567
+ }
568
+
569
+ /** Full Gate-R1 check for one ready roadmap file (content + git layers). */
570
+ export function checkFile(absPath: string): FileResult {
571
+ const text = fs.readFileSync(absPath, 'utf-8');
572
+ const content = checkContent(absPath, text);
573
+ if (content.draftExempt) {
574
+ return { file: absPath, status: 'draft-exempt', violations: [] };
575
+ }
576
+ if (content.registerMissing) {
577
+ // Grandfather clause (contract § 1): exempt while feature-equal with
578
+ // the file's last committed state STRICTLY BEFORE the activation date.
579
+ // Strictness is load-bearing: with `<=`, a substantial change committed
580
+ // on the activation day becomes its own baseline and grandfathers
581
+ // itself — the exemption would cover exactly the changes the gate
582
+ // exists to catch. A file whose history starts on or after activation
583
+ // has no baseline and must carry a register.
584
+ const history = fileHistory(absPath);
585
+ const pre = history.find((h) => h.date < RISK_REGISTER_GATE_ACTIVATION);
586
+ if (pre === undefined) {
587
+ return {
588
+ file: absPath,
589
+ status: 'fail',
590
+ violations: [
591
+ {
592
+ file: absPath,
593
+ line: 1,
594
+ kind: 'missing_register',
595
+ detail:
596
+ 'no `## Risk Register` section and no committed version before the ' +
597
+ `gate activation date ${RISK_REGISTER_GATE_ACTIVATION} — new plans must carry a register`,
598
+ },
599
+ ],
600
+ };
601
+ }
602
+ const blob = _blobAt(absPath, pre.sha);
603
+ if (blob !== null && featuresEqual(extractFeatures(blob), extractFeatures(text))) {
604
+ return { file: absPath, status: 'grandfathered', violations: [] };
605
+ }
606
+ return {
607
+ file: absPath,
608
+ status: 'fail',
609
+ violations: [
610
+ {
611
+ file: absPath,
612
+ line: 1,
613
+ kind: 'missing_register',
614
+ detail:
615
+ 'no `## Risk Register` section and the plan changed substantially since the ' +
616
+ `activation date ${RISK_REGISTER_GATE_ACTIVATION} — the grandfather exemption is lifted`,
617
+ },
618
+ ],
619
+ };
620
+ }
621
+ const violations = [...content.violations];
622
+ // A file with uncommitted modifications has no committed baseline for the
623
+ // current content, so staleness is not judgeable — see
624
+ // hasUncommittedChanges. The committed-history case below is unchanged.
625
+ if (content.reviewed !== null && !hasUncommittedChanges(absPath)) {
626
+ // Staleness (contract §§ 1.1 + 3): compare features of the newest
627
+ // commit on/before the reviewed date against the current content.
628
+ const history = fileHistory(absPath);
629
+ const base = history.find((h) => h.date <= (content.reviewed as string));
630
+ if (base !== undefined) {
631
+ const blob = _blobAt(absPath, base.sha);
632
+ if (blob !== null && !featuresEqual(extractFeatures(blob), extractFeatures(text))) {
633
+ violations.push({
634
+ file: absPath,
635
+ line: 1,
636
+ kind: 'stale_review',
637
+ detail:
638
+ `\`reviewed: ${content.reviewed}\` predates the last substantial change ` +
639
+ '(phase headings, checkbox count, or Acceptance Criteria differ from the ' +
640
+ 'version at the review date) — re-review the plan',
641
+ });
642
+ }
643
+ }
644
+ // No commit on/before the reviewed date → the register was just
645
+ // written (brand-new / uncommitted file) → fresh.
646
+ }
647
+ return {
648
+ file: absPath,
649
+ status: violations.length > 0 ? 'fail' : 'ok',
650
+ violations,
651
+ };
652
+ }
653
+
654
+ // --------------------------------------------------------------------------
655
+ // CLI.
656
+ // --------------------------------------------------------------------------
657
+
658
+ const EXCLUDED_NAMES_RE = /^(template\.md|dashboard.*)$/;
659
+
660
+ /** Ready-roadmap corpus: top-level `*.md` under each root dir, name-filtered. */
661
+ function _resolveTargets(paths: readonly string[]): string[] {
662
+ const out: string[] = [];
663
+ for (const raw of paths) {
664
+ let stat: fs.Stats;
665
+ try {
666
+ stat = fs.statSync(raw);
667
+ } catch {
668
+ continue;
669
+ }
670
+ if (stat.isDirectory()) {
671
+ const entries = fs
672
+ .readdirSync(raw, { withFileTypes: true })
673
+ .filter((e) => e.isFile() && e.name.endsWith('.md') && !EXCLUDED_NAMES_RE.test(e.name))
674
+ .map((e) => path.join(raw, e.name));
675
+ entries.sort();
676
+ out.push(...entries);
677
+ } else if (raw.endsWith('.md')) {
678
+ out.push(raw);
679
+ }
680
+ }
681
+ return out;
682
+ }
683
+
684
+ /** `planning.risk_review === false` in `<dir>/.agent-settings.yml` disables Gate R1. */
685
+ export function riskReviewDisabled(settingsDir: string): boolean {
686
+ const settingsPath = path.join(settingsDir, '.agent-settings.yml');
687
+ let raw: string;
688
+ try {
689
+ raw = fs.readFileSync(settingsPath, 'utf-8');
690
+ } catch {
691
+ return false;
692
+ }
693
+ try {
694
+ const parsed = parseYaml(raw) as unknown;
695
+ if (parsed === null || typeof parsed !== 'object') return false;
696
+ const planning = (parsed as Record<string, unknown>)['planning'];
697
+ if (planning === null || typeof planning !== 'object') return false;
698
+ return (planning as Record<string, unknown>)['risk_review'] === false;
699
+ } catch {
700
+ return false;
701
+ }
702
+ }
703
+
704
+ interface Args {
705
+ paths: string[];
706
+ format: 'text' | 'json';
707
+ quiet: boolean;
708
+ }
709
+
710
+ function parse_args(argv: readonly string[]): Args {
711
+ const paths: string[] = [];
712
+ let format: 'text' | 'json' = 'text';
713
+ let quiet = false;
714
+ let i = 0;
715
+ while (i < argv.length) {
716
+ const arg = argv[i] as string;
717
+ if (arg === '--format') {
718
+ const v = argv[++i] as string | undefined;
719
+ if (v !== 'text' && v !== 'json') {
720
+ process.stderr.write(
721
+ `lint_plan_risk_register: error: argument --format: invalid choice: '${v ?? ''}' (choose from 'text', 'json')\n`,
722
+ );
723
+ process.exit(2);
724
+ }
725
+ format = v;
726
+ } else if (arg.startsWith('--format=')) {
727
+ const v = arg.slice('--format='.length);
728
+ if (v !== 'text' && v !== 'json') {
729
+ process.stderr.write(
730
+ `lint_plan_risk_register: error: argument --format: invalid choice: '${v}' (choose from 'text', 'json')\n`,
731
+ );
732
+ process.exit(2);
733
+ }
734
+ format = v;
735
+ } else if (arg === '--quiet') {
736
+ quiet = true;
737
+ } else if (arg === '-h' || arg === '--help') {
738
+ process.stdout.write(
739
+ 'usage: lint_plan_risk_register [-h] [--format {text,json}] [--quiet] [paths ...]\n',
740
+ );
741
+ process.exit(0);
742
+ } else if (arg.startsWith('-') && arg !== '-') {
743
+ process.stderr.write(`lint_plan_risk_register: error: unrecognized arguments: ${arg}\n`);
744
+ process.exit(2);
745
+ } else {
746
+ paths.push(arg);
747
+ }
748
+ i++;
749
+ }
750
+ return {
751
+ paths: paths.length ? paths : ['agents/roadmaps'],
752
+ format,
753
+ quiet,
754
+ };
755
+ }
756
+
757
+ export function main(argv?: readonly string[]): number {
758
+ const args = parse_args(argv ?? process.argv.slice(2));
759
+
760
+ // Contract § 6: the `scanned:` line is emitted on EVERY exit path, exit 2
761
+ // included, because the coverage guard reads that number. Emit-once, so the
762
+ // count can be published BEFORE the first failure path without any later
763
+ // path duplicating it — the same shape check_completion_review uses (there
764
+ // an unresolvable base ref used to exit 2 emitting no count at all; here it
765
+ // was the dead-scope return, the unexpected-internal return, and the
766
+ // top-level CLI catch).
767
+ let scannedEmitted = false;
768
+ const emitScanned = (n: number): void => {
769
+ if (scannedEmitted) {
770
+ return;
771
+ }
772
+ scannedEmitted = true;
773
+ process.stdout.write(`scanned: ${String(n)}\n`);
774
+ };
775
+
776
+ // Settings escape hatch (contract scope note): planning.risk_review: false
777
+ // disables Gate R1. assertScanned is deliberately NOT called here — the
778
+ // zero-scan is an explicit, configured skip, not a dead scope. The
779
+ // gate-coverage guard runs in CI where no .agent-settings.yml exists, so
780
+ // the real scanned floor is still enforced there.
781
+ if (riskReviewDisabled(process.cwd())) {
782
+ process.stdout.write('⚠️ planning.risk_review=false — Gate R1 skipped (settings escape hatch)\n');
783
+ emitScanned(0);
784
+ return 0;
785
+ }
786
+
787
+ let targets: string[];
788
+ try {
789
+ targets = _resolveTargets(args.paths);
790
+ } catch (exc) {
791
+ // Inventory resolution itself can fail (an unreadable roots directory —
792
+ // EACCES on readdir). That used to escape main entirely and hit the
793
+ // top-level CLI catch, which printed no count at all.
794
+ emitScanned(0);
795
+ process.stderr.write(
796
+ `❌ Internal error: cannot resolve the roadmap corpus (${args.paths.join(', ')}): ` +
797
+ `${exc instanceof Error ? exc.message : String(exc)}\n`,
798
+ );
799
+ return 2;
800
+ }
801
+
802
+ // The inventory needs no git and no file reads, so the coverage number is
803
+ // known here — publish it before anything can fail.
804
+ emitScanned(targets.length);
805
+
806
+ // A root that does not exist on disk at all is a legitimately empty
807
+ // corpus (a project with no roadmaps yet), NOT a dead scope. Only a root
808
+ // that IS there while yielding nothing means the scan went blind. Without
809
+ // this discriminator the gate would block every roadmap-less project with
810
+ // a misleading "the root moved" error.
811
+ const noRootOnDisk = args.paths.every((p) => !fs.existsSync(p));
812
+
813
+ try {
814
+ assertScanned({
815
+ gate: 'lint_plan_risk_register',
816
+ scanned: targets.length,
817
+ units: 'ready roadmap file(s)',
818
+ roots: args.paths,
819
+ ...(noRootOnDisk ? { allowEmpty: 'no roadmaps directory in this project — nothing to gate' } : {}),
820
+ });
821
+ } catch (exc) {
822
+ // A dead scan scope is a POLICY violation (exit 1), never an internal
823
+ // error (exit 2). Exit 2 is warn-and-allow at every call site, so
824
+ // mapping a moved/renamed roadmaps root to 2 would silently degrade
825
+ // this gate to advisory — the exact blind-gate failure the
826
+ // scanned-floor machinery exists to prevent. A gate that read nothing
827
+ // has not passed. Contract carve-out:
828
+ // docs/contracts/plan-review-gates.md § 6.
829
+ // Both returns below rely on the emitScanned above: a dead scope reports
830
+ // `scanned: 0` AND exit 1, so the coverage guard sees the zero instead
831
+ // of a missing line (contract § 6).
832
+ if (exc instanceof DeadScopeError) {
833
+ process.stderr.write(`❌ ${exc.message}\n`);
834
+ return 1;
835
+ }
836
+ // Anything else here really is internal (unexpected): degraded advisory.
837
+ process.stderr.write(`❌ Internal error: ${exc instanceof Error ? exc.message : String(exc)}\n`);
838
+ return 2;
839
+ }
840
+
841
+ const all_violations: Violation[] = [];
842
+ let scanned = 0;
843
+ const statusLines: string[] = [];
844
+ try {
845
+ for (const p of targets) {
846
+ const result = checkFile(p);
847
+ scanned += 1;
848
+ if (result.status === 'draft-exempt') {
849
+ statusLines.push(` draft-exempt: ${p}`);
850
+ } else if (result.status === 'grandfathered') {
851
+ statusLines.push(` grandfathered: ${p}`);
852
+ }
853
+ all_violations.push(...result.violations);
854
+ }
855
+ } catch (exc) {
856
+ // Contract § 6: `scanned:` is emitted on EVERY exit path, exit 2
857
+ // included. A throw mid-loop (unreadable file, permission denied) that
858
+ // exited silently would leave the coverage guard with no count at all —
859
+ // the same blind spot the guard exists to detect. Already published
860
+ // above (the inventory count); this call is the no-op that documents
861
+ // the obligation for this path.
862
+ emitScanned(scanned);
863
+ process.stderr.write(
864
+ `❌ lint_plan_risk_register: internal error after ${String(scanned)} file(s): ` +
865
+ `${exc instanceof Error ? exc.message : String(exc)}\n`,
866
+ );
867
+ return 2;
868
+ }
869
+
870
+ if (args.format === 'json') {
871
+ process.stdout.write(JSON.stringify(all_violations, null, 2) + '\n');
872
+ } else {
873
+ if (!args.quiet && statusLines.length > 0) {
874
+ process.stdout.write(statusLines.join('\n') + '\n');
875
+ }
876
+ if (all_violations.length > 0) {
877
+ process.stdout.write(`❌ ${all_violations.length} Risk-Register violation(s):\n\n`);
878
+ for (const v of all_violations) {
879
+ process.stdout.write(` ${v.file}:${v.line} — ${v.kind}\n`);
880
+ process.stdout.write(` │ ${v.detail}\n`);
881
+ }
882
+ } else if (!args.quiet) {
883
+ process.stdout.write(`✅ Risk Registers clean (${scanned} ready roadmap file(s) scanned).\n`);
884
+ }
885
+ }
886
+
887
+ // Gate-coverage contract (src/config/gate-coverage.yml): always emitted,
888
+ // independent of the pass/fail verdict — already published above, so this
889
+ // is a no-op that keeps the obligation visible on the success path too.
890
+ emitScanned(scanned);
891
+
892
+ return all_violations.length > 0 ? 1 : 0;
893
+ }
894
+
895
+ const _HERE = fileURLToPath(import.meta.url);
896
+ function _isCliEntry(): boolean {
897
+ if (process.argv[1] === undefined) {
898
+ return false;
899
+ }
900
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
901
+ if (import.meta.url === argvUrl) {
902
+ return true;
903
+ }
904
+ // A symlinked invocation (e.g. via an installed projection, or macOS
905
+ // /var → /private/var temp dirs) makes the raw URLs differ — compare
906
+ // realpaths so the entry guard still fires.
907
+ try {
908
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
909
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
910
+ return here === argv;
911
+ } catch {
912
+ return false;
913
+ }
914
+ }
915
+
916
+ if (_isCliEntry() || process.argv[1] === _HERE) {
917
+ try {
918
+ process.exit(main());
919
+ } catch (exc) {
920
+ // Exit-code contract (docs/contracts/plan-review-gates.md § 6):
921
+ // internal error = 2 → degraded advisory mode for hook/CI callers.
922
+ // Last-resort `scanned:` emission: main() publishes its own count before
923
+ // any failure path, and the coverage guard reads the FIRST match, so a
924
+ // real count always wins over this zero — but a throw that escapes main
925
+ // before it emitted must still leave the guard a number, not silence.
926
+ process.stdout.write('scanned: 0\n');
927
+ const msg = exc instanceof Error ? exc.message : String(exc);
928
+ process.stderr.write(`❌ Internal error: ${msg}\n`);
929
+ process.exit(2);
930
+ }
931
+ }