pr-docent 0.1.1 โ†’ 0.2.0

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.
@@ -0,0 +1,78 @@
1
+ // Running the user's own Claude Code (their subscription) on a pull request in a temporary
2
+ // folder. Shared by tour generation and shorts.
3
+
4
+ import { spawn } from "node:child_process";
5
+ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
6
+ import { homedir, tmpdir } from "node:os";
7
+ import { dirname, join, resolve, sep } from "node:path";
8
+
9
+ const TIMEOUT_MS = 15 * 60 * 1000;
10
+ const CONFIG = readConfig();
11
+ let child = null;
12
+
13
+ /** Written by the installer; without it, use whatever claude is on PATH. */
14
+ function readConfig() {
15
+ try {
16
+ return JSON.parse(readFileSync(join(homedir(), ".pr-docent", "config.json"), "utf8"));
17
+ } catch {
18
+ return { claude: "claude" };
19
+ }
20
+ }
21
+
22
+ /**
23
+ * A temporary folder with the PR's changed files under repo/ and its diff and details under out/.
24
+ * Real path: on macOS the temp folder is a symlink, and Claude's write rule must match exactly.
25
+ */
26
+ export function prepareWorkspace({ files, diff, title, body, headSha }) {
27
+ const work = realpathSync(mkdtempSync(join(tmpdir(), "pr-docent-")));
28
+ const repo = join(work, "repo");
29
+ const out = join(work, "out");
30
+ mkdirSync(repo);
31
+ mkdirSync(out);
32
+ for (const [path, content] of Object.entries(files)) {
33
+ const target = resolve(repo, path);
34
+ if (!target.startsWith(repo + sep)) continue; // never write outside the folder
35
+ mkdirSync(dirname(target), { recursive: true });
36
+ writeFileSync(target, content);
37
+ }
38
+ writeFileSync(join(out, "pr.diff"), diff);
39
+ writeFileSync(join(out, "pr.json"), JSON.stringify({ title, body, headRefOid: headSha }, null, 2));
40
+ return { work, repo, out };
41
+ }
42
+
43
+ /**
44
+ * Why Claude Code failed, for the panel to say what to do: "claudeLoggedOut" when its output says
45
+ * it isn't signed in (Claude Code 2.1: "Not logged in ยท Please run /login"), otherwise undefined.
46
+ */
47
+ export function failureCode(output) {
48
+ return /not logged in|please run \/login|invalid api key|oauth token has expired/i.test(output) ? "claudeLoggedOut" : undefined;
49
+ }
50
+
51
+ /** Runs `claude -p [prompt]` in [cwd]; reports elapsed seconds every 5s through [onProgress]. */
52
+ export function runClaude(prompt, cwd, args, onProgress = () => {}) {
53
+ return new Promise((resolvePromise, reject) => {
54
+ const started = Date.now();
55
+ let output = "";
56
+ child = spawn(CONFIG.claude, ["-p", prompt, ...args], { cwd, stdio: ["ignore", "pipe", "pipe"] });
57
+ const keep = (chunk) => (output = (output + chunk).slice(-2000));
58
+ child.stdout.on("data", keep);
59
+ child.stderr.on("data", keep);
60
+ const progress = setInterval(() => onProgress(Math.round((Date.now() - started) / 1000)), 5000);
61
+ const timeout = setTimeout(() => child.kill(), TIMEOUT_MS);
62
+ child.on("error", (error) =>
63
+ reject(Object.assign(new Error(`Could not start Claude Code (${CONFIG.claude}): ${error.message}`), { code: "claudeMissing" })),
64
+ );
65
+ child.on("close", (code) => {
66
+ clearInterval(progress);
67
+ clearTimeout(timeout);
68
+ child = null;
69
+ if (code === 0) resolvePromise();
70
+ else reject(Object.assign(new Error(`Claude Code exited with code ${code}. ${output.trim().slice(-300)}`), { code: failureCode(output) }));
71
+ });
72
+ });
73
+ }
74
+
75
+ /** Stops a running Claude Code, e.g. when the extension disconnects. */
76
+ export function stopClaude() {
77
+ child?.kill();
78
+ }
package/host/native.mjs CHANGED
@@ -3,8 +3,9 @@
3
3
  import { createHash } from "node:crypto";
4
4
 
5
5
  export const HOST_NAME = "dev.prdocent.host";
