Claude Skill

story-maintenance

This skill should be used when the user asks to validate, reindex, repair registries, check links, check continuity, count words, summarize a story project, import an existing manuscript, export a manuscript, run the story CLI, or perform deterministic maintenance on a Story Skil

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download danjdewhurst-story-skills-skills_story-maintenance-9a54e04.zip · 390 KB
Part of danjdewhurst/story-skills — 12 skills

Install

skills CLI npx skills add https://github.com/danjdewhurst/story-skills/tree/main/skills/story-maintenance
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install danjdewhurst-story-skills@llmmart
Git git clone https://github.com/danjdewhurst/story-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole danjdewhurst/story-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Story Maintenance

Overview

Run deterministic maintenance for Story Skills projects. Use the CLI for structure validation, registry rebuilds, word counts, link checks, continuity checks, project reports, next-action reports, pacing, clue, voice, and name checks, revision-pass tracking, Mermaid diagrams, schema migration, entity helpers, manuscript import, and manuscript export and builds. The creative skills still own story decisions; this skill handles mechanical consistency. It runs a check and reports what it found, and fixes mechanical problems such as broken references and stale registries; deciding what a finding means for the story, and revising the story to fix it, belongs to revision-continuity, plot-structure, voice-style, or genre-craft.

CLI Access

Prefer the first available command:

  1. story <command> - when the package bin is installed
  2. node scripts/story.js <command> - bundled fallback, resolving scripts/story.js relative to this skill folder
  3. node <checkout>/bin/story.js <command> - only when the user names a Story Skills repository checkout or you are working in one, where <checkout> is its path

Write the script in forms 2 and 3 as an absolute path and run the command from the folder you would run story from, so . and other relative paths keep their meaning. Use Node, not Bun or the story script in the checkout's package.json: Bun would load that folder's bunfig.toml (which can run code) and .env, and a package script runs from the checkout's root, so . would be the checkout, not the story project.

If none of these are available, perform the requested maintenance manually using the shared conventions in references/conventions.md.

Run the installed or bundled CLI in place. Do not copy scripts/story.js into the user's story project, and do not create project-local build scripts, generator scripts, or bulk writer scripts to generate story content. Story projects should remain markdown-first, plus explicitly requested exports such as dist/manuscript.md.

Commands

Run commands from the story project root, or pass the story path explicitly.

After any change to story files, run the maintenance block, in this order:

story reindex .
story wordcount . --write
story check .

reindex rebuilds the registries, wordcount --write updates chapter counts (and reindexes again), and check then runs validate, links, and continuity over the settled files. check exits 1 only on errors; warnings print but do not fail it (--strict makes them fail). Every skill ends its edits with this block, followed by any skill-specific checks such as story clues . or story pacing ..

For the full command set, run story --help (every command) or story <command> --help (its flags), or read references/commands.md, which gives an example of each.

The check commands' detailed rules live in references/continuity-checks.md, one section per command. Read the matching section before explaining or acting on a finding. The editing commands' detail (add, rename, move, remove, split, merge, import) lives in references/editing-commands.md, and each build format's in references/builds.md.

