Files
document-haness/.agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs
T
DongHyeonkaandClaude Fable 5.1 9d2a3725c5 pipeline: make tech-log-tree.json the one decomposition contract and enforce it
리뷰 두 건을 반영했다.

계약
- tech-log-tree.json 하나가 분해 계약이자 색인이다. 사람이 읽는 트리·Node Specification·
  후보 대장은 없어졌고, 문서에 남아 있던 그 개념을 걷어냈다
- candidateScope — 후보를 찾는 SSOT 범위. 접어 넣은 제2부·제3부는 근거이지 후보가 아니다
- sourceRepository — 분석한 저장소의 경로·리비전·판단 근거. 리비전을 모르면 null 로 두고
  지어내지 않는다. 갈래가 여럿이면 revisions
- 검사기: 계약 미채택·PENDING·PROMOTE↔글감 양방향·candidateScope·sourceRepository 를
  error/warn 으로 센다. 옛 스키마도 검사를 피하지 못한다. 테스트 22 → 31

기록 쓰기
- 템플릿 5종에 source·sourceRevision·topicName, Question 에 닫는 조건, 본문 없는 종류에서
  assets 제거. 고정 절 개수 삭제
- check_evidence.mjs — 인용한 코드가 SSOT 에 있는지, 앵커가 SSOT 를 가리키는지, 제목이
  계약과 같은지, 리비전이 저장소에 있는지. 게시된 기록에서 SSOT 와 다른 URL 을 잡았다

문체
- 문체 규칙의 정본을 ai-tells.md 로. explaining.md 의 질문체 제목·절 끝 대조 반복·그림 예고
  규칙을 삭제해 충돌을 없앴다. 첫 절 「설명 뒤에 평가를 붙이지 않는다」에 지우는 사례 네 유형
- voice 스킬의 「독자 쪽을 본다」를 자료에 오독 기록이 있을 때로 좁히고, 평가만 더한 예시를 교체
- check_prose: 안내 문장을 요구하던 경고 제거, 문장이 끝나지 않은 채 문단이 끝나는 조각 검사 추가

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:39:20 +09:00

116 lines
6.0 KiB
JavaScript