6
- // Extensions the host accepts by default: the unpacked install, whose ID manifest.json's key fixes.
7
- export const EXTENSION_IDS = ["hleefogoiedjahnmjgmmgonckaeicppc"];
6
+ // Extensions the host accepts by default: the unpacked install, whose ID manifest.json's key fixes,
7
+ // and the Chrome Web Store release.
8
+ export const EXTENSION_IDS = ["hleefogoiedjahnmjgmmgonckaeicppc", "deejphpcfjilekklajdhdhfepjbenjii"];
8
9
 
9
10
  /** Chrome native messaging frames each message as a 32-bit little-endian length plus UTF-8 JSON. */
10
11
  export function encodeMessage(value) {
@@ -1,57 +1,68 @@
1
- // Local generator: Chrome starts this through native messaging when the panel asks for a tour.
2
- // It writes the PR's diff and changed files to a temporary folder, runs the user's own Claude
3
- // Code (their subscription, no API key) with the same prompt and checker as the GitHub Actions
4
- // workflow, and sends the checked tour back. Installed by `pr-docent install` (bin/pr-docent.mjs).
1
+ // Local generator: Chrome starts this through native messaging when the panel asks for a tour,
2
+ // a short script, or a video file. Tours: Claude Code (the user's own subscription, no API key)
3
+ // writes one with the same prompt and checker as the GitHub Actions workflow. Shorts: see
4
+ // short.mjs. Installed by `pr-docent install` (bin/pr-docent.mjs).
5
5
 
6
- import { spawn } from "node:child_process";
7
- import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
8
- import { homedir, tmpdir } from "node:os";
9
- import { dirname, join, resolve, sep } from "node:path";
6
+ import { execFile } from "node:child_process";
7
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
+ import { dirname, join, resolve } from "node:path";
10
9
  import { fileURLToPath } from "node:url";
11
10
  import { changedLineCount, checkTour, visibleLines } from "../src/diff.js";
12
11
  import { buildPrompt, checkBrevity, parseGuidelines } from "../src/guidelines.js";
13
12
  import { parseTour } from "../src/tour.js";
13
+ import { prepareWorkspace, runClaude, stopClaude } from "./claude.mjs";
14
14
  import { createDecoder, encodeMessage } from "./native.mjs";
15
+ import { stopRendering } from "./render-short.mjs";
16
+ import { saveShortVideo, writeShortScript } from "./short.mjs";
15
17
 
16
18
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
17
- const CONFIG = readConfig();
18
- const TIMEOUT_MS = 15 * 60 * 1000;
19
-
20
- let child = null;
21
19
  const send = (message) => process.stdout.write(encodeMessage(message));
20
+ // Writing after Chrome closed the pipe fails; there is no one left to tell.
21
+ process.stdout.on("error", () => process.exit(0));
22
+ let busy = false;
22
23
 
23
24
  process.stdin.on(
24
25
  "data",
25
26
  createDecoder((message) => {
26
- if (message.type !== "generate") return;
27
- generate(message)
28
- .then((tour) => send({ type: "done", tour }))
29
- .catch((error) => send({ type: "error", message: error.message }))
30
- .finally(() => process.exit(0));
27
+ busy = true;
28
+ const reply = (promise, toMessage) =>
29
+ promise
30
+ .then((value) => send(toMessage(value)))
31
+ .catch((error) => send({ type: "error", message: error.message, code: error.code }))
32
+ // Exit once stdout has flushed: on macOS pipe writes are asynchronous.
33
+ .finally(() => process.stdout.write("", () => process.exit(0)));
34
+ if (message.type === "generate") {
35
+ reply(generate(message), (tour) => ({ type: "done", tour }));
36
+ } else if (message.type === "script") {
37
+ const onProgress = (progress) => send({ type: "progress", ...progress });
38
+ reply(writeShortScript({ ...message, onProgress }), (short) => ({ type: "done", short }));
39
+ } else if (message.type === "render") {
40
+ const onProgress = (progress) => send({ type: "progress", ...progress });
41
+ reply(saveShortVideo({ short: message.short, onProgress }), (path) => ({ type: "done", path }));
42
+ } else if (message.type === "reveal") {
43
+ reply(reveal(message.path), () => ({ type: "done" }));
44
+ }
31
45
  }),
32
46
  );
33
- // Chrome closes stdin when the extension disconnects (panel closed, tab gone): stop Claude too.
47
+ // Chrome closes stdin when the extension disconnects (panel closed, tab gone): stop Claude or the
48
+ // render too, and let the request finish failing so its temporary folder is removed, then exit
49
+ // (from the reply above, or after a grace period).
34
50
  process.stdin.on("end", () => {
35
- child?.kill();
36
- process.exit(0);
51
+ stopClaude();
52
+ stopRendering();
53
+ if (!busy) process.exit(0);
54
+ setTimeout(() => process.exit(0), 5000);
37
55
  });
38
56
 
57
+ /** Shows a saved video in Finder; only files this tool writes (pr-docent-*.mp4). */
58
+ function reveal(path) {
59
+ if (!/\/pr-docent-[^/]+\.mp4$/.test(String(path)) || !existsSync(path)) return Promise.reject(new Error("Not a PR Docent video"));
60
+ return new Promise((done) => execFile("open", ["-R", path], () => done()));
61
+ }
62
+
39
63
  async function generate({ pr, title, body, headSha, diff, files, guidelines = "", model, language }) {
40
- // Real path: on macOS the temp folder is a symlink, and the write rule must match exactly.
41
- const work = realpathSync(mkdtempSync(join(tmpdir(), "pr-docent-")));
64
+ const { work, repo, out } = prepareWorkspace({ files, diff, title, body, headSha });
42
65
  try {
43
- const repo = join(work, "repo");
44
- const out = join(work, "out");
45
- mkdirSync(repo);
46
- mkdirSync(out);
47
- for (const [path, content] of Object.entries(files)) {
48
- const target = resolve(repo, path);
49
- if (!target.startsWith(repo + sep)) continue; // never write outside the folder
50
- mkdirSync(dirname(target), { recursive: true });
51
- writeFileSync(target, content);
52
- }
53
- writeFileSync(join(out, "pr.diff"), diff);
54
- writeFileSync(join(out, "pr.json"), JSON.stringify({ title, body, headRefOid: headSha }, null, 2));
55
66
  if (guidelines) writeFileSync(join(out, "guidelines.md"), guidelines);
56
67
  const { limits, errors: guidelineErrors } = parseGuidelines(guidelines);
57
68
  if (guidelineErrors.length) throw new Error(`.github/pr-docent.md: ${guidelineErrors.join(" / ")}`);
@@ -70,7 +81,7 @@ async function generate({ pr, title, body, headSha, diff, files, guidelines = ""
70
81
  "--model", model || "claude-opus-5-5",
71
82
  "--permission-mode", "dontAsk",
72
83
  "--allowedTools", `Read,Glob,Grep,Edit(/${out}/tour.json),Bash(node ${ROOT}/scripts/check-tour.mjs:*)`,
73
- ]);
84
+ ], (seconds) => send({ type: "progress", seconds }));
74
85
 
75
86
  let raw;
76
87
  try {
@@ -88,32 +99,3 @@ async function generate({ pr, title, body, headSha, diff, files, guidelines = ""
88
99
  rmSync(work, { recursive: true, force: true });
89
100
  }
90
101
  }
91
-
92
- /** Written by the installer; without it, use whatever claude is on PATH. */
93
- function readConfig() {
94
- try {
95
- return JSON.parse(readFileSync(join(homedir(), ".pr-docent", "config.json"), "utf8"));
96
- } catch {
97
- return { claude: "claude" };
98
- }
99
- }
100
-
101
- function runClaude(prompt, cwd, args) {
102
- return new Promise((resolvePromise, reject) => {
103
- const started = Date.now();
104
- let output = "";
105
- child = spawn(CONFIG.claude, ["-p", prompt, ...args], { cwd, stdio: ["ignore", "pipe", "pipe"] });
106
- const keep = (chunk) => (output = (output + chunk).slice(-2000));
107
- child.stdout.on("data", keep);
108
- child.stderr.on("data", keep);
109
- const progress = setInterval(() => send({ type: "progress", seconds: Math.round((Date.now() - started) / 1000) }), 5000);
110
- const timeout = setTimeout(() => child.kill(), TIMEOUT_MS);
111
- child.on("error", (error) => reject(new Error(`Could not start Claude Code (${CONFIG.claude}): ${error.message}`)));
112
- child.on("close", (code) => {
113
- clearInterval(progress);
114
- clearTimeout(timeout);
115
- if (code === 0) resolvePromise();
116
- else reject(new Error(`Claude Code exited with code ${code}. ${output.trim().slice(-300)}`));
117
- });
118
- });
119
- }
@@ -0,0 +1,96 @@
1
+ // Renders a short (see src/short.js) to a vertical MP4 on macOS. Each scene is drawn as HTML
2
+ // diagrams captured by headless Chrome, one frame per element so the picture builds up while the
3
+ // sentence is read; narration comes from `say` (speechSynthesis audio can't be recorded), and
4
+ // ffmpeg joins everything.
5
+
6
+ import { execFile } from "node:child_process";
7
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { promisify } from "node:util";
10
+ import { FRAME_HEIGHT as HEIGHT, FRAME_WIDTH as WIDTH, SHORT_CSS, sceneList, sceneMarkup, sceneSteps } from "../src/short-scenes.js";
11
+
12
+ // Aborted when the extension disconnects, which kills the say, Chrome or ffmpeg run in progress.
13
+ const stopping = new AbortController();
14
+ const run = (command, args) => promisify(execFile)(command, args, { signal: stopping.signal });
15
+ const LEAD_IN = 0.3; // seconds of silence before each sentence
16
+ const TAIL = 0.8; // and after it
17
+ const CHROME_PATHS = [
18
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
19
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
20
+ ];
21
+ const PREFERRED_VOICES = { ko: ["Yuna (Premium)", "Yuna (Enhanced)", "Yuna"], en: ["Samantha (Premium)", "Samantha (Enhanced)", "Samantha"] };
22
+
23
+ /** Stops a render in progress: its current subprocess is killed and the render fails. */
24
+ export function stopRendering() {
25
+ stopping.abort();
26
+ }
27
+
28
+ /** Writes the MP4 for [short] to [outPath], working in [workDir]. Calls onProgress(text) per scene. */
29
+ export async function renderShort({ short, outPath, workDir, onProgress = () => {} }) {
30
+ const chrome = CHROME_PATHS.find(existsSync);
31
+ if (!chrome) throw new Error("Google Chrome was not found in /Applications.");
32
+ await requireTool("ffmpeg", ["-version"], "ffmpeg is required: brew install ffmpeg");
33
+ const voice = await pickSayVoice(/[๊ฐ€-ํžฃ]/.test(JSON.stringify(short)) ? "ko" : "en");
34
+ const scenes = sceneList(short);
35
+ mkdirSync(workDir, { recursive: true });
36
+
37
+ const clips = [];
38
+ for (const [i, scene] of scenes.entries()) {
39
+ onProgress(`${i + 1}/${scenes.length}`);
40
+ const base = join(workDir, `scene-${i}`);
41
+ await run("say", ["-v", voice, "-o", `${base}.aiff`, scene.narration]);
42
+ const audio = Number((await run("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", `${base}.aiff`])).stdout);
43
+
44
+ // One frame per revealed element, spread across the sentence.
45
+ const steps = sceneSteps(scene);
46
+ const frames = [];
47
+ for (let shown = 1; shown <= steps; shown++) {
48
+ const png = `${base}-${shown}.png`;
49
+ writeFileSync(`${base}.html`, pageHtml(scene, { short, shown }));
50
+ await run(chrome, [
51
+ "--headless=new", "--disable-gpu", "--hide-scrollbars", "--force-device-scale-factor=1",
52
+ `--user-data-dir=${join(workDir, "chrome-profile")}`, `--window-size=${WIDTH},${HEIGHT}`,
53
+ `--screenshot=${png}`, `file://${base}.html`,
54
+ ]);
55
+ const duration = audio / steps + (shown === 1 ? LEAD_IN : 0) + (shown === steps ? TAIL : 0);
56
+ frames.push(`file '${png}'\nduration ${duration.toFixed(3)}`);
57
+ }
58
+ frames.push(`file '${base}-${steps}.png'`); // the concat demuxer needs the last frame twice
59
+ writeFileSync(`${base}.txt`, frames.join("\n"));
60
+
61
+ const delay = Math.round(LEAD_IN * 1000);
62
+ await run("ffmpeg", [
63
+ "-y", "-f", "concat", "-safe", "0", "-i", `${base}.txt`, "-i", `${base}.aiff`,
64
+ "-filter_complex", `[0:v]fps=30,format=yuv420p[v];[1:a]adelay=${delay}|${delay},apad[a]`,
65
+ "-map", "[v]", "-map", "[a]", "-t", (LEAD_IN + audio + TAIL).toFixed(2),
66
+ "-c:v", "libx264", "-c:a", "aac", "-b:a", "160k", "-ar", "44100", "-ac", "2", `${base}.mp4`,
67
+ ]);
68
+ clips.push(`${base}.mp4`);
69
+ }
70
+
71
+ onProgress("โ€ฆ");
72
+ const list = join(workDir, "clips.txt");
73
+ writeFileSync(list, clips.map((clip) => `file '${clip}'`).join("\n"));
74
+ await run("ffmpeg", ["-y", "-f", "concat", "-safe", "0", "-i", list, "-c", "copy", "-movflags", "+faststart", outPath]);
75
+ return outPath;
76
+ }
77
+
78
+ async function requireTool(command, args, message) {
79
+ try {
80
+ await run(command, args);
81
+ } catch {
82
+ throw new Error(message);
83
+ }
84
+ }
85
+
86
+ async function pickSayVoice(language) {
87
+ const { stdout } = await run("say", ["-v", "?"]);
88
+ const names = stdout.split("\n").map((line) => line.split(/\s{2,}/)[0].trim());
89
+ return PREFERRED_VOICES[language].find((name) => names.includes(name)) ?? (language === "ko" ? "Yuna" : "Samantha");
90
+ }
91
+
92
+ /** A whole page for the screenshot: the shared frame with nothing around it. */
93
+ function pageHtml(scene, options) {
94
+ return `<!doctype html><html><head><meta charset="utf-8"><style>body { margin: 0; } ${SHORT_CSS}</style></head>
95
+ <body><div class="short-frame">${sceneMarkup(scene, options)}</div></body></html>`;
96
+ }
@@ -0,0 +1,56 @@
1
+ // `pr-docent short <PR>`: makes a short from the terminal with gh instead of the extension.
2
+ // Uses the tour posted in the PR's comments (or --tour <file>).
3
+
4
+ import { execFileSync } from "node:child_process";
5
+ import { readFileSync } from "node:fs";
6
+ import { visibleLines } from "../src/diff.js";
7
+ import { findTourInBlocks, parseTour, tourLanguage } from "../src/tour.js";
8
+ import { generateShort } from "./short.mjs";
9
+
10
+ const gh = (...args) => execFileSync("gh", args, { encoding: "utf8", maxBuffer: 100e6 });
11
+
12
+ /** "owner/repo#12" or a pull request URL โ†’ { owner, repo, number }, or null. */
13
+ export function parsePullRequestRef(text) {
14
+ const match = String(text).match(/^(?:https:\/\/github\.com\/)?([^/\s#]+)\/([^/\s#]+)(?:#|\/pull\/)(\d+)/);
15
+ return match ? { owner: match[1], repo: match[2], number: Number(match[3]) } : null;
16
+ }
17
+
18
+ export async function shortCommand(args) {
19
+ const pr = parsePullRequestRef(args[0] ?? "");
20
+ if (!pr) throw new Error("usage: pr-docent short <owner/repo#number | pull request URL> [--tour <file>] [--out <file.mp4>]");
21
+ const option = (name) => (args.includes(name) ? args[args.indexOf(name) + 1] : undefined);
22
+ const repoArg = `${pr.owner}/${pr.repo}`;
23
+
24
+ const details = JSON.parse(gh("pr", "view", String(pr.number), "-R", repoArg, "--json", "title,body,headRefOid,comments"));
25
+ let tour;
26
+ if (option("--tour")) {
27
+ tour = JSON.parse(readFileSync(option("--tour"), "utf8"));
28
+ } else {
29
+ const blocks = details.comments.flatMap((c) => [...c.body.matchAll(/```json\n([\s\S]*?)\n```/g)].map((m) => m[1]));
30
+ tour = findTourInBlocks(blocks, pr);
31
+ if (!tour) throw new Error("This PR has no tour comment. Generate a tour first, or pass --tour <file>.");
32
+ }
33
+ const parsed = parseTour(tour);
34
+ if (parsed.errors.length) throw new Error(`The tour is invalid: ${parsed.errors[0]}`);
35
+
36
+ console.log(`Fetching ${repoArg}#${pr.number}โ€ฆ`);
37
+ const diff = gh("pr", "diff", String(pr.number), "-R", repoArg);
38
+ const files = {};
39
+ for (const path of visibleLines(diff).keys()) {
40
+ try {
41
+ files[path] = gh("api", `repos/${repoArg}/contents/${path.split("/").map(encodeURIComponent).join("/")}?ref=${details.headRefOid}`, "-H", "Accept: application/vnd.github.raw");
42
+ } catch {
43
+ // deleted or binary at the head commit
44
+ }
45
+ }
46
+ const path = await generateShort({
47
+ pr, tour, title: details.title, body: details.body, headSha: details.headRefOid, diff, files,
48
+ language: tourLanguage(parsed.tour) === "ko" ? "ํ•œ๊ตญ์–ด" : "English",
49
+ outPath: option("--out"),
50
+ onProgress: ({ stage, seconds, detail }) => {
51
+ if (stage === "script" && seconds % 15 === 0) console.log(`Claude Code is writing the scriptโ€ฆ ${seconds}s`);
52
+ if (stage === "render") console.log(`Rendering ${detail}`);
53
+ },
54
+ });
55
+ console.log(`Saved ${path}`);
56
+ }
package/host/short.mjs ADDED
@@ -0,0 +1,71 @@
1
+ // Shorts on this computer: Claude Code writes the script from the PR's tour (the panel plays it
2
+ // live), and render-short.mjs turns a script into an MP4 when someone wants a file. Used by the
3
+ // native host and by `pr-docent short` on the command line.
4
+
5
+ import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
6
+ import { homedir, tmpdir } from "node:os";
7
+ import { dirname, join, resolve } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { checkShort, parseShort } from "../src/short.js";
10
+ import { prepareWorkspace, runClaude } from "./claude.mjs";
11
+ import { renderShort } from "./render-short.mjs";
12
+
13
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
14
+
15
+ /** Has Claude Code write and check a short script from the PR's tour; returns the raw JSON. */
16
+ export async function writeShortScript({ pr, tour, title, body, headSha, diff, files, language, model, onProgress = () => {} }) {
17
+ const { work, repo, out } = prepareWorkspace({ files, diff, title, body, headSha });
18
+ try {
19
+ writeFileSync(join(out, "tour.json"), JSON.stringify(tour, null, 2));
20
+ const prRef = `${pr.owner}/${pr.repo}#${pr.number}`;
21
+ const prompt = readFileSync(join(ROOT, "scripts", "short-prompt.md"), "utf8").replace(/{{(\w+)}}/g, (match, name) =>
22
+ ({ PR: prRef, OUT: out, TOOLS: ROOT, LANGUAGE: language || "ํ•œ๊ตญ์–ด" })[name] ?? match);
23
+ onProgress({ stage: "script", seconds: 0 });
24
+ await runClaude(prompt, repo, [
25
+ "--model", model || "claude-opus-5-5",
26
+ "--permission-mode", "dontAsk",
27
+ "--allowedTools", `Read,Glob,Grep,Edit(/${out}/short.json),Bash(node ${ROOT}/scripts/check-short.mjs:*)`,
28
+ ], (seconds) => onProgress({ stage: "script", seconds }));
29
+
30
+ let raw;
31
+ try {
32
+ raw = JSON.parse(readFileSync(join(out, "short.json"), "utf8"));
33
+ } catch {
34
+ throw new Error("Claude did not write a short script.");
35
+ }
36
+ const { short, errors } = parseShort(raw);
37
+ const problems = errors.length ? errors : checkShort(short);
38
+ if (problems.length) throw new Error(`The short did not pass the checks: ${problems.slice(0, 3).join(" / ")}`);
39
+ return raw;
40
+ } finally {
41
+ rmSync(work, { recursive: true, force: true });
42
+ }
43
+ }
44
+
45
+ /** Renders a checked short script to an MP4 (Downloads unless [outPath]); returns its path. */
46
+ export async function saveShortVideo({ short: raw, outPath, onProgress = () => {} }) {
47
+ const { short, errors } = parseShort(raw);
48
+ if (!short) throw new Error(`Invalid short: ${errors[0]}`);
49
+ const { pr } = short;
50
+ const work = realpathSync(mkdtempSync(join(tmpdir(), "pr-docent-render-")));
51
+ try {
52
+ const target = outPath ?? freePath(join(homedir(), "Downloads"), `pr-docent-${pr.owner}-${pr.repo}-${pr.number}`);
53
+ return await renderShort({ short, outPath: target, workDir: work, onProgress: (detail) => onProgress({ stage: "render", detail }) });
54
+ } finally {
55
+ rmSync(work, { recursive: true, force: true });
56
+ }
57
+ }
58
+
59
+ /** The script and then the video: what `pr-docent short` does. */
60
+ export async function generateShort(options) {
61
+ const short = await writeShortScript(options);
62
+ return saveShortVideo({ short, outPath: options.outPath, onProgress: options.onProgress });
63
+ }
64
+
65
+ /** dir/name.mp4, or dir/name-2.mp4 and so on when that exists. */
66
+ function freePath(dir, name) {
67
+ for (let i = 1; ; i++) {
68
+ const path = join(dir, `${name}${i === 1 ? "" : `-${i}`}.mp4`);
69
+ if (!existsSync(path)) return path;
70
+ }
71
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-docent",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Guided, narrated review tours on top of GitHub pull request diffs. This package installs the local tour generator for the PR Docent Chrome extension.",
6
6
  "license": "MIT",
@@ -27,12 +27,17 @@
27
27
  "host/",
28
28
  "src/diff.js",
29
29
  "src/guidelines.js",
30
+ "src/short.js",
31
+ "src/short-scenes.js",
30
32
  "src/tour.js",
33
+ "scripts/check-short.mjs",
31
34
  "scripts/check-tour.mjs",
35
+ "scripts/short-prompt.md",
32
36
  "scripts/tour-prompt.md"
33
37
  ],
34
38
  "scripts": {
35
39
  "test": "node --test tests/*.test.mjs",
36
- "install-host": "node bin/pr-docent.mjs install"
40
+ "install-host": "node bin/pr-docent.mjs install",
41
+ "package": "node scripts/package-extension.mjs"
37
42
  }
38
43
  }
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ // Validates a short (video script).
3
+ //
4
+ // node scripts/check-short.mjs <short.json> [owner/repo#number]
5
+ //
6
+ // Prints every problem and exits 1, or prints a one-line summary and exits 0.
7
+
8
+ import { readFileSync } from "node:fs";
9
+ import { checkShort, parseShort } from "../src/short.js";
10
+
11
+ const [shortPath, expectedPr] = process.argv.slice(2);
12
+ if (!shortPath) {
13
+ console.error("usage: node scripts/check-short.mjs <short.json> [owner/repo#number]");
14
+ process.exit(2);
15
+ }
16
+
17
+ const fail = (errors) => {
18
+ console.error(`Short has ${errors.length} problem(s):`);
19
+ for (const error of errors) console.error(`- ${error}`);
20
+ process.exit(1);
21
+ };
22
+
23
+ let raw;
24
+ try {
25
+ raw = JSON.parse(readFileSync(shortPath, "utf8"));
26
+ } catch (error) {
27
+ fail([`${shortPath} is not valid JSON: ${error.message}`]);
28
+ }
29
+ const { short, errors } = parseShort(raw);
30
+ if (errors.length) fail(errors);
31
+ if (expectedPr) {
32
+ const actual = `${short.pr.owner}/${short.pr.repo}#${short.pr.number}`;
33
+ if (actual !== expectedPr) fail([`pr is ${actual}, expected ${expectedPr}`]);
34
+ }
35
+ const problems = checkShort(short);
36
+ if (problems.length) fail(problems);
37
+ console.log(`OK: ${short.scenes.length + 2} scenes`);
@@ -0,0 +1,63 @@
1
+ You are writing a PR Docent short for pull request {{PR}}: a 30-45 second vertical explainer,
2
+ played with narration in the reviewer's browser, for teammates who will not read the code, such as
3
+ planners and people on other teams. It shows no code. Each scene is a simple picture of the idea
4
+ (a flow, a before-and-after, a structure, or a few points) that builds up while one sentence is
5
+ read aloud and subtitled.
6
+
7
+ ## Inputs
8
+
9
+ - `{{OUT}}/tour.json`: the reviewers' tour of this PR. Start from it; it already knows what
10
+ matters and why.
11
+ - `{{OUT}}/pr.json`: the PR's title, body and head commit.
12
+ - `{{OUT}}/pr.diff` and the changed files in the current directory, if you need to check a detail.
13
+
14
+ ## Output
15
+
16
+ Write the short to `{{OUT}}/short.json`, then run
17
+
18
+ node {{TOOLS}}/scripts/check-short.mjs {{OUT}}/short.json {{PR}}
19
+
20
+ and fix every problem it reports until it prints `OK`. Then stop. Use these absolute paths as
21
+ written and do not change directory: `{{OUT}}/short.json` is the only file you may write.
22
+
23
+ ## Format
24
+
25
+ Write every text in the short in {{LANGUAGE}}: the title, hook, headlines, narration, labels,
26
+ items and outro. The example below only shows the shape.
27
+
28
+ ```json
29
+ {
30
+ "version": 1,
31
+ "pr": { "owner": "...", "repo": "...", "number": 0 },
32
+ "title": "Short title",
33
+ "hook": "One sentence on what changes and why",
34
+ "scenes": [
35
+ { "kind": "compare", "headline": "What changes", "narration": "One sentence",
36
+ "before": { "label": "Before", "items": ["..."] }, "after": { "label": "After", "items": ["..."] } },
37
+ { "kind": "flow", "headline": "How it works", "narration": "One sentence",
38
+ "steps": [{ "icon": "๐Ÿ“", "label": "Short name", "detail": "One-line detail", "status": "same" },
39
+ { "icon": "๐Ÿค–", "label": "...", "detail": "...", "status": "new" }],
40
+ "links": ["Label on the arrow"] },
41
+ { "kind": "structure", "headline": "Where it fits", "narration": "One sentence",
42
+ "groups": [{ "label": "Layer or module", "items": [{ "icon": "๐Ÿ“ฆ", "label": "...", "status": "changed" }] }] },
43
+ { "kind": "points", "headline": "Remember", "narration": "One sentence",
44
+ "points": [{ "icon": "โœ…", "text": "Short point" }] }
45
+ ],
46
+ "outro": "One sentence on the impact or what to check"
47
+ }
48
+ ```
49
+
50
+ ## Rules
51
+
52
+ - Write in {{LANGUAGE}}. Plain words a non-developer understands: what changes for users, data,
53
+ operations or the team. Name a component only when the picture needs it, and call it by what
54
+ it does ("the tour checker", "the payment module") rather than by a class or file name.
55
+ - 2 to 4 scenes that tell one story, usually: what changes (compare) โ†’ how it works (flow or
56
+ structure) โ†’ what it means (points). Pick the kinds that fit this PR; do not force all four.
57
+ - Mark what this PR adds as `"status": "new"` and what it modifies as `"changed"`; leave the rest
58
+ as `"same"` so the eye goes to the change.
59
+ - Keep it small enough for a phone: flow 2-5 steps, compare 1-3 items per side, structure 1-3
60
+ groups and at most 8 items, points 2-3. Headlines at most 24 characters, labels at most 16,
61
+ details at most 24, every sentence (hook, narration, outro) one sentence of at most 80.
62
+ - One emoji per icon, chosen to be recognisable at a glance.
63
+ - Never invent behaviour the PR does not have.
@@ -24,16 +24,19 @@ change any other file. Use these absolute paths as written and do not change dir
24
24
 
25
25
  ## Format
26
26
 
27
+ Write every text in the tour in {{LANGUAGE}}: the title, the summaries, chapter titles, notes,
28
+ review points and skipped reasons. The example below only shows the shape.
29
+
27
30
  ```json
28
31
  {
29
32
  "version": 1,
30
33
  "pr": { "owner": "...", "repo": "...", "number": 0 },
31
- "title": "ํ•œ ์ค„ ์ œ๋ชฉ",
32
- "summary": "PR์ด ๋ฌด์—‡์„ ์™œ ๋ฐ”๊พธ๋Š”์ง€ 2~4๋ฌธ์žฅ",
34
+ "title": "One-line title",
35
+ "summary": "What the PR changes and why, in 2-4 sentences",
33
36
  "chapters": [
34
37
  {
35
- "title": "1. ์ฑ•ํ„ฐ ์ œ๋ชฉ",
36
- "summary": "ํ•œ ๋ฌธ์žฅ",
38
+ "title": "1. Chapter title",
39
+ "summary": "One sentence",
37
40
  "steps": [
38
41
  {
39
42
  "file": "path/from/repo/root.kt",
@@ -41,15 +44,15 @@ change any other file. Use these absolute paths as written and do not change dir
41
44
  "side": "right",
42
45
  "level": "focus",
43
46
  "note": [
44
- { "text": "์ด ๋ฌธ์žฅ์ด ์„ค๋ช…ํ•˜๋Š” ์ค„์„ ๊ฐ€๋ฆฌํ‚ต๋‹ˆ๋‹ค.", "lines": [41, 45] },
45
- { "text": "๋‹ค์Œ ๋ฌธ์žฅ์€ ๋‹ค๋ฅธ ์ค„์„ ๊ฐ€๋ฆฌํ‚ต๋‹ˆ๋‹ค.", "lines": [50, 58] }
47
+ { "text": "This sentence points at the lines it talks about.", "lines": [41, 45] },
48
+ { "text": "The next sentence points at other lines.", "lines": [50, 58] }
46
49
  ],
47
- "point": "๋ฆฌ๋ทฐ์–ด๊ฐ€ ํ™•์ธํ•  ๊ตฌ์ฒด์ ์ธ ์งˆ๋ฌธ"
50
+ "point": "A specific question for the reviewer to check"
48
51
  }
49
52
  ]
50
53
  }
51
54
  ],
52
- "skipped": [{ "file": "package-lock.json", "reason": "์˜์กด์„ฑ ์ž ๊ธˆ ํŒŒ์ผ" }]
55
+ "skipped": [{ "file": "package-lock.json", "reason": "dependency lock file" }]
53
56
  }
54
57
  ```
55
58