Use:

  • check at the end of an editing session, after reindex and wordcount --write, and before reporting the project clean: it runs validate, links, and continuity over one scan, lists each finding once, and exits 1 if any of them has an error (--strict also fails on warnings). Run the three separately when you want one check's findings
  • validate after initialization and at the end of any multi-file edit
  • reindex after adding, removing, renaming, or moving any entity file. It rebuilds the character, location, system, faction, artifact, arc, chapter, scene, question, promise, clue, and glossary registries. story add reindexes itself; a hand-written file does not
  • wordcount --write after writing or revising chapters
  • links after changing character relationships, notable locations, arc participants, or chapter references
  • continuity after drafting or revising a chapter, and whenever the user asks about contradictions, dead characters appearing, unfired setups, or stale state. It checks deaths and revivals, promise, question, and clue ordering, Chekhov gaps, casts and status: cut characters, continuity/state.md, prop custody, and clock and route plausibility; intentional exceptions go in continuity/exemptions.md. In a book whose chapters carry choices, read Branching books there too. Rules: references/continuity-checks.md (continuity).
  • compare after a revision pass, or when the user asks what changed since a draft: it needs exactly one of --ref (a git ref), --against (another copy of the project), or --snapshot (a snapshot saved with story snapshot <name>, for a project without git; --list shows them), and --anchor <label> maps review-copy paragraph labels to the current text. Rules: references/continuity-checks.md (compare).
  • snapshot --restore <name> only when the user asks to go back to a snapshot. It deletes every project markdown file the snapshot lacks, and removes (rmdir) the folders that leaves empty, so show them the --dry-run first and restore only with their approval. It saves the project as before-restore-<id>-<n> before changing anything and reindexes after; pass on the undo command it prints, then run story validate.
  • similarity when the user asks whether a passage echoes another text too closely; it is advisory, so report what it found, never a verdict. --against takes a file, folder, or git ref, and --snapshot a saved snapshot. Rules: references/continuity-checks.md (similarity).
  • progress when the user asks how far along the book is, whether they will make a deadline, or after a writing session: it reports words against story.md target-words, days left to deadline and words a day needed, chapter target-words, pace from progress.md, and, once a session is logged, today's words against daily-target-words, the current and longest streak (days outside writing-days never break it), and the words of the last four weeks (--weeks <n>, 1 to 52, for a longer or shorter history). For a serial with release-every (days, or months such as 1 month) and release-start in story.md (or a chapter release-date), progress and next print the next episode due, and warn release-undrafted when an episode due within release-warn-days days (default three), or past due, has no prose or no chapter yet; draft that episode first. Once story.md has status: complete, the cadence stops at the last chapter, so no episode past it is scheduled or warned about. --log records today's total there (--date YYYY-MM-DD to backfill); only log when the user keeps a log (the project has a progress.md) or asks for it
  • pacing when the user asks about pacing, sagging middles, or chapter endings, and after drafting or restructuring chapters: per-chapter words, scene outcomes, sequels, and hooks, with warnings for runs and outliers. Rules: references/continuity-checks.md (pacing).
  • clues for mysteries and any story with a clue ledger: a clue-by-chapter matrix with fair-play warnings. Rules: references/continuity-checks.md (clues).
  • voices when dialogue voices may blur or during a line pass: per-character dialogue fingerprints, with warnings for look-alike voices and voice-avoid/voice-words misses. Rules: references/continuity-checks.md (voices).
  • list <kind> when you need the files that match some frontmatter (the draft chapters, the scenes a character is in, the open questions) instead of reading every file: story list chapters --where status=draft --where pov=ilse --path '<project>'. key=value also matches a list that contains the value, key!=value is the opposite, key means set, and '!key' unset; repeated filters must all match. story list --query '<name>' --path '<project>' runs a query saved in story.md queries (a name, a kind, and where: [status=draft, pov=ilse]), and any --where adds to its filters. Add --json and read data.items[].file
  • --json when you need to read a result rather than show it to the user: validate, links, continuity, check, series, report, next, doctor, knowledge, context, list, progress, timeline, prose, pacing, clues, voices, similarity, names, mentions, compare, passes, diagram, synopsis, export, build, init, import, and the commands that change the project then print one JSON object on stdout (apiVersion, command, ok, data, diagnostics, writes). ok is true exactly when the exit code is 0; each diagnostic has severity, file, message, code (the finding's rule, such as stale-word-count, the name a story.md severity entry takes), and check (the check that raised it). report, next, and doctor always have ok: true, so read their data.checks or error diagnostics instead (doctor --fix is the exception: its ok is false while a check still has an error, and data.fix lists the repairs it made). writes lists the files a command wrote, such as the file build made, whose path is also data.outFile
  • passes to track named revision passes in story.md revision-passes: --init writes the default ladder, --start/--done <pass> update one, and no flag prints the checklist. Rules: references/continuity-checks.md (passes).
  • names before naming a character, place, faction, artifact, system, or glossary term: story names '<name>' ... reports exact clashes as errors and look-alikes as warnings. Rules: references/continuity-checks.md (names).
  • mentions before removing an entity or renaming it without --prose, since neither changes prose: story mentions <kind> <id> --path '<project>' lists each place the chapter prose names it, with file and line, so you can update the text. With no entity it warns named-not-listed (prose names a character the chapter's pov, characters, and mentions leave out; continuity reports this too) and mention-not-named (a mentions entry the prose never names). Fix a true omission in the chapter frontmatter; when the chapter uses a name the bible lacks, add it to the entity's aliases; ask the user before dropping a mention. Rules: references/continuity-checks.md (mentions).
  • diagram when the user wants a picture of the story's structure: story diagram <kind> prints Mermaid source generated from frontmatter, or writes it with --out <file> (--path <project> sets the project). Kinds: relationships (character graph, family edges styled distinctly: the family tree), locations (map-graph from location routes, edges labelled with hours), timeline (dated scenes and chapters in story-time order), clues (clue plant to reveal flow per chapter), and arcs (arcs to the chapters that advance them). GitHub, many editors, and mermaid.live render it; regenerate rather than hand-edit
  • timeline when the user asks what happens when, how flashbacks sit against the main line, whose POV dominates, or where a character drops out. It is read-only; continuity owns clock errors. Rules: references/continuity-checks.md (timeline).
  • prose when the user asks for a prose check or before sharing a draft: advisory per-chapter and manuscript-wide prose metrics against style-sheet.md. Rules: references/continuity-checks.md (prose).
  • series when story.md has follows or precedes links to other books; it orders the linked sequels and prequels by chronology and checks shared canon. Use init --follows <path> or init --precedes <path> to start a linked book, and see the series-continuity skill for carrying canon across. Rules: references/continuity-checks.md (series).
  • import when the user has an existing manuscript or chapter drafts and wants a Story Skills project built from them. import --force deletes every chapter-NN.md in chapters/ first, so confirm with the user before forcing an import over drafted chapters. Rules: references/editing-commands.md (import).
  • report when the user asks for project status, inventory, progress, or a quick health summary
  • next before a drafting session to identify the next deterministic action
  • doctor when the user asks what is stale, broken, or inconsistent. doctor --fix applies only the mechanical repairs the checks call for (putting back a split, merge, move, rename, or remove that stopped part way, migrate for missing registries or an old schema-version, wordcount --write for stale counts, reindex for stale registries), never touches prose, then reports what remains; it exits 1 while any check still has an error. Use it to clear mechanical findings in one step, then work through the remaining actions yourself or with the user
  • migrate when a project has an older schema version or missing v2 paths
  • --dry-run on add, rename, move, split, merge, remove, reindex, migrate, wordcount --write, doctor --fix, snapshot, passes, progress --log, diagram --out, synopsis --out, export, build, init, or import before a change that touches many files, or when the user wants to see what a command will change first: it lists each file it would create, update, or delete and changes nothing (--json gives the list as data.changes). Show the user the list, then run the command without --dry-run
  • add, rename, move, and remove for deterministic entity file operations when they fit the requested change. add takes ids, not names, for reference options, and ids stay ASCII kebab-case (--id sets one by hand). Rules: references/editing-commands.md (add, rename, and remove).
  • move whenever a chapter's number or a scene's chapter or position changes, never a hand rename: chapter and scene ids encode their numbers. Rules: references/editing-commands.md (move).
  • split and merge to split a chapter in two or join two neighbouring chapters, instead of moving prose and renumbering by hand. Run --dry-run first and show the user the list. Rules: references/editing-commands.md (split and merge).
  • init --form <form> records form in story.md (novel, novella, novelette, short-story, flash, serial, picture-book, chapter-book) and sets a default target-words when none is given; validate warns when target-words is outside the form's usual range and report shows the form
  • add matter when the user wants a dedication, epigraph, copyright page, acknowledgments, author's note, about-the-author, or also-by page. Never invent acknowledgments, biographical facts, or copyright details: ask the user for them. Rules: references/editing-commands.md (add matter).
  • add research when the story relies on a real-world fact. Rules: references/editing-commands.md (add research).
  • export only when the user asks for a combined manuscript at a specific path; it includes front and back matter
  • build when the user asks to build the book artifact: markdown, EPUB, DOCX, Shunn, HTML, print, narration, metadata, Fountain, Twee, and ink outputs in dist/, and a codex site in dist/codex/, with front and back matter. Rules: references/builds.md (build).
  • build --format html when the user wants a review or reading copy for people who never open a terminal. Rules: references/builds.md (html).
  • build --format print for a print-ready interior; --pdf renders a PDF with an engine the user has installed. Rules: references/builds.md (print).
  • build --format narration for an audiobook narration script, and build --format metadata for a retailer metadata sheet. Rules: references/builds.md (narration, metadata).
  • build --format twee and build --format ink for a branching book. Rules: references/builds.md (twee, ink).
  • build --format codex when the user wants a browsable story bible; add --spoilers only for the author's own copy. Rules: references/builds.md (codex).
  • build --format epub and build --format shunn for an ebook or a Shunn manuscript. Rules: references/builds.md (epub, shunn).
  • knowledge when the user asks what a character knew at a given chapter: story knowledge <character-id> --at <chapter-id>, marking each fact reader-knowledge or character-knowledge with do not reveal. Rules: references/continuity-checks.md (knowledge).
  • context before drafting a chapter or scene: story context <chapter-or-scene-id> [--budget <tokens>] [--scenes <n>] prints what the target needs in priority order, leaving out later chapters. Rules: references/continuity-checks.md (context).
  • add clue when the user plants a new clue: story add clue 'Name' --planted chapter-02 --payoff chapter-05; omit --payoff when it is not yet known, and pass --red-herring for a clue meant to mislead. Rules: references/continuity-checks.md (add clue).
  • synopsis when the user wants a mechanical synopsis: the first sentence of story.md's ## Synopsis section, then each arc's Setup, Rising Action, Climax, and Resolution. One page is 500 words and three pages is 1500. story synopsis [--pages 1|3] [--out file]. The output is a scaffold; the submission skill rewrites it into an agent-ready synopsis

Project CLI Configuration

story.md may carry cli-defaults (default flags per command, such as - command: build with format: html) and severity (named warnings promoted with level: error or silenced with level: off, such as - warning: todo-markers). A flag on the command line always wins. Edit these fields only when the user asks for project-wide defaults or stricter checks, then run story validate: it rejects unknown commands, flags, warning codes, and levels, and while either field is invalid the other commands refuse to run. Every warning line ends with its code in brackets, such as [todo-markers], which is the name a severity entry takes; an error: line ending in a code is a warning the project has promoted. Errors cannot be overridden. docs/cli-reference.md lists every code under Finding codes.

Failure Handling

  • Treat CLI errors as actionable maintenance findings.
  • Read the exit code to decide what to do next: 1 means the check found error: findings to fix in the project; 2 means the command line was wrong (fix the command, not the project); 3 means the path is not a usable story project or a file it needs does not parse (repair that file, or point at the folder with story.md); 4 means a write was refused (the target exists, is project source, is locked by another story command, or is not writable), so resolve the conflict rather than forcing it.
  • Fix broken references, missing required files, stale registries, or incorrect word counts when the requested task implies doing so.
  • Do not overwrite creative prose or story content merely to satisfy a mechanical check.
  • If a validation warning reflects intentional user data, report it rather than silently changing it.
  • If a command stops with Cannot reindex: fix these files first (or Cannot count words: ..., Cannot build: ...), repair the frontmatter of each listed file, then rerun it. rename, move, and remove report <file>: <error>; nothing was changed for the same cause, and <file> is missing YAML frontmatter; nothing was changed when an entity file, a CLI registry (the _index.md in an entity folder, matter/, or research/), or fixed project file (story.md, style-sheet.md, progress.md, plot/timeline.md, continuity/state.md, continuity/exemptions.md) has none; plain skill notes such as continuity/motifs.md, or an _index.md in a folder of the user's own such as notes/, do not block them.
  • A file-system failure reads Cannot <open|list|check|replace|delete|write to> <path>: <reason> (such as permission denied); fix the file or folder permissions, or the path, rather than the story content.
  • If story reindex fails on a corrupt plot/_index.md, do not hand-edit story content to work around it: restore the index frontmatter from git, or delete plot/_index.md so reindex rebuilds it, then rerun.

Shared Conventions

Every story skill follows the shared conventions in references/conventions.md: kebab-case ids and filenames, YAML frontmatter on every story-project file, _index.md registry tables that story reindex rebuilds (never edit them by hand), bidirectional links between entities, characters for who is on the page and mentions for who is only referred to, status: deceased plus died-in: chapter-{NN} for deaths, and no project-local generator or build scripts (run only the installed or bundled Story CLI). Other skills link to that file and repeat this summary, so update both together.

Files (story-skills)
  • references
    • builds.md 7.4 KB
      # Builds
      
      What each `story build` format writes, and the flags and `story.md` fields it reads. `SKILL.md` says when to build; read the matching section here before running a format or explaining its output. `docs/manuscripts.md` explains the same builds for users, and `docs/cli-reference.md` lists every flag.
      
      ## build
      
      `build` when the user asks to build the book artifact; supports markdown, EPUB, DOCX, Shunn, HTML, print, narration, metadata, Fountain, Twee, and ink outputs in `dist/`, and a codex site in `dist/codex/`, with front and back matter. For EPUB, set `cover: path/to/cover.jpg` (inside the project, and holding the kind of image its extension names) and `author` in `story.md` to embed a cover image and creator; never pass the cover or the `build-style` `css` file as `--out`, which is refused. For an anthology, set `editor` in `story.md` and `author` on each chapter: builds credit the editor on the title pages and print each story's writer under its heading, outside the word count (docs/manuscripts.md#story-authors-in-collections-and-anthologies).
      
      A matter page with `permission: pending`, a misspelt `permission` key, or any value but `not-needed`, `granted`, or `public-domain` is left out of `export` and of every build that prints matter (markdown, EPUB, DOCX, HTML, print, narration), with a `permission-pending-left-out` warning for each. Report the warning rather than work around it. Add `--include-pending` only for a proof the author reads alone, never for a file anyone else sees; `cli-defaults` cannot set it, and other builds refuse it.
      
      ## html
      
      `build --format html` when the user wants a review or reading copy for people who never open a terminal: a single HTML file with a table of contents and a paragraph label on every paragraph, shown faintly in the margin as a link labelled `ch03-p12` (chapter 3, paragraph 12), that reviewers cite in notes.
      
      - A label is the paragraph's position in that build, so any earlier edit renumbers it; add `--stamp <round or date>` so notes can name the build, and ask reviewers to quote each paragraph's first few words.
      - `--note-url <url>` adds a Note link beside each label, prefilled with `title`, `anchor`, `build`, and `quote` query parameters for the reader-note issue form.
      - The `feedback-triage` skill sets up the GitHub Pages workflow that publishes it, and the issue form.
      
      ## print
      
      `build --format print` for a print-ready interior: HTML with CSS paged media, trim size from `--trim` (`5x8`, `5.25x8`, `5.5x8.5`, `6x9`, `a5`; default `5.5x8.5`), mirrored margins with gutter, running heads (author on verso, chapter title on recto, blank on chapter openings), page numbers at the foot of chapter and back-matter pages, chapters on recto, a raised initial at each chapter opening, widow and orphan control, and a copyright page.
      
      Add `--pdf` to render it to `dist/<story-id>.pdf` with a paged-media engine the user has installed (Prince, WeasyPrint, Paged.js CLI `pagedjs-cli`, or headless Chrome/Chromium, tried in that order; `--pdf-engine <name|path>` picks one); the CLI does not bundle one and exits 4 with install hints when none is found.
      
      See the `publishing` skill.
      
      ## narration
      
      `build --format narration` for an audiobook narration script: a pronunciation guide table from every `pronunciation` field, each chapter with an estimated finished runtime at the language's narration pace (155 words per minute in English), scene breaks as `[pause]`, and a total runtime. In a collection or anthology, each story with its own chapter `author` gets a spoken credit after its heading, and the opening credits name the `editor` and then the story authors the book's credits leave out. See the `adaptation` skill.
      
      ## metadata
      
      `build --format metadata` for a retailer metadata sheet from `story.md`: title, series, authors, ISBN, publisher, date, language, description with its character count against common limits (KDP 4,000), keywords, BISAC subjects, word count, estimated page count, AI disclosure, and a readiness checklist of missing fields. See the `publishing` skill.
      
      ## twee
      
      `build --format twee` for a Twine story (Twee 3) of a branching book: one passage per chapter, named by its id, with a `[[text->chapter-NN]]` link for each entry in the chapter's `choices` frontmatter (`text` and `to`).
      
      - The first chapter is the start; once any chapter has choices, a chapter without them is an ending, and with none anywhere each chapter links to the next.
      - The IFID comes from `ifid` in `story.md`; without it the build derives one from the story id and warns with the `ifid:` line to add.
      - It refuses to build while a choice is malformed or leads to a missing chapter; `story validate` and `story links` report the same problems, and `links` warns about chapters no choice path reaches.
      
      See the `interactive-fiction` skill, or `adaptation` to convert a linear book.
      
      ## ink
      
      `build --format ink` for the same branching book as an ink story for Inky and inklecate: one knot per chapter (`chapter-03` becomes `chapter_03`), a sticky `+ [text] -> knot` choice for each entry in `choices`, `-> END` for a chapter without choices, the title, author, and IFID as global tags, and prose escaped so ink reads it as text. It shares the Twee build's rules, checks, and refusals. See the `interactive-fiction` skill.
      
      ## codex
      
      `build --format codex` when the user wants a browsable story bible: a static site in `dist/codex/` with an index, a page per character, location, faction, artifact, system, and arc (fields, relationships, chapters it appears in, and backlinks), and timeline, threads, and progress pages, cross-linked and with no script or external asset.
      
      - It is spoiler-safe by default (no entity notes, statuses, deaths, knowledge, clues, or resolutions); add `--spoilers` only for the author's own copy, never for one readers will see.
      - `--out` names a folder, and a rebuild replaces an earlier codex there.
      - Its headings, columns, and notes follow `story.md` `language`, like other build labels; to reword one, add a `codex-` entry under `labels` (see docs/manuscripts.md#build-labels).
      
      ## epub
      
      `build --format epub` also writes EPUB 3 accessibility metadata, language, semantic chapter and matter markup, and a landmarks nav, and uses the optional `story.md` publishing fields (`cover-alt`, `isbn`, `publisher`, `publication-date`, `description`, `subjects`, `language`, and `copyright`, which generates a copyright page when no copyright matter page exists).
      
      ## shunn
      
      `build --format shunn` when the user wants Shunn manuscript-format markdown: title page, contact block, word count, chapter breaks, and double-spaced prose; `story build . --format docx --shunn` applies the same Shunn formatting to a Word file, `dist/<story-id>.shunn.docx`, so it never replaces the plain `docx` build, and `story build . --format shunn --pdf` renders a Shunn PDF with an installed engine.
      
      ## build-style
      
      A `build-style` block in `story.md` styles the EPUB, HTML review copy, and print interior: `preset` (`classic`, the default, `modern`, or `elegant`), overridden by `body-font`, `heading-font`, `heading-style`, `scene-break`, `drop-caps`, and `paragraphs`, plus `css` for an extra stylesheet inside the project. The Shunn and DOCX builds ignore it. `story validate` reports `invalid-build-style` for a bad block, and the EPUB, HTML, and print builds stop on the same problems. Change it only when the user asks for a different look; see the `publishing` skill, and Build styles in `docs/manuscripts.md` for every value.
      
    • commands.md 3.2 KB
      # Command Catalogue
      
      Example command lines for every `story` command, for when you need the exact form of one. `SKILL.md` says when to run each command, and the other references hold their rules. `story --help` lists every command and `story <command> --help` its flags, and `docs/cli-reference.md` documents every flag.
      
      <!-- command-reference -->
      ```shell
      story reindex .
      story wordcount . --write
      story check .
      story check . --strict
      story validate .
      story links .
      story continuity .
      story prose .
      story voices .
      story pacing .
      story clues .
      story timeline .
      story grid .
      story grid . --format csv
      story passes .
      story passes . --init
      story passes . --start structure
      story passes . --done structure
      story names 'Mira' 'Kelvos'
      story mentions character sera-voss --path .
      story mentions --path .
      story list chapters --where status=draft --path .
      story diagram relationships
      story diagram locations --out dist/locations.mmd
      story diagram timeline
      story diagram clues
      story diagram arcs
      story progress . --log
      story compare . --ref draft-1
      story compare . --against ../book-draft-1
      story snapshot draft-1 --path .
      story snapshot --list --path .
      story compare . --snapshot draft-1
      story snapshot --restore draft-1 --dry-run --path .
      story compare . --ref beta-round-1 --anchor ch03-p12
      story similarity . --against ../book-one
      story similarity . --snapshot draft-1
      story series .
      story init 'The Last Ember' --form novel
      story init 'Embers of the Vale' --follows ../the-last-ember
      story import draft.md --title 'Title'
      story report .
      story report . --actionable
      story next .
      story doctor .
      story doctor . --fix --dry-run
      story doctor . --fix
      story migrate .
      story add character 'Name'
      story add character 'Пётр'
      story add character '李明' --id li-ming
      story add matter 'Dedication'
      story add research 'Tidal bore timing' --source 'Tide tables 2024' --used-in chapter-03
      story add research 'Night shift on a cardiac ward' --method interview --accuracy must-be-accurate --confidence medium --risk medical
      story add matter 'Acknowledgments' --placement back
      story rename character old-id 'New Name'
      story rename character old-id 'New Name' --prose --dry-run
      story rename character petr 'Пётр Иванов'
      story rename character li-ming '李明华' --id li-minghua
      story move chapter chapter-04 --number 5 --dry-run
      story move chapter chapter-04 --number 5
      story move scene chapter-03-scene-02 --chapter chapter-05
      story move scene chapter-03-scene-02 --scene 1
      story split chapter-07 --at 2 --dry-run
      story split chapter-07 --at 'The ferry came at noon.' --title 'The Crossing'
      story merge chapter-07 chapter-08
      story remove promise old-promise
      story export . --out dist/manuscript.md
      story build . --format markdown
      story build . --format epub
      story build . --format docx
      story build . --format shunn
      story build . --format docx --shunn
      story build . --format html
      story build . --format print --trim 6x9
      story build . --format narration
      story build . --format metadata
      story build . --format twee
      story build . --format ink
      story knowledge sera-voss --at chapter-04
      story continuity . --json
      story context chapter-04 --budget 6000
      story add clue 'The silver locket' --planted chapter-02 --payoff chapter-05
      story synopsis --pages 1
      story synopsis --pages 3 --out dist/synopsis.md
      ```
      
    • continuity-checks.md 24.6 KB
      # Continuity and Analysis Checks
      
      The rules each check command applies, and what to run it with. `SKILL.md` says when to run each command; read the matching section here before acting on its findings. These commands report; story decisions belong to the creative skills named in each section. `docs/continuity.md` explains the same checks for users, and `docs/cli-reference.md` lists every finding code.
      
      ## continuity
      
      Run `story continuity .` after drafting or revising a chapter, and whenever the user asks about contradictions, dead characters appearing, unfired setups, or stale state.
      
      ### Deaths and revivals
      
      It deterministically checks `died-in` ordering:
      
      - It warns when a character `deceased` with no `died-in`, dead before the story, is in a cast.
      - `revived-in` ends the dead window.
      - A `died-in` outline chapter is a planned death, not in force, so the character may keep `status: alive` and a later cast is allowed until that chapter is drafted.
      - A `pov` also in `mentions` is a posthumous narrator rather than an appearance.
      - Chapters dated on both sides compare by story date, not number.
      - A character `status` progression to `deceased` with no `died-in` counts as the death for later casts and learning, and a status progression that contradicts `died-in` or `revived-in` warns.
      
      ### Ledgers, casts, and state references
      
      It also checks promise/question chapter ordering, Chekhov gaps, POV/cast consistency, `status: cut` characters still listed in a chapter or scene `pov`/`characters`, an arc's `characters`, or a relationship (warnings), and `continuity/state.md` references.
      
      ### `continuity/state.md` disagreements
      
      It warns where `continuity/state.md` disagrees with:
      
      - scene `state-changes`: a `character` + `knowledge` change with no `knowledge-state` entry learned by that chapter, or an artifact `owner`/`location` set by a later scene
      - deaths: knowledge learned after dying, or `character-state` for a dead character
      - casts: a `learned-in` chapter that does not list the learner, or a `character-state` location the `current-chapter` never visits
      
      ### Clue ledger
      
      It reuses the promise-ordering machinery for the clue ledger (`continuity/clues/`): payoff before plant is an error (also when the planted chapter is a scheduled `chapter-NN` not written yet), and a completed story with planned or planted clues is an error.
      
      ### Chekhov gaps and unanswered questions
      
      - The Chekhov warning requires `status: planted`: it fires as soon as a recorded payoff chapter has been drafted, or, with no payoff recorded, once three or more chapter files follow the plant (positions, not chapter numbers).
      - An open question with no `resolved` chapter warns `question-unanswered` once twelve or more drafted chapters follow `introduced`, counted the same way; the finding stays a warning, and an open question is an error only once `story.md` is `complete`. Exempt one question meant to stay open with `code: question-unanswered` and its file, or set that code's `severity` to `off` to silence all of them.
      
      ### Planned and planted setups
      
      - `story add clue --planted` (and `story add promise --planted`) records the chapter and sets `status: planted` when that chapter has a file, even an `outline` one; with `--planted` naming a chapter that has no file yet, or without `--planted`, the status is `planned`, and `--status` overrides either default. Pass `--status planned` while the planted chapter is only an outline.
      - A promise or clue with `status: planned` and a `planted` chapter warns ("records planted chapter X but status is still planned") only once chapter X has prose, so `--status planned --planted chapter-NN` schedules a setup ahead.
      - Both warnings read the named chapter's own `status`: an `outline` planted or payoff chapter does not count as drafted, even when later chapters are.
      - A recorded payoff chapter that is not drafted yet suppresses the "no payoff yet" warning.
      - `story links` accepts a scheduled `chapter-NN` that has no chapter file yet in `payoff`, in `planted` while the status is `planned` or `abandoned`, in an `open` or `abandoned` question's `introduced`, and in a research note's `used-in`, unless its number is 0 or belongs to an existing chapter under another id (`chapter-1` beside `chapter-01` is reported as missing); once the status is `planted`, `paid-off`, or `dropped` the planted chapter must exist, and once `paid-off` the payoff chapter must too. A question's `resolved` chapter must always exist. Cutting a planned thread needs only `status: abandoned`: leave its chapters in place. If a chapter with that number is added later, `story add chapter` and `story split` warn (`adopted-references`); clear the field then if the cut thread does not belong in that chapter. `story split` refuses to renumber an existing chapter onto a `chapter-NN` that any record names, or to give that id to its new chapter when a record other than an abandoned thread names it: point the record at the chapter it means first.
      
      ### Prop custody
      
      It also checks prop custody: artifacts with `destroyed` or `lost` status must not be referenced after their destruction chapter.
      
      - The destruction chapter is recorded in object-state `since: chapter-NN`; later scenes referencing the artifact in `state-changes` or `mentions` are errors.
      - An entry with no `since` was destroyed or lost before the story, so any scene `state-changes` using it is an error while `mentions` stay allowed.
      - A later entry for the same artifact with another status and a later `since` ends a loss.
      
      ### Clock and time plausibility
      
      It checks clock/time plausibility when scenes or chapters carry `date: YYYY-MM-DD` / `time: HH:MM` frontmatter.
      
      - `time` may be `dawn`, `morning`, `midday`, `afternoon`, `evening`, or `night`.
      - Dated units are scenes, or a chapter with no scene records, in reading order, the same units `story timeline` shows.
      - A unit that cannot fall after the latest moment reached warns as running backward and does not reset that moment, with named times read as the same spans as the route check and each chapter `strand` keeping its own clock.
      - Scene `travel-hours: N` asserts the minimum time since that moment.
      - A character, by scene `characters` or `pov` (the chapter's `pov` when the scene has none), in two dated scenes on the same chapter `strand` (chapters without one share a strand) at locations linked by location `routes`, with less story time between them than the fastest route, is an error, and so is a character at two different places at the same exact `HH:MM` on one strand whether or not routes join them; a sighting in another strand is not the other end of a journey.
      - The route check reads scene `date`, `time`, and `location` only, not chapter dates; a named time is a span such as `morning` 05:00-11:59, an untimed scene spans its whole day, and only journeys impossible on every reading are reported.
      - Without dates, only malformed dates or times and `travel-hours` on an undated scene are reported.
      
      ### Exemptions
      
      Intentional exceptions go in `continuity/exemptions.md` (frontmatter `type: exemption-log`, entries with a `reason` and the finding's `code` plus its `file` or `chapter`, as `--json` diagnostics give them; `pattern` matches message text and still works); exempted findings are reported as dismissed, not errors.
      
      ## Branching books
      
      Once any chapter has `choices`, `story continuity` (and `story check`, which reports the same findings), `story knowledge`, and `story context` read "later" along the paths of choices from the first chapter. Read this before acting on their findings in a branching book. Writing rejoins, recording a fact learned on two branches, and fixing `state-differs-by-path` are covered by the `interactive-fiction` skill's `references/path-continuity.md`.
      
      ### What follows the paths
      
      - **Deaths and revivals.** A death on one branch is not a posthumous appearance on a sibling branch. At a rejoin, a death on any incoming branch counts, so listing the character in that chapter's `characters` or `pov` is a `posthumous-appearance` error. `revived-in` ends the death only when every path from the death passes through the revival chapter. Learning after death and status progressions use the same rule.
      - **Dates.** Two chapters on one path dated on different days compare by date, so a flashback reached later stays earlier. Dates in a `story.md` custom `calendar` compare the same way. Chapters on sibling branches never compare.
      - **Knowledge.** `story knowledge <id> --at <chapter>` and `story context` list a fact once it is learned on some path to the chapter. At a rejoin, a fact learned on only one incoming branch is listed as known, so check that every incoming branch teaches it before the rejoin prose uses it. One `knowledge-state` entry per branch for the same `fact` is not a `state-duplicate-fact` error.
      - **Context.** `story context` takes previous scenes, open promises, clues, and questions, and progressions only from chapters on a path to the target. At a rejoin that is every incoming branch. It shows the `continuity/state.md` character state only when `current-chapter` comes before the target on some path, whatever the chapter numbers; when `current-chapter` is on a sibling branch it prints a line saying the snapshot is left out.
      - **Prop custody.** A destroyed or lost artifact is gone only on paths after its `since` chapter.
      - **`continuity/state.md`.** It is checked against the chapters on a path to `current-chapter`. When those branches last set an artifact's `owner` or `location` differently, it warns `state-differs-by-path`.
      - **Loops and unreachable chapters.** Two chapters that each lead to the other, and any chapter no path reaches (`story links` warns `unreachable-chapter`), compare by date and then by number, as in a linear book.
      
      ### What still goes by chapter number
      
      These read the chapters in number order, so sibling branches can produce findings no reader meets:
      
      - the promise, clue, and question ledgers: payoff before plant, and the chapter counts behind the Chekhov and `question-unanswered` warnings. A setup planted on one branch and paid off on a sibling passes, though no reader sees both, so check each one against the paths by hand.
      - the clock and route checks (`clock-backward`, `travel-too-fast`, `route-too-fast`, `route-same-time`), which compare every dated scene in a `strand`, whichever branch it is on
      - `story timeline`, which marks a sibling ending dated before the one numbered ahead of it as told "after later events"
      - `story grid`, whose columns are every chapter side by side in number order, whichever path reaches each
      - `story pacing` runs, `story next`, and the `progression-out-of-order` list order
      
      `story series` and `story diagram` lifelines, and the second-death checks that read them (`revival-status-mismatch`, `deceased-without-died-in`), read every chapter in one order, not path by path: the reading order of the choices, so a chapter comes after the chapters that lead to it, with dated chapters by date. A death on one branch still counts at the end of the book.
      
      ### Sibling-ending clock warnings
      
      Two endings reached from the same chapter are alternatives, but the clock reads the higher-numbered one after the other. If its scene is dated earlier, `clock-backward` reports it. When that finding only reflects branch order, keep the dates and exempt the finding by `code` and `file`, with a `reason` naming the branch:
      
      ```yaml
      exemptions:
        - code: clock-backward
          file: scenes/chapter-06-scene-01.md
          reason: "Chapter 6 is the other ending: readers reach it from chapter 4, not after chapter 5."
      ```
      
      `examples/the-gull-rock-light` does this, and `story continuity` reports it as `dismissed: scenes/chapter-06-scene-01.md timestamp runs backward (exemption: ...)`. Exempt only the branch-order finding. A `clock-backward` warning between two chapters on one path is a real clock problem: fix the dates or times.
      
      ## clues
      
      Run `story clues .` for mysteries and any story with a clue ledger. It prints a clue-by-chapter matrix (`P` planted, `R` payoff, `x` both, `.` none; `~` after a clue name marks a red herring) and warns about:
      
      - a payoff with no plant
      - a late plant (same chapter as the payoff, or the one before)
      - a clue with no `characters`
      - three or more genuine live clues (not red herrings) with none `significance-delayed`
      - a `red-herring: true` clue with no `payoff`
      
      See the `genre-craft` skill.
      
      ## add clue
      
      Run it when the user plants a new clue. `story add clue 'Name' --planted chapter-02 --payoff chapter-05` creates the clue ledger entity in `continuity/clues/` with `status: planted` (`planned` when `--planted` is omitted or names a chapter with no file yet; pass `--status planned` if the chapter has a file but the clue is not on the page yet). Omit `--payoff` when the payoff is not yet known, and pass `--red-herring` for a clue meant to mislead.
      
      After you add a clue, run `story reindex .`, `story wordcount . --write`, and `story check .`.
      
      ## pacing
      
      Run `story pacing .` when the user asks about pacing, sagging middles, or chapter endings, and after drafting or restructuring chapters. Per chapter it shows words, scene and sequel counts, scene `outcome`s (`yes`, `no`, `yes-but`, `no-and`), and the chapter `hook` (`cliffhanger`, `question`, `revelation`, `reversal`, `decision`, `emotional`, `resolution`). It warns about:
      
      - three or more consecutive `yes` outcomes
      - four or more scene units with no sequel
      - chapter length outliers (over 2x or under 0.5x the median once three chapters have prose)
      - three or more consecutive chapters ending on `resolution`
      - drafted chapters with no `hook`
      
      See the `plot-structure` and `scene-craft` skills.
      
      ## voices
      
      Run `story voices .` when dialogue voices may blur or during a line pass.
      
      - It attributes a quoted line (straight `"..."`, curly `“...”`, or British `‘...’`) when the narration names the speaker next to a speech verb (`"...," Mara said`, `said Mara`, `Mara asked`, aliases included). A name before the verb wins over a name after it: in `"...," Sera told Kael` the line is Sera's, and `said Kael` gives it to Kael. With no speech-verb tag, a paragraph whose narration names exactly one character (an action beat) gives the line to that character.
      - Names and aliases match case-sensitively as proper nouns, and titles are skipped for the given name (`Lord Maren` also matches `Maren`).
      - Pronoun tags (`she said`, `said he`) are never attributed, and a paragraph with one is left unattributed even when its narration names another character. Only a pronoun and verb right after a closing quote or right before an opening one count as a tag; `She said nothing more` elsewhere is narration and does not block an action beat.
      - In close third person the POV character is often under-counted; when that matters, name the tags in a sample chapter and rerun. Unattributed dialogue is not counted, so a low line count may mean few named tags rather than few lines.
      - Per character it reports lines, words, mean sentence length, contraction, question, and exclamation rates, and signature words used more by them than by others.
      - It warns when a character says one of their `voice-avoid` words, when two characters with five or more lines each have close fingerprints ("X and Y may sound alike: similar sentence length, contractions, questions, and exclamations"), and when a character with five or more lines never says a `voice-words` entry.
      
      See the `voice-style` and `line-editing` skills.
      
      ## prose
      
      Run `story prose .` when the user asks for a prose check or before sharing a draft.
      
      - Per chapter it counts sentence length and spread, filter words and -ly adverbs per 1,000 narration words, plain and said-bookism dialogue tags, echoed words, watch words, and avoided spellings from `style-sheet.md` (`dialect`, `preferred`, `watch-words`, `allow-words`).
      - Across the manuscript it lists repeated 4-word phrases and similar character first names.
      - Findings are advisory warnings. The command exits 0 unless a file it reads fails to read or parse, or `story.md` `severity` promotes one of its warnings to an error, or the command line is wrong, such as `--baseline` with no `samples` (exit 2). A `samples` entry that cannot be read only warns.
      - `--max-filter-words <n>` (default 10), `--max-adverbs <n>` (default 12), and `--max-bookisms <n>` (default 2) change the warning thresholds.
      
      See the `voice-style` skill for acting on them.
      
      ## mentions
      
      Run `story mentions <kind> <id> --path '<project>'` to list every place the chapter prose names a character, location, faction, artifact, system, or glossary term, and before `story remove`, which never changes prose, or `story rename` without `--prose`.
      
      - It looks for the `name`, `aliases`, a character's given name (not an initial such as the `J` of `J. R. Dunn`), and each without leading titles or articles (`Hollow` for `The Hollow`). Names match as written and as whole words, so `Rose` is not found in "a rose"; possessives count. Outlines, HTML comments, and code fences are skipped.
      - `story continuity` warns `named-not-listed` when a drafted chapter's prose names a character that its `pov`, `characters`, and `mentions` leave out. Add them to `characters` if they are on the page, or `mentions` if they are only talked about. A one-word name that opens a sentence is not counted when the chapter also uses it as a plain word.
      - `story mentions` with no entity also warns `mention-not-named` for a character or artifact in `mentions` that the prose never names. A chapter that refers to someone only by relationship ("her father") triggers it, so read the chapter before acting: add the name the chapter uses to the entity's `aliases`, drop a stale mention, or exempt it.
      
      ## names
      
      Run `story names '<name>' ...` before naming a character, place, faction, artifact, system, or glossary term. It checks candidates against every existing name and alias.
      
      - A candidate's given name (first word that is not a title or article such as `the`, `lord`, or `captain`) is compared with each character's given name, and everything else as a whole name; an exact match with either is a clash, an error (exit 1).
      - Look-alikes (the same first four letters, or the same initial within edit distance 1, or 2 when both words have five letters or more) and a given name sharing an initial with a protagonist, antagonist, deuteragonist, or narrator are warnings.
      - Multi-word names are only checked for exact clashes, so pass a multi-word name's distinctive words separately.
      - Pass `--path <project>` when not in the project root.
      
      ## timeline
      
      Run `story timeline .` when the user asks what happens when, how flashbacks sit against the main line, whose POV dominates, or where a character drops out. It orders dated scenes (and chapters without scene records) by `date` and `time`, marks entries told after later events, lists undated scenes in reading order, totals chapters and words per POV, and reports each character's chapter presence, longest absence, and absence from the final chapters. It is read-only; `continuity` owns clock errors.
      
      ## knowledge
      
      Run `story knowledge <character-id> --at <chapter-id>` when the user asks what a character knew at a given chapter. It lists knowledge-state entries the character knows there in story time (by date when both chapters are dated, otherwise by chapter number), plus entries without `learned-in`. Each line is `reader-knowledge` (already on the page, or pre-existing) or `character-knowledge` with `do not reveal` (known from a flashback the reader has not reached). `story context` uses the same rule and the same marks.
      
      ## context
      
      Run `story context <chapter-or-scene-id> [--budget <tokens>] [--scenes <n>]` before drafting a chapter or scene.
      
      - It prints, as markdown and in priority order until the estimated budget runs out, the target's outline and cast, the `story.md` essentials and `style-sheet.md` rules, the POV character's knowledge and state at that point, cards for the characters on the page and the locations, with their progressions applied at the target, open promises, clues, and questions, and summaries of the previous `--scenes` scenes (default 5).
      - The budget defaults to 6000 tokens, estimated at 4 per 3 words in spaced text, 2 per 3 Han or katakana characters, 1 per 2 hiragana, and 1 per Thai, Lao, Khmer, or Burmese word.
      - POV knowledge follows the same rule as `knowledge`, including a `character-knowledge` line that must not be revealed. Other material from later chapters is left out, progressions included.
      - For a scene, a fact learned in that chapter is included only when an earlier scene's `state-changes` records it for the POV character.
      - Items left out for the budget are listed with the file to read instead.
      
      ## passes
      
      `story passes .` tracks named revision passes in `story.md` `revision-passes` (`{pass, status}`, status `pending`, `in-progress`, or `done`).
      
      - `--init` writes the default ladder (`structure`, `character`, `theme`, `continuity`, `pacing`, `line`, `copyedit`, `proof`) and keeps existing entries.
      - `--start <pass>` and `--done <pass>` update one.
      - With no flag it prints the checklist and the checks each default pass runs.
      - When the story `status` is `revising`, `next` recommends the next unfinished pass.
      
      See the `revision-continuity` skill.
      
      ## compare
      
      Run `story compare . --ref <git-ref>` or `story compare . --against '<project-path>'` after a revision pass, or when the user asks what changed since a draft. It needs exactly one of `--ref`, `--against`, or `--snapshot`.
      
      - `--ref` reads chapters at a git branch, tag, or commit with `git show` (it never writes to the repository), and `--against` reads another copy of the project.
      - It reports per-chapter word changes, added and removed chapters, and the share of paragraphs unchanged.
      - With `--anchor <label>` (repeatable) it instead maps review-copy paragraph labels from that draft to the current text, one line per label: `ch01-p20 -> ch01-p21 (text unchanged)`, `(edited, NN% similar)`, `not found in the current text ("first words…")`, or `no such label`. Use it to resolve reader notes from an older build.
      
      See Draft Snapshots in the `revision-continuity` skill for taking the snapshot.
      
      ## similarity
      
      Run `story similarity . --against '<source>'` when the user asks whether a passage echoes another text too closely: their earlier books, a previous draft, or a source they worked from.
      
      - `--against` takes a file, a folder (another story project's chapters, or every `.md`, `.markdown`, and `.txt` file in it), or a git ref.
      - It reports each run of `--min-words` (default 8) or more shared words, compared lowercased without punctuation, as a `similarity-shared-passage` warning with the chapter's review-copy label, the reference's location, and the shared words, then per-chapter and total shares.
      - It is advisory and exits 0. Shared text is not proof of copying, so report what it found and where, never a verdict.
      
      See the `editorial-review` skill.
      
      ## series
      
      Run `story series .` when `story.md` has `follows` or `precedes` links to other books.
      
      `story series [path]` finds every book reachable through `follows` and `precedes`, then orders them by chronology. At each step, among the books whose earlier books are already listed, the lowest `book-number` goes next, then title, then folder path. It only follows links between sibling book folders. It lists shared canon and reports:
      
      - **Errors**
        - A linked path that is not a story project
        - Books that declare different `series` ids
        - Two books that share a `book-number`, or a `book-number` that is not a number 0 or more
        - A chronology cycle
        - A character who is dead at the end of an earlier book (by `died-in`, a status progression to `deceased`, or `status: deceased`) but not `deceased` in a later one
        - A later book whose chapter or scene lists that character as `pov` or under `characters`, before a status progression in that book brings them back. Move flashbacks, memories, and ghosts to `mentions`. A ghost narrator may stay the `pov` when also listed in `mentions`.
        - A later book where a character learns a `fact` (an entry with `learned-in`) that the same character already knows in an earlier book. In a prequel, the usual fix is to remove the knowledge from the prequel, or to change which book the discovery happens in.
        - A later book where a character who died in an earlier book learns something (a `knowledge-state` entry with `learned-in`) before being brought back
        - A later book's scene whose `state-changes` target an artifact `destroyed` in an earlier book
      - **Warnings**
        - A shared entity whose `name` (or glossary `term`) or `pronunciation` differs from the most recent earlier book
        - Linked books that set different `series-title` values
        - An artifact that is `destroyed` in an earlier book but has a different status in a later one
        - Linked books that set no `series` id while the others share one (`Linked books <titles> set no series id; add series: <id>`); add the id to each named book
      
      See the `series-continuity` skill. `docs/series.md` gives the fix for each finding.
      
    • conventions.md 5.7 KB
      # Story Conventions
      
      These conventions apply across all story skills. Every `SKILL.md` links here and repeats a one-line summary, so a skill installed on its own without `story-maintenance` still has the essentials. `docs/concepts.md` explains them for users, and `docs/project-format.md` is the full schema reference.
      
      ## Files and identifiers
      
      - **Kebab-case filenames** for all entity files (e.g., `sera-voss.md`, `ashen-citadel.md`)
      - **YAML frontmatter** on every file in a story project for structured metadata (a standalone piece saved outside a project, such as a single poem, needs none)
      - **Schema version** - `story.md` frontmatter includes `schema-version: 2`
      - **Character identifiers** use the kebab-case filename without extension (e.g., `sera-voss`)
      - **Scene identifiers** use `chapter-{NN}-scene-{NN}` and live in `scenes/`
      
      ## Project files
      
      - **`_index.md`** registries list each domain's entities. `story reindex` rebuilds their tables from the entity files, and `story add`, `rename`, `move`, and `remove` reindex for you. Never add or edit a registry row by hand, even without the CLI: change the entity file and reindex. Reindex keeps the hand-written sections, which are yours to edit: `## Relationship Map` and `## Family Trees` in `characters/_index.md`, `## World Overview` in `worldbuilding/_index.md`, and `## Story Structure`, `## Theme Tracking`, and the `structure` field in `plot/_index.md`
      - **`story.md`** is the top-level bible read by all skills for context
      - **`style-sheet.md`** records voice and house style; skills that write or revise prose read it
      - **Continuity state** lives in `continuity/state.md`, with open questions, promises, and clues tracked under `continuity/questions/`, `continuity/promises/`, and `continuity/clues/`
      
      ## Setups, payoffs, and story time
      
      Each setup and each event has one record. Update that record, and never copy it into another:
      
      - **A mystery clue or red herring** - `continuity/clues/{id}.md` (`story add clue`), checked by `story clues` and `story continuity`
      - **Any other setup the reader is owed a payoff on** (a Chekhov's gun, a vow, a prophecy, a deadline) - `continuity/promises/{id}.md` (`story add promise`), checked by `story continuity`
      - **A question the reader is left asking** - `continuity/questions/{id}.md` (`story add question`), checked by `story continuity`
      - **A hint inside one arc that needs no checked payoff** (an image or an echo) - a row of that arc's `## Foreshadowing` table. `story check` only checks that its `chapter-NN` ids exist (and `move` and `split` rewrite or guard them); nothing checks its status or payoff. A clue or promise never gets a row there too
      - **A motif that theme-craft tracks** (a recurring object or image with planted, variation, and ending beats) - a row of the motif table in `continuity/motifs.md`, the only place for motif rows. No CLI check reads that file's status or payoff
      - **When a drafted scene happens** - the scene's `date` and `time`, which `story timeline` orders and `story continuity` checks
      - **Backstory and planned events** - `plot/timeline.md`, the hand-kept plan. Once an event is drafted, set its `Chapter` cell; the scene's `date` and `time` then say when it happens, so do not add a plan row for each drafted scene
      
      A promise or clue moves from `planned` to `planted` (with `planted: chapter-{NN}`) to `paid-off` (with `payoff: chapter-{NN}`); a question moves from `open` to `answered` (with `resolved: chapter-{NN}`).
      
      ## Links and casts
      
      - **Bidirectional cross-links** - when referencing another entity, update both files
      - **Death tracking** - when a character dies on the page, set `status: deceased` and `died-in: chapter-{NN}` so `story continuity` can flag posthumous appearances
      - **`mentions` vs `characters`** - chapter and scene frontmatter lists characters present in-scene under `characters`; characters who are only referenced, remembered, recorded, or seen in flashback go under `mentions`
      
      ## Maintenance
      
      - **One maintenance block, one order** - after adding, removing, renaming, or revising story files, run `story reindex .`, then `story wordcount . --write`, then `story check .` (which runs `validate`, `links`, and `continuity` and fails only on errors). Run any skill-specific check, such as `story clues .` or `story pacing .`, after it.
      
      ## Scripts
      
      - **Markdown-first artifacts** - create and edit story content directly in the target `.md` files. Do not create project-local build scripts, generator scripts, or bulk writer scripts (for example `build-*.js`) to emit story files.
      - **Single-quote the user's words** - a title, name, synopsis, paragraph label, or other text from the user goes into a `story` command in single quotes, never in double quotes, where the shell still runs `$(...)` and backticks and expands `$name`. The skills write these values as `'{Title}'` or `'<name>'`. How to write a single quote inside the value depends on the shell:
        - POSIX shells (bash, zsh, sh, Git Bash): end the quote, add an escaped quote, and reopen it, `'\''` (`story add character 'Mara O'\''Neill'`).
        - PowerShell: double it (`story add character 'Mara O''Neill'`). The POSIX form is a syntax error there, and a value with an unescaped `'` ends the string early, so PowerShell would run the rest as a command.
        - cmd.exe: single quotes do not group words, and `&`, `|`, and `^` act even inside a value, so do not run a command with user text there. Use PowerShell or a POSIX shell.
      - **CLI helpers stay external** - the only JavaScript helper agents should run is the installed or bundled Story CLI (`story`, or `story-maintenance/scripts/story.js` or a Story Skills checkout's `bin/story.js` run with Node from the folder `story` would run in) for deterministic maintenance. Do not copy it into the user's story project, and remove any unavoidable scratch helper before finishing.
      
    • editing-commands.md 9.4 KB
      # Editing Commands
      
      How the commands that create, rename, move, split, merge, and remove entity files, and the `import` that builds a project from a manuscript, behave, and what to check after them. `SKILL.md` says when to run each command; read the matching section here before running one or explaining its result. Run any of them with `--dry-run` first when the change touches many files. `docs/cli-reference.md` lists every flag.
      
      After a command changes files, run `story reindex .`, `story wordcount . --write`, and `story check .` in that project. After `import`, run them in the new project folder that the output names, not in the folder you ran it from: `story reindex <folder>`, `story wordcount <folder> --write`, and `story check <folder>`.
      
      ## add, rename, and remove
      
      `add`, `rename`, `move`, and `remove` for deterministic entity file operations when they fit the requested change.
      
      - `add` takes ids, not names, for reference options (`--planted chapter-01`, `--pov mara-quill`), and `add scene` needs its chapter to exist, so add the chapter first.
      - Ids stay ASCII kebab-case. Cyrillic and Greek names are transliterated (`story add character 'Пётр'` writes `characters/petr.md` and keeps `name: Пётр`), but a name in a script with no transliteration table (`李明`) needs the id by hand: `story add character '李明' --id li-ming` writes `characters/li-ming.md`, and `story rename <kind> <id> '<New Name>' --id <new-id>` does the same on a rename. `--id` is refused for chapters and scenes, whose ids come from their numbers.
      - `rename` rewrites the id in frontmatter and links but leaves chapter prose alone. Add `--prose` when the chapter text should use the new name too: preview with `story rename <kind> <id> '<New Name>' --prose --dry-run`, show the user the replacements, and run it without `--dry-run` only once they approve. `--prose` works for characters, locations, factions, artifacts, systems, and terms. It rewrites only drafted chapters: an `outline` chapter keeps the old name, so list them with `story list chapters --where status=outline` and update their text by hand. It leaves aliases as written, and lists the matches it left alone (a name that may be an ordinary word, a reference-link label) for you to check by hand; the `character-management` skill has the full steps. `rename` never changes a chapter or scene id: use `move`.
      - `remove chapter` refuses while scene files point at the chapter, so remove those scenes first; it walks back ledger statuses that relied on the chapter (planted to planned, paid-off to planted or planned, answered or resolved questions to open), so review the ledgers afterwards.
      
      ## move
      
      `move` whenever a chapter's number or a scene's chapter or position changes, never a hand rename: chapter and scene ids encode their numbers.
      
      - `story move chapter <id> --number <n>` renames the chapter and its scene files, sets `number` and the `# Chapter N:` heading, and rewrites every reference to the old id (scene `chapter`, clue and promise `planted`/`payoff`, question `introduced`/`resolved`, research `used-in`, `died-in`, `continuity/state.md` `since`/`learned-in` and `current-chapter`, markdown links, and bare ids in `plot/timeline.md` and arc bodies).
      - A taken number is refused (`chapter-05 already exists: move it first. To make room, renumber from the highest chapter down`), so to insert a chapter move the later chapters up one, highest first, then `add chapter --number <n>`.
      - `story move scene <id> --chapter <chapter-id>` moves a scene to the next free number in that chapter (`--scene <n>` picks the number, and `--scene` alone reorders within the chapter) and adds its location and characters to the new chapter; give at least one of the two.
      - `move` works only on chapters and scenes (use `rename` for other ids), never edits prose or outline beats that mention a chapter number, and reindexes.
      - References are written before the files move, so rerun an interrupted move: it first puts back what the stopped move changed.
      
      ## split and merge
      
      `split` and `merge` to split a chapter in two or join two neighbouring chapters, instead of moving prose and renumbering by hand.
      
      - `story split <chapter-id> --at '<marker>'` keeps the text before the marker and makes the rest the next chapter (`--title` names it; default `<title> (continued)`), moving the later chapters up one. The marker is a scene break number (`--at 2`), a heading, or a unique line of the chapter text. Scene records after the split move with their text, by order.
      - `story merge <chapter-id> <next-chapter-id>` appends the second chapter's prose after a scene break, adds its outline beats, notes, scenes, and list fields to the first, points every reference to it at the first, and moves the later chapters down one.
      - In a branching book (one with `choices`), both work only on chapters with no choices, and `merge` only when no choice leads to the second chapter; anything else is refused, naming the choices in the way. The renumbering points every choice at the new ids, and a split gives the first half a `Continue` choice that leads to the rest, so ask the user whether to reword it. Both list each choice they add or point elsewhere, under `--dry-run` too.
      - `split` also refuses when it would give a chapter (the last one it renumbers, or the new one when none follows) a `chapter-NN` that a file already names, such as a payoff scheduled for a chapter not written yet. Only abandoned threads naming the new chapter's id are let through, with an `adopted-references` warning. Ask the user which chapter those references mean, point them where the message says (the chapter they belong to, or the next id), then run it again.
      - Run `--dry-run` first and show the user the list. A split or merge stopped part way (`interrupted-change` from `story validate`) is put back by running it again, which then splits or merges, or by `story doctor --fix`; until then every other write command is refused.
      - Then work through the warnings: `split-references` lists files that still name the split chapter (a clue, death, or progression there may now belong to the new chapter, which the CLI cannot tell), `split-scenes` says scene records were assigned by order, and `merge-conflicts` lists fields the two chapters set differently, including `numbered: false` on only one of them (the merged chapter keeps the first chapter's numbering).
      - Neither edits prose, so reread for chapter numbers in the text.
      
      See the `revision-continuity` skill.
      
      ## add matter
      
      `add matter` when the user wants a dedication, epigraph, copyright page, acknowledgments, author's note, about-the-author, or also-by page.
      
      - Pages live in `matter/` (indexed in `matter/_index.md` by reindex) with `title`, `placement` (`front` or `back`), `order`, and `heading` (pass `--heading=false` for a dedication or epigraph, or edit the scaffolded `heading:` key; never add a second one).
      - Write the page text directly in the file; unwritten pages are left out of builds and `validate` warns about them.
      - Never invent acknowledgments, biographical facts, or copyright details: ask the user for them.
      - Matter pages that quote others' work (an epigraph, song lyrics) may record `permission` (`not-needed`, `pending`, `granted`, `public-domain`), `rights-holder`, and `credit`; `validate` warns when `permission: pending` remains on a complete story and when `granted` has no `rights-holder`, and export and the builds leave a `pending` page out (warning `permission-pending-left-out`) unless `--include-pending` is given.
      
      See the `editorial-review` skill.
      
      ## add research
      
      `add research` when the story relies on a real-world fact: notes live in `research/` with `status` (`open`, `verified`, `disputed`), whole-citation `sources`, and `used-in` chapter ids, plus optional `--accuracy` (`must-be-accurate`, `blended`, `invented`), `--confidence` (`high`, `medium`, `low`), `--method` (`fact`, `interview`, `site-visit`, `expert-review`, `reading`), and repeatable `--risk` (`legal`, `medical`, `weapons`, `safety`, `cultural`, `defamation`, `technical`).
      
      `validate` warns when a final chapter relies on open or disputed research (invented notes never trigger this), and when a note with a `risk` is used in a final or complete chapter with no `reviewed-by`.
      
      See the `research` skill.
      
      ## import
      
      `import` when the user has an existing manuscript or chapter drafts and wants a Story Skills project built from them.
      
      - Follow up by creating character and location files from the printed entity candidates, and by setting `form` and `target-words` in `story.md` (import refuses `--form`; without them `story validate` never checks length and `story progress` has no target).
      - Directory sources import in natural file-name order (`chapter-2` before `chapter-10`).
      - `import --force` into an existing directory deletes every `chapter-NN.md` in `chapters/` before writing the imported chapters, so confirm with the user before forcing an import over a project with drafted chapters. It first saves the project as snapshot `before-import-<n>` and prints `story snapshot --restore before-import-<n>`; tell the user that name, since that restore puts the old chapters back.
      - A Story Skills chapter file keeps its `author`; for a collection or anthology of other files, add `--bylines` to set each chapter's `author` from the by-line opening its prose (`By Ben Other`, taken out of the prose) or its file's frontmatter `author`. A line that is not clearly a by-line stays in the prose, so check the chapters left without `author` and set it by hand.
      
  • scripts
    • package.json 23 B
      {
        "type": "module"
      }
      
    • story.js 1.4 MB
      #!/usr/bin/env node
      
      // src/cli.js
      import path21 from "node:path";
      
      // src/commands.js
      import fs15 from "node:fs";
      import path20 from "node:path";
      
      // src/files.js
      import crypto from "node:crypto";
      import fs from "node:fs";
      import path from "node:path";
      import { Buffer } from "node:buffer";
      
      // src/exit-codes.js
      var EXIT_CODES = Object.freeze({
        ok: 0,
        findings: 1,
        usage: 2,
        project: 3,
        refused: 4
      });
      function withCode(message, exitCode, flags) {
        return aboutFlags(Object.assign(new Error(message), { exitCode }), flags);
      }
      function usageError(message, flags) {
        return withCode(message, EXIT_CODES.usage, flags);
      }
      function projectError(message, flags) {
        return withCode(message, EXIT_CODES.project, flags);
      }
      function refusedError(message, flags) {
        return withCode(message, EXIT_CODES.refused, flags);
      }
      function aboutFlags(error, flags) {
        if (flags !== undefined && error !== null && typeof error === "object" && error.flags === undefined) {
          error.flags = [].concat(flags);
        }
        return error;
      }
      function withFlags(flags, run) {
        try {
          return run();
        } catch (error) {
          throw aboutFlags(error, flags);
        }
      }
      function withExitCode(error, exitCode) {
        if (error !== null && typeof error === "object") {
          error.exitCode = exitCode;
        }
        return error;
      }
      function withDefaultExitCode(error, exitCode) {
        if (error !== null && typeof error === "object" && !Number.isInteger(error.exitCode)) {
          error.exitCode = exitCode;
        }
        return error;
      }
      var WRITE_SYSCALLS = new Set(["write", "rename", "mkdir", "mkdtemp", "unlink", "rmdir", "copyfile", "rm", "access", "chmod", "fsync"]);
      var WRITE_ERROR_CODES = new Set(["EROFS", "ENOSPC", "EDQUOT", "EFBIG"]);
      function exitCodeFor(error) {
        if (Number.isInteger(error?.exitCode)) {
          return error.exitCode;
        }
        if (typeof error?.code === "string" && /^E[A-Z]+$/.test(error.code)) {
          return WRITE_SYSCALLS.has(error.syscall) || WRITE_ERROR_CODES.has(error.code) ? EXIT_CODES.refused : EXIT_CODES.project;
        }
        return EXIT_CODES.findings;
      }
      
      // src/files.js
      var MAX_READ_BYTES = 5 * 1024 * 1024;
      function readTextFile(filePath) {
        return decodeUtf8(readFileBytes(filePath), filePath);
      }
      function readFileBytes(filePath, maxBytes = MAX_READ_BYTES) {
        return readFileAndStats(filePath, maxBytes).bytes;
      }
      var SAFE_READ_FLAGS = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0);
      var READ_CHUNK_BYTES = 64 * 1024;
      function readFileAndStats(filePath, maxBytes = MAX_READ_BYTES) {
        const { descriptor, stats } = openRegularFile(filePath, maxBytes);
        try {
          return { bytes: readAtMost(descriptor, filePath, stats.size, maxBytes), stats };
        } finally {
          fs.closeSync(descriptor);
        }
      }
      function readFilePrefix(filePath, length) {
        const { descriptor } = openRegularFile(filePath, Infinity);
        try {
          const buffer = Buffer.allocUnsafe(length);
          let filled = 0;
          let read;
          do {
            read = fs.readSync(descriptor, buffer, filled, length - filled, null);
            filled += read;
          } while (read > 0 && filled < length);
          return buffer.subarray(0, filled);
        } finally {
          fs.closeSync(descriptor);
        }
      }
      function forEachLine(filePath, maxLineBytes, visit) {
        const { descriptor } = openRegularFile(filePath, Infinity);
        try {
          const chunk = Buffer.allocUnsafe(READ_CHUNK_BYTES);
          let parts = [];
          let size = 0;
          let number = 0;
          const add = (bytes) => {
            size += bytes.length;
            if (size > maxLineBytes) {
              throw Object.assign(projectError(`${filePath}: line ${number + 1} is longer than ${maxLineBytes} bytes`), { longLine: number + 1 });
            }
          };
          let read;
          while ((read = fs.readSync(descriptor, chunk, 0, chunk.length, null)) > 0) {
            const data = chunk.subarray(0, read);
            let start = 0;
            for (let end = data.indexOf(10);end !== -1; end = data.indexOf(10, start)) {
              const piece = data.subarray(start, end);
              add(piece);
              number += 1;
              visit(parts.length === 0 ? piece : Buffer.concat([...parts, piece]), number);
              parts = [];
              size = 0;
              start = end + 1;
            }
            add(data.subarray(start));
            parts.push(Buffer.from(data.subarray(start)));
          }
        } finally {
          fs.closeSync(descriptor);
        }
      }
      function openRegularFile(filePath, maxBytes) {
        const named = fs.lstatSync(filePath);
        if (named.isSymbolicLink()) {
          throw symlinkReadRefusal(filePath);
        }
        if (!named.isFile()) {
          throw notRegularFileRefusal(filePath);
        }
        if (named.size > maxBytes) {
          throw projectError(`${filePath}: Refusing to read oversized file: ${named.size} bytes exceeds the ${maxBytes} byte limit`);
        }
        let descriptor;
        try {
          descriptor = fs.openSync(filePath, SAFE_READ_FLAGS);
        } catch (error) {
          throw error.code === "ELOOP" || error.code === "EMLINK" ? symlinkReadRefusal(filePath) : error;
        }
        try {
          const stats = fs.fstatSync(descriptor);
          if (!stats.isFile()) {
            throw notRegularFileRefusal(filePath);
          }
          return { descriptor, stats };
        } catch (error) {
          fs.closeSync(descriptor);
          throw error;
        }
      }
      function readAtMost(descriptor, filePath, size, maxBytes) {
        const chunks = [];
        let total = 0;
        let buffer = Buffer.allocUnsafe(Math.min(size, maxBytes) + 1);
        let filled = 0;
        let read;
        do {
          if (filled === buffer.length) {
            chunks.push(buffer);
            buffer = Buffer.allocUnsafe(READ_CHUNK_BYTES);
            filled = 0;
          }
          read = fs.readSync(descriptor, buffer, filled, buffer.length - filled, null);
          filled += read;
          total += read;
          if (total > maxBytes) {
            throw projectError(`${filePath}: Refusing to read oversized file: it grew past the ${maxBytes} byte limit while story was reading it`);
          }
        } while (read > 0);
        chunks.push(buffer.subarray(0, filled));
        return chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, total);
      }
      function symlinkReadRefusal(filePath) {
        return projectError(`${filePath}: Refusing to read through symlink`);
      }
      function notRegularFileRefusal(filePath) {
        return projectError(`${filePath}: Refusing to read: not a regular file`);
      }
      var FILE_ERROR_REASONS = {
        EACCES: "permission denied",
        EPERM: "permission denied",
        ENOENT: "no such file or folder",
        EISDIR: "it is a folder, not a file",
        ENOTDIR: "a part of the path is not a folder",
        EROFS: "the file system is read-only",
        ENOSPC: "no space left on the device",
        ENAMETOOLONG: "the name is too long",
        EDQUOT: "the disk quota is exceeded",
        EFBIG: "the file is too large",
        EIO: "an input/output error",
        EBUSY: "the file is in use"
      };
      var UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
      function decodeUtf8(buffer, filePath) {
        try {
          return UTF8.decode(buffer);
        } catch {
          const offset = invalidUtf8Offset(buffer);
          const byte = buffer[offset].toString(16).padStart(2, "0");
          throw projectError(`${filePath} is not valid UTF-8 (byte 0x${byte} at offset ${offset}): re-save it as UTF-8`);
        }
      }
      function invalidUtf8Offset(buffer) {
        let offset = 0;
        for (const character of buffer.toString("utf8")) {
          if (character === "�" && !(buffer[offset] === 239 && buffer[offset + 1] === 191 && buffer[offset + 2] === 189)) {
            break;
          }
          offset += Buffer.byteLength(character, "utf8");
        }
        return Math.max(0, Math.min(offset, buffer.length - 1));
      }
      function writeFile(filePath, contents, options = {}) {
        const existed = lstatIfExists(path.resolve(filePath)) !== null;
        assertWriteAllowed(filePath);
        try {
          if (planning > 0) {
            planWrite(filePath, options);
          } else {
            writeWholeFile(filePath, contents, options);
          }
        } catch (error) {
          throw aboutFlags(withExitCode(error, EXIT_CODES.refused), options.flags);
        }
        record(filePath, existed, "write");
      }
      function removeFile(filePath, options = {}) {
        const target = path.resolve(filePath);
        const existed = lstatIfExists(target) !== null;
        assertWriteAllowed(target);
        if (options.unchangedFrom !== undefined && currentText(target) !== options.unchangedFrom) {
          throw Object.assign(new Error(`${options.root ? projectPath(path.resolve(options.root), target) : target} changed on disk while story was deleting it, so it was left as it is`), { changedOnDisk: true, exitCode: EXIT_CODES.refused });
        }
        if (planning > 0) {
          if (!existed && !options.force) {
            throw Object.assign(new Error(`ENOENT: no such file or directory, lstat '${filePath}'`), { code: "ENOENT", path: filePath, syscall: "lstat" });
          }
          if (existed) {
            fs.accessSync(path.dirname(path.resolve(filePath)), fs.constants.W_OK);
          }
        } else {
          if (existed) {
            logUndo(target, null);
          }
          fs.rmSync(filePath, { force: Boolean(options.force) });
          if (existed) {
            afterChange(target);
          }
        }
        if (existed) {
          record(filePath, true, "delete");
        }
      }
      function removeDirectory(directory, { action = "delete" } = {}) {
        assertWriteAllowed(directory);
        if (planning > 0) {
          fs.accessSync(path.dirname(path.resolve(directory)), fs.constants.W_OK);
        } else {
          fs.rmdirSync(directory);
          syncFolder(path.dirname(path.resolve(directory)));
        }
        record(directory, true, action);
      }
      var FOLDER_FLAGS = fs.constants.O_RDONLY | (fs.constants.O_DIRECTORY ?? 0);
      function syncFolder(directory, platform = process.platform) {
        if (platform === "win32") {
          return;
        }
        let descriptor = null;
        try {
          descriptor = fs.openSync(directory, FOLDER_FLAGS);
          fs.fsyncSync(descriptor);
        } catch {} finally {
          if (descriptor !== null) {
            fs.closeSync(descriptor);
          }
        }
      }
      var refusals = [];
      function refuseWrites(root, message, run) {
        const refusal = { root: path.resolve(root), message };
        refusals.push(refusal);
        try {
          return run();
        } finally {
          refusals.splice(refusals.indexOf(refusal), 1);
        }
      }
      function assertWriteAllowed(target) {
        const refusal = planning > 0 ? undefined : refusals.find((entry) => isPathInside(entry.root, path.resolve(target)));
        if (refusal) {
          throw refusedError(refusal.message);
        }
      }
      var journals = [];
      function record(target, existed, action) {
        const key = path.resolve(target);
        const planned = planning > 0;
        for (const journal of journals) {
          if (journal.planned !== planned) {
            continue;
          }
          const entry = journal.entries.get(key);
          if (entry) {
            entry.action = action;
          } else {
            journal.entries.set(key, { existed, action });
          }
        }
      }
      function forgetChanges(target) {
        const key = path.resolve(target);
        for (const journal of journals) {
          for (const file of [...journal.entries.keys()]) {
            if (isPathInside(key, file)) {
              journal.entries.delete(file);
            }
          }
        }
      }
      function recordChanges(root, run) {
        const journal = { entries: new Map, planned: planning > 0 };
        journals.push(journal);
        let result;
        try {
          result = run();
        } catch (error) {
          if (error !== null && typeof error === "object") {
            error.changes = summarizeJournal(root, journal.entries);
          }
          throw error;
        } finally {
          journals.splice(journals.indexOf(journal), 1);
        }
        return { result, changes: summarizeJournal(root, journal.entries) };
      }
      function planChanges(root, run) {
        planning += 1;
        try {
          return recordChanges(root, run);
        } finally {
          planning -= 1;
        }
      }
      function isPlanning() {
        return planning > 0;
      }
      var planning = 0;
      function summarizeJournal(root, journal) {
        const base = path.resolve(root);
        const changes = [];
        for (const [file, { existed, action }] of journal) {
          const kind = action === "mkdir" ? "mkdir" : action === "delete" || action === "rmdir" ? existed ? action : null : existed ? "update" : "create";
          if (kind !== null) {
            changes.push({ action: kind, path: path.relative(base, file).split(path.sep).join("/") || "." });
          }
        }
        const key = (change) => change.action === "rmdir" ? `${change.path}/￿` : change.path;
        return changes.sort((a, b) => key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0);
      }
      function writeWholeFile(filePath, contents, options) {
        const target = prepareWriteTarget(filePath, options.root);
        const existing = lstatIfExists(target);
        if (existing) {
          fs.accessSync(target, fs.constants.W_OK);
        }
        const mode = options.mode ?? (existing ? existing.mode & 511 : 438);
        const temporary = temporaryPath(target);
        let created = false;
        try {
          const descriptor = fs.openSync(temporary, "wx", mode);
          created = true;
          try {
            fs.writeFileSync(descriptor, contents, "utf8");
            if (existing || options.mode !== undefined) {
              fs.fchmodSync(descriptor, mode);
            }
            fs.fsyncSync(descriptor);
          } finally {
            fs.closeSync(descriptor);
          }
          if (options.unchangedFrom !== undefined && currentText(target) !== options.unchangedFrom) {
            throw Object.assign(new Error(`${options.root ? projectPath(path.resolve(options.root), target) : target} changed on disk while story was updating it, so it was left as it is. Run the command again`), { changedOnDisk: true });
          }
          if (options.root) {
            try {
              assertSafeProjectParent(target, options.root);
            } catch {
              throw Object.assign(new Error(`Refusing to write ${projectPath(path.resolve(options.root), target)}: a folder on its path was replaced while story was writing it and now leads outside the project, so nothing was written there. Run the command again`), { changedOnDisk: true });
            }
          }
          logUndo(target, contents);
          fs.renameSync(temporary, target);
          afterChange(target);
        } catch (error) {
          if (created) {
            fs.rmSync(temporary, { force: true });
          }
          if (error.changedOnDisk) {
            throw error;
          }
          const action = existing?.nlink > 1 ? "replace hard-linked" : "write to";
          if (!created && (error.code === "EEXIST" || lstatIfExists(temporary) !== null)) {
            const reason = `something is already at the name of its temporary file (${path.basename(temporary)}, in the same folder), so it was left as it is. Run the command again`;
            throw Object.assign(new Error(`Cannot ${action} ${target}: ${reason}`), { code: "EEXIST", path: target, syscall: "write", reason });
          }
          throw Object.assign(new Error(`Cannot ${action} ${target}: ${error.code ?? error.message}`), { code: error.code, path: target, syscall: "write" });
        }
      }
      function planWrite(filePath, options) {
        const target = prepareWriteTarget(filePath, options.root);
        const existing = lstatIfExists(target);
        if (existing) {
          fs.accessSync(target, fs.constants.W_OK);
        }
        try {
          fs.accessSync(nearestExistingAncestor(path.dirname(target)).ancestor, fs.constants.W_OK);
          if (existing?.isDirectory()) {
            throw Object.assign(new Error("EISDIR"), { code: "EISDIR" });
          }
        } catch (error) {
          const action = existing?.nlink > 1 ? "replace hard-linked" : "write to";
          throw Object.assign(new Error(`Cannot ${action} ${target}: ${error.code ?? error.message}`), { code: error.code, path: target, syscall: "write" });
        }
        if (options.unchangedFrom !== undefined && currentText(target) !== options.unchangedFrom) {
          throw new Error(`${options.root ? projectPath(path.resolve(options.root), target) : target} changed on disk while story was updating it, so it was left as it is. Run the command again`);
        }
      }
      function currentText(target) {
        try {
          return readTextFile(target);
        } catch {
          return null;
        }
      }
      var TEMPORARY_FILE_PATTERN = /^\.(.+)\.story-[0-9a-f]+\.tmp$/;
      var RENAME_MARKER = ".story-rename.tmp";
      var UNDO_LOG = ".story-undo.tmp";
      var MAX_UNDO_LINE_BYTES = 6 * MAX_READ_BYTES + 64 * 1024;
      var UNSAFE_TEXT = /[\p{Cc}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u;
      function isUndoablePath(relative) {
        return relative === RENAME_MARKER || relative.endsWith(".md") && !relative.includes("\\") && !UNSAFE_TEXT.test(relative) && relative.split("/").every((part) => part !== "" && !part.startsWith("."));
      }
      function deleteUndoLog(root) {
        const file = path.join(path.resolve(root), UNDO_LOG);
        assertWriteAllowed(file);
        fs.rmSync(file, { force: true });
        syncFolder(path.dirname(file));
      }
      function contentHash(contents) {
        return crypto.createHash("sha256").update(contents).digest("hex");
      }
      var undoLog = null;
      function withUndoLog(root, command, run) {
        if (undoLog !== null) {
          return run();
        }
        const log = { root: path.resolve(root), command, descriptor: null, seen: new Set, changed: false };
        undoLog = log;
        let finished = false;
        try {
          const result = run();
          finished = true;
          return result;
        } finally {
          undoLog = null;
          if (log.descriptor !== null) {
            fs.closeSync(log.descriptor);
            if (finished || !log.changed) {
              deleteUndoLog(log.root);
            }
          }
        }
      }
      function logUndo(target, after) {
        const log = undoLog;
        if (log === null || !isPathInside(log.root, target)) {
          return;
        }
        const relative = projectPath(log.root, target);
        if (!isUndoablePath(relative)) {
          throw new Error(`${relative} is not a file the undo log of ${log.command} can put back`);
        }
        const entry = { path: relative, after: after === null ? null : contentHash(after) };
        if (!log.seen.has(relative)) {
          const stats = lstatIfExists(target);
          entry.before = stats?.isFile() ? readTextFile(target) : null;
          if (entry.before !== null) {
            entry.mode = stats.mode & 511;
          }
        }
        let line = `${JSON.stringify(entry)}
      `;
        const made = log.descriptor === null;
        if (made) {
          log.descriptor = fs.openSync(path.join(log.root, UNDO_LOG), "ax", 384);
          line = `${JSON.stringify({ command: log.command, started: new Date().toISOString() })}
      ${line}`;
        }
        fs.writeFileSync(log.descriptor, line, "utf8");
        fs.fsyncSync(log.descriptor);
        if (made) {
          syncFolder(log.root);
        }
        log.seen.add(relative);
      }
      function afterChange(target) {
        syncFolder(path.dirname(target));
        if (undoLog !== null) {
          undoLog.changed = true;
        }
      }
      var TEMPORARY_NAME_BYTES = 255 - ".".length - ".story-".length - 16 - ".tmp".length;
      function temporaryPath(target) {
        let name = "";
        let bytes = 0;
        for (const character of path.basename(target)) {
          bytes += Buffer.byteLength(character, "utf8");
          if (bytes > TEMPORARY_NAME_BYTES) {
            break;
          }
          name += character;
        }
        return path.join(path.dirname(target), `.${name}.story-${crypto.randomBytes(8).toString("hex")}.tmp`);
      }
      function prepareWriteTarget(filePath, root) {
        const target = path.resolve(filePath);
        if (root) {
          assertLexicallyInsideRoot(target, root);
          if (planning === 0 || lstatIfExists(path.resolve(root)) !== null) {
            assertExistingAncestorInsideRoot(path.dirname(target), root);
          }
        }
        makeDirectories(path.dirname(target));
        if (root && lstatIfExists(path.dirname(target)) !== null) {
          assertSafeProjectParent(target, root);
        }
        rejectSymlinkTarget(target, "write");
        return target;
      }
      function assertSafeProjectPath(filePath, root) {
        const target = path.resolve(filePath);
        assertLexicallyInsideRoot(target, root);
        assertSafeProjectParent(target, root);
        rejectSymlinkTarget(target, "read");
      }
      function assertSafeProjectDirectory(directory, root) {
        const target = path.resolve(directory);
        assertLexicallyInsideRoot(target, root);
        const stats = lstatIfExists(target);
        if (stats) {
          if (stats.isSymbolicLink()) {
            throw projectError(`Refusing to use symlinked project directory: ${target}`);
          }
          if (!stats.isDirectory()) {
            throw projectError(`Project path is not a directory: ${target}`);
          }
        }
        const rootReal = fs.realpathSync(path.resolve(root));
        const directoryReal = fs.realpathSync(target);
        if (!isPathInside(rootReal, directoryReal)) {
          throw projectError(`Refusing to use project directory outside root: ${target}`);
        }
      }
      function assertSafeProjectParent(filePath, root) {
        const rootReal = fs.realpathSync(path.resolve(root));
        const parentReal = fs.realpathSync(path.dirname(path.resolve(filePath)));
        if (!isPathInside(rootReal, parentReal)) {
          throw projectError(`Refusing to access project path outside root: ${filePath}`);
        }
      }
      function assertExistingAncestorInsideRoot(target, root) {
        const { ancestor: current } = nearestExistingAncestor(target);
        let rootReal;
        let currentReal;
        try {
          rootReal = fs.realpathSync(path.resolve(root));
          currentReal = fs.realpathSync(current);
        } catch {
          throw projectError(`Refusing to access project path outside root: ${target}`);
        }
        if (!isPathInside(rootReal, currentReal)) {
          throw projectError(`Refusing to access project path outside root: ${target}`);
        }
      }
      function nearestExistingAncestor(target, exists = lstatIfExists) {
        const missing = [];
        let current = path.resolve(target);
        while (!exists(current)) {
          const parent = path.dirname(current);
          if (parent === current) {
            break;
          }
          missing.unshift(path.basename(current));
          current = parent;
        }
        return { ancestor: current, missing };
      }
      function makeDirectories(directory) {
        const { ancestor, missing } = nearestExistingAncestor(directory);
        if (missing.length === 0 && fs.statSync(ancestor, { throwIfNoEntry: false })?.isDirectory() !== true) {
          throw directoryError(ancestor, "ENOTDIR");
        }
        if (planning > 0) {
          if (missing.length > 0) {
            const blocked = fs.statSync(ancestor, { throwIfNoEntry: false })?.isFile() === true;
            try {
              fs.accessSync(blocked ? path.join(ancestor, missing[0]) : ancestor, fs.constants.W_OK);
            } catch (error) {
              throw directoryError(path.join(ancestor, missing[0]), error.code ?? error.message);
            }
          }
          let planned = ancestor;
          for (const name of missing) {
            planned = path.join(planned, name);
            record(planned, false, "mkdir");
          }
          return;
        }
        if (missing.length > 0) {
          assertWriteAllowed(path.join(ancestor, missing[0]));
        }
        let current = ancestor;
        for (const name of missing) {
          current = path.join(current, name);
          try {
            fs.mkdirSync(current);
            syncFolder(path.dirname(current));
            record(current, false, "mkdir");
          } catch (error) {
            if (error.code !== "EEXIST" || lstatIfExists(current)?.isDirectory() !== true) {
              throw directoryError(current, error.code ?? error.message);
            }
          }
        }
      }
      function directoryError(directory, code) {
        return Object.assign(new Error(`Cannot create directory ${directory}: ${code}`), { code, path: directory, syscall: "mkdir", exitCode: EXIT_CODES.refused });
      }
      function assertLexicallyInsideRoot(filePath, root) {
        const rootPath = path.resolve(root);
        const target = path.resolve(filePath);
        if (!isPathInside(rootPath, target)) {
          throw projectError(`Refusing to access path outside project root: ${target}`);
        }
      }
      function rejectSymlinkTarget(filePath, action) {
        if (lstatIfExists(filePath)?.isSymbolicLink()) {
          throw projectError(`${filePath}: Refusing to ${action} through symlink`);
        }
      }
      function lstatIfExists(filePath) {
        return fs.lstatSync(filePath, { throwIfNoEntry: false }) ?? null;
      }
      function portablePath(filePath, sep = path.sep) {
        return typeof filePath !== "string" || sep === "/" ? filePath : filePath.split(sep).join("/");
      }
      function projectPath(root, file, paths = path) {
        return portablePath(paths.relative(root, file), paths.sep);
      }
      function isPathInside(root, target) {
        const relativePath = path.relative(root, target);
        return !path.isAbsolute(relativePath) && (relativePath === "" || !relativePath.split(path.sep).includes(".."));
      }
      var IGNORABLE_CHARACTERS = /\p{Default_Ignorable_Code_Point}/gu;
      function fileSystemName(name) {
        return name.split(":")[0].replace(IGNORABLE_CHARACTERS, "").replace(/[. ]+$/, "").toLowerCase();
      }
      function isShortNameOf(name, longName) {
        const match = /^([^~.]{1,6})~\d+(?:\.([^.]{1,3}))?$/.exec(name);
        const dot = longName.lastIndexOf(".");
        const base = (dot > 0 ? longName.slice(0, dot) : longName).replace(/[. ]/g, "");
        const extension = dot > 0 ? longName.slice(dot + 1, dot + 4) : "";
        return match !== null && match[1] === base.slice(0, 6) && (match[2] ?? "") === extension;
      }
      function isGitDirectoryName(name) {
        const lookedUp = fileSystemName(name);
        return lookedUp === ".git" || isShortNameOf(lookedUp, ".git");
      }
      function isInsideGitDirectory(target, base) {
        const resolved = path.resolve(target);
        const from = path.resolve(base);
        if (hasGitDirectoryBelow(from, resolved)) {
          return true;
        }
        const { ancestor, missing } = nearestExistingAncestor(resolved, fs.existsSync);
        return hasGitDirectoryBelow(fs.realpathSync.native(from), path.join(fs.realpathSync.native(ancestor), ...missing));
      }
      function hasGitDirectoryBelow(start, end) {
        return path.relative(start, end).split(path.sep).some((name) => name !== ".." && isGitDirectoryName(name));
      }
      
      // src/findings.js
      function err(code, message, file = null, chapter = null) {
        return { code, message, file: portablePath(file), chapter };
      }
      function warn(code, message, file = null, chapter = null) {
        return { code, message, file: portablePath(file), chapter };
      }
      var FINDING_CODES = {
        "unreadable-file": "error",
        "missing-required-path": "error",
        "windows-reserved-name": "warning",
        "stray-file": "warning",
        "nested-file": "warning",
        "symlinked-file": "warning",
        "interrupted-write": "warning",
        "interrupted-change": "error",
        "stale-registry": "warning",
        "stale-word-count": "warning",
        "todo-markers": "warning",
        "unclosed-comment": "warning",
        "ambiguous-scene-break": "warning",
        "outline-without-chapter-text": "warning",
        "unsupported-footnote": "warning",
        "no-scene-records": "warning",
        "empty-chapter": "warning",
        "beat-too-long": "warning",
        "missing-field": "error",
        "field-not-scalar": "error",
        "field-not-list": "error",
        "field-invalid-items": "error",
        "field-not-integer": "error",
        "field-not-number": "error",
        "field-not-boolean": "error",
        "field-not-text": "error",
        "field-below-minimum": "error",
        "unsupported-value": "error",
        "id-not-kebab": "error",
        "shared-id": "warning",
        "near-miss-key": "warning",
        "frontmatter-id": "warning",
        "wrong-type": "error",
        "story-id-mismatch": "error",
        "substitute-story-id": "warning",
        "entry-not-mapping": "error",
        "schema-too-new": "error",
        "schema-version-mismatch": "error",
        "invalid-book-number": "error",
        "invalid-ifid": "error",
        "invalid-cover": "error",
        "invalid-build-style": "error",
        "invalid-date": "error",
        "invalid-calendar": "error",
        "invalid-cli-config": "error",
        "invalid-filename": "error",
        "filename-number-mismatch": "error",
        "duplicate-chapter-number": "error",
        "duplicate-scene-number": "error",
        "unnumbered-without-title": "error",
        "invalid-choice": "error",
        "invalid-route-hours": "error",
        "duplicate-route": "warning",
        "deceased-without-died-in": "warning",
        "progression-fixed-field": "error",
        "progression-list-field": "error",
        "progression-duplicate": "error",
        "progression-out-of-order": "error",
        "duplicate-pass": "error",
        "duplicate-query": "error",
        "invalid-query": "error",
        "query-unknown-key": "warning",
        "exemption-pattern-too-short": "error",
        "exemption-unknown-code": "error",
        "exemption-code-not-dismissible": "error",
        "exemption-file-not-relative": "error",
        "exemption-too-broad": "error",
        "exemption-misspelled-key": "error",
        "exemption-unknown-key": "error",
        "exemption-chapter-not-carried": "error",
        "style-use-equals-avoid": "error",
        "style-sample-missing": "warning",
        "style-sample-own-chapters": "warning",
        "style-sample-duplicate": "warning",
        "unknown-word-list": "warning",
        "duplicate-session-date": "error",
        "research-no-sources": "warning",
        "research-unsettled": "warning",
        "research-unreviewed": "warning",
        "empty-matter": "warning",
        "matter-todo-markers": "warning",
        "permission-pending": "warning",
        "permission-no-rights-holder": "warning",
        "backslash-path": "warning",
        "form-length-range": "warning",
        "unused-target": "warning",
        "session-without-characters": "warning",
        "invalid-language": "error",
        "unsupported-writing-mode": "error",
        "unsupported-chapter-numerals": "error",
        "invalid-isbn": "error",
        "invalid-subject": "error",
        "too-many-keywords": "warning",
        "todo-placeholder": "warning",
        "author-and-authors": "warning",
        "unknown-label": "warning",
        "blank-label": "warning",
        "missing-reference": "error",
        "missing-backlink": "error",
        "backlink-type-mismatch": "error",
        "legacy-backlink-type": "warning",
        "route-to-self": "error",
        "broken-link": "error",
        "link-backslash": "error",
        "link-not-kebab": "error",
        "link-outside-project": "error",
        "unreachable-chapter": "warning",
        "series-link-backslash": "error",
        "series-link-self": "error",
        "series-link-unreadable": "error",
        "series-link-not-project": "error",
        "series-link-not-sibling": "error",
        "series-missing-backlink": "error",
        "series-link-other-series": "error",
        "revived-without-death": "error",
        "died-in-missing-chapter": "error",
        "revived-in-missing-chapter": "error",
        "revival-before-death": "error",
        "death-status-mismatch": "error",
        "revival-status-mismatch": "error",
        "posthumous-appearance": "error",
        "deceased-in-cast": "warning",
        "progression-deceased-in-cast": "warning",
        "progression-death-conflict": "warning",
        "pov-not-in-cast": "warning",
        "pov-scene-mismatch": "warning",
        "named-not-listed": "warning",
        "scene-cast-not-in-chapter": "warning",
        "scene-location-not-in-chapter": "warning",
        "cut-character-in-cast": "warning",
        "cut-character-in-arc": "warning",
        "cut-character-relationship": "warning",
        "chapter-numbering-start": "warning",
        "chapter-numbering-gap": "warning",
        "promise-payoff-before-plant": "error",
        "promise-payoff-missing": "error",
        "promise-plant-missing": "error",
        "promise-stale-planned": "warning",
        "promise-payoff-passed": "warning",
        "promise-unpaid": "warning",
        "question-resolved-before-introduced": "error",
        "question-resolution-missing": "error",
        "question-open-but-resolved": "error",
        "question-unanswered": "warning",
        "clue-payoff-before-plant": "error",
        "clue-payoff-missing": "error",
        "clue-plant-missing": "error",
        "clue-stale-planned": "warning",
        "clue-payoff-passed": "warning",
        "clue-unpaid": "warning",
        "complete-with-open-promise": "error",
        "complete-with-open-question": "error",
        "complete-with-open-clue": "error",
        "current-chapter-ahead": "error",
        "current-chapter-behind": "warning",
        "state-missing-character": "error",
        "state-missing-location": "error",
        "state-missing-artifact": "error",
        "state-missing-owner": "error",
        "state-missing-chapter": "error",
        "state-missing-knows": "error",
        "state-fact-not-kebab": "error",
        "state-duplicate-fact": "error",
        "state-duplicate-character": "warning",
        "state-duplicate-artifact": "warning",
        "state-status-conflict": "warning",
        "posthumous-learning": "error",
        "deceased-learning": "warning",
        "progression-deceased-learning": "warning",
        "learner-not-in-cast": "warning",
        "knowledge-not-recorded": "warning",
        "state-tracks-dead-character": "warning",
        "state-location-drift": "warning",
        "object-not-recorded": "warning",
        "state-object-drift": "warning",
        "state-differs-by-path": "warning",
        "gone-artifact-used": "error",
        "gone-artifact-mentioned": "error",
        "malformed-date": "warning",
        "malformed-time": "warning",
        "negative-travel-hours": "warning",
        "travel-hours-undated": "warning",
        "clock-backward": "warning",
        "travel-too-fast": "error",
        "route-same-time": "error",
        "route-too-fast": "error",
        "series-link-outside": "error",
        "series-too-many-books": "error",
        "series-conflict": "error",
        "series-id-missing": "warning",
        "series-title-mismatch": "warning",
        "series-cycle": "error",
        "duplicate-book-number": "error",
        "canon-name-mismatch": "warning",
        "canon-pronunciation-mismatch": "warning",
        "canon-death-status": "error",
        "canon-posthumous-appearance": "error",
        "canon-posthumous-learning": "error",
        "canon-destroyed-status": "warning",
        "canon-destroyed-artifact-used": "error",
        "canon-fact-relearned": "error",
        "prose-filter-words": "warning",
        "prose-adverbs": "warning",
        "prose-bookisms": "warning",
        "prose-avoided-spelling": "warning",
        "prose-uniform-sentences": "warning",
        "prose-similar-names": "warning",
        "prose-baseline-sentences": "warning",
        "prose-baseline-paragraphs": "warning",
        "prose-baseline-dialogue": "warning",
        "prose-baseline-filter-words": "warning",
        "prose-baseline-adverbs": "warning",
        "prose-baseline-small": "warning",
        "style-sample-unreadable": "warning",
        "pacing-no-hook": "warning",
        "pacing-no-sequel": "warning",
        "pacing-easy-wins": "warning",
        "pacing-resolution-run": "warning",
        "pacing-long-chapter": "warning",
        "pacing-short-chapter": "warning",
        "clue-unplanted": "warning",
        "clue-late-plant": "warning",
        "clue-no-characters": "warning",
        "clue-herring-unresolved": "warning",
        "clue-none-delayed": "warning",
        "voice-avoid": "warning",
        "voice-words-unused": "warning",
        "voice-sound-alike": "warning",
        "mention-not-named": "warning",
        "name-clash": "error",
        "name-look-alike": "warning",
        "name-shared-initial": "warning",
        "context-file-skipped": "warning",
        "release-undrafted": "warning",
        "story-missing-at-ref": "warning",
        "similarity-shared-passage": "warning",
        "similarity-no-reference-text": "warning",
        "derived-ifid": "warning",
        "scene-outside-book": "warning",
        "scene-no-location": "warning",
        "scene-unknown-location": "warning",
        "chapter-no-scenes": "warning",
        "scene-no-setting": "warning",
        "permission-pending-left-out": "warning",
        "matter-placement-left-out": "warning",
        "unknown-reference": "warning",
        "adopted-references": "warning",
        "prose-name-shared": "warning",
        "linked-book-id": "warning",
        "choices-dropped": "warning",
        "leftover-references": "warning",
        "stale-exemption": "warning",
        "stale-query": "warning",
        "ambiguous-references": "warning",
        "split-references": "warning",
        "split-scenes": "warning",
        "merge-conflicts": "warning",
        "folder-not-removed": "warning",
        "kept-story-options": "warning",
        "unsplit-chapter-lines": "warning",
        "unused-chapter-text": "warning",
        "usage-error": "error",
        "unusable-project": "error",
        "write-refused": "error",
        "command-failed": "error"
      };
      function codesAt(level) {
        return Object.keys(FINDING_CODES).filter((code) => FINDING_CODES[code] === level);
      }
      var PROJECTLESS_CODES = ["kept-story-options", "unsplit-chapter-lines", "unused-chapter-text"];
      function severityCodes() {
        return codesAt("warning").filter((code) => !PROJECTLESS_CODES.includes(code));
      }
      var CONTINUITY_ERROR_CODES = [
        "entry-not-mapping",
        "revived-without-death",
        "died-in-missing-chapter",
        "revived-in-missing-chapter",
        "revival-before-death",
        "death-status-mismatch",
        "revival-status-mismatch",
        "posthumous-appearance",
        "promise-payoff-before-plant",
        "promise-payoff-missing",
        "promise-plant-missing",
        "question-resolved-before-introduced",
        "question-resolution-missing",
        "question-open-but-resolved",
        "clue-payoff-before-plant",
        "clue-payoff-missing",
        "clue-plant-missing",
        "complete-with-open-promise",
        "complete-with-open-question",
        "complete-with-open-clue",
        "current-chapter-ahead",
        "state-missing-character",
        "state-missing-location",
        "state-missing-artifact",
        "state-missing-owner",
        "state-missing-chapter",
        "state-missing-knows",
        "state-fact-not-kebab",
        "state-duplicate-fact",
        "posthumous-learning",
        "gone-artifact-used",
        "gone-artifact-mentioned",
        "travel-too-fast",
        "route-same-time",
        "route-too-fast"
      ];
      function exemptionCodes() {
        return [...severityCodes(), ...CONTINUITY_ERROR_CODES];
      }
      var CHAPTER_CODES = [
        "posthumous-appearance",
        "deceased-in-cast",
        "progression-deceased-in-cast",
        "progression-death-conflict",
        "pov-not-in-cast",
        "pov-scene-mismatch",
        "named-not-listed",
        "mention-not-named",
        "scene-cast-not-in-chapter",
        "scene-location-not-in-chapter",
        "cut-character-in-cast",
        "posthumous-learning",
        "deceased-learning",
        "progression-deceased-learning",
        "learner-not-in-cast",
        "knowledge-not-recorded",
        "state-tracks-dead-character",
        "state-location-drift",
        "object-not-recorded",
        "state-object-drift",
        "state-differs-by-path",
        "gone-artifact-used",
        "gone-artifact-mentioned",
        "malformed-date",
        "malformed-time",
        "negative-travel-hours",
        "travel-hours-undated",
        "clock-backward",
        "travel-too-fast",
        "route-same-time",
        "route-too-fast"
      ];
      
      // src/clues.js
      var LIVE_STATUSES = new Set(["planned", "planted", "paid-off"]);
      function buildClueMatrix(project) {
        const chapters = [...project.chapters].sort((left, right) => left.number - right.number || left.id.localeCompare(right.id, "en"));
        const position = new Map(chapters.map((chapter, index) => [chapter.id, index]));
        const warnings = [];
        const rows = [];
        const clues = [...project.clues].sort((left, right) => {
          const leftPlant = position.get(left.planted) ?? Infinity;
          const rightPlant = position.get(right.planted) ?? Infinity;
          return leftPlant - rightPlant || left.id.localeCompare(right.id, "en");
        });
        for (const clue of clues) {
          const label = `clue ${clue.id}`;
          const file = projectPath(project.root, clue.file);
          const plantAt = position.get(clue.planted);
          const payoffAt = position.get(clue.payoff);
          rows.push({
            id: clue.id,
            title: clue.title,
            status: clue.status,
            redHerring: clue.redHerring,
            significanceDelayed: clue.significanceDelayed,
            cells: chapters.map((chapter, index) => cell(index === plantAt, index === payoffAt))
          });
          if (!LIVE_STATUSES.has(clue.status)) {
            continue;
          }
          if (clue.payoff !== "" && clue.planted === "") {
            warnings.push(warn("clue-unplanted", `${label} is revealed in ${clue.payoff} but never planted: readers cannot play fair`, file));
          }
          if (plantAt !== undefined && payoffAt !== undefined && payoffAt - plantAt >= 0 && payoffAt - plantAt < 2) {
            const where = payoffAt === plantAt ? "the same chapter as" : "the chapter before";
            warnings.push(warn("clue-late-plant", `${label} is planted in ${where} its reveal (${clue.planted} -> ${clue.payoff}): late plant gives readers no time to notice it`, file));
          }
          if (clue.characters.length === 0) {
            warnings.push(warn("clue-no-characters", `${label} lists no characters: record who could notice it`, file));
          }
          if (clue.redHerring && clue.payoff === "") {
            warnings.push(warn("clue-herring-unresolved", `${label} is a red herring with no payoff: record the chapter that debunks it`, file));
          }
        }
        const live = project.clues.filter((clue) => LIVE_STATUSES.has(clue.status));
        const genuine = live.filter((clue) => !clue.redHerring);
        if (genuine.length >= 3 && !genuine.some((clue) => clue.significanceDelayed)) {
          warnings.push(warn("clue-none-delayed", "no clue is significance-delayed: every clue announces its meaning when planted"));
        }
        return {
          chapters: chapters.map((chapter) => ({ id: chapter.id, number: chapter.number })),
          rows,
          totals: {
            clues: live.length,
            redHerrings: live.filter((clue) => clue.redHerring).length,
            planted: live.filter((clue) => clue.status !== "planned" && position.has(clue.planted)).length,
            revealed: live.filter((clue) => clue.status === "paid-off" && position.has(clue.payoff)).length
          },
          warnings
        };
      }
      function cell(planted, revealed) {
        if (planted && revealed) {
          return "x";
        }
        if (planted) {
          return "P";
        }
        if (revealed) {
          return "R";
        }
        return ".";
      }
      function formatClueMatrix(matrix) {
        const { totals } = matrix;
        const herrings = `${totals.redHerrings} red herring${totals.redHerrings === 1 ? "" : "s"}`;
        const lines = [`Clues: ${totals.clues} live (${herrings}), ${totals.planted} planted, ${totals.revealed} revealed`];
        if (matrix.rows.length === 0) {
          lines.push("", '- None: add clues with story add clue "Name" --planted chapter-02 --payoff chapter-09');
          return `${lines.join(`
      `)}
      `;
        }
        const width = Math.max(...matrix.rows.map((row) => row.id.length + (row.redHerring ? 2 : 0)), 4);
        const cellWidth = Math.max(2, ...matrix.chapters.map((chapter) => String(chapter.number).length)) + 1;
        const header = matrix.chapters.map((chapter) => String(chapter.number).padStart(cellWidth)).join("");
        lines.push("", `${"Clue".padEnd(width)} ${header}`);
        for (const row of matrix.rows) {
          const name = row.redHerring ? `${row.id} ~` : row.id;
          const flags = [row.status];
          if (row.significanceDelayed) {
            flags.push("delayed");
          }
          lines.push(`${name.padEnd(width)} ${row.cells.map((value) => value.padStart(cellWidth)).join("")}  ${flags.join(", ")}`);
        }
        lines.push("", "P planted, R revealed, x both, ~ red herring");
        return `${lines.join(`
      `)}
      `;
      }
      
      // src/calendar.js
      var CALENDAR_NAME = /^\s*[^\s\d,][^,]*$/u;
      var KINDS = ["month", "era", "weekdays", "hours-per-day"];
      var ERA_DIRECTIONS = ["forward", "backward"];
      var DEFAULT_HOURS_PER_DAY = 24;
      var MAX_HOURS_PER_DAY = 100;
      function isName(value) {
        return typeof value === "string" && CALENDAR_NAME.test(value);
      }
      function isCount(value) {
        return Number.isSafeInteger(value) && value >= 1;
      }
      function isDayLength(value) {
        return isCount(value) && value <= MAX_HOURS_PER_DAY;
      }
      function dayHours(calendar) {
        return calendar?.hoursPerDay ?? DEFAULT_HOURS_PER_DAY;
      }
      function dayLengthKnown(calendar) {
        return calendar?.hoursPerDay !== null;
      }
      function hasDayLength(entry) {
        return entry !== null && typeof entry === "object" && entry["hours-per-day"] !== undefined;
      }
      function invalidDayLength(entries, hoursPerDay) {
        const given = entries.filter(hasDayLength).length;
        if (given === 0) {
          return DEFAULT_HOURS_PER_DAY;
        }
        return given === 1 ? hoursPerDay : null;
      }
      function ordinalSuffix(day) {
        const tens = day % 100;
        if (tens >= 11 && tens <= 13) {
          return "th";
        }
        return ["th", "st", "nd", "rd"][day % 10] ?? "th";
      }
      function fold(value) {
        return String(value).trim().replace(/\s+/g, " ").toLowerCase();
      }
      function plainName(value) {
        return String(value).trim().replace(/\s+/g, " ");
      }
      function parseCalendar(value) {
        if (value === undefined) {
          return { calendar: null, problems: [] };
        }
        const problems = [];
        if (!Array.isArray(value)) {
          return { calendar: { invalid: true, hoursPerDay: invalidDayLength([value], null) }, problems: ["must be a list of month, era, weekdays, and hours-per-day entries"] };
        }
        const months = [];
        const eras = [];
        let weekdays = null;
        let firstWeekday = 0;
        let hoursPerDay = null;
        value.forEach((entry, index) => {
          const at = `entry ${index + 1}`;
          if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
            problems.push(`${at} must be a month, era, weekdays, or hours-per-day entry, such as - month: Thaw then days: 30`);
            return;
          }
          const before = problems.length;
          for (const key of ["month", "era", "abbrev"]) {
            if (entry[key] !== undefined && !isName(entry[key])) {
              problems.push(`${at} ${key} must be a name that does not start with a digit and has no comma, got ${entry[key]}`);
            }
          }
          for (const key of ["days", "years"]) {
            if (entry[key] !== undefined && !isCount(entry[key])) {
              problems.push(`${at} ${key} must be a whole number 1 or more, got ${entry[key]}`);
            }
          }
          if (entry["hours-per-day"] !== undefined && !isDayLength(entry["hours-per-day"])) {
            problems.push(`${at} hours-per-day must be a whole number from 1 to ${MAX_HOURS_PER_DAY}, got ${entry["hours-per-day"]}`);
          }
          if (entry.direction !== undefined && !ERA_DIRECTIONS.includes(entry.direction)) {
            problems.push(`${at} direction must be forward or backward, got ${entry.direction}`);
          }
          if (entry.weekdays !== undefined && (!Array.isArray(entry.weekdays) || !entry.weekdays.every(isName))) {
            problems.push(`${at} weekdays must be a list of names that do not start with a digit and have no comma, such as [Hearthday, Stoneday]`);
          }
          if (entry["first-weekday"] !== undefined && typeof entry["first-weekday"] !== "string") {
            problems.push(`${at} first-weekday must be text, got ${entry["first-weekday"]}`);
          }
          if (entry.month !== undefined && entry.days === undefined) {
            problems.push(`${at} month ${entry.month} needs days, such as days: 30`);
          }
          const kinds = KINDS.filter((kind) => entry[kind] !== undefined);
          if (kinds.length !== 1) {
            problems.push(`${at} must name exactly one of month, era, weekdays, or hours-per-day${kinds.length > 1 ? `, not ${kinds.join(" and ")}` : ""}`);
            return;
          }
          if (problems.length > before) {
            return;
          }
          if (kinds[0] === "month") {
            months.push({ name: plainName(entry.month), days: entry.days });
          } else if (kinds[0] === "era") {
            eras.push({
              name: plainName(entry.era),
              abbrev: entry.abbrev === undefined ? "" : plainName(entry.abbrev),
              backward: entry.direction === "backward",
              years: entry.years ?? null
            });
          } else if (kinds[0] === "hours-per-day") {
            if (hoursPerDay !== null) {
              problems.push(`${at} repeats hours-per-day; give it once`);
            } else {
              hoursPerDay = entry["hours-per-day"];
            }
          } else if (weekdays !== null) {
            problems.push(`${at} repeats weekdays; list them all in one entry`);
          } else if (entry.weekdays.length === 0) {
            problems.push(`${at} weekdays needs at least one name`);
          } else {
            weekdays = entry.weekdays.map(plainName);
            if (entry["first-weekday"] !== undefined) {
              firstWeekday = weekdays.findIndex((name) => fold(name) === fold(entry["first-weekday"]));
              if (firstWeekday < 0) {
                problems.push(`${at} first-weekday must be one of the weekdays (${weekdays.join(", ")}), got ${entry["first-weekday"]}`);
              }
            }
          }
        });
        if (!value.some((entry) => entry !== null && typeof entry === "object" && entry.month !== undefined)) {
          problems.push("needs at least one month entry, such as - month: Thaw then days: 30");
        }
        repeated(months.map((month) => month.name), "month", problems);
        repeated(weekdays ?? [], "weekday", problems);
        repeated(eras.flatMap((era) => [era.name, era.abbrev].filter(Boolean)), "era name or abbrev", problems);
        eras.forEach((era, index) => {
          if (era.backward && index > 0) {
            problems.push(`era ${era.name} counts backward, but only the first era may`);
          }
          if (!era.backward && era.years === null && index < eras.length - 1) {
            problems.push(`era ${era.name} needs years, the number of years it lasts, since another era follows it`);
          }
        });
        if (problems.length > 0) {
          return { calendar: { invalid: true, hoursPerDay: invalidDayLength(value, hoursPerDay) }, problems };
        }
        let start = 1;
        for (const era of eras) {
          if (!era.backward) {
            era.start = start;
            start += era.years ?? 0;
          }
        }
        let offset = 0;
        for (const month of months) {
          month.offset = offset;
          offset += month.days;
        }
        return {
          calendar: { invalid: false, months, yearDays: offset, weekdays: weekdays ?? [], firstWeekday, eras, hoursPerDay: hoursPerDay ?? DEFAULT_HOURS_PER_DAY },
          problems: []
        };
      }
      function repeated(names, what, problems) {
        const seen = new Set;
        for (const name of names) {
          if (seen.has(fold(name))) {
            problems.push(`${what} ${name} appears more than once`);
          }
          seen.add(fold(name));
        }
      }
      function leadingName(text, names) {
        const lower = text.toLowerCase();
        const sorted = [...names].sort((left, right) => right.length - left.length);
        for (const name of sorted) {
          const key = name.toLowerCase();
          if (lower.startsWith(key) && (lower.length === key.length || lower[key.length] === " " || lower[key.length] === ",")) {
            return { name, rest: text.slice(key.length).replace(/^,?\s*/, "") };
          }
        }
        return null;
      }
      function calendarShaped(value, calendar) {
        const text = plainName(value);
        return /^\d/.test(text) || calendar.weekdays.length > 0 && leadingName(text, calendar.weekdays) !== null;
      }
      function parseCalendarDate(value, calendar) {
        const original = String(value).trim();
        let text = plainName(value);
        let stated = null;
        if (calendar.weekdays.length > 0) {
          const weekday = leadingName(text, calendar.weekdays);
          if (weekday) {
            stated = weekday.name;
            text = weekday.rest;
          }
        }
        let day;
        let month;
        let rest;
        const numeric = /^(\d+)-(\d{1,2})-(\d{1,2})(?: (.+))?$/.exec(text);
        if (numeric) {
          const index = Number(numeric[2]);
          if (index < 1 || index > calendar.months.length) {
            return { problem: `month ${numeric[2]} is not one of the calendar's ${calendar.months.length} months` };
          }
          month = calendar.months[index - 1];
          day = Number(numeric[3]);
          rest = `${numeric[1]}${numeric[4] === undefined ? "" : ` ${numeric[4]}`}`;
        } else {
          const named = /^(\d+)(st|nd|rd|th)? (.+)$/i.exec(text);
          if (!named) {
            return { problem: "write it as day, month, and year, such as 3 Thaw 301 AE or 301-02-03 AE" };
          }
          day = Number(named[1]);
          if (named[2] !== undefined && named[2].toLowerCase() !== ordinalSuffix(day)) {
            return { problem: `write ${named[1]}${ordinalSuffix(day)}, not ${named[1]}${named[2]}` };
          }
          const names = calendar.months.map((entry) => entry.name);
          const readings = [named[3], .../^of /i.test(named[3]) ? [named[3].slice(3)] : []].map((candidate) => leadingName(candidate, names)).filter(Boolean);
          const found = readings.find((reading) => /^\d/.test(reading.rest)) ?? readings[0];
          if (!found) {
            return { problem: `it names no calendar month (${names.join(", ")})` };
          }
          month = calendar.months.find((entry) => entry.name === found.name);
          rest = found.rest;
        }
        const yearMatch = /^(\d+)(?: (.+))?$/.exec(rest);
        if (!yearMatch) {
          return { problem: "the month must be followed by a year, such as 3 Thaw 301 AE" };
        }
        if (day < 1 || day > month.days) {
          return { problem: `${month.name} has ${month.days} days, not ${day}` };
        }
        const year = Number(yearMatch[1]);
        if (year < 1) {
          return { problem: "years start at 1" };
        }
        const absolute = absoluteYear(year, yearMatch[2], calendar);
        if (absolute.problem) {
          return absolute;
        }
        const days = (absolute.year - 1) * calendar.yearDays + month.offset + day - 1;
        if (!Number.isSafeInteger(days)) {
          return { problem: "the year is too far from year 1 to count its days" };
        }
        if (stated !== null) {
          const actual = calendar.weekdays[mod(days + calendar.firstWeekday, calendar.weekdays.length)];
          if (actual !== stated) {
            return { problem: `that day is a ${actual}, not a ${stated}` };
          }
        }
        return { text: original, days };
      }
      function absoluteYear(year, eraText, calendar) {
        if (calendar.eras.length === 0) {
          return eraText === undefined ? { year } : { problem: `the calendar has no eras, so ${eraText} is not one` };
        }
        const era = eraText === undefined ? calendar.eras[calendar.eras.length - 1] : calendar.eras.find((entry) => fold(entry.name) === fold(eraText) || entry.abbrev !== "" && fold(entry.abbrev) === fold(eraText));
        if (!era) {
          return { problem: `${eraText} is not one of the calendar's eras (${calendar.eras.map((entry) => entry.abbrev || entry.name).join(", ")})` };
        }
        if (era.years !== null && year > era.years) {
          return { problem: `${era.name} lasts ${era.years} years, not ${year}` };
        }
        return { year: era.backward ? 1 - year : era.start + year - 1 };
      }
      function mod(value, divisor) {
        return (value % divisor + divisor) % divisor;
      }
      
      // src/exemptions.js
      import path4 from "node:path";
      
      // src/frontmatter.js
      var FRONTMATTER_PATTERN = /^(?:\uFEFF)?---[ \t]*\r?\n(?:([\s\S]*?)\r?\n)?---[ \t]*(?:\r?\n|$)/;
      var OPENING_PATTERN = /^(?:\uFEFF)?---[ \t]*\r?\n/;
      function parseFrontmatter(markdown, filePath = "markdown") {
        const match = FRONTMATTER_PARTS_PATTERN.exec(markdown);
        if (!match) {
          if (OPENING_PATTERN.test(markdown)) {
            throw projectError(`${filePath} has unclosed YAML frontmatter: add a line holding only --- after the last field`);
          }
          throw projectError(`${filePath} is missing YAML frontmatter`);
        }
        const [whole, , raw = "", separator] = match;
        return {
          data: parseYamlBlocks(yamlSource(raw, separator)).data,
          body: markdown.slice(whole.length),
          raw
        };
      }
      function scalarText(data, key) {
        return data?.[SCALAR_TEXT]?.[key];
      }
      var SCALAR_TEXT = Symbol("scalarText");
      function stringifyFrontmatter(data) {
        const lines = ["---"];
        for (const [key, value] of Object.entries(data)) {
          if (Array.isArray(value)) {
            if (value.length === 0) {
              lines.push(`${key}: []`);
              continue;
            }
            lines.push(`${key}:`);
            for (const item of value) {
              lines.push(...stringifyItem(key, item));
            }
          } else {
            lines.push(`${key}: ${formatScalar(value)}`);
          }
        }
        lines.push("---", "", "");
        return lines.join(`
      `);
      }
      function replaceFrontmatter(markdown, data, bodyOverride) {
        const match = FRONTMATTER_PARTS_PATTERN.exec(markdown);
        if (!match) {
          throw new Error("Cannot replace missing YAML frontmatter");
        }
        const [whole, opening, raw = "", separator, closingLine] = match;
        const eol = opening.endsWith(`\r
      `) ? `\r
      ` : `
      `;
        const closing = `${separator ?? eol}${closingLine}`;
        const crlfClose = closing.startsWith("\r");
        const { data: original, blocks } = parseYamlBlocks(yamlSource(raw, separator));
        const generatedEnd = eol === `\r
      ` ? "\r" : "";
        const lines = [];
        const written = new Set;
        for (const block of blocks) {
          if (block.key === undefined) {
            lines.push(block.line);
            continue;
          }
          if (!Object.prototype.hasOwnProperty.call(data, block.key)) {
            continue;
          }
          written.add(block.key);
          const value = data[block.key];
          if (isDeepEqual(original[block.key], value)) {
            lines.push(...block.lines);
          } else {
            lines.push(...stringifyEntry(block.key, value, block, generatedEnd));
          }
        }
        for (const [key, value] of Object.entries(data)) {
          if (!written.has(key)) {
            lines.push(...stringifyEntry(key, value, undefined, generatedEnd));
          }
        }
        const rest = bodyOverride === undefined ? markdown.slice(whole.length) : String(bodyOverride);
        if (lines.length === 0) {
          return `${opening}${separator ?? ""}${closingLine}${rest}`;
        }
        let body = lines.join(`
      `);
        if (crlfClose && body.endsWith("\r")) {
          body = body.slice(0, -1);
        }
        return `${opening}${body}${closing}${rest}`;
      }
      var FRONTMATTER_PARTS_PATTERN = /^((?:\uFEFF)?---[ \t]*\r?\n)(?:([\s\S]*?)(\r?\n))?(---[ \t]*(?:\r?\n|$))/;
      function yamlSource(raw, separator) {
        return raw !== "" && separator === `\r
      ` ? `${raw}\r` : raw;
      }
      function stringifyEntry(key, value, original = {}, lineEnd = "") {
        if (!Array.isArray(value) || value.length === 0 || original.flow && !value.some(isNested)) {
          return [`${key}: ${inlineText(value, original)}${lineEnd}`];
        }
        const originalItems = original.items ?? [];
        const indent = original.indent ?? "  ";
        const lines = [`${key}:${original.comment ?? ""}${lineEnd}`, ...original.leading ?? []];
        const take = unusedByValue(originalItems);
        const reused = new Set;
        const matches = value.map((item) => {
          const reuse = take(item);
          if (reuse) {
            reused.add(reuse);
          }
          return reuse;
        });
        value.forEach((item, index) => {
          if (matches[index]) {
            lines.push(...matches[index].before, ...matches[index].lines);
            return;
          }
          const partial = isPlainObject(item) ? originalItems.find((candidate) => !reused.has(candidate) && isPlainObject(candidate.value) && sameKeys(candidate.value, item) && candidate.childIndent === indent.length + 2) : undefined;
          if (partial) {
            reused.add(partial);
            lines.push(...partial.before);
            const fresh = stringifyItem(key, item, indent, partial);
            Object.keys(item).forEach((childKey, childIndex) => {
              const source = partial.keyLines[childIndex];
              lines.push(...source.before);
              lines.push(...isDeepEqual(partial.value[childKey], item[childKey]) ? source.lines : [`${fresh[childIndex]}${lineEnd}`]);
            });
            return;
          }
          const positional = originalItems[index];
          let replaced;
          if (positional && !reused.has(positional)) {
            reused.add(positional);
            lines.push(...positional.before);
            replaced = positional;
          }
          lines.push(...stringifyItem(key, item, indent, replaced).map((line) => `${line}${lineEnd}`));
        });
        return lines;
      }
      function inlineText(value, source = {}) {
        const text = source.flow && Array.isArray(value) ? flowText(value, source.flow) : formatScalar(value);
        return `${text}${source.comment ?? ""}`;
      }
      function flowText(value, entries) {
        const take = unusedByValue(entries);
        return flowList(value, (item) => take(item)?.text ?? formatFlowEntry(item));
      }
      function flowList(value, textOf) {
        const texts = value.map(textOf);
        if (/^TODO\b/i.test(texts[0] ?? "")) {
          texts[0] = quote(String(value[0]));
        }
        return `[${texts.join(", ")}]`;
      }
      function unusedByValue(entries) {
        const queues = new Map;
        for (const entry of entries) {
          const entryKey = valueKey(entry.value);
          if (!queues.has(entryKey)) {
            queues.set(entryKey, { entries: [], next: 0 });
          }
          queues.get(entryKey).entries.push(entry);
        }
        return (value) => {
          const queue = queues.get(valueKey(value));
          const entry = queue?.entries[queue.next];
          if (entry && isDeepEqual(entry.value, value)) {
            queue.next += 1;
            return entry;
          }
          return;
        };
      }
      function isNested(value) {
        return Array.isArray(value) || isPlainObject(value);
      }
      function sameKeys(left, right) {
        const leftKeys = Object.keys(left);
        const rightKeys = Object.keys(right);
        return leftKeys.length === rightKeys.length && leftKeys.every((childKey, index) => childKey === rightKeys[index]);
      }
      function stringifyItem(key, item, indent = "  ", original = {}) {
        if (!isPlainObject(item)) {
          return [`${indent}- ${inlineText(item, original.keyLines ? undefined : original)}`];
        }
        const entries = Object.entries(item);
        if (entries.length === 0) {
          throw new Error("Cannot stringify empty mapping in " + key);
        }
        const keyed = new Map((original.keyLines ?? []).map((source) => [source.key, source]));
        return entries.map(([childKey, childValue], index) => `${indent}${index === 0 ? "- " : "  "}${childKey}: ${inlineText(childValue, keyed.get(childKey))}`);
      }
      function valueKey(value) {
        return JSON.stringify(value) ?? String(value);
      }
      function isDeepEqual(left, right) {
        if (left === right) {
          return true;
        }
        if (Array.isArray(left) || Array.isArray(right)) {
          return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((entry, index) => isDeepEqual(entry, right[index]));
        }
        if (isPlainObject(left) && isPlainObject(right)) {
          const leftKeys = Object.keys(left);
          const rightKeys = Object.keys(right);
          return leftKeys.length === rightKeys.length && leftKeys.every((key, index) => key === rightKeys[index] && isDeepEqual(left[key], right[key]));
        }
        return false;
      }
      var KEY_PATTERN = /^([A-Za-z0-9_-]+):([^\r]*)$/;
      var ITEM_KEY_PATTERN = /^([A-Za-z0-9_-]+):([ \t][^\r]*)?$/;
      var LIST_ITEM_PATTERN = /^( *)-([ \t][^\r]*)?$/;
      var BLOCK_HEADER_PATTERN = /^([|>])(?:([-+])([1-9])?|([1-9])([-+])?)?$/;
      function parseYamlBlocks(source, firstLine = 2) {
        const rawLines = source === "" ? [] : source.split(`
      `);
        const lines = rawLines.map((line) => line.replace(/\r$/, ""));
        const data = Object.create(null);
        const texts = Object.create(null);
        const blocks = [];
        const fail = (index, hint, problem = `Unsupported frontmatter line: ${lines[index].trim()}`) => {
          throw projectError(`${problem} (line ${ind
  • SKILL.md 21.2 KB
    ---
    name: story-maintenance
    description: This skill should be used when the user asks to "validate", "reindex", "repair registries", "check links", "run the continuity, pacing, clue, voice, or name checks", "count words", "summarize a story project", "import an existing manuscript", "export a manuscript", "run a build", "generate a diagram", "record revision-pass status with `story passes`", "run the story CLI", or wants deterministic maintenance on a Story Skills markdown project. It runs the CLI and reads its output; NOT for judging a finding or revising the story to fix it (use revision-continuity for continuity errors and revision passes, plot-structure for pacing, voice-style for voices and prose, genre-craft for clues and fair play), or for sharing a review copy with readers (use feedback-triage).
    ---
    
    # Story Maintenance
    
    ## Overview
    
    Run deterministic maintenance for Story Skills projects. Use the CLI for structure validation, registry rebuilds, word counts, link checks, continuity checks, project reports, next-action reports, pacing, clue, voice, and name checks, revision-pass tracking, Mermaid diagrams, schema migration, entity helpers, manuscript import, and manuscript export and builds. The creative skills still own story decisions; this skill handles mechanical consistency. It runs a check and reports what it found, and fixes mechanical problems such as broken references and stale registries; deciding what a finding means for the story, and revising the story to fix it, belongs to revision-continuity, plot-structure, voice-style, or genre-craft.
    
    ## CLI Access
    
    Prefer the first available command:
    
    1. `story <command>` - when the package bin is installed
    2. `node scripts/story.js <command>` - bundled fallback, resolving `scripts/story.js` relative to this skill folder
    3. `node <checkout>/bin/story.js <command>` - only when the user names a Story Skills repository checkout or you are working in one, where `<checkout>` is its path
    
    Write the script in forms 2 and 3 as an absolute path and run the command from the folder you would run `story` from, so `.` and other relative paths keep their meaning. Use Node, not Bun or the `story` script in the checkout's `package.json`: Bun would load that folder's `bunfig.toml` (which can run code) and `.env`, and a package script runs from the checkout's root, so `.` would be the checkout, not the story project.
    
    If none of these are available, perform the requested maintenance manually using the shared conventions in `references/conventions.md`.
    
    Run the installed or bundled CLI in place. Do not copy `scripts/story.js` into the user's story project, and do not create project-local build scripts, generator scripts, or bulk writer scripts to generate story content. Story projects should remain markdown-first, plus explicitly requested exports such as `dist/manuscript.md`.
    
    ## Commands
    
    Run commands from the story project root, or pass the story path explicitly.
    
    After any change to story files, run the maintenance block, in this order:
    
    ```shell
    story reindex .
    story wordcount . --write
    story check .
    ```
    
    `reindex` rebuilds the registries, `wordcount --write` updates chapter counts (and reindexes again), and `check` then runs `validate`, `links`, and `continuity` over the settled files. `check` exits 1 only on errors; warnings print but do not fail it (`--strict` makes them fail). Every skill ends its edits with this block, followed by any skill-specific checks such as `story clues .` or `story pacing .`.
    
    For the full command set, run `story --help` (every command) or `story <command> --help` (its flags), or read `references/commands.md`, which gives an example of each.
    
    The check commands' detailed rules live in `references/continuity-checks.md`, one section per command. Read the matching section before explaining or acting on a finding. The editing commands' detail (`add`, `rename`, `move`, `remove`, `split`, `merge`, `import`) lives in `references/editing-commands.md`, and each build format's in `references/builds.md`.
    
    Use:
    
    - `check` at the end of an editing session, after `reindex` and `wordcount --write`, and before reporting the project clean: it runs `validate`, `links`, and `continuity` over one scan, lists each finding once, and exits 1 if any of them has an error (`--strict` also fails on warnings). Run the three separately when you want one check's findings
    - `validate` after initialization and at the end of any multi-file edit
    - `reindex` after adding, removing, renaming, or moving any entity file. It rebuilds the character, location, system, faction, artifact, arc, chapter, scene, question, promise, clue, and glossary registries. `story add` reindexes itself; a hand-written file does not
    - `wordcount --write` after writing or revising chapters
    - `links` after changing character relationships, notable locations, arc participants, or chapter references
    - `continuity` after drafting or revising a chapter, and whenever the user asks about contradictions, dead characters appearing, unfired setups, or stale state. It checks deaths and revivals, promise, question, and clue ordering, Chekhov gaps, casts and `status: cut` characters, `continuity/state.md`, prop custody, and clock and route plausibility; intentional exceptions go in `continuity/exemptions.md`. In a book whose chapters carry `choices`, read Branching books there too. Rules: `references/continuity-checks.md` (continuity).
    - `compare` after a revision pass, or when the user asks what changed since a draft: it needs exactly one of `--ref` (a git ref), `--against` (another copy of the project), or `--snapshot` (a snapshot saved with `story snapshot <name>`, for a project without git; `--list` shows them), and `--anchor <label>` maps review-copy paragraph labels to the current text. Rules: `references/continuity-checks.md` (compare).
    - `snapshot --restore <name>` only when the user asks to go back to a snapshot. It deletes every project markdown file the snapshot lacks, and removes (`rmdir`) the folders that leaves empty, so show them the `--dry-run` first and restore only with their approval. It saves the project as `before-restore-<id>-<n>` before changing anything and reindexes after; pass on the undo command it prints, then run `story validate`.
    - `similarity` when the user asks whether a passage echoes another text too closely; it is advisory, so report what it found, never a verdict. `--against` takes a file, folder, or git ref, and `--snapshot` a saved snapshot. Rules: `references/continuity-checks.md` (similarity).
    - `progress` when the user asks how far along the book is, whether they will make a deadline, or after a writing session: it reports words against `story.md` `target-words`, days left to `deadline` and words a day needed, chapter `target-words`, pace from `progress.md`, and, once a session is logged, today's words against `daily-target-words`, the current and longest streak (days outside `writing-days` never break it), and the words of the last four weeks (`--weeks <n>`, 1 to 52, for a longer or shorter history). For a serial with `release-every` (days, or months such as `1 month`) and `release-start` in `story.md` (or a chapter `release-date`), `progress` and `next` print the next episode due, and warn `release-undrafted` when an episode due within `release-warn-days` days (default three), or past due, has no prose or no chapter yet; draft that episode first. Once `story.md` has `status: complete`, the cadence stops at the last chapter, so no episode past it is scheduled or warned about. `--log` records today's total there (`--date YYYY-MM-DD` to backfill); only log when the user keeps a log (the project has a `progress.md`) or asks for it
    - `pacing` when the user asks about pacing, sagging middles, or chapter endings, and after drafting or restructuring chapters: per-chapter words, scene outcomes, sequels, and hooks, with warnings for runs and outliers. Rules: `references/continuity-checks.md` (pacing).
    - `clues` for mysteries and any story with a clue ledger: a clue-by-chapter matrix with fair-play warnings. Rules: `references/continuity-checks.md` (clues).
    - `voices` when dialogue voices may blur or during a line pass: per-character dialogue fingerprints, with warnings for look-alike voices and `voice-avoid`/`voice-words` misses. Rules: `references/continuity-checks.md` (voices).
    - `list <kind>` when you need the files that match some frontmatter (the draft chapters, the scenes a character is in, the open questions) instead of reading every file: `story list chapters --where status=draft --where pov=ilse --path '<project>'`. `key=value` also matches a list that contains the value, `key!=value` is the opposite, `key` means set, and `'!key'` unset; repeated filters must all match. `story list --query '<name>' --path '<project>'` runs a query saved in `story.md` `queries` (a `name`, a `kind`, and `where: [status=draft, pov=ilse]`), and any `--where` adds to its filters. Add `--json` and read `data.items[].file`
    - `--json` when you need to read a result rather than show it to the user: `validate`, `links`, `continuity`, `check`, `series`, `report`, `next`, `doctor`, `knowledge`, `context`, `list`, `progress`, `timeline`, `prose`, `pacing`, `clues`, `voices`, `similarity`, `names`, `mentions`, `compare`, `passes`, `diagram`, `synopsis`, `export`, `build`, `init`, `import`, and the commands that change the project then print one JSON object on stdout (`apiVersion`, `command`, `ok`, `data`, `diagnostics`, `writes`). `ok` is true exactly when the exit code is 0; each diagnostic has `severity`, `file`, `message`, `code` (the finding's rule, such as `stale-word-count`, the name a `story.md` `severity` entry takes), and `check` (the check that raised it). `report`, `next`, and `doctor` always have `ok: true`, so read their `data.checks` or error diagnostics instead (`doctor --fix` is the exception: its `ok` is false while a check still has an error, and `data.fix` lists the repairs it made). `writes` lists the files a command wrote, such as the file `build` made, whose path is also `data.outFile`
    - `passes` to track named revision passes in `story.md` `revision-passes`: `--init` writes the default ladder, `--start`/`--done <pass>` update one, and no flag prints the checklist. Rules: `references/continuity-checks.md` (passes).
    - `names` before naming a character, place, faction, artifact, system, or glossary term: `story names '<name>' ...` reports exact clashes as errors and look-alikes as warnings. Rules: `references/continuity-checks.md` (names).
    - `mentions` before removing an entity or renaming it without `--prose`, since neither changes prose: `story mentions <kind> <id> --path '<project>'` lists each place the chapter prose names it, with file and line, so you can update the text. With no entity it warns `named-not-listed` (prose names a character the chapter's `pov`, `characters`, and `mentions` leave out; `continuity` reports this too) and `mention-not-named` (a `mentions` entry the prose never names). Fix a true omission in the chapter frontmatter; when the chapter uses a name the bible lacks, add it to the entity's `aliases`; ask the user before dropping a mention. Rules: `references/continuity-checks.md` (mentions).
    - `diagram` when the user wants a picture of the story's structure: `story diagram <kind>` prints Mermaid source generated from frontmatter, or writes it with `--out <file>` (`--path <project>` sets the project). Kinds: `relationships` (character graph, family edges styled distinctly: the family tree), `locations` (map-graph from location `routes`, edges labelled with hours), `timeline` (dated scenes and chapters in story-time order), `clues` (clue plant to reveal flow per chapter), and `arcs` (arcs to the chapters that advance them). GitHub, many editors, and mermaid.live render it; regenerate rather than hand-edit
    - `timeline` when the user asks what happens when, how flashbacks sit against the main line, whose POV dominates, or where a character drops out. It is read-only; `continuity` owns clock errors. Rules: `references/continuity-checks.md` (timeline).
    - `prose` when the user asks for a prose check or before sharing a draft: advisory per-chapter and manuscript-wide prose metrics against `style-sheet.md`. Rules: `references/continuity-checks.md` (prose).
    - `series` when `story.md` has `follows` or `precedes` links to other books; it orders the linked sequels and prequels by chronology and checks shared canon. Use `init --follows <path>` or `init --precedes <path>` to start a linked book, and see the `series-continuity` skill for carrying canon across. Rules: `references/continuity-checks.md` (series).
    - `import` when the user has an existing manuscript or chapter drafts and wants a Story Skills project built from them. `import --force` deletes every `chapter-NN.md` in `chapters/` first, so confirm with the user before forcing an import over drafted chapters. Rules: `references/editing-commands.md` (import).
    - `report` when the user asks for project status, inventory, progress, or a quick health summary
    - `next` before a drafting session to identify the next deterministic action
    - `doctor` when the user asks what is stale, broken, or inconsistent. `doctor --fix` applies only the mechanical repairs the checks call for (putting back a `split`, `merge`, `move`, `rename`, or `remove` that stopped part way, `migrate` for missing registries or an old `schema-version`, `wordcount --write` for stale counts, `reindex` for stale registries), never touches prose, then reports what remains; it exits 1 while any check still has an error. Use it to clear mechanical findings in one step, then work through the remaining actions yourself or with the user
    - `migrate` when a project has an older schema version or missing v2 paths
    - `--dry-run` on `add`, `rename`, `move`, `split`, `merge`, `remove`, `reindex`, `migrate`, `wordcount --write`, `doctor --fix`, `snapshot`, `passes`, `progress --log`, `diagram --out`, `synopsis --out`, `export`, `build`, `init`, or `import` before a change that touches many files, or when the user wants to see what a command will change first: it lists each file it would create, update, or delete and changes nothing (`--json` gives the list as `data.changes`). Show the user the list, then run the command without `--dry-run`
    - `add`, `rename`, `move`, and `remove` for deterministic entity file operations when they fit the requested change. `add` takes ids, not names, for reference options, and ids stay ASCII kebab-case (`--id` sets one by hand). Rules: `references/editing-commands.md` (add, rename, and remove).
    - `move` whenever a chapter's number or a scene's chapter or position changes, never a hand rename: chapter and scene ids encode their numbers. Rules: `references/editing-commands.md` (move).
    - `split` and `merge` to split a chapter in two or join two neighbouring chapters, instead of moving prose and renumbering by hand. Run `--dry-run` first and show the user the list. Rules: `references/editing-commands.md` (split and merge).
    - `init --form <form>` records `form` in `story.md` (`novel`, `novella`, `novelette`, `short-story`, `flash`, `serial`, `picture-book`, `chapter-book`) and sets a default `target-words` when none is given; `validate` warns when `target-words` is outside the form's usual range and `report` shows the form
    - `add matter` when the user wants a dedication, epigraph, copyright page, acknowledgments, author's note, about-the-author, or also-by page. Never invent acknowledgments, biographical facts, or copyright details: ask the user for them. Rules: `references/editing-commands.md` (add matter).
    - `add research` when the story relies on a real-world fact. Rules: `references/editing-commands.md` (add research).
    - `export` only when the user asks for a combined manuscript at a specific path; it includes front and back matter
    - `build` when the user asks to build the book artifact: markdown, EPUB, DOCX, Shunn, HTML, print, narration, metadata, Fountain, Twee, and ink outputs in `dist/`, and a codex site in `dist/codex/`, with front and back matter. Rules: `references/builds.md` (build).
    - `build --format html` when the user wants a review or reading copy for people who never open a terminal. Rules: `references/builds.md` (html).
    - `build --format print` for a print-ready interior; `--pdf` renders a PDF with an engine the user has installed. Rules: `references/builds.md` (print).
    - `build --format narration` for an audiobook narration script, and `build --format metadata` for a retailer metadata sheet. Rules: `references/builds.md` (narration, metadata).
    - `build --format twee` and `build --format ink` for a branching book. Rules: `references/builds.md` (twee, ink).
    - `build --format codex` when the user wants a browsable story bible; add `--spoilers` only for the author's own copy. Rules: `references/builds.md` (codex).
    - `build --format epub` and `build --format shunn` for an ebook or a Shunn manuscript. Rules: `references/builds.md` (epub, shunn).
    - `knowledge` when the user asks what a character knew at a given chapter: `story knowledge <character-id> --at <chapter-id>`, marking each fact `reader-knowledge` or `character-knowledge` with `do not reveal`. Rules: `references/continuity-checks.md` (knowledge).
    - `context` before drafting a chapter or scene: `story context <chapter-or-scene-id> [--budget <tokens>] [--scenes <n>]` prints what the target needs in priority order, leaving out later chapters. Rules: `references/continuity-checks.md` (context).
    - `add clue` when the user plants a new clue: `story add clue 'Name' --planted chapter-02 --payoff chapter-05`; omit `--payoff` when it is not yet known, and pass `--red-herring` for a clue meant to mislead. Rules: `references/continuity-checks.md` (add clue).
    - `synopsis` when the user wants a mechanical synopsis: the first sentence of `story.md`'s `## Synopsis` section, then each arc's Setup, Rising Action, Climax, and Resolution. One page is 500 words and three pages is 1500. `story synopsis [--pages 1|3] [--out file]`. The output is a scaffold; the `submission` skill rewrites it into an agent-ready synopsis
    
    ## Project CLI Configuration
    
    `story.md` may carry `cli-defaults` (default flags per command, such as `- command: build` with `format: html`) and `severity` (named warnings promoted with `level: error` or silenced with `level: off`, such as `- warning: todo-markers`). A flag on the command line always wins. Edit these fields only when the user asks for project-wide defaults or stricter checks, then run `story validate`: it rejects unknown commands, flags, warning codes, and levels, and while either field is invalid the other commands refuse to run. Every warning line ends with its code in brackets, such as `[todo-markers]`, which is the name a `severity` entry takes; an `error:` line ending in a code is a warning the project has promoted. Errors cannot be overridden. `docs/cli-reference.md` lists every code under Finding codes.
    
    ## Failure Handling
    
    - Treat CLI errors as actionable maintenance findings.
    - Read the exit code to decide what to do next: `1` means the check found `error:` findings to fix in the project; `2` means the command line was wrong (fix the command, not the project); `3` means the path is not a usable story project or a file it needs does not parse (repair that file, or point at the folder with `story.md`); `4` means a write was refused (the target exists, is project source, is locked by another story command, or is not writable), so resolve the conflict rather than forcing it.
    - Fix broken references, missing required files, stale registries, or incorrect word counts when the requested task implies doing so.
    - Do not overwrite creative prose or story content merely to satisfy a mechanical check.
    - If a validation warning reflects intentional user data, report it rather than silently changing it.
    - If a command stops with `Cannot reindex: fix these files first` (or `Cannot count words: ...`, `Cannot build: ...`), repair the frontmatter of each listed file, then rerun it. `rename`, `move`, and `remove` report `<file>: <error>; nothing was changed` for the same cause, and `<file> is missing YAML frontmatter; nothing was changed` when an entity file, a CLI registry (the `_index.md` in an entity folder, `matter/`, or `research/`), or fixed project file (`story.md`, `style-sheet.md`, `progress.md`, `plot/timeline.md`, `continuity/state.md`, `continuity/exemptions.md`) has none; plain skill notes such as `continuity/motifs.md`, or an `_index.md` in a folder of the user's own such as `notes/`, do not block them.
    - A file-system failure reads `Cannot <open|list|check|replace|delete|write to> <path>: <reason>` (such as `permission denied`); fix the file or folder permissions, or the path, rather than the story content.
    - If `story reindex` fails on a corrupt `plot/_index.md`, do not hand-edit story content to work around it: restore the index frontmatter from git, or delete `plot/_index.md` so reindex rebuilds it, then rerun.
    
    ## Shared Conventions
    
    Every story skill follows the shared conventions in [`references/conventions.md`](references/conventions.md): kebab-case ids and filenames, YAML frontmatter on every story-project file, `_index.md` registry tables that `story reindex` rebuilds (never edit them by hand), bidirectional links between entities, `characters` for who is on the page and `mentions` for who is only referred to, `status: deceased` plus `died-in: chapter-{NN}` for deaths, and no project-local generator or build scripts (run only the installed or bundled Story CLI). Other skills link to that file and repeat this summary, so update both together.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related