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
50 changes: 41 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@ The initial extraction includes:
It deliberately excludes Silent Guardian prose, characters, art, audio, secrets,
and project-specific production routes.

## How PickBits Weaver works

Weaver is opinionated about setup so that it can be trusted with a manuscript.
These are requirements, not suggestions:

1. **Your book lives in its own folder, under Git.** Every change has a
history and can be undone. `weaver init` sets this up for you.
2. **Weaver is installed as a package, never copied into your book.** The AI
that works on your book cannot change the tool.
3. **You work with an AI that can run commands,** such as Claude Code. Chat
windows that can't run Weaver aren't supported.
4. **Your original files are never changed.** Import reads them; export writes
new files.

`weaver doctor` checks all of this. Until it passes, Weaver won't write to your
book, and it tells you in plain words what to fix.

## Quick start

Requirements: Node.js 22 or newer. There are no runtime dependencies.
Expand All @@ -30,14 +47,31 @@ npx weaver status --root ./my-book
npx weaver grounding 1-1 --root ./my-book
```

## Repair a finished book

Use one Weaver project for each book in a series. Start an empty book, import
the finished Word, Markdown, text, or folder source, then check and export it.
The original source is read-only and is never changed, moved, or renamed.

```bash
npx weaver init ./my-book --id my-book --title "My Book" --empty
npx weaver import ./finished-book.docx --root ./my-book
npx weaver check --root ./my-book
npx weaver export --format docx --out ./repaired-book.docx --root ./my-book
```

An imported book reports its narrative state as stale until a later workflow
bootstraps that derived state; this is expected, and its critical-path check
is skipped until a project-specific path is defined.

Scene prose uses Markdown-style `*italics*`; literal asterisks and backslashes
are escaped. Bold Markdown is intentionally unsupported and is imported as
literal text.

The GitHub install works today. The equivalent registry package will be
`@pickbitsai/weaver` after npm publication.

## Supported setup

Keep the book in its own folder under Git. Install Weaver as a package outside
the book; never copy Weaver into the book folder. Weaver runs with an AI that
can run commands, such as Claude Code, and `weaver doctor` checks this setup.
## Keep narrative state current

After writing or revising a scene, update its corresponding
`narrative-state/<chapter>-<scene>.md`, then explicitly accept the derived state:
Expand All @@ -56,6 +90,8 @@ Re-derive them in order before accepting state again.
| Command | Purpose |
| --- | --- |
| `init <directory>` | Create a generic book workspace |
| `import <source>` | Import a finished DOCX, Markdown, text file, or folder into an empty book |
| `export --format md\|docx --out <file>` | Export the current manuscript without overwriting the source or an existing file |
| `doctor` | Check the book folder, Git, host, and Weaver installation |
| `status` | Summarize manuscript hash, words, scenes, and state freshness |
| `check` | Run project, critical-path, repetition, and state gates |
Expand Down Expand Up @@ -100,7 +136,3 @@ considered a release.
Apache-2.0; see [LICENSE](LICENSE) and [NOTICE](NOTICE). Keep NOTICE with any
copy or derivative. Versions up to and including 0.2.0 were released under the
MIT License.

## Historical License

MIT 漏 Mark Pickering and PickBits.AI
77 changes: 77 additions & 0 deletions contracts/import.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/pickbitsai/weaver/main/contracts/import.schema.json",
"title": "Weaver book import record",
"type": "object",
"additionalProperties": false,
"required": ["$schema", "generator", "version", "imported_at", "source", "front_matter", "not_imported", "pov_rule", "chapters", "totals"],
"properties": {
"$schema": { "const": "https://pickbits.ai/schemas/weaver/import-1.json" },
"generator": { "type": "string" },
"version": { "type": "string" },
"imported_at": { "type": "string", "format": "date-time" },
"source": {
"type": "object",
"additionalProperties": false,
"required": ["file", "sha256", "kind"],
"properties": {
"file": { "type": "string", "minLength": 1 },
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
"kind": { "enum": ["docx", "markdown", "text", "folder"] }
}
},
"front_matter": { "type": "string" },
"not_imported": {
"type": "object",
"additionalProperties": false,
"required": ["comments", "footnotes", "endnotes"],
"properties": {
"comments": { "type": "integer", "minimum": 0 },
"footnotes": { "type": "integer", "minimum": 0 },
"endnotes": { "type": "integer", "minimum": 0 }
}
},
"pov_rule": { "enum": ["title", "none"] },
"chapter_pattern": { "type": "string" },
"chapters": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["number", "title", "pov", "scenes", "scene_count", "words"],
"properties": {
"number": { "type": "integer", "minimum": 1 },
"title": { "type": "string" },
"pov": { "type": ["string", "null"] },
"scenes": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["number", "prose", "words", "sha256"],
"properties": {
"number": { "type": "integer", "minimum": 1 },
"prose": { "type": "string" },
"words": { "type": "integer", "minimum": 0 },
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }
}
}
},
"scene_count": { "type": "integer", "minimum": 1 },
"words": { "type": "integer", "minimum": 0 }
}
}
},
"totals": {
"type": "object",
"additionalProperties": false,
"required": ["chapters", "scenes", "words"],
"properties": {
"chapters": { "type": "integer", "minimum": 1 },
"scenes": { "type": "integer", "minimum": 1 },
"words": { "type": "integer", "minimum": 0 }
}
}
}
}
15 changes: 15 additions & 0 deletions contracts/project.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,21 @@
"chapters": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"source_book": {
"type": "object",
"additionalProperties": false,
"required": ["file", "sha256", "kind", "imported_at", "chapters", "scenes", "words", "pov_rule"],
"properties": {
"file": { "type": "string", "minLength": 1 },
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
"kind": { "enum": ["docx", "markdown", "text", "folder"] },
"imported_at": { "type": "string", "format": "date-time" },
"chapters": { "type": "integer", "minimum": 1 },
"scenes": { "type": "integer", "minimum": 1 },
"words": { "type": "integer", "minimum": 0 },
"pov_rule": { "enum": ["title", "none"] }
}
}
}
}
21 changes: 21 additions & 0 deletions docs/WORKFLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,27 @@ Before working on a book, run:
weaver doctor --root ./my-book
```

