neo-agent-skills 0.1.72 → 0.1.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -166,6 +166,27 @@ Consumer repositories call the stable `Secrets` job in `.github/workflows/reusab
166
166
  scans **every tracked file** at the pull request's head, not only the diff: a credential already on
167
167
  the base branch still fails, because it is one to revoke whichever pull request finds it.
168
168
 
169
+ ## Shared PR-review check
170
+
171
+ ```bash
172
+ npx --no-install neo-agent-skills-pr-review-body --envelope review.json --json --comment-file comment.md
173
+ ```
174
+
175
+ ```js
176
+ import {judgeReview} from 'neo-agent-skills/pr-review-policy';
177
+ ```
178
+
179
+ Brain's `manage_pr_review` refuses a malformed agent review before it posts. A review submitted with
180
+ `gh pr review` or in the UI skips that gate, so consumer repositories call
181
+ `.github/workflows/reusable-pr-review.yml` on `pull_request_review` and get the same policy afterwards: a
182
+ red `Review body` check and one corrective comment per failing review, edited in place when the review
183
+ is. The check is informative, not required: it runs once per review, so a new head has no result until
184
+ the next one.
185
+
186
+ The policy ships beside the templates it enforces, so one release carries both. A diagnostic names at
187
+ most one missing anchor and never a structural one. The bin exits 0 on a pass or on a review that is
188
+ not an agent's, 1 on a policy failure, and 2 on a malformed envelope.
189
+
169
190
  ## Generated `AGENTS.md`
170
191
 
171
192
  ```bash
@@ -197,6 +218,7 @@ import writes nothing: Fleet puts the text into each seat's harness home with it
197
218
  | `scripts/check-ticket-archaeology.mjs` | the portable comment/JSDoc archaeology guard |
198
219
  | `scripts/check-npm-overrides.mjs` | the portable stale-`overrides` guard |
199
220
  | `scripts/check-substrate-size.mjs` | the portable per-turn substrate byte-budget guard |
221
+ | `scripts/check-pr-review-body.mjs` | the agent PR-review policy (`./pr-review-policy`) and its bin |
200
222
 
201
223
  The manifest governs **projection**: a skill declaring `claudeSymlinkRequired: false` is a declared
202
224
  opt-out and is correctly absent from the façade. Per-skill links rather than one directory link,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neo-agent-skills",
3
- "version": "0.1.72",
3
+ "version": "0.1.73",
4
4
  "description": "Install the workflow substrate a cross-model AI team actually runs on: ticket intake, PR review gates, lane coordination and guards, versioned as a package. The skills Neo.mjs maintainers load every session — materialized into your repo, not copied.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,6 +22,7 @@
