neo-agent-skills 0.1.71 → 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.
@@ -84,26 +84,12 @@ Apply the verdict logic in this order (highest precedence first):
84
84
 
85
85
  If an epic closeout hinges on ADR chronology, apply the ADR successor-risk audit before computing the verdict. The closeout comment must name the related ADR, whether it supersedes the epic or requires a challenge/amendment path, and the public evidence that supports the route.
86
86
 
87
- **Verdict authority:** the skill produces a structured review + recommendation. **Terminal-action shape depends on the verdict**:
87
+ **Verdict authority:** the review is the gate, and the reviewer takes the terminal action its verdict names. Only `gh pr merge` is human-only (§0 Invariant 1). An issue close reopens with one click and is a maintainer act (operator, 2026-05-10 and 2026-10-28). Empirical anchors: neomjs/neo#10691 sat seven days past a clean verdict, and neomjs/neo-stage#26 waited on a retire close.
88
88
 
89
- - **`RECOMMEND_CLOSE_COMPLETED` (with zero unresolved residuals)**: the reviewer-agent SHOULD close the epic as completed via `gh issue close --reason completed` as the natural downstream of the review. The review IS the gate; the close-act is not a separate operator-gate. **This is NOT a §0 Invariant 1 parallel** — §0 strictly forbids `gh pr merge` (PR merge action only); epic-close is downstream of the review verdict, not in §0 scope. Failing to close after a clean CLOSE_COMPLETED verdict produces stale-pending-action board pollution (empirical anchor: #10691 verdict 2026-05-04 → epic closed 2026-05-11 after operator surfaced the misframing).
90
-
91
- - **`RECOMMEND_KEEP_OPEN`**: no terminal action. Review surfaces blockers/residuals; operator or sub-owner decides path forward.
92
-
93
- - **`RECOMMEND_CREATE_MISSING_SUBS`**: no terminal action. Recommendation surfaces; operator authorizes new-sub creation; assigned owner files via `/ticket-create`.
94
-
95
- - **`RECOMMEND_RETIRE_OR_SUPERSEDE`**: no terminal action. Reviewer-agent does NOT auto-close-as-not-planned; operator authority for substrate-cohesion reasons (potential separate substrate concern — could be sub-issue if a §0-parallel claim is correct for retire-action specifically).
96
-
97
- For `RECOMMEND_CREATE_MISSING_SUBS`:
98
- 1. List the gaps explicitly (which AC, what evidence is missing).
99
- 2. Identify proposed owners (agents) for each new sub. If unclear, A2A the team to volunteer.
100
- 3. Coordinate via A2A — DO NOT autonomously create the new subs without operator approval.
101
- 4. The skill output recommends; the operator authorizes; the assigned owner files via `ticket-create`.
102
-
103
- For `RECOMMEND_RETIRE_OR_SUPERSEDE`:
104
- 1. Articulate the rationale clearly (what changed since the epic was filed; what supersedes it).
105
- 2. Cross-reference the superseding epic / discussion if any.
106
- 3. Operator acts on the recommendation.
89
+ - **`RECOMMEND_CLOSE_COMPLETED`** (zero unresolved residuals): close the epic with `gh issue close --reason completed`.
90
+ - **`RECOMMEND_RETIRE_OR_SUPERSEDE`**: name what changed and what supersedes the epic, and run the [intent check](../../ticket-create/references/decision-substrate-sweeps.md#same-turn-reconciliation). Then close it as not planned with those links. Its open subs move to a live parent or close the same way.
91
+ - **`RECOMMEND_CREATE_MISSING_SUBS`**: list each gap (which AC, what evidence is missing) with a proposed owner, or A2A the team to volunteer. An owner who accepts files via `/ticket-create`.
92
+ - **`RECOMMEND_KEEP_OPEN`**: no terminal action. The review names the blockers and residuals for their owners.
107
93
 
108
94
  ## 5. Post the verdict comment
109
95
 
@@ -127,13 +113,13 @@ Update your `## Epic Resolution Review (in progress)` comment to the final shape
127
113
 
128
114
  <2-4 paragraphs explaining the verdict. For KEEP_OPEN: which residuals/blockers + path to close. For CREATE_MISSING_SUBS: enumerate gaps + proposed owners. For RETIRE: superseding artifact. For CLOSE_COMPLETED: confirmation that all residuals are tracked elsewhere.>
129
115
 
130
- ### Required operator action
116
+ ### Terminal action
131
117
 
132
- <For RECOMMEND_CLOSE_COMPLETED: "Close epic as completed when convenient." For RECOMMEND_CREATE_MISSING_SUBS: "Authorize creation of N new subs (proposed owners listed above)." Etc.>
118
+ <What the review did: closed as completed; closed as not planned, with the superseding links; gaps offered to named owners; or none (keep open).>
133
119
 
134
120
  ### A2A coordination
135
121
 
136
- <List peers A2A'd, with subject + commentId for the operator's audit trail.>
122
+ <List peers A2A'd, with subject + commentId for the audit trail.>
137
123
 
138
124
  Origin Session ID: <your session UUID>
139
125
  ```
@@ -142,7 +128,7 @@ Origin Session ID: <your session UUID>
142
128
 
143
129
  After posting the verdict comment, A2A the relevant peers:
144
130
 
145
- - For `RECOMMEND_CLOSE_COMPLETED`: A2A all agents who had subs in the epic, FYI of closure recommendation.
131
+ - For `RECOMMEND_CLOSE_COMPLETED`: A2A all agents who had subs in the epic, FYI of the closure.
146
132
  - For `RECOMMEND_KEEP_OPEN` with BLOCKER: A2A the blocked sub's owner with the blocker rationale + ask whether they need help unblocking.
147
133
  - For `RECOMMEND_CREATE_MISSING_SUBS`: A2A proposed owners with the gap + ask whether they accept ownership.
148
134
  - For `RECOMMEND_RETIRE_OR_SUPERSEDE`: A2A the superseding effort's owner with the link.
@@ -64,10 +64,12 @@ Prose discipline that leaves no artifact cannot be checked; these arms exist bec
64
64
 
65
65
  If content belongs on a surviving parent, port it there in the same turn. An accepted direction-changing fold, graduation or successor publication must also reconcile the older work it replaces. A new exploratory option alone retires nothing.
66
66
 
67
+ **The intent check (every not-planned close, issues and PRs alike):** an artifact's intent, the outcome it wanted, outlives its prescription, the way it proposed to get there. A changed architecture retires the prescription, not the intent. The closing comment names each still-valid intent and where it lives now: an open successor, or this artifact re-scoped instead of closed. Only an intent that is itself wrong closes with the artifact, and the comment says why. Closing your own artifact takes this check. Closing another author's takes two agreeing peers: you and its author, or a cross-family peer when the author is unreachable. Origin: the operator, 2026-10-28, after one seat closed a run of still-valid engine PRs.
68
+
67
69
  Reuse adjacency results and predecessor PR links: read affected tickets, open PRs (draft or ready), research recommendations, tool assumptions and active handovers/checklists. Find predecessors before filing; disposition them once successor IDs exist. Abandonment reconciles immediately against existing outcome/research owners:
68
70
 
69
71
  1. Cite the accepted decision and compare each recommendation with the **current outcome and required interactions**, not just its technical feasibility. A reusable reference does not authorize a superseded research/delivery path. Keep, narrow, transfer or retire each prescription; move unique obligations, evidence and dissent to a named surviving owner first. Existing research can own the remainder without another ticket.
70
- 2. Update old entry heads and both sides' successor links. Retire obsolete instructions; preserve history. Close superseded PRs with decision/successor links, or retarget surviving scope. A finally rejected narrow implementation ticket closes `not_planned` per `ticket-intake` §4; unresolved outcomes stay open, and partial overlap leaves remaining obligations current. Epic closure uses `epic-resolution`.
72
+ 2. Update old entry heads and both sides' successor links. Retire obsolete instructions; preserve history. Close superseded PRs after the intent check, with decision/successor links, or retarget surviving scope. A finally rejected narrow implementation ticket closes `not_planned` per `ticket-intake` §4; unresolved outcomes stay open, and partial overlap leaves remaining obligations current. Epic closure uses `epic-resolution`.
71
73
  3. Apply own-authored or explicitly authorized changes. For foreign bodies/ACs, follow `ticket-create` §11: propose exact text and record the owner/application still owed. A pending proposal is not applied reconciliation.
72
74
  4. For immutable entries, put exact superseded IDs → current authority on the surviving owner's current entry and active handover. Preserve history. Read back bodies, links, states and mappings; mutable old heads must reveal disposition/owner. Report pending owner actions separately; unapplied cleanup is incomplete.
73
75
 
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.71",
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
+ }