#!/usr/bin/env node
// 지어낸 목소리를 잡는다. 모자란 목소리는 재지 않는다 — 그것은 사람이 읽어야 안다.
//
// node check_voice.mjs [--블로그] <파일.md> [...]
//
// 왜 세지 않는가: 이 저장소에서 「종결어미 종류 수」를 세는 검사를 넣었더니, 그것을 맞추려고
// 없던 물음표 문장과 「~해 보자」가 문서에 끼어들었다. 목소리를 개수로 재면 같은 일이 난다.
// 그래서 이 검사기는 있어야 할 것을 요구하지 않고, 있으면 안 되는 것만 잡는다.
//
// 참고 여섯 편을 그냥 돌리면 떨어진다. 그것은 규칙이 과해서가 아니라 장르가 달라서다 —
// 여섯 편은 합니다체 블로그 글이라 `~는데요`를 쓰고 맺음말에 인사를 둔다. 기록은 한다체이고
// 인사할 자리가 없다. 여섯 편에 돌려 볼 때는 `--블로그`를 붙인다. 그러면 이 두 규칙만 꺼지고
// 지어낸 목소리를 잡는 규칙은 그대로 돈다 — 여섯 편은 그쪽에 한 건도 걸리지 않는다.
import { readFileSync } from "node:fs";
import { basename } from "node:path";
const ERR = "error", WARN = "warn";
const RULES = [
// 1. 수사 과정을 말하는 문구.
//
// 이것만으로는 지어냈는지 알 수 없다. 22396 의 「여러 시행착오를 겪었습니다」는 바로 뒤에
// 실패한 시도 1 을 통째로 싣고, 7835 의 「고민 끝에」는 글 전체가 그 고민이다. 둘 다 정당하다.
// 나쁜 것은 문장만 있고 과정이 없는 경우인데, 그건 문서를 읽어야 안다. 그래서 판단 항목이다.
{ id: "process-claimed", sev: WARN,
re: /(처음에는[^.\n]{0,30}(의심|생각|짐작)|한참[^.\n]{0,15}(헤매|찾|고민)|여러[^.\n]{0,10}(시행착오|삽질)|고민\s*끝에|우여곡절|검토\s*끝에)/g,
msg: "겪은 과정을 말했습니다. 이 문서가 그 과정을 실제로 보여 주면 두고, 문장만 있으면 지우세요." },
{ id: "invented-emotion", sev: ERR,
re: /(놀랍게도|당황스럽|의외로|뜻밖에도|아쉽게도|다행히도|기쁘게도|흥미롭게도|충격적)/g,
msg: "감정을 지어냈습니다. 관측이 뜻밖이었다는 근거가 자료에 있어야 쓸 수 있습니다." },
// 2. 참고 글의 맺음말을 옮겨 온 자리. 기록에는 독자에게 인사하는 칸이 없다.
{ id: "borrowed-greeting", sev: ERR,
re: /(도움이\s*되(길|기를)|되었으면\s*좋겠|공유(드립니다|하고자|합니다)|읽어\s*주셔서|감사합니다|즐거움을\s*느끼|노력하겠습니다|기대합니다)/g,
msg: "블로그 맺음말의 인사입니다. 기록에는 그 자리가 없습니다. 남은 일이나 감수한 것으로 닫으세요." },
// 3. 합니다체의 부드러움을 한다체 문서에 섞은 자리
{ id: "register-mix", sev: ERR,
re: /(는데요|거든요|텐데요|인데요|한데요|잖아요|네요)/g,
msg: "합니다체의 부드러운 어미를 섞었습니다. 참고 글의 어조이지 이 기록의 어조가 아닙니다." },
// 4. 겪지 않은 1인칭. 기록의 주어는 대개 코드와 요청이다.
// 앞 글자가 한글이면 낱말 안이다 — 「브라우저는」의 「저는」을 잡지 않는다
{ id: "unsupported-first-person", sev: WARN,
re: /(?<![가-힣])(저는|저희(는|가|의|도)|제가|우리는)\s/g,
msg: "1인칭입니다. 자료가 그 사람의 행동을 기록했으면 두고, 아니면 무엇이 그렇게 했는지로 바꾸세요." },
// 5. 독자를 끌고 다니는 문장이 여러 번 나오는 것은 참고 글도 하지 않는다 (개수는 아래에서 본다)
{ id: "steering", sev: WARN,
re: /(라고\s*생각하기\s*쉽|겉보기에는|여기서\s*확인할\s*값은|짐작하기\s*쉽|헷갈리기\s*쉽)/g,
msg: "독자 쪽을 보는 문장입니다. 한 기록에 한 번이면 충분합니다." },
];
function strip(src) {
return src
.replace(/```[\s\S]*?```/g, (m) => m.replace(/[^\n]/g, " "))
.replace(/`[^`\n]*`/g, (m) => " ".repeat(m.length))
.replace(/^---\n[\s\S]*?\n---\n/, (m) => m.replace(/[^\n]/g, " "));
}
function lineOf(text, index) {
return text.slice(0, index).split("\n").length;
}
let failed = 0;
const argv = process.argv.slice(2);
// 합니다체 블로그 글에 돌릴 때는 어조·인사 규칙을 끈다. 장르가 다르지 글이 나빠서가 아니다.
const blogMode = argv.includes("--블로그");
const GENRE = new Set(["register-mix", "borrowed-greeting"]);
const files = argv.filter((a) => !a.startsWith("--"));
if (!files.length) {
console.error("쓰는 법: node check_voice.mjs [--블로그] <파일.md>");
process.exit(2);
}
for (const file of files) {
const raw = readFileSync(file, "utf8");
const text = strip(raw);
const found = [];
for (const r of RULES) {
if (blogMode && GENRE.has(r.id)) continue;
for (const m of text.matchAll(r.re)) {
found.push({ ...r, line: lineOf(text, m.index), hit: m[0].trim() });
}
}
// 독자 쪽을 보는 문장은 하나까지가 정상이다
const steering = found.filter((f) => f.id === "steering");
const rest = found.filter((f) => f.id !== "steering");
const shown = steering.length > 1 ? rest.concat(steering) : rest;
const errors = shown.filter((f) => f.sev === ERR).length;
const warns = shown.length - errors;
if (errors) failed = 1;
console.log(
errors
? `FAIL ${basename(file)} — error ${errors}${warns ? ` · 경고 ${warns}건` : ""}`
: `OK ${basename(file)}${warns ? ` (경고 ${warns}건)` : ""}`
);
for (const f of shown) {
const mark = f.sev === ERR ? " " : "·";
console.log(` ${mark} ${basename(file)}:${f.line} [${f.id}] "${f.hit}"`);
console.log(` ${f.msg}`);
}
if (steering.length > 1) {
console.log(` · [steering] 독자 쪽을 보는 문장이 ${steering.length}개입니다 — 하나만 남기세요.`);
}
}
console.log(
"\n검사기가 조용해도 목소리가 생긴 것은 아닙니다. 모자란 것은 사람이 읽어야 압니다."
);
process.exit(failed);