@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,1140 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Gate R2 — completion-review validator (deterministic).
4
+ *
5
+ * Implements exactly the findings-artifact grammar in
6
+ * `docs/contracts/plan-review-gates.md` §2 (header marker, findings table,
7
+ * honest-null grammar, skip-declaration grammar, findings-before-fixes
8
+ * ancestry) and the exit-code contract in §6. A divergence from that file is
9
+ * a validator bug, never a contract reinterpretation.
10
+ *
11
+ * What it checks, given the current branch state (`--repo`, `--base`):
12
+ * - a completion-review artifact (`*.findings.md` under `--artifact-dir`)
13
+ * exists for the current REVIEW-SCOPE HASH, OR a valid skip declaration
14
+ * covers it;
15
+ * - the artifact carries a §5 context manifest (a relevant artifact without
16
+ * one is unverifiable → `missing-manifest`);
17
+ * - every finding row is terminal (`fixed` / `accepted-risk` / `deferred`)
18
+ * with the required Reason/Ref content;
19
+ * - every table-shaped row parses into the six §2.2 cells (`malformed-row`) —
20
+ * a short row is never silently dropped, or an `open` finding would pass;
21
+ * - severity rows are sorted descending (critical > high > medium > low);
22
+ * - findings-before-fixes ancestry: the commit that FIRST added the
23
+ * artifact is an ancestor of every referenced fix commit (§2.5 — the
24
+ * first-add commit is what counts, so backdating is detected too).
25
+ *
26
+ * The review binds to the review-SCOPE HASH, never to a commit sha. That
27
+ * definition is owned by `dispatch_r2_reviewer.ts` and imported here so the
28
+ * dispatcher and the validator can never diverge. Manifest hash
29
+ * re-derivation stays a separate step (`dispatch_r2_reviewer --verify`).
30
+ *
31
+ * Settings escape hatch: `planning.completion_review: false` in the repo's
32
+ * `.agent-settings.yml` skips the gate (note + `scanned: 0` + exit 0), exactly
33
+ * as `planning.risk_review: false` skips Gate R1.
34
+ *
35
+ * Exit codes (contract §6): 0 = pass, 1 = policy violation (including a DEAD
36
+ * SCAN SCOPE — a gate that read nothing has not passed), 2 = internal error
37
+ * (missing base ref, git failure — the CALLER applies degraded advisory
38
+ * mode). With `--advisory` (Stage-A window, §2) violations are reported as
39
+ * warnings and the exit is ALWAYS 0.
40
+ */
41
+
42
+ import { execFileSync } from 'node:child_process';
43
+ import * as fs from 'node:fs';
44
+ import * as path from 'node:path';
45
+ import { fileURLToPath, pathToFileURL } from 'node:url';
46
+
47
+ import { gitEnv } from './_lib/git_env.js';
48
+ import { splitMarkdownRow } from './_lib/md_table.js';
49
+ import { completionReviewDisabled } from './_lib/planning_settings.js';
50
+ import { DeadScopeError, assertScanned } from './_lib/scan_scope.js';
51
+ import {
52
+ computeReviewScope,
53
+ deriveSlug,
54
+ parseManifest,
55
+ reviewScopeNameOnlyArgs,
56
+ sanitizeSlug,
57
+ type ParsedManifest,
58
+ } from './dispatch_r2_reviewer.js';
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Types
62
+ // ---------------------------------------------------------------------------
63
+
64
+ export interface Violation {
65
+ kind: string;
66
+ file: string | null;
67
+ detail: string;
68
+ }
69
+
70
+ export interface Marker {
71
+ reviewed: string;
72
+ /** The review-scope hash the review is bound to (§2.1) — 64 hex chars. */
73
+ scope: string;
74
+ /** Branch-head sha at review time. Provenance only, NEVER compared. */
75
+ diffSha: string;
76
+ reviewer: string;
77
+ }
78
+
79
+ export interface HonestNull {
80
+ scope: string;
81
+ reviewed: string;
82
+ }
83
+
84
+ export interface SkipDeclaration {
85
+ reason: string;
86
+ scope: string; // 64-hex scope hash or the literal 'none' (empty scope only)
87
+ declared: string;
88
+ }
89
+
90
+ export interface FindingRow {
91
+ index: string;
92
+ severity: string;
93
+ fileLine: string;
94
+ finding: string;
95
+ status: string;
96
+ reasonRef: string;
97
+ line: number;
98
+ }
99
+
100
+ export interface ParsedArtifact {
101
+ marker: Marker | null;
102
+ /** A `completion-review:` comment exists but does not match the §2.1 grammar. */
103
+ markerMalformed: boolean;
104
+ honestNull: HonestNull | null;
105
+ skip: SkipDeclaration | null;
106
+ rows: FindingRow[];
107
+ /** Lines that start like honest-null / skip but miss the exact grammar. */
108
+ malformedLines: string[];
109
+ /**
110
+ * Table-shaped lines that do not parse into the six §2.2 cells.
111
+ *
112
+ * Kept separate from {@link malformedLines} so the violation can be reported
113
+ * as `malformed-row`, matching Gate R1's `malformed_row` naming for the same
114
+ * defect class.
115
+ */
116
+ malformedRows: string[];
117
+ /** The §5 context manifest, when present and parseable. */
118
+ manifest: ParsedManifest | null;
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Pure grammar functions (exported for unit tests)
123
+ // ---------------------------------------------------------------------------
124
+
125
+ // Contract §2.4. Deliberately broad: the suite installs into consumer repos of
126
+ // every stack, and a stack whose extension is missing here would classify a
127
+ // code-bearing completion as "no code surface" and accept a skip declaration.
128
+ const CODE_EXTENSIONS = new Set([
129
+ // JS / TS
130
+ 'ts',
131
+ 'tsx',
132
+ 'js',
133
+ 'jsx',
134
+ 'mjs',
135
+ 'cjs',
136
+ 'mts',
137
+ 'cts',
138
+ 'vue',
139
+ 'svelte',
140
+ // Python / Ruby / PHP / Perl
141
+ 'py',
142
+ 'pyi',
143
+ 'rb',
144
+ 'rake',
145
+ 'php',
146
+ 'pl',
147
+ 'pm',
148
+ // JVM / .NET
149
+ 'java',
150
+ 'kt',
151
+ 'kts',
152
+ 'scala',
153
+ 'groovy',
154
+ 'cs',
155
+ 'fs',
156
+ // Native
157
+ 'c',
158
+ 'h',
159
+ 'cc',
160
+ 'cpp',
161
+ 'cxx',
162
+ 'hpp',
163
+ 'hh',
164
+ 'm',
165
+ 'mm',
166
+ 'swift',
167
+ 'go',
168
+ 'rs',
169
+ 'zig',
170
+ // Shell / scripting
171
+ 'sh',
172
+ 'bash',
173
+ 'zsh',
174
+ 'fish',
175
+ 'ps1',
176
+ 'lua',
177
+ // Data / templates that carry executable behaviour
178
+ 'sql',
179
+ 'ex',
180
+ 'exs',
181
+ 'erl',
182
+ 'dart',
183
+ 'r',
184
+ ]);
185
+
186
+ /**
187
+ * Template extensions whose *inner* extension decides. `foo.blade.php` is
188
+ * already caught by `php`; `foo.html.twig` / `foo.j2` are not, so they are
189
+ * listed as whole suffixes.
190
+ */
191
+ const CODE_SUFFIXES = ['.blade.php', '.html.twig', '.twig', '.j2', '.erb', '.hbs'];
192
+
193
+ export const SEVERITY_RANK: Readonly<Record<string, number>> = {
194
+ critical: 4,
195
+ high: 3,
196
+ medium: 2,
197
+ low: 1,
198
+ };
199
+
200
+ const VALID_STATUSES = new Set(['open', 'fixed', 'accepted-risk', 'deferred']);
201
+
202
+ /**
203
+ * Contract §2.4 code-path classification. `agents/**` never counts as code
204
+ * (the artifact itself lives there); `src/scripts/**` always does; everything
205
+ * else is decided by extension.
206
+ */
207
+ export function isCodePath(p: string): boolean {
208
+ const norm = p.replace(/\\/g, '/').replace(/^\.\//, '');
209
+ if (norm === 'agents' || norm.startsWith('agents/')) {
210
+ return false;
211
+ }
212
+ if (norm.startsWith('src/scripts/')) {
213
+ return true;
214
+ }
215
+ const base = norm.slice(norm.lastIndexOf('/') + 1).toLowerCase();
216
+ if (CODE_SUFFIXES.some((s) => base.endsWith(s))) {
217
+ return true;
218
+ }
219
+ const dot = base.lastIndexOf('.');
220
+ if (dot <= 0) {
221
+ return false;
222
+ }
223
+ return CODE_EXTENSIONS.has(base.slice(dot + 1));
224
+ }
225
+
226
+ const MARKER_RE =
227
+ /^<!--\s*completion-review:\s*v1\s*\|\s*reviewed:\s*(\d{4}-\d{2}-\d{2})\s*\|\s*scope:\s*([0-9a-fA-F]{64})\s*\|\s*diff:\s*([0-9a-fA-F]{7,40})\s*\|\s*reviewer:\s*(\S(?:[^|>]*[^|>\s])?)\s*-->$/;
228
+
229
+ export function parseMarkerLine(line: string): Marker | null {
230
+ const m = MARKER_RE.exec(line.trim());
231
+ if (!m) {
232
+ return null;
233
+ }
234
+ return {
235
+ reviewed: m[1] as string,
236
+ scope: (m[2] as string).toLowerCase(),
237
+ diffSha: (m[3] as string).toLowerCase(),
238
+ reviewer: (m[4] as string).trim(),
239
+ };
240
+ }
241
+
242
+ const HONEST_NULL_RE = /^\*\*Honest-null:\*\* 0 findings, scope ([0-9a-fA-F]{64}), reviewed (\d{4}-\d{2}-\d{2})$/;
243
+
244
+ export function parseHonestNull(line: string): HonestNull | null {
245
+ const m = HONEST_NULL_RE.exec(line.trim());
246
+ if (!m) {
247
+ return null;
248
+ }
249
+ return { scope: (m[1] as string).toLowerCase(), reviewed: m[2] as string };
250
+ }
251
+
252
+ const SKIP_RE =
253
+ /^\*\*Skipped:\*\* no code surface for this completion — (.+), scope ([0-9a-fA-F]{64}|none), declared (\d{4}-\d{2}-\d{2})$/;
254
+
255
+ export function parseSkipDeclaration(line: string): SkipDeclaration | null {
256
+ const m = SKIP_RE.exec(line.trim());
257
+ if (!m) {
258
+ return null;
259
+ }
260
+ const reason = (m[1] as string).trim();
261
+ if (reason === '') {
262
+ return null;
263
+ }
264
+ return { reason, scope: (m[2] as string).toLowerCase(), declared: m[3] as string };
265
+ }
266
+
267
+ /** First 7-40 char hex token in a Reason/Ref cell — the commit-ish, if any. */
268
+ export function extractFixRef(reasonRef: string): string | null {
269
+ const m = /\b[0-9a-f]{7,40}\b/i.exec(reasonRef);
270
+ return m ? (m[0] as string).toLowerCase() : null;
271
+ }
272
+
273
+ /**
274
+ * Does a leftover artifact in the reviews directory belong to THIS branch?
275
+ *
276
+ * Only own artifacts may produce violations. Without this, one malformed
277
+ * legacy artifact in the tracked reviews directory would block every
278
+ * subsequent PR (directory-wide poisoning). Containment is allowed in both
279
+ * directions so a roadmap-slug artifact (`road-to-x.findings.md`) still
280
+ * counts as own on branch `feat/road-to-x`; a 4-char floor keeps a stub like
281
+ * `feat` from matching everything.
282
+ */
283
+ export function isOwnArtifactSlug(artifactSlug: string, branchSlug: string): boolean {
284
+ const a = artifactSlug.toLowerCase();
285
+ const b = branchSlug.toLowerCase();
286
+ if (a === '' || b === '') {
287
+ return false;
288
+ }
289
+ if (a === b) {
290
+ return true;
291
+ }
292
+ const shorter = a.length <= b.length ? a : b;
293
+ if (shorter.length < 4) {
294
+ return false;
295
+ }
296
+ return a.includes(b) || b.includes(a);
297
+ }
298
+
299
+ function splitTableRow(line: string): string[] {
300
+ return splitMarkdownRow(line);
301
+ }
302
+
303
+ const FENCE_RE = /^\s*```/;
304
+
305
+ export function parseArtifact(text: string): ParsedArtifact {
306
+ const out: ParsedArtifact = {
307
+ marker: null,
308
+ markerMalformed: false,
309
+ honestNull: null,
310
+ skip: null,
311
+ rows: [],
312
+ malformedLines: [],
313
+ malformedRows: [],
314
+ manifest: parseManifest(text),
315
+ };
316
+ const lines = text.split(/\r?\n/);
317
+ let inFence = false;
318
+ for (let i = 0; i < lines.length; i++) {
319
+ const raw = lines[i] as string;
320
+ const lineno = i + 1;
321
+ if (FENCE_RE.test(raw)) {
322
+ inFence = !inFence;
323
+ continue;
324
+ }
325
+ if (inFence) {
326
+ continue;
327
+ }
328
+ const trimmed = raw.trim();
329
+ // Marker candidates must LOOK like the §2.1 HTML comment: prose that
330
+ // merely mentions the grammar (`the \`completion-review:\` marker …`,
331
+ // or a reviewer note quoting it in backticks — real artefacts in this
332
+ // repo contain exactly that) is not a candidate at all. And a candidate
333
+ // that fails to parse is only malformed until a valid marker turns up
334
+ // later: without that, one quoted grammar line above the header
335
+ // permanently poisoned the artefact with a spurious `bad-marker`.
336
+ if (out.marker === null && trimmed.startsWith('<!--') && trimmed.includes('completion-review:')) {
337
+ const marker = parseMarkerLine(trimmed);
338
+ if (marker) {
339
+ out.marker = marker;
340
+ out.markerMalformed = false;
341
+ } else {
342
+ out.markerMalformed = true;
343
+ }
344
+ continue;
345
+ }
346
+ if (trimmed.startsWith('**Honest-null:**')) {
347
+ const hn = parseHonestNull(trimmed);
348
+ if (hn) {
349
+ out.honestNull ??= hn;
350
+ } else {
351
+ out.malformedLines.push(`line ${lineno}: honest-null line does not match the exact §2.3 grammar`);
352
+ }
353
+ continue;
354
+ }
355
+ if (trimmed.startsWith('**Skipped:**')) {
356
+ const skip = parseSkipDeclaration(trimmed);
357
+ if (skip) {
358
+ out.skip ??= skip;
359
+ } else {
360
+ out.malformedLines.push(`line ${lineno}: skip declaration does not match the exact §2.4 grammar`);
361
+ }
362
+ continue;
363
+ }
364
+ if (trimmed.startsWith('|')) {
365
+ const cells = splitTableRow(trimmed);
366
+ if (cells.every((c) => /^[-: ]*$/.test(c))) {
367
+ continue; // separator row
368
+ }
369
+ const first = (cells[0] ?? '').toLowerCase();
370
+ const second = (cells[1] ?? '').toLowerCase();
371
+ if (first === '#' || second === 'severity') {
372
+ continue; // header row
373
+ }
374
+ if (cells.length >= 6) {
375
+ out.rows.push({
376
+ index: cells[0] ?? '',
377
+ severity: (cells[1] ?? '').toLowerCase(),
378
+ fileLine: cells[2] ?? '',
379
+ finding: cells[3] ?? '',
380
+ status: (cells[4] ?? '').toLowerCase(),
381
+ reasonRef: cells[5] ?? '',
382
+ line: lineno,
383
+ });
384
+ } else {
385
+ // A short row is NOT "not a findings row": dropping it silently
386
+ // let an `open` finding pass whenever one well-formed row kept
387
+ // `rows.length > 0` (so the neither-table-nor-honest-null
388
+ // fallback stayed quiet too). Omitting the trailing empty
389
+ // `Reason/Ref` cell is the likeliest authoring slip, since the
390
+ // §2.2 template ends in exactly that empty cell. Gate R1 already
391
+ // reports this class — same name, `malformed_row`.
392
+ out.malformedRows.push(
393
+ `line ${lineno}: findings row has ${String(cells.length)} cell(s), expected 6 ` +
394
+ '(`| # | Severity | File:Line | Finding | Status | Reason/Ref |` — the trailing ' +
395
+ 'Reason/Ref cell is required even when empty)',
396
+ );
397
+ }
398
+ }
399
+ }
400
+ return out;
401
+ }
402
+
403
+ /** Row-level checks: bad-value, severity-order, open/deferred/accepted-risk gates. */
404
+ export function validateFindingRows(rows: readonly FindingRow[]): Array<{ kind: string; detail: string }> {
405
+ const out: Array<{ kind: string; detail: string }> = [];
406
+ let prevRank: number | null = null;
407
+ let orderReported = false;
408
+ for (const row of rows) {
409
+ const rank = SEVERITY_RANK[row.severity];
410
+ if (rank === undefined) {
411
+ out.push({
412
+ kind: 'bad-value',
413
+ detail: `row ${row.index} (line ${row.line}): unknown severity '${row.severity}' — expected critical|high|medium|low`,
414
+ });
415
+ }
416
+ if (!VALID_STATUSES.has(row.status)) {
417
+ out.push({
418
+ kind: 'bad-value',
419
+ detail: `row ${row.index} (line ${row.line}): unknown status '${row.status}' — expected open|fixed|accepted-risk|deferred`,
420
+ });
421
+ }
422
+ if (rank !== undefined) {
423
+ if (prevRank !== null && rank > prevRank && !orderReported) {
424
+ out.push({
425
+ kind: 'severity-order',
426
+ detail:
427
+ `row ${row.index} (line ${row.line}): severity '${row.severity}' outranks an earlier row — ` +
428
+ 'rows must be sorted descending (critical > high > medium > low)',
429
+ });
430
+ orderReported = true;
431
+ }
432
+ prevRank = rank;
433
+ }
434
+ if (row.status === 'open') {
435
+ out.push({
436
+ kind: 'open-finding',
437
+ detail: `row ${row.index} (line ${row.line}): finding is still 'open' — every finding must reach a terminal status`,
438
+ });
439
+ } else if (row.status === 'deferred' && row.reasonRef.trim() === '') {
440
+ out.push({
441
+ kind: 'deferred-without-ref',
442
+ detail: `row ${row.index} (line ${row.line}): 'deferred' without a ticket/issue/roadmap ref in Reason/Ref`,
443
+ });
444
+ } else if (row.status === 'accepted-risk' && row.reasonRef.trim() === '') {
445
+ out.push({
446
+ kind: 'accepted-risk-without-reason',
447
+ detail: `row ${row.index} (line ${row.line}): 'accepted-risk' without a reason in Reason/Ref`,
448
+ });
449
+ }
450
+ }
451
+ return out;
452
+ }
453
+
454
+ // ---------------------------------------------------------------------------
455
+ // Git plumbing
456
+ // ---------------------------------------------------------------------------
457
+
458
+ class InternalError extends Error {}
459
+
460
+ interface GitResult {
461
+ ok: boolean;
462
+ stdout: string;
463
+ status: number;
464
+ }
465
+
466
+ /**
467
+ * The review-scope diff is read through this runner, and Node's default
468
+ * `maxBuffer` is 1 MiB — a branch whose scope diff exceeds that throws ENOBUFS,
469
+ * which surfaces as exit 2 and makes every caller warn-and-allow. Gate R2 would
470
+ * then silently self-disable on exactly the large PRs that most need it. The
471
+ * dispatcher already reads the identical diff with this ceiling; the shared
472
+ * scope definition is only as strong as the weakest injected git runner.
473
+ */
474
+ export const GIT_MAX_BUFFER = 256 * 1024 * 1024;
475
+
476
+ function gitTry(repo: string, args: readonly string[]): GitResult {
477
+ try {
478
+ const stdout = execFileSync('git', args as string[], {
479
+ cwd: repo,
480
+ encoding: 'utf8',
481
+ maxBuffer: GIT_MAX_BUFFER,
482
+ stdio: ['ignore', 'pipe', 'pipe'],
483
+ // cwd decides, never an inherited GIT_DIR (hook environments).
484
+ env: gitEnv(),
485
+ });
486
+ return { ok: true, stdout, status: 0 };
487
+ } catch (exc) {
488
+ const status = (exc as { status?: unknown }).status;
489
+ return { ok: false, stdout: '', status: typeof status === 'number' ? status : -1 };
490
+ }
491
+ }
492
+
493
+ function gitOut(repo: string, args: readonly string[]): string {
494
+ const r = gitTry(repo, args);
495
+ if (!r.ok) {
496
+ throw new InternalError(`git ${args.join(' ')} failed (exit ${r.status}) in ${repo}`);
497
+ }
498
+ return r.stdout;
499
+ }
500
+
501
+ // ---------------------------------------------------------------------------
502
+ // Gate evaluation
503
+ // ---------------------------------------------------------------------------
504
+
505
+ interface ArtifactFile {
506
+ file: string; // absolute path
507
+ art: ParsedArtifact;
508
+ /** `<slug>.findings.md` → `<slug>`, sanitized. */
509
+ slug: string;
510
+ /** This branch's artifact (see {@link isOwnArtifactSlug}). */
511
+ own: boolean;
512
+ }
513
+
514
+ interface EvalInput {
515
+ repo: string;
516
+ head: string;
517
+ /** sha256 of the review-scope diff — what the review binds to (§2.1). */
518
+ scopeHash: string;
519
+ /** The review scope is empty — the only state a `scope none` skip covers. */
520
+ scopeEmpty: boolean;
521
+ changed: readonly string[];
522
+ artifacts: readonly ArtifactFile[];
523
+ artifactDirLabel: string;
524
+ }
525
+
526
+ /** Does this artifact claim the CURRENT review scope? */
527
+ function claimsScope(art: ParsedArtifact, scopeHash: string, scopeEmpty: boolean): boolean {
528
+ if (art.marker !== null && art.marker.scope === scopeHash) {
529
+ return true;
530
+ }
531
+ if (art.honestNull !== null && art.honestNull.scope === scopeHash) {
532
+ return true;
533
+ }
534
+ if (art.skip !== null) {
535
+ // `scope none` is valid ONLY for a genuinely empty review scope.
536
+ // Without that condition one committed `scope none` skip would satisfy
537
+ // the gate for every later diff, forever.
538
+ return art.skip.scope === 'none' ? scopeEmpty : art.skip.scope === scopeHash;
539
+ }
540
+ return false;
541
+ }
542
+
543
+ /**
544
+ * Does the artifact carry a review (header marker, findings table, or
545
+ * honest-null line)? §5 makes a context manifest mandatory for one; a bare §2.4
546
+ * skip declaration carries no reviewer dispatch and needs none.
547
+ */
548
+ function artifactCarriesReview(art: ParsedArtifact): boolean {
549
+ return art.marker !== null || art.rows.length > 0 || art.honestNull !== null;
550
+ }
551
+
552
+ export interface ArtifactRelevance {
553
+ /** Claims the CURRENT review scope — §2.6 relevance. */
554
+ relevant: boolean;
555
+ /** Carries a review, so §5 requires a manifest that can be re-derived. */
556
+ carriesReview: boolean;
557
+ }
558
+
559
+ /**
560
+ * The §2.6 relevance notion, exported so a CI re-derivation step can SELECT the
561
+ * artefacts to verify instead of verifying the whole accumulating directory
562
+ * (`dispatch_r2_reviewer --verify-current`). Restating the notion there is what
563
+ * would silently re-break the gate, so it lives here once.
564
+ */
565
+ export function artifactRelevance(text: string, scopeHash: string, scopeEmpty: boolean): ArtifactRelevance {
566
+ const art = parseArtifact(text);
567
+ return {
568
+ relevant: claimsScope(art, scopeHash, scopeEmpty),
569
+ carriesReview: artifactCarriesReview(art),
570
+ };
571
+ }
572
+
573
+ function evaluate(input: EvalInput): Violation[] {
574
+ const { repo, head, scopeHash, scopeEmpty, changed, artifacts } = input;
575
+ const violations: Violation[] = [];
576
+ const codePaths = changed.filter(isCodePath);
577
+
578
+ const relevant = artifacts.filter(({ art }) => claimsScope(art, scopeHash, scopeEmpty));
579
+ const relevantFiles = new Set(relevant.map(({ file }) => file));
580
+
581
+ // Grammar violations are reported ONLY for artifacts that are this
582
+ // branch's (own slug) or that claim the current scope. A foreign or
583
+ // legacy artifact sitting in the tracked reviews directory must not be
584
+ // able to block an unrelated PR — that is directory-wide poisoning.
585
+ for (const { file, art, own } of artifacts) {
586
+ if (!own && !relevantFiles.has(file)) {
587
+ continue;
588
+ }
589
+ if (art.markerMalformed) {
590
+ violations.push({
591
+ kind: 'bad-marker',
592
+ file,
593
+ detail:
594
+ 'completion-review header marker malformed — expected ' +
595
+ '`<!-- completion-review: v1 | reviewed: YYYY-MM-DD | scope: <64-hex scope hash> | ' +
596
+ 'diff: <sha> | reviewer: <id> -->`',
597
+ });
598
+ }
599
+ for (const detail of art.malformedLines) {
600
+ violations.push({ kind: 'bad-value', file, detail });
601
+ }
602
+ // §2.2: a table-shaped line that does not carry the six cells is a
603
+ // violation, never a row to skip — an invisible row is an unreviewed
604
+ // finding that passes.
605
+ for (const detail of art.malformedRows) {
606
+ violations.push({ kind: 'malformed-row', file, detail });
607
+ }
608
+ }
609
+
610
+ for (const { file, art } of relevant) {
611
+ const carriesReview = artifactCarriesReview(art);
612
+
613
+ // A skip and a findings table cannot both hold. Reported, and the
614
+ // table is still validated — a skip line must never suppress rows.
615
+ if (art.skip !== null && art.rows.length > 0) {
616
+ violations.push({
617
+ kind: 'bad-value',
618
+ file,
619
+ detail:
620
+ 'contradictory artifact: a `**Skipped:**` declaration and a findings table are both ' +
621
+ 'present — a completion is either skip-eligible (no code surface) or reviewed, never both',
622
+ });
623
+ }
624
+
625
+ if (art.skip !== null && codePaths.length > 0) {
626
+ violations.push({
627
+ kind: 'skip-on-code-diff',
628
+ file,
629
+ detail:
630
+ `skip declaration present but the diff touches ${codePaths.length} code path(s) ` +
631
+ `(e.g. ${codePaths.slice(0, 3).join(', ')}) — a code diff requires a findings artifact`,
632
+ });
633
+ }
634
+
635
+ // §2.1: every header field is mandatory. A review-bearing artifact with
636
+ // NO parseable header marker at all is malformed — not "no marker,
637
+ // therefore nothing to check". A marker-SHAPED line that failed to
638
+ // parse is already reported above, so this reports once (§2.6).
639
+ if (carriesReview && art.marker === null && !art.markerMalformed) {
640
+ violations.push({
641
+ kind: 'bad-marker',
642
+ file,
643
+ detail:
644
+ 'findings artifact carries a review but no `completion-review: v1` header marker — the §2.1 ' +
645
+ 'header is mandatory: `<!-- completion-review: v1 | reviewed: YYYY-MM-DD | ' +
646
+ 'scope: <64-hex scope hash> | diff: <sha> | reviewer: <id> -->`',
647
+ });
648
+ }
649
+
650
+ // §2.1: the header `scope:` is the ONLY field staleness is decided on.
651
+ // Relevance ORs header / honest-null / skip, so without this an artifact
652
+ // whose header still points at an older scope, while its honest-null
653
+ // line or manifest carries the current one, was relevant AND stale — and
654
+ // passed both gates.
655
+ if (art.marker !== null && art.marker.scope !== scopeHash) {
656
+ violations.push({
657
+ kind: 'stale-review',
658
+ file,
659
+ detail:
660
+ `header declares scope ${art.marker.scope} but the current review scope is ${scopeHash} — ` +
661
+ 'the header `scope:` is the only field staleness is decided on (contract §2.1)',
662
+ });
663
+ }
664
+
665
+ // §5: the manifest is verification, not self-attestation — an artifact
666
+ // without one is unverifiable, so its absence is a policy violation
667
+ // rather than something to tolerate. A bare skip declaration carries
668
+ // no reviewer dispatch and needs none. When both are present the
669
+ // manifest's `scope_hash` MUST agree with the header's `scope:` (§5);
670
+ // staleness itself is the header check above, so agreement is all that
671
+ // is left to verify here.
672
+ if (carriesReview && art.manifest === null) {
673
+ violations.push({
674
+ kind: 'missing-manifest',
675
+ file,
676
+ detail:
677
+ 'findings artifact carries no parseable `context-manifest: v1` block — the §5 manifest ' +
678
+ 'is mandatory (verification, not self-attestation); re-dispatch via dispatch_r2_reviewer',
679
+ });
680
+ } else if (art.manifest !== null && art.marker !== null && art.manifest.scope_hash !== art.marker.scope) {
681
+ violations.push({
682
+ kind: 'manifest-header-mismatch',
683
+ file,
684
+ detail:
685
+ `manifest records scope_hash ${art.manifest.scope_hash} but the header declares scope ` +
686
+ `${art.marker.scope} — the two must agree (contract §5); re-dispatch via dispatch_r2_reviewer`,
687
+ });
688
+ }
689
+
690
+ if (art.honestNull !== null && art.marker !== null && art.honestNull.scope !== scopeHash) {
691
+ violations.push({
692
+ kind: 'stale-review',
693
+ file,
694
+ detail: `honest-null declares scope ${art.honestNull.scope} but the current review scope is ${scopeHash}`,
695
+ });
696
+ }
697
+
698
+ if (art.rows.length > 0) {
699
+ for (const v of validateFindingRows(art.rows)) {
700
+ violations.push({ ...v, file });
701
+ }
702
+ violations.push(...checkFindingsBeforeFixes(repo, file, art.rows));
703
+ } else if (art.honestNull === null && art.skip === null) {
704
+ violations.push({
705
+ kind: 'bad-value',
706
+ file,
707
+ detail: 'artifact has neither a findings table nor an honest-null / skip declaration line',
708
+ });
709
+ }
710
+ }
711
+
712
+ if (relevant.length === 0) {
713
+ const ownArtifacts = artifacts.filter(({ own }) => own);
714
+ const ownParseable = ownArtifacts.filter(
715
+ ({ art }) => art.marker !== null || art.honestNull !== null || art.skip !== null,
716
+ );
717
+ const anyOwnBadMarker = ownArtifacts.some(({ art }) => art.markerMalformed);
718
+ if (ownParseable.length > 0) {
719
+ const scopes = ownParseable.map(
720
+ ({ art }) => art.marker?.scope ?? art.skip?.scope ?? art.honestNull?.scope ?? '?',
721
+ );
722
+ violations.push({
723
+ kind: 'stale-review',
724
+ file: (ownParseable[0] as ArtifactFile).file,
725
+ detail:
726
+ `no artifact matches the current review scope ${scopeHash} (head ${head}) — found ` +
727
+ `artifact(s) for scope(s): ${scopes.join(', ')}. A change to the reviewed content forces ` +
728
+ 're-review (contract §2.1).',
729
+ });
730
+ } else if (!anyOwnBadMarker) {
731
+ violations.push({
732
+ kind: 'missing-artifact',
733
+ file: null,
734
+ detail:
735
+ `no completion-review artifact for review scope ${scopeHash} (head ${head}) under ` +
736
+ `${input.artifactDirLabel} and no valid skip declaration (diff has ${codePaths.length} ` +
737
+ `code path(s) of ${changed.length} changed file(s))`,
738
+ });
739
+ }
740
+ }
741
+
742
+ return violations;
743
+ }
744
+
745
+ /** Contract §2.5 — the artifact's FIRST-add commit must precede every fix commit. */
746
+ function checkFindingsBeforeFixes(repo: string, artifactAbs: string, rows: readonly FindingRow[]): Violation[] {
747
+ const violations: Violation[] = [];
748
+ const fixedRows = rows.filter((r) => r.status === 'fixed');
749
+ if (fixedRows.length === 0) {
750
+ return violations;
751
+ }
752
+
753
+ // `/`-normalized: a raw Windows separator is not a valid git pathspec, and
754
+ // an empty result here reads as `artifact-not-committed` on a properly
755
+ // committed artifact.
756
+ const rel = path.relative(repo, artifactAbs).split(path.sep).join('/');
757
+ // Earliest add commit: `git log --diff-filter=A` lists newest-first, so the
758
+ // LAST line is the first commit that ever added the file (backdating via a
759
+ // later amend does not move it).
760
+ const logRes = gitTry(repo, ['log', '--diff-filter=A', '--format=%H', '--', rel]);
761
+ const addLines = logRes.stdout
762
+ .split('\n')
763
+ .map((l) => l.trim())
764
+ .filter(Boolean);
765
+ const addSha = addLines.length > 0 ? (addLines[addLines.length - 1] as string) : null;
766
+
767
+ let notCommittedReported = false;
768
+ for (const row of fixedRows) {
769
+ const token = extractFixRef(row.reasonRef);
770
+ if (token === null) {
771
+ violations.push({
772
+ kind: 'unresolvable-fix-ref',
773
+ file: artifactAbs,
774
+ detail: `row ${row.index} (line ${row.line}): 'fixed' without a commit ref in Reason/Ref`,
775
+ });
776
+ continue;
777
+ }
778
+ const resolved = gitTry(repo, ['rev-parse', '--verify', '--quiet', `${token}^{commit}`]);
779
+ if (!resolved.ok) {
780
+ violations.push({
781
+ kind: 'unresolvable-fix-ref',
782
+ file: artifactAbs,
783
+ detail: `row ${row.index} (line ${row.line}): fix ref '${token}' does not resolve to a commit`,
784
+ });
785
+ continue;
786
+ }
787
+ const fixSha = resolved.stdout.trim();
788
+ if (addSha === null) {
789
+ if (!notCommittedReported) {
790
+ violations.push({
791
+ kind: 'artifact-not-committed',
792
+ file: artifactAbs,
793
+ detail:
794
+ 'artifact is untracked/uncommitted but marks findings fixed with commit refs — ' +
795
+ 'commit the findings artifact BEFORE the fix commits (contract §2.5)',
796
+ });
797
+ notCommittedReported = true;
798
+ }
799
+ continue;
800
+ }
801
+ const ancestry = gitTry(repo, ['merge-base', '--is-ancestor', addSha, fixSha]);
802
+ if (ancestry.ok) {
803
+ continue;
804
+ }
805
+ if (ancestry.status === 1) {
806
+ violations.push({
807
+ kind: 'fix-before-artifact',
808
+ file: artifactAbs,
809
+ detail:
810
+ `row ${row.index} (line ${row.line}): fix commit ${fixSha.slice(0, 12)} predates the ` +
811
+ `artifact's first-add commit ${addSha.slice(0, 12)} — findings must be committed before fixes ` +
812
+ '(contract §2.5; the first-add commit is what counts, so backdating is detected)',
813
+ });
814
+ } else {
815
+ throw new InternalError(
816
+ `git merge-base --is-ancestor ${addSha} ${fixSha} failed (exit ${ancestry.status})`,
817
+ );
818
+ }
819
+ }
820
+ return violations;
821
+ }
822
+
823
+ // ---------------------------------------------------------------------------
824
+ // CLI
825
+ // ---------------------------------------------------------------------------
826
+
827
+ interface Args {
828
+ base: string;
829
+ advisory: boolean;
830
+ format: 'text' | 'json';
831
+ quiet: boolean;
832
+ artifactDir: string;
833
+ repo: string;
834
+ }
835
+
836
+ const USAGE =
837
+ 'usage: check_completion_review [-h] [--base REF] [--advisory] [--format {text,json}] ' +
838
+ '[--quiet] [--artifact-dir DIR] [--repo DIR]\n';
839
+
840
+ function parseArgs(argv: readonly string[]): Args {
841
+ const args: Args = {
842
+ base: 'origin/main',
843
+ advisory: false,
844
+ format: 'text',
845
+ quiet: false,
846
+ artifactDir: 'agents/evidence/reviews',
847
+ repo: '.',
848
+ };
849
+ const takeValue = (argvArr: readonly string[], i: number, name: string): string => {
850
+ const v = argvArr[i];
851
+ if (v === undefined) {
852
+ process.stderr.write(`check_completion_review: error: argument ${name}: expected one value\n`);
853
+ process.exit(2);
854
+ }
855
+ return v;
856
+ };
857
+ let i = 0;
858
+ while (i < argv.length) {
859
+ const arg = argv[i] as string;
860
+ if (arg === '--base') {
861
+ args.base = takeValue(argv, ++i, '--base');
862
+ } else if (arg.startsWith('--base=')) {
863
+ args.base = arg.slice('--base='.length);
864
+ } else if (arg === '--advisory') {
865
+ args.advisory = true;
866
+ } else if (arg === '--quiet') {
867
+ args.quiet = true;
868
+ } else if (arg === '--artifact-dir') {
869
+ args.artifactDir = takeValue(argv, ++i, '--artifact-dir');
870
+ } else if (arg.startsWith('--artifact-dir=')) {
871
+ args.artifactDir = arg.slice('--artifact-dir='.length);
872
+ } else if (arg === '--repo') {
873
+ args.repo = takeValue(argv, ++i, '--repo');
874
+ } else if (arg.startsWith('--repo=')) {
875
+ args.repo = arg.slice('--repo='.length);
876
+ } else if (arg === '--format' || arg.startsWith('--format=')) {
877
+ const v = arg === '--format' ? takeValue(argv, ++i, '--format') : arg.slice('--format='.length);
878
+ if (v !== 'text' && v !== 'json') {
879
+ process.stderr.write(
880
+ `check_completion_review: error: argument --format: invalid choice: '${v}' (choose from 'text', 'json')\n`,
881
+ );
882
+ process.exit(2);
883
+ }
884
+ args.format = v;
885
+ } else if (arg === '-h' || arg === '--help') {
886
+ process.stdout.write(USAGE);
887
+ process.exit(0);
888
+ } else {
889
+ process.stderr.write(`check_completion_review: error: unrecognized arguments: ${arg}\n`);
890
+ process.exit(2);
891
+ }
892
+ i++;
893
+ }
894
+ return args;
895
+ }
896
+
897
+ function report(args: Args, violations: readonly Violation[], passNote: string | null): number {
898
+ if (args.format === 'json') {
899
+ process.stdout.write(JSON.stringify(violations, null, 2) + '\n');
900
+ } else if (violations.length === 0) {
901
+ if (!args.quiet) {
902
+ process.stdout.write(passNote ?? '✅ Completion review clean.\n');
903
+ }
904
+ } else {
905
+ const head = args.advisory
906
+ ? `⚠️ ${violations.length} completion-review violation(s) (advisory — not blocking):\n\n`
907
+ : `❌ ${violations.length} completion-review violation(s):\n\n`;
908
+ process.stdout.write(head);
909
+ const prefix = args.advisory ? '⚠️ (advisory) ' : '';
910
+ for (const v of violations) {
911
+ process.stdout.write(` ${prefix}${v.kind}${v.file !== null ? ` — ${v.file}` : ''}\n`);
912
+ process.stdout.write(` │ ${v.detail}\n`);
913
+ }
914
+ }
915
+ if (violations.length === 0) {
916
+ return 0;
917
+ }
918
+ return args.advisory ? 0 : 1;
919
+ }
920
+
921
+ /**
922
+ * `planning.completion_review === false` in `<dir>/.agent-settings.yml` disables
923
+ * Gate R2. Parsing mirrors Gate R1's `riskReviewDisabled` exactly (same file,
924
+ * same fail-open-on-unreadable/unparseable behaviour, same strict `=== false`
925
+ * test) so a missing key or a missing file leaves the gate ACTIVE.
926
+ *
927
+ * Re-exported from `_lib/planning_settings.ts`: the dispatcher's `--verify`
928
+ * layer must honour the same hatch, and it cannot import from this module
929
+ * (this module imports the scope hash from it).
930
+ */
931
+ export { completionReviewDisabled };
932
+
933
+ /** Is `rel` a tracked directory in `ref`? Absolute paths are never tracked. */
934
+ function isTrackedTree(repo: string, ref: string, rel: string): boolean {
935
+ if (path.isAbsolute(rel)) {
936
+ return false;
937
+ }
938
+ return gitTry(repo, ['ls-tree', '-d', '--name-only', ref, '--', rel]).stdout.trim() !== '';
939
+ }
940
+
941
+ /**
942
+ * When is an unresolvable artefact root NOT a dead scope?
943
+ *
944
+ * The dead-scope assertion exists to catch a MOVED root (the ADR-051 class:
945
+ * fourteen gates kept scanning a path a migration had renamed, and reported
946
+ * green). Two states are legitimately empty instead:
947
+ * - the review scope is empty — there is nothing to review at all;
948
+ * - the root is absent AND was never tracked — a repo with no review corpus
949
+ * yet. That state still blocks, via the actionable `missing-artifact`.
950
+ *
951
+ * A root that IS tracked but no longer resolves on disk is the real defect and
952
+ * gets no exemption.
953
+ *
954
+ * @returns the `allowEmpty` option to spread, or `null` to let it throw.
955
+ */
956
+ function deadScopeExemption(
957
+ repo: string,
958
+ args: Args,
959
+ state: { scopeEmpty: boolean; artifactRootResolved: boolean },
960
+ ): { allowEmpty: string } | null {
961
+ if (state.scopeEmpty) {
962
+ return { allowEmpty: 'review scope is empty — no reviewable diff vs base' };
963
+ }
964
+ if (state.artifactRootResolved) {
965
+ return null; // scanned >= 1 anyway; the assertion will not fire
966
+ }
967
+ const tracked =
968
+ isTrackedTree(repo, args.base, args.artifactDir) || isTrackedTree(repo, 'HEAD', args.artifactDir);
969
+ if (tracked) {
970
+ return null; // tracked root that no longer resolves → dead scope
971
+ }
972
+ return {
973
+ allowEmpty: `artefact root '${args.artifactDir}' is absent and untracked — no review corpus in this repo yet`,
974
+ };
975
+ }
976
+
977
+ function run(args: Args): number {
978
+ const repo = path.resolve(args.repo);
979
+
980
+ // Settings escape hatch (contract § Scope): `planning.completion_review:
981
+ // false` disables Gate R2 — documented in the settings template, the Zod
982
+ // schema and the /create-pr surface, so it has to actually work. Mirrors
983
+ // Gate R1's `riskReviewDisabled`, including the explicit `scanned: 0`:
984
+ // the zero-scan is a configured skip, not a dead scope.
985
+ if (completionReviewDisabled(repo)) {
986
+ process.stdout.write('⚠️ planning.completion_review=false — Gate R2 skipped (settings escape hatch)\n');
987
+ process.stdout.write('scanned: 0\n');
988
+ return 0;
989
+ }
990
+
991
+ // The artefact inventory needs no git, so it is resolved FIRST: the
992
+ // gate-coverage guard parses `scanned:` and must get a number on EVERY exit
993
+ // path, including the exit-2 internal-error one below (an unresolvable base
994
+ // ref used to exit 2 emitting no count at all).
995
+ const artifactDirAbs = path.isAbsolute(args.artifactDir) ? args.artifactDir : path.join(repo, args.artifactDir);
996
+ let artifactRootResolved = false;
997
+ let artifactPaths: string[] = [];
998
+ try {
999
+ if (fs.statSync(artifactDirAbs).isDirectory()) {
1000
+ artifactRootResolved = true;
1001
+ artifactPaths = fs
1002
+ .readdirSync(artifactDirAbs)
1003
+ .filter((n) => n.endsWith('.findings.md'))
1004
+ .sort()
1005
+ .map((n) => path.join(artifactDirAbs, n));
1006
+ }
1007
+ } catch {
1008
+ // Root missing → handled by the dead-scope check below, never silently.
1009
+ }
1010
+
1011
+ // Gate-coverage contract: `scanned:` is emitted BEFORE the pass/fail branch
1012
+ // (coverage and verdict are different questions). N counts the artefacts
1013
+ // inspected plus 1 for the diff evaluation — and that +1 is counted ONLY
1014
+ // when the artefact root actually resolves, so a moved/renamed root drops
1015
+ // N to 0 instead of hiding behind a floor that cannot fail.
1016
+ const scanned = artifactPaths.length + (artifactRootResolved ? 1 : 0);
1017
+ process.stdout.write(`scanned: ${scanned}\n`);
1018
+
1019
+ const baseCheck = gitTry(repo, ['rev-parse', '--verify', '--quiet', `${args.base}^{commit}`]);
1020
+ if (!baseCheck.ok) {
1021
+ throw new InternalError(`base ref '${args.base}' not found in ${repo}`);
1022
+ }
1023
+ const head = gitOut(repo, ['rev-parse', 'HEAD']).trim().toLowerCase();
1024
+ // The REVIEW SCOPE excludes the review artefacts themselves, so committing
1025
+ // the findings artifact (§2.5) cannot invalidate the review it records.
1026
+ const scope = computeReviewScope((a) => gitOut(repo, a), args.base);
1027
+ const scopeHash = scope.hash;
1028
+ const scopeEmpty = scope.empty;
1029
+ const changed = gitOut(repo, reviewScopeNameOnlyArgs(args.base))
1030
+ .split('\n')
1031
+ .map((l) => l.trim())
1032
+ .filter(Boolean);
1033
+ const branchSlug = deriveSlug((a) => gitOut(repo, a));
1034
+
1035
+ try {
1036
+ assertScanned({
1037
+ gate: 'check_completion_review',
1038
+ scanned,
1039
+ units: 'review artefact(s) + diff evaluation',
1040
+ roots: [args.artifactDir],
1041
+ ...(deadScopeExemption(repo, args, { scopeEmpty, artifactRootResolved }) ?? {}),
1042
+ });
1043
+ } catch (exc) {
1044
+ if (!(exc instanceof DeadScopeError)) {
1045
+ throw exc;
1046
+ }
1047
+ // Contract §6 carve-out: a dead scan scope is a POLICY violation, not
1048
+ // an internal error. Mapping it to exit 2 would make every caller
1049
+ // warn-and-allow, silently degrading the gate to advisory the moment
1050
+ // the artefact root moves.
1051
+ return report(
1052
+ args,
1053
+ [
1054
+ {
1055
+ kind: 'dead-scan-scope',
1056
+ file: null,
1057
+ detail:
1058
+ `${exc.message} (artefact root '${args.artifactDir}' does not resolve under ${repo}; ` +
1059
+ 'create it or repoint --artifact-dir — a gate that read nothing has not passed)',
1060
+ },
1061
+ ],
1062
+ null,
1063
+ );
1064
+ }
1065
+
1066
+ if (scopeEmpty) {
1067
+ return report(args, [], `✅ No reviewable changes vs ${args.base} — nothing to review.\n`);
1068
+ }
1069
+
1070
+ const artifacts: ArtifactFile[] = artifactPaths.map((file) => {
1071
+ let text: string;
1072
+ try {
1073
+ text = fs.readFileSync(file, 'utf-8');
1074
+ } catch (exc) {
1075
+ throw new InternalError(`unreadable artifact ${file}: ${exc instanceof Error ? exc.message : String(exc)}`);
1076
+ }
1077
+ const slug = sanitizeSlug(path.basename(file).replace(/\.findings\.md$/, ''));
1078
+ return { file, art: parseArtifact(text), slug, own: isOwnArtifactSlug(slug, branchSlug) };
1079
+ });
1080
+
1081
+ const violations = evaluate({
1082
+ repo,
1083
+ head,
1084
+ scopeHash,
1085
+ scopeEmpty,
1086
+ changed,
1087
+ artifacts,
1088
+ artifactDirLabel: args.artifactDir,
1089
+ });
1090
+ if (violations.length === 0) {
1091
+ return report(
1092
+ args,
1093
+ violations,
1094
+ `✅ Completion review clean (${artifactPaths.length} artefact(s), ${changed.length} changed file(s) vs ${args.base}).\n`,
1095
+ );
1096
+ }
1097
+ return report(args, violations, null);
1098
+ }
1099
+
1100
+ export function main(argv?: readonly string[]): number {
1101
+ const args = parseArgs(argv ?? process.argv.slice(2));
1102
+ try {
1103
+ return run(args);
1104
+ } catch (exc) {
1105
+ const msg = exc instanceof Error ? exc.message : String(exc);
1106
+ process.stderr.write(`❌ Internal error: ${msg}\n`);
1107
+ return 2;
1108
+ }
1109
+ }
1110
+
1111
+ const _HERE = fileURLToPath(import.meta.url);
1112
+ function _isCliEntry(): boolean {
1113
+ if (process.argv[1] === undefined) {
1114
+ return false;
1115
+ }
1116
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
1117
+ if (import.meta.url === argvUrl) {
1118
+ return true;
1119
+ }
1120
+ // A symlinked invocation (e.g. via an installed `.augment/` projection,
1121
+ // or macOS /var → /private/var temp dirs) makes the raw URLs differ:
1122
+ // compare realpaths so the entry guard still fires.
1123
+ try {
1124
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
1125
+ const argv1 = fs.realpathSync(path.resolve(process.argv[1]));
1126
+ return here === argv1;
1127
+ } catch {
1128
+ return false;
1129
+ }
1130
+ }
1131
+
1132
+ if (_isCliEntry() || process.argv[1] === _HERE) {
1133
+ try {
1134
+ process.exit(main());
1135
+ } catch (exc) {
1136
+ const msg = exc instanceof Error ? exc.message : String(exc);
1137
+ process.stderr.write(`❌ Internal error: ${msg}\n`);
1138
+ process.exit(2);
1139
+ }
1140
+ }