pr-docent 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,6 +15,7 @@ GitHub 풀 리퀘스트의 **Files changed** 화면 위에서 동작하는 리
15
15
  - 접힌 대용량 diff("Load Diff")는 자동으로 펼칩니다.
16
16
  - 패널이 열려 있는 동안 diff가 패널에 가리지 않도록 페이지 폭을 줄입니다.
17
17
  - PR마다 마지막 위치를 기억합니다.
18
+ - 화면 언어는 영어(기본)와 한국어를 지원합니다. 툴바의 PR Docent 아이콘을 눌러 자동(브라우저 언어), English, 한국어 중에서 고릅니다. 로컬에서 만드는 투어도 고른 언어로 작성되고, 음성은 투어 문장의 언어에 맞춰 읽습니다.
18
19
 
19
20
  ## 단축키
20
21
 
@@ -90,6 +91,27 @@ PR이 열리거나 커밋이 추가될 때마다 Claude Code가 diff를 읽고
90
91
  - 검사를 통과한 투어를 이 PR에 저장하고 바로 재생합니다. 생성에는 보통 2~3분이 걸립니다.
91
92
  - 제거: `npx pr-docent uninstall`
92
93
 
94
+ ## 투어 생성 규칙 바꾸기 (`.github/pr-docent.md`)
95
+
96
+ Actions 생성과 로컬 생성 모두 기본적으로 짧은 투어를 만듭니다. 단계 수는 바뀐 줄 25줄당 1개에 3을 더한 값까지(최대 20)이고, 단계마다 문장 3개, 문장마다 80자까지입니다. 주석만 바뀐 곳, 임포트, 문서처럼 사소한 변경은 단계 대신 "건너뛴 파일"에 넣습니다. 검사기가 이 기준을 확인하고, 넘으면 Claude가 다시 줄입니다.
97
+
98
+ 저장소에 `.github/pr-docent.md`를 두면 그 저장소의 규칙을 더할 수 있습니다. 앞부분 `---` 블록은 숫자 기준을 바꾸고, 본문은 생성 지시문 뒤에 붙어 기본 규칙보다 우선합니다.
99
+
100
+ ```markdown
101
+ ---
102
+ maxSteps: 6 # 단계 수 상한 (기본: 3 + 바뀐 줄 25줄당 1, 최대 20)
103
+ sentencesPerStep: 2 # 단계당 문장 수 (기본 3)
104
+ sentenceChars: 100 # 문장 길이 (기본 80자)
105
+ pointChars: 120 # 리뷰 포인트 길이 (기본 120자)
106
+ summaryChars: 200 # PR 요약 길이 (기본 200자)
107
+ chapterSummaryChars: 60
108
+ ---
109
+ - 도메인 용어는 원장, 안분, 파생 라인으로 씁니다.
110
+ - Flyway 마이그레이션 파일은 항상 집중 단계로 다룹니다.
111
+ ```
112
+
113
+ PR의 head 커밋에 있는 파일을 읽으므로, 규칙을 바꾸는 PR에서는 바뀐 규칙으로 생성됩니다.
114
+
93
115
  ## 투어 불러오기
94
116
 
95
117
  - PR 코멘트에 자동 생성된 투어가 있으면 그 투어를 불러옵니다. 직접 불러온 투어가 있으면 그쪽이 우선합니다.
@@ -142,6 +164,7 @@ GitHub의 2026년 Files changed 화면 기준입니다. 화면 구조가 바뀌
142
164
 
143
165
  - 빌드 단계가 없습니다. 콘텐츠 스크립트(`src/loader.js`)가 ES 모듈(`src/main.js`)을 불러옵니다.
144
166
  - 테스트: `npm test` (Node 내장 테스트 러너). 투어 검증, diff 대조, 재생 상태, 단축키, PR 주소 해석, GitHub 파일 ID 해시, 줄 선택자를 다룹니다.
