@iceinvein/agent-skills 0.1.35 → 0.1.37
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/package.json +1 -1
- package/skills/index.json +2 -2
- package/skills/magpie/README.md +2 -2
- package/skills/magpie/SKILL.md +53 -25
- package/skills/magpie/scripts/__tests__/changed-lines.test.ts +66 -0
- package/skills/magpie/scripts/__tests__/diff-utils.test.ts +20 -1
- package/skills/magpie/scripts/__tests__/preflight.test.ts +15 -3
- package/skills/magpie/scripts/__tests__/render-progress.test.ts +1 -1
- package/skills/magpie/scripts/changed-lines.ts +36 -0
- package/skills/magpie/scripts/dedupe-cmd.ts +10 -2
- package/skills/magpie/scripts/diff-utils.ts +26 -1
- package/skills/magpie/scripts/post-cmd.ts +2 -25
- package/skills/magpie/scripts/preflight.ts +13 -2
- package/skills/magpie/scripts/render-progress.ts +2 -2
- package/skills/magpie/scripts/setup-cmd.ts +12 -1
- package/skills/magpie/scripts/types.ts +8 -0
- package/skills/magpie/skill.json +2 -2
package/package.json
CHANGED
package/skills/index.json
CHANGED
|
@@ -219,9 +219,9 @@
|
|
|
219
219
|
},
|
|
220
220
|
{
|
|
221
221
|
"name": "magpie",
|
|
222
|
-
"description": "Interactive PR review pipeline. Runs five parallel specialist subagents (security, bugs, performance, code-smells, architecture), dedupes findings, applies a critic rubric, peer-reviews via codex exec, and serves an interactive HTML report for selecting findings to post via gh. Bundles a Bun CLI installed onto PATH via the skill's postinstall step. Use when the user asks to review a GitHub pull request.",
|
|
222
|
+
"description": "Interactive PR review pipeline. Runs five parallel specialist subagents (security, bugs, performance, code-smells, architecture), dedupes findings, applies a critic rubric, peer-reviews via codex exec (falling back to a Claude second opinion when codex is unavailable), and serves an interactive HTML report for selecting findings to post via gh. Bundles a Bun CLI installed onto PATH via the skill's postinstall step. Use when the user asks to review a GitHub pull request.",
|
|
223
223
|
"type": "prompt",
|
|
224
|
-
"version": "0.
|
|
224
|
+
"version": "0.7.0"
|
|
225
225
|
},
|
|
226
226
|
{
|
|
227
227
|
"name": "module-secret-auditor",
|
package/skills/magpie/README.md
CHANGED
|
@@ -4,14 +4,14 @@ Interactive Claude Code skill that runs a multi-stage PR review pipeline inside
|
|
|
4
4
|
|
|
5
5
|
## What it does
|
|
6
6
|
|
|
7
|
-
Given a GitHub PR number, dispatches five specialist subagents in parallel (security, bugs, performance, code-smells, architecture), dedupes their findings, applies a critic rubric, peer-reviews via `codex exec
|
|
7
|
+
Given a GitHub PR number, dispatches five specialist subagents in parallel (security, bugs, performance, code-smells, architecture), dedupes their findings, applies a critic rubric, peer-reviews via `codex exec` (falling back to a Claude second-opinion subagent when codex is unavailable), serves an interactive HTML report, and posts the findings the user selects via `gh`.
|
|
8
8
|
|
|
9
9
|
## Requirements
|
|
10
10
|
|
|
11
11
|
- `bun` on PATH (https://bun.sh)
|
|
12
12
|
- `gh` on PATH, authenticated (`gh auth status`)
|
|
13
|
-
- `codex` on PATH, authenticated
|
|
14
13
|
- `git` on PATH
|
|
14
|
+
- `codex` on PATH, authenticated (optional; if absent the peer-review stage falls back to a Claude second-opinion subagent)
|
|
15
15
|
|
|
16
16
|
## Install
|
|
17
17
|
|
package/skills/magpie/SKILL.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: magpie
|
|
3
|
-
description: Interactive PR review pipeline. Runs five parallel specialist subagents (security, bugs, performance, code-smells, architecture), dedupes findings, applies a critic rubric, peer-reviews via codex exec, and serves an interactive HTML report for selecting findings to post via gh. Use when the user asks to review a GitHub pull request.
|
|
3
|
+
description: Interactive PR review pipeline. Runs five parallel specialist subagents (security, bugs, performance, code-smells, architecture), dedupes findings, applies a critic rubric, peer-reviews via codex exec (falling back to a Claude second opinion when codex is unavailable), and serves an interactive HTML report for selecting findings to post via gh. Use when the user asks to review a GitHub pull request.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Magpie
|
|
7
7
|
|
|
8
8
|
## Prerequisites
|
|
9
9
|
|
|
10
|
-
The skill pre-flights `bun`, `gh`, `
|
|
10
|
+
The skill pre-flights `bun`, `gh`, `git` (required) and `codex` (optional). If a required binary is missing the run aborts with a single install hint line. `codex` is the preferred peer reviewer, but it is optional: if it is missing the run continues and the peer-review stage falls back to a Claude second-opinion subagent (setup prints a one-line notice and logs `{stage: preflight, status: done, missingOptional: ["codex"]}`).
|
|
11
11
|
|
|
12
12
|
## Stage walkthrough
|
|
13
13
|
|
|
@@ -58,7 +58,7 @@ magpie render "$RUN_DIR" progress
|
|
|
58
58
|
|
|
59
59
|
Dispatch the five specialist subagents in a single message using five Agent tool calls in parallel. For each focus in (security, bugs, performance, code-smells, architecture), the prompt is:
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
````
|
|
62
62
|
<specialist block for focus from this SKILL.md>
|
|
63
63
|
|
|
64
64
|
You are reviewing PR #<PR_NUMBER>.
|
|
@@ -95,6 +95,7 @@ Write findings to $RUN_DIR/findings/<focus>.json before returning. The file MUST
|
|
|
95
95
|
- `severity`, `risk.impact`, `risk.confidence` use category names (e.g. `high`, `low`), not sentences.
|
|
96
96
|
- `risk.likelihood` describes frequency, not impact. Valid values are exactly `likely`, `possible`, `edge-case`, `unknown`. NEVER use `high`/`medium`/`low` here (those are likelihood-as-impact and will be auto-corrected, but pick the right axis).
|
|
97
97
|
- `risk.action` is the disposition tag, not the recommendation text. Valid values are exactly `must-fix`, `should-fix`, `consider`, `optional`. The recommendation prose belongs in `description` under `Suggested direction:`, never in `risk.action`.
|
|
98
|
+
- Keep `severity` coherent with `risk`. `severity` is the headline label: use `blocker`/`high` only with `risk.impact` of `critical`/`high` and `risk.action` of `must-fix`/`should-fix`. A `low` severity paired with `must-fix`, or a `blocker` paired with `optional`, is contradictory. The 0-10 score that gates the drop threshold is derived from `risk`, not from `severity`, so an inflated `severity` on a weak `risk` is still dropped. Set `risk` accurately rather than leaning on `severity`.
|
|
98
99
|
|
|
99
100
|
Bad (will be silently coerced, do not rely on this):
|
|
100
101
|
```
|
|
@@ -111,7 +112,7 @@ Good:
|
|
|
111
112
|
- `Observation: <one idea, what the diff actually does and where>`
|
|
112
113
|
- `Why it matters: <impact at realistic scale or on a real user path>`
|
|
113
114
|
- `Suggested direction: <one concrete next step, optional if the fix isn't obvious>`
|
|
114
|
-
- `Needs verification: <what you couldn't confirm from the bundle, optional, low/medium severity only>`
|
|
115
|
+
- `Needs verification: <what you couldn't confirm from the bundle, optional, low/medium severity only>` This labelled paragraph is the only channel for uncertainty: never hedge inside another section, and never raise `severity` to compensate for what you couldn't verify (a blocker/high you cannot stand behind is not a blocker/high). Use the exact `Needs verification:` prefix, not inline phrasing.
|
|
115
116
|
|
|
116
117
|
One idea per paragraph. Do not collapse them into a single wall of text. Do not invent extra labels. If a section doesn't apply, omit it. The interactive report and the GitHub comment both parse these labels and render them as section headers, so missing labels degrade the output.
|
|
117
118
|
|
|
@@ -123,7 +124,7 @@ One idea per paragraph. Do not collapse them into a single wall of text. Do not
|
|
|
123
124
|
- Wrapping the code in a `` ``` `` fence inside `body` is tolerated (the poster hoists the inner code out), but bare code is preferred.
|
|
124
125
|
|
|
125
126
|
If you have no findings, write []. Return as your final tool result a single line: `<focus>: <N> findings (<blocker>/<high>/<medium>/<low>)`. Do not include other prose.
|
|
126
|
-
|
|
127
|
+
````
|
|
127
128
|
|
|
128
129
|
After each subagent returns, append `{stage: specialist, focus: <focus>, status: done, findings: <count>}` to `$RUN_DIR/log.jsonl` and re-render progress. (Per-focus `specialist` entries are diagnostic; only the aggregate `specialists` entry advances `magpie status`.)
|
|
129
130
|
|
|
@@ -143,21 +144,27 @@ Re-render progress.
|
|
|
143
144
|
|
|
144
145
|
### 5. Critic
|
|
145
146
|
|
|
146
|
-
Read `$RUN_DIR/findings.deduped.json`.
|
|
147
|
+
Read `$RUN_DIR/findings.deduped.json`. Substitute both placeholders in the critic rubric (the compact candidate list including each finding's `onChangedLine`, and the `<<DIFF_EXCERPT>>` hunks for the referenced files), then apply the rubric verbatim (one verdict per finding). Write the kept subset to `$RUN_DIR/findings.kept.json`. Append `{stage: critic, status: done}` and re-render progress.
|
|
147
148
|
|
|
148
149
|
### 6. Peer review
|
|
149
150
|
|
|
150
|
-
|
|
151
|
+
This stage always runs. `codex` is the preferred reviewer because it is a different model from the Claude agents that produced the findings; when `codex` is unavailable, a Claude second-opinion subagent stands in.
|
|
152
|
+
|
|
153
|
+
Build the peer-review prompt first: take the `magpie-peer-review` block from this SKILL.md and substitute the placeholders listed in its `## Substitute before use` preamble. Write the substituted prompt to `$RUN_DIR/peer-prompt.md`.
|
|
154
|
+
|
|
155
|
+
**Codex path (preferred).** If `codex` is available (setup did not log `missingOptional: ["codex"]` and `command -v codex` succeeds), set `<<PEER_PROVIDER>>` to `codex` and run codex with the prompt piped on stdin:
|
|
151
156
|
|
|
152
157
|
```
|
|
153
158
|
codex exec < "$RUN_DIR/peer-prompt.md" > "$RUN_DIR/peer.out"
|
|
154
159
|
```
|
|
155
160
|
|
|
156
|
-
`peer.out` is codex's full transcript; extract the fenced JSON block tagged `review-peer-review` from it to get the verdicts array. Write that verdicts array to `$RUN_DIR/peer.json`
|
|
161
|
+
`peer.out` is codex's full transcript; extract the fenced JSON block tagged `review-peer-review` from it to get the verdicts array. Write that verdicts array to `$RUN_DIR/peer.json`, append `{stage: peer-review, status: done, provider: codex}`, then apply the verdicts as described below.
|
|
157
162
|
|
|
158
|
-
If codex returns non-zero,
|
|
163
|
+
If codex returns non-zero, do not abort: fall through to the Claude path and record `{stage: peer-review, provider: codex, status: error}` first.
|
|
159
164
|
|
|
160
|
-
|
|
165
|
+
**Claude path (fallback).** When `codex` is unavailable or failed, get the second opinion from a Claude subagent instead. Set `<<PEER_PROVIDER>>` to `claude`, then prepend the `magpie-peer-review-claude-preamble` block from this SKILL.md to the substituted peer-review prompt (the preamble forces genuine independence, since the reviewer shares a model family with the primary reviewers). Dispatch one subagent (Agent tool, `general-purpose`) whose entire task is that combined prompt, and instruct it to return only the fenced `review-peer-review` JSON block. Write its output to `$RUN_DIR/peer.out`, extract the `review-peer-review` block to `$RUN_DIR/peer.json`, and append `{stage: peer-review, status: done, provider: claude}`.
|
|
166
|
+
|
|
167
|
+
**Apply the verdicts (both paths).** Parse the verdicts JSON and apply the `update` / `add` entries (an empty array means no change), then write `findings.final.json`. Re-render progress.
|
|
161
168
|
|
|
162
169
|
### 7. Report
|
|
163
170
|
|
|
@@ -267,7 +274,9 @@ For each potential finding:
|
|
|
267
274
|
- medium: Defense-in-depth concern or validation gap with limited or uncertain exploitability
|
|
268
275
|
- low: Minor hardening opportunity with low impact
|
|
269
276
|
|
|
270
|
-
Report only credible concerns grounded in code shown. If a concern depends on context you can't see,
|
|
277
|
+
Report only credible concerns grounded in code shown. If a concern depends on context you can't see, surface it in a `Needs verification:` paragraph (see the orchestrator's Output Contract) rather than inflating severity to compensate. Do not invent vulnerabilities without evidence.
|
|
278
|
+
|
|
279
|
+
Boundary with Architecture: report missing input validation here when it enables an attack (injection, path traversal, SSRF, auth bypass). Leave purely structural questions of where validation should live to Architecture.
|
|
271
280
|
|
|
272
281
|
Use the JSON schema defined in the orchestrator's `## Output Contract` block; do not invent fields.
|
|
273
282
|
```
|
|
@@ -328,7 +337,9 @@ For each potential bug:
|
|
|
328
337
|
- medium: Edge-case bug or missing guard with limited blast radius
|
|
329
338
|
- low: Very small correctness cleanup with low user impact
|
|
330
339
|
|
|
331
|
-
Prioritize bugs that cause silent wrong behavior over those that crash (crashes are at least visible).
|
|
340
|
+
Prioritize bugs that cause silent wrong behavior over those that crash (crashes are at least visible). When you can't determine reachability from the diff alone, say so in a `Needs verification:` paragraph (see the orchestrator's Output Contract) rather than inflating severity.
|
|
341
|
+
|
|
342
|
+
Boundary with Performance: report leaks, unbounded growth, and missing cleanup here only when the primary consequence is incorrect behavior, a crash, or resource exhaustion that breaks a workflow. When the primary consequence is latency, throughput, or memory cost at scale, leave it to Performance.
|
|
332
343
|
|
|
333
344
|
Use the JSON schema defined in the orchestrator's `## Output Contract` block; do not invent fields.
|
|
334
345
|
```
|
|
@@ -387,6 +398,8 @@ For each potential issue:
|
|
|
387
398
|
|
|
388
399
|
Only flag issues that would have noticeable impact at realistic scale. Don't suggest micro-optimizations on cold paths.
|
|
389
400
|
|
|
401
|
+
Boundary with Bugs: focus on cost at realistic scale. Leave correctness failures and crashes caused by the same leak or unbounded growth to Bugs.
|
|
402
|
+
|
|
390
403
|
Use the JSON schema defined in the orchestrator's `## Output Contract` block; do not invent fields.
|
|
391
404
|
```
|
|
392
405
|
|
|
@@ -504,6 +517,8 @@ For each potential issue:
|
|
|
504
517
|
|
|
505
518
|
Boundary with Code Smells: focus on module boundaries, public contracts, ownership, and system-level data flow. Leave local implementation smells such as duplicate branches, long functions, and primitive obsession to Code Smells.
|
|
506
519
|
|
|
520
|
+
Boundary with Security: flag validation gaps as design/contract issues (where validation belongs, which boundary should enforce it). Leave exploitability assessment to Security.
|
|
521
|
+
|
|
507
522
|
Focus on design decisions introduced or materially worsened by this PR that affect the long-term health of the codebase. Don't flag things that are "technically impure" but work well in practice.
|
|
508
523
|
|
|
509
524
|
Use the JSON schema defined in the orchestrator's `## Output Contract` block; do not invent fields.
|
|
@@ -515,24 +530,24 @@ The main agent runs this in-conversation against `findings.deduped.json` and wri
|
|
|
515
530
|
|
|
516
531
|
## Substitute before use
|
|
517
532
|
|
|
518
|
-
The block below contains
|
|
533
|
+
The block below contains two placeholders. Replace both before running the rubric.
|
|
519
534
|
|
|
520
|
-
- `<<DEDUPED_FINDINGS_COMPACT>>` — pretty-printed JSON array of the deduped candidates with only the fields the critic needs. Build with:
|
|
535
|
+
- `<<DEDUPED_FINDINGS_COMPACT>>` — pretty-printed JSON array of the deduped candidates with only the fields the critic needs. Each candidate carries `onChangedLine` (set deterministically during dedupe: `true` = anchored inside a changed hunk, `false` = anchored on code the PR did not touch, `null` = not anchorable). Build with:
|
|
521
536
|
```
|
|
522
|
-
jq '[.[] | {id, file, line, severity, risk, domain, title, description}]' "$RUN_DIR/findings.deduped.json"
|
|
537
|
+
jq '[.[] | {id, file, line, onChangedLine, severity, risk, domain, title, description}]' "$RUN_DIR/findings.deduped.json"
|
|
523
538
|
```
|
|
539
|
+
- `<<DIFF_EXCERPT>>` — the diff hunks for the files referenced by the candidates. For small PRs the full `diff.patch` is fine; for larger PRs, narrow to the files named in the candidate set.
|
|
524
540
|
|
|
525
541
|
````magpie-critic
|
|
526
|
-
You are a senior code reviewer auditing a list of candidate review findings produced by other agents on a pull request. Your only job is to keep the findings that a busy reviewer would genuinely thank you for surfacing, and drop the rest. You
|
|
542
|
+
You are a senior code reviewer auditing a list of candidate review findings produced by other agents on a pull request. Your only job is to keep the findings that a busy reviewer would genuinely thank you for surfacing, and drop the rest. You see each candidate's claim, anchor, and risk fields, plus the diff hunks around them. Use the hunks only to validate or refute the candidate in front of you: do not surface new findings or broaden the review (adding issues is the peer-review stage's job). Treat each candidate skeptically.
|
|
527
543
|
|
|
528
544
|
Drop a finding if any of the following hold:
|
|
529
545
|
- The description sounds speculative, hedged, or "needs verification" without strong evidence in the title or anchor.
|
|
530
546
|
- The finding is a stylistic preference, micro-optimization, or "nice to have" cleanup with no concrete user or maintenance impact.
|
|
531
|
-
-
|
|
532
|
-
- The finding is a theoretical risk that requires unlikely preconditions, or defense-in-depth on already
|
|
547
|
+
- Its `onChangedLine` is `false` and the description does not explain why the PR newly triggers a pre-existing concern (i.e. it is anchored on code this PR did not change).
|
|
548
|
+
- The finding is a theoretical risk that requires unlikely preconditions, or defense-in-depth on code the supplied hunks show is already guarded.
|
|
533
549
|
- The finding belongs to a category the repository's linter already enforces (naming, formatting, unused imports).
|
|
534
550
|
- The finding is on a test file or a generated/vendored file unless it materially affects test correctness.
|
|
535
|
-
- The finding duplicates another candidate at a similar anchor and is the weaker version.
|
|
536
551
|
|
|
537
552
|
Keep a finding if it points to a concrete defect on a changed line, with enough specificity that a reviewer could decide to act on it without re-reading the entire PR.
|
|
538
553
|
|
|
@@ -560,17 +575,18 @@ Output every candidate exactly once. Do not invent ids. Do not output anything o
|
|
|
560
575
|
```json
|
|
561
576
|
<<DEDUPED_FINDINGS_COMPACT>>
|
|
562
577
|
```
|
|
578
|
+
|
|
579
|
+
## Diff Hunks For Those Candidates
|
|
580
|
+
```diff
|
|
581
|
+
<<DIFF_EXCERPT>>
|
|
582
|
+
```
|
|
563
583
|
````
|
|
564
584
|
|
|
565
585
|
## Peer-review prompt
|
|
566
586
|
|
|
567
|
-
The agent substitutes the placeholders below
|
|
568
|
-
|
|
569
|
-
```
|
|
570
|
-
codex exec < <run-dir>/peer-prompt.md > <run-dir>/peer.out
|
|
571
|
-
```
|
|
587
|
+
The agent substitutes the placeholders below and writes the result to `<run-dir>/peer-prompt.md`. Step 6 then feeds that prompt to the peer reviewer: `codex exec < <run-dir>/peer-prompt.md > <run-dir>/peer.out` when codex is available, or a Claude `general-purpose` subagent (with the `magpie-peer-review-claude-preamble` prepended) writing to `<run-dir>/peer.out` when it is not.
|
|
572
588
|
|
|
573
|
-
|
|
589
|
+
Either way, extract the fenced `review-peer-review` block from `peer.out` and save it to `<run-dir>/peer.json`.
|
|
574
590
|
|
|
575
591
|
## Substitute before use
|
|
576
592
|
|
|
@@ -638,6 +654,18 @@ Rules:
|
|
|
638
654
|
```
|
|
639
655
|
````
|
|
640
656
|
|
|
657
|
+
## Claude peer-review preamble
|
|
658
|
+
|
|
659
|
+
Used only by the Claude fallback path in step 6. Prepend this block verbatim (no substitutions) to the substituted `magpie-peer-review` prompt before dispatching the subagent. Its job is to buy back the independence you lose by using the same model family that produced the findings: the reviewer must re-derive each verdict from the diff rather than trusting the finding text, and must actively resist rubber-stamping.
|
|
660
|
+
|
|
661
|
+
````magpie-peer-review-claude-preamble
|
|
662
|
+
You are a fresh, independent second-opinion reviewer. You have no memory of, and no stake in, how the findings below were produced. They were generated by other agents that share your model family, so they may carry the same blind spots you would: do not defer to them, and do not assume they are correct because they sound confident.
|
|
663
|
+
|
|
664
|
+
Ground every verdict in the diff hunks provided, not in the prose of the finding. For each finding, independently re-derive whether the described problem is actually present on the cited line before you accept it. If a finding's reasoning does not hold against the hunk, or the anchor is wrong, or the severity is off, say so with "update"; if you can see a clearly actionable adjacent issue in the same hunks that was missed, add it. When the existing finding survives your own check unchanged, leave it alone.
|
|
665
|
+
|
|
666
|
+
Hold yourself to the exact same output contract and constraints described below. Return [] when the review is already sound.
|
|
667
|
+
````
|
|
668
|
+
|
|
641
669
|
## Resuming a crashed run
|
|
642
670
|
|
|
643
671
|
If the user re-invokes the skill and a `$RUN_DIR/state/server-info` exists:
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { annotateChangedLines } from '../changed-lines.ts'
|
|
3
|
+
import type { ReviewFinding } from '../types.ts'
|
|
4
|
+
|
|
5
|
+
const SAMPLE = `diff --git a/src/a.ts b/src/a.ts
|
|
6
|
+
index abc..def 100644
|
|
7
|
+
--- a/src/a.ts
|
|
8
|
+
+++ b/src/a.ts
|
|
9
|
+
@@ -1,3 +1,4 @@
|
|
10
|
+
line1
|
|
11
|
+
-old
|
|
12
|
+
+new
|
|
13
|
+
+added
|
|
14
|
+
line3
|
|
15
|
+
`
|
|
16
|
+
|
|
17
|
+
function finding(overrides: Partial<ReviewFinding>): ReviewFinding {
|
|
18
|
+
return {
|
|
19
|
+
id: 'x-1',
|
|
20
|
+
file: 'src/a.ts',
|
|
21
|
+
line: 2,
|
|
22
|
+
severity: 'high',
|
|
23
|
+
risk: { impact: 'high', likelihood: 'likely', confidence: 'high', action: 'must-fix' },
|
|
24
|
+
title: 't',
|
|
25
|
+
description: 'd',
|
|
26
|
+
domain: 'bugs',
|
|
27
|
+
...overrides,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('annotateChangedLines', () => {
|
|
32
|
+
test('flags an anchor inside a changed hunk as true', () => {
|
|
33
|
+
const [f] = annotateChangedLines([finding({ line: 3 })], SAMPLE)
|
|
34
|
+
expect(f?.onChangedLine).toBe(true)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('flags a context line inside a hunk as true', () => {
|
|
38
|
+
const [f] = annotateChangedLines([finding({ line: 4 })], SAMPLE)
|
|
39
|
+
expect(f?.onChangedLine).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('flags an anchor outside any hunk as false', () => {
|
|
43
|
+
const [f] = annotateChangedLines([finding({ line: 99 })], SAMPLE)
|
|
44
|
+
expect(f?.onChangedLine).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('flags a finding whose file is absent from the diff as false', () => {
|
|
48
|
+
const [f] = annotateChangedLines([finding({ file: 'src/other.ts', line: 1 })], SAMPLE)
|
|
49
|
+
expect(f?.onChangedLine).toBe(false)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('leaves unanchorable findings null', () => {
|
|
53
|
+
const [f] = annotateChangedLines([finding({ line: null })], SAMPLE)
|
|
54
|
+
expect(f?.onChangedLine).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('leaves everything null when the diff is unavailable', () => {
|
|
58
|
+
const [f] = annotateChangedLines([finding({ line: 3 })], '')
|
|
59
|
+
expect(f?.onChangedLine).toBeNull()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('matches paths that differ only by a b/ prefix', () => {
|
|
63
|
+
const [f] = annotateChangedLines([finding({ file: 'b/src/a.ts', line: 2 })], SAMPLE)
|
|
64
|
+
expect(f?.onChangedLine).toBe(true)
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
buildNewSideLineIndex,
|
|
4
|
+
filePathMatches,
|
|
5
|
+
parseUnifiedDiffToHunks,
|
|
6
|
+
splitDiffByFile,
|
|
7
|
+
} from '../diff-utils.ts'
|
|
3
8
|
|
|
4
9
|
const SAMPLE = `diff --git a/src/a.ts b/src/a.ts
|
|
5
10
|
index abc..def 100644
|
|
@@ -93,6 +98,20 @@ describe('splitDiffByFile', () => {
|
|
|
93
98
|
})
|
|
94
99
|
})
|
|
95
100
|
|
|
101
|
+
describe('buildNewSideLineIndex', () => {
|
|
102
|
+
test('collects added and context new-side line numbers per file', () => {
|
|
103
|
+
const index = buildNewSideLineIndex(SAMPLE)
|
|
104
|
+
// src/a.ts: context(1), added(2), added(3), context(4). Removed line has no new-side number.
|
|
105
|
+
expect([...(index.get('src/a.ts') ?? [])].sort((a, b) => a - b)).toEqual([1, 2, 3, 4])
|
|
106
|
+
// src/b.ts: two added lines.
|
|
107
|
+
expect([...(index.get('src/b.ts') ?? [])].sort((a, b) => a - b)).toEqual([1, 2])
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('returns empty map for empty input', () => {
|
|
111
|
+
expect(buildNewSideLineIndex('').size).toBe(0)
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
96
115
|
describe('filePathMatches', () => {
|
|
97
116
|
test('exact match', () => {
|
|
98
117
|
expect(filePathMatches('src/a.ts', 'src/a.ts')).toBe(true)
|
|
@@ -10,18 +10,30 @@ test('returns ok when all binaries resolve', async () => {
|
|
|
10
10
|
})
|
|
11
11
|
expect(result.ok).toBe(true)
|
|
12
12
|
expect(result.missing).toEqual([])
|
|
13
|
+
expect(result.missingOptional).toEqual([])
|
|
13
14
|
})
|
|
14
15
|
|
|
15
|
-
test('returns missing list when binaries do not resolve', async () => {
|
|
16
|
+
test('returns missing list when required binaries do not resolve', async () => {
|
|
16
17
|
const result = await preflight({
|
|
17
18
|
bun: 'bun',
|
|
18
19
|
gh: 'definitely-not-a-binary-xyz123',
|
|
19
|
-
codex: '
|
|
20
|
+
codex: 'echo',
|
|
20
21
|
git: 'git',
|
|
21
22
|
})
|
|
22
23
|
expect(result.ok).toBe(false)
|
|
23
24
|
expect(result.missing).toContain('gh')
|
|
24
|
-
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('missing codex is optional and does not abort the run', async () => {
|
|
28
|
+
const result = await preflight({
|
|
29
|
+
bun: 'bun',
|
|
30
|
+
gh: 'echo',
|
|
31
|
+
codex: 'also-not-real-abc456',
|
|
32
|
+
git: 'git',
|
|
33
|
+
})
|
|
34
|
+
expect(result.ok).toBe(true)
|
|
35
|
+
expect(result.missing).not.toContain('codex')
|
|
36
|
+
expect(result.missingOptional).toContain('codex')
|
|
25
37
|
})
|
|
26
38
|
|
|
27
39
|
test('renderInstallHint produces a single-line message per missing tool', () => {
|
|
@@ -92,7 +92,7 @@ test('each pipeline stage exposes a short sublabel so first-timers can learn wha
|
|
|
92
92
|
})
|
|
93
93
|
expect(html).toContain('class="hint"')
|
|
94
94
|
expect(html).toContain('five reviewers in parallel')
|
|
95
|
-
expect(html).toContain('second opinion
|
|
95
|
+
expect(html).toContain('independent second opinion')
|
|
96
96
|
})
|
|
97
97
|
|
|
98
98
|
test('renderProgressHtml includes the archived banner element', () => {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { buildNewSideLineIndex, filePathMatches } from './diff-utils.ts'
|
|
2
|
+
import type { ReviewFinding } from './types.ts'
|
|
3
|
+
|
|
4
|
+
function lookup(index: Map<string, Set<number>>, file: string): Set<number> | undefined {
|
|
5
|
+
const direct = index.get(file)
|
|
6
|
+
if (direct) return direct
|
|
7
|
+
for (const [key, set] of index) {
|
|
8
|
+
if (filePathMatches(key, file)) return set
|
|
9
|
+
}
|
|
10
|
+
return undefined
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Annotate each finding with `onChangedLine`: whether its anchor falls on a line
|
|
15
|
+
* this PR added or kept as context within a changed hunk.
|
|
16
|
+
*
|
|
17
|
+
* - `true` — anchor is inside a changed hunk (plausibly introduced/triggered here).
|
|
18
|
+
* - `false` — anchor is on code this PR did not touch (pre-existing / unrelated).
|
|
19
|
+
* - `null` — undecidable: the finding has no line anchor, or the diff is
|
|
20
|
+
* unavailable (e.g. archived-run replay).
|
|
21
|
+
*
|
|
22
|
+
* This is a deterministic input for the critic so it never has to eyeball
|
|
23
|
+
* "was this introduced by the PR?" from prose alone.
|
|
24
|
+
*/
|
|
25
|
+
export function annotateChangedLines(findings: ReviewFinding[], diff: string): ReviewFinding[] {
|
|
26
|
+
if (!diff.trim()) {
|
|
27
|
+
return findings.map((f) => ({ ...f, onChangedLine: null }))
|
|
28
|
+
}
|
|
29
|
+
const index = buildNewSideLineIndex(diff)
|
|
30
|
+
return findings.map((f) => {
|
|
31
|
+
if (f.line == null || !f.file) return { ...f, onChangedLine: null }
|
|
32
|
+
const set = lookup(index, f.file)
|
|
33
|
+
if (!set) return { ...f, onChangedLine: false }
|
|
34
|
+
return { ...f, onChangedLine: set.has(f.line) }
|
|
35
|
+
})
|
|
36
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { appendFile, readdir, readFile, writeFile } from 'node:fs/promises'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
+
import { annotateChangedLines } from './changed-lines.ts'
|
|
3
4
|
import { deduplicateFindings } from './dedupe.ts'
|
|
4
5
|
import { verifyEvidence } from './evidence-filter.ts'
|
|
5
6
|
import { DEFAULT_THRESHOLD, scoreRisk } from './score.ts'
|
|
@@ -77,8 +78,15 @@ export async function runDedupe(runDir: string, options: RunDedupeOptions = {}):
|
|
|
77
78
|
const deduped = deduplicateFindings(collected)
|
|
78
79
|
const scored = deduped.map((f) => ({ ...f, score: scoreRisk(f.risk) }))
|
|
79
80
|
const evidence = await verifyEvidence(scored, join(runDir, 'worktree'))
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
let diff = ''
|
|
82
|
+
try {
|
|
83
|
+
diff = await readFile(join(runDir, 'diff.patch'), 'utf8')
|
|
84
|
+
} catch {
|
|
85
|
+
diff = ''
|
|
86
|
+
}
|
|
87
|
+
const annotated = annotateChangedLines(evidence.kept, diff)
|
|
88
|
+
const aboveThreshold = annotated.filter((f) => (f.score ?? 0) >= threshold)
|
|
89
|
+
const belowThreshold = annotated.filter((f) => (f.score ?? 0) < threshold)
|
|
82
90
|
await writeFile(
|
|
83
91
|
join(runDir, 'findings.deduped.json'),
|
|
84
92
|
`${JSON.stringify(aboveThreshold, null, 2)}\n`,
|
|
@@ -76,6 +76,31 @@ export function parseUnifiedDiffToHunks(diff: string): DiffHunk[] {
|
|
|
76
76
|
return hunks
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Per-file set of new-side (RIGHT) line numbers that fall within a changed hunk
|
|
81
|
+
* (added or context lines). These are the lines GitHub accepts as inline-comment
|
|
82
|
+
* anchors, and also the lines we treat as "within the PR's diff" when deciding
|
|
83
|
+
* whether a finding is anchored on changed code vs pre-existing/unrelated code.
|
|
84
|
+
* Keyed by the post-image (`b/`) file path.
|
|
85
|
+
*/
|
|
86
|
+
export function buildNewSideLineIndex(diff: string): Map<string, Set<number>> {
|
|
87
|
+
const result = new Map<string, Set<number>>()
|
|
88
|
+
if (!diff) return result
|
|
89
|
+
for (const [file, chunk] of splitDiffByFile(diff)) {
|
|
90
|
+
const hunks = parseUnifiedDiffToHunks(chunk)
|
|
91
|
+
const set = new Set<number>()
|
|
92
|
+
for (const h of hunks) {
|
|
93
|
+
for (const l of h.lines) {
|
|
94
|
+
if (l.newLineNo != null && (l.type === 'added' || l.type === 'context')) {
|
|
95
|
+
set.add(l.newLineNo)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
result.set(file, set)
|
|
100
|
+
}
|
|
101
|
+
return result
|
|
102
|
+
}
|
|
103
|
+
|
|
79
104
|
export function splitDiffByFile(diff: string): Map<string, string> {
|
|
80
105
|
const m = new Map<string, string>()
|
|
81
106
|
if (!diff) return m
|
|
@@ -83,7 +108,7 @@ export function splitDiffByFile(diff: string): Map<string, string> {
|
|
|
83
108
|
for (const chunk of chunks) {
|
|
84
109
|
if (!chunk.startsWith('diff --git ')) continue
|
|
85
110
|
const headerMatch = chunk.match(FILE_HEADER)
|
|
86
|
-
if (!headerMatch
|
|
111
|
+
if (!headerMatch?.[2]) continue
|
|
87
112
|
m.set(headerMatch[2], chunk)
|
|
88
113
|
}
|
|
89
114
|
return m
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import { appendFile, readFile, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
-
import {
|
|
4
|
+
import { buildNewSideLineIndex } from './diff-utils.ts'
|
|
5
5
|
import { formatFindingDescriptionMarkdown } from './finding-description.ts'
|
|
6
6
|
import {
|
|
7
7
|
type FocusId,
|
|
@@ -11,29 +11,6 @@ import {
|
|
|
11
11
|
type Severity,
|
|
12
12
|
} from './types.ts'
|
|
13
13
|
|
|
14
|
-
/**
|
|
15
|
-
* Build a per-file set of RIGHT-side line numbers that GitHub's PR Reviews API
|
|
16
|
-
* will accept as inline-comment anchors (added or context lines within hunks).
|
|
17
|
-
* Returns an empty map when the diff is empty/unavailable.
|
|
18
|
-
*/
|
|
19
|
-
function buildValidRightLines(diff: string): Map<string, Set<number>> {
|
|
20
|
-
const result = new Map<string, Set<number>>()
|
|
21
|
-
if (!diff) return result
|
|
22
|
-
for (const [file, chunk] of splitDiffByFile(diff)) {
|
|
23
|
-
const hunks = parseUnifiedDiffToHunks(chunk)
|
|
24
|
-
const set = new Set<number>()
|
|
25
|
-
for (const h of hunks) {
|
|
26
|
-
for (const l of h.lines) {
|
|
27
|
-
if (l.newLineNo != null && (l.type === 'added' || l.type === 'context')) {
|
|
28
|
-
set.add(l.newLineNo)
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
result.set(file, set)
|
|
33
|
-
}
|
|
34
|
-
return result
|
|
35
|
-
}
|
|
36
|
-
|
|
37
14
|
export type PostInput = {
|
|
38
15
|
runDir: string
|
|
39
16
|
findingIds: string[]
|
|
@@ -555,7 +532,7 @@ export async function postFindingsAsReview(input: PostReviewInput): Promise<Post
|
|
|
555
532
|
let validRightLines: Map<string, Set<number>> | null = null
|
|
556
533
|
try {
|
|
557
534
|
const diff = await readFile(join(input.runDir, 'diff.patch'), 'utf8')
|
|
558
|
-
validRightLines =
|
|
535
|
+
validRightLines = buildNewSideLineIndex(diff)
|
|
559
536
|
} catch {
|
|
560
537
|
// diff.patch absent (archived/legacy runs); skip validation and trust caller.
|
|
561
538
|
validRightLines = null
|
|
@@ -7,10 +7,17 @@ export type Deps = {
|
|
|
7
7
|
|
|
8
8
|
export type PreflightResult = {
|
|
9
9
|
ok: boolean
|
|
10
|
+
/** Required binaries that did not resolve; any entry here aborts the run. */
|
|
10
11
|
missing: string[]
|
|
12
|
+
/** Optional binaries that did not resolve; these only degrade features, they never abort. */
|
|
13
|
+
missingOptional: string[]
|
|
11
14
|
resolved: Record<keyof Deps, string | null>
|
|
12
15
|
}
|
|
13
16
|
|
|
17
|
+
// codex only powers the optional peer-review stage; a missing codex degrades that
|
|
18
|
+
// stage to skipped rather than blocking the whole run.
|
|
19
|
+
const OPTIONAL_DEPS: ReadonlySet<keyof Deps> = new Set<keyof Deps>(['codex'])
|
|
20
|
+
|
|
14
21
|
export async function preflight(deps: Deps): Promise<PreflightResult> {
|
|
15
22
|
const resolved: Record<keyof Deps, string | null> = {
|
|
16
23
|
bun: Bun.which(deps.bun),
|
|
@@ -18,8 +25,12 @@ export async function preflight(deps: Deps): Promise<PreflightResult> {
|
|
|
18
25
|
codex: Bun.which(deps.codex),
|
|
19
26
|
git: Bun.which(deps.git),
|
|
20
27
|
}
|
|
21
|
-
const
|
|
22
|
-
|
|
28
|
+
const unresolved = (Object.keys(resolved) as Array<keyof Deps>).filter(
|
|
29
|
+
(k) => resolved[k] === null,
|
|
30
|
+
)
|
|
31
|
+
const missing = unresolved.filter((k) => !OPTIONAL_DEPS.has(k))
|
|
32
|
+
const missingOptional = unresolved.filter((k) => OPTIONAL_DEPS.has(k))
|
|
33
|
+
return { ok: missing.length === 0, missing, missingOptional, resolved }
|
|
23
34
|
}
|
|
24
35
|
|
|
25
36
|
const HINTS: Record<string, string> = {
|
|
@@ -19,7 +19,7 @@ const STAGE_HINT: Record<StageId, string> = {
|
|
|
19
19
|
specialists: 'five reviewers in parallel',
|
|
20
20
|
dedupe: 'merge overlaps',
|
|
21
21
|
critic: 'keep the high-signal ones',
|
|
22
|
-
'peer-review': 'second opinion
|
|
22
|
+
'peer-review': 'independent second opinion',
|
|
23
23
|
report: 'render this page',
|
|
24
24
|
post: 'comment on the PR',
|
|
25
25
|
}
|
|
@@ -30,7 +30,7 @@ const STAGE_NOW_DOING: Record<StageId, string> = {
|
|
|
30
30
|
specialists: 'Five reviewers reading the diff in parallel',
|
|
31
31
|
dedupe: 'Merging overlapping findings',
|
|
32
32
|
critic: 'Keeping only the high-signal ones',
|
|
33
|
-
'peer-review': '
|
|
33
|
+
'peer-review': 'Getting an independent second opinion',
|
|
34
34
|
report: 'Composing the report page',
|
|
35
35
|
post: 'Ready to post; switch tabs to pick findings',
|
|
36
36
|
}
|
|
@@ -32,7 +32,18 @@ export async function runSetup(input: RunSetupInput): Promise<number> {
|
|
|
32
32
|
await cleanup(input.runDir)
|
|
33
33
|
return 3
|
|
34
34
|
}
|
|
35
|
-
|
|
35
|
+
if (preResult.missingOptional.length > 0) {
|
|
36
|
+
await logLine(input.runDir, {
|
|
37
|
+
stage: 'preflight',
|
|
38
|
+
status: 'done',
|
|
39
|
+
missingOptional: preResult.missingOptional,
|
|
40
|
+
})
|
|
41
|
+
process.stderr.write(
|
|
42
|
+
`magpie: optional dependency unavailable, some stages will be skipped:\n${renderInstallHint(preResult.missingOptional)}\n`,
|
|
43
|
+
)
|
|
44
|
+
} else {
|
|
45
|
+
await logLine(input.runDir, { stage: 'preflight', status: 'done' })
|
|
46
|
+
}
|
|
36
47
|
|
|
37
48
|
const fetched = await fetchPr({
|
|
38
49
|
ghBin: deps.gh,
|
|
@@ -54,6 +54,11 @@ export type ReviewFinding = {
|
|
|
54
54
|
mergedFrom?: MergedFromEntry[]
|
|
55
55
|
/** Derived 0-10 importance score from risk fields. Populated during dedupe. */
|
|
56
56
|
score?: number
|
|
57
|
+
/**
|
|
58
|
+
* Whether the anchor sits on a line this PR changed. Populated during dedupe
|
|
59
|
+
* from the diff; `null` when not anchorable or the diff is unavailable.
|
|
60
|
+
*/
|
|
61
|
+
onChangedLine?: boolean | null
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
const SEVERITY_SYNONYMS: Record<string, Severity> = {
|
|
@@ -309,6 +314,9 @@ export function parseFinding(raw: unknown): ReviewFinding {
|
|
|
309
314
|
domain: (r.domain as ReviewFinding['domain']) ?? null,
|
|
310
315
|
mergedFrom: Array.isArray(r.mergedFrom) ? (r.mergedFrom as MergedFromEntry[]) : undefined,
|
|
311
316
|
...(typeof r.score === 'number' && Number.isFinite(r.score) ? { score: r.score } : {}),
|
|
317
|
+
...(typeof r.onChangedLine === 'boolean' || r.onChangedLine === null
|
|
318
|
+
? { onChangedLine: r.onChangedLine as boolean | null }
|
|
319
|
+
: {}),
|
|
312
320
|
}
|
|
313
321
|
}
|
|
314
322
|
|
package/skills/magpie/skill.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magpie",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Interactive PR review pipeline. Runs five parallel specialist subagents (security, bugs, performance, code-smells, architecture), dedupes findings, applies a critic rubric, peer-reviews via codex exec, and serves an interactive HTML report for selecting findings to post via gh. Bundles a Bun CLI installed onto PATH via the skill's postinstall step. Use when the user asks to review a GitHub pull request.",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Interactive PR review pipeline. Runs five parallel specialist subagents (security, bugs, performance, code-smells, architecture), dedupes findings, applies a critic rubric, peer-reviews via codex exec (falling back to a Claude second opinion when codex is unavailable), and serves an interactive HTML report for selecting findings to post via gh. Bundles a Bun CLI installed onto PATH via the skill's postinstall step. Use when the user asks to review a GitHub pull request.",
|
|
5
5
|
"author": "iceinvein",
|
|
6
6
|
"type": "prompt",
|
|
7
7
|
"tools": [
|