From b5cb72b0bf8b73ea48858906d026231d07d4bc32 Mon Sep 17 00:00:00 2001 From: MrPickering Date: Sat, 26 Sep 2026 06:23:53 -0700 Subject: [PATCH 1/3] Story-so-far setup: host adapter, work packets, state bootstrap, facts ledger Weaver's core stays deterministic: it builds work packets, a host (Claude Code interactively, or the `claude` CLI in batch) answers them, and Weaver validates and records the answers with the hashes of their inputs. - engine/host.mjs: runs the configured host with the prompt on stdin, WITHOUT ANTHROPIC_API_KEY in the child environment (so runs stay on the user's Claude subscription). Survives a host that exits early, times out a hung host, and retries once on a transient failure. Batch runs are concurrency-limited. Doctor warns when no host is available. - engine/packets.mjs: deterministic packet ids, JSON extraction, and one repair round for answers that fail validation; interactive `weaver packet` / `weaver submit`. - `weaver state bootstrap [--run]`: per-scene narrative-state records plus facts files for imported books, in reading order, resumable, and never auto-accepted (the author runs state:accept). Anti-invention rule: every fact must quote the scene verbatim, or the answer is repaired or skipped. After acceptance, imported books can release. - continuity/facts.json: a deterministic facts ledger (per character, in reading order, plus where each fact was first established), with a compact summary as packet context. Verified: npm run preflight (55/55) with a fake host covering the happy path through release, invented quotes, repair, retry, early exit, the API key never reaching the host, resume, and the interactive round trip. Co-Authored-By: Claude Opus 5.5 Session-Id: 04db4d93-9884-44ee-b28f-627e92b33731 Agent: claude-code --- README.md | 36 ++++ contracts/facts-ledger.schema.json | 44 +++++ contracts/facts.schema.json | 29 ++++ contracts/project.schema.json | 10 ++ docs/WORKFLOW.md | 16 ++ engine/doctor.mjs | 17 ++ engine/host.mjs | 144 ++++++++++++++++ engine/packets.mjs | 56 ++++++ engine/project.mjs | 9 + engine/state.mjs | 263 ++++++++++++++++++++++++++++- integrity.json | 18 +- scripts/weaver.mjs | 45 ++++- test/fixtures/fake-host.mjs | 49 ++++++ test/state-bootstrap.test.mjs | 130 ++++++++++++++ 14 files changed, 855 insertions(+), 11 deletions(-) create mode 100644 contracts/facts-ledger.schema.json create mode 100644 contracts/facts.schema.json create mode 100644 engine/host.mjs create mode 100644 engine/packets.mjs create mode 100644 test/fixtures/fake-host.mjs create mode 100644 test/state-bootstrap.test.mjs diff --git a/README.md b/README.md index 1a1b5c5..300b717 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,38 @@ npx weaver release --root ./my-book --id beta-01 If an earlier scene changes, `state:status` marks it and all later scenes stale. Re-derive them in order before accepting state again. +## Story-so-far setup + +Imported books have prose but no trusted scene-by-scene story-so-far records. +Bootstrap derives those records and a facts ledger before release. Weaver only +builds packets and checks answers; a configured command-line host does the AI +work, and the author still accepts the resulting state: + +```bash +npx weaver state bootstrap --run --root ./my-book +npx weaver state:accept --through 3-2 --root ./my-book +npx weaver facts build --root ./my-book +``` + +For an interactive Claude Code session, print one packet and submit its answer: + +```bash +npx weaver packet state --scene 1-1 --root ./my-book +npx weaver submit --file ./answer.md --root ./my-book +``` + +Answers contain a Markdown narrative-state record and a fenced JSON facts +block. Every fact quote must be a verbatim 3–25 word excerpt from that scene; +quotes from elsewhere are rejected, including after the single repair round. +`continuity/facts.json` is deterministic derived state: it keeps facts in scene +order and records the first scene that establishes each normalized value. + +The host defaults to Claude Code (`claude -p --output-format text`) and can be +set in `project.json` with `host.command`, `host.args`, `host.concurrency`, and +`host.timeout_seconds`. Weaver removes `ANTHROPIC_API_KEY` from the child +environment, so host runs use the user's Claude subscription rather than +silently billing an API key. A missing host is a doctor warning, not a block. + ## Commands | Command | Purpose | @@ -202,7 +234,11 @@ Re-derive them in order before accepting state again. | `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 bootstrap` | Build story-so-far packets, optionally run them through the host | | `state:accept` | Stamp reviewed narrative state through a scene | +| `packet state` | Print an interactive story-so-far packet | +| `submit` | Validate and record a packet answer | +| `facts build` | Rebuild the deterministic facts ledger | | `grounding ` | Assemble the context packet for a writing pass | | `release --id ` | Build an immutable Markdown/HTML reader release | diff --git a/contracts/facts-ledger.schema.json b/contracts/facts-ledger.schema.json new file mode 100644 index 0000000..0e0ce22 --- /dev/null +++ b/contracts/facts-ledger.schema.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/pickbitsai/weaver/main/contracts/facts-ledger.schema.json", + "title": "Weaver facts ledger", + "type": "object", + "additionalProperties": false, + "required": ["$schema", "generator", "manuscript_sha256", "subjects", "first_established"], + "properties": { + "$schema": { "const": "https://pickbits.ai/schemas/weaver/facts-ledger-1.json" }, + "generator": { "type": "string", "minLength": 1 }, + "manuscript_sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "subjects": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["scene_id", "kind", "value", "quote"], + "properties": { + "scene_id": { "type": "string", "pattern": "^[0-9]+-[0-9]+$" }, + "kind": { "enum": ["location", "knowledge", "possession", "relationship", "event", "time"] }, + "value": { "type": "string", "minLength": 1 }, + "quote": { "type": "string", "minLength": 1 } + } + } + } + }, + "first_established": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["normalized_value", "subject", "kind", "scene_id"], + "properties": { + "normalized_value": { "type": "string", "minLength": 1 }, + "subject": { "type": "string", "minLength": 1 }, + "kind": { "enum": ["location", "knowledge", "possession", "relationship", "event", "time"] }, + "scene_id": { "type": "string", "pattern": "^[0-9]+-[0-9]+$" } + } + } + } + } +} diff --git a/contracts/facts.schema.json b/contracts/facts.schema.json new file mode 100644 index 0000000..4a05bed --- /dev/null +++ b/contracts/facts.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/pickbitsai/weaver/main/contracts/facts.schema.json", + "title": "Weaver scene facts", + "type": "object", + "additionalProperties": false, + "required": ["$schema", "scene_id", "prose_sha256", "packet_id", "generator", "facts"], + "properties": { + "$schema": { "const": "https://pickbits.ai/schemas/weaver/facts-1.json" }, + "scene_id": { "type": "string", "pattern": "^[0-9]+-[0-9]+$" }, + "prose_sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "packet_id": { "type": "string", "pattern": "^[a-f0-9]{12}$" }, + "generator": { "type": "string", "minLength": 1 }, + "facts": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "subject", "value", "quote"], + "properties": { + "kind": { "enum": ["location", "knowledge", "possession", "relationship", "event", "time"] }, + "subject": { "type": "string", "minLength": 1 }, + "value": { "type": "string", "minLength": 1 }, + "quote": { "type": "string", "minLength": 1 } + } + } + } + } +} diff --git a/contracts/project.schema.json b/contracts/project.schema.json index 63235fc..792c97b 100644 --- a/contracts/project.schema.json +++ b/contracts/project.schema.json @@ -30,6 +30,16 @@ "reader_state_path": { "type": "string", "minLength": 1 }, "outline_scene_root": { "type": "string", "minLength": 1 }, "require_current_state_for_release": { "type": "boolean" }, + "host": { + "type": "object", + "additionalProperties": false, + "properties": { + "command": { "type": "string", "minLength": 1 }, + "args": { "type": "array", "items": { "type": "string" } }, + "concurrency": { "type": "integer", "minimum": 1, "maximum": 4 }, + "timeout_seconds": { "type": "integer", "minimum": 1 } + } + }, "chapters": { "type": "object", "additionalProperties": { "type": "string" } diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index f162d70..dad7867 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -32,6 +32,22 @@ 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. +Before release, bootstrap the story-so-far records and facts ledger. The host +answers deterministic packets, then the author reviews and accepts the records: + +```bash +weaver state bootstrap --run --root ./my-book +weaver state:accept --through 3-2 --root ./my-book +weaver facts build --root ./my-book +``` + +The interactive route is `weaver packet state --scene 1-1`, followed by +`weaver submit --file answer.md`. A malformed or deterministic +validation failure gets one host repair attempt. Scene facts must use registry +IDs (or `world`) and every quote must be a verbatim excerpt from that scene; +invalid answers are skipped and never written. Editing an earlier scene makes +its state and downstream state stale again, so bootstrap in reading order. + 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. diff --git a/engine/doctor.mjs b/engine/doctor.mjs index 80b182d..f7b793e 100644 --- a/engine/doctor.mjs +++ b/engine/doctor.mjs @@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url"; import { compareIntegrity } from "./integrity.mjs"; import { GENERATOR } from "./identity.mjs"; import { WEAVER_HOOK_COMMANDS } from "./hooks.mjs"; +import { hostAvailable } from "./host.mjs"; const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -60,6 +61,13 @@ const FINDINGS = { 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." }, + hostAvailable: { + id: "host-available", + problem: "The configured command-line AI host is not available", + title: "Command-line AI host is available", + why: "Bootstrap needs the configured command-line AI host to answer work packets.", + fix: "install the command-line AI host (Claude Code) or set \"host\" in project.json." + }, hooksInstalled: { id: "hooks-installed", problem: "Weaver's Claude Code hooks are not installed", @@ -213,6 +221,15 @@ export function runDoctor({ bookRoot, packageRoot = PACKAGE_ROOT, nodeVersion = warnings.push(...hostFinding); checks.push(checkResult(FINDINGS.host.id, FINDINGS.host.title, "warning", hostFinding)); + let project = {}; + try { project = JSON.parse(readFileSync(join(root, "project.json"), "utf8")); } catch { + // The project check is reported elsewhere; a missing project cannot make this warning blocking. + } + const available = hostAvailable(project); + const availableFinding = available ? [] : [finding(FINDINGS.hostAvailable)]; + if (!available) warnings.push(...availableFinding); + checks.push(checkResult(FINDINGS.hostAvailable.id, FINDINGS.hostAvailable.title, available ? "pass" : "warning", availableFinding)); + return { generator: GENERATOR, ok: blocking.length === 0, blocking, warnings, checks }; } diff --git a/engine/host.mjs b/engine/host.mjs new file mode 100644 index 0000000..3af0ce0 --- /dev/null +++ b/engine/host.mjs @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Mark Pickering and PICKBITS LLC. Part of PickBits Weaver. +import { spawn, spawnSync } from "node:child_process"; + +export const DEFAULT_HOST = Object.freeze({ + command: "claude", + args: ["-p", "--output-format", "text"], + concurrency: 2, + timeout_seconds: 600 +}); + +export function hostConfig(project = {}) { + const configured = project.host || {}; + return { + command: configured.command || DEFAULT_HOST.command, + args: Array.isArray(configured.args) ? [...configured.args] : [...DEFAULT_HOST.args], + concurrency: configured.concurrency || DEFAULT_HOST.concurrency, + timeout_seconds: configured.timeout_seconds || DEFAULT_HOST.timeout_seconds + }; +} + +function safeEnvironment() { + const environment = { ...process.env }; + delete environment.ANTHROPIC_API_KEY; + return environment; +} + +function executableForShell(command) { + if (process.platform !== "win32" || !/\s/u.test(command) || /^".*"$/u.test(command)) return command; + return `"${command.replaceAll('"', '\\"')}"`; +} + +function runAttempt(config, promptText) { + return new Promise((resolve) => { + const started = Date.now(); + let child; + try { + child = spawn(executableForShell(config.command), config.args, { + cwd: config.cwd, + env: safeEnvironment(), + shell: process.platform === "win32", + stdio: ["pipe", "pipe", "pipe"] + }); + } catch (error) { + resolve({ ok: false, text: "", seconds: (Date.now() - started) / 1000, error: `could not start host: ${error.message}` }); + return; + } + + let stdout = ""; + let stderr = ""; + let settled = false; + let timedOut = false; + const finish = (result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ ...result, seconds: (Date.now() - started) / 1000 }); + }; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.stdin.on("error", () => { + // A host that exits before consuming its prompt may close stdin with EPIPE. + }); + child.on("error", (error) => { + finish({ ok: false, text: stdout, error: `could not start host: ${error.message}` }); + }); + child.on("close", (code, signal) => { + if (timedOut) return; + if (code !== 0) { + const detail = stderr.trim() ? `: ${stderr.trim().replace(/\s+/gu, " ").slice(0, 240)}` : ""; + finish({ ok: false, text: stdout, error: `host exited with ${signal ? `signal ${signal}` : `code ${code}`}${detail}` }); + } else if (!stdout.trim()) { + finish({ ok: false, text: stdout, error: "host returned empty output" }); + } else { + finish({ ok: true, text: stdout }); + } + }); + const timer = setTimeout(() => { + timedOut = true; + child.kill(); + finish({ ok: false, text: stdout, error: `host timed out after ${config.timeout_seconds} seconds` }); + }, config.timeout_seconds * 1000); + try { + child.stdin.end(promptText, () => {}); + } catch (error) { + finish({ ok: false, text: stdout, error: `could not send prompt to host: ${error.message}` }); + } + }); +} + +export async function runHost(root, project, promptText) { + const config = { ...hostConfig(project), cwd: root }; + const failures = []; + const started = Date.now(); + for (let attempt = 1; attempt <= 2; attempt += 1) { + const result = await runAttempt(config, promptText); + if (result.ok) { + return { ok: true, text: result.text, seconds: (Date.now() - started) / 1000, attempts: attempt }; + } + failures.push(`attempt ${attempt}: ${result.error}`); + } + return { + ok: false, + text: "", + seconds: (Date.now() - started) / 1000, + attempts: 2, + error: `host failed after 2 attempts: ${failures.join("; ")}` + }; +} + +export async function runHostBatch(root, project, prompts, { concurrency } = {}) { + const limit = Math.min(4, Math.max(1, Number(concurrency || hostConfig(project).concurrency) || 1)); + const results = new Array(prompts.length); + let next = 0; + async function worker() { + while (true) { + const index = next; + next += 1; + if (index >= prompts.length) return; + results[index] = await runHost(root, project, prompts[index]); + } + } + await Promise.all(Array.from({ length: Math.min(limit, prompts.length) }, () => worker())); + return results; +} + +export function hostAvailable(project, timeoutMs = 15_000) { + const config = hostConfig(project); + if (!/[\\/]/u.test(config.command)) { + const locator = process.platform === "win32" ? "where.exe" : "which"; + const found = spawnSync(locator, [config.command], { encoding: "utf8", timeout: Math.min(timeoutMs, 2_000), stdio: ["ignore", "pipe", "ignore"] }); + if (found.status !== 0) return false; + } + const result = spawnSync(executableForShell(config.command), ["--version"], { + env: safeEnvironment(), + shell: process.platform === "win32", + timeout: timeoutMs, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"] + }); + return result.status === 0 && !result.error; +} diff --git a/engine/packets.mjs b/engine/packets.mjs new file mode 100644 index 0000000..5631aac --- /dev/null +++ b/engine/packets.mjs @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Mark Pickering and PICKBITS LLC. Part of PickBits Weaver. +import { createHash } from "node:crypto"; +import { runHost } from "./host.mjs"; + +export function packetId(kind, target, inputs) { + const inputHashes = Object.keys(inputs).sort().map((name) => inputs[name]).join(""); + return createHash("sha256").update(`${kind}${target}${inputHashes}`).digest("hex").slice(0, 12); +} + +export function buildPacket(kind, target, prompt, inputs, expects) { + return { id: packetId(kind, target, inputs), kind, target, prompt, inputs, expects }; +} + +export function extractJsonBlock(text) { + const matches = [...String(text || "").matchAll(/```json\s*([\s\S]*?)```/giu)]; + if (!matches.length) return { ok: false, value: null, errors: ["answer must contain a fenced ```json block"] }; + const raw = matches.at(-1)[1]; + try { + return { ok: true, value: JSON.parse(raw), errors: [], raw, start: matches.at(-1).index }; + } catch (error) { + return { ok: false, value: null, errors: [`invalid JSON in the answer block: ${error.message}`], raw, start: matches.at(-1).index }; + } +} + +function validatorResult(result) { + if (Array.isArray(result)) return { ok: result.length === 0, value: null, errors: result }; + if (result && typeof result === "object") { + const errors = Array.isArray(result.errors) ? result.errors.map(String) : []; + return { ok: result.ok === true && errors.length === 0, value: result.value, errors }; + } + return { ok: false, value: null, errors: ["validator did not return a result"] }; +} + +async function validate(answerText, validator) { + try { + return validatorResult(await validator(answerText)); + } catch (error) { + return { ok: false, value: null, errors: [error.message || String(error)] }; + } +} + +export async function validateAndRepair(root, project, packet, answerText, validator) { + const first = await validate(answerText, validator); + if (first.ok) return { ...first, answerText, attempts: 1 }; + const repairPrompt = `${packet.prompt}\n\nYour previous answer failed validation:\n${first.errors.map((error) => `- ${error}`).join("\n")}\nReturn the complete corrected answer only.`; + const hostResult = await runHost(root, project, repairPrompt); + if (!hostResult.ok) return { ok: false, value: null, errors: first.errors, answerText, attempts: 1 + hostResult.attempts, host_error: hostResult.error }; + const repaired = await validate(hostResult.text, validator); + return { + ...repaired, + answerText: hostResult.text, + attempts: 1 + hostResult.attempts, + host_error: repaired.ok ? undefined : hostResult.error + }; +} diff --git a/engine/project.mjs b/engine/project.mjs index 7bc8c73..98c3dcd 100644 --- a/engine/project.mjs +++ b/engine/project.mjs @@ -36,5 +36,14 @@ export function validateProject(project) { errors.push(`${key} must stay inside the project root`); } } + if (project?.host !== undefined) { + if (!project.host || typeof project.host !== "object" || Array.isArray(project.host)) errors.push("host must be an object"); + else { + if (project.host.command !== undefined && (typeof project.host.command !== "string" || !project.host.command.trim())) errors.push("host.command must be a non-empty string"); + if (project.host.args !== undefined && (!Array.isArray(project.host.args) || project.host.args.some((arg) => typeof arg !== "string"))) errors.push("host.args must be a string array"); + if (project.host.concurrency !== undefined && (!Number.isInteger(project.host.concurrency) || project.host.concurrency < 1 || project.host.concurrency > 4)) errors.push("host.concurrency must be an integer from 1 to 4"); + if (project.host.timeout_seconds !== undefined && (!Number.isInteger(project.host.timeout_seconds) || project.host.timeout_seconds < 1)) errors.push("host.timeout_seconds must be a positive integer"); + } + } return errors; } diff --git a/engine/state.mjs b/engine/state.mjs index 17e1815..3265ccc 100644 --- a/engine/state.mjs +++ b/engine/state.mjs @@ -1,9 +1,16 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Mark Pickering and PICKBITS LLC. Part of PickBits Weaver. import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { loadScenes, sha256 } from "./manuscript.mjs"; -import { GENERATOR } from "./identity.mjs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadCharacters } from "./characters.mjs"; +import { runHost } from "./host.mjs"; +import { buildPacket, extractJsonBlock, validateAndRepair } from "./packets.mjs"; +import { countWords, loadScenes, manuscriptHash, sha256 } from "./manuscript.mjs"; +import { GENERATOR, SCHEMA_BASE } from "./identity.mjs"; + +const FACT_KINDS = new Set(["location", "knowledge", "possession", "relationship", "event", "time"]); +const EXTRACTOR_PROMPT = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "prompts", "state-extractor.md"), "utf8").trim(); const EMPTY_UPSTREAM = sha256("weaver:narrative-state:root"); @@ -15,6 +22,14 @@ export function stateManifestPath(root, project) { return join(root, project.state_root, "manifest.json"); } +export function factsPath(root, project, sceneId) { + return join(root, project.state_root, `${sceneId}.facts.json`); +} + +export function factsLedgerPath(root) { + return join(root, "continuity", "facts.json"); +} + export function readStateManifest(root, project) { const path = stateManifestPath(root, project); if (!existsSync(path)) return { version: 1, project_id: project.project_id, scenes: {} }; @@ -154,3 +169,245 @@ export function buildGroundingPacket(root, project, sceneId) { `` ].join("\n"); } + +function normalizeQuote(value) { + return String(value) + .replace(/[“”„‟]/gu, '"') + .replace(/[‘’‚‛]/gu, "'") + .replace(/\s+/gu, " ") + .trim(); +} + +function normalizedValue(value) { + return String(value).toLocaleLowerCase().replace(/\s+/gu, " ").trim(); +} + +function factsSummary(ledger) { + const lines = []; + let words = 0; + for (const subject of Object.keys(ledger.subjects).sort()) { + const facts = ledger.subjects[subject]; + const location = facts.filter((fact) => fact.kind === "location").at(-1); + const knowledge = facts.filter((fact) => fact.kind === "knowledge").slice(-3); + const parts = []; + if (location) parts.push(`location: ${location.value}`); + if (knowledge.length) parts.push(`knowledge: ${knowledge.map((fact) => fact.value).join("; ")}`); + if (!parts.length) continue; + const line = `- ${subject}: ${parts.join("; ")}`; + const lineWords = countWords(line); + if (words + lineWords > 1500) break; + lines.push(line); + words += lineWords; + } + return lines.length ? lines.join("\n") : "_No scene facts have been recorded yet._"; +} + +export function readFactsLedger(root) { + const path = factsLedgerPath(root); + if (!existsSync(path)) return { $schema: `${SCHEMA_BASE}facts-ledger-1.json`, generator: GENERATOR, manuscript_sha256: "", subjects: {}, first_established: [] }; + return JSON.parse(readFileSync(path, "utf8")); +} + +export function buildFactsLedger(root, project) { + const scenes = loadScenes(root, project); + const subjects = {}; + const firstEstablished = []; + const firstKeys = new Set(); + for (const scene of scenes) { + const path = factsPath(root, project, scene.id); + if (!existsSync(path)) continue; + let document; + try { document = JSON.parse(readFileSync(path, "utf8")); } catch { continue; } + if (document.prose_sha256 !== scene.hash || !Array.isArray(document.facts)) continue; + for (const fact of document.facts) { + if (!fact || !FACT_KINDS.has(fact.kind) || typeof fact.subject !== "string") continue; + if (!subjects[fact.subject]) subjects[fact.subject] = []; + subjects[fact.subject].push({ scene_id: scene.id, kind: fact.kind, value: fact.value, quote: fact.quote }); + const key = `${fact.subject}\0${fact.kind}\0${normalizedValue(fact.value)}`; + if (!firstKeys.has(key)) { + firstKeys.add(key); + firstEstablished.push({ normalized_value: normalizedValue(fact.value), subject: fact.subject, kind: fact.kind, scene_id: scene.id }); + } + } + } + const ledger = { + $schema: `${SCHEMA_BASE}facts-ledger-1.json`, + generator: GENERATOR, + manuscript_sha256: manuscriptHash(scenes), + subjects, + first_established: firstEstablished + }; + mkdirSync(dirname(factsLedgerPath(root)), { recursive: true }); + writeFileSync(factsLedgerPath(root), `${JSON.stringify(ledger, null, 2)}\n`); + return ledger; +} + +function registryContext(root) { + return loadCharacters(root).characters.map((character) => ({ id: character.id, name: character.name, aliases: character.aliases })); +} + +function validateStateAnswer(scene, root, answerText) { + const extracted = extractJsonBlock(answerText); + if (!extracted.ok) return { ok: false, value: null, errors: extracted.errors }; + const record = String(answerText).slice(0, extracted.start).trim(); + const requiredHeadings = ["Newly established", "Character deltas", "Active threads", "Timeline and location", "Closing image"]; + const errors = requiredHeadings.filter((heading) => !new RegExp(`^##\\s+${heading}\\s*$`, "imu").test(record)).map((heading) => `missing required heading: ${heading}`); + const document = extracted.value; + if (!document || typeof document !== "object" || Array.isArray(document) || !Array.isArray(document.facts)) { + errors.push("JSON answer must contain a facts array"); + return { ok: false, value: null, errors }; + } + const registry = new Set(registryContext(root).map((character) => character.id)); + const normalizedScene = normalizeQuote(scene.prose); + for (const [index, fact] of document.facts.entries()) { + if (!fact || typeof fact !== "object" || Array.isArray(fact)) { errors.push(`fact ${index + 1} must be an object`); continue; } + if (!FACT_KINDS.has(fact.kind)) errors.push(`fact ${index + 1} has invalid kind`); + if (!(fact.subject === "world" || registry.has(fact.subject))) errors.push(`fact ${index + 1} has an unknown subject`); + if (typeof fact.value !== "string" || !fact.value.trim()) errors.push(`fact ${index + 1} must have a value`); + if (typeof fact.quote !== "string" || countWords(fact.quote) < 3 || countWords(fact.quote) > 25) errors.push(`fact ${index + 1} quote must contain 3 to 25 words`); + if (typeof fact.quote === "string" && !normalizedScene.includes(normalizeQuote(fact.quote))) errors.push(`fact ${index + 1} quote is not verbatim from this scene`); + } + return errors.length ? { ok: false, value: null, errors } : { ok: true, value: { record, facts: document.facts }, errors: [] }; +} + +function statePacket(root, project, scene, previous, ledger) { + const registry = registryContext(root); + const previousRecord = previous && existsSync(statePath(root, project, previous.id)) + ? readFileSync(statePath(root, project, previous.id), "utf8").trim() + : "_No previous narrative-state record._"; + const summary = factsSummary(ledger); + const prompt = [ + `# STATE packet ${scene.id}`, + "", + EXTRACTOR_PROMPT, + "", + "## Character registry", + "", + JSON.stringify(registry, null, 2), + "", + "## Upstream context", + "", + previousRecord, + "", + "## Facts ledger summary", + "", + summary, + "", + "## Scene reader text", + "", + scene.prose, + "", + "## Answer contract", + "", + "Return the narrative-state record in Markdown with these headings: Newly established, Character deltas, Active threads, Timeline and location, Closing image; add Contradictions when any.", + "Then return a fenced ```json block exactly shaped as {\"facts\":[{\"kind\":\"location|knowledge|possession|relationship|event|time\",\"subject\":\"\",\"value\":\"\",\"quote\":\"\"}]}. Every quote must be copied from the scene reader text." + ].join("\n"); + const inputs = { + scene_prose: sha256(scene.prose), + previous_record: sha256(previousRecord), + facts_ledger: sha256(summary), + character_registry: sha256(JSON.stringify(registry)), + extractor_prompt: sha256(EXTRACTOR_PROMPT) + }; + return buildPacket("STATE", scene.id, prompt, inputs, "facts-1"); +} + +function packetPath(root, packetId) { + return join(root, ".weaver", "packets", `${packetId}.json`); +} + +function isResumeCurrent(root, project, scene, statusRecord) { + if (!existsSync(statePath(root, project, scene.id)) || !existsSync(factsPath(root, project, scene.id))) return false; + if (statusRecord?.reason === "upstream-stale") return false; + try { + return JSON.parse(readFileSync(factsPath(root, project, scene.id), "utf8")).prose_sha256 === scene.hash; + } catch { + return false; + } +} + +function writeStateAnswer(root, project, scene, packet, answer) { + const directory = join(root, project.state_root); + mkdirSync(directory, { recursive: true }); + writeFileSync(statePath(root, project, scene.id), `${answer.value.record}\n`); + writeFileSync(factsPath(root, project, scene.id), `${JSON.stringify({ + $schema: `${SCHEMA_BASE}facts-1.json`, + scene_id: scene.id, + prose_sha256: scene.hash, + packet_id: packet.id, + generator: GENERATOR, + facts: answer.value.facts + }, null, 2)}\n`); +} + +export async function bootstrapState(root, project, { through = null, run = false, onProgress = (line) => process.stdout.write(`${line}\n`) } = {}) { + const scenes = loadScenes(root, project); + const throughIndex = through ? scenes.findIndex((scene) => scene.id === through) : scenes.length - 1; + if (throughIndex < 0) throw new Error(`unknown scene: ${through}`); + let ledger = buildFactsLedger(root, project); + const status = narrativeStateStatus(root, project); + for (let index = 0; index <= throughIndex; index += 1) { + const scene = scenes[index]; + const statusRecord = status.records[index]; + if (isResumeCurrent(root, project, scene, statusRecord)) { + onProgress(`Scene ${scene.id}: skipped, already current`); + continue; + } + const previous = scenes[index - 1]; + const packet = statePacket(root, project, scene, previous, ledger); + mkdirSync(join(root, ".weaver", "packets"), { recursive: true }); + writeFileSync(packetPath(root, packet.id), `${JSON.stringify(packet, null, 2)}\n`); + if (!run) { + onProgress(`Scene ${scene.id}: packet ${packet.id} written; answer it with weaver submit ${packet.id} --file `); + continue; + } + const hostResult = await runHost(root, project, packet.prompt); + if (!hostResult.ok) { + onProgress(`Scene ${scene.id}: host error: ${hostResult.error}`); + continue; + } + const answer = await validateAndRepair(root, project, packet, hostResult.text, (answerText) => validateStateAnswer(scene, root, answerText)); + if (!answer.ok) { + onProgress(`Scene ${scene.id}: answer invalid after repair: ${answer.errors[0]}`); + continue; + } + writeStateAnswer(root, project, scene, packet, answer); + ledger = buildFactsLedger(root, project); + onProgress(`Scene ${scene.id}: recorded (${answer.value.facts.length} facts)`); + } + const finalLedger = buildFactsLedger(root, project); + return { ledger: finalLedger }; +} + +export function readPacket(root, packetId) { + const path = packetPath(root, packetId); + if (!existsSync(path)) throw new Error(`unknown packet: ${packetId}`); + return JSON.parse(readFileSync(path, "utf8")); +} + +export async function submitStatePacket(root, project, packetId, answerText) { + const packet = readPacket(root, packetId); + if (packet.kind !== "STATE") throw new Error(`packet ${packetId} is not a state packet`); + const scenes = loadScenes(root, project); + const scene = scenes.find((candidate) => candidate.id === packet.target); + if (!scene) throw new Error(`unknown scene: ${packet.target}`); + if (packet.inputs?.scene_prose && packet.inputs.scene_prose !== scene.hash) { + return { ok: false, errors: ["packet is stale because the scene prose changed"], packet }; + } + const answer = await validateAndRepair(root, project, packet, answerText, (value) => validateStateAnswer(scene, root, value)); + if (!answer.ok) return { ok: false, errors: answer.errors, packet }; + writeStateAnswer(root, project, scene, packet, answer); + buildFactsLedger(root, project); + return { ok: true, scene_id: scene.id, facts: answer.value.facts.length, packet }; +} + +export function buildStatePacket(root, project, sceneId) { + const scenes = loadScenes(root, project); + const index = scenes.findIndex((scene) => scene.id === sceneId); + if (index < 0) throw new Error(`unknown scene: ${sceneId}`); + const ledger = buildFactsLedger(root, project); + const packet = statePacket(root, project, scenes[index], scenes[index - 1], ledger); + mkdirSync(join(root, ".weaver", "packets"), { recursive: true }); + writeFileSync(packetPath(root, packet.id), `${JSON.stringify(packet, null, 2)}\n`); + return packet; +} diff --git a/integrity.json b/integrity.json index 54f6d7b..0cc2216 100644 --- a/integrity.json +++ b/integrity.json @@ -5,13 +5,15 @@ "files": { "LICENSE": "3954ad2ee7d56cfda60326c71dcfd3d7edd79a44727f51b5ca5e4b7c33b8c6d6", "NOTICE": "83abe7f22edf5a06f7b4814e8b4b4b124e1b612bd81dd4c6542c0b119fab4976", - "README.md": "b9db82abc022f3dc6accda62b5763953923ee01a1b281a3bb719e30b777c4336", + "README.md": "9a753c1013a8915d0e1abc9ff2edc8fc1d70f25db410bc5c5096d7e929e7e7a6", "SECURITY.md": "5c9c72ffaa8dfdc744360ea666d2082c68bba2479c61d13810bbd595498afe2b", "contracts/characters.schema.json": "f9bd6fcc9c2ad363fd805d8d5209c9a292204b0beaf9035a4b6ceadacfd36c7b", + "contracts/facts-ledger.schema.json": "109ca6f8e761fe3aa1ac753b7ff4ec41cc59d14c98f49c8bce46d21e54ec608d", + "contracts/facts.schema.json": "61d44483b525c93b82a0e7c0710f3746483e6884c070e0fab4c08674d9dde69e", "contracts/import.schema.json": "68a9be97050a3e58bacbc984c9e1050d2b0b1db72066af96e0a5fb4019fe9e85", "contracts/integrity.schema.json": "1a8cb4b28748b0f2bfeffc4d0210f5f422d1b69cfed3a386ff60c5de0bdd8d08", "contracts/loop-manifest.schema.json": "edb1022ab2434ea24890fb0a14ad7daee7dda190968c60e9ab306250412e6258", - "contracts/project.schema.json": "b6cf551a2f688898c941ea098ee1d0b18d30bcb008a01cb2803fa43f3e5ceda9", + "contracts/project.schema.json": "49cbfdc8539bef7ab45b885c543aa5bff31558045f16382cb87b19ce6546287b", "contracts/provenance.schema.json": "bea26ddbcbfef8c046b0707e9a2f6dff8913cd9531b6267d3f374f821919d107", "contracts/release-manifest.schema.json": "65175761fc3095419ceb01b28457e6615c57c6eff4baceb2867128da73303db4", "contracts/rules.schema.json": "59dfbe8f73bceb81a294e299e90604600d5d8d3984511bb3c9167dc528893924", @@ -20,22 +22,24 @@ "docs/HANDOFF.md": "6e37433f60c54989abb1171c6989ed9c0cbab83be0d414d3d36ff6e985a9ee8c", "docs/LOOP.md": "bf3098c9afbcac6254d1682e87ad6b1e6ed798f0cb5e22a5f76a8fd3941fe8d5", "docs/ORIGIN.md": "d66e5d9a0c3e2c05a983c790dd616fc051d2666417ae0e1fd697478e5ff4cda8", - "docs/WORKFLOW.md": "efeaa318ec7aab346c8cc5f96034967e55a0c269fdac3c871142faa76da1896d", + "docs/WORKFLOW.md": "533375e53bb2a09711e97269b0fbd0538053390e836a6c97e880b47ade4b2faf", "engine/changes.mjs": "aa99efacf9a953296cd7b323be505363d442c447006381126214db50b9e667d9", "engine/characters.mjs": "1d8cc8e621e1d2b42ab81ff787be1df0648c5e852e4256dccc8dc2f7d4f0e166", - "engine/doctor.mjs": "9fd9036d14712b12cd425bb3c4b6333cef9e56200f609d2b786ebb72de36c017", + "engine/doctor.mjs": "39ee2a515be1743900b7c2360415746743c2ff434e8549362d36a159bf1da15a", "engine/export.mjs": "092ad9a4687cf24ca5daa822c844879c8314adc227542cf46ec7b871e43f9f3c", "engine/hooks.mjs": "07468f9f390360c7fb1f94092b42d9bad987c0843f2f206439b21bce1cf5c01c", + "engine/host.mjs": "ddfc819a409e2e9c61189b6c96d2b62204ff8c5686a7bedf8ca743b4a1fd027f", "engine/identity.mjs": "670f211b093aa783a0d6ed24be566239c438deb9da75e9ef20748e2c1b057364", "engine/import.mjs": "36b5f8475563d4fd0ea0ca9209d0232598e53d824733137cf46b8dbc2d3a81cc", "engine/init.mjs": "232d97f1034b12014774ca4a438b0ee6106919ca911e3c657e180409b9903d4d", "engine/integrity.mjs": "5c9ae6d22bbd28515887678e0e1ac214edd77ef2d2312fab63eda52407301e9f", "engine/manuscript.mjs": "eb03ce6d453b9b6d3a7e44b5d1a8d40eb88dcb86ae3963aaf0b4597345b14a59", - "engine/project.mjs": "9f31f86a6cee011ea2486b7773491e5f6529a0b7f28e14094f1fb436c6ab2b81", + "engine/packets.mjs": "8245677d25d9a018d8fb82a3d1c37858765ccb7084d97b250df76c323d5f148c", + "engine/project.mjs": "d89dd6087d1802c0767a2a1d676a8c1ca290610a54eaf6dd17ca31e98c95f8d8", "engine/prose.mjs": "e6c75d599abc9fee5fd494c3cd56798135ae21ee15eb9f16d5c83b7c1c877fcd", "engine/release.mjs": "ffe5cbc72e888823c9b74514629fe2dcd0b4594df55b121b59fabf9c99d99fe1", "engine/rules.mjs": "5b75d64c8a2eadb7703cf75b9c8778e9b90613856f6b29c8c0f4ad3dc37f6b93", - "engine/state.mjs": "89204ffd1d349761d8977860f976099d784cd7b14be0ab91f63b3d9a61bcccb1", + "engine/state.mjs": "de9c2a19393bd886454ad0e62c0cc91940d3fe9bbea7adedb384dd9143d41024", "engine/voice.mjs": "1830ff86c67cc6fa7e84c943b9637cd6ecdfd9eca1d6542be5dcd83d91b29cab", "loop.manifest.json": "340b33bb8b03c60ce4997b9182baa18435fc3d6716d660ea8d15ae466663abc0", "package.json": "d1b143a5559d860707d5085265a175e64ef07af09cf12411634e3522c0cb39be", @@ -45,7 +49,7 @@ "prompts/reviewers/style.md": "4479a4cb676627ca1ff4f7116f15e0cdc1f50fe2763db1388371d80570c9cdd4", "prompts/state-extractor.md": "b2d92d014892f2a1c201003b1cd16cf9d0adcc5e8611f7d350eab612b8947cc5", "scripts/integrity.mjs": "78c0bcdf7b5858de517f02eb09db1febf6d33eec787f09e7c24178f0cb263243", - "scripts/weaver.mjs": "87f6951485e616593280e584cd1ee437b1387606fac91ab96fb65542c42b0442", + "scripts/weaver.mjs": "970593ca3355f12f834aa7b89be4f39993dc4200850775c2623f3c6326eaeae5", "templates/book/.claude/settings.json": "9fb07cc94b12c3cce506c1bd2706c351dca83309f1db2bb25f5897cec1735fe2", "templates/book/AGENTS.md": "6bb83885ca4498cfd5e994f2b36cd1688db04d6174905b0a813c5fbedd7c4a10", "templates/book/README.md": "c33756ab69072410e4583f44daf21d08fa6c576816c5ed240284850d6e4b2659", diff --git a/scripts/weaver.mjs b/scripts/weaver.mjs index 1189092..4a8cc06 100644 --- a/scripts/weaver.mjs +++ b/scripts/weaver.mjs @@ -10,7 +10,7 @@ import { readProject } from "../engine/project.mjs"; import { buildRelease, runQualityChecks } from "../engine/release.mjs"; import { DEFAULT_CHAPTER_PATTERN, importBook } from "../engine/import.mjs"; import { exportBook } from "../engine/export.mjs"; -import { acceptNarrativeState, buildGroundingPacket, narrativeStateStatus } from "../engine/state.mjs"; +import { acceptNarrativeState, bootstrapState, buildFactsLedger, buildGroundingPacket, buildStatePacket, narrativeStateStatus, submitStatePacket } from "../engine/state.mjs"; import { GENERATOR } from "../engine/identity.mjs"; import { installHooks, runHook } from "../engine/hooks.mjs"; import { runRules } from "../engine/rules.mjs"; @@ -263,6 +263,41 @@ try { warnAboutDoctor(root); const { project } = projectContext(); print(narrativeStateStatus(root, project)); + } else if (command === "state" && positional() === "bootstrap") { + if (refuseIfDoctorBlocks(root, command)) process.exitCode = 1; + else { + const { project } = projectContext(); + await bootstrapState(root, project, { through: option("--through"), run: argv.includes("--run") }); + } + } else if (command === "packet" && positional() === "state") { + if (refuseIfDoctorBlocks(root, command)) process.exitCode = 1; + else { + const { project } = projectContext(); + const sceneId = option("--scene"); + if (!sceneId) throw new Error("usage: weaver packet state --scene [--root directory]"); + const packet = buildStatePacket(root, project, sceneId); + print(`Packet ${packet.id}\n\n${packet.prompt}`); + } + } else if (command === "submit") { + if (refuseIfDoctorBlocks(root, command)) process.exitCode = 1; + else { + const packetId = positional(); + const answerPath = option("--file"); + if (!packetId || !answerPath) throw new Error("usage: weaver submit --file [--root directory]"); + const { project } = projectContext(); + const result = await submitStatePacket(root, project, packetId, readFileSync(resolve(answerPath), "utf8")); + if (!result.ok) { + print(`Packet ${packetId}: answer invalid after repair: ${result.errors[0]}`); + process.exitCode = 1; + } else print(`Scene ${result.scene_id}: recorded (${result.facts} facts)`); + } + } else if (command === "facts" && positional() === "build") { + if (refuseIfDoctorBlocks(root, command)) process.exitCode = 1; + else { + const { project } = projectContext(); + const ledger = buildFactsLedger(root, project); + print(`Facts ledger built: ${Object.keys(ledger.subjects).length} subject(s).`); + } } else if (command === "state:accept") { if (refuseIfDoctorBlocks(root, command)) process.exitCode = 1; else { @@ -374,7 +409,11 @@ Usage: weaver history [--root directory] [--json] weaver hooks install [--root directory] weaver state:status [--root directory] + weaver state bootstrap [--through scene-id] [--run] [--root directory] weaver state:accept [--root directory] [--through scene-id] + weaver packet state --scene scene-id [--root directory] + weaver submit packet-id --file answer [--root directory] + weaver facts build [--root directory] weaver grounding [--root directory] [--output file] weaver release --id beta-01 [--root directory] [--force] weaver import [--root directory] [--pov title|none] [--chapter-pattern regex] [--json] @@ -398,7 +437,11 @@ Usage: weaver history [--root directory] [--json] weaver hooks install [--root directory] weaver state:status [--root directory] + weaver state bootstrap [--through scene-id] [--run] [--root directory] weaver state:accept [--root directory] [--through scene-id] + weaver packet state --scene scene-id [--root directory] + weaver submit packet-id --file answer [--root directory] + weaver facts build [--root directory] weaver grounding [--root directory] [--output file] weaver release --id beta-01 [--root directory] [--force] weaver import [--root directory] [--pov title|none] [--chapter-pattern regex] [--json] diff --git a/test/fixtures/fake-host.mjs b/test/fixtures/fake-host.mjs new file mode 100644 index 0000000..f813d46 --- /dev/null +++ b/test/fixtures/fake-host.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Mark Pickering and PICKBITS LLC. Part of PickBits Weaver. +import { existsSync, readFileSync, writeFileSync } from "node:fs"; + +const mode = process.argv[2] || "happy"; +if (mode === "early-exit") process.exit(0); + +const counterPath = ".weaver/fake-host-count"; +const prompt = readFileSync(0, "utf8"); +const sceneId = prompt.match(/# STATE packet ([0-9]+-[0-9]+)/u)?.[1] || "1-1"; +let firstModeCall = false; +if (mode === "retry" || mode === "malformed-once") { + if (!existsSync(counterPath)) { + writeFileSync(counterPath, "1\n"); + firstModeCall = true; + if (mode === "retry") process.exit(1); + } +} +if (mode === "key") writeFileSync(".weaver/fake-host-key.txt", process.env.ANTHROPIC_API_KEY ? "present\n" : "absent\n"); +if (mode === "invalid-quote") { + process.stdout.write([ + `## Narrative state after scene ${sceneId}`, + "", + "## Newly established", "", "-", "", + "## Character deltas", "", "-", "", + "## Active threads", "", "-", "", + "## Timeline and location", "", "-", "", + "## Closing image", "", "-", "", + "```json", JSON.stringify({ facts: [{ kind: "event", subject: "world", value: "an invented event", quote: "This quote is absent" }] }), "```", "" + ].join("\n")); + process.exit(0); +} +const sceneText = prompt.split("## Scene reader text\n")[1]?.split("\n\n## Answer contract")[0]?.trim() || "The scene establishes a careful fact for the reader."; +const quote = sceneText.split(/\s+/u).slice(0, 5).join(" "); +if (mode === "malformed-once" && firstModeCall) { + process.stdout.write("```json\nnot json\n```\n"); + process.exit(0); +} +process.stdout.write([ + `## Narrative state after scene ${sceneId}`, + "", + "## Newly established", "", "- The scene establishes one reader fact.", "", + "## Character deltas", "", "- No character delta.", "", + "## Active threads", "", "- The event remains active.", "", + "## Timeline and location", "", "- The scene continues in sequence.", "", + "## Closing image", "", "- The reader is left with a changed pressure.", "", + "```json", JSON.stringify({ facts: [{ kind: "event", subject: "world", value: "the scene establishes one reader fact", quote }] }), "```", "" +].join("\n")); diff --git a/test/state-bootstrap.test.mjs b/test/state-bootstrap.test.mjs new file mode 100644 index 0000000..86d713a --- /dev/null +++ b/test/state-bootstrap.test.mjs @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Mark Pickering and PICKBITS LLC. Part of PickBits Weaver. +import assert from "node:assert/strict"; +import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { buildFactsLedger } from "../engine/state.mjs"; +import { importBook } from "../engine/import.mjs"; +import { initializeProject } from "../engine/init.mjs"; +import { readProject } from "../engine/project.mjs"; +import { seedCharacters } from "../engine/characters.mjs"; +import { runDoctor } from "../engine/doctor.mjs"; + +const ROOT = resolve("."); +const CLI = join(ROOT, "scripts", "weaver.mjs"); +const FAKE_HOST = join(ROOT, "test", "fixtures", "fake-host.mjs"); + +function runWeaver(args, root, extraEnv = {}) { + return spawnSync(process.execPath, [CLI, ...args, "--root", root], { + cwd: ROOT, + env: { ...process.env, ...extraEnv }, + encoding: "utf8" + }); +} + +function makeBook(mode = "happy") { + const root = mkdtempSync(join(tmpdir(), "weaver-state-")); + initializeProject(root, { projectId: "state-book", title: "State Book", empty: true }); + const source = join(root, "finished.md"); + writeFileSync(source, "# Brask\n\nBrask crosses the bridge before dawn.\n\n* * *\n\n# Lady Veyra\n\nLady Veyra keeps the sealed letter close.\n\n* * *\n\n# Wren\n\nWren waits beside the northern gate.\n"); + importBook(root, readProject(root), source); + seedCharacters(root, ["Brask", "Lady Veyra", "Wren"]); + const project = readProject(root); + project.host = { command: process.execPath, args: [FAKE_HOST, mode], concurrency: 2, timeout_seconds: 10 }; + writeFileSync(join(root, "project.json"), `${JSON.stringify(project, null, 2)}\n`); + return root; +} + +function cleanup(root) { + rmSync(root, { recursive: true, force: true }); +} + +test("bootstrap run records imported story-so-far state and release succeeds", () => { + const root = makeBook(); + try { + const result = runWeaver(["state", "bootstrap", "--run"], root); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stdout, /Scene 1-1: recorded/); + assert.equal(existsSync(join(root, "narrative-state", "1-1.facts.json")), true); + assert.equal(existsSync(join(root, "continuity", "facts.json")), true); + assert.equal(runWeaver(["state:accept", "--through", "3-1"], root).status, 0); + const release = runWeaver(["release", "--id", "state-release"], root); + assert.equal(release.status, 0, `${release.stdout}\n${release.stderr}`); + const manifest = JSON.parse(readFileSync(join(root, "releases", "state-release", "release-manifest.json"), "utf8")); + assert.deepEqual(Object.keys(manifest).sort(), ["artifacts", "created_at", "gates", "lifecycle", "narrative_state", "project_id", "release_id", "schema_version", "source"]); + } finally { cleanup(root); } +}); + +test("state answers get one repair round, retry, early-exit, and API-key isolation", () => { + for (const mode of ["malformed-once", "retry", "key"]) { + const root = makeBook(mode); + try { + const result = runWeaver(["state", "bootstrap", "--run", "--through", "1-1"], root, { ANTHROPIC_API_KEY: "sentinel" }); + assert.equal(result.status, 0, `${mode}: ${result.stdout}\n${result.stderr}`); + assert.equal(existsSync(join(root, "narrative-state", "1-1.md")), true, mode); + if (mode === "key") assert.equal(readFileSync(join(root, ".weaver", "fake-host-key.txt"), "utf8").trim(), "absent"); + } finally { cleanup(root); } + } + const root = makeBook("early-exit"); + try { + const result = runWeaver(["state", "bootstrap", "--run", "--through", "1-1"], root); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stdout, /host failed after 2 attempts/); + assert.equal(existsSync(join(root, "narrative-state", "1-1.md")), false); + } finally { cleanup(root); } +}); + +test("invalid quotes are rejected after repair, resume is deterministic, and the ledger picks the first scene", () => { + const invalid = makeBook("invalid-quote"); + try { + const result = runWeaver(["state", "bootstrap", "--run", "--through", "1-1"], invalid); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stdout, /answer invalid after repair: .*quote/); + assert.equal(existsSync(join(invalid, "narrative-state", "1-1.md")), false); + } finally { cleanup(invalid); } + + const root = makeBook(); + try { + assert.equal(runWeaver(["state", "bootstrap", "--run"], root).status, 0); + const second = runWeaver(["state", "bootstrap", "--run"], root); + assert.equal(second.status, 0, `${second.stdout}\n${second.stderr}`); + assert.match(second.stdout, /Scene 1-1: skipped, already current/); + const before = readFileSync(join(root, "continuity", "facts.json"), "utf8"); + buildFactsLedger(root, readProject(root)); + assert.equal(readFileSync(join(root, "continuity", "facts.json"), "utf8"), before); + const ledger = JSON.parse(before); + assert.equal(ledger.first_established[0].scene_id, "1-1"); + const scene = join(root, "manuscript", "chapter-01", "scene-01", "scene.md"); + writeFileSync(scene, readFileSync(scene, "utf8").replace("before dawn", "after midnight")); + const status = runWeaver(["state:status"], root); + assert.match(status.stdout, /"first_stale": "1-1"/); + assert.match(status.stdout, /"stale": 3/); + } finally { cleanup(root); } +}); + +test("interactive packet submit round-trips and a missing host is only a warning", () => { + const root = makeBook(); + try { + const packet = runWeaver(["packet", "state", "--scene", "1-1"], root); + assert.equal(packet.status, 0, `${packet.stdout}\n${packet.stderr}`); + const id = packet.stdout.match(/Packet ([a-f0-9]{12})/u)?.[1]; + assert.ok(id); + const answer = join(root, "answer.md"); + writeFileSync(answer, [ + "## Narrative state after scene 1-1", "", "## Newly established", "", "- A fact.", "", + "## Character deltas", "", "-", "", "## Active threads", "", "-", "", + "## Timeline and location", "", "-", "", "## Closing image", "", "-", "", + "```json", JSON.stringify({ facts: [{ kind: "event", subject: "world", value: "a fact", quote: "Brask crosses the bridge" }] }), "```", "" + ].join("\n")); + const submitted = runWeaver(["submit", id, "--file", answer], root); + assert.equal(submitted.status, 0, `${submitted.stdout}\n${submitted.stderr}`); + assert.equal(existsSync(join(root, "narrative-state", "1-1.facts.json")), true); + const project = readProject(root); + project.host = { command: "weaver-command-that-does-not-exist" }; + writeFileSync(join(root, "project.json"), `${JSON.stringify(project, null, 2)}\n`); + assert.equal(runDoctor({ bookRoot: root }).checks.find((check) => check.id === "host-available").status, "warning"); + } finally { cleanup(root); } +}); From 9e9bc1f37b880024a3f5fb21da0fdc18814b25a7 Mon Sep 17 00:00:00 2001 From: MrPickering Date: Sat, 26 Sep 2026 07:07:04 -0700 Subject: [PATCH 2/3] Reviews with triage, a unified findings queue, and an author rulings ledger On a simulated 12-chapter book the three reviewer lenses caught what no script can (a character placed where canon says he never goes), but at about 10 findings per lens per chapter. The product is triage and memory. - `weaver review --chapter N [--run]`: continuity/style/critic packets. Continuity gets compact context (facts ledger plus the previous chapter's state records), not every earlier chapter. Paragraph-numbered text; each finding must quote its paragraph verbatim or it is dropped (and counted). Records carry prose and facts hashes and go stale when either changes. - `weaver findings`: one queue across style rules, voice and judgment, with stable ids. Judgment is triaged: definite findings, plus at most 2 items from a failing critic, capped at 6 per chapter; everything else becomes counted notes (--all). - `weaver rule --decision fix|allow|intended`: decide once. An allow/intended ruling suppresses the finding while the passage is unchanged and lapses (with a note) when it changes. Allowing a blocking style finding is audited in check/approve output. `--to-fix` lists what the author wants repaired. Each ruling is its own commit touching only continuity/rulings.json. Verified: npm run preflight (60/60) with the fake host: review records, dropped invented quotes, repair, staleness, triage caps, stable ids, ruling suppression, lapse, audited blocking exceptions, fix resolution, per-ruling commits. Co-Authored-By: Claude Opus 5.5 Session-Id: 04db4d93-9884-44ee-b28f-627e92b33731 Agent: claude-code --- README.md | 31 ++++++ contracts/review.schema.json | 35 +++++++ contracts/rulings.schema.json | 32 ++++++ docs/WORKFLOW.md | 7 ++ engine/changes.mjs | 5 +- engine/findings.mjs | 73 +++++++++++++ engine/release.mjs | 2 +- engine/review.mjs | 189 ++++++++++++++++++++++++++++++++++ engine/rules.mjs | 8 +- engine/rulings.mjs | 92 +++++++++++++++++ engine/voice.mjs | 4 +- integrity.json | 19 ++-- scripts/weaver.mjs | 73 ++++++++++++- test/consumer-install.mjs | 8 ++ test/fixtures/fake-host.mjs | 26 +++++ test/review.test.mjs | 97 +++++++++++++++++ test/rulings.test.mjs | 70 +++++++++++++ test/voice.test.mjs | 8 ++ 18 files changed, 760 insertions(+), 19 deletions(-) create mode 100644 contracts/review.schema.json create mode 100644 contracts/rulings.schema.json create mode 100644 engine/findings.mjs create mode 100644 engine/review.mjs create mode 100644 engine/rulings.mjs create mode 100644 test/review.test.mjs create mode 100644 test/rulings.test.mjs diff --git a/README.md b/README.md index 300b717..8cdcc43 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,33 @@ manuscript hash and Git commit; working-tree edits do not make them stale. Voice findings are warnings for the author to judge; Weaver never decides that a voice is right. +## Reviews and triage + +Run the continuity, style, and critic lenses for one chapter with +`weaver review --chapter 1 --run --root ./my-book`. The configured host runs the +three lenses concurrently. Omit `--run` to write interactive packets, then use +`weaver submit --file `. Reviews cite a scene, paragraph, +and verbatim reader-text quote; invalid citations are dropped. A review becomes +out of date when its chapter prose changes, or when the continuity facts context +changes. + +`weaver findings --root ./my-book` gives the author one queue across rules, +voice, and reviews. Definite review problems enter the queue. A failing critic +adds at most its first two craft or definite findings. Other review findings +remain notes, visible with `weaver findings --all`. At most six review findings +are queued per chapter, with continuity before style before critic. The queue +also reports out-of-date reviews. Every finding has a stable ID. + +## Rulings + +Decide once with `weaver rule --decision fix|allow|intended +--root ./my-book`. `fix` keeps the item in `weaver findings --to-fix`; `allow` +and `intended` suppress it while its paragraph stays unchanged. Editing the +paragraph lapses the ruling and shows the finding again with a lapse note. +`weaver rulings` shows active, lapsed, and resolved decisions. Each decision is +its own Git commit containing only `continuity/rulings.json`. Allowed blocking +style findings are listed by ID in `check` and `approve` output. + ## Claude Code hooks Run `weaver hooks install --root ./my-book` to merge Weaver's PreToolUse and @@ -227,6 +254,10 @@ silently billing an API key. A missing host is a doctor warning, not a block. | `characters seed` | Add imported POV names to the character registry | | `voices build` | Build deterministic character voice profiles | | `voices check` | Show voice warnings (`--scene`, `--pending`, `--json` supported) | +| `review --chapter N` | Run or prepare chapter review lenses (`--lenses`, `--run` supported) | +| `findings` | Show the triaged queue (`--chapter`, `--all`, `--to-fix`, `--json` supported) | +| `rule ` | Save an author decision (`--decision`, `--note` supported) | +| `rulings` | List active, lapsed, and resolved decisions (`--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 | diff --git a/contracts/review.schema.json b/contracts/review.schema.json new file mode 100644 index 0000000..40469b4 --- /dev/null +++ b/contracts/review.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/pickbitsai/weaver/main/contracts/review.schema.json", + "title": "Weaver chapter review", + "type": "object", + "additionalProperties": false, + "required": ["$schema", "chapter", "lens", "verdict", "findings", "dropped_findings", "chapter_prose_sha256", "facts_sha256", "packet_id", "generator"], + "properties": { + "$schema": { "const": "https://pickbits.ai/schemas/weaver/review-1.json" }, + "chapter": { "type": "integer", "minimum": 1 }, + "lens": { "enum": ["continuity", "style", "critic"] }, + "verdict": { "enum": ["CLEAR", "FLAGS", "CONSISTENT", "DRIFT", "PASS", "FAIL"] }, + "findings": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["severity", "scene_id", "paragraph", "quote", "issue", "repair"], + "properties": { + "severity": { "enum": ["definite", "ambiguity", "minor", "craft"] }, + "scene_id": { "type": "string", "pattern": "^[0-9]+-[0-9]+$" }, + "paragraph": { "type": "integer", "minimum": 1 }, + "quote": { "type": "string", "minLength": 1 }, + "issue": { "type": "string", "minLength": 1 }, + "repair": { "type": "string", "minLength": 1 } + } + } + }, + "dropped_findings": { "type": "integer", "minimum": 0 }, + "chapter_prose_sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "facts_sha256": { "type": ["string", "null"], "pattern": "^[a-f0-9]{64}$" }, + "packet_id": { "type": "string", "pattern": "^[a-f0-9]{12}$" }, + "generator": { "type": "string", "minLength": 1 } + } +} diff --git a/contracts/rulings.schema.json b/contracts/rulings.schema.json new file mode 100644 index 0000000..9a81c90 --- /dev/null +++ b/contracts/rulings.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/pickbitsai/weaver/main/contracts/rulings.schema.json", + "title": "Weaver author rulings", + "type": "object", + "additionalProperties": false, + "required": ["$schema", "generator", "entries"], + "properties": { + "$schema": { "const": "https://pickbits.ai/schemas/weaver/rulings-1.json" }, + "generator": { "type": "string", "minLength": 1 }, + "entries": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["finding_id", "kind", "source", "scene_id", "paragraph", "passage_sha256", "decision", "note", "ruled_at", "generator"], + "properties": { + "finding_id": { "type": "string", "pattern": "^[a-f0-9]{10}$" }, + "kind": { "enum": ["rule", "voice", "judgment"] }, + "source": { "type": "string", "minLength": 1 }, + "scene_id": { "type": "string", "pattern": "^[0-9]+-[0-9]+$" }, + "paragraph": { "type": "integer", "minimum": 1 }, + "passage_sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "decision": { "enum": ["fix", "allow", "intended"] }, + "note": { "type": "string" }, + "ruled_at": { "type": "string", "format": "date-time" }, + "generator": { "type": "string", "minLength": 1 } + } + } + } + } +} diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index dad7867..3fd1472 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -10,6 +10,13 @@ weaver doctor --root ./my-book ## Repair path +For each chapter, run `weaver review --chapter N --run`, then inspect +`weaver findings --chapter N`. Use `--all` to see lower-priority review notes. +Decide an item with `weaver rule --decision fix|allow|intended`. +Work from `weaver findings --to-fix --chapter N` for requested repairs, rerun +the reviews after prose changes, then approve the chapter. An allowed blocking +style finding appears in the approval and check audit output by ID. + 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 diff --git a/engine/changes.mjs b/engine/changes.mjs index 1173e1d..99b7f3a 100644 --- a/engine/changes.mjs +++ b/engine/changes.mjs @@ -111,6 +111,7 @@ function chapterFindings(root, project, pending) { const result = runRules(root, project, { sceneIds: available }); const voice = runVoiceCheck(root, project, { sceneIds: available }); const byScene = new Map(); + byScene.allowed_blocking = result.allowed_blocking; for (const finding of result.findings) { if (!byScene.has(finding.scene_id)) byScene.set(finding.scene_id, []); byScene.get(finding.scene_id).push(finding); @@ -184,7 +185,7 @@ function gitStatusAll(root) { return parseStatus(result.stdout); } -function ensureIdentity(root, project) { +export 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()) { @@ -228,7 +229,7 @@ export function approveChapter(root, project, chapter = null, note = "") { 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"); const voices = buildVoiceProfiles(root, project); - return { chapter: selectedChapter, id: sha.stdout.trim(), paths, message: subject, voice_profiles: voices.profiles.length }; + return { chapter: selectedChapter, id: sha.stdout.trim(), paths, message: subject, voice_profiles: voices.profiles.length, allowed_blocking: selectedFindings.allowed_blocking || [] }; } // An imported book is the author's existing work: it is the starting point, not a pending change. diff --git a/engine/findings.mjs b/engine/findings.mjs new file mode 100644 index 0000000..5ae9fc4 --- /dev/null +++ b/engine/findings.mjs @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Mark Pickering and PICKBITS LLC. Part of PickBits Weaver. +import { existsSync, readFileSync } from "node:fs"; +import { loadScenes } from "./manuscript.mjs"; +import { runRules } from "./rules.mjs"; +import { runVoiceCheck } from "./voice.mjs"; +import { LENSES, reviewInputs, reviewIsCurrent, reviewRecordPath } from "./review.mjs"; +import { applyRulings } from "./rulings.mjs"; + +export const MAX_QUEUED_JUDGMENTS = 6; +export const MAX_CRITIC_FAIL_CRAFT = 2; +const PRIORITY = { continuity: 0, style: 1, critic: 2 }; +const SEVERITY_PRIORITY = { definite: 0, craft: 1, ambiguity: 2, minor: 3 }; + +function reviewJudgments(root, project, chapter, scenes) { + const inputs = reviewInputs(root, project, chapter, scenes); + const candidates = []; + const stale = []; + for (const lens of LENSES) { + const path = reviewRecordPath(root, chapter, lens); + if (!existsSync(path)) continue; + const record = JSON.parse(readFileSync(path, "utf8")); + if (!reviewIsCurrent(record, inputs)) { stale.push(lens); continue; } + let criticSelected = 0; + for (const finding of record.findings || []) { + const eligible = finding.severity === "definite" || + (lens === "critic" && record.verdict === "FAIL" && ["craft", "definite"].includes(finding.severity) && criticSelected < MAX_CRITIC_FAIL_CRAFT); + if (lens === "critic" && eligible && ["craft", "definite"].includes(finding.severity)) criticSelected += 1; + candidates.push({ ...finding, chapter, lens, verdict: record.verdict, queued: eligible, source: lens }); + } + } + const ordered = candidates.sort((a, b) => PRIORITY[a.lens] - PRIORITY[b.lens] || + SEVERITY_PRIORITY[a.severity] - SEVERITY_PRIORITY[b.severity] || a.paragraph - b.paragraph); + let queued = 0; + for (const finding of ordered) { + if (finding.queued) finding.queued = ++queued <= MAX_QUEUED_JUDGMENTS; + } + const filtered = applyRulings(root, project, ordered, { kind: "judgment", sourceOf: (finding) => finding.lens, quoteOf: (finding) => finding.quote, scenes }); + for (const finding of filtered.findings) if (finding.ruling === "author wants this fixed") finding.queued = true; + let visibleQueued = 0; + for (const finding of filtered.findings) if (finding.queued) finding.queued = ++visibleQueued <= MAX_QUEUED_JUDGMENTS; + return { judgments: filtered.findings, stale }; +} + +export function collectFindings(root, project, { chapter = null, all = false, toFix = false } = {}) { + const scenes = loadScenes(root, project); + const selectedChapters = [...new Set(scenes.map((scene) => scene.chapter))].filter((value) => chapter === null || value === Number(chapter)); + if (chapter !== null && !selectedChapters.length) throw new Error(`unknown chapter: ${chapter}`); + const ruleFindings = runRules(root, project).findings; + const voiceFindings = runVoiceCheck(root, project).findings; + const chapters = selectedChapters.map((number) => { + const rules = ruleFindings.filter((item) => Number(item.scene_id.split("-")[0]) === number) + .map((item) => ({ ...item, chapter: number, queued: true, issue: item.message, repair: "Revise the passage to satisfy the rule." })); + const voices = voiceFindings.filter((item) => Number(item.scene_id.split("-")[0]) === number) + .map((item) => ({ ...item, chapter: number, queued: true, issue: item.reason, repair: "Revise the line to match the character's established voice." })); + const { judgments, stale } = reviewJudgments(root, project, number, scenes); + const notes = judgments.filter((item) => !item.queued); + let findings = [...rules, ...voices, ...judgments.filter((item) => item.queued)]; + if (toFix) findings = findings.filter((item) => item.ruling === "author wants this fixed"); + return { + chapter: number, findings, + notes: all && !toFix ? notes : [], note_count: notes.length, + stale_reviews: stale, + counts: { + definite_contradictions: findings.filter((item) => item.kind === "judgment" && item.lens === "continuity").length, + style_breaks: findings.filter((item) => item.kind === "judgment" && item.lens === "style").length, + critic_issues: findings.filter((item) => item.kind === "judgment" && item.lens === "critic").length, + queued_judgments: findings.filter((item) => item.kind === "judgment").length + } + }; + }); + return { chapters, findings: chapters.flatMap((item) => item.findings), notes: chapters.flatMap((item) => item.notes) }; +} diff --git a/engine/release.mjs b/engine/release.mjs index 9bf82c3..4537378 100644 --- a/engine/release.mjs +++ b/engine/release.mjs @@ -39,7 +39,7 @@ export function runQualityChecks(root, project) { issues.push(`narrative state is stale from scene ${state.first_stale}`); } if (rules.blocking) issues.push(`style rules: ${rules.blocking} blocking finding(s)`); - return { ok: issues.length === 0, issues, scenes, criticalPath, duplicates, unapprovedDuplicates, state, rules, voices }; + return { ok: issues.length === 0, issues, scenes, criticalPath, duplicates, unapprovedDuplicates, state, rules, voices, allowed_blocking: rules.allowed_blocking }; } function assembleHtml(project, scenes, releaseId) { diff --git a/engine/review.mjs b/engine/review.mjs new file mode 100644 index 0000000..80f5939 --- /dev/null +++ b/engine/review.mjs @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Mark Pickering and PICKBITS LLC. Part of PickBits Weaver. +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadCharacters } from "./characters.mjs"; +import { runHostBatch } from "./host.mjs"; +import { GENERATOR, SCHEMA_BASE } from "./identity.mjs"; +import { countWords, loadScenes, sha256 } from "./manuscript.mjs"; +import { buildPacket, extractJsonBlock, validateAndRepair } from "./packets.mjs"; +import { readerText } from "./prose.mjs"; +import { factsLedgerPath, statePath } from "./state.mjs"; + +const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +export const LENSES = ["continuity", "style", "critic"]; +export const VERDICTS = { continuity: ["CLEAR", "FLAGS"], style: ["CONSISTENT", "DRIFT"], critic: ["PASS", "FAIL"] }; +const SEVERITIES = new Set(["definite", "ambiguity", "minor", "craft"]); + +export function reviewParagraphs(scene) { + return scene.prose.split(/\n\s*\n/u).filter((value) => value.trim()).map((value, index) => ({ + scene_id: scene.id, paragraph: index + 1, text: readerText(value).replace(/\s+/gu, " ").trim() + })); +} + +export function chapterProseHash(scenes, chapter) { + return sha256(scenes.filter((scene) => scene.chapter === chapter).map((scene) => `${scene.id}:${scene.hash}`).join("\n")); +} + +function factsContext(root, chapter) { + const path = factsLedgerPath(root); + if (!existsSync(path)) return { text: "_No facts ledger._", hash: null }; + const ledger = JSON.parse(readFileSync(path, "utf8")); + const lines = []; + for (const subject of Object.keys(ledger.subjects || {}).sort()) { + const prior = ledger.subjects[subject].filter((fact) => Number(String(fact.scene_id).split("-")[0]) < chapter); + const location = prior.filter((fact) => fact.kind === "location").at(-1); + const knowledge = prior.filter((fact) => fact.kind === "knowledge").slice(-5); + if (location) lines.push(`${subject} location: ${location.value} [${location.scene_id}]`); + for (const fact of knowledge) lines.push(`${subject} knowledge: ${fact.value} [${fact.scene_id}]`); + } + const text = lines.join("\n") || "_No earlier facts._"; + return { text, hash: sha256(text) }; +} + +function worldBible(root) { + const lines = []; + for (const name of ["characters.md", "locations.md", "terminology.md"]) { + const path = join(root, "world-bible", name); + const starter = join(PACKAGE_ROOT, "templates", "book", "world-bible", name); + if (existsSync(path) && (!existsSync(starter) || readFileSync(path, "utf8") !== readFileSync(starter, "utf8"))) { + lines.push(`### ${name}\n${readFileSync(path, "utf8").trim()}`); + } + } + return lines.join("\n\n") || "_No project world-bible notes._"; +} + +export function reviewInputs(root, project, chapter, scenes = loadScenes(root, project)) { + const chapterScenes = scenes.filter((scene) => scene.chapter === chapter); + if (!chapterScenes.length) throw new Error(`unknown chapter: ${chapter}`); + const facts = factsContext(root, chapter); + const previous = scenes.filter((scene) => scene.chapter === chapter - 1).map((scene) => { + const path = statePath(root, project, scene.id); + return `### ${scene.id}\n${existsSync(path) ? readFileSync(path, "utf8").trim() : "_No state record._"}`; + }).join("\n\n") || "_No previous chapter state records._"; + return { chapterScenes, proseHash: chapterProseHash(scenes, chapter), facts, previous }; +} + +export function reviewRecordPath(root, chapter, lens) { + return join(root, "review", `chapter-${String(chapter).padStart(2, "0")}-${lens}.json`); +} + +export function reviewIsCurrent(record, inputs) { + return record?.chapter_prose_sha256 === inputs.proseHash && + (record.lens !== "continuity" || record.facts_sha256 === inputs.facts.hash); +} + +export function buildReviewPacket(root, project, chapter, lens, inputs = reviewInputs(root, project, chapter)) { + if (!LENSES.includes(lens)) throw new Error(`unknown review lens: ${lens}`); + const lensPrompt = readFileSync(join(PACKAGE_ROOT, "prompts", "reviewers", `${lens}.md`), "utf8").trim(); + const registry = JSON.stringify(loadCharacters(root).characters, null, 2); + const bible = worldBible(root); + const paragraphs = inputs.chapterScenes.flatMap(reviewParagraphs); + const context = lens === "continuity" ? `## Earlier facts\n${inputs.facts.text}\n\n## Previous chapter narrative state\n${inputs.previous}\n\n` : ""; + const prompt = `# REVIEW packet chapter ${chapter} lens ${lens}\n\n${lensPrompt}\n\n## Character registry\n${registry}\n\n## World bible\n${bible}\n\n${context}## Chapter reader text\n${paragraphs.map((item) => `[${item.scene_id} p${item.paragraph}] ${item.text}`).join("\n\n")}\n\n## Answer contract\nStart with ${lens.toUpperCase()}: ${VERDICTS[lens].join(" or ")}. Then return a fenced \`\`\`json block: {"verdict":"","findings":[{"severity":"definite|ambiguity|minor|craft","scene_id":"","paragraph":1,"quote":"","issue":"","repair":""}]}. Quote only this chapter's reader text.`; + return buildPacket("REVIEW", `${chapter}:${lens}`, prompt, { + chapter_prose: inputs.proseHash, facts: lens === "continuity" ? inputs.facts.hash || "" : "", + previous_state: lens === "continuity" ? sha256(inputs.previous) : "", + registry: sha256(registry), world_bible: sha256(bible), lens_prompt: sha256(lensPrompt) + }, "review-1"); +} + +function normalizeQuote(value) { + return String(value).replace(/[\u201c\u201d]/gu, '"').replace(/[\u2018\u2019]/gu, "'").replace(/\s+/gu, " ").trim(); +} + +function parsedAnswer(text, lens) { + const parsed = extractJsonBlock(text); + if (!parsed.ok) return { errors: parsed.errors, document: null }; + const document = parsed.value; + const errors = []; + if (!document || typeof document !== "object" || Array.isArray(document)) errors.push("answer must be a JSON object"); + else { + if (!VERDICTS[lens].includes(document.verdict)) errors.push("invalid lens verdict"); + if (!Array.isArray(document.findings)) errors.push("findings must be an array"); + const line = String(text).slice(0, parsed.start).trim().split(/\n/u)[0]?.trim(); + if (line !== `${lens.toUpperCase()}: ${document.verdict}`) errors.push("lens verdict line does not match JSON verdict"); + } + return { errors, document }; +} + +function validatedFindings(document, paragraphs) { + const byKey = new Map(paragraphs.map((item) => [`${item.scene_id}:${item.paragraph}`, item.text])); + const findings = []; + let dropped = 0; + const errors = []; + for (const [index, finding] of document.findings.entries()) { + const paragraph = byKey.get(`${finding?.scene_id}:${finding?.paragraph}`); + const valid = finding && SEVERITIES.has(finding.severity) && Number.isInteger(finding.paragraph) && paragraph && + typeof finding.quote === "string" && countWords(finding.quote) >= 3 && countWords(finding.quote) <= 25 && + normalizeQuote(paragraph).includes(normalizeQuote(finding.quote)) && + typeof finding.issue === "string" && finding.issue.trim() && typeof finding.repair === "string" && finding.repair.trim(); + if (valid) findings.push(finding); + else { dropped += 1; errors.push(`finding ${index + 1} has an invalid location, quote, severity, issue, or repair`); } + } + return { findings, dropped, errors }; +} + +export function validateReviewAnswer(text, lens, paragraphs) { + const parsed = parsedAnswer(text, lens); + if (parsed.errors.length) return { ok: false, value: null, errors: parsed.errors }; + const checked = validatedFindings(parsed.document, paragraphs); + return { ok: checked.errors.length === 0, value: { verdict: parsed.document.verdict, findings: checked.findings, dropped_findings: checked.dropped }, errors: checked.errors }; +} + +async function recordAnswer(root, project, chapter, lens, packet, inputs, text) { + const paragraphs = inputs.chapterScenes.flatMap(reviewParagraphs); + const validator = (answer) => validateReviewAnswer(answer, lens, paragraphs); + const result = await validateAndRepair(root, project, packet, text, validator); + let value = result.value; + if (!result.ok) { + const parsed = parsedAnswer(result.answerText, lens); + if (parsed.errors.length) return { ok: false, errors: parsed.errors }; + const checked = validatedFindings(parsed.document, paragraphs); + value = { verdict: parsed.document.verdict, findings: checked.findings, dropped_findings: checked.dropped }; + } + const record = { + $schema: `${SCHEMA_BASE}review-1.json`, chapter, lens, verdict: value.verdict, + findings: value.findings, dropped_findings: value.dropped_findings, + chapter_prose_sha256: inputs.proseHash, facts_sha256: lens === "continuity" ? inputs.facts.hash : null, + packet_id: packet.id, generator: GENERATOR + }; + mkdirSync(join(root, "review"), { recursive: true }); + writeFileSync(reviewRecordPath(root, chapter, lens), `${JSON.stringify(record, null, 2)}\n`); + return { ok: true, record }; +} + +export async function runReview(root, project, { chapter, lenses = LENSES, run = false, onProgress = (line) => process.stdout.write(`${line}\n`) } = {}) { + const selected = [...new Set(lenses)]; + if (!selected.length || selected.some((lens) => !LENSES.includes(lens))) throw new Error(`lenses must be ${LENSES.join(",")}`); + const inputs = reviewInputs(root, project, Number(chapter)); + const pending = []; + for (const lens of selected) { + const path = reviewRecordPath(root, Number(chapter), lens); + const existing = existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null; + if (reviewIsCurrent(existing, inputs)) { onProgress(`Chapter ${chapter} ${lens}: skipped, already current`); continue; } + const packet = buildReviewPacket(root, project, Number(chapter), lens, inputs); + mkdirSync(join(root, ".weaver", "packets"), { recursive: true }); + writeFileSync(join(root, ".weaver", "packets", `${packet.id}.json`), `${JSON.stringify(packet, null, 2)}\n`); + pending.push({ lens, packet }); + if (!run) onProgress(`Chapter ${chapter} ${lens}: packet ${packet.id} written; answer it with weaver submit ${packet.id} --file `); + } + if (!run || !pending.length) return; + const responses = await runHostBatch(root, project, pending.map(({ packet }) => packet.prompt)); + for (const [index, { lens, packet }] of pending.entries()) { + const response = responses[index]; + if (!response.ok) { onProgress(`Chapter ${chapter} ${lens}: host error: ${response.error}`); continue; } + const result = await recordAnswer(root, project, Number(chapter), lens, packet, inputs, response.text); + onProgress(result.ok ? `Chapter ${chapter} ${lens}: recorded (${result.record.findings.length} findings, ${result.record.dropped_findings} dropped)` : `Chapter ${chapter} ${lens}: answer invalid after repair: ${result.errors[0]}`); + } +} + +export async function submitReviewPacket(root, project, packet, answerText) { + const [chapterText, lens] = String(packet.target).split(":"); + const chapter = Number(chapterText); + const inputs = reviewInputs(root, project, chapter); + const current = buildReviewPacket(root, project, chapter, lens, inputs); + if (current.id !== packet.id) return { ok: false, errors: ["packet is stale because its review inputs changed"] }; + return recordAnswer(root, project, chapter, lens, packet, inputs, answerText); +} diff --git a/engine/rules.mjs b/engine/rules.mjs index e157773..8f748bf 100644 --- a/engine/rules.mjs +++ b/engine/rules.mjs @@ -4,6 +4,7 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { loadScenes, countWords } from "./manuscript.mjs"; import { readerText } from "./prose.mjs"; +import { applyRulings } from "./rulings.mjs"; const RULES_PATH = join("quality", "rules.json"); const RULE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; @@ -343,9 +344,10 @@ export function runRules(root, project, { sceneIds = null } = {}) { findings.push(...evaluateRule(rule, scene, paragraphs, unitParagraphs)); } } - const blocking = findings.filter((finding) => finding.severity === "block").length; - const warnings = findings.filter((finding) => finding.severity === "warn").length; - return { ok: blocking === 0, findings, blocking, warnings, rules_checked: document.rules.length }; + const filtered = applyRulings(root, project, findings, { kind: "rule", sourceOf: (finding) => finding.rule_id, quoteOf: (finding) => finding.excerpt, scenes: allScenes }); + const blocking = filtered.findings.filter((finding) => finding.severity === "block").length; + const warnings = filtered.findings.filter((finding) => finding.severity === "warn").length; + return { ok: blocking === 0, findings: filtered.findings, blocking, warnings, allowed_blocking: filtered.allowed_blocking, rules_checked: document.rules.length }; } export function rulesPath() { diff --git a/engine/rulings.mjs b/engine/rulings.mjs new file mode 100644 index 0000000..46ecc6e --- /dev/null +++ b/engine/rulings.mjs @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Mark Pickering and PICKBITS LLC. Part of PickBits Weaver. +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { ensureIdentity } from "./changes.mjs"; +import { GENERATOR, SCHEMA_BASE } from "./identity.mjs"; +import { loadScenes, sha256 } from "./manuscript.mjs"; +import { readerText } from "./prose.mjs"; + +export const LAPSE_NOTE = "an earlier ruling no longer applies because the passage changed"; +const RULINGS_FILE = join("continuity", "rulings.json"); + +export function findingId(kind, source, sceneId, paragraph, quote) { + const normalized = String(quote || "").replace(/[\u201c\u201d]/gu, '"').replace(/[\u2018\u2019]/gu, "'").replace(/\s+/gu, " ").trim().toLocaleLowerCase(); + return sha256([kind, source, sceneId, paragraph, normalized].join(" | ")).slice(0, 10); +} + +export function passageText(scenes, sceneId, paragraph) { + const scene = scenes.find((item) => item.id === sceneId); + const prose = scene?.prose.split(/\n\s*\n/u).filter((item) => item.trim())[Number(paragraph) - 1]; + return prose === undefined ? null : readerText(prose); +} + +export function readRulings(root) { + const path = join(root, RULINGS_FILE); + if (!existsSync(path)) return { $schema: `${SCHEMA_BASE}rulings-1.json`, generator: GENERATOR, entries: [] }; + const document = JSON.parse(readFileSync(path, "utf8")); + if (!Array.isArray(document.entries)) throw new Error("invalid continuity/rulings.json: entries must be an array"); + return document; +} + +export function applyRulings(root, project, findings, { kind, sourceOf, quoteOf, scenes = loadScenes(root, project) } = {}) { + const rulings = readRulings(root).entries; + const visible = []; + const allowed_blocking = []; + for (const finding of findings) { + const source = sourceOf(finding); + const quote = quoteOf(finding); + const id = findingId(kind, source, finding.scene_id, finding.paragraph, quote); + const passage = passageText(scenes, finding.scene_id, finding.paragraph); + const passageHash = passage === null ? null : sha256(passage); + const ruling = [...rulings].reverse().find((item) => item.finding_id === id); + const earlier = ruling || [...rulings].reverse().find((item) => item.kind === kind && item.source === source && item.scene_id === finding.scene_id && item.paragraph === finding.paragraph); + const active = ruling && ruling.passage_sha256 === passageHash; + if (active && ["allow", "intended"].includes(ruling.decision)) { + if (finding.severity === "block") allowed_blocking.push(id); + continue; + } + const item = { ...finding, id, kind, source, quote }; + if (active && ruling.decision === "fix") item.ruling = "author wants this fixed"; + if (earlier && earlier.passage_sha256 !== passageHash) item.ruling = LAPSE_NOTE; + visible.push(item); + } + return { findings: visible, allowed_blocking }; +} + +export function rulingStatuses(root, project, activeFindings) { + const scenes = loadScenes(root, project); + const active = new Set(activeFindings.map((item) => item.id)); + return readRulings(root).entries.map((entry) => { + const passage = passageText(scenes, entry.scene_id, entry.paragraph); + const changed = passage === null || sha256(passage) !== entry.passage_sha256; + const status = entry.decision === "fix" && !active.has(entry.finding_id) ? "resolved" + : changed ? "lapsed" : ["allow", "intended"].includes(entry.decision) || active.has(entry.finding_id) ? "active" : "resolved"; + return { ...entry, status }; + }); +} + +export function saveRuling(root, project, finding, decision, note = "") { + if (!["fix", "allow", "intended"].includes(decision)) throw new Error("decision must be fix, allow, or intended"); + const scenes = loadScenes(root, project); + const passage = passageText(scenes, finding.scene_id, finding.paragraph); + if (passage === null) throw new Error(`finding ${finding.id} has no current passage`); + const document = readRulings(root); + const entry = { + finding_id: finding.id, kind: finding.kind, source: finding.source, + scene_id: finding.scene_id, paragraph: finding.paragraph, passage_sha256: sha256(passage), + decision, note: String(note), ruled_at: new Date().toISOString(), generator: GENERATOR + }; + document.entries.push(entry); + const path = join(root, RULINGS_FILE); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(document, null, 2)}\n`); + ensureIdentity(root, project); + const stage = spawnSync("git", ["-C", root, "add", "--", RULINGS_FILE.replaceAll("\\", "/")], { encoding: "utf8" }); + if (stage.status !== 0) throw new Error(`could not stage ruling: ${(stage.stderr || "").trim()}`); + const args = ["-C", root, "commit", "--only", "-m", `Ruling: ${decision} ${finding.id}`, "-m", `Approved-with: ${GENERATOR}`, "--", RULINGS_FILE.replaceAll("\\", "/")]; + const commit = spawnSync("git", args, { encoding: "utf8" }); + if (commit.status !== 0) throw new Error(`could not save ruling commit: ${(commit.stderr || commit.stdout || "").trim()}`); + return entry; +} diff --git a/engine/voice.mjs b/engine/voice.mjs index 2003dba..21de147 100644 --- a/engine/voice.mjs +++ b/engine/voice.mjs @@ -5,6 +5,7 @@ import { spawnSync } from "node:child_process"; import { join } from "node:path"; import { countWords, extractProse, loadScenes, manuscriptHash, sha256 } from "./manuscript.mjs"; import { readerText } from "./prose.mjs"; +import { applyRulings } from "./rulings.mjs"; import { loadCharacters } from "./characters.mjs"; const VOICES_ROOT = join("world-bible", "voices"); @@ -713,7 +714,8 @@ export function runVoiceCheck(root, project, { sceneIds = null, pendingScenes = } findings.push(finding); } - return { findings, notes, stale_profiles: staleProfiles, warnings: findings.length, hash: approved.manuscript_sha256 }; + const filtered = applyRulings(root, project, findings, { kind: "voice", sourceOf: (finding) => finding.speaker, quoteOf: (finding) => finding.line }); + return { findings: filtered.findings, notes, stale_profiles: staleProfiles, warnings: filtered.findings.length, hash: approved.manuscript_sha256 }; } export function attributeDialogue(scene, characters) { diff --git a/integrity.json b/integrity.json index 0cc2216..3550121 100644 --- a/integrity.json +++ b/integrity.json @@ -5,7 +5,7 @@ "files": { "LICENSE": "3954ad2ee7d56cfda60326c71dcfd3d7edd79a44727f51b5ca5e4b7c33b8c6d6", "NOTICE": "83abe7f22edf5a06f7b4814e8b4b4b124e1b612bd81dd4c6542c0b119fab4976", - "README.md": "9a753c1013a8915d0e1abc9ff2edc8fc1d70f25db410bc5c5096d7e929e7e7a6", + "README.md": "1f5422bf2e7c6ad68f43448faf9fe3ac6944bdc89d92ff4900291aea4cb93ad8", "SECURITY.md": "5c9c72ffaa8dfdc744360ea666d2082c68bba2479c61d13810bbd595498afe2b", "contracts/characters.schema.json": "f9bd6fcc9c2ad363fd805d8d5209c9a292204b0beaf9035a4b6ceadacfd36c7b", "contracts/facts-ledger.schema.json": "109ca6f8e761fe3aa1ac753b7ff4ec41cc59d14c98f49c8bce46d21e54ec608d", @@ -16,17 +16,20 @@ "contracts/project.schema.json": "49cbfdc8539bef7ab45b885c543aa5bff31558045f16382cb87b19ce6546287b", "contracts/provenance.schema.json": "bea26ddbcbfef8c046b0707e9a2f6dff8913cd9531b6267d3f374f821919d107", "contracts/release-manifest.schema.json": "65175761fc3095419ceb01b28457e6615c57c6eff4baceb2867128da73303db4", + "contracts/review.schema.json": "6c4ad08c26bff0b4193d4104d0d78d0550365bf716f5627286c7f1305ce0aad3", "contracts/rules.schema.json": "59dfbe8f73bceb81a294e299e90604600d5d8d3984511bb3c9167dc528893924", + "contracts/rulings.schema.json": "5b2b707fe3d6616720a958ccf41e7e1560020d9bd1055027a473cace99f2ea2e", "contracts/state-manifest.schema.json": "e6c0c2ce39c81f5db5feed31f7e7432a694eeb04ab329445dc1fbac29aff553f", "docs/ARCHITECTURE.md": "5d92f171bf886ea6a56e4d5dffa622904752fd793f53d7daabedf0304d9fc9a6", "docs/HANDOFF.md": "6e37433f60c54989abb1171c6989ed9c0cbab83be0d414d3d36ff6e985a9ee8c", "docs/LOOP.md": "bf3098c9afbcac6254d1682e87ad6b1e6ed798f0cb5e22a5f76a8fd3941fe8d5", "docs/ORIGIN.md": "d66e5d9a0c3e2c05a983c790dd616fc051d2666417ae0e1fd697478e5ff4cda8", - "docs/WORKFLOW.md": "533375e53bb2a09711e97269b0fbd0538053390e836a6c97e880b47ade4b2faf", - "engine/changes.mjs": "aa99efacf9a953296cd7b323be505363d442c447006381126214db50b9e667d9", + "docs/WORKFLOW.md": "ddfe169e6563d564b7159781ae345dac74c30c7280c9514d12bb967e4ae333c8", + "engine/changes.mjs": "8dd233b8d974d48e04701c5d7c18ed0672ac68d2bff495c95e94285ce983d326", "engine/characters.mjs": "1d8cc8e621e1d2b42ab81ff787be1df0648c5e852e4256dccc8dc2f7d4f0e166", "engine/doctor.mjs": "39ee2a515be1743900b7c2360415746743c2ff434e8549362d36a159bf1da15a", "engine/export.mjs": "092ad9a4687cf24ca5daa822c844879c8314adc227542cf46ec7b871e43f9f3c", + "engine/findings.mjs": "66cc518f6b9ac605146f4c614b03b37d31418f039947714705db02169fcc5472", "engine/hooks.mjs": "07468f9f390360c7fb1f94092b42d9bad987c0843f2f206439b21bce1cf5c01c", "engine/host.mjs": "ddfc819a409e2e9c61189b6c96d2b62204ff8c5686a7bedf8ca743b4a1fd027f", "engine/identity.mjs": "670f211b093aa783a0d6ed24be566239c438deb9da75e9ef20748e2c1b057364", @@ -37,10 +40,12 @@ "engine/packets.mjs": "8245677d25d9a018d8fb82a3d1c37858765ccb7084d97b250df76c323d5f148c", "engine/project.mjs": "d89dd6087d1802c0767a2a1d676a8c1ca290610a54eaf6dd17ca31e98c95f8d8", "engine/prose.mjs": "e6c75d599abc9fee5fd494c3cd56798135ae21ee15eb9f16d5c83b7c1c877fcd", - "engine/release.mjs": "ffe5cbc72e888823c9b74514629fe2dcd0b4594df55b121b59fabf9c99d99fe1", - "engine/rules.mjs": "5b75d64c8a2eadb7703cf75b9c8778e9b90613856f6b29c8c0f4ad3dc37f6b93", + "engine/release.mjs": "609b41f7fc06c2f2fbd2b515bcdf3fab72dd7bfc255107155ab2f1e08167eb0a", + "engine/review.mjs": "c5e31bfff932b7edd10b1b97fd527428fede4c23b175e0e9f4c05c7bf94831c3", + "engine/rules.mjs": "9e663c68825a9e8a8ab3a7d10293085d58c001a7c75edded02b8ea7e382ea8e2", + "engine/rulings.mjs": "4373a468fb16c63f84aff00d2066b85a42d6d77404289d473b623100a019e74e", "engine/state.mjs": "de9c2a19393bd886454ad0e62c0cc91940d3fe9bbea7adedb384dd9143d41024", - "engine/voice.mjs": "1830ff86c67cc6fa7e84c943b9637cd6ecdfd9eca1d6542be5dcd83d91b29cab", + "engine/voice.mjs": "a88aff749cc190511386ca687b9a50328a5cd9e07aed8919245340afdceb6258", "loop.manifest.json": "340b33bb8b03c60ce4997b9182baa18435fc3d6716d660ea8d15ae466663abc0", "package.json": "d1b143a5559d860707d5085265a175e64ef07af09cf12411634e3522c0cb39be", "prompts/architect.md": "51ed331812edfb4801fd60948293eb40f960a2735d949376d80488dc1c3724e4", @@ -49,7 +54,7 @@ "prompts/reviewers/style.md": "4479a4cb676627ca1ff4f7116f15e0cdc1f50fe2763db1388371d80570c9cdd4", "prompts/state-extractor.md": "b2d92d014892f2a1c201003b1cd16cf9d0adcc5e8611f7d350eab612b8947cc5", "scripts/integrity.mjs": "78c0bcdf7b5858de517f02eb09db1febf6d33eec787f09e7c24178f0cb263243", - "scripts/weaver.mjs": "970593ca3355f12f834aa7b89be4f39993dc4200850775c2623f3c6326eaeae5", + "scripts/weaver.mjs": "b4ca234e4937b6ee4457c06cd877466112c1b9efc7bb3cfbffefe1ab78c792fb", "templates/book/.claude/settings.json": "9fb07cc94b12c3cce506c1bd2706c351dca83309f1db2bb25f5897cec1735fe2", "templates/book/AGENTS.md": "6bb83885ca4498cfd5e994f2b36cd1688db04d6174905b0a813c5fbedd7c4a10", "templates/book/README.md": "c33756ab69072410e4583f44daf21d08fa6c576816c5ed240284850d6e4b2659", diff --git a/scripts/weaver.mjs b/scripts/weaver.mjs index 4a8cc06..0bd46b3 100644 --- a/scripts/weaver.mjs +++ b/scripts/weaver.mjs @@ -10,13 +10,16 @@ import { readProject } from "../engine/project.mjs"; import { buildRelease, runQualityChecks } from "../engine/release.mjs"; import { DEFAULT_CHAPTER_PATTERN, importBook } from "../engine/import.mjs"; import { exportBook } from "../engine/export.mjs"; -import { acceptNarrativeState, bootstrapState, buildFactsLedger, buildGroundingPacket, buildStatePacket, narrativeStateStatus, submitStatePacket } from "../engine/state.mjs"; +import { acceptNarrativeState, bootstrapState, buildFactsLedger, buildGroundingPacket, buildStatePacket, narrativeStateStatus, readPacket, submitStatePacket } from "../engine/state.mjs"; import { GENERATOR } from "../engine/identity.mjs"; import { installHooks, runHook } from "../engine/hooks.mjs"; import { runRules } from "../engine/rules.mjs"; import { approveChapter, approvalHistory, describePendingChanges, pendingChanges, recordImportBaseline, rejectChapter, undoApproval } from "../engine/changes.mjs"; import { seedCharacters } from "../engine/characters.mjs"; import { buildVoiceProfiles, runVoiceCheck } from "../engine/voice.mjs"; +import { collectFindings } from "../engine/findings.mjs"; +import { runReview, submitReviewPacket } from "../engine/review.mjs"; +import { rulingStatuses, saveRuling } from "../engine/rulings.mjs"; const argv = process.argv.slice(2); const command = argv.shift() || "help"; @@ -50,7 +53,7 @@ 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}`; + return `Chapter ${chapter.chapter}: ${chapter.scenes_changed} scene${chapter.scenes_changed === 1 ? "" : "s"} changed, +${chapter.words_added} / −${chapter.words_removed} words, ${findingLabel}, ${chapter.queued_judgments || 0} queued judgment(s)`; }).join("\n"); } @@ -184,7 +187,9 @@ try { narrative_state: checks.state, rules: { blocking: checks.rules.blocking, warnings: checks.rules.warnings }, voices: { warnings: checks.voices.warnings, stale_profiles: checks.voices.stale_profiles } - }; + }; + report.allowed_blocking = checks.allowed_blocking; + if (checks.allowed_blocking.length) report.blocking_finding_audit = `${checks.allowed_blocking.length} blocking finding(s) allowed by author rulings: ${checks.allowed_blocking.join(", ")}`; print(report); if (!checks.ok) process.exitCode = 1; } else if (command === "rules") { @@ -215,6 +220,8 @@ try { } else if (command === "changes") { const { project } = projectContext(); const report = describePendingChanges(root, project); + const judgments = collectFindings(root, project); + for (const entry of report.chapters) entry.queued_judgments = judgments.chapters.find((item) => item.chapter === entry.chapter)?.counts.queued_judgments || 0; if (argv.includes("--json")) print(report); else print(pendingSummary(report)); } else if (command === "approve") { @@ -224,6 +231,7 @@ try { const chapterOption = option("--chapter"); const result = approveChapter(root, project, chapterOption == null ? null : Number(chapterOption), option("--note", "")); print(`Approval ${result.id}: Chapter ${result.chapter} approved.`); + if (result.allowed_blocking.length) print(`${result.allowed_blocking.length} blocking finding(s) allowed by author rulings: ${result.allowed_blocking.join(", ")}`); } } else if (command === "reject") { if (refuseIfDoctorBlocks(root, command)) process.exitCode = 1; @@ -285,12 +293,59 @@ try { const answerPath = option("--file"); if (!packetId || !answerPath) throw new Error("usage: weaver submit --file [--root directory]"); const { project } = projectContext(); - const result = await submitStatePacket(root, project, packetId, readFileSync(resolve(answerPath), "utf8")); + const packet = readPacket(root, packetId); + const result = packet.kind === "REVIEW" + ? await submitReviewPacket(root, project, packet, readFileSync(resolve(answerPath), "utf8")) + : await submitStatePacket(root, project, packetId, readFileSync(resolve(answerPath), "utf8")); if (!result.ok) { print(`Packet ${packetId}: answer invalid after repair: ${result.errors[0]}`); process.exitCode = 1; - } else print(`Scene ${result.scene_id}: recorded (${result.facts} facts)`); + } else if (packet.kind === "REVIEW") print(`Chapter ${result.record.chapter} ${result.record.lens}: recorded (${result.record.findings.length} findings, ${result.record.dropped_findings} dropped)`); + else print(`Scene ${result.scene_id}: recorded (${result.facts} facts)`); } + } else if (command === "review") { + if (refuseIfDoctorBlocks(root, command)) process.exitCode = 1; + else { + const { project } = projectContext(); + const chapter = Number(option("--chapter")); + if (!Number.isInteger(chapter) || chapter < 1) throw new Error("usage: weaver review --chapter N [--lenses continuity,style,critic] [--run]"); + const lenses = option("--lenses")?.split(",").map((item) => item.trim()) || undefined; + await runReview(root, project, { chapter, lenses, run: argv.includes("--run") }); + } + } else if (command === "findings") { + const { project } = projectContext(); + const chapter = option("--chapter"); + const result = collectFindings(root, project, { chapter: chapter === null ? null : Number(chapter), all: argv.includes("--all"), toFix: argv.includes("--to-fix") }); + if (argv.includes("--json")) print(result); + else { + for (const entry of result.chapters) { + const counts = entry.counts; + print(`Chapter ${entry.chapter}: ${counts.definite_contradictions} definite contradiction(s), ${counts.style_breaks} style break(s), ${counts.critic_issues} critic issue(s), ${entry.note_count} notes (see --all)`); + for (const lens of entry.stale_reviews) print(` ${lens} review out of date, run weaver review --chapter ${entry.chapter}`); + for (const item of entry.findings) print(` ${item.id} ${item.source}, ${item.scene_id} paragraph ${item.paragraph}: ${item.quote}\n ${item.issue}\n Suggested repair: ${item.repair}${item.ruling ? `\n ${item.ruling}` : ""}`); + if (argv.includes("--all")) for (const item of entry.notes) print(` note ${item.id} ${item.source}, ${item.scene_id} paragraph ${item.paragraph}: ${item.issue}`); + } + } + } else if (command === "rule") { + if (refuseIfDoctorBlocks(root, command)) process.exitCode = 1; + else { + const { project } = projectContext(); + const id = positional(); + const decision = option("--decision"); + if (!id || !decision) throw new Error("usage: weaver rule --decision fix|allow|intended [--note text]"); + const collected = collectFindings(root, project, { all: true }); + const finding = [...collected.findings, ...collected.notes].find((item) => item.id === id); + if (!finding) throw new Error(`unknown finding id: ${id}`); + const entry = saveRuling(root, project, finding, decision, option("--note", "")); + print(`Ruling ${entry.decision} ${entry.finding_id} saved.`); + } + } else if (command === "rulings") { + const { project } = projectContext(); + const active = collectFindings(root, project, { all: true }); + const result = rulingStatuses(root, project, [...active.findings, ...active.notes]); + if (argv.includes("--json")) print(result); + else if (!result.length) print("No author rulings."); + else for (const item of result) print(`${item.finding_id} ${item.decision}: ${item.status}${item.note ? ` — ${item.note}` : ""}`); } else if (command === "facts" && positional() === "build") { if (refuseIfDoctorBlocks(root, command)) process.exitCode = 1; else { @@ -402,6 +457,10 @@ Usage: weaver characters seed [--root directory] [--json] weaver voices build [--root directory] [--json] weaver voices check [--scene scene-id | --pending] [--root directory] [--json] + weaver review --chapter N [--lenses continuity,style,critic] [--run] [--root directory] + weaver findings [--chapter N] [--all] [--to-fix] [--json] [--root directory] + weaver rule --decision fix|allow|intended [--note text] [--root directory] + weaver rulings [--json] [--root directory] weaver changes [--root directory] [--json] weaver approve [--chapter number] [--note "text"] [--root directory] weaver reject [--chapter number] [--root directory] @@ -430,6 +489,10 @@ Usage: weaver characters seed [--root directory] [--json] weaver voices build [--root directory] [--json] weaver voices check [--scene scene-id | --pending] [--root directory] [--json] + weaver review --chapter N [--lenses continuity,style,critic] [--run] [--root directory] + weaver findings [--chapter N] [--all] [--to-fix] [--json] [--root directory] + weaver rule --decision fix|allow|intended [--note text] [--root directory] + weaver rulings [--json] [--root directory] weaver changes [--root directory] [--json] weaver approve [--chapter number] [--note "text"] [--root directory] weaver reject [--chapter number] [--root directory] diff --git a/test/consumer-install.mjs b/test/consumer-install.mjs index f6aa9df..119f0d6 100644 --- a/test/consumer-install.mjs +++ b/test/consumer-install.mjs @@ -80,6 +80,14 @@ try { 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."); weaver(["import", finishedSource, "--root", repairBook]); + const fakeHost = join(consumer, "fake-host.mjs"); + writeFileSync(fakeHost, readFileSync(join(root, "test", "fixtures", "fake-host.mjs"), "utf8")); + const repairProjectPath = join(repairBook, "project.json"); + const repairProject = JSON.parse(readFileSync(repairProjectPath, "utf8")); + repairProject.host = { command: process.execPath, args: [fakeHost, "review-triage"], concurrency: 3, timeout_seconds: 10 }; + writeFileSync(repairProjectPath, `${JSON.stringify(repairProject, null, 2)}\n`); + weaver(["review", "--chapter", "1", "--run", "--root", repairBook]); + assert.ok(existsSync(join(repairBook, "review", "chapter-01-continuity.json"))); // An imported book has no accepted narrative state yet: check must say so, and release must // refuse until state is current. Export (the repair path) does not depend on state. const repairCheckResult = weaverResult(["check", "--root", repairBook]); diff --git a/test/fixtures/fake-host.mjs b/test/fixtures/fake-host.mjs index f813d46..eee2d14 100644 --- a/test/fixtures/fake-host.mjs +++ b/test/fixtures/fake-host.mjs @@ -8,6 +8,32 @@ if (mode === "early-exit") process.exit(0); const counterPath = ".weaver/fake-host-count"; const prompt = readFileSync(0, "utf8"); +const review = prompt.match(/^# REVIEW packet chapter (\d+) lens (continuity|style|critic)/mu); +if (review) { + const lens = review[2]; + const paragraphs = [...prompt.matchAll(/^\[([0-9]+-[0-9]+) p(\d+)\] (.+)$/gmu)]; + const first = paragraphs[0]; + const quote = first?.[3].split(/\s+/u).slice(0, 5).join(" ") || "No valid reader passage exists"; + const finding = (severity, index = 0) => ({ + severity, scene_id: first?.[1] || "1-1", paragraph: Number(first?.[2] || 1), quote, + issue: `A ${severity} issue appears in this passage.`, repair: "Revise the smallest relevant phrase." + }); + if (mode === "review-capture") writeFileSync(`.weaver/review-${lens}-prompt.txt`, prompt); + if (mode === "review-malformed-once" && !existsSync(`.weaver/review-${lens}-retry`)) { + writeFileSync(`.weaver/review-${lens}-retry`, "1"); + process.stdout.write("```json\nnot json\n```\n"); + process.exit(0); + } + const verdict = { continuity: "FLAGS", style: "DRIFT", critic: "FAIL" }[lens]; + const findings = mode === "review-triage" + ? lens === "continuity" ? [finding("definite"), ...Array.from({ length: 8 }, () => finding("minor"))] + : lens === "critic" ? Array.from({ length: 5 }, () => finding("craft")) : [] + : mode === "review-cap" ? Array.from({ length: 10 }, () => finding("definite")) + : [finding("definite")]; + if (mode === "review-invalid") findings.push({ ...finding("definite"), quote: "These invented words are absent" }); + process.stdout.write(`${lens.toUpperCase()}: ${verdict}\n\n\`\`\`json\n${JSON.stringify({ verdict, findings })}\n\`\`\`\n`); + process.exit(0); +} const sceneId = prompt.match(/# STATE packet ([0-9]+-[0-9]+)/u)?.[1] || "1-1"; let firstModeCall = false; if (mode === "retry" || mode === "malformed-once") { diff --git a/test/review.test.mjs b/test/review.test.mjs new file mode 100644 index 0000000..8ff50b9 --- /dev/null +++ b/test/review.test.mjs @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Mark Pickering and PICKBITS LLC. Part of PickBits Weaver. +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; +import { initializeProject } from "../engine/init.mjs"; +import { importBook } from "../engine/import.mjs"; +import { readProject } from "../engine/project.mjs"; +import { collectFindings } from "../engine/findings.mjs"; +import { buildReviewPacket } from "../engine/review.mjs"; + +const ROOT = resolve("."); +const CLI = join(ROOT, "scripts", "weaver.mjs"); +const HOST = join(ROOT, "test", "fixtures", "fake-host.mjs"); + +function book(mode = "review-triage") { + const root = mkdtempSync(join(tmpdir(), "weaver-review-")); + initializeProject(root, { projectId: "review-book", title: "Review Book", empty: true }); + const source = join(root, "original.md"); + writeFileSync(source, "# Brask\n\nBrask waits beside the eastern gate before the careful dawn arrives.\n\n* * *\n\n# Wren\n\nWren carries a quiet message into the courtyard as morning advances.\n"); + importBook(root, readProject(root), source); + const project = readProject(root); + project.host = { command: process.execPath, args: [HOST, mode], concurrency: 3, timeout_seconds: 10 }; + writeFileSync(join(root, "project.json"), `${JSON.stringify(project, null, 2)}\n`); + return { root, project }; +} + +function cli(root, args) { + return spawnSync(process.execPath, [CLI, ...args, "--root", root], { cwd: ROOT, encoding: "utf8" }); +} + +test("review runs three lenses, drops invented quotes, repairs malformed JSON, and resumes", async () => { + for (const mode of ["review-invalid", "review-malformed-once"]) { + const { root } = book(mode); + try { + const result = cli(root, ["review", "--chapter", "1", "--run"]); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + const records = ["continuity", "style", "critic"].map((lens) => JSON.parse(readFileSync(join(root, "review", `chapter-01-${lens}.json`), "utf8"))); + assert.equal(records.length, 3); + for (const record of records) { + assert.equal(record.findings.length, 1); + assert.equal(record.dropped_findings, mode === "review-invalid" ? 1 : 0); + } + const again = cli(root, ["review", "--chapter", "1", "--run"]); + assert.match(again.stdout, /skipped, already current/); + } finally { rmSync(root, { recursive: true, force: true }); } + } +}); + +test("continuity packet uses compact prior facts and state without earlier prose", () => { + const { root, project } = book(); + try { + const source = join(root, "manuscript", "chapter-01", "scene-01", "scene.md"); + writeFileSync(source, readFileSync(source, "utf8").replace("careful dawn arrives", "SENTINEL EARLIER PROSE remains")); + mkdirSync(join(root, "continuity"), { recursive: true }); + writeFileSync(join(root, "continuity", "facts.json"), JSON.stringify({ subjects: { brask: [ + { scene_id: "1-1", kind: "location", value: "the eastern gate" }, + { scene_id: "1-1", kind: "knowledge", value: "the courier is late" } + ] } })); + writeFileSync(join(root, "narrative-state", "1-1.md"), "Brask knows the courier is late."); + const packet = buildReviewPacket(root, project, 2, "continuity"); + assert.match(packet.prompt, /brask location: the eastern gate/); + assert.match(packet.prompt, /Brask knows the courier is late/); + assert.doesNotMatch(packet.prompt, /SENTINEL EARLIER PROSE/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("triage queues one definite and two critic issues, keeps notes, stays stable, and invalidates edited prose", () => { + const { root, project } = book(); + try { + assert.equal(cli(root, ["review", "--chapter", "1", "--run"]).status, 0); + const first = collectFindings(root, project, { chapter: 1, all: true }); + const judgments = first.findings.filter((item) => item.kind === "judgment"); + assert.equal(judgments.length, 3); + assert.equal(first.notes.length, 11); + const second = collectFindings(root, project, { chapter: 1, all: true }); + assert.deepEqual(second.findings.map((item) => item.id), first.findings.map((item) => item.id)); + const path = join(root, "manuscript", "chapter-01", "scene-01", "scene.md"); + writeFileSync(path, readFileSync(path, "utf8").replace("eastern gate", "western gate")); + const stale = collectFindings(root, project, { chapter: 1 }); + assert.equal(stale.findings.filter((item) => item.kind === "judgment").length, 0); + assert.equal(stale.chapters[0].stale_reviews.length, 3); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("judgment queue never exceeds six items per chapter", () => { + const { root, project } = book("review-cap"); + try { + assert.equal(cli(root, ["review", "--chapter", "1", "--run"]).status, 0); + const result = collectFindings(root, project, { chapter: 1, all: true }); + assert.equal(result.findings.filter((item) => item.kind === "judgment").length, 6); + assert.equal(result.notes.length, 24); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/test/rulings.test.mjs b/test/rulings.test.mjs new file mode 100644 index 0000000..48ba0ff --- /dev/null +++ b/test/rulings.test.mjs @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Mark Pickering and PICKBITS LLC. Part of PickBits Weaver. +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; +import { initializeProject } from "../engine/init.mjs"; +import { readProject } from "../engine/project.mjs"; +import { collectFindings } from "../engine/findings.mjs"; + +const ROOT = resolve("."); +const CLI = join(ROOT, "scripts", "weaver.mjs"); + +function book() { + const root = mkdtempSync(join(tmpdir(), "weaver-ruling-")); + initializeProject(root, { projectId: "ruling-book", title: "Ruling Book" }); + const rulesPath = join(root, "quality", "rules.json"); + const rules = JSON.parse(readFileSync(rulesPath, "utf8")); + rules.rules.push({ id: "pressure-block", type: "phrase-list", severity: "block", message: "Avoid this pressure phrase.", phrases: ["creates a pressure"] }); + rules.rules.push({ id: "closing-warn", type: "phrase-list", severity: "warn", message: "Check the closing image.", phrases: ["closing image"] }); + writeFileSync(rulesPath, `${JSON.stringify(rules, null, 2)}\n`); + return root; +} + +function cli(root, args) { + return spawnSync(process.execPath, [CLI, ...args, "--root", root], { cwd: ROOT, encoding: "utf8" }); +} + +function git(root, args) { + return spawnSync("git", ["-C", root, ...args], { encoding: "utf8" }); +} + +test("rulings suppress allowed blocks, audit approval, lapse after edits, and commit only the ledger", () => { + const root = book(); + try { + const project = readProject(root); + const initial = collectFindings(root, project); + const block = initial.findings.find((item) => item.source === "pressure-block"); + const warn = initial.findings.find((item) => item.source === "closing-warn"); + assert.ok(block && warn); + const unknown = cli(root, ["rule", "0000000000", "--decision", "allow"]); + assert.equal(unknown.status, 1); + assert.match(unknown.stderr, /unknown finding id/); + let result = cli(root, ["rule", block.id, "--decision", "allow", "--note", "Deliberate phrasing"]); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.deepEqual(git(root, ["show", "--format=", "--name-only", "HEAD"]).stdout.trim().split(/\r?\n/u), ["continuity/rulings.json"]); + assert.match(git(root, ["log", "-1", "--format=%B"]).stdout, /Approved-with: PickBits Weaver/); + assert.equal(cli(root, ["state:accept"]).status, 0); + result = cli(root, ["check"]); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stdout, /1 blocking finding\(s\) allowed by author rulings: /); + const scene = join(root, "manuscript", "chapter-01", "scene-01", "scene.md"); + writeFileSync(scene, readFileSync(scene, "utf8").replace("Starter placeholder", "Reviewed placeholder")); + result = cli(root, ["approve", "--chapter", "1"]); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stdout, /1 blocking finding\(s\) allowed by author rulings/); + result = cli(root, ["rule", warn.id, "--decision", "fix"]); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.deepEqual(git(root, ["show", "--format=", "--name-only", "HEAD"]).stdout.trim().split(/\r?\n/u), ["continuity/rulings.json"]); + assert.equal(JSON.parse(cli(root, ["findings", "--to-fix", "--json"]).stdout).findings.length, 1); + writeFileSync(scene, readFileSync(scene, "utf8").replace("closing image", "last sight")); + const status = JSON.parse(cli(root, ["rulings", "--json"]).stdout); + assert.equal(status.find((item) => item.finding_id === warn.id).status, "resolved"); + writeFileSync(scene, readFileSync(scene, "utf8").replace("first line creates a pressure", "first line still creates a pressure")); + const lapsed = cli(root, ["findings"]); + assert.match(lapsed.stdout, /an earlier ruling no longer applies because the passage changed/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/test/voice.test.mjs b/test/voice.test.mjs index d90c9c6..a8651cb 100644 --- a/test/voice.test.mjs +++ b/test/voice.test.mjs @@ -141,6 +141,14 @@ test("profiles and checks are deterministic, evidentiary, stale-aware, and advis assert.equal(hookOutput.decision, undefined); assert.match(hookOutput.hookSpecificOutput.additionalContext, /Typical lines/); + const voiceId = result.findings.find((finding) => finding.speaker === "Brask").id; + const ruling = spawnSync(process.execPath, [CLI, "rule", voiceId, "--decision", "allow", "--root", root], { cwd: ROOT, encoding: "utf8" }); + assert.equal(ruling.status, 0, `${ruling.stdout}\n${ruling.stderr}`); + assert.equal(runVoiceCheck(root, project).findings.some((finding) => finding.id === voiceId), false); + writeFileSync(path, readFileSync(path, "utf8").replace('Brask said, "Indeed, the eastern bastion', 'At noon, Brask said, "Indeed, the eastern bastion')); + assert.equal(runVoiceCheck(root, project).findings.find((finding) => finding.id === voiceId)?.ruling, + "an earlier ruling no longer applies because the passage changed"); + const approval = spawnSync(process.execPath, [CLI, "approve", "--chapter", "1", "--root", root], { cwd: ROOT, encoding: "utf8" }); assert.equal(approval.status, 0, `${approval.stdout}\n${approval.stderr}`); const approvedProfile = JSON.parse(readFileSync(join(root, "world-bible", "voices", "brask.json"), "utf8")); From 331f24053cbda39a51b17971d60da84391c4b115 Mon Sep 17 00:00:00 2001 From: MrPickering Date: Sat, 26 Sep 2026 07:52:57 -0700 Subject: [PATCH 3/3] Studio: the author's local page (read, decide, approve, undo, run jobs) The author never uses a terminal or handles files, codes or Git. The studio is a thin UI over the existing engine: every button calls the same function as the matching command, through the same doctor refusal. - `weaver studio [--port]`: node:http bound to 127.0.0.1 only, default port 4187 (project.json "studio.port" optional). /api/health carries a body signature. POSTs require X-Weaver-Studio: 1 plus a local Host. Routes are allow-listed, there is no path traversal, and a restrictive CSP; only packaged assets are served. Offline, no dependencies. - Pages: progress ("N of M chapters approved since import"), a book browser with paragraph numbers and finding markers, character pages (voice evidence, facts timeline), places and threads derived from the facts ledger and state records (wiki links and stable slugs), a triaged findings queue with Fix / Intended / Allow, pending changes side by side with Approve / Reject, history with Undo, and a doctor footer on every page. - Jobs: run a chapter review or the story-so-far setup from the page, one at a time, with a live log tail. Verified: npm run preflight (63/63): health signature, all pages, 404s, POST protection, approve/reject/undo and rulings via the API asserted against Git, wiki links, progress count, jobs with the fake host, a 127.0.0.1 bind, and the studio starting from the packed tarball. Co-Authored-By: Claude Opus 5.5 Session-Id: 04db4d93-9884-44ee-b28f-627e92b33731 Agent: claude-code --- README.md | 18 ++ contracts/project.schema.json | 5 + docs/WORKFLOW.md | 17 ++ engine/project.mjs | 4 + engine/studio.mjs | 330 ++++++++++++++++++++++++++++++++++ integrity.json | 15 +- package.json | 1 + scripts/weaver.mjs | 8 + studio/studio.css | 5 + studio/studio.js | 45 +++++ test/consumer-install.mjs | 22 ++- test/fixtures/fake-host.mjs | 5 +- test/studio.test.mjs | 142 +++++++++++++++ 13 files changed, 608 insertions(+), 9 deletions(-) create mode 100644 engine/studio.mjs create mode 100644 studio/studio.css create mode 100644 studio/studio.js create mode 100644 test/studio.test.mjs diff --git a/README.md b/README.md index 8cdcc43..9033e20 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,23 @@ npx weaver status --root ./my-book npx weaver grounding 1-1 --root ./my-book ``` +## The studio + +Start the local author page with `weaver studio --root ./my-book`. It listens +only on `127.0.0.1:4187`; open `http://127.0.0.1:4187/` on the same computer. +Use `--port N` to choose another port, or set `"studio": { "port": N }` in +`project.json`. The studio works offline and uses no web services for its pages. +Review and bootstrap jobs use the book's configured command-line AI host. + +The home page shows approval progress and findings. Book is the reader, with +chapter and scene status. Character pages show aliases, voice evidence, facts, +and spoken scenes. Places and Threads follow the facts ledger and narrative +state. Findings is the decision queue, Changes compares approved and new text, +History shows decisions and offers undo, and Jobs runs review or story-so-far +bootstrap and lets the author read and accept the resulting state. The doctor footer appears on every page. Every button performs the +same engine action as its matching Weaver command and obeys the same doctor +refusal. The author can read and decide entirely on the local page. + ## Repair a finished book Use one Weaver project for each book in a series. Start an empty book, import @@ -248,6 +265,7 @@ silently billing an API key. A missing host is a doctor warning, not a block. | `import ` | Import a finished DOCX, Markdown, text file, or folder into an empty book | | `export --format md\|docx --out ` | Export the current manuscript without overwriting the source or an existing file | | `doctor` | Check the book folder, Git, host, and Weaver installation | +| `studio` | Open the local author page (`--port N` supported) | | `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) | diff --git a/contracts/project.schema.json b/contracts/project.schema.json index 792c97b..de2e174 100644 --- a/contracts/project.schema.json +++ b/contracts/project.schema.json @@ -40,6 +40,11 @@ "timeout_seconds": { "type": "integer", "minimum": 1 } } }, + "studio": { + "type": "object", + "additionalProperties": false, + "properties": { "port": { "type": "integer", "minimum": 1, "maximum": 65535 } } + }, "chapters": { "type": "object", "additionalProperties": { "type": "string" } diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index 3fd1472..a5513e3 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -1,5 +1,22 @@ # Writing and revision workflow +## The author's daily loop in the studio + +Open the local page started by `weaver studio --root ./my-book`. On Home, check +approved chapters, open findings, and the doctor footer. Read Book by chapter +and scene; paragraph markers lead to the relevant finding. Character, Place, +and Thread pages provide the current story context. + +On Jobs, run a chapter review or bootstrap story-so-far state. Read each new +state record there and explicitly accept it before release. Return to +Findings, read the cited passage and suggested repair, then choose **Fix this**, +**It's intended**, or **Allow**. Recheck a changed passage because its earlier +ruling may lapse. Open Changes to compare approved and new paragraphs and +their style findings. Approve or reject one chapter there. History records the +decisions and offers a confirmed undo of the last approval. The studio calls +the same engine operations as the matching commands below, including their +doctor refusal. + ## 0. Check the setup Before working on a book, run: diff --git a/engine/project.mjs b/engine/project.mjs index 98c3dcd..aa3ee91 100644 --- a/engine/project.mjs +++ b/engine/project.mjs @@ -45,5 +45,9 @@ export function validateProject(project) { if (project.host.timeout_seconds !== undefined && (!Number.isInteger(project.host.timeout_seconds) || project.host.timeout_seconds < 1)) errors.push("host.timeout_seconds must be a positive integer"); } } + if (project?.studio !== undefined) { + if (!project.studio || typeof project.studio !== "object" || Array.isArray(project.studio)) errors.push("studio must be an object"); + else if (project.studio.port !== undefined && (!Number.isInteger(project.studio.port) || project.studio.port < 1 || project.studio.port > 65535)) errors.push("studio.port must be an integer from 1 to 65535"); + } return errors; } diff --git a/engine/studio.mjs b/engine/studio.mjs new file mode 100644 index 0000000..b7ef53d --- /dev/null +++ b/engine/studio.mjs @@ -0,0 +1,330 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Mark Pickering and PICKBITS LLC. Part of PickBits Weaver. +import { createServer } from "node:http"; +import { spawn, spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { approveChapter, approvalHistory, describePendingChanges, pendingChanges, rejectChapter, undoApproval } from "./changes.mjs"; +import { loadCharacters, characterKebabCase } from "./characters.mjs"; +import { runDoctor } from "./doctor.mjs"; +import { collectFindings } from "./findings.mjs"; +import { GENERATOR } from "./identity.mjs"; +import { loadScenes } from "./manuscript.mjs"; +import { readProject } from "./project.mjs"; +import { parseEscapedProse, readerText } from "./prose.mjs"; +import { readRulings, saveRuling } from "./rulings.mjs"; +import { acceptNarrativeState, narrativeStateStatus, readFactsLedger, statePath } from "./state.mjs"; +import { attributeDialogue } from "./voice.mjs"; + +const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const CLI = join(PACKAGE_ROOT, "scripts", "weaver.mjs"); +const ASSETS = new Map([ + ["/studio.css", ["text/css; charset=utf-8", readFileSync(join(PACKAGE_ROOT, "studio", "studio.css"))]], + ["/studio.js", ["text/javascript; charset=utf-8", readFileSync(join(PACKAGE_ROOT, "studio", "studio.js"))]] +]); +const NAV = [["/", "Home"], ["/book", "Book"], ["/characters", "Characters"], ["/places", "Places"], ["/threads", "Threads"], ["/findings", "Findings"], ["/changes", "Changes"], ["/history", "History"], ["/jobs", "Jobs"]]; +const esc = (value) => String(value ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); +const chapterSlug = (number) => `chapter-${String(number).padStart(2, "0")}`; +const sceneSlug = (number) => `scene-${String(number).padStart(2, "0")}`; +const chapterHref = (number) => `/book/${chapterSlug(number)}`; +const sceneHref = (scene) => `${chapterHref(scene.chapter)}/${sceneSlug(scene.scene)}`; +const paragraphs = (prose) => prose.split(/\n\s*\n/u).filter((part) => part.trim()); +const list = (items) => items.length ? `
    ${items.map((item) => `
  • ${item}
  • `).join("")}
