@event4u/agent-config 9.17.0 → 9.18.1

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