22
22
  "bin": {
23
23
  "neo-agent-skills-materialize": "./scripts/materialize-harness-skills.mjs",
24
24
  "neo-agent-skills-pr-body": "./scripts/check-pr-body.mjs",
25
+ "neo-agent-skills-pr-review-body": "./scripts/check-pr-review-body.mjs",
25
26
  "neo-agent-skills-ticket-body": "./scripts/check-ticket-body.mjs",
26
27
  "neo-agent-skills-substrate-size": "./scripts/check-substrate-size.mjs",
27
28
  "neo-agent-skills-ticket-archaeology": "./scripts/check-ticket-archaeology.mjs",
@@ -35,6 +36,7 @@
35
36
  "exports": {
36
37
  "./agents-md": "./scripts/generate-agents-md.mjs",
37
38
  "./manifest": "./.agents/skills/skills.manifest.json",
39
+ "./pr-review-policy": "./scripts/check-pr-review-body.mjs",
38
40
  "./package.json": "./package.json"
39
41
  },
40
42
  "files": [
@@ -45,6 +47,7 @@
45
47
  "scripts/check-discussion-fidelity.mjs",
46
48
  "scripts/check-npm-overrides.mjs",
47
49
  "scripts/check-pr-body.mjs",
50
+ "scripts/check-pr-review-body.mjs",
48
51
  "scripts/check-secrets.mjs",
49
52
  "scripts/check-substrate-size.mjs",
50
53
  "scripts/check-ticket-archaeology.mjs",
@@ -56,7 +59,7 @@
56
59
  "scripts": {
57
60
  "materialize": "node scripts/materialize-harness-skills.mjs",
58
61
  "lint": "node scripts/lint-skill-corpus.mjs",
59
- "test": "node scripts/test-lint-skill-corpus.mjs && node scripts/test-reusable-pr-baseline.mjs && node scripts/test-reusable-dependabot-automerge.mjs && node scripts/test-ticket-archaeology.mjs && node scripts/test-substrate-size.mjs && node scripts/test-check-pr-body.mjs && node scripts/test-check-ticket-body.mjs && node scripts/test-workflow-concurrency.mjs && node scripts/test-generate-agents-md.mjs && node scripts/test-materialize-harness-skills.mjs && node scripts/test-npm-overrides.mjs && node scripts/test-secrets.mjs && node scripts/test-commit-authorship.mjs && node scripts/test-check-discussion-fidelity.mjs && node scripts/test-tag-release.mjs && node scripts/test-check-version-bump.mjs && node scripts/test-release-origin.mjs",
62
+ "test": "node scripts/test-lint-skill-corpus.mjs && node scripts/test-reusable-pr-baseline.mjs && node scripts/test-reusable-dependabot-automerge.mjs && node scripts/test-ticket-archaeology.mjs && node scripts/test-substrate-size.mjs && node scripts/test-check-pr-body.mjs && node scripts/test-check-pr-review-body.mjs && node scripts/test-reusable-pr-review.mjs && node scripts/test-check-ticket-body.mjs && node scripts/test-workflow-concurrency.mjs && node scripts/test-generate-agents-md.mjs && node scripts/test-materialize-harness-skills.mjs && node scripts/test-npm-overrides.mjs && node scripts/test-secrets.mjs && node scripts/test-commit-authorship.mjs && node scripts/test-check-discussion-fidelity.mjs && node scripts/test-tag-release.mjs && node scripts/test-check-version-bump.mjs && node scripts/test-release-origin.mjs",
60
63
  "generate-agents-md": "node scripts/generate-agents-md.mjs",
61
64
  "postpublish": "node scripts/tag-release.mjs"
62
65
  },
@@ -0,0 +1,529 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @summary Judges one submitted agent pull-request review against the pr-review templates this package ships.
4
+ *
5
+ * The decision half only, like `check-pr-body.mjs`: it receives a normalized review envelope and returns a
6
+ * verdict. It never reads GitHub and never posts. The reusable `pull_request_review` workflow acquires the
7
+ * event, makes the bounded reads, and owns the one corrective comment.
8
+ *
9
+ * **Ownership.** This package owns the rules a review body alone decides. Brain's managed gate
10
+ * (`manage_pr_review`) applies them before submission and keeps every decision that needs pull-request history:
11
+ * the Round-2 relation to the cited review, acknowledgement of an outstanding Request Changes, and follow-up
12
+ * ownership against the close targets. This module judges a review after submission, which is the path
13
+ * `gh pr review` and the UI take around that gate.
14
+ *
15
+ * **The anchors are declared here and bound to the templates by the contract suite.** Each anchor is asserted
16
+ * to occur in the template it names, so a template edit that drops or renames one fails the release that ships
17
+ * it. Reading the templates at runtime instead would need a fallback for a missing file, and a fallback is a
18
+ * second, silent copy of the contract.
19
+ *
20
+ * **Nothing from the review body reaches a diagnostic.** A finding is always one of this file's own strings,
21
+ * so the corrective comment cannot be made to say what a reviewer wrote.
22
+ */
23
+
24
+ import {readFileSync, realpathSync, writeFileSync} from 'node:fs';
25
+ import process from 'node:process';
26
+ import {parseArgs} from 'node:util';
27
+ import {fileURLToPath} from 'node:url';
28
+
29
+ /**
30
+ * Package-relative template paths, keyed by the review shape that selects them.
31
+ * @member {Object<String, String>} REVIEW_TEMPLATES
32
+ */
33
+ export const REVIEW_TEMPLATES = Object.freeze({
34
+ 'canonical' : '.agents/skills/pr-review/assets/pr-review-template.md',
35
+ 'follow-up' : '.agents/skills/pr-review/assets/pr-review-followup-template.md',
36
+ 'micro' : '.agents/skills/pr-review/assets/pr-review-micro-review-template.md',
37
+ 'micro-delta': '.agents/skills/pr-review/assets/pr-review-micro-delta-template.md',
38
+ 'round-2' : '.agents/skills/pr-review/assets/pr-review-round-2-template.md'
39
+ });
40
+
41
+ /**
42
+ * The seven evaluation-metric tags of the full and follow-up templates. Named in a diagnostic when missing.
43
+ * @member {String[]} VISIBLE_METRIC_ANCHORS
44
+ */
45
+ export const VISIBLE_METRIC_ANCHORS = Object.freeze([
46
+ '[ARCH_ALIGNMENT]', '[CONTENT_COMPLETENESS]', '[EXECUTION_QUALITY]', '[PRODUCTIVITY]', '[IMPACT]', '[COMPLEXITY]', '[EFFORT_PROFILE]'
47
+ ]);
48
+
49
+ /**
50
+ * Structural substrings checked and never named. A body rebuilt from a diagnostic carries what the diagnostic
51
+ * named and omits these, so the check still refuses it; that holds only while no message ever names them.
52
+ * @member {String[]} INVISIBLE_STRUCTURE_ANCHORS
53
+ */
54
+ export const INVISIBLE_STRUCTURE_ANCHORS = Object.freeze(['Depth Floor', 'Required Actions', 'Strategic-Fit Decision']);
55
+
56
+ /**
57
+ * The four premise-snapshot fields every full and follow-up review carries, as their bold template labels.
58
+ * @member {Array<{label: String, token: String}>} PREMISE_ANCHORS
59
+ */
60
+ export const PREMISE_ANCHORS = Object.freeze([
61
+ {label: 'Inputs Read Before Patch', token: '**Inputs Read Before Patch:**'},
62
+ {label: 'Expected Solution Shape', token: '**Expected Solution Shape:**'},
63
+ {label: 'Patch Verdict', token: '**Patch Verdict:**'},
64
+ {label: 'Premise Coherence', token: '**Premise Coherence:**'}
65
+ ]);
66
+
67
+ /**
68
+ * Each shape's skeleton: the headings and fixed lines its template prints verbatim. Checked silently, like the
69
+ * invisible anchors, because a skeleton list in a message is a template to copy without reading one.
70
+ * @member {Object<String, String[]>} SKELETON_ANCHORS
71
+ */
72
+ export const SKELETON_ANCHORS = Object.freeze({
73
+ 'canonical': [
74
+ '# PR Review Summary', '### 🪜 Strategic-Fit Decision', '### 🧭 Patch-Blind Premise Snapshot',
75
+ '### 🕸️ Context & Graph Linking', '### 🔬 Depth Floor', '### 🧠 Graph Ingestion Notes',
76
+ '### 📋 Required Actions', '### 📊 Evaluation Metrics'
77
+ ],
78
+ 'follow-up': [
79
+ '# PR Review Follow-Up — exceptional verdicts only', '### 🧭 Patch-Blind Premise Snapshot',
80
+ '### 🪜 Strategic-Fit Decision', '### ⚓ Prior Review Anchor', '### 🔁 Delta Scope',
81
+ '### ✅ Previous Required Actions Audit', '### 🔬 Delta Depth Floor', '### 🔬 Premise Falsifiers',
82
+ '### 📊 Metrics Delta', '### 📋 Required Actions'
83
+ ],
84
+ 'micro': ['# PR Micro-Review', '**Class:**', '**Verdict:**', '**Glance:**', '- **Origin Session ID:**'],
85
+ 'micro-delta': [
86
+ '# Pull Request Micro-Delta Review', '> **Context:**', '### State Vector', '- **Target SHA:**',
87
+ '- **Origin Session ID:**', '- **Current reviewDecision:**', '- **Semantic Status:**', '- **CI Status:**',
88
+ '- **Remaining Blocker Class:**', '- **Measured Discussion Cost:**', '### Micro-Delta Focus',
89
+ '*Only defects classified as `mechanical-hygiene` or `metadata-drift` are reviewed here.*', '### Verdict',
90
+ '**APPROVED**', '**COMMENTED CLOSURE**', '**MAINTAINER POLISH FAST PATH APPLIED**'
91
+ ],
92
+ 'round-2': ['# PR Review — Round 2 (disposition only)', '### ⚓ Anchor', '### 📋 Disposition', '### 🔚 Verdict']
93
+ });
94
+
95
+ /**
96
+ * Body markers that select a follow-up (exceptional verdict) review instead of a full one.
97
+ * @member {String[]} FOLLOW_UP_HINTS
98
+ */
99
+ export const FOLLOW_UP_HINTS = Object.freeze([
100
+ '# PR Review Follow-Up', '**Cycle:**', '### ⚓ Prior Review Anchor', '### 🔁 Delta Scope', '### Prior Review Anchor',
101
+ '### Delta Scope', '### ✅ Previous Required Actions Audit', '### Previous Required Actions Audit',
102
+ '### 🔬 Delta Depth Floor', '### Delta Depth Floor', '### 📊 Metrics Delta', '### Metrics Delta'
103
+ ]);
104
+
105
+ /**
106
+ * Review states as the REST API reports them for a submitted review.
107
+ * @member {String[]} REVIEW_STATES
108
+ */
109
+ export const REVIEW_STATES = Object.freeze(['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED']);
110
+
111
+ /**
112
+ * What each failure code asks of the reviewer. The CLI and the corrective comment print the same line.
113
+ * @member {Object<String, String>} REMEDIES
114
+ */
115
+ export const REMEDIES = Object.freeze({
116
+ BUDGET_ACTIVATION_UNPROVABLE: 'The review-budget activation history could not be read completely, so this review\'s cohort is unknown.',
117
+ BUDGET_COHORT_UNKNOWN : 'The pull request has no valid creation time, so its review-budget cohort is unknown.',
118
+ BUDGET_OVERRIDE_UNMANAGED : '`[review-budget-override]` is valid only on a review the managed path submitted.',
119
+ BUDGET_PROVENANCE_MISSING : 'A Request Changes after the review-budget activation goes through `manage_pr_review`, or discloses the bypass on a `[review-budget-bypass] reason: …` line.',
120
+ COMMENT_ACTION_PACKET : 'A COMMENT dispositions existing actions and raises no new ones. A first demand round is a Request Changes.',
121
+ COMMENTED_CLOSURE_STATE : 'A COMMENTED CLOSURE is submitted as a COMMENT; it cannot be another Request Changes.',
122
+ DROP_SUPERSEDE_INCOMPLETE : 'A Drop+Supersede carries its Status and Decision lines, a disposition and every contract field of pr-review §9, on the full or follow-up template.',
123
+ DROP_SUPERSEDE_STATE : 'A terminal Drop+Supersede is submitted as a Request Changes.',
124
+ ROUND_2_STATE_INCOHERENT : 'A Round 2 states one legal `**Status:**` (Approved, Approve+Follow-Up, Comment), and any STILL_OPEN row makes it Comment.',
125
+ TEMPLATE_INCOMPLETE : 'The body does not follow the template its shape selects.'
126
+ });
127
+
128
+ const
129
+ ORIGIN_SESSION = /^\s*[*-]\s+\*\*Origin Session ID:\*\*\s+[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}\s*$/im,
130
+ ROUND_2_H1 = /^#[ \t]+PR Review — Round 2\b/m,
131
+ ROUND_2_STATUS = '**Status:**',
132
+ ROUND_2_LEGAL_STATUSES = ['approved', 'approve+follow-up', 'comment'],
133
+ // Every line end that `^` and `$` honour under the `m` flag, so a line scan reads the lines a regex would
134
+ LINE_BREAK = /\r\n|[\n\r\u2028\u2029]/,
135
+ ROUND_2_VERBS = ['ADDRESSED', 'DEFENDED', 'STILL_OPEN'],
136
+ ROUND_2_ROW = /\|[^|\n]*\b(ADDRESSED|DEFENDED|STILL_OPEN)\b[^|\n]*\|/,
137
+ ROUND_1_REFERENCE = /\*\*Round-1 Review ID:\*\*[ \t]*(?!\[[^\]\n]*\](?!\())[^\s·]/,
138
+ CHECKLIST_ITEM = /^[ \t]*[-*][ \t]+\[[ x]\]/im,
139
+ MICRO_CLASS = /(?:^|[^\w-])(micro|contained|mechanical)(?:$|[^\w-])/i,
140
+ BLOCKER_CLASS = /(?:^|[^\w-])(mechanical-hygiene|metadata-drift)(?:$|[^\w-])/i,
141
+ COMMENTED_CLOSURE = /^\s*-\s*\[[xX]\]\s*\*\*COMMENTED CLOSURE\*\*/m,
142
+ CLOSURE_PACKET_FIELDS = ['Consumer sweep', 'Falsifier/property matrix', 'Carried-vs-new census', 'Truth-fold', 'Semantic-surface freeze'],
143
+ DROP_SUPERSEDE_FIELDS = ['Source-coordinate falsifiers', 'Salvage map', 'Successor landing pad', 'Successor map citation'],
144
+ DROP_SUPERSEDE_KINDS = ['implementation-off', 'ticket-prescription-off', 'ticket-premise-dead'],
145
+ FENCE = /^[ \t]{0,3}(`{3,}|~{3,})/,
146
+ HEADING = /^[ \t]{0,3}#{1,6}[ \t]/,
147
+ ACTION_HEADING = /Required\s+Actions?\b/i,
148
+ ACTION_ITEM = /^[ \t]*[-*][ \t]+\[[ \t]*\][ \t]*\S/,
149
+ ACTION_RA = /^[ \t]*(?:[-*][ \t]+)?\*{0,2}RA[-_ ]?\d+\*{0,2}[ \t]*[:.)—-][ \t]*\S/i,
150
+ BUDGET_MANAGED = '[review-budget-managed]',
151
+ BUDGET_OVERRIDE = '[review-budget-override]',
152
+ BUDGET_BYPASS = /^\[review-budget-bypass\]\s+reason:\s*\S.*$/im,
153
+ // The template a failure points at, when the code decides it rather than the body's shape.
154
+ CODE_TEMPLATE = {COMMENTED_CLOSURE_STATE: 'micro-delta', DROP_SUPERSEDE_INCOMPLETE: 'follow-up', DROP_SUPERSEDE_STATE: 'follow-up', ROUND_2_STATE_INCOHERENT: 'round-2'};
155
+
156
+ /**
157
+ * @summary The body's lines outside fenced code blocks, split on CR/LF only.
158
+ * @param {String} body
159
+ * @returns {String[]}
160
+ */
161
+ function unfencedLines(body) {
162
+ const lines = [];
163
+ let fence = null;
164
+
165
+ for (const line of String(body).split(/\r\n|[\n\r]/)) {
166
+ const opener = FENCE.exec(line);
167
+
168
+ if (opener) {
169
+ if (fence === null) fence = opener[1][0];
170
+ else if (opener[1][0] === fence) fence = null;
171
+ continue
172
+ }
173
+
174
+ fence === null && lines.push(line)
175
+ }
176
+
177
+ return lines
178
+ }
179
+
180
+ /**
181
+ * @summary The Required Actions items a body demands: unchecked boxes and `RA-N` lines under that heading.
182
+ *
183
+ * Scoped to the section, because a Micro-Delta verdict block is a list of unchecked OPTIONS and a body-wide scan
184
+ * would read each as a demand. Blockquoted carried actions fail the item shape, and fenced examples are skipped.
185
+ * @param {String} body
186
+ * @returns {String[]} Demanded item lines, trimmed.
187
+ */
188
+ export function demandedActionItems(body) {
189
+ const items = [];
190
+ let inSection = false;
191
+
192
+ for (const line of unfencedLines(body)) {
193
+ if (HEADING.test(line)) { inSection = ACTION_HEADING.test(line); continue }
194
+
195
+ inSection && (ACTION_ITEM.test(line) || ACTION_RA.test(line)) && items.push(line.trim())
196
+ }
197
+
198
+ return items
199
+ }
200
+
201
+ /**
202
+ * @param {String} body
203
+ * @returns {Boolean} Whether the body is a Micro-Delta review.
204
+ */
205
+ function isMicroDelta(body) {
206
+ return body.includes('# Pull Request Micro-Delta Review') || (body.includes('### State Vector') && body.includes('### Micro-Delta Focus'))
207
+ }
208
+
209
+ /**
210
+ * @summary The shape a body selects, in the managed gate's dispatch order.
211
+ *
212
+ * A micro-review is selected by a real H1 outside fences, so a full review that quotes the light form is not
213
+ * judged against the light floor. Round 2 is tested before the follow-up hints, whose vocabulary it shares.
214
+ * @param {String} body
215
+ * @returns {'micro'|'round-2'|'micro-delta'|'follow-up'|'canonical'}
216
+ */
217
+ export function reviewShape(body) {
218
+ if (unfencedLines(body).some(line => /^#[ \t]+PR Micro-Review\b/.test(line))) return 'micro';
219
+ if (ROUND_2_H1.test(body)) return 'round-2';
220
+ if (isMicroDelta(body)) return 'micro-delta';
221
+
222
+ return FOLLOW_UP_HINTS.some(hint => body.includes(hint)) ? 'follow-up' : 'canonical'
223
+ }
224
+
225
+ /**
226
+ * @summary A `- **Label:** value` field, or '' when absent.
227
+ * @param {String} body
228
+ * @param {String} label
229
+ * @returns {String}
230
+ */
231
+ function fieldValue(body, label) {
232
+ const prefix = `- **${label}:**`,
233
+ line = body.split('\n').find(candidate => candidate.trim().startsWith(prefix));
234
+
235
+ return line ? line.trim().slice(prefix.length).trim() : ''
236
+ }
237
+
238
+ /**
239
+ * @param {String} value
240
+ * @returns {Boolean} Whether a contract field is empty or still holds a placeholder.
241
+ */
242
+ function isPlaceholder(value) {
243
+ return !value || /^\[.*\]$/.test(value) || /^(?:todo|tbd)$/i.test(value)
244
+ }
245
+
246
+ /**
247
+ * @summary A shape's template misses, split into what a diagnostic may name and what it never names.
248
+ * @param {String} body
249
+ * @param {String} shape From `reviewShape`.
250
+ * @returns {{visible: String[], invisible: String[]}}
251
+ */
252
+ export function templateMisses(body, shape) {
253
+ const
254
+ visible = [],
255
+ invisible = SKELETON_ANCHORS[shape].filter(anchor => !body.includes(anchor)),
256
+ lineWith = label => body.split('\n').find(line => line.includes(label)) || '';
257
+
258
+ if (shape === 'canonical' || shape === 'follow-up') {
259
+ visible.push(...VISIBLE_METRIC_ANCHORS.filter(anchor => !body.includes(anchor)));
260
+ visible.push(...PREMISE_ANCHORS.filter(({token}) => !body.includes(token)).map(({label}) => label));
261
+ invisible.push(...INVISIBLE_STRUCTURE_ANCHORS.filter(anchor => !body.includes(anchor)))
262
+ }
263
+
264
+ // The light form names its origin line but has never required the UUID; every other shape does.
265
+ shape !== 'micro' && !ORIGIN_SESSION.test(body) && visible.push('Origin Session ID: a Memory Core UUID on its own line');
266
+
267
+ if (shape === 'micro') {
268
+ !MICRO_CLASS.test(lineWith('**Class:**')) && visible.push('Class: micro | contained | mechanical')
269
+ }
270
+
271
+ if (shape === 'micro-delta') {
272
+ !BLOCKER_CLASS.test(lineWith('- **Remaining Blocker Class:**')) && visible.push('Remaining Blocker Class: mechanical-hygiene | metadata-drift');
273
+
274
+ if (COMMENTED_CLOSURE.test(body)) {
275
+ !body.includes('### RC2 Closure Packet') && visible.push('RC2 Closure Packet');
276
+ visible.push(...CLOSURE_PACKET_FIELDS.filter(label => isPlaceholder(fieldValue(body, label))))
277
+ }
278
+ }
279
+
280
+ if (shape === 'round-2') {
281
+ !ROUND_1_REFERENCE.test(body) && visible.push('Round-1 Review ID: the review this round dispositions');
282
+ !ROUND_2_ROW.test(body) && visible.push('a disposition row: ADDRESSED, DEFENDED or STILL_OPEN');
283
+ CHECKLIST_ITEM.test(body) && visible.push('no new action checklist: prior actions live in the disposition table')
284
+ }
285
+
286
+ return {invisible, visible}
287
+ }
288
+
289
+ /**
290
+ * @summary Why a Round 2's declared Status contradicts its own disposition table, or null.
291
+ * @param {String} body
292
+ * @returns {String|null}
293
+ */
294
+ export function round2Incoherence(body) {
295
+ // A line scan, not a `[ \t]*(.+?)[ \t]*$` capture, which backtracks quadratically on a run of tabs
296
+ const status = body.split(LINE_BREAK).find(line => line.startsWith(ROUND_2_STATUS) && line.length > ROUND_2_STATUS.length);
297
+
298
+ if (!status) return 'no `**Status:**` line';
299
+
300
+ const declared = status.slice(ROUND_2_STATUS.length).replace(/[*_`]/g, '').trim().toLowerCase();
301
+
302
+ if (!ROUND_2_LEGAL_STATUSES.includes(declared)) return 'a `**Status:**` outside the legal Round-2 set';
303
+
304
+ const stillOpen = body.split('\n')
305
+ .filter(line => line.trim().startsWith('|') && ROUND_2_VERBS.some(verb => line.includes(verb)))
306
+ .some(line => line.split('|').slice(1, -1).map(cell => cell.trim()).includes('STILL_OPEN'));
307
+
308
+ return stillOpen && declared !== 'comment' ? 'a STILL_OPEN row under a Status other than Comment' : null
309
+ }
310
+
311
+ /**
312
+ * @summary The earliest merge that activated the review budget, from a complete closing-PR history.
313
+ * @param {Object} activation
314
+ * @param {String} activation.base Branch whose merge activates the budget.
315
+ * @param {{totalCount: Number, hasNextPage: Boolean, nodes: Object[]}} activation.closingPullRequests
316
+ * @returns {{activatedMs: Number|null}|{code: String}} `activatedMs: null` means not activated yet.
317
+ */
318
+ export function resolveActivation({base, closingPullRequests: history}) {
319
+ const nodes = history?.nodes;
320
+
321
+ // A history that may be partial cannot prove the earliest merge, so it fails closed rather than guessing.
322
+ if (!Array.isArray(nodes) || history.hasNextPage !== false || !Number.isInteger(history.totalCount) || history.totalCount !== nodes.length) {
323
+ return {code: 'BUDGET_ACTIVATION_UNPROVABLE'}
324
+ }
325
+
326
+ const times = nodes.filter(node => node?.state === 'MERGED' && node?.baseRefName === base).map(node => Date.parse(node?.mergedAt || ''));
327
+
328
+ if (!times.every(Number.isFinite)) return {code: 'BUDGET_ACTIVATION_UNPROVABLE'};
329
+
330
+ return {activatedMs: times.length ? Math.min(...times) : null}
331
+ }
332
+
333
+ /**
334
+ * @summary The provenance code a Request Changes fails on, or null when it owes none.
335
+ * @param {Object} envelope
336
+ * @returns {String|null}
337
+ */
338
+ function budgetFailure({budgetActivation, pullRequest, review: {body}}) {
339
+ // No activation coordinate means the caller's whole history postdates the budget.
340
+ if (budgetActivation !== null) {
341
+ const resolved = resolveActivation(budgetActivation);
342
+
343
+ if (resolved.code) return resolved.code;
344
+ if (resolved.activatedMs === null) return null;
345
+
346
+ const createdMs = Date.parse(pullRequest.createdAt);
347
+
348
+ if (!Number.isFinite(createdMs)) return 'BUDGET_COHORT_UNKNOWN';
349
+ if (createdMs <= resolved.activatedMs) return null
350
+ }
351
+
352
+ const managed = body.includes(BUDGET_MANAGED);
353
+
354
+ if (body.includes(BUDGET_OVERRIDE) && !managed) return 'BUDGET_OVERRIDE_UNMANAGED';
355
+
356
+ return managed || BUDGET_BYPASS.test(body) ? null : 'BUDGET_PROVENANCE_MISSING'
357
+ }
358
+
359
+ /**
360
+ * @summary Throws unless the envelope carries every field the verdict reads, in its documented shape.
361
+ * @param {Object} envelope
362
+ * @throws {TypeError} Naming the first malformed field.
363
+ */
364
+ export function assertEnvelope(envelope) {
365
+ const
366
+ fail = field => { throw new TypeError(`malformed review envelope: ${field}`) },
367
+ positive = value => Number.isInteger(value) && value > 0,
368
+ activation = envelope?.budgetActivation;
369
+
370
+ if (!envelope || typeof envelope !== 'object') fail('not an object');
371
+ if (!/^[\w.-]+\/[\w.-]+$/.test(envelope.repository ?? '')) fail('repository');
372
+ if (!positive(envelope.pullRequest?.number)) fail('pullRequest.number');
373
+ if (typeof envelope.pullRequest.createdAt !== 'string') fail('pullRequest.createdAt');
374
+ if (!positive(envelope.review?.id)) fail('review.id');
375
+ if (!REVIEW_STATES.includes(envelope.review.state)) fail('review.state');
376
+ if (typeof envelope.review.reviewer !== 'string' || !envelope.review.reviewer) fail('review.reviewer');
377
+ if (typeof envelope.review.body !== 'string') fail('review.body');
378
+
379
+ // Required even when there is none: an absent key is a wrapper that forgot the read, never "no budget".
380
+ if (!('budgetActivation' in envelope)) fail('budgetActivation');
381
+
382
+ if (activation !== null && (typeof activation !== 'object' || !positive(activation.issue) ||
383
+ typeof activation.base !== 'string' || !activation.base || typeof activation.closingPullRequests !== 'object')) {
384
+ fail('budgetActivation')
385
+ }
386
+ }
387
+
388
+ /**
389
+ * @summary The verdict on one submitted review. Pure: the same envelope always yields the same verdict.
390
+ *
391
+ * The order is the reader's fix order: what the review IS, its state against its own declarations, before
392
+ * whether its body is complete.
393
+ * @param {Object} envelope Validated by `assertEnvelope`.
394
+ * @returns {{outcome: 'pass'|'fail'|'not-enforced', code: String, shape: String, finding: String|null}}
395
+ */
396
+ export function judgeReview(envelope) {
397
+ const
398
+ {body, reviewer, state} = envelope.review,
399
+ shape = reviewShape(body),
400
+ verdict = (outcome, code, finding = null) => ({code, finding, outcome, shape}),
401
+ lines = body.split('\n').map(line => line.trim()),
402
+ dsStatus = lines.includes('**Status:** Drop+Supersede'),
403
+ dsDecided = lines.includes('- **Decision**: Drop+Supersede'),
404
+ closure = body.includes('# Pull Request Micro-Delta Review') && COMMENTED_CLOSURE.test(body),
405
+ round2 = ROUND_2_H1.test(body);
406
+
407
+ // Every agent seat carries the `neo-` prefix; a person's review owes no agent template.
408
+ if (!reviewer.startsWith('neo-')) return verdict('not-enforced', 'REVIEWER_NOT_AGENT');
409
+ if (state === 'DISMISSED') return verdict('not-enforced', 'REVIEW_DISMISSED');
410
+
411
+ if (dsStatus || dsDecided) {
412
+ if (state !== 'CHANGES_REQUESTED') return verdict('fail', 'DROP_SUPERSEDE_STATE');
413
+
414
+ const missing = [
415
+ ...(dsStatus && dsDecided ? [] : ['the Status and Decision lines both read Drop+Supersede']),
416
+ ...(DROP_SUPERSEDE_KINDS.includes(fieldValue(body, 'Disposition')) ? [] : [`Disposition: ${DROP_SUPERSEDE_KINDS.join(' | ')}`]),
417
+ ...DROP_SUPERSEDE_FIELDS.filter(label => isPlaceholder(fieldValue(body, label))),
418
+ ...(shape === 'micro-delta' ? ['the full or follow-up template, not the Micro-Delta one'] : [])
419
+ ];
420
+
421
+ if (missing.length) return verdict('fail', 'DROP_SUPERSEDE_INCOMPLETE', missing[0])
422
+ }
423
+
424
+ if (closure && state !== 'COMMENTED') return verdict('fail', 'COMMENTED_CLOSURE_STATE');
425
+
426
+ const incoherence = round2 && round2Incoherence(body);
427
+
428
+ if (incoherence) return verdict('fail', 'ROUND_2_STATE_INCOHERENT', incoherence);
429
+
430
+ // A plain COMMENT owes no template. It may still not open a demand packet, in any budget state: otherwise the
431
+ // counted round stays avoidable by never choosing Request Changes.
432
+ if (state === 'COMMENTED' && !closure && !round2) {
433
+ const demanded = demandedActionItems(body).length;
434
+
435
+ return demanded
436
+ ? verdict('fail', 'COMMENT_ACTION_PACKET', `${demanded} new required-action item(s)`)
437
+ : verdict('pass', 'COMMENT_SUPPLEMENTARY')
438
+ }
439
+
440
+ const budget = state === 'CHANGES_REQUESTED' ? budgetFailure(envelope) : null;
441
+
442
+ if (budget) return verdict('fail', budget);
443
+
444
+ const {invisible, visible} = templateMisses(body, shape);
445
+
446
+ return visible.length || invisible.length
447
+ ? verdict('fail', 'TEMPLATE_INCOMPLETE', visible[0] ?? null)
448
+ : verdict('pass', 'REVIEW_SHAPE_VALID')
449
+ }
450
+
451
+ /**
452
+ * @param {Number} reviewId
453
+ * @returns {String} The hidden first line that makes a corrective comment one per review.
454
+ */
455
+ export function commentMarker(reviewId) {
456
+ return `<!-- neo-agent-skills:pr-review-lint review=${reviewId} -->`
457
+ }
458
+
459
+ /**
460
+ * @summary The corrective comment for a failed review: one remedy and at most one finding, both this file's strings.
461
+ * @param {Object} envelope
462
+ * @param {Object} verdict A failing verdict from `judgeReview`.
463
+ * @returns {String} Markdown, opened by `commentMarker`.
464
+ */
465
+ export function correctiveComment({pullRequest, repository, review}, {code, finding, shape}) {
466
+ const
467
+ url = `https://github.com/${repository}/pull/${pullRequest.number}#pullrequestreview-${review.id}`,
468
+ template = REVIEW_TEMPLATES[code === 'TEMPLATE_INCOMPLETE' ? shape : CODE_TEMPLATE[code]];
469
+
470
+ return [
471
+ commentMarker(review.id),
472
+ '## 🚨 Agent PR review lint',
473
+ '',
474
+ `@${review.reviewer}: your [review on #${pullRequest.number}](${url}) fails \`${code}\`. ${REMEDIES[code]}`,
475
+ ...(finding ? ['', `First finding: ${finding}.`] : []),
476
+ '',
477
+ `**Required action:** read \`.agents/skills/pr-review/SKILL.md\`${template ? ` and \`${template}\`` : ''} before posting a corrective review.`,
478
+ 'The check verifies more than this comment names; following the template is the reliable path.'
479
+ ].join('\n')
480
+ }
481
+
482
+ /**
483
+ * @summary CLI entry: judges the `--envelope` file, prints the verdict, and writes the comment on a failure.
484
+ * @param {String[]} [argv]
485
+ * @param {Object} [options={}]
486
+ * @param {Function} [options.out=console.log]
487
+ * @param {Function} [options.error=console.error]
488
+ * @returns {Number} 0 pass or not enforced, 1 policy failure, 2 bad arguments or a malformed envelope.
489
+ */
490
+ export function run(argv = process.argv.slice(2), {out = console.log, error = console.error} = {}) {
491
+ let envelope, values;
492
+
493
+ try {
494
+ ({values} = parseArgs({
495
+ args : argv,
496
+ strict : true,
497
+ options: {'comment-file': {type: 'string'}, envelope: {type: 'string'}, json: {type: 'boolean', default: false}}
498
+ }));
499
+
500
+ if (!values.envelope) throw new TypeError('--envelope <file> is required');
501
+
502
+ envelope = JSON.parse(readFileSync(values.envelope, 'utf8'));
503
+ assertEnvelope(envelope)
504
+ } catch (cause) {
505
+ error(`check-pr-review-body: ${cause.message}`);
506
+ values?.json && out(JSON.stringify({code: 'ENVELOPE_INVALID', finding: null, outcome: 'error', shape: null}));
507
+ return 2
508
+ }
509
+
510
+ const verdict = judgeReview(envelope),
511
+ failed = verdict.outcome === 'fail',
512
+ subject = `${verdict.shape} review by ${envelope.review.reviewer}`;
513
+
514
+ if (values.json) out(JSON.stringify(verdict));
515
+ else if (!failed) out(`✅ ${verdict.code} (${subject}).`);
516
+ else {
517
+ error(`❌ ${verdict.code} (${subject}). ${REMEDIES[verdict.code]}`);
518
+ verdict.finding && error(` First finding: ${verdict.finding}.`)
519
+ }
520
+
521
+ failed && values['comment-file'] && writeFileSync(values['comment-file'], correctiveComment(envelope, verdict));
522
+
523
+ return failed ? 1 : 0
524
+ }
525
+
526
+ // Entrypoint guard, canonicalized on both sides so a symlinked bin still runs (see `check-pr-body.mjs`).
527
+ if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
528
+ process.exit(run())
529
+ }