Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,25 @@
All notable changes to Splus. Format follows [Keep a Changelog](https://keepachangelog.com);
this project uses [semantic versioning](https://semver.org).

## [Unreleased]

Closer to the PR review workflow: land a verified Splus review on a GitHub pull request.

### Added
- **`prReview` MCP tool** + **`@splus/shared` diff-anchor mapper** — turns the agent's
verified survivors into a ready-to-post GitHub Pull Request Reviews payload. The mapper
(`buildDiffAnchorIndex` / `anchorFinding` / `buildReviewPayload`) is pure and
deterministic: it walks the PR's unified diff, resolves each finding's `file:line` to a
RIGHT-side inline anchor (multi-line within a hunk; collapses cross-hunk), folds
out-of-diff findings into the summary (never dropped), and picks the review event from
the must-fix count. The server stays read-only and never shells `gh` — it returns the
JSON and the `gh api … /reviews` command for the agent to post. The "what" (comment
prose) stays the agent's; the "where" (the anchor) is deterministic.
- **`splus-pr-review` skill** — drives the round-trip: resolve the PR (`gh pr view` →
base/head/number) → run the existing review protocol scoped to the PR's `base..HEAD` →
emit verified survivors as a real PR review (inline comments + verdict). Wired into the
installer for Claude Code / Codex / OpenCode.

## [1.1.0] — 2026-06-09

Dynamic grounding, history facts, a checkable protocol, and memory that ages.
Expand Down
27 changes: 27 additions & 0 deletions docs/TOOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ API key and no cloud step; the coding agent connected over stdio is the reviewer
| [`mute`](#mute) | yes | Silence an entire rule for this repo. |
| [`learnings`](#learnings) | no | List what's been learned on this repo. |
| [`report`](#report) | no | Audit protocol coverage deterministically, then render the offline HTML report. |
| [`prReview`](#prreview) | no | Anchor verified findings to the diff → a ready-to-post GitHub PR review payload. |
| [`index`](#index) | yes | Build a SCIP index for compiler-grade blast radius. |

## Typical flow
Expand Down Expand Up @@ -265,6 +266,32 @@ Read-only.

---

## `prReview`

The PR-native deliverable — `report`'s sibling for when the review IS a GitHub
pull request. You supply your **verified** survivors and a summary; this is the
deterministic *where* (the *what* — every comment's prose — is yours). It
recomputes the diff for `mode`/`base` (use the **same** scope you reviewed),
anchors each finding to a real **RIGHT-side** diff line, folds any **out-of-diff**
finding (an unchanged caller the change breaks) into the summary instead of
dropping it, picks the review **event** from the must-fix count (`must-fix > 0` →
`REQUEST_CHANGES`; else `COMMENT`, or `APPROVE` when `approveWhenClean`), and tags
each comment with a hidden `<!-- splus:<id> -->` marker for re-review dedup. It
**does not touch the network** — it returns the exact JSON and the `gh api …
/reviews --input` command for **you** to post. Read-only. (The `splus-pr-review`
skill drives the whole flow: resolve the PR → review `base..HEAD` → post.)

| Param | Type | Default | Description |
|---|---|---|---|
| `root` | string | server CWD | Repo root. |
| `mode` | `working` \| `staged` \| `base` \| `all` | `working` | The scope you reviewed — recomputes the diff anchors resolve against. A PR is `base`. |
| `base` | string | — | Base ref — required when `mode: "base"` (the PR base). |
| `summary` | string | — | The review summary body (your prose; markdown + mermaid render on GitHub). |
| `approveWhenClean` | boolean | `false` | With zero must-fix, `APPROVE` rather than just `COMMENT`. |
| `findings` | object[] | — | Your verified survivors: `{ id?, tier, file, line, endLine?, body }`. Tiers drive the verdict; `body` is the comment markdown (rationale + a `suggestion` block). |

---

## `index`

Build a compiler-grade **SCIP index** so cross-file blast radius resolves precisely
Expand Down
12 changes: 6 additions & 6 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -333,36 +333,36 @@ if [ -z "${SPLUS_NO_WIRE:-}" ] && [ -d "$INSTALL_DIR/skills" ]; then
# Claude Code — native skills (auto-triggered by name + user-invocable).
if command -v claude >/dev/null 2>&1 || [ -d "$HOME/.claude" ]; then
mkdir -p "$HOME/.claude/skills"
for s in review prefs; do
for s in review pr-review prefs; do
[ -d "$INSTALL_DIR/skills/$s" ] || continue
rm -rf "$HOME/.claude/skills/splus-$s"
cp -R "$INSTALL_DIR/skills/$s" "$HOME/.claude/skills/splus-$s"
done
ok "Claude Code skills (splus-review, splus-prefs)"
ok "Claude Code skills (splus-review, splus-pr-review, splus-prefs)"
fi

# Codex — custom prompts, slash-invocable (/splus-review).
if command -v codex >/dev/null 2>&1 || [ -d "$HOME/.codex" ]; then
mkdir -p "$HOME/.codex/prompts"
for s in review prefs; do
for s in review pr-review prefs; do
[ -f "$INSTALL_DIR/skills/$s/SKILL.md" ] || continue
{ skill_body "$INSTALL_DIR/skills/$s/SKILL.md"; skill_refs "$s"; } > "$HOME/.codex/prompts/splus-$s.md"
done
ok "Codex prompts (/splus-review, /splus-prefs)"
ok "Codex prompts (/splus-review, /splus-pr-review, /splus-prefs)"
fi

# OpenCode — commands, slash-invocable (/splus-review).
if command -v opencode >/dev/null 2>&1 || [ -d "$HOME/.config/opencode" ]; then
mkdir -p "$HOME/.config/opencode/command"
for s in review prefs; do
for s in review pr-review prefs; do
[ -f "$INSTALL_DIR/skills/$s/SKILL.md" ] || continue
{
printf -- '---\ndescription: %s\n---\n' "$(skill_desc "$INSTALL_DIR/skills/$s/SKILL.md")"
skill_body "$INSTALL_DIR/skills/$s/SKILL.md"
skill_refs "$s"
} > "$HOME/.config/opencode/command/splus-$s.md"
done
ok "OpenCode commands (/splus-review, /splus-prefs)"
ok "OpenCode commands (/splus-review, /splus-pr-review, /splus-prefs)"
fi
fi

Expand Down
113 changes: 110 additions & 3 deletions packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
* floor — re-ground on the deterministic finding floor for a scope (no directive)
* preferences — show the merged SPLUS.md contract (repo + ~/.splus)
* report — render the review as a standalone offline HTML report (final step)
* prReview — assemble a diff-anchored GitHub PR review payload (the PR-native deliverable)
* dismiss — teach Splus a finding is noise (generalizes semantically)
* accept — teach Splus a finding was real (reinforces + stores recallable memory)
* note — remember a discovered repo convention (→ recall)
Expand All @@ -43,6 +44,7 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import {
applyPolicy,
buildReviewPayload,
changedExportedSymbols,
diffText,
inspect as engineInspect,
Expand All @@ -54,6 +56,7 @@ import {
type InspectKind,
type Report,
type SplusConfig,
type VerifiedFinding,
} from "@splus/shared";
import {
applySuppression,
Expand Down Expand Up @@ -94,7 +97,7 @@ When the user asks you to review code:
3. VERIFY every finding by trying to refute it against the cited line. Drop any you can't defend. Then REPORT survivors as must-fix / concern / nit with file:line and a concrete fix.
4. TEACH the repo: \`dismiss <id>\` for noise, \`accept <id>\` for real, \`note\` to record a convention. \`preferences\` shows the active \`SPLUS.md\`; \`recall\` surfaces what was learned here before.

Other tools: \`report\` audits your protocol coverage deterministically (pass \`keptIds\`; it sees which exports you actually inspected and which floor findings got an explicit fate) and renders the offline HTML deliverable, \`mute\` silences a rule, \`learnings\` lists what's been taught, \`index\` builds a SCIP index for precise blast radius.`;
Other tools: \`report\` audits your protocol coverage deterministically (pass \`keptIds\`; it sees which exports you actually inspected and which floor findings got an explicit fate) and renders the offline HTML deliverable, \`prReview\` assembles a diff-anchored GitHub PR review payload to post with \`gh\` (the PR-native deliverable — reviewing a pull request), \`mute\` silences a rule, \`learnings\` lists what's been taught, \`index\` builds a SCIP index for precise blast radius.`;

const server = new McpServer({ name: "splus", version: VERSION }, { instructions: SERVER_INSTRUCTIONS });

Expand Down Expand Up @@ -285,7 +288,7 @@ function discoveryDirective(files: string[], changedSymbols: string[] = []): str
"4. VERIFY — before posting anything, re-read each candidate's cited line and try to REFUTE it. Drop any you can't defend (already handled nearby, speculative, the line doesn't actually demonstrate it). A wrong comment costs more than a missed nit.",
"5. REPORT — the survivors as must-fix / concern / nit with file:line and a concrete fix. Never invent a finding; every claim cites a real line.",
"6. TEACH — `dismiss <id>` when the user agrees something is noise, `accept <id>` when they act on a real one — Splus learns this repo both ways.",
"7. RENDER — the deliverable that ends the review: call the `report` tool with `keptIds` (the floor ids your verified review keeps). Its response OPENS with a deterministic protocol audit — computed from this session's actual tool calls — certifying every changed export above was `inspect`ed and every floor finding got an explicit fate (kept / dismissed / accepted). Close any gap it lists, then fill the returned HTML template with the verdict + your verified survivors + the file-level impact graph, and write `splus-report.html`. One self-contained, offline file — the artifact a dev keeps next to the diff.",
"7. RENDER — the deliverable that ends the review: call the `report` tool with `keptIds` (the floor ids your verified review keeps). Its response OPENS with a deterministic protocol audit — computed from this session's actual tool calls — certifying every changed export above was `inspect`ed and every floor finding got an explicit fate (kept / dismissed / accepted). Close any gap it lists, then fill the returned HTML template with the verdict + your verified survivors + the file-level impact graph, and write `splus-report.html`. One self-contained, offline file — the artifact a dev keeps next to the diff. When the review IS a GitHub pull request (you reviewed `mode:base` against the PR base), the PR-native deliverable is `prReview` instead: it anchors your verified survivors to the diff and hands back the `gh api` payload to post them as an actual PR review (inline comments + verdict). Still close the audit first.",
].join("\n");
}

Expand Down Expand Up @@ -623,6 +626,110 @@ server.registerTool(
async ({ root, keptIds }) => ok(`${auditBlock(rootOf(root), keptIds)}\n\n${reportInstructions()}`),
);

// --- prReview (land the review on a GitHub PR) -----------------------------

/**
* Render the unanchored findings — those whose line isn't in the diff (an
* unchanged caller the change breaks, a whole-file concern) — as a deterministic
* markdown appendix. They can't be inline comments, so they ride in the summary
* body; never silently dropped.
*/
function unanchoredMarkdown(unanchored: VerifiedFinding[]): string {
if (!unanchored.length) return "";
const rows = unanchored
.map((f) => `- **${f.tier}** \`${f.file}:${f.line}\` — ${f.body.replace(/\n+/g, " ")}`)
.join("\n");
return `\n\n### Findings outside the diff\n${rows}`;
}

server.registerTool(
"prReview",
{
title: "Assemble a GitHub PR review payload",
description:
"The PR-native deliverable: turn your VERIFIED findings into a ready-to-post GitHub Pull " +
"Request Reviews API payload, each anchored to the right diff line. YOU are still the driver — " +
"this does NOT touch the network or shell `gh`; it recomputes the diff for `mode`/`base` " +
"(use the SAME scope you reviewed), resolves each finding to a RIGHT-side inline anchor (or " +
"folds it into the summary as an out-of-diff note — never dropped), picks the review event " +
"from the must-fix count (must-fix>0 → REQUEST_CHANGES; else COMMENT, or APPROVE when " +
"`approveWhenClean`), and tags each comment with a hidden id marker so a re-review can dedup. " +
"Returns the exact JSON to POST and the `gh api` command to post it. Run AFTER you've verified " +
"every survivor — this is the report's PR-native sibling, not a substitute for the protocol.",
inputSchema: {
root: z.string().optional().describe("Repo root (default: server CWD)."),
mode: z
.enum(["working", "staged", "base", "all"])
.optional()
.describe("The scope you reviewed — used to recompute the diff the anchors resolve against (default 'working'; a PR is 'base')."),
base: z.string().optional().describe("Base git ref — required when mode='base' (the PR base)."),
summary: z.string().describe("The review summary body (your prose; markdown + mermaid render on GitHub)."),
approveWhenClean: z
.boolean()
.optional()
.describe("When there are no must-fix findings, APPROVE rather than just COMMENT (default false)."),
findings: z
.array(
z.object({
id: z.string().optional().describe("Floor/agent finding id — embedded as a dedup marker."),
tier: z.enum(["must-fix", "concern", "nit"]),
file: z.string().describe("Repo-relative path (matches the diff)."),
line: z.number().describe("New-side line the finding starts on."),
endLine: z.number().optional().describe("New-side end line for a multi-line finding."),
body: z.string().describe("The comment markdown — rationale + a concrete fix / ```suggestion block."),
}),
)
.describe("Your VERIFIED survivors. Order is preserved; tiers drive the verdict."),
},
annotations: { readOnlyHint: true, openWorldHint: false },
},
async ({ root, mode, base, summary, approveWhenClean, findings }) => {
const repo = rootOf(root);
const m = (mode ?? "working") as ReviewMode;
if (m === "base" && !base) return fail("mode='base' requires a `base` ref (the PR base).");
const diff = diffText(repo, toMode(m, base ?? null));
if (!diff.trim()) {
return fail(
`No diff for mode='${m}'${base ? ` base='${base}'` : ""} — nothing to anchor against. ` +
`(mode='all' has no diff; for a PR use mode='base' with the PR's base ref.)`,
);
}
const payload = buildReviewPayload({
diff,
summary,
approveWhenClean,
findings: findings as VerifiedFinding[],
});
// Out-of-diff findings can't be inline — append them to the body so the PR
// review still carries them. Deterministic rendering; the prose was the agent's.
const postBody = payload.body + unanchoredMarkdown(payload.unanchored);
const post = {
event: payload.event,
body: postBody,
comments: payload.comments,
};
const recipe = [
"=== Splus · post this as a PR review (you drive the network call) ===",
`Event: ${payload.event} · ${payload.comments.length} inline comment(s)` +
(payload.unanchored.length ? ` · ${payload.unanchored.length} folded into the summary (out of diff)` : ""),
"",
"1. Resolve the PR coordinates (if you haven't):",
" gh pr view --json number,baseRefName,headRefName,headRepositoryOwner,url",
"2. Write the payload below to a file (e.g. .splus-cache/pr-review.json), then POST it:",
" gh api repos/{owner}/{repo}/pulls/{number}/reviews --method POST --input .splus-cache/pr-review.json",
" (commit_id is optional — GitHub defaults to the PR's latest commit.)",
"",
"If the API 422s on a comment, that line left the diff since you reviewed — re-run `review` on the",
"current head and rebuild. To avoid double-posting on a re-review, first minimize/resolve prior",
"Splus comments (they carry a `<!-- splus:<id> -->` marker) or skip ids already present on the PR.",
"",
"--- PAYLOAD (POST verbatim) ---",
JSON.stringify(post, null, 2),
].join("\n");
return ok(recipe);
},
);

// --- dismiss ---------------------------------------------------------------

server.registerTool(
Expand Down Expand Up @@ -953,7 +1060,7 @@ async function main(): Promise<void> {
await server.connect(transport);
process.stderr.write(
"splus-mcp ready (stdio) — local engine, no network · you are the reviewer; " +
"tools: review, inspect, floor, preferences, report, dismiss, accept, note, recall, mute, learnings, index\n",
"tools: review, inspect, floor, preferences, report, prReview, dismiss, accept, note, recall, mute, learnings, index\n",
);
}

Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,3 +349,4 @@ export async function changedExportedSymbols(root: string, files: string[], diff

// The per-repo review contract (`SPLUS.md`): loader + binding policy.
export * from "./splusMd.js";
export * from "./prReview.js";
Loading