From 836b4d93c8f32548a18e471d7ddfcb86e60da850 Mon Sep 17 00:00:00 2001 From: Russell Smith Date: Tue, 9 Jun 2026 15:18:00 -0700 Subject: [PATCH 1/4] Add agent-authoring CLI primitives: --scaffold, schema, render --redact fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the "AI authors the story, renderer stays separate" thesis a first-class CLI flow so any coding agent (not just the built-in anthropic adapter) can drive patchstory: - `--scaffold` on the source commands (diff/commits/file/github) emits the editable pr-walkthrough.json (the IR) instead of rendering a site. `--emit-diff` additionally writes the exact resolved diff bytes, so the same input can be passed to `render --diff` with hunk line refs still aligned. - `schema` command prints the canonical WALKTHROUGH_JSON_SCHEMA for validation. - Fix `render --redact`: it was a no-op, because redaction only ran inside buildWalkthrough (the source paths), never in renderCommand — which parses the --diff file itself. Now render calls redactParsedDiff on the parsed diff, so secrets are masked in the embedded output and the "masked N lines" count is truthful. Adds tests/cli.test.ts covering schema output, scaffold validity + byte-identical --emit-diff, and the render --redact masking (regression guard). Skips when the bundle isn't built; CI builds before testing. Co-Authored-By: Claude Opus 4.8 --- README.md | 39 +++++++++++- packages/cli/src/args.ts | 1 + packages/cli/src/cli.ts | 133 +++++++++++++++++++++++++++++---------- tests/cli.test.ts | 94 +++++++++++++++++++++++++++ 4 files changed, 234 insertions(+), 33 deletions(-) create mode 100644 tests/cli.test.ts diff --git a/README.md b/README.md index b953686..7a9fb37 100644 --- a/README.md +++ b/README.md @@ -144,12 +144,18 @@ Commands github GitHub PR (uses `gh` if available, else public .diff) render render an existing pr-walkthrough.json serve [dir|file] serve an output folder/file on your LAN + schema print the pr-walkthrough.json JSON Schema Options - -o, --out output dir, or .html file with --single-file (default ./walkthrough) + -o, --out output dir; .html file with --single-file; + .json file (or stdout) with --scaffold (default ./walkthrough) -g, --generator none | anthropic (default none) --repo git repo to operate in (default cwd) --model model id for the anthropic generator + --scaffold emit the editable pr-walkthrough.json (the IR) instead + of rendering — for an agent or human to enrich + --emit-diff with --scaffold: also write the resolved raw diff, so the + same bytes can be passed to `render --diff` --single-file emit one self-contained .html (easy to email/attach) --redact mask secrets in the diff before generating/rendering --serve serve the result on your LAN after generating @@ -186,6 +192,37 @@ Keyboard: `j`/`k` next/prev chapter · `/` search · `e`/`c` expand/collapse all --- +## Author the story with your own AI agent + +PatchStory keeps the **story** (a JSON document) separate from the **renderer**, so you +don't need the built-in `anthropic` adapter to get an AI-written walkthrough — you can let +*your own* coding agent (Claude Code, Cursor, aider, …) author it. The agent reads the diff +in the context of the whole repo, so its narrative is usually better than a one-shot API +call, and **no API key is involved**. + +The flow is three commands: + +```bash +# 1. Scaffold a schema-valid skeleton from any source, plus the exact diff bytes. +patchstory github --scaffold -o pr-walkthrough.json --emit-diff pr.diff + +# 2. Your agent rewrites pr-walkthrough.json into a real narrative — chapter intent, +# risk, reviewer questions, verification steps. Validate against the schema anytime: +patchstory schema > pr-walkthrough.schema.json + +# 3. Render the agent's story. --redact keeps secrets out of the embedded diff. +patchstory render pr-walkthrough.json --diff pr.diff --redact --single-file -o pr.html --open +``` + +`--scaffold` works with every source command (`diff`, `commits`, `file`, `github`) and runs +the `none` heuristic to hand the agent an accurate starting point — correct `stats`, file +groupings, and `diff_hunks` line numbers — which the agent then enriches. Because +`--emit-diff` writes the *same* bytes the scaffold was computed from, the hunk line refs stay +aligned when you `render --diff` them. (`--redact` masks the embedded diff at render time; the +scaffolded IR and emitted diff are left unredacted for the agent to read.) + +--- + ## The walkthrough JSON (`pr-walkthrough.json`) This is the canonical intermediate representation. The renderer consumes it; AI diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts index 5e6c447..16ec5f8 100644 --- a/packages/cli/src/args.ts +++ b/packages/cli/src/args.ts @@ -9,6 +9,7 @@ export interface ParsedArgs { /** Flags that take no value (presence = true). */ const BOOLEAN_FLAGS = new Set([ "zip", "help", "version", "no-open", "single-file", "serve", "open", "redact", + "scaffold", ]); export function parseArgs(argv: string[]): ParsedArgs { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index d7991fd..9ee12dd 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -5,16 +5,18 @@ */ import { resolve, basename } from "node:path"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { buildWalkthrough, parseDiff, parseWalkthrough, + redactParsedDiff, resolveCommitRange, resolveDiffFile, resolveGitDiff, resolveGithubPr, diffStats, + WALKTHROUGH_JSON_SCHEMA, } from "@patchstory/core"; import type { ParsedDiff, @@ -44,13 +46,19 @@ Commands: github Walkthrough of a GitHub pull request render Render an existing pr-walkthrough.json serve [dir|file] Serve an output folder/file on your LAN + schema Print the pr-walkthrough.json JSON Schema Options: - -o, --out Output directory, or .html file with --single-file + -o, --out Output directory; .html file with --single-file; + .json file (or stdout) with --scaffold (default: ./walkthrough) -g, --generator Story generator: none | anthropic (default: none) --repo Git repo to run in (default: cwd) --model Model for the anthropic generator + --scaffold Emit the editable pr-walkthrough.json (the IR) for an + agent/human to enrich, instead of rendering a site + --emit-diff With --scaffold: also write the resolved raw diff, so + the same bytes can be passed to \`render --diff\` --single-file Emit one self-contained .html (easy to email/attach) --redact Mask secrets in the diff before generating/rendering --serve Serve the result on your LAN after generating @@ -68,6 +76,12 @@ Examples: patchstory file ./my-pr.diff --redact patchstory serve ./walkthrough --open +Author the story with your own AI agent (the IR is separate from the renderer): + patchstory github --scaffold -o pr-walkthrough.json --emit-diff pr.diff + # ...an agent edits pr-walkthrough.json into a real narrative... + patchstory render pr-walkthrough.json --diff pr.diff --redact --single-file -o pr.html --open + patchstory schema > pr-walkthrough.schema.json # validate against this + The non-AI "none" generator always works (no API key needed). With -g anthropic + ANTHROPIC_API_KEY it asks Claude to author the story (falling back to the heuristic on error). Use --redact to keep secrets out of the @@ -116,47 +130,37 @@ async function main() { return; } + // `schema` is standalone: print the canonical JSON Schema for the IR. + if (command === "schema") { + process.stdout.write(JSON.stringify(WALKTHROUGH_JSON_SCHEMA, null, 2) + "\n"); + return; + } + const generator = flagStr(flags, "generator") ?? "none"; const model = flagStr(flags, "model"); const redact = !!flags.redact; const cwd = resolve(flagStr(flags, "repo") ?? process.cwd()); const singleFile = !!flags["single-file"]; + const scaffold = !!flags.scaffold; let bundle: WalkthroughBundle; let redactedCount = 0; + let rawDiffText: string | undefined; if (command === "render") { - bundle = await renderCommand(positionals, flags); - } else { - let resolved: ResolvedSource; - const arg = positionals[1]; - switch (command) { - case "diff": - if (!arg) fail("`diff` needs a range, e.g. patchstory diff main...feature"); - resolved = resolveGitDiff(arg, cwd); - break; - case "commits": - if (!arg) fail("`commits` needs a range, e.g. patchstory commits abc..def"); - resolved = resolveCommitRange(arg, cwd); - break; - case "file": - if (!arg) fail("`file` needs a path to a .diff file"); - if (!existsSync(arg)) fail(`diff file not found: ${arg}`); - resolved = resolveDiffFile(resolve(arg)); - break; - case "github": - if (!arg) fail("`github` needs a PR URL"); - resolved = await resolveGithubPr(arg); - break; - default: - fail(`unknown command "${command}". Run patchstory --help.`); + if (scaffold) { + fail("`--scaffold` builds the IR from a source; it can't be combined with `render`."); } - - if (!resolved!.rawDiff.trim()) { + const result = await renderCommand(positionals, flags, redact); + bundle = { walkthrough: result.walkthrough, diff: result.diff }; + redactedCount = result.redactedCount; + } else { + const resolved = await resolveSource(command, positionals[1], cwd); + if (!resolved.rawDiff.trim()) { fail("the diff is empty — nothing to walk through. Check your range/input."); } - - const result = await buildWalkthrough(resolved!, { + rawDiffText = resolved.rawDiff; + const result = await buildWalkthrough(resolved, { generator, generatorOptions: { model }, redact, @@ -165,6 +169,37 @@ async function main() { redactedCount = result.redactedCount; } + // --scaffold: emit the editable IR (and optionally the diff) and stop. + if (scaffold) { + const doc = bundle.walkthrough; + doc.generated_at = nowIso(); + const json = JSON.stringify(doc, null, 2) + "\n"; + const outPath = flagStr(flags, "out"); + if (outPath) { + const p = resolve(outPath); + writeFileSync(p, json); + process.stderr.write( + `\n✓ Walkthrough IR written to ${p}\n` + + ` ${doc.chapters.length} chapters · generator: ${doc.generator ?? "none"}\n` + + ` edit it into a real narrative, then: patchstory render ${outPath} --diff \n`, + ); + } else { + process.stdout.write(json); + } + const emitDiff = flagStr(flags, "emit-diff"); + if (emitDiff && rawDiffText != null) { + const p = resolve(emitDiff); + writeFileSync(p, rawDiffText); + process.stderr.write(`✓ Raw diff written to ${p} (feed it to \`render --diff\`)\n`); + } + if (redact) { + process.stderr.write( + "🛈 --redact applies when rendering the embedded diff; the emitted IR/diff are unredacted.\n", + ); + } + return; + } + const renderOpts = { generatedAt: nowIso(), toolVersion: VERSION }; const outFlag = flagStr(flags, "out") ?? "./walkthrough"; @@ -212,11 +247,37 @@ function reportDone(bundle: WalkthroughBundle, out: string, single: boolean) { ); } +/** Resolve a source command (diff/commits/file/github) into a raw diff + metadata. */ +async function resolveSource( + command: string, + arg: string | undefined, + cwd: string, +): Promise { + switch (command) { + case "diff": + if (!arg) fail("`diff` needs a range, e.g. patchstory diff main...feature"); + return resolveGitDiff(arg, cwd); + case "commits": + if (!arg) fail("`commits` needs a range, e.g. patchstory commits abc..def"); + return resolveCommitRange(arg, cwd); + case "file": + if (!arg) fail("`file` needs a path to a .diff file"); + if (!existsSync(arg)) fail(`diff file not found: ${arg}`); + return resolveDiffFile(resolve(arg)); + case "github": + if (!arg) fail("`github` needs a PR URL"); + return resolveGithubPr(arg); + default: + fail(`unknown command "${command}". Run patchstory --help.`); + } +} + /** `render` builds a bundle from an existing walkthrough JSON (+ optional diff). */ async function renderCommand( positionals: string[], flags: ReturnType["flags"], -): Promise { + redact: boolean, +): Promise { const jsonPath = positionals[1]; if (!jsonPath) fail("`render` needs a path to pr-walkthrough.json"); if (!existsSync(jsonPath)) fail(`file not found: ${jsonPath}`); @@ -224,10 +285,18 @@ async function renderCommand( const walkthrough = parseWalkthrough(readFileSync(resolve(jsonPath), "utf8")); let diff: ParsedDiff; + let redactedCount = 0; const diffPath = flagStr(flags, "diff"); if (diffPath) { if (!existsSync(diffPath)) fail(`diff file not found: ${diffPath}`); diff = parseDiff(readFileSync(resolve(diffPath), "utf8")); + // Unlike the source commands, `render` parses the diff here — so redaction + // has to happen here too, or `--redact` would silently do nothing. + if (redact) { + const r = redactParsedDiff(diff); + diff = r.diff; + redactedCount = r.count; + } if (!walkthrough.stats || !walkthrough.stats.files_changed) { walkthrough.stats = diffStats(diff); } @@ -246,7 +315,7 @@ async function renderCommand( }; } - return { walkthrough, diff }; + return { walkthrough, diff, redactedCount }; } main().catch((err) => { diff --git a/tests/cli.test.ts b/tests/cli.test.ts new file mode 100644 index 0000000..fd0d337 --- /dev/null +++ b/tests/cli.test.ts @@ -0,0 +1,94 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { validateWalkthrough } from "../packages/core/src/schema.ts"; + +const ROOT = fileURLToPath(new URL("..", import.meta.url)); +const BIN = join(ROOT, "packages/cli/dist/patchstory.mjs"); +const SAMPLE = join(ROOT, "examples/sample.diff"); + +// These exercise the built bundle, so they need `npm run build` first (CI does). +const skip = existsSync(BIN) ? false : "run `npm run build` first"; + +function run(args: string[]): string { + return execFileSync(process.execPath, [BIN, ...args], { + encoding: "utf8", + cwd: ROOT, + maxBuffer: 64 * 1024 * 1024, + }); +} + +function tmp(): string { + return mkdtempSync(join(tmpdir(), "patchstory-cli-")); +} + +test("`schema` prints the canonical JSON Schema", { skip }, () => { + const schema = JSON.parse(run(["schema"])); + assert.match(schema.$id, /pr-walkthrough/); + assert.deepEqual(schema.required, [ + "version", "title", "summary", "source", "stats", "chapters", + ]); + assert.ok(schema.properties.chapters, "schema documents chapters"); +}); + +test("`--scaffold` emits a schema-valid IR to stdout", { skip }, () => { + const doc = JSON.parse(run(["file", SAMPLE, "--scaffold"])); + const v = validateWalkthrough(doc); + assert.equal(v.valid, true, v.errors.join("; ")); + assert.equal(doc.generator, "none"); + assert.ok(doc.generated_at, "stamps generated_at"); + assert.ok(doc.chapters.length > 0); +}); + +test("`--scaffold --emit-diff` writes the IR and the exact diff bytes", { skip }, () => { + const dir = tmp(); + const jsonPath = join(dir, "ir.json"); + const diffPath = join(dir, "used.diff"); + run(["file", SAMPLE, "--scaffold", "-o", jsonPath, "--emit-diff", diffPath]); + assert.ok(existsSync(jsonPath), "wrote the IR file"); + assert.equal( + readFileSync(diffPath, "utf8"), + readFileSync(SAMPLE, "utf8"), + "emitted diff is byte-identical to the source", + ); +}); + +test("`render --redact` masks secrets in the embedded diff", { skip }, () => { + const dir = tmp(); + const diffPath = join(dir, "secret.diff"); + writeFileSync( + diffPath, + [ + "diff --git a/.env b/.env", + "index 1..2 100644", + "--- a/.env", + "+++ b/.env", + "@@ -1,2 +1,3 @@", + " KEEP=this", + "+AWS_KEY=AKIAIOSFODNN7EXAMPLE", + "+password = hunter2supersecret", + "", + ].join("\n"), + ); + const irPath = join(dir, "ir.json"); + run(["file", diffPath, "--scaffold", "-o", irPath]); + + const plain = join(dir, "plain"); + run(["render", irPath, "--diff", diffPath, "--out", plain]); + assert.match( + readFileSync(join(plain, "data.js"), "utf8"), + /AKIAIOSFODNN7EXAMPLE/, + "without --redact the secret is embedded", + ); + + const redacted = join(dir, "redacted"); + run(["render", irPath, "--diff", diffPath, "--redact", "--out", redacted]); + const data = readFileSync(join(redacted, "data.js"), "utf8"); + assert.doesNotMatch(data, /AKIAIOSFODNN7EXAMPLE|hunter2supersecret/, "secrets are masked"); + assert.match(data, /redacted/, "mask token is present"); + assert.match(data, /KEEP=this/, "benign content is preserved"); +}); From db8063b4fd6c9c17eb727005d949ebc1f1c3bbfe Mon Sep 17 00:00:00 2001 From: Russell Smith Date: Tue, 9 Jun 2026 15:23:01 -0700 Subject: [PATCH 2/4] Ship the Claude Code skill in integrations/claude-code/ A ready-to-install skill that drives patchstory with Claude authoring the narrative (the JSON IR), built on the new CLI primitives so it stays thin: - scripts/collect.sh resolves the source (branch's open PR, else branch-vs-base, or an explicit PR#/URL/range/branch) and runs a single `patchstory scaffold --emit-diff` to produce skel.json + the exact diff. No more re-implementing redaction or rendering a whole site just to get a skeleton. - SKILL.md: the authoring rubric + the scaffold -> author -> render --redact --open flow. References `patchstory schema` as the contract. - README.md: symlink/copy install, prerequisites (Linux/macOS, node/git/gh). The top-level README's "Author the story with your own AI agent" section now links here; the recipe is agent-agnostic, this is the turnkey reference impl. Co-Authored-By: Claude Opus 4.8 --- README.md | 4 + integrations/claude-code/README.md | 51 ++++++ integrations/claude-code/patchstory/SKILL.md | 161 ++++++++++++++++++ .../claude-code/patchstory/scripts/collect.sh | 122 +++++++++++++ 4 files changed, 338 insertions(+) create mode 100644 integrations/claude-code/README.md create mode 100644 integrations/claude-code/patchstory/SKILL.md create mode 100755 integrations/claude-code/patchstory/scripts/collect.sh diff --git a/README.md b/README.md index 7a9fb37..6c61432 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,10 @@ groupings, and `diff_hunks` line numbers — which the agent then enriches. Beca aligned when you `render --diff` them. (`--redact` masks the embedded diff at render time; the scaffolded IR and emitted diff are left unredacted for the agent to read.) +A ready-to-install **Claude Code** skill that runs exactly this flow lives in +[`integrations/claude-code/`](integrations/claude-code/) — it auto-detects the source, scaffolds, +has Claude author the narrative, and renders. Other agents can follow the same three commands. + --- ## The walkthrough JSON (`pr-walkthrough.json`) diff --git a/integrations/claude-code/README.md b/integrations/claude-code/README.md new file mode 100644 index 0000000..abec4ce --- /dev/null +++ b/integrations/claude-code/README.md @@ -0,0 +1,51 @@ +# PatchStory skill for Claude Code + +A [Claude Code](https://claude.com/claude-code) skill that drives `patchstory` to produce a +human-readable, interactive walkthrough of a PR or local change — with **Claude itself +authoring the narrative** (the JSON IR), not the built-in heuristic or `-g anthropic` adapter. + +It leans on three CLI primitives so it stays thin: + +- `patchstory --scaffold --emit-diff` — get a schema-valid skeleton + the exact diff +- `patchstory schema` — the IR contract, for validation +- `patchstory render --diff … --redact` — render the agent's story with secrets masked + +## What it does + +1. Resolve the source (branch's open PR, else branch-vs-base; or an explicit PR#/URL/range). +2. Scaffold a starting `pr-walkthrough.json` + capture the diff. +3. Claude rewrites the skeleton into a real narrative — chapter intent, risk, reviewer + questions, verification steps. +4. Render one self-contained `.html` (secrets redacted) and open it in the browser. + +## Install + +Requires `patchstory` on PATH (`npm i -g patchstory`, or the scripts fall back to +`npx -y patchstory`), plus `node` >= 20, `git`, and `gh` for PR mode. Linux or macOS. + +Symlink (recommended — stays in sync as you `git pull`): + +```bash +ln -s "$(pwd)/integrations/claude-code/patchstory" ~/.claude/skills/patchstory +``` + +…or copy it: + +```bash +cp -r integrations/claude-code/patchstory ~/.claude/skills/patchstory +``` + +Then in Claude Code: `/patchstory`, or `/patchstory 123`, or "make a walkthrough of this PR". + +## Layout + +``` +integrations/claude-code/ + README.md + patchstory/ + SKILL.md # the skill definition + authoring rubric + scripts/collect.sh # resolves the source and runs `patchstory scaffold` +``` + +Other agents (Cursor, aider, …) can follow the same recipe directly — see +**“Author the story with your own AI agent”** in the [top-level README](../../README.md). diff --git a/integrations/claude-code/patchstory/SKILL.md b/integrations/claude-code/patchstory/SKILL.md new file mode 100644 index 0000000..5768fb2 --- /dev/null +++ b/integrations/claude-code/patchstory/SKILL.md @@ -0,0 +1,161 @@ +--- +name: patchstory +description: > + Turn a pull request or local change into a human-readable, interactive walkthrough + ("PR story") with the `patchstory` CLI. Auto-detects the source — the current branch's + open PR if there is one, otherwise the branch vs its default base — or takes an explicit + PR number / PR URL / git range. The agent authors the narrative itself (chapters with + intent, risk, reviewer questions, and verification steps), then patchstory renders it as + one self-contained .html with secrets redacted and opens it in the browser. + Triggers: "/patchstory", "patchstory this", "make a walkthrough of this PR", + "tell the story of this PR", "PR walkthrough", "explain this PR for a human", + "patchstory #123", "patchstory the current branch". +--- + +# patchstory — human-readable PR walkthroughs + +[`patchstory`](https://github.com/russ/patchstory) turns a diff into a self-contained +interactive HTML walkthrough: logical **chapters**, each with intent, the relevant diff +hunks, reviewer questions, a risk level, and verification steps. + +Its defining design choice: **the story (a JSON document) is separate from the renderer**, so +an agent can author the story directly. **That is this skill's job.** You read the diff and +write `pr-walkthrough.json`; patchstory renders it. You — reading the code in context — are a +better author than a one-shot API call, and no API key is involved. (patchstory's own `none` +heuristic and `-g anthropic` adapter exist, but this skill does not use them for the +narrative.) + +Local-first: no hosted service. By default it renders one self-contained `.html` and opens it +in the browser (serving on the LAN is an alternative — see Notes). + +## Prerequisites + +- `patchstory` on PATH (`npm i -g patchstory`) — else the scripts fall back to `npx -y patchstory`. +- `node` >= 20, `git`, and `gh` (for PR mode; the public `.diff` fallback only covers public repos). +- Linux or macOS (not Windows). + +## Workflow + +### 1. Resolve the source + scaffold + +Run the helper from inside the target git repo. Pass nothing to auto-detect, or pass a PR +number, PR URL, range (`main...feature`), or branch name: + +```bash +bash scripts/collect.sh # auto-detect (branch PR, else branch vs base) +bash scripts/collect.sh 123 # PR number +bash scripts/collect.sh https://github.com/org/repo/pull/123 +bash scripts/collect.sh main...my-branch +``` + +(`--repo ` targets another checkout; `--out ` overrides the work-dir root, +default `~/.cache/patchstory`.) + +It runs `patchstory scaffold` and prints `WORK`, `SKELETON`, and `RAWDIFF` paths plus the +exact render command. Under the hood: + +- `$WORK/skel.json` — a schema-valid `pr-walkthrough.json` skeleton from patchstory's `none` + heuristic: accurate `source`, `title`, `stats`, `commits`, and **`diff_hunks` line numbers**. +- `$WORK/pr.diff` — the exact diff bytes the skeleton was computed from (so hunk refs stay + aligned when you `render --diff` it). + +### 2. Read the inputs + +- `$WORK/skel.json` — your starting point. The `source`/`title`/`stats`/`commits` are correct; + the chapters are heuristic — replace their prose with a real narrative. +- `$WORK/pr.diff` — the actual diff. **Read this** to understand intent. +- Need the contract? `patchstory schema` prints the canonical JSON Schema. +- **Do not paste secret literals** (tokens, keys) into your JSON — `--redact` masks the + embedded diff at render time, not your prose. + +### 3. Author `$WORK/pr-walkthrough.json` + +Write a genuinely better narrative than the heuristic — this is the whole point. Keep +`skel.json`'s `source`, `stats`, and `commits`; rewrite everything else. Aim for a reviewer +who has never seen the change: + +- **`summary`** — 2–4 sentences: what changes and *why it matters to a human*, not a file list. +- **`themes`** — a few high-level threads (e.g. "Auth", "DB migration", "Tests"). +- **`chapters`** — order them as a **reading path**, not by directory. Group related files into + one chapter when they tell one sub-story. Each chapter: + - **`intent`** — *why* this exists / what problem it solves (the most valuable field). + - **`summary`** — what the diff in this chapter does. + - **`risk_level`** — `low|medium|high`. Raise for auth, payments, migrations, money math, + deletions, or anything externally observable. + - **`review_notes`** — sharp reviewer questions. + - **`verification_steps`** — concrete things to do/check to trust it. + - **`files`** + **`diff_hunks`** — reuse the skeleton's hunk line refs; re-summarize each. +- **`reviewer_path`** — chapter `id`s in suggested reading order. +- **`start_here`** — 1–3 `{file, reason}` entries a reviewer should open first. +- Set **`"generator": "claude"`** (or your agent's name) and keep **`"version": "0.1"`**. + +Scale effort to the diff: a tiny PR may be 1–2 chapters; a large one, 5–8. Don't pad. + +### 4. Render to one self-contained `.html` and open it + +Run the command the helper printed. `render` re-validates the JSON (surfacing schema errors), +masks secrets in the embedded diff with `--redact`, and `--open` launches the default browser +(`open` on macOS, `xdg-open` on Linux — detached, returns immediately): + +```bash +patchstory render "$WORK/pr-walkthrough.json" --diff "$WORK/pr.diff" \ + --redact --single-file --out "$WORK/walkthrough.html" --open +``` + +Report the `$WORK/walkthrough.html` path so it can be re-opened or attached. If `--open` can't +reach a browser (headless / no GUI), just report the path. + +## The walkthrough JSON schema + +Authoritative copy: `patchstory schema`. Required: `version`, `title`, `summary`, `source` +(+ `source.type` ∈ `github_pr|git_diff|commit_range|diff_file`), `stats` (`files_changed`, +`additions`, `deletions` — numbers), `chapters`. Each chapter needs a **unique** `id`, `title`, +`summary`, `risk_level` (`low|medium|high`), and `files`. `diff_hunks` items need `file`, +`start_line`, `end_line` (line numbers in the **new** file). Everything else is optional. + +```jsonc +{ + "version": "0.1", + "title": "Add multi-face media review workflow", + "summary": "Introduces a creator-approval step for media where more than one face is detected, so multi-person uploads can't auto-publish.", + "generator": "claude", + "source": { "type": "github_pr", "repo": "org/repo", "pr_number": 123, "base": "main", "head": "feature/multi-face-review" }, + "stats": { "files_changed": 12, "additions": 340, "deletions": 72 }, + "themes": ["Data model & migrations", "Detection service", "Tests"], + "reviewer_path": ["face-detection", "review-state", "tests"], + "start_here": [{ "file": "app/services/face_detection_service.rb", "reason": "Core new logic; everything else supports it." }], + "chapters": [ + { + "id": "face-detection", + "title": "Detect multiple faces in uploaded media", + "summary": "Adds metadata and detection logic for multi-face media.", + "intent": "Determine whether creator approval is needed before publishing.", + "risk_level": "medium", + "files": ["app/models/media.rb", "app/services/face_detection_service.rb"], + "diff_hunks": [ + { "file": "app/models/media.rb", "start_line": 42, "end_line": 88, "summary": "Adds face_count and review_state fields." } + ], + "review_notes": [ + "Confirm single-face uploads are not accidentally blocked.", + "What happens when face detection fails or times out?" + ], + "verification_steps": [ + "Upload media with one face.", + "Upload media with multiple faces." + ] + } + ] +} +``` + +## Notes & gotchas + +- **Redaction is handled by `patchstory render --redact`** — no manual masking step. It masks + token shapes / `KEY=value` / private keys in the embedded diff. The scaffolded skeleton and + `pr.diff` are left unredacted for you to read; just don't quote secrets in your prose. +- **Want to share on the LAN instead of opening locally?** Render to a folder (`--out + "$WORK/site"`, drop `--single-file`) and then `patchstory serve "$WORK/site"` in the + background — it binds `0.0.0.0` and prints a URL other devices can open. The `--port` is a + starting hint; if taken, serve picks the next free port and prints the real one. +- **Private PRs** need `gh` (authenticated). The public `.diff` fallback is public-repos-only. +- The work dir under `~/.cache/patchstory/` persists; old runs can be deleted freely. diff --git a/integrations/claude-code/patchstory/scripts/collect.sh b/integrations/claude-code/patchstory/scripts/collect.sh new file mode 100755 index 0000000..a844c64 --- /dev/null +++ b/integrations/claude-code/patchstory/scripts/collect.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# collect.sh [] [--repo ] [--out ] +# +# Thin wrapper that turns "what change do you mean?" into a patchstory scaffold. +# It resolves a source the way a human would expect, then runs a single +# `patchstory scaffold` to produce, in a fresh work dir: +# +# - skel.json : a schema-valid pr-walkthrough.json skeleton (the `none` +# heuristic) for the agent to enrich +# - pr.diff : the exact raw diff the skeleton was computed from, to pass +# back to `patchstory render --diff` +# +# Source resolution: +# - explicit PR number / PR URL -> github +# - explicit range "a..b" / "a...b" -> diff +# - explicit branch name -> diff (default-base...branch) +# - no arg + branch has an open PR (gh) -> github +# - no arg + no PR -> diff (default-base...HEAD) +# +# Portable across Linux and macOS (GNU + BSD userland, bash >= 3.2). Not for Windows. +set -euo pipefail + +ARG="" +REPO="" +OUT="${PATCHSTORY_WORK:-$HOME/.cache/patchstory}" + +while [ $# -gt 0 ]; do + case "$1" in + --repo) REPO="${2:?--repo needs a dir}"; shift 2 ;; + --out) OUT="${2:?--out needs a dir}"; shift 2 ;; + -h|--help) sed -n '2,20p' "$0" | sed -E 's/^# ?//'; exit 0 ;; + -*) echo "unknown flag: $1" >&2; exit 2 ;; + *) if [ -z "$ARG" ]; then ARG="$1"; shift; else echo "unexpected arg: $1" >&2; exit 2; fi ;; + esac +done + +PATCHSTORY="$(command -v patchstory || true)" +[ -n "$PATCHSTORY" ] || PATCHSTORY="npx -y patchstory" + +if [ -z "$REPO" ]; then + REPO="$(git rev-parse --show-toplevel 2>/dev/null || true)" +fi +if [ -n "$REPO" ]; then cd "$REPO"; fi + +have_gh() { command -v gh >/dev/null 2>&1; } +is_pr_url() { [[ "$1" =~ ^https?://github\.com/.+/pull/[0-9]+ ]]; } + +default_base() { + local ref + ref="$(git symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null || true)" + if [ -n "$ref" ]; then echo "${ref#refs/remotes/}"; return; fi + local b + for b in origin/main origin/master main master; do + if git rev-parse --verify --quiet "$b" >/dev/null 2>&1; then echo "$b"; return; fi + done + echo "main" +} + +# PR number -> full URL (patchstory's `github` source wants a URL). +pr_url_for() { + have_gh || { echo "gh CLI required to resolve a PR number" >&2; exit 1; } + gh pr view "$1" --json url -q .url 2>/dev/null +} + +MODE="" PR_URL="" RANGE="" SLUG="" + +if [ -z "$ARG" ]; then + if have_gh && PR_URL="$(gh pr view --json url -q .url 2>/dev/null)" && [ -n "$PR_URL" ]; then + MODE="github" + else + MODE="diff"; RANGE="$(default_base)...HEAD" + fi +elif [[ "$ARG" =~ ^[0-9]+$ ]]; then + MODE="github"; PR_URL="$(pr_url_for "$ARG")" + [ -n "$PR_URL" ] || { echo "could not resolve PR #$ARG" >&2; exit 1; } +elif is_pr_url "$ARG"; then + MODE="github"; PR_URL="$ARG" +elif [[ "$ARG" == *".."* ]]; then + MODE="diff"; RANGE="$ARG" +else + MODE="diff"; RANGE="$(default_base)...${ARG}" +fi + +if [ "$MODE" = "github" ]; then + SLUG="pr-$(printf '%s' "$PR_URL" | grep -oE '[0-9]+' | tail -1)" +else + [ -n "$REPO" ] || { echo "not in a git repo (use --repo)" >&2; exit 1; } + SLUG="$(printf '%s' "${RANGE##*..}" | tr '/ ' '--' | LC_ALL=C sed -E 's/[^A-Za-z0-9._-]//g')" +fi + +STAMP="$(date +%Y%m%d-%H%M%S)" +WORK="$OUT/${SLUG:-change}-$STAMP" +mkdir -p "$WORK" + +echo ">> mode=$MODE work=$WORK" >&2 + +if [ "$MODE" = "github" ]; then + $PATCHSTORY github "$PR_URL" --scaffold -o "$WORK/skel.json" --emit-diff "$WORK/pr.diff" >&2 +else + $PATCHSTORY diff "$RANGE" --repo "$REPO" --scaffold -o "$WORK/skel.json" --emit-diff "$WORK/pr.diff" >&2 +fi + +node -e ' + const fs = require("fs"); + const d = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + console.log("title: " + d.title); + console.log("source: " + JSON.stringify(d.source)); + console.log("stats: " + JSON.stringify(d.stats)); + console.log("commits: " + (d.commits ? d.commits.length : 0)); + console.log("skeleton chapters:"); + for (const c of d.chapters || []) console.log(" - " + c.title + " [" + (c.files || []).length + " file(s)]"); +' "$WORK/skel.json" + +cat < Date: Tue, 9 Jun 2026 15:37:48 -0700 Subject: [PATCH 3/4] Bump patchstory to 0.1.3 Releases the agent-authoring CLI primitives (--scaffold/--emit-diff, schema, and the render --redact fix) to npm. publish.yml publishes packages/cli on a GitHub Release via OIDC; npm refuses to re-publish 0.1.2, so the bump is required. Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 2 +- packages/cli/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0408c13..ff5dcd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -547,7 +547,7 @@ }, "packages/cli": { "name": "patchstory", - "version": "0.1.2", + "version": "0.1.3", "license": "MIT", "bin": { "patchstory": "dist/patchstory.mjs" diff --git a/packages/cli/package.json b/packages/cli/package.json index f268db0..02ccf25 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "patchstory", - "version": "0.1.2", + "version": "0.1.3", "description": "Generate a static, interactive PR walkthrough from a git diff or GitHub PR.", "type": "module", "license": "MIT", From cb396e8164e5f74c172e26ca54e8638e146d83af Mon Sep 17 00:00:00 2001 From: Russell Smith Date: Tue, 9 Jun 2026 15:37:48 -0700 Subject: [PATCH 4/4] Package the Claude Code skill as an installable plugin Lets users install with the marketplace instead of a manual symlink: /plugin marketplace add russ/patchstory /plugin install patchstory@russ-patchstory - .claude-plugin/marketplace.json (repo root) catalogs the plugin (source: ./integrations/claude-code/patchstory). - integrations/claude-code/patchstory/.claude-plugin/plugin.json manifest. - Move the skill into the plugin skill layout: skills/patchstory/{SKILL.md, scripts/collect.sh} (history preserved via git mv). - SKILL.md resolves its helper via $CLAUDE_PLUGIN_ROOT with manual-install fallbacks, since CLAUDE_PLUGIN_ROOT isn't guaranteed for model-run Bash. - READMEs document the plugin install (primary) and manual symlink. Validated with `claude plugin validate .` (passed). Co-Authored-By: Claude Opus 4.8 --- .claude-plugin/marketplace.json | 14 +++++++ README.md | 13 ++++-- integrations/claude-code/README.md | 41 +++++++++++-------- .../patchstory/.claude-plugin/plugin.json | 10 +++++ .../{ => skills/patchstory}/SKILL.md | 21 +++++++--- .../patchstory}/scripts/collect.sh | 0 6 files changed, 73 insertions(+), 26 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 integrations/claude-code/patchstory/.claude-plugin/plugin.json rename integrations/claude-code/patchstory/{ => skills/patchstory}/SKILL.md (90%) rename integrations/claude-code/patchstory/{ => skills/patchstory}/scripts/collect.sh (100%) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..a15d780 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,14 @@ +{ + "name": "russ-patchstory", + "owner": { "name": "Russell Smith" }, + "description": "PatchStory — turn a PR or diff into an interactive, human-readable walkthrough, with your coding agent authoring the narrative.", + "plugins": [ + { + "name": "patchstory", + "source": "./integrations/claude-code/patchstory", + "description": "Turn a pull request or local change into an interactive PR walkthrough. The agent authors the story (chapters with intent, risk, reviewer questions, verification steps); patchstory renders it as a self-contained .html with secrets redacted and opens it.", + "homepage": "https://github.com/russ/patchstory", + "repository": "https://github.com/russ/patchstory" + } + ] +} diff --git a/README.md b/README.md index 6c61432..51feba9 100644 --- a/README.md +++ b/README.md @@ -221,9 +221,16 @@ groupings, and `diff_hunks` line numbers — which the agent then enriches. Beca aligned when you `render --diff` them. (`--redact` masks the embedded diff at render time; the scaffolded IR and emitted diff are left unredacted for the agent to read.) -A ready-to-install **Claude Code** skill that runs exactly this flow lives in -[`integrations/claude-code/`](integrations/claude-code/) — it auto-detects the source, scaffolds, -has Claude author the narrative, and renders. Other agents can follow the same three commands. +A ready-to-install **Claude Code** plugin that runs exactly this flow lives in +[`integrations/claude-code/`](integrations/claude-code/): + +```text +/plugin marketplace add russ/patchstory +/plugin install patchstory@russ-patchstory +``` + +It auto-detects the source, scaffolds, has Claude author the narrative, and renders. Other +agents can follow the same three commands. --- diff --git a/integrations/claude-code/README.md b/integrations/claude-code/README.md index abec4ce..caed8e6 100644 --- a/integrations/claude-code/README.md +++ b/integrations/claude-code/README.md @@ -1,8 +1,9 @@ -# PatchStory skill for Claude Code +# PatchStory plugin for Claude Code -A [Claude Code](https://claude.com/claude-code) skill that drives `patchstory` to produce a -human-readable, interactive walkthrough of a PR or local change — with **Claude itself -authoring the narrative** (the JSON IR), not the built-in heuristic or `-g anthropic` adapter. +A [Claude Code](https://claude.com/claude-code) plugin (one skill) that drives `patchstory` to +produce a human-readable, interactive walkthrough of a PR or local change — with **Claude +itself authoring the narrative** (the JSON IR), not the built-in heuristic or `-g anthropic` +adapter. It leans on three CLI primitives so it stays thin: @@ -20,31 +21,37 @@ It leans on three CLI primitives so it stays thin: ## Install -Requires `patchstory` on PATH (`npm i -g patchstory`, or the scripts fall back to -`npx -y patchstory`), plus `node` >= 20, `git`, and `gh` for PR mode. Linux or macOS. +Prereqs on every machine: `patchstory` >= 0.1.3 on PATH (`npm i -g patchstory`, or the script +falls back to `npx -y patchstory`), plus `node` >= 20, `git`, and `gh` for PR mode. Linux or +macOS (not Windows). -Symlink (recommended — stays in sync as you `git pull`): +### As a plugin (recommended) -```bash -ln -s "$(pwd)/integrations/claude-code/patchstory" ~/.claude/skills/patchstory +```text +/plugin marketplace add russ/patchstory +/plugin install patchstory@russ-patchstory ``` -…or copy it: +Then: `/patchstory`, `/patchstory 123`, or "make a walkthrough of this PR". The marketplace +manifest must be on the repo's default branch for `marketplace add` to find it. + +### Manually (symlink the skill) ```bash -cp -r integrations/claude-code/patchstory ~/.claude/skills/patchstory +ln -s "$(pwd)/integrations/claude-code/patchstory/skills/patchstory" ~/.claude/skills/patchstory ``` -Then in Claude Code: `/patchstory`, or `/patchstory 123`, or "make a walkthrough of this PR". +…or copy that directory into `~/.claude/skills/patchstory`. ## Layout ``` -integrations/claude-code/ - README.md - patchstory/ - SKILL.md # the skill definition + authoring rubric - scripts/collect.sh # resolves the source and runs `patchstory scaffold` +.claude-plugin/marketplace.json # marketplace catalog (repo root) +integrations/claude-code/patchstory/ # plugin root (marketplace `source`) + .claude-plugin/plugin.json # plugin manifest + skills/patchstory/ + SKILL.md # the skill + authoring rubric + scripts/collect.sh # resolves the source, runs `patchstory scaffold` ``` Other agents (Cursor, aider, …) can follow the same recipe directly — see diff --git a/integrations/claude-code/patchstory/.claude-plugin/plugin.json b/integrations/claude-code/patchstory/.claude-plugin/plugin.json new file mode 100644 index 0000000..a65a3ec --- /dev/null +++ b/integrations/claude-code/patchstory/.claude-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "patchstory", + "description": "Turn a pull request or local change into an interactive, human-readable PR walkthrough. The agent authors the JSON story; patchstory renders it as a self-contained .html (secrets redacted) and opens it in the browser.", + "version": "0.1.0", + "author": { "name": "Russell Smith" }, + "homepage": "https://github.com/russ/patchstory", + "repository": "https://github.com/russ/patchstory", + "license": "MIT", + "keywords": ["pull-request", "code-review", "diff", "walkthrough", "agent"] +} diff --git a/integrations/claude-code/patchstory/SKILL.md b/integrations/claude-code/patchstory/skills/patchstory/SKILL.md similarity index 90% rename from integrations/claude-code/patchstory/SKILL.md rename to integrations/claude-code/patchstory/skills/patchstory/SKILL.md index 5768fb2..9a59841 100644 --- a/integrations/claude-code/patchstory/SKILL.md +++ b/integrations/claude-code/patchstory/skills/patchstory/SKILL.md @@ -38,14 +38,23 @@ in the browser (serving on the LAN is an alternative — see Notes). ### 1. Resolve the source + scaffold -Run the helper from inside the target git repo. Pass nothing to auto-detect, or pass a PR -number, PR URL, range (`main...feature`), or branch name: +This skill ships a helper at `scripts/collect.sh` next to this SKILL.md. Resolve its absolute +path first — it works whether installed as a plugin or symlinked manually: ```bash -bash scripts/collect.sh # auto-detect (branch PR, else branch vs base) -bash scripts/collect.sh 123 # PR number -bash scripts/collect.sh https://github.com/org/repo/pull/123 -bash scripts/collect.sh main...my-branch +COLLECT="${CLAUDE_PLUGIN_ROOT:+$CLAUDE_PLUGIN_ROOT/skills/patchstory/scripts/collect.sh}" +[ -f "$COLLECT" ] || COLLECT="$HOME/.claude/skills/patchstory/scripts/collect.sh" +[ -f "$COLLECT" ] || COLLECT="$(find "$HOME/.claude" -name collect.sh -path '*patchstory*' 2>/dev/null | head -1)" +``` + +Then run it from inside the target git repo. Pass nothing to auto-detect, or pass a PR number, +PR URL, range (`main...feature`), or branch name: + +```bash +bash "$COLLECT" # auto-detect (branch PR, else branch vs base) +bash "$COLLECT" 123 # PR number +bash "$COLLECT" https://github.com/org/repo/pull/123 +bash "$COLLECT" main...my-branch ``` (`--repo ` targets another checkout; `--out ` overrides the work-dir root, diff --git a/integrations/claude-code/patchstory/scripts/collect.sh b/integrations/claude-code/patchstory/skills/patchstory/scripts/collect.sh similarity index 100% rename from integrations/claude-code/patchstory/scripts/collect.sh rename to integrations/claude-code/patchstory/skills/patchstory/scripts/collect.sh