## Repair path

For a finished book, create a separate Weaver project with `--empty`; this
removes the starter scene and starter critical-path fixture while preserving
the generic project configuration. Importing never merges with existing scene
files and never writes to the source document. A series uses one project per
book.

```bash
weaver init ./my-book --id my-book --title "My Book" --empty
weaver import ./finished-book.docx --root ./my-book
weaver status --root ./my-book
weaver check --root ./my-book
weaver export --format docx --out ./repaired-book.docx --root ./my-book
```

The import record under `imports/` preserves the source hash, front matter,
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.

## 1. Lock the architecture

Before drafting, define the ending, scene-level beat map, reveal/reframe ledger,
Expand Down
192 changes: 192 additions & 0 deletions engine/export.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Mark Pickering and PICKBITS LLC. Part of PickBits Weaver.
import { existsSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { deflateRawSync } from "node:zlib";
import { loadScenes } from "./manuscript.mjs";
import { GENERATOR } from "./identity.mjs";
import { parseEscapedProse } from "./prose.mjs";

const DOS_TIME = 0;
const DOS_DATE = 33;
const CRC_TABLE = Array.from({ length: 256 }, (_, value) => {
let crc = value;
for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
return crc >>> 0;
});

export function crc32(bytes) {
let crc = 0xffffffff;
for (const byte of bytes) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}

function u16(value) {
const result = Buffer.alloc(2);
result.writeUInt16LE(value, 0);
return result;
}

function u32(value) {
const result = Buffer.alloc(4);
result.writeUInt32LE(value >>> 0, 0);
return result;
}

export function zipEntries(entries, { method = "deflate", compression = null } = {}) {
const pairs = entries instanceof Map ? [...entries.entries()] : Array.isArray(entries) ? entries : Object.entries(entries);
method = compression || method;
const local = [];
const central = [];
let offset = 0;
for (const [name, input] of pairs) {
const filename = Buffer.from(name, "utf8");
const content = Buffer.from(input);
if (method !== "deflate" && method !== "stored") throw new Error(`unsupported ZIP method: ${method}`);
const compressed = method === "stored" ? content : deflateRawSync(content, { level: 9 });
const checksum = crc32(content);
const localHeader = Buffer.concat([
u32(0x04034b50), u16(20), u16(0x0800), u16(method === "stored" ? 0 : 8), u16(DOS_TIME), u16(DOS_DATE), u32(checksum),
u32(compressed.length), u32(content.length), u16(filename.length), u16(0), filename, compressed
]);
local.push(localHeader);
central.push(Buffer.concat([
u32(0x02014b50), u16(20), u16(20), u16(0x0800), u16(method === "stored" ? 0 : 8), u16(DOS_TIME), u16(DOS_DATE), u32(checksum),
u32(compressed.length), u32(content.length), u16(filename.length), u16(0), u16(0), u16(0), u16(0), u32(0), u32(offset), filename
]));
offset += localHeader.length;
}
const localBytes = Buffer.concat(local);
const centralBytes = Buffer.concat(central);
const end = Buffer.concat([
u32(0x06054b50), u16(0), u16(0), u16(pairs.length), u16(pairs.length), u32(centralBytes.length), u32(localBytes.length), u16(0)
]);
return Buffer.concat([localBytes, centralBytes, end]);
}

export const writeZip = zipEntries;

function xmlEscape(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}

function textRun(text, italic = false) {
if (!text) return "";
const properties = italic ? "<w:rPr><w:i/></w:rPr>" : "";
const chunks = text.split(/([\t\n])/);
const body = chunks.map((chunk) => {
if (chunk === "\t") return "<w:tab/>";
if (chunk === "\n") return "<w:br/>";
return chunk ? `<w:t xml:space="preserve">${xmlEscape(chunk)}</w:t>` : "";
}).join("");
return `<w:r>${properties}${body}</w:r>`;
}

function markdownRuns(prose) {
return parseEscapedProse(prose).map((token) => textRun(token.text, token.italic)).join("");
}

function paragraph(text, style = "Normal", alignment = null, runs = null) {
const properties = [style ? `<w:pStyle w:val="${style}"/>` : "", alignment ? `<w:jc w:val="${alignment}"/>` : ""].join("");
return `<w:p><w:pPr>${properties}</w:pPr>${runs || markdownRuns(text)}</w:p>`;
}

function chapterHeading(project, chapter) {
const title = project.chapters?.[String(chapter)];
return `Chapter ${chapter}${title && title !== `Chapter ${chapter}` ? `: ${title}` : ""}`;
}

function documentXml(project, scenes) {
const body = [paragraph(project.title, "Title", "center", textRun(project.title))];
if (project.author) body.push(paragraph(`By ${project.author}`, "Normal", "center", textRun(`By ${project.author}`)));
let activeChapter = null;
for (const scene of scenes) {
if (scene.chapter !== activeChapter) {
activeChapter = scene.chapter;
const heading = chapterHeading(project, activeChapter);
body.push(paragraph(heading, "Heading1", "center", textRun(heading)));
} else {
body.push(paragraph("* * *", "Normal", "center", textRun("* * *")));
}
for (const proseParagraph of scene.prose.split(/\n\s*\n/).filter(Boolean)) {
body.push(paragraph(proseParagraph.replace(/\n/g, "\n")));
}
}
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${body.join("")}<w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr></w:body></w:document>`;
}

function stylesXml() {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:rPr><w:sz w:val="24"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:rPr><w:b/><w:sz w:val="32"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="28"/></w:rPr></w:style></w:styles>`;
}

function contentTypesXml() {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/></Types>`;
}

function relationshipsXml() {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>`;
}

function documentRelationshipsXml() {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>`;
}

function coreXml(project) {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>${xmlEscape(project.title)}</dc:title><dc:creator>${xmlEscape(project.author || "")}</dc:creator></cp:coreProperties>`;
}

function appXml() {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"><Application>${xmlEscape(GENERATOR)}</Application></Properties>`;
}

export function buildDocx(project, scenes) {
const entries = [
["[Content_Types].xml", contentTypesXml()],
["_rels/.rels", relationshipsXml()],
["word/document.xml", documentXml(project, scenes)],
["word/styles.xml", stylesXml()],
["word/_rels/document.xml.rels", documentRelationshipsXml()],
["docProps/core.xml", coreXml(project)],
["docProps/app.xml", appXml()]
];
return zipEntries(entries);
}

export function assembleExportMarkdown(project, scenes) {
const lines = [`# ${project.title}`, ""];
let activeChapter = null;
for (const scene of scenes) {
if (scene.chapter !== activeChapter) {
activeChapter = scene.chapter;
lines.push(`## ${chapterHeading(project, activeChapter)}`, "");
} else {
lines.push("* * *", "");
}
lines.push(scene.prose, "");
}
return `${lines.join("\n").trimEnd()}\n`;
}

function recordedSourceTarget(root, project, outPath) {
if (!project.source_book?.file) return false;
return resolve(outPath).endsWith(`\\${project.source_book.file}`) || resolve(outPath).endsWith(`/${project.source_book.file}`);
}

export function exportBook(root, project, format, outPath) {
if (!/^(md|docx)$/.test(format || "")) throw new Error("export format must be md or docx");
if (!outPath) throw new Error("export requires --out <file>");
const destination = resolve(outPath);
if (existsSync(destination)) throw new Error(`refusing to overwrite export file: ${destination}`);
if (recordedSourceTarget(root, project, destination)) throw new Error("refusing to export onto the recorded source file name");
const scenes = loadScenes(root, project);
const bytes = format === "docx"
? buildDocx(project, scenes)
: Buffer.from(assembleExportMarkdown(project, scenes), "utf8");
writeFileSync(destination, bytes);
return { format, out: destination, bytes: bytes.length };
}
Loading
Loading