diff --git a/README.md b/README.md index beadd5b..1502ab6 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,26 @@ malformed input or internal errors; `check` and `release` remain authoritative. The GitHub install works today. The equivalent registry package will be `@pickbitsai/weaver` after npm publication. +## Approving changes + +The author decides what enters the book. AI scene edits stay pending until the +author approves them, and only one chapter is approved or rejected at a time: + +```bash +npx weaver changes --root ./my-book +npx weaver approve --chapter 1 --note "Opening pass" --root ./my-book +npx weaver reject --chapter 1 --root ./my-book +npx weaver undo --root ./my-book +npx weaver history --root ./my-book +``` + +`changes` shows the scenes, word changes, and style findings waiting for the +author. `approve` saves an approval as a Git commit, while `reject` saves the +rejected text under `.weaver/rejected/` before restoring the last approved +text. `undo` creates a Git revert, so history is never rewritten. Rejected +copies are kept on disk and ignored by Git; `history` shows approvals and +undos. Nothing is ever silently discarded. + ## Keep narrative state current After writing or revising a scene, update its corresponding @@ -120,6 +140,11 @@ Re-derive them in order before accepting state again. | `status` | Summarize manuscript hash, words, scenes, and state freshness | | `check` | Run project, critical-path, repetition, and state gates | | `rules` | Run deterministic style rules (`--scene`, `--json` supported) | +| `changes` | Show pending scene edits grouped by chapter (`--json` supported) | +| `approve` | Save one chapter's pending edits as an approval | +| `reject` | Save and restore one chapter's pending edits | +| `undo` | Revert the most recent approval with a new Git commit | +| `history` | Show approvals and undos (`--json` supported) | | `hooks install` | Install or merge Claude Code scene-edit hooks | | `state:status` | Show current and stale derived-state records | | `state:accept` | Stamp reviewed narrative state through a scene | diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index eefdaa6..71ec6db 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -32,6 +32,10 @@ chapter and scene counts, POV decisions, and scene word counts. Imported books begin with missing or stale narrative state by design; `check` reports that state but does not fail on it until the project's state workflow is bootstrapped. +The repair path uses the same author approval boundary: imported or AI-edited +scene changes appear in `weaver changes`, and the author approves or rejects +one chapter at a time before continuing to state review and export. + ## 1. Lock the architecture Before drafting, define the ending, scene-level beat map, reveal/reframe ledger, @@ -87,7 +91,23 @@ Revise until continuity is clear, style drift is resolved or accepted, and the critic passes. Major structural changes remain human-gated. The reviewer prompts live under `prompts/reviewers/`. -## 5. Update derived state +## 5. Approve or reject the chapter + +The author controls when AI edits enter the book. Inspect pending scene edits +with `weaver changes --root ./my-book`. Work on one chapter at a time: + +```bash +weaver approve --chapter 1 --note "Reviewed opening" --root ./my-book +``` + +Approval creates a Git snapshot and refreshes each changed scene's derived word +footer. If the author does not want the edits, use +`weaver reject --chapter 1 --root ./my-book`; Weaver saves the rejected files +under `.weaver/rejected/` and restores the last approved text. Rejected copies +stay on disk and are ignored by Git. `weaver undo` creates a new revert commit, +and `weaver history` lists approvals and undos. Nothing is silently lost. + +## 6. Update derived state Update only what changed in `narrative-state/.md` and the cumulative reader ledger. Then accept reviewed state: @@ -99,7 +119,7 @@ weaver state:accept --root ./my-book --through 1-1 If an early scene is revised later, re-derive every stale scene in order. `prompts/state-extractor.md` defines the generic delta format. -## 6. Verify and release +## 7. Verify and release ```bash weaver check --root ./my-book diff --git a/engine/changes.mjs b/engine/changes.mjs new file mode 100644 index 0000000..a713858 --- /dev/null +++ b/engine/changes.mjs @@ -0,0 +1,325 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Mark Pickering and PICKBITS LLC. Part of PickBits Weaver. +import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { dirname, join, relative, resolve } from "node:path"; +import { countWords, extractProse } from "./manuscript.mjs"; +import { runRules } from "./rules.mjs"; +import { GENERATOR } from "./identity.mjs"; + +const SCENE_PATH = /^chapter-(\d+)\/scene-(\d+)\/scene\.md$/u; + +function git(root, args, options = {}) { + return spawnSync("git", ["-C", root, ...args], { + encoding: "utf8", + ...options + }); +} + +function hasHead(root) { + return git(root, ["rev-parse", "--verify", "HEAD"], { stdio: ["ignore", "pipe", "ignore"] }).status === 0; +} + +function parseStatus(output) { + const records = []; + const parts = output ? output.split("\0") : []; + for (let index = 0; index < parts.length; index += 1) { + const entry = parts[index]; + if (!entry) continue; + const status = entry.slice(0, 2); + const firstPath = entry.slice(3); + if (!firstPath) continue; + const paths = [firstPath]; + if (status.includes("R") || status.includes("C")) { + if (parts[index + 1]) paths.push(parts[++index]); + } + for (const path of paths) records.push({ status, path: path.replaceAll("\\", "/") }); + } + return records; +} + +function statusRecords(root, project, { allFiles = false } = {}) { + const args = ["status", "--porcelain=v1", "-z", "-uall"]; + if (!allFiles) args.push("--", project.source_root); + const result = git(root, args, { stdio: ["ignore", "pipe", "pipe"] }); + if (result.status !== 0) throw new Error(`could not read the book's Git changes: ${(result.stderr || "").trim()}`); + return parseStatus(result.stdout); +} + +function sceneRecord(path, status, root, project, headExists) { + const sourceRelative = relative(resolve(root, project.source_root), resolve(root, path)).replaceAll("\\", "/"); + const match = SCENE_PATH.exec(sourceRelative); + if (!match) return null; + const relativePath = join(project.source_root, sourceRelative).replaceAll("\\", "/"); + const absolute = resolve(root, relativePath); + const beforeText = headExists ? gitShow(root, relativePath) : null; + const afterText = existsSync(absolute) ? readFileSync(absolute, "utf8") : null; + const beforeProse = beforeText === null ? "" : extractProse(beforeText, relativePath); + const afterProse = afterText === null ? "" : extractProse(afterText, relativePath); + const before = countWords(beforeProse); + const after = countWords(afterProse); + return { + id: `${Number(match[1])}-${Number(match[2])}`, + chapter: Number(match[1]), + scene: Number(match[2]), + path: relativePath, + absolute, + status, + before, + after, + delta: after - before, + beforeProse, + afterProse, + deleted: afterText === null, + untracked: status.trim() === "??" + }; +} + +function gitShow(root, path) { + const result = git(root, ["show", `HEAD:${path}`], { stdio: ["ignore", "pipe", "ignore"] }); + return result.status === 0 ? result.stdout : null; +} + +export function pendingChanges(root, project) { + const headExists = hasHead(root); + const records = statusRecords(root, project); + const scenes = records + .map((record) => sceneRecord(record.path, record.status, root, project, headExists)) + .filter(Boolean) + .sort((a, b) => a.chapter - b.chapter || a.scene - b.scene || a.path.localeCompare(b.path)); + const unique = new Map(); + for (const scene of scenes) unique.set(scene.path, scene); + const changedScenes = [...unique.values()]; + const chapters = new Map(); + for (const scene of changedScenes) { + if (!chapters.has(scene.chapter)) chapters.set(scene.chapter, []); + chapters.get(scene.chapter).push(scene); + } + return { + headExists, + files: records.map((record) => record.path), + scenes: changedScenes, + chapters: [...chapters.entries()].sort((a, b) => a[0] - b[0]).map(([chapter, chapterScenes]) => ({ chapter, scenes: chapterScenes })) + }; +} + +function chapterFindings(root, project, pending) { + if (!pending.scenes.length) return new Map(); + const available = pending.scenes.filter((scene) => !scene.deleted).map((scene) => scene.id); + if (!available.length) return new Map(); + const result = runRules(root, project, { sceneIds: available }); + const byScene = new Map(); + for (const finding of result.findings) { + if (!byScene.has(finding.scene_id)) byScene.set(finding.scene_id, []); + byScene.get(finding.scene_id).push(finding); + } + return byScene; +} + +export function describePendingChanges(root, project) { + const pending = pendingChanges(root, project); + const findings = chapterFindings(root, project, pending); + const chapters = pending.chapters.map(({ chapter, scenes }) => { + const changed = scenes.map((scene) => ({ + id: scene.id, + chapter: scene.chapter, + scene: scene.scene, + path: scene.path, + status: scene.status, + words_before: scene.before, + words_after: scene.after, + words_delta: scene.delta, + findings: findings.get(scene.id) || [] + })); + const allFindings = changed.flatMap((scene) => scene.findings); + const added = changed.reduce((sum, scene) => sum + Math.max(scene.words_delta, 0), 0); + const removed = changed.reduce((sum, scene) => sum + Math.max(-scene.words_delta, 0), 0); + return { + chapter, + scenes_changed: changed.length, + words_before: changed.reduce((sum, scene) => sum + scene.words_before, 0), + words_after: changed.reduce((sum, scene) => sum + scene.words_after, 0), + words_added: added, + words_removed: removed, + blocking: allFindings.filter((finding) => finding.severity === "block").length, + warnings: allFindings.filter((finding) => finding.severity === "warn").length, + scenes: changed + }; + }); + return { pending: chapters.length > 0, chapters, files: pending.files }; +} + +export function otherPendingChapters(root, project, chapter) { + return pendingChanges(root, project).chapters + .map((entry) => entry.chapter) + .filter((value) => value !== Number(chapter)); +} + +function writeFooter(path, words) { + const raw = readFileSync(path, "utf8").replace(/\r\n/g, "\n"); + const lines = raw.split("\n"); + const separators = lines.flatMap((line, index) => line.trim() === "---" ? [index] : []); + if (separators.length < 2) throw new Error(`${path}: expected metadata and footer separators`); + const footerStart = separators.at(-1) + 1; + const footer = lines.slice(footerStart).join("\n"); + const updatedFooter = /\*\*Words:\*\*\s*\d+/u.test(footer) + ? footer.replace(/\*\*Words:\*\*\s*\d+/u, `**Words:** ${words}`) + : `\n**Words:** ${words}${footer}`; + if (updatedFooter !== footer) { + lines.splice(footerStart, lines.length - footerStart, ...updatedFooter.split("\n")); + writeFileSync(path, lines.join("\n")); + } +} + +function gitStatusAll(root) { + const result = git(root, ["status", "--porcelain=v1", "-z", "-uall"], { stdio: ["ignore", "pipe", "pipe"] }); + if (result.status !== 0) throw new Error(`could not read the book's Git changes: ${(result.stderr || "").trim()}`); + return parseStatus(result.stdout); +} + +function ensureIdentity(root, project) { + const name = git(root, ["config", "--get", "user.name"], { stdio: ["ignore", "pipe", "ignore"] }); + const email = git(root, ["config", "--get", "user.email"], { stdio: ["ignore", "pipe", "ignore"] }); + if (name.status !== 0 || !name.stdout.trim()) { + const result = git(root, ["config", "--local", "user.name", project.author?.trim() || "Author"], { stdio: ["ignore", "pipe", "pipe"] }); + if (result.status !== 0) throw new Error("could not set the book's local Git author name"); + } + if (email.status !== 0 || !email.stdout.trim()) { + const result = git(root, ["config", "--local", "user.email", "author@weaver.local"], { stdio: ["ignore", "pipe", "pipe"] }); + if (result.status !== 0) throw new Error("could not set the book's local Git author email"); + } +} + +function formatChapter(chapter) { + return `Chapter ${chapter}`; +} + +export function approveChapter(root, project, chapter = null, note = "") { + const pending = pendingChanges(root, project); + if (!pending.scenes.length) throw new Error("There are no scene changes waiting for approval."); + const chapters = pending.chapters.map((entry) => entry.chapter); + if (chapters.length > 1 && chapter === null) throw new Error("Changes are waiting in more than one chapter. Choose one chapter at a time with --chapter."); + const selectedChapter = chapter === null ? chapters[0] : Number(chapter); + if (!Number.isInteger(selectedChapter) || !chapters.includes(selectedChapter)) throw new Error(`${formatChapter(chapter)} has no scene changes waiting for approval.`); + const selected = pending.chapters.find((entry) => entry.chapter === selectedChapter).scenes; + const selectedFindings = chapterFindings(root, project, { ...pending, scenes: selected }); + const blocking = selected.flatMap((scene) => selectedFindings.get(scene.id) || []).filter((finding) => finding.severity === "block"); + if (blocking.length) { + const lines = blocking.map((finding) => `${finding.scene_id}: ${finding.rule_id} (paragraph ${finding.paragraph}): ${finding.message}`); + throw new Error(`Approval stopped because Chapter ${selectedChapter} has blocking style findings:\n- ${lines.join("\n- ")}`); + } + for (const scene of selected) { + if (!scene.deleted) writeFooter(scene.absolute, countWords(extractProse(readFileSync(scene.absolute, "utf8"), scene.path))); + } + ensureIdentity(root, project); + const paths = selected.map((scene) => scene.path); + const add = git(root, ["add", "--all", "--", ...paths], { stdio: ["ignore", "pipe", "pipe"] }); + if (add.status !== 0) throw new Error(`could not stage the chapter changes: ${(add.stderr || "").trim()}`); + const subject = `Approve chapter ${selectedChapter}${note?.trim() ? `: ${note.trim()}` : ""}`; + const commit = git(root, ["commit", "--only", "-m", subject, "-m", `Approved-with: ${GENERATOR}`, "--", ...paths], { stdio: ["ignore", "pipe", "pipe"] }); + if (commit.status !== 0) throw new Error(`could not save the approval: ${(commit.stderr || commit.stdout || "").trim()}`); + const sha = git(root, ["rev-parse", "--short", "HEAD"], { stdio: ["ignore", "pipe", "pipe"] }); + if (sha.status !== 0) throw new Error("approval was saved, but its id could not be read from Git"); + return { chapter: selectedChapter, id: sha.stdout.trim(), paths, message: subject }; +} + +export function rejectChapter(root, project, chapter = null) { + const pending = pendingChanges(root, project); + if (!pending.scenes.length) throw new Error("There are no scene changes waiting for rejection."); + const chapters = pending.chapters.map((entry) => entry.chapter); + if (chapters.length > 1 && chapter === null) throw new Error("Changes are waiting in more than one chapter. Choose one chapter at a time with --chapter."); + const selectedChapter = chapter === null ? chapters[0] : Number(chapter); + if (!Number.isInteger(selectedChapter) || !chapters.includes(selectedChapter)) throw new Error(`${formatChapter(chapter)} has no scene changes waiting for rejection.`); + const selected = pending.chapters.find((entry) => entry.chapter === selectedChapter).scenes; + const createdAt = new Date().toISOString(); + const rejectedBase = join(root, ".weaver", "rejected"); + const timestampBase = createdAt.replaceAll(":", "-"); + let timestamp = timestampBase; + let suffix = 2; + while (existsSync(join(rejectedBase, timestamp))) timestamp = `${timestampBase}-${suffix++}`; + const rejectedRoot = join(rejectedBase, timestamp); + const deleted = []; + mkdirSync(rejectedRoot, { recursive: true }); + const restore = []; + for (const scene of selected) { + const destination = join(rejectedRoot, scene.path); + if (scene.deleted) { + deleted.push(scene.path); + restore.push(scene.path); + } else if (scene.untracked) { + mkdirSync(dirname(destination), { recursive: true }); + renameSync(scene.absolute, destination); + } else { + mkdirSync(dirname(destination), { recursive: true }); + copyFileSync(scene.absolute, destination); + restore.push(scene.path); + } + } + writeFileSync(join(rejectedRoot, "manifest.json"), `${JSON.stringify({ chapter: selectedChapter, created_at: createdAt, files: selected.map((scene) => scene.path), deleted }, null, 2)}\n`); + if (restore.length) { + const result = git(root, ["restore", "--source=HEAD", "--staged", "--worktree", "--", ...restore], { stdio: ["ignore", "pipe", "pipe"] }); + if (result.status !== 0) throw new Error(`could not restore the rejected chapter: ${(result.stderr || "").trim()}`); + } + return { chapter: selectedChapter, path: relative(root, rejectedRoot).replaceAll("\\", "/"), files: selected.map((scene) => scene.path) }; +} + +export function undoApproval(root) { + if (gitStatusAll(root).length) throw new Error("There are pending changes. Approve or reject them first."); + const log = git(root, ["log", "--format=%H%x00%s", "--all"], { stdio: ["ignore", "pipe", "pipe"] }); + if (log.status !== 0) throw new Error("could not read the book's approval history"); + const entries = log.stdout.split("\n").filter(Boolean).map((line) => { + const [sha, subject] = line.split("\0"); + return { sha, subject }; + }); + const approval = entries.find((entry) => /^Approve chapter \d+/u.test(entry.subject)); + if (!approval) throw new Error("There is no approval to undo."); + const result = git(root, ["revert", "--no-edit", approval.sha], { stdio: ["ignore", "pipe", "pipe"] }); + if (result.status !== 0) { + git(root, ["revert", "--abort"], { stdio: ["ignore", "pipe", "ignore"] }); + throw new Error("Undo could not be completed because Git found a conflict. Nothing was changed."); + } + const sha = git(root, ["rev-parse", "--short", "HEAD"], { stdio: ["ignore", "pipe", "pipe"] }); + return { id: sha.stdout.trim(), approval: approval.sha.slice(0, 7) }; +} + +function approvalDetails(subject) { + const match = /^Approve chapter (\d+)(?:: (.*))?$/u.exec(subject); + return match ? { chapter: Number(match[1]), note: match[2] || "" } : null; +} + +export function approvalHistory(root) { + const log = git(root, ["log", "--format=%H%x00%aI%x00%s", "--all"], { stdio: ["ignore", "pipe", "pipe"] }); + if (log.status !== 0) throw new Error("could not read the book's approval history"); + const history = []; + for (const line of log.stdout.split("\n").filter(Boolean)) { + const [sha, date, subject] = line.split("\0"); + const approval = approvalDetails(subject); + const undone = /^Revert "(Approve chapter \d+(?:: .*)?)"/u.exec(subject); + if (approval) history.push({ type: "approval", date, chapter: approval.chapter, note: approval.note, id: sha.slice(0, 7) }); + else if (undone) { + const details = approvalDetails(undone[1]); + history.push({ type: "undo", date, chapter: details.chapter, note: details.note, id: sha.slice(0, 7) }); + } + } + const rejectedRoot = join(root, ".weaver", "rejected"); + if (existsSync(rejectedRoot)) { + for (const entry of readdirSync(rejectedRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const manifestPath = join(rejectedRoot, entry.name, "manifest.json"); + if (!existsSync(manifestPath)) continue; + try { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + history.push({ + type: "rejection", + date: manifest.created_at || entry.name, + chapter: manifest.chapter, + note: `Rejected text saved in .weaver/rejected/${entry.name}`, + id: entry.name.slice(-7) + }); + } catch { + // A damaged rejection record should not hide the Git approval history. + } + } + } + return history.sort((a, b) => String(b.date).localeCompare(String(a.date))); +} diff --git a/engine/doctor.mjs b/engine/doctor.mjs index ca345b9..80b182d 100644 --- a/engine/doctor.mjs +++ b/engine/doctor.mjs @@ -6,6 +6,7 @@ import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { compareIntegrity } from "./integrity.mjs"; import { GENERATOR } from "./identity.mjs"; +import { WEAVER_HOOK_COMMANDS } from "./hooks.mjs"; const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -58,6 +59,20 @@ const FINDINGS = { title: "Command-running AI host", why: "PickBits Weaver runs with an AI that can run commands, such as Claude Code. Chat-only AIs (web chat windows) are not supported.", fix: "Use an AI that can run commands, such as Claude Code." + }, + hooksInstalled: { + id: "hooks-installed", + problem: "Weaver's Claude Code hooks are not installed", + title: "Claude Code hooks installed", + why: "Without Weaver's hooks, AI scene edits are not protected by the chapter and style checks.", + fix: "Run `weaver hooks install` from the book folder." + }, + hooksRunnable: { + id: "hooks-runnable", + problem: "Weaver's Claude Code hooks cannot run from this book folder", + title: "Claude Code hooks can run", + why: "Claude Code runs the hooks from the book folder, so Weaver must be installed there.", + fix: "Install Weaver in the book folder with npm." } }; @@ -114,6 +129,34 @@ function copiedWeaverEngine(bookRoot) { return walk(bookRoot); } +function hookEntryInstalled(entries, command) { + return Array.isArray(entries) && entries.some((entry) => entry?.matcher === WEAVER_HOOK_COMMANDS.matcher + && Array.isArray(entry.hooks) + && entry.hooks.some((hook) => hook?.type === "command" && hook.command === command)); +} + +function hooksInstalled(bookRoot) { + try { + const settings = JSON.parse(readFileSync(join(bookRoot, ".claude", "settings.json"), "utf8")); + return Boolean(settings && typeof settings === "object" && !Array.isArray(settings) + && hookEntryInstalled(settings.hooks?.PreToolUse, WEAVER_HOOK_COMMANDS.pre) + && hookEntryInstalled(settings.hooks?.PostToolUse, WEAVER_HOOK_COMMANDS.post)); + } catch { + return false; + } +} + +function hooksRunnable(bookRoot) { + const executable = process.platform === "win32" ? "npx.cmd" : "npx"; + const result = spawnSync(executable, ["--no-install", "weaver", "--version"], { + cwd: bookRoot, + encoding: "utf8", + timeout: 15_000, + stdio: ["ignore", "pipe", "pipe"] + }); + return result.status === 0 && result.stdout.includes(GENERATOR); +} + export function runDoctor({ bookRoot, packageRoot = PACKAGE_ROOT, nodeVersion = process.versions.node } = {}) { const root = resolve(bookRoot || process.cwd()); const blocking = []; @@ -156,6 +199,16 @@ export function runDoctor({ bookRoot, packageRoot = PACKAGE_ROOT, nodeVersion = integrityFinding )); + const installed = hooksInstalled(root); + const hooksInstalledFinding = installed ? [] : [finding(FINDINGS.hooksInstalled)]; + if (!installed) blocking.push(...hooksInstalledFinding); + checks.push(checkResult(FINDINGS.hooksInstalled.id, FINDINGS.hooksInstalled.title, installed ? "pass" : "blocking", hooksInstalledFinding)); + + const runnable = hooksRunnable(root); + const hooksRunnableFinding = runnable ? [] : [finding(FINDINGS.hooksRunnable)]; + if (!runnable) warnings.push(...hooksRunnableFinding); + checks.push(checkResult(FINDINGS.hooksRunnable.id, FINDINGS.hooksRunnable.title, runnable ? "pass" : "warning", hooksRunnableFinding)); + const hostFinding = [finding(FINDINGS.host)]; warnings.push(...hostFinding); checks.push(checkResult(FINDINGS.host.id, FINDINGS.host.title, "warning", hostFinding)); diff --git a/engine/hooks.mjs b/engine/hooks.mjs index b0f6c49..e698186 100644 --- a/engine/hooks.mjs +++ b/engine/hooks.mjs @@ -5,11 +5,14 @@ import { isAbsolute, join, relative, resolve } from "node:path"; import { countWords, loadScenes } from "./manuscript.mjs"; import { readProject } from "./project.mjs"; import { loadRules, runRules } from "./rules.mjs"; +import { otherPendingChapters } from "./changes.mjs"; const MATCHER = "Edit|Write|MultiEdit"; const PRE_COMMAND = "npx --no-install weaver hook pre"; const POST_COMMAND = "npx --no-install weaver hook post"; +export const WEAVER_HOOK_COMMANDS = { pre: PRE_COMMAND, post: POST_COMMAND, matcher: MATCHER }; + function hookEntry(matcher, command) { return { matcher, hooks: [{ type: "command", command }] }; } @@ -89,6 +92,14 @@ function findingText(finding) { function postHook(payload) { const scene = hookScene(payload); if (!scene) return null; + const otherChapters = otherPendingChapters(scene.cwd, scene.project, Number(scene.sceneId.split("-")[0])); + if (otherChapters.length) { + const chapters = otherChapters.join(", "); + return { + decision: "block", + reason: `Chapter ${chapters} has changes waiting for the author. Finish that chapter and ask the author to approve or reject it before editing chapter ${scene.sceneId.split("-")[0]}.` + }; + } const result = runRules(scene.cwd, scene.project, { sceneIds: [scene.sceneId] }); if (result.blocking) { const blocking = result.findings.filter((finding) => finding.severity === "block").map(findingText).join("\n"); diff --git a/engine/init.mjs b/engine/init.mjs index 81ff2c9..fba0930 100644 --- a/engine/init.mjs +++ b/engine/init.mjs @@ -1,10 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Mark Pickering and PICKBITS LLC. Part of PickBits Weaver. -import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { PROJECT_ID_PATTERN } from "./project.mjs"; +import { installHooks } from "./hooks.mjs"; const ENGINE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const TEMPLATE_ROOT = join(ENGINE_ROOT, "templates", "book"); @@ -31,10 +32,30 @@ export function initializeProject(target, { projectId = "untitled-book", title = project.project_id = projectId; project.title = title.trim(); writeFileSync(projectPath, `${JSON.stringify(project, null, 2)}\n`); - spawnSync("git", ["init", "-q"], { - cwd: destination, + const gitignorePath = join(destination, ".gitignore"); + const existingGitignore = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf8") : ""; + const requiredIgnoreLines = ["node_modules/", ".weaver/tmp/", ".weaver/rejected/"]; + const missing = requiredIgnoreLines.filter((line) => !existingGitignore.split(/\r?\n/u).some((entry) => entry.trim() === line)); + if (missing.length) { + const prefix = existingGitignore && !existingGitignore.endsWith("\n") ? "\n" : ""; + appendFileSync(gitignorePath, `${prefix}${missing.join("\n")}\n`); + } + const gitInit = spawnSync("git", ["-C", destination, "init", "-q"], { encoding: "utf8", stdio: ["ignore", "ignore", "ignore"] }); + installHooks(destination); + if (gitInit.status === 0) { + const config = (key, value) => { + const current = spawnSync("git", ["-C", destination, "config", "--get", key], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); + if (current.status !== 0 || !current.stdout.trim()) { + spawnSync("git", ["-C", destination, "config", "--local", key, value], { encoding: "utf8", stdio: ["ignore", "ignore", "ignore"] }); + } + }; + config("user.name", project.author?.trim() || "Author"); + config("user.email", "author@weaver.local"); + spawnSync("git", ["-C", destination, "add", "-A"], { encoding: "utf8", stdio: ["ignore", "ignore", "ignore"] }); + spawnSync("git", ["-C", destination, "commit", "-m", "Initialize book"], { encoding: "utf8", stdio: ["ignore", "ignore", "ignore"] }); + } return destination; } diff --git a/integrity.json b/integrity.json index a548dd7..99f73ff 100644 --- a/integrity.json +++ b/integrity.json @@ -5,7 +5,7 @@ "files": { "LICENSE": "3954ad2ee7d56cfda60326c71dcfd3d7edd79a44727f51b5ca5e4b7c33b8c6d6", "NOTICE": "83abe7f22edf5a06f7b4814e8b4b4b124e1b612bd81dd4c6542c0b119fab4976", - "README.md": "f75d4c169b2b52010cdbe806db5e9626a287eb6f7a886624eb7387735e9b0ffd", + "README.md": "ce359e8675d27bca69cadbac2b594431a651f29e654539fdede4d972afebca60", "SECURITY.md": "5c9c72ffaa8dfdc744360ea666d2082c68bba2479c61d13810bbd595498afe2b", "contracts/import.schema.json": "68a9be97050a3e58bacbc984c9e1050d2b0b1db72066af96e0a5fb4019fe9e85", "contracts/integrity.schema.json": "1a8cb4b28748b0f2bfeffc4d0210f5f422d1b69cfed3a386ff60c5de0bdd8d08", @@ -19,13 +19,14 @@ "docs/HANDOFF.md": "6e37433f60c54989abb1171c6989ed9c0cbab83be0d414d3d36ff6e985a9ee8c", "docs/LOOP.md": "bf3098c9afbcac6254d1682e87ad6b1e6ed798f0cb5e22a5f76a8fd3941fe8d5", "docs/ORIGIN.md": "d66e5d9a0c3e2c05a983c790dd616fc051d2666417ae0e1fd697478e5ff4cda8", - "docs/WORKFLOW.md": "86164c2c6e6544701572c088ed425801ded279cd52477fae7997e4f5984103e7", - "engine/doctor.mjs": "1e029539b16339f660347653d93ed1c003172f31c0d859a1f9edd0a4dd22ba5a", + "docs/WORKFLOW.md": "74e029e5d0977906b9a279787516e4e272478d6345705b48e1144911c539b44a", + "engine/changes.mjs": "faf344fd0a96ea9bc6beb414a30c89d9afa2ef40343af2db184fa7729059d4db", + "engine/doctor.mjs": "9fd9036d14712b12cd425bb3c4b6333cef9e56200f609d2b786ebb72de36c017", "engine/export.mjs": "092ad9a4687cf24ca5daa822c844879c8314adc227542cf46ec7b871e43f9f3c", - "engine/hooks.mjs": "ec844846a31850b7fc8da7c1c0df76e00fff03404b95a5882fb65487ec6e5d53", + "engine/hooks.mjs": "3d30b38ec828fbbb7606c7025cc1c63bd83f35a084ceae7abd8688ac4fccf36a", "engine/identity.mjs": "670f211b093aa783a0d6ed24be566239c438deb9da75e9ef20748e2c1b057364", "engine/import.mjs": "36b5f8475563d4fd0ea0ca9209d0232598e53d824733137cf46b8dbc2d3a81cc", - "engine/init.mjs": "a09729d6f34049b9629b9bba152bf1a7d190158d47c4e1e73b3d01ab66235475", + "engine/init.mjs": "dc9523ffd32226ae3624a8cba4997ceaaa11bac5fdc69f0909da03addc11a16d", "engine/integrity.mjs": "5c9ae6d22bbd28515887678e0e1ac214edd77ef2d2312fab63eda52407301e9f", "engine/manuscript.mjs": "eb03ce6d453b9b6d3a7e44b5d1a8d40eb88dcb86ae3963aaf0b4597345b14a59", "engine/project.mjs": "9f31f86a6cee011ea2486b7773491e5f6529a0b7f28e14094f1fb436c6ab2b81", @@ -41,7 +42,8 @@ "prompts/reviewers/style.md": "4479a4cb676627ca1ff4f7116f15e0cdc1f50fe2763db1388371d80570c9cdd4", "prompts/state-extractor.md": "b2d92d014892f2a1c201003b1cd16cf9d0adcc5e8611f7d350eab612b8947cc5", "scripts/integrity.mjs": "78c0bcdf7b5858de517f02eb09db1febf6d33eec787f09e7c24178f0cb263243", - "scripts/weaver.mjs": "46b925faf84a30b9bd917bf5ade8df9fac9ce71735359060e9ddf7af02079284", + "scripts/weaver.mjs": "11f6545c0d130ec71db813cbac503314fafb0dd272d7cc21a9a7b736469ac29a", + "templates/book/.claude/settings.json": "9fb07cc94b12c3cce506c1bd2706c351dca83309f1db2bb25f5897cec1735fe2", "templates/book/AGENTS.md": "6bb83885ca4498cfd5e994f2b36cd1688db04d6174905b0a813c5fbedd7c4a10", "templates/book/README.md": "c33756ab69072410e4583f44daf21d08fa6c576816c5ed240284850d6e4b2659", "templates/book/manuscript/chapter-01/scene-01/scene.md": "b17ea1077a47bcca702be9646c5aa3fbf6f557625e1daedfed30a7dfd684f5c6", diff --git a/scripts/weaver.mjs b/scripts/weaver.mjs index 50712ad..ab86d1e 100644 --- a/scripts/weaver.mjs +++ b/scripts/weaver.mjs @@ -14,6 +14,7 @@ import { acceptNarrativeState, buildGroundingPacket, narrativeStateStatus } from import { GENERATOR } from "../engine/identity.mjs"; import { installHooks, runHook } from "../engine/hooks.mjs"; import { runRules } from "../engine/rules.mjs"; +import { approveChapter, approvalHistory, describePendingChanges, rejectChapter, undoApproval } from "../engine/changes.mjs"; const argv = process.argv.slice(2); const command = argv.shift() || "help"; @@ -43,6 +44,14 @@ function print(value) { process.stdout.write(`${typeof value === "string" ? value : JSON.stringify(value, null, 2)}\n`); } +function pendingSummary(report) { + if (!report.pending) return "No scene changes are waiting for approval."; + return report.chapters.map((chapter) => { + const findingLabel = `${chapter.blocking} blocking style finding${chapter.blocking === 1 ? "" : "s"}`; + return `Chapter ${chapter.chapter}: ${chapter.scenes_changed} scene${chapter.scenes_changed === 1 ? "" : "s"} changed, +${chapter.words_added} / −${chapter.words_removed} words, ${findingLabel}`; + }).join("\n"); +} + function importOmissionsSummary(notImported = {}) { const comments = Number(notImported.comments || 0); const footnotes = Number(notImported.footnotes || 0) + Number(notImported.endnotes || 0); @@ -61,10 +70,13 @@ function warnAboutDoctor(root) { } } -function refuseIfDoctorBlocks(root, command) { +function refuseIfDoctorBlocks(root, command, { allowHookInstall = false } = {}) { const report = doctorReport(root); - if (report.ok) return false; - const [first, ...rest] = report.blocking; + const blocking = allowHookInstall + ? report.blocking.filter((finding) => finding.id !== "hooks-installed") + : report.blocking; + if (!blocking.length) return false; + const [first, ...rest] = blocking; print({ error: first.problem, command, @@ -167,8 +179,40 @@ try { print(`\n${result.blocking} blocking, ${result.warnings} warning(s); ${result.rules_checked} rule(s) checked.`); } if (result.blocking) process.exitCode = 1; - } else if (command === "hooks" && positional() === "install") { + } else if (command === "changes") { + const { project } = projectContext(); + const report = describePendingChanges(root, project); + if (argv.includes("--json")) print(report); + else print(pendingSummary(report)); + } else if (command === "approve") { + if (refuseIfDoctorBlocks(root, command)) process.exitCode = 1; + else { + const { project } = projectContext(); + const chapterOption = option("--chapter"); + const result = approveChapter(root, project, chapterOption == null ? null : Number(chapterOption), option("--note", "")); + print(`Approval ${result.id}: Chapter ${result.chapter} approved.`); + } + } else if (command === "reject") { if (refuseIfDoctorBlocks(root, command)) process.exitCode = 1; + else { + const { project } = projectContext(); + const chapterOption = option("--chapter"); + const result = rejectChapter(root, project, chapterOption == null ? null : Number(chapterOption)); + print(`Rejected Chapter ${result.chapter}. The rejected text was saved in ${result.path}.`); + } + } else if (command === "undo") { + if (refuseIfDoctorBlocks(root, command)) process.exitCode = 1; + else { + const result = undoApproval(root); + print(`Undo ${result.id}: the most recent approval was reversed.`); + } + } else if (command === "history") { + const history = approvalHistory(root); + if (argv.includes("--json")) print(history); + else if (!history.length) print("No approvals or undos yet."); + else print(history.map((entry) => `${entry.date} Chapter ${entry.chapter}: ${entry.note || "(no note)"} (${entry.type === "approval" ? "Approval" : entry.type === "undo" ? "Undo" : "Rejection"} ${entry.id})`).join("\n")); + } else if (command === "hooks" && positional() === "install") { + if (refuseIfDoctorBlocks(root, command, { allowHookInstall: true })) process.exitCode = 1; else { const result = installHooks(root); print(`${result.changed ? (result.created ? "Created" : "Updated") : "Already installed"} ${result.path}`); @@ -240,7 +284,6 @@ try { print(exportBook(root, project, option("--format"), option("--out"))); } } else if (command === "version" || command === "--version") { - warnAboutDoctor(root); print(GENERATOR); } else if (command === "help") { warnAboutDoctor(root); @@ -252,6 +295,11 @@ Usage: weaver status [--root directory] weaver check [--root directory] weaver rules [--root directory] [--scene scene-id] [--json] + weaver changes [--root directory] [--json] + weaver approve [--chapter number] [--note "text"] [--root directory] + weaver reject [--chapter number] [--root directory] + weaver undo [--root directory] + weaver history [--root directory] [--json] weaver hooks install [--root directory] weaver state:status [--root directory] weaver state:accept [--root directory] [--through scene-id] @@ -268,6 +316,11 @@ Usage: weaver status [--root directory] weaver check [--root directory] weaver rules [--root directory] [--scene scene-id] [--json] + weaver changes [--root directory] [--json] + weaver approve [--chapter number] [--note "text"] [--root directory] + weaver reject [--chapter number] [--root directory] + weaver undo [--root directory] + weaver history [--root directory] [--json] weaver hooks install [--root directory] weaver state:status [--root directory] weaver state:accept [--root directory] [--through scene-id] diff --git a/templates/book/.claude/settings.json b/templates/book/.claude/settings.json new file mode 100644 index 0000000..3285b81 --- /dev/null +++ b/templates/book/.claude/settings.json @@ -0,0 +1,26 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "npx --no-install weaver hook pre" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "npx --no-install weaver hook post" + } + ] + } + ] + } +} diff --git a/test/approvals.test.mjs b/test/approvals.test.mjs new file mode 100644 index 0000000..a1d853f --- /dev/null +++ b/test/approvals.test.mjs @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Mark Pickering and PICKBITS LLC. Part of PickBits Weaver. +import assert from "node:assert/strict"; +import test from "node:test"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +const ROOT = resolve("."); +const CLI = join(ROOT, "scripts", "weaver.mjs"); + +function git(root, args, env = {}) { + return spawnSync("git", ["-C", root, ...args], { cwd: ROOT, env: { ...process.env, ...env }, encoding: "utf8" }); +} + +function book({ baseline = false } = {}) { + const root = mkdtempSync(join(tmpdir(), "weaver-approvals-")); + cpSync(join(ROOT, "templates", "book"), root, { recursive: true }); + assert.equal(git(root, ["init", "-q"]).status, 0); + if (baseline) { + git(root, ["config", "--local", "user.name", "Test Author"]); + git(root, ["config", "--local", "user.email", "test@example.invalid"]); + assert.equal(git(root, ["add", "-A"]).status, 0); + assert.equal(git(root, ["commit", "-m", "Baseline"]).status, 0); + } + return root; +} + +function scenePath(root, chapter = 1, scene = 1) { + return join(root, "manuscript", `chapter-${String(chapter).padStart(2, "0")}`, `scene-${String(scene).padStart(2, "0")}`, "scene.md"); +} + +function addScene(root, chapter, scene, prose) { + const path = scenePath(root, chapter, scene); + const directory = dirname(path); + const source = `# Chapter ${chapter}, Scene ${scene}\n\n**POV:** Protagonist\n\n---\n\n${prose}\n\n---\n\n**Words:** 0\n`; + mkdirSync(directory, { recursive: true }); + writeFileSync(path, source); + return path; +} + +function run(root, args, globalConfig = join(tmpdir(), `weaver-global-${process.pid}-${Math.random().toString(16).slice(2)}.config`)) { + writeFileSync(globalConfig, ""); + return spawnSync(process.execPath, [CLI, ...args], { + cwd: ROOT, + env: { ...process.env, GIT_CONFIG_GLOBAL: globalConfig }, + encoding: "utf8" + }); +} + +test("changes and approve commit only one chapter, recompute words, add trailer, and set local identity", () => { + const root = book(); + try { + const second = addScene(root, 2, 1, "A second chapter scene waits for its own decision."); + const first = scenePath(root); + writeFileSync(first, readFileSync(first, "utf8").replace("different state.", "different and uncertain state.")); + let result = run(root, ["changes", "--root", root, "--json"]); + assert.equal(result.status, 0, result.stderr); + const pending = JSON.parse(result.stdout); + assert.deepEqual(pending.chapters.map((chapter) => chapter.chapter), [1, 2]); + + result = run(root, ["approve", "--root", root]); + assert.equal(result.status, 1); + assert.match(result.stderr + result.stdout, /more than one chapter/i); + + const globalConfig = join(root, "empty-global-config"); + result = run(root, ["approve", "--root", root, "--chapter", "1", "--note", "Opening pass"], globalConfig); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + const show = git(root, ["show", "--format=%B", "--name-only", "HEAD"], { GIT_CONFIG_GLOBAL: globalConfig }); + assert.equal(show.status, 0, show.stderr); + assert.match(show.stdout, /Approve chapter 1: Opening pass/); + assert.match(show.stdout, /Approved-with: PickBits Weaver 0\.3\.0/); + assert.match(show.stdout, /manuscript[\\/]chapter-01[\\/]scene-01[\\/]scene\.md/); + assert.doesNotMatch(show.stdout, /chapter-02/); + assert.equal(git(root, ["config", "--local", "--get", "user.name"], { GIT_CONFIG_GLOBAL: globalConfig }).stdout.trim(), "Author"); + assert.equal(git(root, ["config", "--local", "--get", "user.email"], { GIT_CONFIG_GLOBAL: globalConfig }).stdout.trim(), "author@weaver.local"); + assert.equal(readFileSync(first, "utf8").match(/\*\*Words:\*\* (\d+)/u)[1], "22"); + assert.equal(readFileSync(second, "utf8").includes("second chapter"), true); + assert.equal(readFileSync(globalConfig, "utf8"), ""); + const after = JSON.parse(run(root, ["changes", "--root", root, "--json"], globalConfig).stdout); + assert.deepEqual(after.chapters.map((chapter) => chapter.chapter), [2]); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("approve refuses no pending changes and blocking style findings", () => { + const root = book({ baseline: true }); + try { + let result = run(root, ["approve", "--root", root]); + assert.equal(result.status, 1); + assert.match(result.stderr + result.stdout, /no scene changes/i); + writeFileSync(join(root, "quality", "rules.json"), JSON.stringify({ version: 1, rules: [{ id: "ban-delve", type: "phrase-list", severity: "block", message: "Use specific prose.", phrases: ["delve"] }] })); + const path = scenePath(root); + writeFileSync(path, readFileSync(path, "utf8").replace("first line creates", "They delve before the first line creates")); + result = run(root, ["approve", "--root", root, "--chapter", "1"]); + assert.equal(result.status, 1); + assert.match(result.stderr + result.stdout, /blocking style findings/i); + assert.equal(git(root, ["log", "--format=%s"]).stdout.trim(), "Baseline"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("reject restores modified text and moves an untracked scene into rejected", () => { + const root = book({ baseline: true }); + try { + const original = readFileSync(scenePath(root), "utf8"); + const changed = original.replace("different state.", "rejected different state."); + writeFileSync(scenePath(root), changed); + let result = run(root, ["reject", "--root", root, "--chapter", "1"]); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.equal(readFileSync(scenePath(root), "utf8").replace(/\r\n/gu, "\n"), original.replace(/\r\n/gu, "\n")); + const rejectedPath = join(root, result.stdout.match(/in (.+)\.$/m)[1], "manuscript", "chapter-01", "scene-01", "scene.md"); + assert.equal(readFileSync(rejectedPath, "utf8"), changed); + + const newPath = addScene(root, 2, 1, "A new scene that the author rejects."); + const newText = readFileSync(newPath, "utf8"); + result = run(root, ["reject", "--root", root, "--chapter", "2"]); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.equal(existsSync(newPath), false); + const newRejected = join(root, result.stdout.match(/in (.+)\.$/m)[1], "manuscript", "chapter-02", "scene-01", "scene.md"); + assert.equal(readFileSync(newRejected, "utf8"), newText); + const history = JSON.parse(run(root, ["history", "--root", root, "--json"]).stdout); + assert.equal(history.some((entry) => entry.type === "rejection" && entry.chapter === 2), true); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("undo creates a revert commit, preserves approval history, restores prose, and refuses pending work", () => { + const root = book({ baseline: true }); + try { + const path = scenePath(root); + const before = readFileSync(path, "utf8"); + const edited = before.replace("different state.", "approved changed state."); + writeFileSync(path, edited); + let result = run(root, ["approve", "--root", root, "--chapter", "1"]); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + const approvalSha = git(root, ["rev-parse", "HEAD"]).stdout.trim(); + result = run(root, ["undo", "--root", root]); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.equal(readFileSync(path, "utf8").replace(/\r\n/gu, "\n"), before.replace(/\r\n/gu, "\n")); + assert.equal(git(root, ["rev-list", "--count", "HEAD"]).stdout.trim(), "3"); + assert.equal(git(root, ["cat-file", "-t", approvalSha]).stdout.trim(), "commit"); + assert.match(git(root, ["log", "--format=%s"]).stdout, /Revert "Approve chapter 1"/); + + writeFileSync(path, before.replace("different state.", "pending state.")); + result = run(root, ["undo", "--root", root]); + assert.equal(result.status, 1); + assert.match(result.stderr + result.stdout, /pending changes/i); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("approve keeps an author's existing Git identity instead of overriding it locally", () => { + const root = book(); + const globalConfig = join(tmpdir(), `weaver-global-identity-${process.pid}.config`); + writeFileSync(globalConfig, "[user]\n\tname = Real Person\n\temail = real@example.invalid\n"); + const env = { ...process.env, GIT_CONFIG_GLOBAL: globalConfig }; + try { + const first = scenePath(root); + writeFileSync(first, readFileSync(first, "utf8").replace("different state.", "different and uncertain state.")); + const result = spawnSync(process.execPath, [CLI, "approve", "--root", root], { cwd: ROOT, env, encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + assert.equal(git(root, ["log", "-1", "--format=%an <%ae>"], env).stdout.trim(), "Real Person "); + assert.notEqual(git(root, ["config", "--local", "--get", "user.name"], env).status, 0, "no local override when an identity exists"); + } finally { + rmSync(root, { recursive: true, force: true }); + rmSync(globalConfig, { force: true }); + } +}); diff --git a/test/consumer-install.mjs b/test/consumer-install.mjs index 684f68a..f6aa9df 100644 --- a/test/consumer-install.mjs +++ b/test/consumer-install.mjs @@ -71,6 +71,11 @@ try { assert.ok(existsSync(installedNotice), "NOTICE is missing from the packed package"); assert.match(readFileSync(installedNotice, "utf8"), /PickBits Weaver/); weaver(["init", book, "--id", "consumer-book", "--title", "Consumer Book"]); + const bookGitignore = readFileSync(join(book, ".gitignore"), "utf8"); + assert.match(bookGitignore, /node_modules\//); + assert.match(bookGitignore, /\.weaver\/tmp\//); + assert.match(bookGitignore, /\.weaver\/rejected\//); + assert.ok(existsSync(join(book, ".claude", "settings.json"))); weaver(["init", repairBook, "--id", "consumer-repair", "--title", "Consumer Repair", "--empty"]); writeFileSync(finishedSource, "# Wren\n\nA finished scene with an em — dash.\n\n* * *\n\nA second scene."); @@ -96,6 +101,18 @@ try { assert.equal(status.project_id, "consumer-book"); assert.equal(status.scenes, 1); + const scenePath = join(book, "manuscript", "chapter-01", "scene-01", "scene.md"); + const approvedBefore = readFileSync(scenePath, "utf8"); + writeFileSync(scenePath, approvedBefore.replace("different state.", "approved then undone state.")); + const pending = JSON.parse(weaver(["changes", "--root", book, "--json"])); + assert.equal(pending.chapters[0].chapter, 1); + weaver(["approve", "--chapter", "1", "--root", book]); + const history = JSON.parse(weaver(["history", "--root", book, "--json"])); + assert.equal(history[0].type, "approval"); + weaver(["undo", "--root", book]); + assert.equal(readFileSync(scenePath, "utf8").includes("approved then undone state."), false); + assert.equal(readFileSync(scenePath, "utf8").includes("different state."), true); + weaver(["state:accept", "--root", book]); const check = JSON.parse(weaver(["check", "--root", book])); assert.equal(check.ok, true); diff --git a/test/doctor.test.mjs b/test/doctor.test.mjs index bc41ea4..32f7305 100644 --- a/test/doctor.test.mjs +++ b/test/doctor.test.mjs @@ -145,6 +145,20 @@ test("doctor JSON output is machine-readable and identifies Weaver", () => { } }); +test("doctor blocks missing hooks and hooks install repairs that check", () => { + const book = makeBook(); + try { + rmSync(join(book, ".claude", "settings.json")); + const report = runDoctor({ bookRoot: book }); + assert.ok(report.blocking.some((finding) => finding.id === "hooks-installed")); + const result = runWeaver(["hooks", "install", "--root", book]); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.equal(runDoctor({ bookRoot: book }).blocking.some((finding) => finding.id === "hooks-installed"), false); + } finally { + rmSync(book, { recursive: true, force: true }); + } +}); + function requireSeparator() { return process.platform === "win32" ? "\\" : "/"; } diff --git a/test/hooks.test.mjs b/test/hooks.test.mjs index 091f571..7c9369d 100644 --- a/test/hooks.test.mjs +++ b/test/hooks.test.mjs @@ -90,14 +90,38 @@ test("hooks install merges unrelated settings and is idempotent", () => { } }); -test("hooks install uses the doctor refusal", () => { +test("post hook blocks editing another chapter while one chapter is pending", () => { + const root = book(); + try { + const secondDirectory = join(root, "manuscript", "chapter-02", "scene-01"); + mkdirSync(secondDirectory, { recursive: true }); + writeFileSync(join(secondDirectory, "scene.md"), `# Chapter 2, Scene 1\n\n**POV:** Protagonist\n\n---\n\nA second chapter scene carries the consequence forward.\n\n---\n\n**Words:** 9\n`); + const first = scenePath(root); + writeFileSync(first, readFileSync(first, "utf8").replace("different state.", "different and changed state.")); + const result = cli(["hook", "post"], root, JSON.stringify({ cwd: root, tool_name: "Edit", tool_input: { file_path: join(secondDirectory, "scene.md") } })); + assert.equal(result.status, 0, result.stderr); + const output = JSON.parse(result.stdout); + assert.equal(output.decision, "block"); + assert.match(output.reason, /Chapter 1 has changes waiting/); + assert.match(output.reason, /editing chapter 2/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("hooks install still uses the doctor refusal for a broken book", () => { const root = book(); try { rmSync(join(root, ".git"), { recursive: true, force: true }); + // New books ship with hooks, so strip them first: a real install would add them back, which + // makes "byte-identical afterwards" prove the refused command wrote nothing. + const settingsPath = join(root, ".claude", "settings.json"); + const stripped = `${JSON.stringify({ marker: "kept", hooks: {} }, null, 2)}\n`; + writeFileSync(settingsPath, stripped); const result = cli(["hooks", "install"], root); assert.equal(result.status, 1); assert.match(result.stdout, /Your book folder is not under Git/); - assert.equal(existsSync(join(root, ".claude", "settings.json")), false); + assert.equal(readFileSync(settingsPath, "utf8"), stripped); } finally { rmSync(root, { recursive: true, force: true }); }