167
+ - npm 게시: `package.json`의 버전을 올려 머지한 뒤 `git tag npm-v<버전> && git push origin npm-v<버전>`. GitHub Actions(`publish.yml`)가 trusted publishing으로 게시하므로 npm 토큰이나 2FA 입력이 필요 없습니다.
145
168
  - 투어 검사: `node scripts/check-tour.mjs <tour.json> <pr.diff> [owner/repo#number]` (`gh pr diff <number> > pr.diff`)
146
169
 
147
170
  ## 라이선스
package/bin/pr-docent.mjs CHANGED
@@ -17,7 +17,7 @@ import { EXTENSION_IDS, HOST_NAME } from "../host/native.mjs";
17
17
 
18
18
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
19
19
  // What the host needs at run time; everything else in the package is for the extension or tests.
20
- const RUNTIME_FILES = ["host/native.mjs", "host/pr-docent-host.mjs", "src/diff.js", "src/tour.js", "scripts/check-tour.mjs", "scripts/tour-prompt.md"];
20
+ const RUNTIME_FILES = ["host/native.mjs", "host/pr-docent-host.mjs", "src/diff.js", "src/guidelines.js", "src/tour.js", "scripts/check-tour.mjs", "scripts/tour-prompt.md"];
21
21
  const HOSTS_DIRS = {
22
22
  darwin: join(homedir(), "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts"),
23
23
  linux: join(homedir(), ".config", "google-chrome", "NativeMessagingHosts"),
@@ -75,7 +75,8 @@ function install(args) {
75
75
  Generator: ${app}
76
76
  Allowed extensions: ${extensionIds.join(", ")}
77
77
  Claude Code: ${claude}
78
- Reload the extension in chrome://extensions, then use "로컬 Claude Code로 투어 생성" in the panel.`);
78
+ Reload the extension in chrome://extensions, then click "Create a tour with local Claude Code"
79
+ in the panel ("로컬 Claude Code로 투어 생성" in Korean).`);
79
80
  }
80
81
 
81
82
  function uninstall() {
package/host/native.mjs CHANGED
@@ -33,8 +33,3 @@ export function extensionIdFromKey(key) {
33
33
  const hex = createHash("sha256").update(Buffer.from(key, "base64")).digest("hex").slice(0, 32);
34
34
  return [...hex].map((c) => String.fromCharCode(97 + parseInt(c, 16))).join("");
35
35
  }
36
-
37
- /** Replaces {{NAME}} placeholders; unknown ones are left as they are. */
38
- export function fillTemplate(text, vars) {
39
- return text.replace(/{{(\w+)}}/g, (match, name) => (name in vars ? String(vars[name]) : match));
40
- }
@@ -8,9 +8,10 @@ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSy
8
8
  import { homedir, tmpdir } from "node:os";
9
9
  import { dirname, join, resolve, sep } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
- import { checkTour, visibleLines } from "../src/diff.js";
11
+ import { changedLineCount, checkTour, visibleLines } from "../src/diff.js";
12
+ import { buildPrompt, checkBrevity, parseGuidelines } from "../src/guidelines.js";
12
13
  import { parseTour } from "../src/tour.js";
13
- import { createDecoder, encodeMessage, fillTemplate } from "./native.mjs";
14
+ import { createDecoder, encodeMessage } from "./native.mjs";
14
15
 
15
16
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
16
17
  const CONFIG = readConfig();
@@ -35,7 +36,7 @@ process.stdin.on("end", () => {
35
36
  process.exit(0);
36
37
  });
37
38
 
38
- async function generate({ pr, title, body, headSha, diff, files, model, language }) {
39
+ async function generate({ pr, title, body, headSha, diff, files, guidelines = "", model, language }) {
39
40
  // Real path: on macOS the temp folder is a symlink, and the write rule must match exactly.
40
41
  const work = realpathSync(mkdtempSync(join(tmpdir(), "pr-docent-")));
41
42
  try {
@@ -51,13 +52,19 @@ async function generate({ pr, title, body, headSha, diff, files, model, language
51
52
  }
52
53
  writeFileSync(join(out, "pr.diff"), diff);
53
54
  writeFileSync(join(out, "pr.json"), JSON.stringify({ title, body, headRefOid: headSha }, null, 2));
55
+ if (guidelines) writeFileSync(join(out, "guidelines.md"), guidelines);
56
+ const { limits, errors: guidelineErrors } = parseGuidelines(guidelines);
57
+ if (guidelineErrors.length) throw new Error(`.github/pr-docent.md: ${guidelineErrors.join(" / ")}`);
58
+ const changedLines = changedLineCount(diff);
54
59
 
55
60
  const prRef = `${pr.owner}/${pr.repo}#${pr.number}`;
56
- const prompt = fillTemplate(readFileSync(join(ROOT, "scripts", "tour-prompt.md"), "utf8"), {
57
- PR: prRef,
58
- OUT: out,
59
- TOOLS: ROOT,
60
- LANGUAGE: language || "한국어",
61
+ const prompt = buildPrompt(readFileSync(join(ROOT, "scripts", "tour-prompt.md"), "utf8"), {
62
+ pr: prRef,
63
+ out,
64
+ tools: ROOT,
65
+ language: language || "한국어",
66
+ changedLines,
67
+ guidelinesText: guidelines,
61
68
  });
62
69
  await runClaude(prompt, repo, [
63
70
  "--model", model || "claude-opus-5-5",
@@ -69,11 +76,13 @@ async function generate({ pr, title, body, headSha, diff, files, model, language
69
76
  try {
70
77
  raw = JSON.parse(readFileSync(join(out, "tour.json"), "utf8"));
71
78
  } catch {
72
- throw new Error("Claude가 투어 파일을 만들지 못했습니다.");
79
+ throw new Error("Claude did not write a tour file.");
73
80
  }
74
81
  const { tour, errors } = parseTour(raw);
75
- const problems = errors.length ? errors : checkTour(tour, visibleLines(diff));
76
- if (problems.length) throw new Error(`투어 검사를 통과하지 못했습니다: ${problems.slice(0, 3).join(" / ")}`);
82
+ const problems = errors.length
83
+ ? errors
84
+ : [...checkTour(tour, visibleLines(diff)), ...checkBrevity(tour, { changedLines, limits })];
85
+ if (problems.length) throw new Error(`The tour did not pass the checks: ${problems.slice(0, 3).join(" / ")}`);
77
86
  return raw;
78
87
  } finally {
79
88
  rmSync(work, { recursive: true, force: true });
@@ -99,12 +108,12 @@ function runClaude(prompt, cwd, args) {
99
108
  child.stderr.on("data", keep);
100
109
  const progress = setInterval(() => send({ type: "progress", seconds: Math.round((Date.now() - started) / 1000) }), 5000);
101
110
  const timeout = setTimeout(() => child.kill(), TIMEOUT_MS);
102
- child.on("error", (error) => reject(new Error(`Claude Code를 실행하지 못했습니다 (${CONFIG.claude}): ${error.message}`)));
111
+ child.on("error", (error) => reject(new Error(`Could not start Claude Code (${CONFIG.claude}): ${error.message}`)));
103
112
  child.on("close", (code) => {
104
113
  clearInterval(progress);
105
114
  clearTimeout(timeout);
106
115
  if (code === 0) resolvePromise();
107
- else reject(new Error(`Claude Code가 종료 코드 ${code}로 끝났습니다. ${output.trim().slice(-300)}`));
116
+ else reject(new Error(`Claude Code exited with code ${code}. ${output.trim().slice(-300)}`));
108
117
  });
109
118
  });
110
119
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-docent",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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",
@@ -26,6 +26,7 @@
26
26
  "bin/",
27
27
  "host/",
28
28
  "src/diff.js",
29
+ "src/guidelines.js",
29
30
  "src/tour.js",
30
31
  "scripts/check-tour.mjs",
31
32
  "scripts/tour-prompt.md"
@@ -1,18 +1,24 @@
1
1
  #!/usr/bin/env node
2
2
  // Validates a tour against a pull request diff.
3
3
  //
4
- // node scripts/check-tour.mjs <tour.json> <pr.diff> [owner/repo#number]
4
+ // node scripts/check-tour.mjs <tour.json> <pr.diff> [owner/repo#number] [--guidelines <file>]
5
5
  //
6
6
  // Prints every problem and exits 1, or prints a one-line summary and exits 0.
7
7
  // The diff must use 3 lines of context (`gh pr diff` or `git diff -U3`), as GitHub does.
8
+ // --guidelines points at a copy of the repository's .github/pr-docent.md; a missing file means
9
+ // the default limits.
8
10
 
9
- import { readFileSync } from "node:fs";
10
- import { checkTour, visibleLines } from "../src/diff.js";
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { changedLineCount, checkTour, visibleLines } from "../src/diff.js";
13
+ import { checkBrevity, parseGuidelines } from "../src/guidelines.js";
11
14
  import { flattenSteps, parseTour } from "../src/tour.js";
12
15
 
13
- const [tourPath, diffPath, expectedPr] = process.argv.slice(2);
16
+ const args = process.argv.slice(2);
17
+ const flag = args.indexOf("--guidelines");
18
+ const guidelinesPath = flag >= 0 ? args.splice(flag, 2)[1] : null;
19
+ const [tourPath, diffPath, expectedPr] = args;
14
20
  if (!tourPath || !diffPath) {
15
- console.error("usage: node scripts/check-tour.mjs <tour.json> <pr.diff> [owner/repo#number]");
21
+ console.error("usage: node scripts/check-tour.mjs <tour.json> <pr.diff> [owner/repo#number] [--guidelines <file>]");
16
22
  process.exit(2);
17
23
  }
18
24
 
@@ -37,7 +43,13 @@ if (expectedPr) {
37
43
  if (actual !== expectedPr) fail([`pr is ${actual}, expected ${expectedPr}`]);
38
44
  }
39
45
 
40
- const problems = checkTour(tour, visibleLines(readFileSync(diffPath, "utf8")));
46
+ const diff = readFileSync(diffPath, "utf8");
47
+ const guidelines = parseGuidelines(guidelinesPath && existsSync(guidelinesPath) ? readFileSync(guidelinesPath, "utf8") : "");
48
+ if (guidelines.errors.length) fail(guidelines.errors.map((error) => `.github/pr-docent.md: ${error}`));
49
+ const problems = [
50
+ ...checkTour(tour, visibleLines(diff)),
51
+ ...checkBrevity(tour, { changedLines: changedLineCount(diff), limits: guidelines.limits }),
52
+ ];
41
53
  if (problems.length) fail(problems);
42
54
 
43
55
  console.log(`OK: ${tour.chapters.length} chapters, ${flattenSteps(tour).length} steps, ${tour.skipped.length} skipped files`);
@@ -16,7 +16,7 @@ code while they listen, so the tour must say what to look at and why, in reading
16
16
 
17
17
  Write the tour to `{{OUT}}/tour.json`, then run
18
18
 
19
- node {{TOOLS}}/scripts/check-tour.mjs {{OUT}}/tour.json {{OUT}}/pr.diff {{PR}}
19
+ node {{TOOLS}}/scripts/check-tour.mjs {{OUT}}/tour.json {{OUT}}/pr.diff {{PR}} --guidelines {{OUT}}/guidelines.md
20
20
 
21
21
  and fix every problem it reports until it prints `OK`. Then stop. Do not post comments or
22
22
  change any other file. Use these absolute paths as written and do not change directory:
@@ -55,12 +55,23 @@ change any other file. Use these absolute paths as written and do not change dir
55
55
 
56
56
  ## Rules
57
57
 
58
+ Be brief. Reviewers listen while they look at the code, so every sentence must earn its place.
59
+ The checker enforces these limits for this diff ({{CHANGED_LINES}} changed lines):
60
+ - At most {{MAX_STEPS}} steps in total.
61
+ - At most {{SENTENCES_PER_STEP}} sentences per step, each at most {{SENTENCE_CHARS}} characters.
62
+ - `point`: one question, at most {{POINT_CHARS}} characters.
63
+ - `summary`: at most {{SUMMARY_CHARS}} characters; each chapter `summary`: at most
64
+ {{CHAPTER_SUMMARY_CHARS}}.
65
+
58
66
  Coverage and order
59
- - Every changed file must be visited by a step or listed in `skipped` with a reason
60
- (lock files, generated code, snapshots, pure renames, binaries).
67
+ - Every changed file must be visited by a step or listed in `skipped` with a short reason.
68
+ - Make steps only for changes a reviewer must think about. Put trivial changes in `skipped`
69
+ instead of steps: comment or doc-comment edits, imports, docs, test fixtures that pass values
70
+ through, lock files, generated code, renames, binaries. A file can have a step for its real
71
+ change and nothing for its trivial ones.
72
+ - Merge neighbouring changes that make one point into one step; never repeat a step.
61
73
  - Order chapters the way a person should read the change, not by file path: what changes and
62
- why, then the core logic, then how it is wired in, then tests and docs.
63
- - A large file may need several steps; a trivial one may share a chapter with related files.
74
+ why, then the core logic, then how it is wired in, then tests.
64
75
 
65
76
  Lines
66
77
  - `lines` are line numbers in the new file (`"side": "right"`, the default). Use
@@ -72,13 +83,16 @@ Lines
72
83
  step as a whole.
73
84
 
74
85
  Notes are spoken aloud
75
- - Write in {{LANGUAGE}}. One or two short sentences per note entry, 2-5 entries per step.
76
- - Say what the code does and why it matters for the change; do not read the code out.
86
+ - Write in {{LANGUAGE}}. One sentence per note entry, one idea per sentence.
87
+ - Say why the change matters: intent, risk, or how it connects to other code. Do not restate
88
+ what the code plainly shows ("adds a parameter", "imports X", "updates the comment").
77
89
  - Avoid symbols, brackets and long lists that sound bad in speech. Name identifiers as written.
78
90
 
79
91
  Levels and review points
80
92
  - `focus`: where a bug would hurt or a design decision is made; at most about a third of the
81
- steps. `skim`: worth a look. `skip`: can be skipped safely (formatting, boilerplate).
82
- - `point` is a concrete question for the reviewer, grounded in the code you read: an edge case,
83
- a failure mode, a contract another caller relies on. Leave it out when there is nothing real
84
- to check. Never invent problems.
93
+ steps. `skim`: worth a look.
94
+ - `point` is one concrete question for the reviewer, grounded in the code you read: an edge
95
+ case, a failure mode, a contract another caller relies on. Leave it out when there is nothing
96
+ real to check. Never invent problems.
97
+
98
+ {{GUIDELINES}}
package/src/diff.js CHANGED
@@ -43,16 +43,20 @@ export function visibleLines(diffText) {
43
43
 
44
44
  /**
45
45
  * Problems that would make the tour point at the wrong place or skip part of the PR:
46
- * files outside the diff, lines not shown on the page, beats outside their step, and
47
- * changed files the tour neither visits nor skips.
46
+ * files outside the diff, lines not shown on the page, beats outside their step, repeated
47
+ * steps, and changed files the tour neither visits nor skips.
48
48
  */
49
49
  export function checkTour(tour, files) {
50
50
  const errors = [];
51
51
  const visited = new Set();
52
+ const seen = new Set();
52
53
  tour.chapters.forEach((chapter, c) =>
53
54
  chapter.steps.forEach((step, s) => {
54
55
  const where = `chapters[${c}].steps[${s}] (${step.file})`;
55
56
  visited.add(step.file);
57
+ const key = `${step.file}|${step.side}|${step.lines}`;
58
+ if (seen.has(key)) errors.push(`${where}: repeats an earlier step on the same lines`);
59
+ seen.add(key);
56
60
  const file = files.get(step.file);
57
61
  if (!file) return errors.push(`${where}: file is not changed in this pull request`);
58
62
  const shown = file[step.side];
@@ -80,6 +84,18 @@ export function checkTour(tour, files) {
80
84
  return errors;
81
85
  }
82
86
 
87
+ /** Added plus removed lines, which sets how long a tour of the diff may be. */
88
+ export function changedLineCount(diffText) {
89
+ let count = 0;
90
+ let inHunk = false;
91
+ for (const line of String(diffText).split("\n")) {
92
+ if (line.startsWith("diff --git ")) inHunk = false;
93
+ else if (line.startsWith("@@")) inHunk = true;
94
+ else if (inHunk && (line.startsWith("+") || line.startsWith("-"))) count++;
95
+ }
96
+ return count;
97
+ }
98
+
83
99
  function range([start, end]) {
84
100
  return Array.from({ length: end - start + 1 }, (_, i) => start + i);
85
101
  }
@@ -0,0 +1,102 @@
1
+ // How long a generated tour may be, and a repository's own instructions for it.
2
+ // Pure functions — no DOM, no chrome.*, no file system.
3
+ //
4
+ // A repository can add .github/pr-docent.md. Its optional front matter overrides the limits,
5
+ // and the rest is appended to the generator's instructions:
6
+ //
7
+ // ---
8
+ // maxSteps: 5
9
+ // sentenceChars: 100
10
+ // ---
11
+ // Use the domain terms 원장, 안분 and 파생 라인. Always mark migrations as focus.
12
+
13
+ import { flattenSteps, splitSentences } from "./tour.js";
14
+
15
+ export const GUIDELINES_PATH = ".github/pr-docent.md";
16
+
17
+ export const DEFAULT_LIMITS = {
18
+ maxSteps: null, // null: 3 + 1 per 25 changed lines, at most 20
19
+ sentencesPerStep: 3,
20
+ sentenceChars: 80,
21
+ pointChars: 120,
22
+ summaryChars: 200,
23
+ chapterSummaryChars: 60,
24
+ };
25
+
26
+ /** { limits, instructions, errors } from the text of .github/pr-docent.md (or "" when absent). */
27
+ export function parseGuidelines(text) {
28
+ const limits = { ...DEFAULT_LIMITS };
29
+ const errors = [];
30
+ const match = String(text ?? "").match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
31
+ for (const line of match ? match[1].split(/\r?\n/) : []) {
32
+ if (!line.trim() || line.trim().startsWith("#")) continue;
33
+ const [, key, value] = line.replace(/\s+#.*$/, "").match(/^\s*(\w+)\s*:\s*(.*?)\s*$/) ?? [];
34
+ if (!key || !(key in DEFAULT_LIMITS)) errors.push(`Unknown setting: ${line.trim()}`);
35
+ else if (!/^\d+$/.test(value) || Number(value) < 1) errors.push(`${key} must be a positive whole number`);
36
+ else limits[key] = Number(value);
37
+ }
38
+ const instructions = String(text ?? "").slice(match ? match[0].length : 0).trim();
39
+ return { limits, instructions, errors };
40
+ }
41
+
42
+ /** How many steps a tour of a diff with [changedLines] added or removed lines may have. */
43
+ export function stepBudget(changedLines, limits = DEFAULT_LIMITS) {
44
+ return limits.maxSteps ?? Math.min(20, 3 + Math.ceil(changedLines / 25));
45
+ }
46
+
47
+ /** Problems that make a tour wordier than the limits allow. */
48
+ export function checkBrevity(tour, { changedLines, limits = DEFAULT_LIMITS }) {
49
+ const errors = [];
50
+ const steps = flattenSteps(tour);
51
+ const budget = stepBudget(changedLines, limits);
52
+ if (steps.length > budget) {
53
+ errors.push(`${steps.length} steps for ${changedLines} changed lines; at most ${budget}. Merge related steps and list trivial changes (comments, imports, docs, pass-through code) in "skipped".`);
54
+ }
55
+ if (tour.summary.length > limits.summaryChars) errors.push(`summary is ${tour.summary.length} characters; at most ${limits.summaryChars}`);
56
+ tour.chapters.forEach((chapter, c) => {
57
+ if (chapter.summary.length > limits.chapterSummaryChars) {
58
+ errors.push(`chapters[${c}].summary is ${chapter.summary.length} characters; at most ${limits.chapterSummaryChars}`);
59
+ }
60
+ chapter.steps.forEach((step, s) => {
61
+ const where = `chapters[${c}].steps[${s}] (${step.file})`;
62
+ const sentences = step.beats.flatMap((beat) => splitSentences(beat.text).map((x) => x.text));
63
+ if (sentences.length > limits.sentencesPerStep) {
64
+ errors.push(`${where}: ${sentences.length} sentences; at most ${limits.sentencesPerStep}. Keep only what the code doesn't already say.`);
65
+ }
66
+ for (const sentence of sentences) {
67
+ if (sentence.length > limits.sentenceChars) {
68
+ errors.push(`${where}: a sentence is ${sentence.length} characters; at most ${limits.sentenceChars}: "${sentence.slice(0, 30)}…"`);
69
+ }
70
+ }
71
+ if (step.point.length > limits.pointChars) {
72
+ errors.push(`${where}: point is ${step.point.length} characters; at most ${limits.pointChars}. Ask one question.`);
73
+ }
74
+ });
75
+ });
76
+ return errors;
77
+ }
78
+
79
+ /**
80
+ * The generator's instructions: [template] (scripts/tour-prompt.md) with the paths, the limits
81
+ * for this diff and the repository's own instructions filled in.
82
+ */
83
+ export function buildPrompt(template, { pr, out, tools, language, changedLines, guidelinesText = "" }) {
84
+ const { limits, instructions } = parseGuidelines(guidelinesText);
85
+ const vars = {
86
+ PR: pr,
87
+ OUT: out,
88
+ TOOLS: tools,
89
+ LANGUAGE: language,
90
+ CHANGED_LINES: changedLines,
91
+ MAX_STEPS: stepBudget(changedLines, limits),
92
+ SENTENCES_PER_STEP: limits.sentencesPerStep,
93
+ SENTENCE_CHARS: limits.sentenceChars,
94
+ POINT_CHARS: limits.pointChars,
95
+ SUMMARY_CHARS: limits.summaryChars,
96
+ CHAPTER_SUMMARY_CHARS: limits.chapterSummaryChars,
97
+ GUIDELINES: instructions
98
+ ? `## Repository guidelines (${GUIDELINES_PATH})\n\nThese come from the repository and take precedence over the rules above.\n\n${instructions}\n`
99
+ : "",
100
+ };
101
+ return template.replace(/{{(\w+)}}/g, (match, name) => (name in vars ? String(vars[name]) : match));
102
+ }
package/src/tour.js CHANGED
@@ -160,6 +160,12 @@ export function findTourInBlocks(blocks, { owner, repo, number }) {
160
160
  return found;
161
161
  }
162
162
 
163
+ /** "ko" when the tour's text has Hangul, otherwise "en": the language to narrate it in. */
164
+ export function tourLanguage(tour) {
165
+ const text = [tour.title, tour.summary, ...flattenSteps(tour).flatMap((s) => [s.note, s.point])].join(" ");
166
+ return /[가-힣]/.test(text) ? "ko" : "en";
167
+ }
168
+
163
169
  /** The bundled tour file name for a PR, e.g. "your-org--your-repo--42.json". */
164
170
  export function tourFileName({ owner, repo, number }) {
165
171
  return `${owner}--${repo}--${number}.json`;