` : "

None yet.

"; +const localMessage = (message, root) => String(message || "Something went wrong.").replaceAll(root, "the book folder").replace(/[A-Za-z]:\\[^\s:]+/gu, "a local file"); + +function gitSubjects(root) { + const result = spawnSync("git", ["-C", root, "log", "--reverse", "--format=%s"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); + return result.status === 0 ? result.stdout.split(/\r?\n/u).filter(Boolean) : []; +} + +export function approvalProgress(root, scenes) { + const subjects = gitSubjects(root); + let baseline = -1; + subjects.forEach((subject, index) => { if (/^(?:Import original manuscript|Initialize book)/u.test(subject)) baseline = index; }); + const approved = new Set(); + for (const subject of subjects.slice(baseline + 1)) { + const match = /^Approve chapter (\d+)(?::|$)/u.exec(subject); + if (match) approved.add(Number(match[1])); + } + return { approved, total: new Set(scenes.map((scene) => scene.chapter)).size }; +} + +function proseHtml(value, characters) { + const aliases = characters.flatMap((character) => [character.name, ...character.aliases].map((name) => ({ name, id: character.id }))) + .sort((a, b) => b.name.length - a.name.length); + const output = []; + for (const token of parseEscapedProse(value)) { + const text = token.text; + const pieces = []; + let cursor = 0; + const matches = []; + for (const alias of aliases) { + const pattern = new RegExp(`(? a.index - b.index || b.length - a.length); + for (const match of matches) { + if (match.index < cursor) continue; + pieces.push(esc(text.slice(cursor, match.index))); + pieces.push(`${esc(text.slice(match.index, match.index + match.length))}`); + cursor = match.index + match.length; + } + pieces.push(esc(text.slice(cursor))); + const html = pieces.join("").replaceAll("\n", "
"); + output.push(token.italic ? `${html}` : html); + } + return output.join(""); +} + +function sceneReader(scene, characters, findings) { + return `
${paragraphs(scene.prose).map((part, index) => { + const relevant = findings.filter((item) => item.scene_id === scene.id && item.paragraph === index + 1); + return `

¶${index + 1} ${proseHtml(part, characters)}${relevant.length ? ` ● ${relevant.length}` : ""}

`; + }).join("")}
`; +} + +function statusChips(chapter, pending, findings, approved) { + const chips = []; + if (approved.has(chapter)) chips.push("approved"); + if (pending.chapters.some((item) => item.chapter === chapter)) chips.push("changes waiting"); + const queue = findings.chapters.find((item) => item.chapter === chapter); + if (queue?.findings.length) chips.push("open findings"); + if (queue?.stale_reviews.length) chips.push("review out of date"); + return chips.map((chip) => `${chip}`).join(" "); +} + +function placeGroups(ledger) { + const places = new Map(); + for (const [subject, facts] of Object.entries(ledger.subjects || {})) for (const fact of facts) { + if (fact.kind !== "location") continue; + const slug = characterKebabCase(String(fact.value).toLocaleLowerCase().replace(/\s+/gu, " ").trim()); + if (!places.has(slug)) places.set(slug, { value: fact.value, entries: [] }); + places.get(slug).entries.push({ ...fact, subject }); + } + return places; +} + +function threadGroups(root, project, scenes) { + const threads = new Map(); + for (const scene of scenes) { + const path = statePath(root, project, scene.id); + if (!existsSync(path)) continue; + const text = readFileSync(path, "utf8"); + const section = text.match(/^## Active threads\s*\n([\s\S]*?)(?=^## |$(?![\s\S]))/mu)?.[1] || ""; + for (const line of section.split(/\r?\n/u)) { + const bullet = /^\s*[-*]\s+(.+)$/u.exec(line); + if (!bullet || /^\s*(?:none|no active threads|—|-)\s*\.?$/iu.test(bullet[1])) continue; + const statusMatch = /^(added|advanced|resolved)\s*:\s*(.+)$/iu.exec(bullet[1]); + const status = statusMatch ? statusMatch[1].toLocaleLowerCase() : "advanced"; + const value = (statusMatch ? statusMatch[2] : bullet[1]).trim(); + const slug = characterKebabCase(value.toLocaleLowerCase().replace(/\s+/gu, " ")); + if (!threads.has(slug)) threads.set(slug, { value, entries: [] }); + threads.get(slug).entries.push({ scene, status }); + } + } + return threads; +} + +function findingCard(item, { note = false } = {}) { + const quote = item.quote || item.excerpt || item.line || ""; + return ``; +} + +function diffWords(before, after) { + const a = readerText(before).split(/(\s+)/u), b = readerText(after).split(/(\s+)/u); + let start = 0, end = 0; + while (start < a.length && start < b.length && a[start] === b[start]) start++; + while (end < a.length - start && end < b.length - start && a[a.length - 1 - end] === b[b.length - 1 - end]) end++; + const prefix = esc(a.slice(0, start).join("")), suffix = esc(end ? a.slice(-end).join("") : ""); + return { before: `${prefix}${esc(a.slice(start, a.length - end).join(""))}${suffix}`, after: `${prefix}${esc(b.slice(start, b.length - end).join(""))}${suffix}` }; +} + +function changedParagraphs(scene, styleFindings) { + const before = paragraphs(scene.beforeProse), after = paragraphs(scene.afterProse); + const rows = []; + for (let index = 0; index < Math.max(before.length, after.length); index++) { + if (before[index] === after[index]) continue; + const diff = diffWords(before[index] || "", after[index] || ""); + const style = styleFindings.filter((item) => item.paragraph === index + 1); + rows.push(`

Scene ${esc(scene.id)}, ¶${index + 1}

Approved text

${diff.before}

New text

${diff.after}

${style.length ? list(style.map((item) => `${esc(item.source || item.rule_id)}: ${esc(item.issue || item.message)}`)) : ""}
`); + } + return rows.join(""); +} + +function pageBody(path, root, project, job) { + const scenes = loadScenes(root, project), characters = loadCharacters(root).characters; + const findings = collectFindings(root, project, { all: true }); + const pending = describePendingChanges(root, project); + const progress = approvalProgress(root, scenes); + const chapterNumbers = [...new Set(scenes.map((scene) => scene.chapter))]; + if (path === "/") { + const toFix = findings.findings.filter((item) => item.ruling === "author wants this fixed").length; + return `

${esc(project.title)}

Progress

${progress.approved.size} of ${progress.total} chapters approved since import

Open findings: ${findings.findings.length - toFix} to decide, ${toFix} to fix

Read the book · Review changes

`; + } + if (path === "/book") return `

Book

${chapterNumbers.map((number) => `

Chapter ${number}${project.chapters?.[number] ? `: ${esc(project.chapters[number])}` : ""}

${statusChips(number, pending, findings, progress.approved)}

${list(scenes.filter((scene) => scene.chapter === number).map((scene) => `Scene ${scene.scene}`))}
`).join("")}`; + const sceneRoute = /^\/book\/chapter-(\d+)\/scene-(\d+)$/u.exec(path); + if (sceneRoute) { + const scene = scenes.find((item) => item.chapter === Number(sceneRoute[1]) && item.scene === Number(sceneRoute[2])); + if (!scene) return null; + return `

Chapter ${scene.chapter}

Scene ${scene.scene}

${sceneReader(scene, characters, findings.findings)}`; + } + const chapterRoute = /^\/book\/chapter-(\d+)$/u.exec(path); + if (chapterRoute) { + const number = Number(chapterRoute[1]), selected = scenes.filter((scene) => scene.chapter === number); + if (!selected.length) return null; + return `

Chapter ${number}${project.chapters?.[number] ? `: ${esc(project.chapters[number])}` : ""}

${statusChips(number, pending, findings, progress.approved)}

${selected.map((scene) => `

Scene ${scene.scene}

${sceneReader(scene, characters, findings.findings)}
`).join("")}`; + } + if (path === "/characters") return `

Characters

${list(characters.map((item) => `${esc(item.name)}`))}`; + const characterRoute = /^\/characters\/([a-z0-9]+(?:-[a-z0-9]+)*)$/u.exec(path); + if (characterRoute) { + const character = characters.find((item) => item.id === characterRoute[1]); + if (!character) return null; + const profilePath = join(root, "world-bible", "voices", `${character.id}.json`); + const profile = existsSync(profilePath) ? JSON.parse(readFileSync(profilePath, "utf8")) : null; + const facts = readFactsLedger(root).subjects?.[character.id] || []; + const speaking = scenes.filter((scene) => attributeDialogue(scene, characters).some((line) => line.speaker === character.id)); + return `

${esc(character.name)}

Aliases: ${character.aliases.length ? character.aliases.map(esc).join(", ") : "none"}

Voice profile

${profile?.enough_evidence ? "Enough evidence" : "Not enough evidence yet"}${profile ? ` (${profile.lines} lines, ${profile.words} words)` : ""}

${list((profile?.evidence || []).map((line) => `“${esc(line.text)}” Scene ${esc(line.scene_id)}, ¶${line.paragraph}`))}

Facts timeline

${list(facts.map((fact) => `Scene ${esc(fact.scene_id)} → ${esc(fact.kind)} → ${esc(fact.value)} → “${esc(fact.quote)}”`))}

Scenes they speak in

${list(speaking.map((scene) => `Scene ${scene.id}`))}`; + } + const places = placeGroups(readFactsLedger(root)); + if (path === "/places") return `

Places

${list([...places].map(([slug, place]) => `${esc(place.value)}`))}`; + const placeRoute = /^\/places\/([a-z0-9]+(?:-[a-z0-9]+)*)$/u.exec(path); + if (placeRoute) { + const place = places.get(placeRoute[1]); + if (!place) return null; + return `

${esc(place.value)}

Who is there

${list(place.entries.map((entry) => { + const character = characters.find((item) => item.id === entry.subject); + return `${character ? `${esc(character.name)}` : esc(entry.subject)} in scene ${esc(entry.scene_id)} · “${esc(entry.quote)}”`; + }))}`; + } + if (path === "/threads") return `

Threads

${[...threadGroups(root, project, scenes)].map(([slug, thread]) => `

${esc(thread.value)}

${list(thread.entries.map(({ scene, status }) => `${esc(status)} in scene ${esc(scene.id)}`))}
`).join("") || "

No active threads recorded yet.

"}`; + if (path === "/findings") return `

Findings

${findings.chapters.map((chapter) => `

Chapter ${chapter.chapter}

${chapter.stale_reviews.length ? `

${chapter.stale_reviews.map(esc).join(", ")} review out of date

` : ""}${[["Mechanical: style rules", chapter.findings.filter((item) => item.kind === "rule")], ["Voice", chapter.findings.filter((item) => item.kind === "voice")], ["Judgment: triaged", chapter.findings.filter((item) => item.kind === "judgment")]].map(([heading, items]) => `

${heading}

${items.length ? items.map((item) => findingCard(item)).join("") : "

None.

"}`).join("")}
Show notes (${chapter.note_count})${chapter.notes.map((item) => findingCard(item, { note: true })).join("") || "

No notes.

"}
`).join("")}`; + if (path === "/changes") { + const raw = pendingChanges(root, project); + return `

Changes waiting

${pending.chapters.length ? pending.chapters.map((chapter) => `

Chapter ${chapter.chapter}

${chapter.scenes_changed} scenes changed · ${chapter.blocking} blocking style findings · ${chapter.warnings} warnings

${raw.chapters.find((item) => item.chapter === chapter.chapter).scenes.map((scene) => changedParagraphs(scene, chapter.scenes.find((item) => item.id === scene.id)?.findings || [])).join("")}
`).join("") : "

No changes are waiting.

"}`; + } + if (path === "/history") { + const approvals = approvalHistory(root); + const rulings = readRulings(root).entries.map((item) => ({ type: "ruling", date: item.ruled_at, note: `${item.decision} ${item.finding_id}${item.note ? `: ${item.note}` : ""}` })); + const events = [...approvals, ...rulings].sort((a, b) => String(b.date).localeCompare(String(a.date))); + return `

History

${list(events.map((event) => ` · ${esc(event.type)}${event.chapter ? ` · Chapter ${event.chapter}` : ""}${event.note ? ` · ${esc(event.note)}` : ""}`))}`; + } + if (path === "/jobs") { + const state = narrativeStateStatus(root, project); + return `

Jobs

Run a chapter review or build story-so-far state through the configured AI host.

Current job

${job ? `

${esc(job.kind)} · ${esc(job.state)} · started ${esc(job.started_at)}

${esc(job.log_tail.join("\n"))}
` : "

No job has started.

"}

Story state to review

${state.current} current, ${state.stale} waiting for acceptance or refresh.

${scenes.map((scene) => { const record = state.records.find((item) => item.scene === scene.id); const file = statePath(root, project, scene.id); return `
Scene ${esc(scene.id)} · ${record?.current ? "current" : esc(record?.reason || "missing")}${existsSync(file) ? `
${esc(readFileSync(file, "utf8"))}
` : "

No story-state record yet.

"}
`; }).join("")}

After reading the records, accept the story state explicitly. An edited scene makes it and all later scenes stale again.

`; + } + return null; +} + +function layout(body, root, project) { + const doctor = runDoctor({ bookRoot: root }); + const first = doctor.blocking[0]; + const integrity = doctor.checks.find((item) => item.id === "install-integrity")?.status === "pass"; + return `${esc(project.title)} · Weaver Studio
Weaver Studio
${body}

Doctor

Supported setup: ${doctor.ok ? "yes" : "no"}${first ? ` · ${esc(first.problem)}. ${esc(first.why)} ${esc(first.fix)}` : ""}

Weaver install unchanged: ${integrity ? "yes" : "no"}

`; +} + +function send(response, status, type, body) { + response.writeHead(status, { "content-type": type, "cache-control": "no-store", "x-content-type-options": "nosniff", "content-security-policy": "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'", "referrer-policy": "no-referrer" }); + response.end(body); +} + +function json(response, status, value) { send(response, status, "application/json; charset=utf-8", JSON.stringify(value)); } + +function validPost(request, port) { + const allowed = new Set([`127.0.0.1:${port}`, `localhost:${port}`]); + if (request.headers["x-weaver-studio"] !== "1" || !allowed.has(request.headers.host)) return false; + if (!request.headers.origin) return true; + try { const origin = new URL(request.headers.origin); return origin.protocol === "http:" && allowed.has(origin.host) && origin.pathname === "/"; } + catch { return false; } +} + +async function requestJson(request) { + let text = ""; + for await (const chunk of request) { + text += chunk; + if (text.length > 8192) throw new Error("Request is too large."); + } + try { return JSON.parse(text || "{}"); } catch { throw new Error("The request must contain valid JSON."); } +} + +export async function startStudio({ root = process.cwd(), port = null } = {}) { + root = resolve(root); + const project = readProject(root); + const selectedPort = port === null ? project.studio?.port ?? 4187 : Number(port); + if (!Number.isInteger(selectedPort) || selectedPort < 0 || selectedPort > 65535) throw new Error("Studio port must be an integer from 0 to 65535."); + let currentJob = null; + let boundPort = selectedPort; + const server = createServer(async (request, response) => { + const raw = request.url || ""; + if (/(?:^|[\/\\])\.\.(?:[\/\\]|$)/u.test(raw) || /%2e|%2f|%5c|\\/iu.test(raw)) { send(response, 404, "text/plain; charset=utf-8", "Page not found."); return; } + let path; + try { path = new URL(raw, `http://127.0.0.1:${boundPort}`).pathname; } catch { send(response, 404, "text/plain; charset=utf-8", "Page not found."); return; } + if (request.method === "GET") { + if (path === "/api/health") { json(response, 200, { service: "pickbits-weaver-studio", generator: GENERATOR, book: project.project_id }); return; } + if (path === "/api/jobs/current") { json(response, 200, currentJob ? { kind: currentJob.kind, started_at: currentJob.started_at, state: currentJob.state, log_tail: currentJob.log_tail.slice(-40) } : { kind: null, started_at: null, state: null, log_tail: [] }); return; } + if (ASSETS.has(path)) { const [type, data] = ASSETS.get(path); send(response, 200, type, data); return; } + try { const liveProject = readProject(root); const body = pageBody(path, root, liveProject, currentJob); if (body !== null) send(response, 200, "text/html; charset=utf-8", layout(body, root, liveProject)); else send(response, 404, "text/plain; charset=utf-8", "Page not found."); } + catch (error) { send(response, 500, "text/plain; charset=utf-8", localMessage(error.message, root)); } + return; + } + if (request.method !== "POST" || !new Set(["/api/rulings", "/api/approve", "/api/reject", "/api/undo", "/api/jobs", "/api/state/accept"]).has(path)) { send(response, 404, "text/plain; charset=utf-8", "Page not found."); return; } + if (!validPost(request, boundPort)) { json(response, 403, { error: "This action is only available from the local studio page." }); return; } + try { + const doctor = runDoctor({ bookRoot: root }); + if (!doctor.ok) { const first = doctor.blocking[0]; json(response, 409, { error: `${first.problem}. ${first.why} ${first.fix}` }); return; } + const data = await requestJson(request); + const liveProject = readProject(root); + if (path === "/api/rulings") { + const finding = collectFindings(root, liveProject, { all: true }); + const selected = [...finding.findings, ...finding.notes].find((item) => item.id === data.id); + if (!selected) throw new Error(`unknown finding id: ${data.id}`); + const result = saveRuling(root, liveProject, selected, data.decision, data.note || ""); + json(response, 200, { message: `Ruling ${result.decision} saved.` }); + } else if (path === "/api/approve") { + const result = approveChapter(root, liveProject, data.chapter, data.note || ""); + json(response, 200, { message: `Chapter ${result.chapter} approved.` }); + } else if (path === "/api/reject") { + const result = rejectChapter(root, liveProject, data.chapter); + json(response, 200, { message: `Chapter ${result.chapter} rejected. The rejected text was saved for recovery.` }); + } else if (path === "/api/undo") { + undoApproval(root); + json(response, 200, { message: "The most recent approval was reversed." }); + } else if (path === "/api/state/accept") { + const result = acceptNarrativeState(root, liveProject); + json(response, 200, { message: `Story state accepted: ${result.current} current scenes.` }); + } else { + if (currentJob?.state === "running") { json(response, 409, { error: "A job is already running. Wait for it to finish." }); return; } + const kind = data.kind, chapter = Number(data.chapter); + if (kind !== "bootstrap" && kind !== "review") throw new Error("Choose review or bootstrap."); + if (kind === "review" && (!Number.isInteger(chapter) || !loadScenes(root, liveProject).some((scene) => scene.chapter === chapter))) throw new Error("Choose an existing chapter."); + const args = kind === "review" ? [CLI, "review", "--chapter", String(chapter), "--run", "--root", root] : [CLI, "state", "bootstrap", "--run", "--root", root]; + const child = spawn(process.execPath, args, { cwd: root, env: { ...process.env }, stdio: ["ignore", "pipe", "pipe"] }); + currentJob = { kind, started_at: new Date().toISOString(), state: "running", log_tail: [], child }; + const job = currentJob; + const log = (chunk) => { for (const line of chunk.toString("utf8").split(/\r?\n/u)) if (line) job.log_tail.push(localMessage(line, root)); job.log_tail = job.log_tail.slice(-40); }; + child.stdout.on("data", log); child.stderr.on("data", log); + child.on("error", (error) => { log(`Could not start job: ${error.message}`); job.state = "failed"; }); + child.on("close", (code) => { job.state = code === 0 ? "done" : "failed"; }); + json(response, 202, { message: `${kind === "review" ? `Chapter ${chapter} review` : "State bootstrap"} started.` }); + } + } catch (error) { json(response, 400, { error: localMessage(error.message, root) }); } + }); + function stopJob() { + if (currentJob?.state !== "running") return; + if (process.platform === "win32" && currentJob.child.pid) { + spawnSync("taskkill", ["/PID", String(currentJob.child.pid), "/T", "/F"], { stdio: "ignore" }); + } else currentJob.child.kill(); + } + const shutdown = () => { stopJob(); server.close(); }; + try { + await new Promise((ready, reject) => { server.once("error", reject); server.listen(selectedPort, "127.0.0.1", ready); }); + } catch (error) { + if (error.code === "EADDRINUSE") throw new Error(`Port ${selectedPort} is already in use. Try weaver studio --port another-number.`); + throw error; + } + boundPort = server.address().port; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + server.on("close", () => { stopJob(); process.off("SIGINT", shutdown); process.off("SIGTERM", shutdown); }); + return server; +} diff --git a/integrity.json b/integrity.json index 3550121..ce07efb 100644 --- a/integrity.json +++ b/integrity.json @@ -5,7 +5,7 @@ "files": { "LICENSE": "3954ad2ee7d56cfda60326c71dcfd3d7edd79a44727f51b5ca5e4b7c33b8c6d6", "NOTICE": "83abe7f22edf5a06f7b4814e8b4b4b124e1b612bd81dd4c6542c0b119fab4976", - "README.md": "1f5422bf2e7c6ad68f43448faf9fe3ac6944bdc89d92ff4900291aea4cb93ad8", + "README.md": "238ce5845f8e604becc6b36036555232ab778fd97ce02ceb6d088ff8e5abcb10", "SECURITY.md": "5c9c72ffaa8dfdc744360ea666d2082c68bba2479c61d13810bbd595498afe2b", "contracts/characters.schema.json": "f9bd6fcc9c2ad363fd805d8d5209c9a292204b0beaf9035a4b6ceadacfd36c7b", "contracts/facts-ledger.schema.json": "109ca6f8e761fe3aa1ac753b7ff4ec41cc59d14c98f49c8bce46d21e54ec608d", @@ -13,7 +13,7 @@ "contracts/import.schema.json": "68a9be97050a3e58bacbc984c9e1050d2b0b1db72066af96e0a5fb4019fe9e85", "contracts/integrity.schema.json": "1a8cb4b28748b0f2bfeffc4d0210f5f422d1b69cfed3a386ff60c5de0bdd8d08", "contracts/loop-manifest.schema.json": "edb1022ab2434ea24890fb0a14ad7daee7dda190968c60e9ab306250412e6258", - "contracts/project.schema.json": "49cbfdc8539bef7ab45b885c543aa5bff31558045f16382cb87b19ce6546287b", + "contracts/project.schema.json": "76fd7d8c9a2cf225f7757e7b388e01399fb954d7425eed93012eff9b647262cc", "contracts/provenance.schema.json": "bea26ddbcbfef8c046b0707e9a2f6dff8913cd9531b6267d3f374f821919d107", "contracts/release-manifest.schema.json": "65175761fc3095419ceb01b28457e6615c57c6eff4baceb2867128da73303db4", "contracts/review.schema.json": "6c4ad08c26bff0b4193d4104d0d78d0550365bf716f5627286c7f1305ce0aad3", @@ -24,7 +24,7 @@ "docs/HANDOFF.md": "6e37433f60c54989abb1171c6989ed9c0cbab83be0d414d3d36ff6e985a9ee8c", "docs/LOOP.md": "bf3098c9afbcac6254d1682e87ad6b1e6ed798f0cb5e22a5f76a8fd3941fe8d5", "docs/ORIGIN.md": "d66e5d9a0c3e2c05a983c790dd616fc051d2666417ae0e1fd697478e5ff4cda8", - "docs/WORKFLOW.md": "ddfe169e6563d564b7159781ae345dac74c30c7280c9514d12bb967e4ae333c8", + "docs/WORKFLOW.md": "f7c0c07ece3343377c11918b13e4d8511774fa9c8092364610a956934f61fb44", "engine/changes.mjs": "8dd233b8d974d48e04701c5d7c18ed0672ac68d2bff495c95e94285ce983d326", "engine/characters.mjs": "1d8cc8e621e1d2b42ab81ff787be1df0648c5e852e4256dccc8dc2f7d4f0e166", "engine/doctor.mjs": "39ee2a515be1743900b7c2360415746743c2ff434e8549362d36a159bf1da15a", @@ -38,23 +38,26 @@ "engine/integrity.mjs": "5c9ae6d22bbd28515887678e0e1ac214edd77ef2d2312fab63eda52407301e9f", "engine/manuscript.mjs": "eb03ce6d453b9b6d3a7e44b5d1a8d40eb88dcb86ae3963aaf0b4597345b14a59", "engine/packets.mjs": "8245677d25d9a018d8fb82a3d1c37858765ccb7084d97b250df76c323d5f148c", - "engine/project.mjs": "d89dd6087d1802c0767a2a1d676a8c1ca290610a54eaf6dd17ca31e98c95f8d8", + "engine/project.mjs": "3d67c44a4e9747ecf3c5b0a905bc143654c2790fbe8170f7ab434b4855309cab", "engine/prose.mjs": "e6c75d599abc9fee5fd494c3cd56798135ae21ee15eb9f16d5c83b7c1c877fcd", "engine/release.mjs": "609b41f7fc06c2f2fbd2b515bcdf3fab72dd7bfc255107155ab2f1e08167eb0a", "engine/review.mjs": "c5e31bfff932b7edd10b1b97fd527428fede4c23b175e0e9f4c05c7bf94831c3", "engine/rules.mjs": "9e663c68825a9e8a8ab3a7d10293085d58c001a7c75edded02b8ea7e382ea8e2", "engine/rulings.mjs": "4373a468fb16c63f84aff00d2066b85a42d6d77404289d473b623100a019e74e", "engine/state.mjs": "de9c2a19393bd886454ad0e62c0cc91940d3fe9bbea7adedb384dd9143d41024", + "engine/studio.mjs": "1399c77f6963b4bbddba2acce90ce519ed260078ba0a4864c2173724979fd43c", "engine/voice.mjs": "a88aff749cc190511386ca687b9a50328a5cd9e07aed8919245340afdceb6258", "loop.manifest.json": "340b33bb8b03c60ce4997b9182baa18435fc3d6716d660ea8d15ae466663abc0", - "package.json": "d1b143a5559d860707d5085265a175e64ef07af09cf12411634e3522c0cb39be", + "package.json": "dedad6adce4eca233ac650e9f5ffdc74bb51252b1f7de0ee52fc0a08dd92dd9e", "prompts/architect.md": "51ed331812edfb4801fd60948293eb40f960a2735d949376d80488dc1c3724e4", "prompts/reviewers/continuity.md": "b3e77bcfcfb8433e71ba7c5028962d5ca9882989c05eadfa204effc54573273b", "prompts/reviewers/critic.md": "9a921c7d05a0656d67a8f31f27e06af87842afc223b8646c64db6de291897f7d", "prompts/reviewers/style.md": "4479a4cb676627ca1ff4f7116f15e0cdc1f50fe2763db1388371d80570c9cdd4", "prompts/state-extractor.md": "b2d92d014892f2a1c201003b1cd16cf9d0adcc5e8611f7d350eab612b8947cc5", "scripts/integrity.mjs": "78c0bcdf7b5858de517f02eb09db1febf6d33eec787f09e7c24178f0cb263243", - "scripts/weaver.mjs": "b4ca234e4937b6ee4457c06cd877466112c1b9efc7bb3cfbffefe1ab78c792fb", + "scripts/weaver.mjs": "87000e2d30aa34d6844e1614e52ccd13a4252d3d2c0226d33b2a911a548b136f", + "studio/studio.css": "5b43ae992811dcc1c9b48bed2e2d8138dfa86f59f9ac0a314d892f06d032628b", + "studio/studio.js": "e06181583916170fc36818d218febd190e9f2b7708af2a7bbf5238183252399e", "templates/book/.claude/settings.json": "9fb07cc94b12c3cce506c1bd2706c351dca83309f1db2bb25f5897cec1735fe2", "templates/book/AGENTS.md": "6bb83885ca4498cfd5e994f2b36cd1688db04d6174905b0a813c5fbedd7c4a10", "templates/book/README.md": "c33756ab69072410e4583f44daf21d08fa6c576816c5ed240284850d6e4b2659", diff --git a/package.json b/package.json index 2f41cb2..123c4e1 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "engine/", "prompts/", "scripts/", + "studio/", "templates/", "integrity.json", "README.md", diff --git a/scripts/weaver.mjs b/scripts/weaver.mjs index 0bd46b3..519c3c7 100644 --- a/scripts/weaver.mjs +++ b/scripts/weaver.mjs @@ -20,6 +20,7 @@ import { buildVoiceProfiles, runVoiceCheck } from "../engine/voice.mjs"; import { collectFindings } from "../engine/findings.mjs"; import { runReview, submitReviewPacket } from "../engine/review.mjs"; import { rulingStatuses, saveRuling } from "../engine/rulings.mjs"; +import { startStudio } from "../engine/studio.mjs"; const argv = process.argv.slice(2); const command = argv.shift() || "help"; @@ -173,6 +174,11 @@ try { first_stale: state.first_stale } }); + } else if (command === "studio") { + const suppliedPort = option("--port"); + if (suppliedPort !== null && (!/^\d+$/u.test(suppliedPort) || Number(suppliedPort) > 65535)) throw new Error("usage: weaver studio [--root directory] [--port N]"); + const server = await startStudio({ root, port: suppliedPort === null ? null : Number(suppliedPort) }); + print(`Weaver Studio is ready at http://127.0.0.1:${server.address().port}/`); } else if (command === "check") { warnAboutDoctor(root); const { project } = projectContext(); @@ -451,6 +457,7 @@ try { Usage: weaver init [--id book-id] [--title "Book Title"] [--empty] weaver doctor [--root directory] [--json] + weaver studio [--root directory] [--port N] weaver status [--root directory] weaver check [--root directory] weaver rules [--root directory] [--scene scene-id] [--json] @@ -483,6 +490,7 @@ Usage: Usage: weaver init [--id book-id] [--title "Book Title"] [--empty] weaver doctor [--root directory] [--json] + weaver studio [--root directory] [--port N] weaver status [--root directory] weaver check [--root directory] weaver rules [--root directory] [--scene scene-id] [--json] diff --git a/studio/studio.css b/studio/studio.css new file mode 100644 index 0000000..d2bbdd5 --- /dev/null +++ b/studio/studio.css @@ -0,0 +1,5 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2026 Mark Pickering and PICKBITS LLC. Part of PickBits Weaver. */ +:root{color-scheme:light dark;font:16px/1.55 system-ui,sans-serif;--page:#faf9f5;--ink:#242522;--muted:#5e615d;--line:#cccfc9;--card:#fff;--accent:#315b72;--focus:#b75d12} +@media(prefers-color-scheme:dark){:root{--page:#161b1e;--ink:#edf0eb;--muted:#b8c2c3;--line:#475258;--card:#222a2d;--accent:#9bd4ed;--focus:#f6bc75}} +*{box-sizing:border-box}body{margin:0;background:var(--page);color:var(--ink)}header,main,footer{max-width:70rem;margin:auto;padding:1rem}header{border-bottom:1px solid var(--line)}nav{display:flex;flex-wrap:wrap;gap:.35rem 1rem;margin-top:.5rem}a{color:var(--accent);text-underline-offset:.15em}nav a{padding:.3rem 0}main{min-height:55vh}h1{font-size:clamp(1.8rem,5vw,2.5rem);line-height:1.2}h2{margin-top:2rem;font-size:1.4rem}h3{font-size:1.1rem}h4{margin:.2rem 0}.byline{color:var(--muted)}section{margin:1.5rem 0}.reader-paragraph{max-width:43rem;font:1.12rem/1.8 Georgia,serif;margin:1.3rem 0}.paragraph-number{font:small system-ui,sans-serif;color:var(--muted);white-space:nowrap}.finding-marker{font:small system-ui,sans-serif}.chip{display:inline-block;border:1px solid var(--line);border-radius:1rem;padding:.1rem .55rem;font-size:.8rem;margin:.1rem}.card,.change-row{border:1px solid var(--line);border-radius:.55rem;background:var(--card);padding:1rem;margin:1rem 0}blockquote{border-left:3px solid var(--accent);margin:1rem 0;padding:.2rem .8rem;color:var(--muted)}button,input,select{font:inherit;border:1px solid var(--line);border-radius:.35rem;padding:.48rem .7rem;background:var(--card);color:var(--ink)}button{cursor:pointer}button:hover{border-color:var(--accent)}.actions{display:flex;flex-wrap:wrap;gap:.5rem;margin:1rem 0}label{display:block;margin:.5rem 0 .2rem}input{width:min(100%,30rem)}.columns{display:grid;grid-template-columns:1fr 1fr;gap:1rem}.columns>div{min-width:0;overflow-wrap:anywhere}del{background:#eec9c9;color:#2b1616}ins{background:#caecd4;color:#15351c;text-decoration:none}pre{white-space:pre-wrap;overflow-wrap:anywhere}.notice{color:var(--muted)}footer{border-top:1px solid var(--line);color:var(--muted);font-size:.9rem}footer h2{font-size:1rem;margin:.2rem 0}footer p{margin:.2rem 0}.skip{position:absolute;left:-9999px}.skip:focus{left:1rem;top:1rem;background:var(--card);padding:.5rem;z-index:2}:focus-visible{outline:3px solid var(--focus);outline-offset:3px}#action-message{max-width:70rem;margin:auto;padding:0 1rem;color:var(--accent)}@media(max-width:600px){header,main,footer{padding:.8rem}.columns{grid-template-columns:1fr}.reader-paragraph{font-size:1rem}nav{gap:.15rem .75rem}.card,.change-row{padding:.7rem}} diff --git a/studio/studio.js b/studio/studio.js new file mode 100644 index 0000000..09b01b5 --- /dev/null +++ b/studio/studio.js @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Mark Pickering and PICKBITS LLC. Part of PickBits Weaver. +const message = document.getElementById("action-message"); +async function refresh() { + const response = await fetch(location.pathname, { cache: "no-store" }); + if (!response.ok) return; + const next = new DOMParser().parseFromString(await response.text(), "text/html"); + document.getElementById("main").replaceWith(next.getElementById("main")); + document.querySelector("footer").replaceWith(next.querySelector("footer")); + if (location.pathname === "/jobs" && document.querySelector("[data-poll='1']")) schedulePoll(); +} +async function post(route, data) { + try { + const response = await fetch(route, { method: "POST", headers: { "Content-Type": "application/json", "X-Weaver-Studio": "1" }, body: JSON.stringify(data) }); + const result = await response.json(); + message.textContent = result.error || result.message || "The action finished."; + if (response.ok) await refresh(); + } catch { message.textContent = "The local studio did not respond. Reload the page and try again."; } + message.scrollIntoView({ block: "nearest" }); +} +document.addEventListener("click", (event) => { + const button = event.target.closest("button[data-action]"); + if (!button) return; + const action = button.dataset.action; + if (action === "confirm-undo") { document.getElementById("undo-confirm").hidden = false; button.hidden = true; return; } + if (action === "cancel-undo") { document.getElementById("undo-confirm").hidden = true; document.getElementById("undo-start").hidden = false; return; } + if (action === "ruling") post("/api/rulings", { id: button.dataset.id, decision: button.dataset.decision }); + if (action === "approve") post("/api/approve", { chapter: Number(button.dataset.chapter), note: document.getElementById(button.dataset.note).value }); + if (action === "reject") post("/api/reject", { chapter: Number(button.dataset.chapter) }); + if (action === "undo") post("/api/undo", {}); + if (action === "accept-state") post("/api/state/accept", {}); + if (action === "review-job") post("/api/jobs", { kind: "review", chapter: Number(document.getElementById("review-chapter").value) }); + if (action === "bootstrap-job") post("/api/jobs", { kind: "bootstrap" }); +}); +let pollTimer; +function schedulePoll() { + clearTimeout(pollTimer); + pollTimer = setTimeout(async () => { + try { + const response = await fetch("/api/jobs/current", { cache: "no-store" }); + if (response.ok) await refresh(); + } catch { message.textContent = "Job status is unavailable. Reload the page to reconnect."; } + }, 2000); +} +if (location.pathname === "/jobs" && document.querySelector("[data-poll='1']")) schedulePoll(); diff --git a/test/consumer-install.mjs b/test/consumer-install.mjs index 119f0d6..de5fc5e 100644 --- a/test/consumer-install.mjs +++ b/test/consumer-install.mjs @@ -5,7 +5,7 @@ import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSyn import { tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const sandbox = mkdtempSync(join(tmpdir(), "weaver-consumer-")); @@ -15,6 +15,7 @@ const repairBook = join(consumer, "repair-book"); const finishedSource = join(consumer, "finished.md"); const npmCli = process.env.npm_execpath; const env = { ...process.env, NPM_CONFIG_CACHE: join(sandbox, "npm-cache") }; +let studioChild; assert.ok(npmCli, "npm_execpath is required; run this check through npm"); function run(command, args, cwd) { @@ -105,6 +106,24 @@ try { assert.equal(doctor.checks.find((check) => check.id === "install-integrity").status, "pass"); assert.equal(doctor.ok, true); + studioChild = spawn(process.execPath, [join(consumer, "node_modules", "@pickbitsai", "weaver", "scripts", "weaver.mjs"), "studio", "--root", book, "--port", "0"], { cwd: consumer, env, stdio: ["ignore", "pipe", "pipe"] }); + const studioUrl = await new Promise((ready, reject) => { + let output = ""; + const timer = setTimeout(() => reject(new Error("installed studio did not start")), 15_000); + studioChild.stdout.on("data", (chunk) => { + output += chunk.toString(); + const match = output.match(/http:\/\/127\.0\.0\.1:\d+\//u); + if (match) { clearTimeout(timer); ready(match[0]); } + }); + studioChild.on("error", (error) => { clearTimeout(timer); reject(error); }); + studioChild.on("exit", (code) => { if (!output.includes("http://127.0.0.1:")) { clearTimeout(timer); reject(new Error(`installed studio exited ${code}`)); } }); + }); + const studioHealth = await fetch(new URL("api/health", studioUrl)); + assert.equal(studioHealth.status, 200); + assert.deepEqual(await studioHealth.json(), { service: "pickbits-weaver-studio", generator: "PickBits Weaver 0.3.0", book: "consumer-book" }); + studioChild.kill(); + studioChild = null; + const status = JSON.parse(weaver(["status", "--root", book])); assert.equal(status.project_id, "consumer-book"); assert.equal(status.scenes, 1); @@ -150,5 +169,6 @@ try { console.log("consumer smoke passed: packed, installed, initialized, checked, and released"); } finally { + studioChild?.kill(); rmSync(sandbox, { recursive: true, force: true }); } diff --git a/test/fixtures/fake-host.mjs b/test/fixtures/fake-host.mjs index eee2d14..041399a 100644 --- a/test/fixtures/fake-host.mjs +++ b/test/fixtures/fake-host.mjs @@ -10,6 +10,7 @@ const counterPath = ".weaver/fake-host-count"; const prompt = readFileSync(0, "utf8"); const review = prompt.match(/^# REVIEW packet chapter (\d+) lens (continuity|style|critic)/mu); if (review) { + if (mode === "review-delay" || mode === "studio") await new Promise((ready) => setTimeout(ready, 600)); const lens = review[2]; const paragraphs = [...prompt.matchAll(/^\[([0-9]+-[0-9]+) p(\d+)\] (.+)$/gmu)]; const first = paragraphs[0]; @@ -68,8 +69,8 @@ process.stdout.write([ "", "## Newly established", "", "- The scene establishes one reader fact.", "", "## Character deltas", "", "- No character delta.", "", - "## Active threads", "", "- The event remains active.", "", + "## Active threads", "", mode === "studio" ? "- Added: The lantern journey" : "- The event remains active.", "", "## Timeline and location", "", "- The scene continues in sequence.", "", "## Closing image", "", "- The reader is left with a changed pressure.", "", - "```json", JSON.stringify({ facts: [{ kind: "event", subject: "world", value: "the scene establishes one reader fact", quote }] }), "```", "" + "```json", JSON.stringify({ facts: [mode === "studio" ? { kind: "location", subject: "brask", value: "Eastern Gate", quote } : { kind: "event", subject: "world", value: "the scene establishes one reader fact", quote }] }), "```", "" ].join("\n")); diff --git a/test/studio.test.mjs b/test/studio.test.mjs new file mode 100644 index 0000000..3c6cac0 --- /dev/null +++ b/test/studio.test.mjs @@ -0,0 +1,142 @@ +// 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 { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { initializeProject } from "../engine/init.mjs"; +import { importBook } from "../engine/import.mjs"; +import { recordImportBaseline } from "../engine/changes.mjs"; +import { readProject } from "../engine/project.mjs"; +import { seedCharacters } from "../engine/characters.mjs"; +import { startStudio } from "../engine/studio.mjs"; + +const ROOT = resolve("."); +const HOST = join(ROOT, "test", "fixtures", "fake-host.mjs"); +const git = (root, ...args) => spawnSync("git", ["-C", root, ...args], { encoding: "utf8" }); +const scenePath = (root) => join(root, "manuscript", "chapter-01", "scene-01", "scene.md"); + +function book(mode = "review-triage") { + const root = mkdtempSync(join(tmpdir(), "weaver-studio-")); + initializeProject(root, { projectId: "studio-book", title: "Studio Book", empty: true }); + const source = join(root, "original.md"); + writeFileSync(source, "# Chapter 1\n\nBrask and the sergeant wait at the eastern gate before dawn arrives.\n\n* * *\n\nBrask said, \"Wren carries the lantern toward the courtyard.\"\n"); + const imported = importBook(root, readProject(root), source); + recordImportBaseline(root, imported.project, imported.record, imported.importPath); + seedCharacters(root, ["Brask", "Wren"]); + const registryPath = join(root, "world-bible", "characters.json"); + const registry = JSON.parse(readFileSync(registryPath, "utf8")); + registry.characters.find((item) => item.id === "brask").aliases = ["the sergeant"]; + writeFileSync(registryPath, `${JSON.stringify(registry, null, 2)}\n`); + const project = readProject(root); + project.host = { command: process.execPath, args: [HOST, mode], concurrency: 3, timeout_seconds: 10 }; + writeFileSync(join(root, "project.json"), `${JSON.stringify(project, null, 2)}\n`); + const rules = { version: 1, rules: [{ id: "gate-phrase", type: "phrase-list", severity: "warn", message: "Check gate phrasing.", phrases: ["eastern gate"] }] }; + writeFileSync(join(root, "quality", "rules.json"), `${JSON.stringify(rules, null, 2)}\n`); + assert.equal(git(root, "add", "-A").status, 0); + assert.equal(git(root, "commit", "-qm", "Set up studio fixture").status, 0); + return root; +} + +async function studio(root) { + const server = await startStudio({ root, port: 0 }); + assert.equal(server.address().address, "127.0.0.1"); + const base = `http://127.0.0.1:${server.address().port}`; + const get = (path) => fetch(`${base}${path}`); + const post = (path, data, headers = {}) => fetch(`${base}${path}`, { method: "POST", headers: { "Content-Type": "application/json", "X-Weaver-Studio": "1", Origin: base, ...headers }, body: JSON.stringify(data) }); + return { server, base, get, post }; +} + +function close(server) { return new Promise((ready) => server.close(ready)); } + +test("health, local binding, page routes, traversal, and POST protection", async () => { + const root = book(); + const app = await studio(root); + try { + const health = await app.get("/api/health"); + assert.equal(health.status, 200); + assert.deepEqual(await health.json(), { service: "pickbits-weaver-studio", generator: "PickBits Weaver 0.3.0", book: "studio-book" }); + for (const route of ["/", "/book", "/book/chapter-01", "/book/chapter-01/scene-01", "/characters", "/characters/brask", "/places", "/threads", "/findings", "/changes", "/history", "/jobs"]) { + const response = await app.get(route); + assert.equal(response.status, 200, route); + const html = await response.text(); + assert.match(html, /Supported setup: yes/u); + assert.match(html, /Weaver install unchanged: yes/u); + assert.doesNotMatch(html, new RegExp(root.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"))); + } + const scene = await (await app.get("/book/chapter-01/scene-01")).text(); + assert.match(scene, /href="\/characters\/brask">Brask<\/a>/u); + assert.match(scene, /href="\/characters\/brask">the sergeant<\/a>/u); + assert.equal((await app.get("/missing")).status, 404); + assert.equal((await app.get("/%2e%2e/project.json")).status, 404); + const noHeader = await fetch(`${app.base}/api/approve`, { method: "POST", headers: { Origin: app.base }, body: "{}" }); + assert.equal(noHeader.status, 403); + assert.equal((await app.post("/api/approve", {}, { Origin: "http://foreign.example" })).status, 403); + } finally { await close(app.server); rmSync(root, { recursive: true, force: true }); } +}); + +test("approve, reject, undo, progress, and ruling lapse use engine decisions", async () => { + const root = book(); + const app = await studio(root); + try { + const initial = await (await app.get("/findings")).text(); + const finding = initial.match(/id="finding-([a-f0-9]{10})"/u)?.[1]; + assert.ok(finding); + assert.equal((await app.post("/api/rulings", { id: finding, decision: "allow" })).status, 200); + assert.doesNotMatch(await (await app.get("/findings")).text(), new RegExp(`id="finding-${finding}"`, "u")); + const path = scenePath(root); + const baseline = readFileSync(path, "utf8"); + writeFileSync(path, baseline.replace("the eastern gate", "the quiet eastern gate")); + assert.match(await (await app.get("/findings")).text(), /an earlier ruling no longer applies because the passage changed/u); + const before = git(root, "rev-list", "--count", "HEAD").stdout.trim(); + assert.equal((await app.post("/api/approve", { chapter: 1, note: "Reviewed" })).status, 200); + assert.equal(Number(git(root, "rev-list", "--count", "HEAD").stdout.trim()), Number(before) + 1); + assert.deepEqual(git(root, "show", "--format=", "--name-only", "HEAD").stdout.trim().split(/\r?\n/u), ["manuscript/chapter-01/scene-01/scene.md"]); + assert.match(await (await app.get("/")).text(), /1 of 1 chapters approved since import/u); + assert.equal((await app.post("/api/undo", {})).status, 200); + assert.match(git(root, "log", "-1", "--format=%s").stdout, /Revert "Approve chapter 1/u); + writeFileSync(path, readFileSync(path, "utf8").replace("before dawn", "after dusk")); + assert.equal((await app.post("/api/reject", { chapter: 1 })).status, 200); + assert.equal(readFileSync(path, "utf8").replace(/\r\n/gu, "\n"), baseline.replace(/\r\n/gu, "\n")); + assert.equal(existsSync(join(root, ".weaver", "rejected")), true); + } finally { await close(app.server); rmSync(root, { recursive: true, force: true }); } +}); + +test("bootstrap facts and threads appear, and review job refuses overlap", async () => { + const root = book("studio"); + const app = await studio(root); + try { + const started = await app.post("/api/jobs", { kind: "review", chapter: 1 }); + assert.equal(started.status, 202); + assert.equal((await app.post("/api/jobs", { kind: "review", chapter: 1 })).status, 409); + let job; + for (let attempt = 0; attempt < 80; attempt++) { + job = await (await app.get("/api/jobs/current")).json(); + if (job.state !== "running") break; + await new Promise((ready) => setTimeout(ready, 100)); + } + assert.equal(job.state, "done", JSON.stringify(job)); + assert.equal(existsSync(join(root, "review", "chapter-01-continuity.json")), true); + assert.equal((await app.post("/api/jobs", { kind: "bootstrap" })).status, 202); + for (let attempt = 0; attempt < 80; attempt++) { + job = await (await app.get("/api/jobs/current")).json(); + if (job.state !== "running") break; + await new Promise((ready) => setTimeout(ready, 100)); + } + assert.equal(job.state, "done", JSON.stringify(job)); + const factsPath = join(root, "continuity", "facts.json"); + assert.equal(existsSync(factsPath), true); + const facts = JSON.parse(readFileSync(factsPath, "utf8")); + assert.equal(facts.subjects.brask[0].kind, "location"); + assert.match(await (await app.get("/places/eastern-gate")).text(), /Brask/u); + assert.match(await (await app.get("/threads")).text(), /The lantern journey/u); + assert.match(await (await app.get("/jobs")).text(), /Accept story state/u); + assert.equal((await app.post("/api/state/accept", {})).status, 200); + assert.match(await (await app.get("/jobs")).text(), /2 current, 0 waiting/u); + const path = scenePath(root); + writeFileSync(path, readFileSync(path, "utf8").replace("before dawn", "after dusk")); + for (const route of ["/", "/book", "/book/chapter-01", "/book/chapter-01/scene-01", "/characters/brask", "/places", "/places/eastern-gate", "/threads", "/findings", "/changes", "/history", "/jobs"]) assert.equal((await app.get(route)).status, 200, route); + } finally { await close(app.server); rmSync(root, { recursive: true, force: true }); } +});