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 +22 -0
- package/package.json +5 -2
- package/scripts/check-pr-review-body.mjs +529 -0
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.
|
|
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
|
+
}
|