Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |
Expand Down
24 changes: 22 additions & 2 deletions docs/WORKFLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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/<scene-id>.md` and the cumulative
reader ledger. Then accept reviewed state:
Expand All @@ -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
Expand Down
325 changes: 325 additions & 0 deletions engine/changes.mjs

Large diffs are not rendered by default.

53 changes: 53 additions & 0 deletions engine/doctor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)), "..");

Expand Down Expand Up @@ -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."
}
};

Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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));
Expand Down
11 changes: 11 additions & 0 deletions engine/hooks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }] };
}
Expand Down Expand Up @@ -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");
Expand Down
27 changes: 24 additions & 3 deletions engine/init.mjs
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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;
}
14 changes: 8 additions & 6 deletions integrity.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
Loading
Loading