feat: add authoring tools for notes, characters, and arcs

- Introduced commands for managing notes: list, get, create, and append.
- Added character management commands: list, get, create, and update.
- Implemented arc management commands: list, get, create, and update.
- Enhanced AI setup to include new character and arc functionalities.
- Updated package.json to reflect new commands and tools.
- Improved workspace settings handling for story, notes, and arc folders.
- Added input validation and prompts for user interactions in commands.
This commit is contained in:
Erik van der Boom 2026-05-29 00:03:54 +02:00
parent 6b12a89744
commit 288b491cc4
41 changed files with 4134 additions and 868 deletions

View file

@ -30,7 +30,7 @@ bindery-merge/ ← Shared merge logic (chapter discovery, export orchestrati
vscode-ext/ ← VS Code extension (published to Marketplace)
src/
extension.ts ← activation, all 17+ commands, format-on-save handler
extension.ts ← activation, all host commands, format-on-save handler
workspace.ts ← reads/writes .bindery/settings.json + translations.json
merge.ts ← re-exports from @bindery/merge (pure delegation)
format.ts ← typography transforms via @bindery/core
@ -40,7 +40,7 @@ vscode-ext/ ← VS Code extension (published to Marketplace)
Obsidian-plugin/ ← Obsidian plugin (Community Plugins marketplace)
src/
main.ts ← activation, all 17+ commands (feature parity with vscode-ext)
main.ts ← activation, all host commands (feature parity with vscode-ext)
workspace.ts ← Vault I/O, settings management (Obsidian-specific)
merge.ts ← Obsidian-specific wrapper around @bindery/merge
ai-setup.ts ← per-target instruction file generation
@ -87,7 +87,7 @@ No annotation = mcpb submission rejection.
| calls external API or tool (eg search API) | `openWorldHint: true` (in addition to one of the above) |
### Adding a new tool — checklist
When adding a tool, touch **all four** of these: missing any breaks one surface.
When adding a tool, touch **all five** of these: missing any breaks one surface.
1. **`mcp-ts/src/tools.ts`** — add the implementation function (`toolXxx(root, args): string`)
2. **`mcp-ts/src/index.ts`** — `server.registerTool(...)` with Zod schema, description, and ONE annotation
@ -127,7 +127,7 @@ host-specific UI/activation):
| Command | VS Code | Obsidian | Description |
|---|---|---|---|
| `bindery.init` | ✅ | ✅ | Create `.bindery/settings.json` + `translations.json` |
| `bindery.init` | ✅ | ✅ | Create `.bindery/settings.json`, `translations.json`, generated `.bindery/README.md`, and the opinionated Arc / Notes / Characters / COWORK / memory / status scaffold |
| `bindery.setupAI` | ✅ | ✅ | Generate CLAUDE.md / copilot-instructions.md / skills / AGENTS.md |
| `bindery.formatDocument` | ✅ | ✅ | Typography formatting (curly quotes, em-dash, ellipsis) |
| `bindery.formatFolder` | ✅ | ✅ | Recursively format all .md files in a folder |
@ -141,6 +141,23 @@ host-specific UI/activation):
| `bindery.addTranslation` | ✅ | ✅ | Add a cross-language glossary entry |
| `bindery.addLanguage` | ✅ | ✅ | Add a new language and scaffold its story folder |
| `bindery.openTranslations` | ✅ | ✅ | Show path to translations.json (edit in host editor) |
| `bindery.noteList` / `note-list` | ✅ | ✅ | List story notes under the configured notes folder |
| `bindery.noteGet` / `note-get` | ✅ | ✅ | Read a story note by path |
| `bindery.noteCreate` / `note-create` | ✅ | ✅ | Create a story note |
| `bindery.noteAppend` / `note-append` | ✅ | ✅ | Append markdown content to a story note |
| `bindery.characterList` / `character-list` | ✅ | ✅ | List structured character profiles |
| `bindery.characterGet` / `character-get` | ✅ | ✅ | Read a character profile by name |
| `bindery.characterCreate` / `character-create` | ✅ | ✅ | Create a character profile and update the character index |
| `bindery.characterUpdate` / `character-update` | ✅ | ✅ | Update a character profile and refresh the index row |
| `bindery.arcList` / `arc-list` | ✅ | ✅ | List structured arc files |
| `bindery.arcGet` / `arc-get` | ✅ | ✅ | Read an arc file by path |
| `bindery.arcCreate` / `arc-create` | ✅ | ✅ | Create an arc file and update the arc index |
| `bindery.arcUpdate` / `arc-update` | ✅ | ✅ | Update an arc file and refresh the arc index |
| `bindery.memoryList` / `memory-list` | ✅ | ✅ | List durable memory files |
| `bindery.memoryAppend` / `memory-append` | ✅ | ✅ | Append a dated memory entry |
| `bindery.memoryCompact` / `memory-compact` | ✅ | ✅ | Compact a memory file with backup |
| `bindery.chapterStatusGet` / `chapter-status-get` | ✅ | ✅ | Show chapter progress state |
| `bindery.chapterStatusUpdate` / `chapter-status-update` | ✅ | ✅ | Upsert chapter progress entries |
| `bindery.registerMcp` | ✅ | — | Write .vscode/mcp.json for Claude/Codex MCP discovery (VS Code-only) |
| `bindery.showMcpConfig` | — | ✅ | Display MCP configuration snippet (Obsidian-only) |
@ -157,9 +174,11 @@ host-specific UI/activation):
| `cursor` | `.cursor/rules` |
| `agents` | `AGENTS.md` |
Skills: `review`, `brainstorm`, `memory`, `translate`, `translation-review`, `status`, `continuity`, `read-aloud`, `read-in`, `proof-read`.
Skills: `review`, `brainstorm`, `memory`, `translate`, `translation-review`, `status`, `continuity`, `read-aloud`, `read-in`, `proof-read`, `plan-beats`, `character-setup`.
The **memory skill** uses `memory_list``memory_append``memory_compact`. Do not fall back to `get_text` + Edit tool for memory writes.
The **memory skill** uses `memory_list``memory_append``memory_compact` for session decisions and `note_list` / `note_get` / `note_create` / `note_append` for canonical story notes. Do not fall back to `get_text` + Edit tool for memory or note writes when a structured tool exists.
Current authoring-tool boundary: note, character, arc, memory, and chapter-status MCP/LM tools exist and have matching VS Code/Obsidian host command wrappers. Dedicated session-focus and inbox-processing tools/commands are still planned.
### AI setup versioning
`FILE_VERSION_INFO` in `bindery-core/src/templates.ts` is a per-file version table/map (a Record keyed by output path) that controls staleness detection.
@ -200,6 +219,10 @@ npm run build # builds bindery-core, bindery-merge, compiles all others
# Type-check only (no emit)
npx tsc --noEmit
# Parity guards
node scripts/check-tool-parity.mjs
node scripts/check-command-parity.mjs
```
### Release workflow

View file

@ -168,6 +168,9 @@ jobs:
- name: Tool parity guard
run: node scripts/check-tool-parity.mjs
- name: Command parity guard
run: node scripts/check-command-parity.mjs
- name: bindery-core coverage
run: npm run test:coverage --workspace=bindery-core

View file

@ -22,8 +22,8 @@ The **Bindery** extension provides:
- **Chapter merge & export** — Markdown, DOCX, EPUB, PDF output via Pandoc + LibreOffice, with auto-detection of tool paths
- **Dialect & translation management** — extensible substitution rules for dialect exports (e.g. US→UK), plus cross-language glossaries in `.bindery/translations.json`
- **Multi-language support** — configurable per-language chapter labelling and folder structure, with dialect derivatives
- **Workspace config** — `.bindery/settings.json` for project-level settings
- **MCP integration** — registers 25 Bindery tools for GitHub Copilot Chat and writes `.vscode/mcp.json` for Claude / Codex
- **Opinionated workspace setup** — `.bindery/settings.json` plus Arc, Notes, Characters, COWORK, memory, and chapter-status scaffolding
- **MCP integration** — registers 38 Bindery tools for GitHub Copilot Chat and writes `.vscode/mcp.json` for Claude / Codex
Install from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=option-a.bindery) or:
@ -44,12 +44,16 @@ A [Model Context Protocol](https://modelcontextprotocol.io/) server that exposes
- **Optional semantic search** — set `BINDERY_OLLAMA_URL` for semantic reranking, or enable a full semantic index for precomputed embedding search
- **Version tracking**`get_review_text` returns a structured git diff **plus** any regions wrapped in `<!-- Bindery: Review start --> ... <!-- Bindery: Review stop -->` markers (so committed work-in-progress can still be reviewed). `git_snapshot` saves progress as a git commit. Git is auto-initialized during workspace setup if available
- **Translation & dialect management** — glossary entries and dialect substitution rules in `.bindery/translations.json`, queryable and updatable by agents
- **Opinionated authoring scaffold**`init_workspace` creates `COWORK.md`, `Arc/index.md`, `Arc/Overall.md`, `Arc/Acts/`, `Notes/Inbox.md`, `Notes/Characters/index.md`, structured note folders, `.bindery/memories/global.md`, and `.bindery/chapter-status.json`
- **Story note management** — agents can list, read, create, and append notes under the configured notes folder while `get_notes` remains compatible with older recursive note layouts
- **Session memory** — persistent `.bindery/memories/` files for cross-session decisions, with append, list, and compact operations
- **Chapter status tracking** — per-chapter progress tracker (`draft`, `in-progress`, `done`, `needs-review`)
- **Structured arc & character management** — agents can create/update arc files and character profiles using structured tools that keep indexes in sync
- **Host command parity** — VS Code and Obsidian expose command-palette actions for notes, characters, arcs, memory, and chapter status, backed by the same structured tool functions agents use
- **Multi-book support** — configure one or more books via `--book Name=path` CLI args or `BINDERY_BOOKS` env var; every tool call specifies which book to use by name (agents never see raw paths)
- **Container/mount aware** — agents in sandboxed environments (e.g. Cowork) can call `identify_book` with their working directory to discover their book name, even when mount paths differ from the configured paths
See [mcpb/README.md](mcpb/README.md) for the full 27-tool reference and usage examples.
See [mcpb/README.md](mcpb/README.md) for the full 40-tool reference and usage examples.
### [obsidian-plugin/](obsidian-plugin/) — Obsidian Plugin
@ -59,9 +63,10 @@ The **Bindery** Obsidian plugin provides the same feature set as the VS Code ext
- **Chapter merge & export** — Markdown, DOCX, EPUB, PDF output via Pandoc + LibreOffice
- **Dialect & translation management** — extensible substitution rules and glossaries
- **Multi-language support** — configurable per-language chapter labelling
- **Workspace config** — `.bindery/settings.json` for vault-level settings
- **Opinionated workspace setup** — `.bindery/settings.json` plus Arc, Notes, Characters, COWORK, memory, and chapter-status scaffolding
- **AI instruction generation** — Generate CLAUDE.md, copilot-instructions.md, .cursor/rules, AGENTS.md
- **Review markers** — Mark regions for agent feedback
- **Opinionated authoring commands** — command-palette actions for notes, characters, arcs, memory, and chapter status
- **MCP snippet generator** — Copy JSON for Claude Desktop integration
Build and install:
@ -88,7 +93,7 @@ Packages the MCP server as a `.mcpb` file for one-click installation in Claude D
1. Install the [Bindery extension](https://marketplace.visualstudio.com/items?itemName=option-a.bindery) from the Marketplace
2. Open your book folder in VS Code
3. Run `Bindery: Initialize Workspace` to create `.bindery/settings.json` (also initializes a git repo if not present)
3. Run `Bindery: Initialize Workspace` to create `.bindery/settings.json`, `.bindery/translations.json`, the generated `.bindery/README.md` capability reference, and the opinionated Arc / Notes / Characters / COWORK / memory / status scaffold
4. Run `Bindery: Register MCP Server` to create `.vscode/mcp.json` (primarily for Claude/Codex discovery; not needed for GitHub Copilot Chat because the extension registers the tools automatically)
5. Tools are now available in GitHub Copilot Chat, Claude for VS Code, and Codex
@ -229,7 +234,7 @@ MIT — see [LICENSE](LICENSE).
- **Chapter merge & export** (MD, DOCX, EPUB, PDF via Pandoc + LibreOffice)
- **Dialect & translation management** — extensible substitution rules and glossaries
- **Multi-language support** — configurable chapter labels and folder structures
- **Workspace initialization**`.bindery/settings.json` and `.bindery/translations.json`
- **Workspace initialization**`.bindery/settings.json`, `.bindery/translations.json`, generated `.bindery/README.md`, and the opinionated Arc / Notes / Characters / COWORK / memory / status scaffold
- **AI instruction generation** — CLAUDE.md, copilot-instructions.md, .cursor/rules, AGENTS.md
- **Review markers** — wrap text in `<!-- Bindery: Review start/stop -->` for agent feedback
- **MCP config snippet** — JSON for Claude Desktop / Cowork integration
@ -282,5 +287,5 @@ cd obsidian-plugin && npm test
The CI workflow (`.github/workflows/ci.yml`) runs on every push and pull request:
1. Builds and tests `bindery-core`, `mcp-ts`, `vscode-ext`, and `obsidian-plugin` (Ubuntu, Windows, macOS).
2. Runs the **tool parity guard** (`scripts/check-tool-parity.mjs`) on coverage job.
2. Runs the **tool parity guard** (`scripts/check-tool-parity.mjs`) and **command parity guard** (`scripts/check-command-parity.mjs`) on the coverage job.
3. Enforces **coverage thresholds** (statements 80%, branches 65%, functions 90%, lines 80%) across packages.

View file

@ -33,6 +33,8 @@ export interface DialectConfig {
label?: string;
}
export type ArcGranularity = 'overall' | 'act' | 'chapter' | 'thread' | 'custom';
// ─── Settings Schema ──────────────────────────────────────────────────────
/**
@ -56,6 +58,16 @@ export interface WorkspaceSettings {
/** Claude skills previously chosen when running Set Up AI Files. */
aiSkills?: string[];
storyFolder?: string;
/** Story notes folder relative to the book root. */
notesFolder?: string;
/** Story architecture folder relative to the book root. */
arcFolder?: string;
/** Character profile folder relative to the book root. */
charactersFolder?: string;
/** Current-focus/session file relative to the book root. */
sessionFile?: string;
/** Preferred arc planning granularity. */
arcGranularity?: ArcGranularity;
mergedOutputDir?: string;
mergeFilePrefix?: string;
formatOnSave?: boolean;
@ -75,6 +87,13 @@ export const BINDERY_FOLDER = '.bindery';
export const SETTINGS_FILENAME = 'settings.json';
export const TRANSLATIONS_FILENAME = 'translations.json';
export const DEFAULT_STORY_FOLDER = 'Story';
export const DEFAULT_NOTES_FOLDER = 'Notes';
export const DEFAULT_ARC_FOLDER = 'Arc';
export const DEFAULT_CHARACTERS_FOLDER = 'Notes/Characters';
export const DEFAULT_SESSION_FILE = 'COWORK.md';
export const DEFAULT_ARC_GRANULARITY: ArcGranularity = 'act';
// ─── Path helpers ─────────────────────────────────────────────────────────────
export function getBinderyFolder(root: string): string {
@ -89,6 +108,36 @@ export function getTranslationsPath(root: string): string {
return path.join(root, BINDERY_FOLDER, TRANSLATIONS_FILENAME);
}
// ─── Authoring Path Defaults ─────────────────────────────────────────────────
type AuthoringPathSettings = Pick<WorkspaceSettings,
'storyFolder' | 'notesFolder' | 'arcFolder' | 'charactersFolder' | 'sessionFile' | 'arcGranularity'
>;
export function getStoryFolder(settings: AuthoringPathSettings | null): string {
return settings?.storyFolder ?? DEFAULT_STORY_FOLDER;
}
export function getNotesFolder(settings: AuthoringPathSettings | null): string {
return settings?.notesFolder ?? DEFAULT_NOTES_FOLDER;
}
export function getArcFolder(settings: AuthoringPathSettings | null): string {
return settings?.arcFolder ?? DEFAULT_ARC_FOLDER;
}
export function getCharactersFolder(settings: AuthoringPathSettings | null): string {
return settings?.charactersFolder ?? path.posix.join(getNotesFolder(settings), 'Characters');
}
export function getSessionFile(settings: AuthoringPathSettings | null): string {
return settings?.sessionFile ?? DEFAULT_SESSION_FILE;
}
export function getArcGranularity(settings: AuthoringPathSettings | null): ArcGranularity {
return settings?.arcGranularity ?? DEFAULT_ARC_GRANULARITY;
}
// ─── Readers ─────────────────────────────────────────────────────────────────
export function readWorkspaceSettings(root: string): WorkspaceSettings | null {

View file

@ -28,6 +28,8 @@ import * as continuity from './templates/skills/continuity';
import * as readAloud from './templates/skills/read-aloud';
import * as readIn from './templates/skills/read-in';
import * as proofRead from './templates/skills/proof-read';
import * as planBeats from './templates/skills/plan-beats';
import * as characterSetup from './templates/skills/character-setup';
import type { TemplateContext, TemplateMeta } from './templates/context';
@ -54,6 +56,8 @@ const TEMPLATES: Record<string, TemplateModule> = {
'read-aloud': readAloud,
'read-in': readIn,
'proof-read': proofRead,
'plan-beats': planBeats,
'character-setup': characterSetup,
};
// ─── File version metadata ────────────────────────────────────────────────────
@ -76,7 +80,7 @@ export const FILE_VERSION_INFO: Record<string, { version: number; label: string;
* Top-level file templates: 'claude', 'copilot', 'cursor', 'agents', 'bindery-readme'
* Skill templates: 'review', 'brainstorm', 'memory', 'translate',
* 'translation-review', 'status', 'continuity', 'read-aloud',
* 'read-in', 'proof-read'
* 'read-in', 'proof-read', 'plan-beats', 'character-setup'
*/
export function renderTemplate(name: string, ctx: TemplateContext): string {
const t = TEMPLATES[name];

View file

@ -2,13 +2,13 @@ import { audienceNote, languageSection, type TemplateContext, type TemplateMeta
export const meta: TemplateMeta = {
file: 'AGENTS.md',
version: 8,
version: 9,
label: 'agents instructions',
zip: null,
};
export function render(ctx: TemplateContext): string {
const { title, author, description, genre, storyFolder, notesFolder, arcFolder, memoriesFolder } = ctx;
const { title, author, description, genre, storyFolder, notesFolder, arcFolder, charactersFolder, sessionFile, arcGranularity, memoriesFolder } = ctx;
const lines: string[] = [`# Agent Instructions — ${title}`, ''];
lines.push('## Project overview');
if (genre) { lines.push(`${genre} novel.`); }
@ -19,18 +19,22 @@ export function render(ctx: TemplateContext): string {
languageSection(ctx),
'',
'## Start of session',
`1. Read \`${memoriesFolder}/global.md\` for cross-chapter context.`,
`2. If working on a specific chapter, read \`${memoriesFolder}/chXX.md\` if it exists.`,
'3. Check `.claude/skills/` for shared slash workflows before improvising a bespoke process.',
`1. Read \`${sessionFile}\` for user-owned current focus and handoff notes if it exists.`,
`2. Read \`${memoriesFolder}/global.md\` for cross-chapter context.`,
`3. If working on a specific chapter, read \`${memoriesFolder}/chXX.md\` if it exists.`,
'4. Check `.claude/skills/` for shared slash workflows before improvising a bespoke process.',
'',
'## Story files',
`- Chapter files are \`.md\` files in \`${storyFolder}/\`, organized in act subfolders.`,
`- Arc files live in \`${arcFolder}/\`; default planning granularity is \`${arcGranularity}\`. Treat these as story architecture.`,
`- Character profiles live in \`${charactersFolder}/\`; use one profile per character plus the index.`,
'- HTML comments `<!-- -->` are writer notes — treat as context only, not prose.',
'- Quotation marks and em-dashes are managed by the Bindery extension. Do not normalize them.',
'',
'## Shared skill workflows',
'- Shared workflows live in `.claude/skills/` and can be used by agents beyond Claude when the runtime supports workspace skills.',
'- Prefer `/read-in`, `/review`, `/translation-review`, `/translate`, `/memory`, `/continuity`, `/status`, `/read-aloud`, and `/proof-read` when the user is asking for one of those structured tasks.',
'- Prefer `/read-in`, `/review`, `/translation-review`, `/translate`, `/memory`, `/continuity`, `/status`, `/read-aloud`, `/proof-read`, `/plan-beats`, and `/character-setup` when the user is asking for one of those structured tasks.',
'- Use `arc_*` tools for story structure, `character_*` tools for cast profiles, `note_*` tools for story notes, `memory_*` tools for session decisions, and `chapter_status_*` tools for progress when available.',
'',
'## Writing guidelines',
'- Do not rewrite paragraphs unless explicitly asked. Suggest edits only.',
@ -44,7 +48,9 @@ export function render(ctx: TemplateContext): string {
'| File | Contains |',
'|---|---|',
`| \`${arcFolder}/\` | Story arc files for overall and per-act structure and beats |`,
`| \`${notesFolder}/\` | Story notes, like character profiles and world rules |`,
`| \`${charactersFolder}/\` | Character index and one profile per character |`,
`| \`${notesFolder}/\` | Story notes, like world rules, scene ideas, inbox, and research |`,
`| \`${sessionFile}\` | User-owned current focus, handoff notes, and personal working context |`,
`| \`${memoriesFolder}/global.md\` | Cross-session decisions |`,
);
return lines.join('\n') + '\n';

View file

@ -2,13 +2,13 @@ import type { TemplateContext, TemplateMeta } from './context';
export const meta: TemplateMeta = {
file: '.bindery/README.md',
version: 6,
version: 7,
label: 'bindery capabilities',
zip: null,
};
export function render(ctx: TemplateContext): string {
const { title } = ctx;
const { title, storyFolder, notesFolder, arcFolder, charactersFolder, sessionFile, arcGranularity, memoriesFolder } = ctx;
return `# Bindery — capabilities for this workspace
This file is **the single source agents should consult to answer "What can Bindery do?"**
@ -31,14 +31,35 @@ Bindery is a markdown book authoring toolkit with three surfaces:
3. **Skill workflows** opinionated slash-commands like \`/review\`, \`/translate\`, \`/memory\`.
All three operate on the same workspace state: \`.bindery/settings.json\`,
\`.bindery/translations.json\`, the story folder, \`Notes/\`, \`Arc/\`, and
\`.bindery/memories/\`.
\`.bindery/translations.json\`, the story folder, \`${notesFolder}/\`, \`${arcFolder}/\`, and
\`${memoriesFolder}/\`.
## Opinionated Authoring Layout
Bindery's default scaffold is intentionally structured so agents and authors can
work from the same map:
| Path | Purpose |
|---|---|
| \`${sessionFile}\` | User-owned current focus, handoff notes, and personal working context. Bindery creates a minimal file if missing; the author owns its contents. |
| \`${arcFolder}/index.md\` | Arc map and links to structure files. |
| \`${arcFolder}/Overall.md\` | Whole-book arc, promise, turns, ending direction, and open questions. |
| \`${arcFolder}/Acts/\` | Act-level arc files. Default arc granularity: \`${arcGranularity}\`. |
| \`${charactersFolder}/index.md\` | Cast overview. |
| \`${charactersFolder}/<character>.md\` | One durable profile per character. |
| \`${notesFolder}/Inbox.md\` | Loose notes, pasted mobile chats, and unsorted ideas before triage. |
| \`${notesFolder}/World/\`, \`${notesFolder}/Scenes/\`, \`${notesFolder}/Research/\` | Structured supporting notes. |
| \`${memoriesFolder}/global.md\` | Durable cross-session decisions and story rules. |
| \`${storyFolder}/\` | Chapter source files by language. |
Treat \`${arcFolder}/\` as story architecture, \`${charactersFolder}/\` as cast truth,
\`${notesFolder}/\` as supporting reference, and \`${memoriesFolder}/\` as session memory.
## VS Code / Obsidian commands (Command Palette "Bindery: …")
| Command | What it does |
|---|---|
| \`Bindery: Initialize Workspace\` | Create \`.bindery/settings.json\`, \`.bindery/translations.json\`, and this README. |
| \`Bindery: Initialize Workspace\` | Create \`.bindery/settings.json\`, \`.bindery/translations.json\`, this README, and the opinionated Arc / Notes / Characters / COWORK / memory / status scaffold. |
| \`Bindery: Setup AI Assistant Files\` | Generate CLAUDE.md / copilot-instructions.md / cursor rules / AGENTS.md and refresh this capabilities doc. |
| \`Bindery: Format Typography\` / \`Format All Markdown in Folder\` | Curly quotes, em-dashes, ellipses, etc. |
| \`Bindery: Merge Chapters → Markdown / DOCX / EPUB / PDF / All Formats\` | Build a deliverable from chapter files. |
@ -47,6 +68,11 @@ All three operate on the same workspace state: \`.bindery/settings.json\`,
| \`Bindery: Open translations.json\` | Open the per-language rules file. |
| \`Bindery: Insert Review Start Marker (or wrap selection)\` | Insert \`<!-- Bindery: Review start -->\`, or wrap the current selection in matched start/stop markers. |
| \`Bindery: Insert Review Stop Marker\` | Insert \`<!-- Bindery: Review stop -->\` at the cursor. |
| \`Bindery: List/Create/Append Notes\` | Work with story notes under \`${notesFolder}/\` through the same structured tools agents use. |
| \`Bindery: List/Create/Update Character Profile\` | Maintain structured character files under \`${charactersFolder}/\`. |
| \`Bindery: List/Create/Update Arc File\` | Maintain story architecture files under \`${arcFolder}/\`. |
| \`Bindery: List/Append/Compact Memories\` | Maintain durable session-memory files under \`${memoriesFolder}/\`. |
| \`Bindery: Show/Update Chapter Status\` | Read or update \`.bindery/chapter-status.json\`. |
| \`Bindery: Register MCP Server\` | Write \`.vscode/mcp.json\` so Claude / Codex pick the bundled server up. |
### Default keybindings (markdown editors only)
@ -68,11 +94,14 @@ tagged **(writes)** modify files or git state.
|---|---|
| \`health\` (reads) | Workspace status: settings, index, AI-file versions, embedding backend. |
| \`identify_book\` (reads) | Confirm the active book root and registry entry. |
| \`init_workspace\` (writes) | Create \`.bindery/settings.json\` with title/author/story-folder defaults. |
| \`init_workspace\` (writes) | Create \`.bindery/settings.json\`, \`.bindery/translations.json\`, generated \`.bindery/README.md\`, and the opinionated Arc / Notes / Characters / COWORK / memory / status scaffold. |
| \`update_workspace\` (writes) | \`git fetch\` + pull, optional branch switch, optional auto-stash. |
| \`settings_update\` (writes) | Patch \`.bindery/settings.json\` from an agent. |
| \`setup_ai_files\` (writes) | (Re)generate AI instruction files + refresh this capabilities doc. |
| \`get_text\` / \`get_chapter\` / \`get_book_until\` / \`get_overview\` / \`get_notes\` (reads) | Read source files, chapters, ranges, and notes. |
| \`note_list\` / \`note_get\` / \`note_create\` / \`note_append\` (reads / writes) | First-class story note management under \`${notesFolder}/\`. |
| \`character_list\` / \`character_get\` / \`character_create\` / \`character_update\` (reads / writes) | Structured character profiles under \`${charactersFolder}/\`. |
| \`arc_list\` / \`arc_get\` / \`arc_create\` / \`arc_update\` (reads / writes) | Structured story-architecture files under \`${arcFolder}/\`. |
| \`search\` (reads) | BM25 / semantic search across the corpus (Ollama optional). |
| \`index_build\` / \`index_status\` (writes / reads) | Build or inspect the lexical/semantic index. |
| \`format\` (writes) | Apply typography to a file or folder (\`dryRun\` supported). |
@ -84,6 +113,21 @@ tagged **(writes)** modify files or git state.
| \`memory_list\` / \`memory_append\` / \`memory_compact\` (reads / writes) | Manage \`.bindery/memories/\` files. |
| \`chapter_status_get\` / \`chapter_status_update\` (reads / writes) | Per-chapter progress tracker in \`.bindery/chapter-status.json\`. |
### Tool Workflow Shortcuts
- Use \`init_workspace\` first for a new book. It creates the settings, translation file, generated capability README, and the default authoring scaffold.
- Use \`setup_ai_files\` after init or when \`health\` reports outdated AI files. It refreshes CLAUDE.md, Copilot instructions, Cursor rules, AGENTS.md, Claude skills, skill zips, and this capability README.
- Use \`arc_*\` for story architecture under \`${arcFolder}/\`.
- Use \`character_*\` for cast profiles under \`${charactersFolder}/\`.
- Use \`note_*\` for canonical story notes under \`${notesFolder}/\`.
- Use \`get_notes\` for broad note lookup, \`memory_*\` for durable session decisions, and \`chapter_status_*\` for progress state.
### Current Tool Boundaries
- Dedicated note, character, arc, memory, and chapter-status tools are available now.
- Dedicated session-focus/COWORK tools are not available yet. The configured session file is intentionally user-owned; Bindery creates only a small neutral scaffold and should not replace personal working notes.
- VS Code and Obsidian host commands now mirror the structured note, character, arc, memory, and chapter-status tools. Host prompts cover common fields; agents can still call the MCP tools directly for complete structured payloads.
## Review markers
Bindery review markers let an author tag arbitrary regions for the next review
@ -114,6 +158,8 @@ command or by paraphrasing the description.
| \`/translation-review\` | "review my translation", "what do you think" (translation focus) |
| \`/translate\` | "translate chapter X", "spot-check the NL of chapter X" |
| \`/brainstorm\` | "I'm stuck", "help me think of ideas" |
| \`/plan-beats\` | "help me plan the beats", "refine chapter beats" |
| \`/character-setup\` | "make a character profile", "help me define this character" |
| \`/memory\` | "save what we decided", end-of-session memory updates |
| \`/status\` | "where are we", "what's the book status" |
| \`/continuity\` | "check chapter X for errors", "continuity check" |

View file

@ -2,13 +2,13 @@ import { audienceNote, languageSection, type TemplateContext, type TemplateMeta
export const meta: TemplateMeta = {
file: 'CLAUDE.md',
version: 12,
version: 13,
label: 'project instructions',
zip: null,
};
export function render(ctx: TemplateContext): string {
const { title, author, description, genre, storyFolder, notesFolder, arcFolder } = ctx;
const { title, author, description, genre, storyFolder, notesFolder, arcFolder, charactersFolder, sessionFile, arcGranularity } = ctx;
const lines: string[] = [
`# Claude — ${title}`,
'',
@ -24,7 +24,7 @@ export function render(ctx: TemplateContext): string {
'1. Use /read-in at the start of a session to load context and get your bearings.',
'2. Run `health` from the Bindery MCP and check `ai_versions_outdated`.',
'3. If `ai_versions_outdated` has entries, run `setup_ai_files` and present the returned `skill_zips.reupload_required` list to the user for Claude Desktop.',
'4. If the skill or MCP server is not available, read at least COWORK.md (if present) for current focus and context.',
`4. If the skill or MCP server is not available, read at least ${sessionFile} (if present) for user-owned current focus and handoff context.`,
'',
'## Memory system',
'1. When concluding a discussion, or after you give a meaningful, preservation-worthy response: use /memory to store it.',
@ -32,8 +32,13 @@ export function render(ctx: TemplateContext): string {
'',
'## Repo layout',
'```',
`${arcFolder}/ ← story arc files`,
`${notesFolder}/ ← story notes (characters, world)`,
`${arcFolder}/ ← story architecture (${arcGranularity}-level arc planning by default)`,
` index.md ← arc map`,
` Overall.md ← whole-book arc`,
` Acts/ ← act-level arc files`,
`${notesFolder}/ ← story notes`,
`${charactersFolder}/ ← character index and one profile per character`,
`${sessionFile} ← user-owned current focus / handoff notes`,
`${storyFolder}/`,
...ctx.languages.map(l => ` ${l.folderName}/ ← ${l.code} chapters (one .md per chapter)`),
'```',
@ -53,7 +58,9 @@ export function render(ctx: TemplateContext): string {
'| Command | Purpose |',
'|---|---|',
'| `/review` | Review a chapter for language, arc consistency, and age-appropriateness |',
'| `/brainstorm` | Generate plot/character/scene ideas |',
'| `/brainstorm` | Guided story ideation when the author is stuck |',
'| `/plan-beats` | Create, expand, or refine chapter and scene beatmaps |',
'| `/character-setup` | Create or refine structured character profiles |',
'| `/memory` | Update memory files and compact if needed |',
'| `/translate` | Assist with chapter translation |',
'| `/translation-review` | Review a hand-crafted translation against the source |',
@ -73,7 +80,7 @@ export function render(ctx: TemplateContext): string {
'| `list_books` | List all configured book names |',
'| `identify_book` | Match a working directory to a book name |',
'| `health` | Server status: settings, index, embedding backend |',
'| `init_workspace` | Create or update `.bindery/settings.json` and `translations.json` |',
'| `init_workspace` | Create or update `.bindery/settings.json`, `translations.json`, `.bindery/README.md`, and the opinionated Arc / Notes / Characters / COWORK / memory / status scaffold |',
'| `setup_ai_files` | Regenerate AI instruction files, rebuild Claude skill zip files, and return a change manifest |',
'| `index_build` | Build or rebuild the full-text search index |',
'| `index_status` | Show index chunk count and build time |',
@ -82,6 +89,12 @@ export function render(ctx: TemplateContext): string {
'| `get_book_until` | Fetch chapters from 1..N (or start..N) in one call, concatenated in reading order |',
'| `get_overview` | Chapter structure — acts, chapters, titles |',
'| `get_notes` | Notes/ files, filterable by category or name |',
'| `note_list` / `note_get` | List or read canonical story notes under Notes/ |',
'| `note_create` / `note_append` | Create or append canonical story notes under Notes/ |',
'| `character_list` / `character_get` | List or read structured character profiles |',
'| `character_create` / `character_update` | Create or update structured character profiles and the cast index |',
'| `arc_list` / `arc_get` | List or read structured arc files |',
'| `arc_create` / `arc_update` | Create or update structured arc files and the arc index |',
'| `search` | BM25 full-text search with ranked snippets, optional semantic ranking |',
'| `format` | Apply typography formatting to a file or folder |',
'| `get_review_text` | Structured git diff with review-marker regions and optional auto-staging that consumes markers |',

View file

@ -14,6 +14,9 @@ export interface TemplateContext {
storyFolder: string;
notesFolder: string;
arcFolder: string;
charactersFolder: string;
sessionFile: string;
arcGranularity: string;
memoriesFolder: string;
languages: Array<{ code: string; folderName: string }>;
langList: string;

View file

@ -2,13 +2,13 @@ import { audienceNote, languageSection, type TemplateContext, type TemplateMeta
export const meta: TemplateMeta = {
file: '.github/copilot-instructions.md',
version: 8,
version: 9,
label: 'copilot instructions',
zip: null,
};
export function render(ctx: TemplateContext): string {
const { title, author, description, genre, storyFolder, notesFolder, arcFolder } = ctx;
const { title, author, description, genre, storyFolder, notesFolder, arcFolder, charactersFolder, sessionFile, arcGranularity } = ctx;
const lines: string[] = [`# GitHub Copilot — ${title}`, ''];
if (genre || description || ctx.audience) {
lines.push('## Project');
@ -21,15 +21,19 @@ export function render(ctx: TemplateContext): string {
lines.push(
'## Repo layout',
'```',
`${arcFolder}/ ← story arc files`,
`${notesFolder}/ ← story bible, translation table, memories`,
`${arcFolder}/ ← story architecture (${arcGranularity}-level arc planning by default)`,
` index.md / Overall.md / Acts/`,
`${notesFolder}/ ← story notes`,
`${charactersFolder}/ ← character index and one profile per character`,
`${sessionFile} ← user-owned current focus / handoff notes`,
`${storyFolder}/`,
...ctx.languages.map(l => ` ${l.folderName}/ ← ${l.code} chapters`),
'```',
'',
'## Shared skill workflows',
'- Workspace skill files live in `.claude/skills/` and may also be picked up by agents beyond Claude.',
'- Prefer those shared slash workflows when available: `/read-in`, `/review`, `/translation-review`, `/translate`, `/memory`, `/continuity`, `/status`, `/read-aloud`, `/proof-read`.',
'- Prefer those shared slash workflows when available: `/read-in`, `/review`, `/translation-review`, `/translate`, `/memory`, `/continuity`, `/status`, `/read-aloud`, `/proof-read`, `/plan-beats`, `/character-setup`.',
'- Treat arc files as story architecture, not generic notes. Use `arc_*` tools for structure, `character_*` tools for durable cast facts, `note_*` tools for story notes, and `memory_*` tools for cross-session decisions when available.',
'',
'## Writing guidelines',
'- HTML comments `<!-- -->` in chapter files are writer notes — treat as context only.',

View file

@ -2,25 +2,30 @@ import type { TemplateContext, TemplateMeta } from './context';
export const meta: TemplateMeta = {
file: '.cursor/rules',
version: 8,
version: 9,
label: 'cursor rules',
zip: null,
};
export function render(ctx: TemplateContext): string {
const { title, storyFolder, notesFolder, arcFolder, memoriesFolder } = ctx;
const { title, storyFolder, notesFolder, arcFolder, charactersFolder, sessionFile, arcGranularity, memoriesFolder } = ctx;
const lines: string[] = [
`# Cursor rules — ${title}`,
'',
`Story folder: \`${storyFolder}/\``,
`Notes folder: \`${notesFolder}/\``,
`Arc folder: \`${arcFolder}/\` (Overall.md, Act_I_*.md, Act_II_*.md, Act_III_*.md)`,
`Arc folder: \`${arcFolder}/\` (index.md, Overall.md, Acts/; default granularity: ${arcGranularity})`,
`Characters folder: \`${charactersFolder}/\``,
`Session file: \`${sessionFile}\``,
'',
'## Context files to read',
`- \`${sessionFile}\` — user-owned current focus, handoff notes, and personal working context`,
`- \`${memoriesFolder}/global.md\` — cross-chapter decisions (read at start of session)`,
`- \`${arcFolder}/\` — story arc files for overall and per-act structure and beats`,
`- \`${notesFolder}/\` — story notes, like character profiles and world rules`,
'- Shared workflows live in `.claude/skills/`; if your runtime exposes them, prefer `/read-in`, `/review`, `/translation-review`, `/translate`, `/memory`, `/continuity`, `/status`, `/read-aloud`, and `/proof-read` for those tasks.',
`- \`${arcFolder}/\` — story architecture, structure, pacing, and beats`,
`- \`${charactersFolder}/\` — character index and one profile per character`,
`- \`${notesFolder}/\` — story notes, like world rules, scene ideas, inbox, and research`,
'- Shared workflows live in `.claude/skills/`; if your runtime exposes them, prefer `/read-in`, `/review`, `/translation-review`, `/translate`, `/memory`, `/continuity`, `/status`, `/read-aloud`, `/proof-read`, `/plan-beats`, and `/character-setup` for those tasks.',
'- Use `arc_*` tools for story structure, `character_*` tools for cast profiles, `note_*` tools for story notes, `memory_*` tools for session decisions, and `chapter_status_*` tools for progress when available.',
'',
'## Rules',
'- HTML comments `<!-- -->` in chapter files are writer notes. Treat as context, not story content.',

View file

@ -2,57 +2,90 @@ import type { TemplateContext, TemplateMeta } from '../context';
export const meta: TemplateMeta = {
file: '.claude/skills/brainstorm/SKILL.md',
version: 11,
version: 12,
label: 'brainstorm skill',
zip: '.claude/skills/brainstorm.zip',
};
const CONTENT = [
"---",
"name: brainstorm",
"description: Bindery workspace - Brainstorm story ideas, character moments, or scene concepts. Use for /brainstorm, \"I'm stuck\", \"help me think of ideas\", or \"Am I stuck?\".",
"---",
"# Skill: /brainstorm",
"",
"Help the author get unstuck through discussion — not an idea dump, but a guided conversation that surfaces options organically and only generates when direction is clear.",
"",
"## Prerequisites",
"This skill requires a Bindery workspace. If unsure, call `identify_book` to check. If no workspace is found, tell the user and stop.",
"",
"## Trigger",
"User says `/brainstorm`, \"I'm stuck\", \"help me think of ideas\", or \"Am I stuck?\".",
"",
"## Tools",
"Use these Bindery MCP tools to gather context:",
"- `get_text(path)` — read settings, arc files, or memory files",
"- `search(query, language)` — find thematic parallels and related moments across the book",
"- `get_notes(category, name)` — look up character profiles, world rules, or equipment details",
"- `get_chapter(chapterNumber, language)` — read a specific chapter for reference",
"",
"## Phases",
"",
"### Phase 1 — Diagnose the block",
"Before loading any context, ask one open question:",
"> \"What's the problem? Tell me about the scene or chapter and where you're stuck.\"",
"",
"Do not ask about scope, format, or constraints yet — just listen.",
"",
"From the answer, identify:",
"- **What kind of block**: no ideas, too many options, something feels wrong, arc doesn't fit, character won't cooperate, etc.",
"- **Where in the story**: chapter, scene, or act",
"- **What's already decided** vs what's open",
"",
"If the user already gave enough detail in their trigger message, skip the question and proceed to Phase 2.",
"",
"### Phase 2 — Load context",
"Load context appropriate to the block. Do this silently — don't list files to the user.",
"",
"- Always: `get_text(\".bindery/settings.json\")`, `get_text(\".bindery/memories/global.md\")`, and the relevant arc file from `Arc/`",
"- If chapter-specific: `get_text(\".bindery/memories/chXX.md\")` if it exists",
"- If character-focused: `get_notes(category: \"Characters\")`",
"- Always: `search` for related moments or themes already in the book",
"",
"### Phase 3 — Discuss",
"This is the core of the skill. Probe the story problem before jumping to solutions.",
"",
"What this looks like:",
"- Reflect back your understanding of the block",
"- Ask what the scene needs to accomplish for the arc",
"- Surface the tension between what the character wants and what the story needs",
"- Name constraints the author may not have made explicit",
"",
"**Stay in Phase 3 until one of these is true:**",
"- The author's direction has become clear through the exchange",
"- The author explicitly asks for options or ideas",
"",
"Keep responses focused — one or two observations or questions at a time, not a wall of analysis.",
"",
"### Phase 4 — Generate",
"Only enter this phase once Phase 3 has surfaced real direction.",
"",
"**Output adapts to where the discussion landed:**",
"",
"- **Still exploring** — offer 23 loose directional possibilities as questions or half-formed ideas. Invite reaction, don't conclude.",
"- **Direction is clear** — offer 24 concrete options. Each: a short title + 35 sentence description grounded in the book's established material.",
"- **Nearly decided** — offer one developed idea with reasoning, plus one alternative as a safety net.",
"",
"Always close with an invitation to continue:",
"> \"Does any of this feel right? Want to push further in one direction?\"",
"",
"## Rules",
"- Do not generate ideas before Phase 3 has surfaced a real direction",
"- Respect established world rules and character voices",
"- Keep ideas appropriate for the book's configured target audience",
"- The output is a step in a conversation, not a final answer",
].join('\n') + '\n';
export function render(_ctx: TemplateContext): string {
return `---
name: brainstorm
description: Bindery workspace - Brainstorm story ideas, plot beats, character moments, or scene concepts. Use for /brainstorm, "I'm stuck", "help me think of ideas", or "Am I stuck?".
---
# Skill: /brainstorm
Brainstorm story ideas, character moments, or plot solutions.
## Prerequisites
This skill requires a Bindery workspace. If unsure, call \`identify_book\` to check.
## Trigger
User says \`/brainstorm\`, "I'm stuck", "help me think of ideas", or "Am I stuck?".
## Clarify first
- Scope: plot beat | character moment | scene idea | chapter open/close
- Chapter/story point: specify one
- Constraints: list any
## Tools
Use these Bindery MCP tools to gather context:
- \`search(query, language)\` — find thematic parallels and related moments across the book
- \`get_notes(category, name)\` — look up character profiles, world rules, or equipment details
- \`get_chapter(chapterNumber, language)\` — read a specific chapter for reference
## Steps
1. Read ".bindery/settings.json" with \`get_text\` to pick up the current book's genre, target audience, and story structure.
2. Read \`.bindery/memories/global.md\` and the relevant arc file from \`Arc/\`.
3. If chapter specific, read \`.bindery/memories/chXX.md\` if it exists.
4. If character-focused, use \`get_notes(category: "Characters")\` for character profiles.
5. Use \`search\` to find related moments or themes already in the book.
6. Generate 3-5 concrete ideas that fit the arc and feel true to the characters.
## Output format
**Option A [short title]**
[3-5 sentence description]
...
End with a brief note on which options feel most aligned with the arc.
## Rules
- Respect established world rules and character voices
- Keep ideas appropriate for the book's configured target audience
`;
return CONTENT;
}

View file

@ -0,0 +1,91 @@
import type { TemplateContext, TemplateMeta } from '../context';
export const meta: TemplateMeta = {
file: '.claude/skills/character-setup/SKILL.md',
version: 1,
label: 'character-setup skill',
zip: '.claude/skills/character-setup.zip',
};
const CONTENT = [
'---',
'name: character-setup',
'description: Bindery workspace - Create or refine structured character profiles. Use for /character-setup, "help me define this character", "make a character profile", "update this character", or "describe the cast".',
"argument-hint: 'character name or cast group (optional)'",
'---',
'# Skill: /character-setup',
'',
'Build or refine character profiles using Bindery structured character tools.',
'',
'## Prerequisites',
'This skill requires a Bindery workspace. If unsure, call `identify_book` to check. If no workspace is found, tell the user and stop.',
'',
'## Trigger',
'User says `/character-setup`, "help me define this character", "make a character profile", "update this character", "describe the cast", or asks for durable character setup/description.',
'',
'## Clarify first',
'- Character name: ask if no target is clear.',
'- Mode: create | update | review. Infer from context when possible.',
'- Scope: full profile | quick profile | one section such as relationships, appearance, or narrative arc.',
'',
'## Tools',
'Use these Bindery MCP tools:',
'- `character_list(name?)` — inspect existing profiles before creating duplicates',
'- `character_get(name)` — read an existing structured profile',
'- `character_create(...)` — create a profile and update the character index',
'- `character_update(...)` — update known profile fields and refresh the index row',
'- `get_notes(category: "Characters")` — broad lookup for older note layouts or unstructured character notes',
'- `search(query, language)` — find in-story mentions, first appearance, and continuity facts',
'- `get_chapter(chapterNumber, language)` / `get_book_until(...)` — inspect source text when the profile must reflect written canon',
'- `arc_list` / `arc_get` — check relevant arc movement before writing narrative-arc fields',
'',
'## Profile fields',
'Bindery character profiles use these durable fields:',
'- role',
'- age',
'- origin',
'- skills',
'- strengths',
'- weaknesses',
'- personality',
'- background',
'- narrativeArc',
'- appearanceNotes',
'- relationships',
'- firstAppearance',
'- openQuestions',
'- continuityNotes',
'- indexNotes',
'',
'## Steps',
'',
'### 1. Check for an existing profile',
'Call `character_list` with the character name or likely spelling. If a plausible match exists, call `character_get` and update it instead of creating a duplicate.',
'',
'### 2. Gather canon and planning context',
'- Use `search` for the character name and aliases.',
'- Use `get_notes(category: "Characters")` if older character notes may exist.',
'- Use `arc_list` / `arc_get` when narrative movement or act-level purpose matters.',
'- Ask the user for missing intent before inventing durable facts.',
'',
'### 3. Draft the profile',
'Separate confirmed canon from open questions. Put uncertainties in `openQuestions`, not in factual fields. Keep descriptions useful for continuity and future writing rather than decorative.',
'',
'### 4. Write the profile',
'- For a new character, call `character_create` with the populated fields.',
'- For an existing character, call `character_update` with only fields that should change.',
'- Do not use generic file edits for character profiles when `character_create` or `character_update` can express the change.',
'',
'### 5. Report back',
'Summarize what changed, list any open questions, and mention the profile path returned by the tool.',
'',
'## Rules',
'- Do not overwrite confirmed canon without calling out the change.',
'- Keep profiles concise enough to scan, but complete enough to support continuity checks.',
'- Use `openQuestions` for speculation and unresolved author choices.',
'- Use `continuityNotes` for facts that future chapters must not contradict.',
].join('\n') + '\n';
export function render(_ctx: TemplateContext): string {
return CONTENT;
}

View file

@ -2,57 +2,77 @@ import type { TemplateContext, TemplateMeta } from '../context';
export const meta: TemplateMeta = {
file: '.claude/skills/continuity/SKILL.md',
version: 12,
version: 13,
label: 'continuity skill',
zip: '.claude/skills/continuity.zip',
};
const CONTENT = [
"---",
"name: continuity",
"description: Bindery workspace - Cross-check a chapter for consistency errors in characters, world rules, or timeline. Use for /continuity, \"check continuity\", or \"check chapter X for errors\".",
"---",
"# Skill: /continuity",
"",
"Cross-check a chapter for consistency errors.",
"",
"## Prerequisites",
"This skill requires a Bindery workspace. If unsure, call `identify_book` to check. If no workspace is found, tell the user and stop.",
"",
"## Trigger",
"User says `/continuity`, \"check continuity\", or \"check chapter X for errors\".",
"",
"## Clarify first",
"- Chapter: number — if not provided, ask before proceeding",
"- Focus: all | characters | world rules | timeline — if not provided, default to **all**",
"- Language: use the source language by default; if the user specifies a translation, use that",
"",
"## Tools",
"Use these Bindery MCP tools:",
"- `get_text(path)` — read settings, COWORK.md, and memory files",
"- `arc_list` / `arc_get` — read structured arc files",
"- `character_list` / `character_get` — read structured character profiles",
"- `get_chapter(chapterNumber, language)` — read a specific chapter",
"- `get_book_until(chapterNumber, language, startChapter?)` — load all prior chapters in one call for timeline checks; fall back to `get_chapter` for nearby chapters if unavailable",
"- `get_notes(category, name)` — look up older character notes (`category: \"Characters\"`) or world rules (`category: \"World\"`)",
"- `search(query, language)` — find earlier mentions of a specific detail or event",
"- `memory_list` — check whether a chapter-specific memory file exists (`chXX.md`)",
"",
"## Steps",
"",
"### Load context",
"1. `get_text(\".bindery/settings.json\")` — pick up the book's structure and conventions.",
"2. Use `arc_list` and `arc_get` to load the structural map. If the chapter's act is known, read the matching act file.",
"3. Use `character_list` to load the cast map.",
"4. `get_text(\".bindery/memories/global.md\")` — cross-chapter decisions and established facts. Use `memory_list` to check if a chapter-specific memory file exists; if so, read it too.",
"5. `get_chapter(chapterNumber, language)` — read the chapter to check.",
"",
"### Load reference material (by focus area)",
"6. **Characters**: `character_get` for relevant structured character profiles; use `get_notes(category: \"Characters\")` only for older notes.",
"7. **World rules**: `get_notes(category: \"World\")` for world and magic rules.",
"8. **Timeline**: `get_book_until` up to the focus chapter for continuity context.",
"9. For any focus: use `search` to verify specific details against earlier chapters.",
"",
"### Check and report",
"10. Compare the chapter against the loaded reference material.",
"11. Report findings using the output format below.",
"",
"## Output format",
"",
"| Type | Location | Issue | Reference |",
"|---|---|---|---|",
"| Character | Para 3 | Description contradicts... | global.md |",
"",
"- **Confirmed inconsistency** — flag as an error",
"- **Uncertain** — flag as a question, marked with `?` in the Type column",
"",
"End with a one-line overall assessment. If no issues found, say so clearly.",
"",
"## Rules",
"- Reporting only — do not suggest rewrites",
"- Limit checks to the requested focus area; if focus is \"all\", cover all three categories",
].join('\n') + '\n';
export function render(_ctx: TemplateContext): string {
return `---
name: continuity
description: Bindery workspace - Cross-check a chapter for consistency errors in characters, world rules, or timeline. Use for /continuity, "check continuity", or "check chapter X for errors".
---
# Skill: /continuity
Cross-check a chapter for consistency errors.
## Prerequisites
This skill requires a Bindery workspace. If unsure, call \`identify_book\` to check.
## Trigger
User says \`/continuity\`, "check continuity", or "check chapter X for errors".
## Clarify first
- Chapter: number
- Focus: all | characters | world rules | timeline
## Tools
Use these Bindery MCP tools:
- \`get_chapter(chapterNumber, language)\` — read a specific chapter
- \`get_book_until(chapterNumber, language, startChapter?)\` — load prior chapters in one call for timeline/continuity context
- \`get_notes(category, name)\` — look up character profiles or world rules
- \`search(query, language)\` — find earlier mentions of a character detail or event
- \`memory_list\` — check whether a chapter-specific memory file exists (\`chXX.md\`)
## Steps
1. Use \`get_text(".bindery/settings.json")\` to pick up the current book's structure and conventions.
2. Use \`get_chapter\` to read the chapter.
3. Use \`get_text\` to read \`.bindery/memories/global.md\`. Use \`memory_list\` to check if a chapter-specific memory file (\`chXX.md\`) exists; if so, read it with \`get_text\` too. Use \`get_notes(category: "Characters")\` for character profiles.
4. For world rules: use \`get_notes(category: "World")\`.
5. For timeline and continuity drift checks: use \`get_book_until\` up to the focus chapter. If unavailable, fall back to \`get_chapter\` for nearby prior chapters.
6. Use \`search\` to verify specific details against earlier chapters.
## Output format
| Type | Location | Issue | Reference |
|---|---|---|---|
| Character | Line X | Description contradicts... | global.md |
End with a one-line overall assessment. If no issues found, say so clearly.
## Rules
- Flag issues only do not suggest rewrites
- Phrase uncertain items as questions, not errors
`;
return CONTENT;
}

View file

@ -2,82 +2,120 @@ import type { TemplateContext, TemplateMeta } from '../context';
export const meta: TemplateMeta = {
file: '.claude/skills/memory/SKILL.md',
version: 11,
version: 12,
label: 'memory skill',
zip: '.claude/skills/memory.zip',
};
const CONTENT = [
"---",
"name: memory",
"description: Bindery workspace - Save session decisions to persistent memory files using Bindery MCP tools. Use for /memory, \"save this to memory\", \"update memories\", or at end of session.",
"---",
"# Skill: /memory",
"",
"Update project memory files with decisions from the current session.",
"",
"**What memory is:** A working log of decisions and preferences as they stood at the time — not canonical truth. Canonical sources live in `Notes/`, `Arc/`, and `COWORK.md`. If something in memory conflicts with those files, the canonical file wins.",
"",
"**What memory is not:** A substitute for proper notes files. Character profiles, world rules, and writing conventions belong in their dedicated files, not accumulating in memory.",
"",
"## Prerequisites",
"This skill requires a Bindery workspace. If unsure, call `identify_book` to check. If no workspace is found, tell the user and stop.",
"",
"## Trigger",
"User says `/memory`, \"save this to memory\", \"update memories\", at meaningful points, or at session end.",
"",
"## Tools",
"Use these Bindery MCP tools:",
"- `note_list(category?)` — list canonical story note files",
"- `note_get(path)` / `get_notes(category, name)` — read existing canonical notes before routing settled facts out of memory",
"- `note_create(path, title, content)` — create a new canonical story note when the user confirms settled reference material",
"- `note_append(path, content, heading?)` — append confirmed material to an existing canonical story note",
"- `memory_list` — discover which memory files exist and their line counts",
"- `memory_append(file, title, content)` — append a dated session entry; the tool stamps the date automatically",
"- `memory_compact(file, compacted_content)` — overwrite a file with a summary; backs up the original to `archive/` automatically",
"- `git_snapshot(message)` — after updating memories, offer to save a snapshot",
"",
"## Steps",
"",
"### 0. Cross-check assistant memory (if available)",
"If the runtime has local/session memory, review entries from this session.",
"Promote repo-worthy entries into Step 3 content.",
"",
"Promote:",
"- Story/craft decisions",
"- Character or world rules",
"- Structural decisions needed in future sessions",
"- Anything that must survive across devices",
"",
"Keep local only:",
"- Workflow/tool preferences",
"- Assistant behavior feedback",
"- Setup/environment notes",
"- Session-local context",
"",
"If no local/session memory exists, skip this step.",
"",
"### 1. Identify what to save and where it belongs",
"List the decisions, insights, or facts from the session worth preserving. For each item, classify it before writing anything. If an item looks like settled character or world reference material, use `note_list` and `note_get`/`get_notes` first to check whether the canonical note already exists or needs updating:",
"",
"| Destination | What goes here |",
"|---|---|",
"| `global.md` | Cross-chapter decisions that must survive sessions: confirmed character names, confirmed world rules, confirmed style choices |",
"| `chXX.md` | Chapter-specific decisions: confirmed plot choices, resolved open questions for that chapter |",
"| **Notes/Arc/COWORK file** (not memory) | Character profiles, world-building detail, magic rules, writing conventions, arc commitments — anything that has become settled enough to be a reference |",
"| **Discard** | Session-local context, tool/workflow observations, things that were speculative and were not confirmed |",
"",
"> If an item is a preference or suggestion rather than a confirmed decision, either discard it or note it explicitly as tentative (e.g., `\"Tentative: consider X\"`) — do not write it as a fact.",
"",
"Items routed to canonical story notes should be written with `note_create` or `note_append` after the user confirms the target file and wording. Do not duplicate them into memory.",
"",
"> Current tool boundary: Bindery has first-class note, character, and arc write tools, but no dedicated COWORK/session-focus write tools yet. Session-focus changes should be listed for the user unless the runtime can edit the target file and the user explicitly asks for that edit.",
"",
"### 2. Check existing files",
"Use `memory_list` to see which memory files exist and how large they are.",
"",
"### 3. Append the entry",
"Use `memory_append` to write to the right file:",
"- `global.md` — cross-chapter decisions (character names, world rules, style choices)",
"- `chXX.md` — chapter-specific decisions (e.g. `ch10.md`)",
"",
"Arguments:",
"- `file`: just the filename, e.g. `global.md` or `ch10.md`",
"- `title`: short topic label, e.g. `\"Elder introduction — character decisions\"`",
"- `content`: the decisions to record, one per line",
"",
"The tool stamps the current date. Do not add a date to the content.",
"",
"### 4. Compact if needed",
"If `memory_list` shows a file exceeding ~150 lines, offer to compact it:",
"- Summarize the existing content into a concise replacement",
"- For each summarized entry, apply the routing table from Step 1: does it stay in memory, move to a canonical file, or get discarded? Use `note_list` and `note_get`/`get_notes` when checking whether routed note material already exists.",
"- Call `memory_compact(file, compacted_content)` — original is backed up automatically",
"- Move confirmed note material with `note_create` or `note_append`, character material with `character_create` or `character_update`, and arc material with `arc_create` or `arc_update`; list COWORK items that still need manual placement",
"",
"The compaction pass is also a good time to flag any entries that were written as facts but were originally preferences — surface these for the user to confirm or soften.",
"",
"#### Canonical homes for content moving out of memory:",
"- Character decisions → `Notes/Characters/index.md` for cast-level facts or `Notes/Characters/<Name>.md` for per-character profiles",
"- World rules → `Notes/World/` or the project's existing world reference file",
"- Arc commitments → `Arc/index.md`, `Arc/Overall.md`, or the relevant file under `Arc/Acts/`",
"- Writing/style conventions → `COWORK.md` or a dedicated style file under `Notes/`",
"",
"### 5. Snapshot",
"Offer to save a snapshot with `git_snapshot`.",
"",
"## Rules",
"- Always use `memory_append` — never use the Edit tool to write to memory files",
"- Do not add dates to content — the tool stamps them automatically",
"- Compaction is always opt-in",
"- Memory is a log, not a spec — when memory conflicts with `Notes/`, `Arc/`, or `COWORK.md`, the canonical file is authoritative",
"",
"## Tool boundary",
"Bindery can create and append canonical note files with `note_create` and `note_append`, manage character profiles with `character_create` and `character_update`, and manage arc files with `arc_create` and `arc_update`. It does not yet have dedicated COWORK/session-focus write tools, so route those items by listing the exact target file and proposed content for the user, or by editing the target file only when the runtime supports file edits and the user asks for it.",
].join('\n') + '\n';
export function render(_ctx: TemplateContext): string {
return `---
name: memory
description: Bindery workspace - Save session decisions to persistent memory files using Bindery MCP tools. Use for /memory, "save this to memory", "update memories", or at end of session.
---
# Skill: /memory
Update project memory files with decisions from the current session.
## Prerequisites
This skill requires a Bindery workspace. If unsure, call \`identify_book\` to check.
## Trigger
User says \`/memory\`, "save this to memory", "update memories", at meaningful points, or at session end.
## Tools
Use these Bindery MCP tools:
- \`memory_list\` — discover which memory files exist and their line counts
- \`memory_append(file, title, content)\` — append a dated session entry; the tool stamps the date automatically
- \`memory_compact(file, compacted_content)\` — overwrite a file with a summary; backs up the original to \`archive/\` automatically
- \`git_snapshot(message)\` — after updating memories, offer to save a snapshot
## Steps
### 0. Cross-check assistant memory (if available)
If the runtime has local/session memory, review entries from this session.
Promote repo-worthy entries into Step 3 content.
Promote:
- Story/craft decisions
- Character or world rules
- Structural decisions needed in future sessions
- Anything that must survive across devices
Keep local only:
- Workflow/tool preferences
- Assistant behavior feedback
- Setup/environment notes
- Session-local context
If no local/session memory exists, skip this step.
### 1. Identify what to save
List the decisions, insights, or facts from the session worth preserving.
### 2. Check existing files
Use \`memory_list\` to see which memory files exist and how large they are.
### 3. Append the entry
Use \`memory_append\` to write to the right file:
- \`global.md\` — cross-chapter decisions (character names, world rules, style choices)
- \`chXX.md\` — chapter-specific decisions (e.g. \`ch10.md\`)
Arguments:
- \`file\`: just the filename, e.g. \`global.md\` or \`ch10.md\`
- \`title\`: short topic label, e.g. \`"Elder introduction — character decisions"\`
- \`content\`: the decisions to record, one per line
The tool stamps the current date. Do not add a date to the content.
### 4. Compact if needed
If \`memory_list\` shows a file exceeding ~150 lines, offer to compact it:
- Summarize the existing content into a concise replacement
- Call \`memory_compact(file, compacted_content)\` — original is backed up automatically
### 5. Snapshot
Offer to save a snapshot with \`git_snapshot\`.
## Rules
- Always use \`memory_append\` — never use the Edit tool to write to memory files
- Do not add dates to content the tool stamps them automatically
- Compaction is always opt-in
`;
return CONTENT;
}

View file

@ -0,0 +1,127 @@
import type { TemplateContext, TemplateMeta } from '../context';
export const meta: TemplateMeta = {
file: '.claude/skills/plan-beats/SKILL.md',
version: 2,
label: 'plan-beats skill',
zip: '.claude/skills/plan-beats.zip',
};
const CONTENT = [
"---",
"name: plan-beats",
"description: Bindery workspace - Create or refine a beatmap for a chapter or scene. Use for /plan-beats, \"help me plan the beats\", \"let's work on the beatmap\", \"update the beatmap\", \"extend the beatmap\", \"refine chapter beats\", or \"discuss the structure of this chapter\".",
"argument-hint: 'chapter number or scene name (optional)'",
"---",
"# Skill: /plan-beats",
"",
"Build, discuss, and refine a beatmap for a chapter or scene — from scratch, from a story idea, from the arc, or by updating an existing beatmap.",
"",
"**Layered writing workflow:** Beats naturally progress through detail levels as writing matures:",
"1. **Story outline** — the chapter's role in the arc, rough shape",
"2. **1-line beats** — one sentence per beat: subject + action + consequence",
"3. **1-paragraph beats** — one paragraph per beat: expanded with intent, emotion, and context",
"4. **Prose** — the written chapter",
"",
"This skill works at levels 13. When working with existing beats, detect the current level and treat advancement to the next level as a valid refinement goal.",
"",
"## Prerequisites",
"This skill requires a Bindery workspace. If unsure, call `identify_book` to check. If no workspace is found, tell the user and stop.",
"",
"## Trigger",
"User says `/plan-beats`, \"help me plan the beats\", \"let's work on the beatmap\", \"update the beatmap\", \"extend the beatmap\", \"refine chapter beats\", \"discuss the structure\", or any request that explicitly mentions beats, structure, or chapter planning.",
"",
"## Clarify first",
"Determine the **mode** and **target** before proceeding:",
"",
"- **Mode** (pick one):",
" - **create** — build a fresh beatmap from the arc, a general idea, or a chapter outline",
" - **update** — add, move, remove, or reorder beats in an existing beatmap",
" - **refine** — improve beat descriptions, tighten pacing, or clarify purpose",
" - **expand** — advance beats from one detail level to the next (1-line → 1-paragraph)",
"- **Target**: chapter number, scene name, or range (e.g. \"Chapter 5\" or \"the ambush scene in Chapter 8\")",
"- **Detail level** (for create/expand): 1-line or 1-paragraph — infer from context or existing beats; ask only if genuinely unclear",
"",
"If mode is ambiguous, infer it: existing beats found and user mentions adding/removing/reordering → **update**; existing beats found and user mentions improving/tightening/clarifying → **refine**; existing 1-line beats and user mentions expanding/fleshing out → **expand**; existing beats found and intent unclear → default to **refine**; no beats found → **create**. This skill is meant to be interactive and iterative — if the user just wants a full beatmap generated without discussion, they can clarify that in the prompt or ask for a draft first.",
"",
"> **Project convention**: Beat storage varies by project. Common locations: `Arc/Acts/` planning files, chapter sections in `Arc/Overall.md`, inline comments in the chapter file, or a separate outline file. Detect this from the project files or workspace instructions (for example `COWORK.md` or `.bindery/README.md`). If unclear, ask the user.",
"",
"## Tools",
"Use these Bindery MCP tools:",
"- `get_chapter(chapterNumber, language)` — read an existing chapter and any embedded beatmap",
"- `get_text(path)` — read settings, `COWORK.md`, `.bindery/README.md`, or memory files",
"- `arc_list` / `arc_get` / `arc_update` — read or update structured arc files that hold beatmaps",
"- `character_list` / `character_get` — load relevant structured character profiles",
"- `get_notes(category, name)` — look up characters, world rules, or equipment",
"- `search(query, language)` — find parallel moments or thematic echoes across the book",
"",
"> If any tool call fails or returns no data, inform the user which file could not be read and continue without it.",
"",
"## Steps",
"",
"### Phase 1 — Confirm intent",
"1. Identify the **mode** and **target** from the user's message. Ask only if genuinely ambiguous. If the user requests a range of chapters (e.g. \"chapters 58\"), handle one chapter at a time and confirm before moving to the next.",
"",
"### Phase 2 — Load arc and settings context",
"2. Read `.bindery/settings.json` with `get_text` (genre, target audience, story structure).",
"3. Read `.bindery/memories/global.md`, `Arc/index.md`, `Arc/Overall.md`, and the relevant file under `Arc/Acts/` for the chapter's act. If no `Arc/` directory exists, check for alternative planning directories (e.g. `Outline/`, `Planning/`); if none found, proceed without arc context and note this to the user.",
"",
"### Phase 3 — Locate existing beats",
"4. Locate beat storage using this fallback chain:",
" 1. Check COWORK.md or README for a beat storage convention.",
" 2. If not found, check `Arc/index.md`, `Arc/Overall.md`, relevant `Arc/Acts/` files, then the chapter file itself.",
" 3. If still not found, ask the user: *\"Where do you keep your chapter beats — in an arc file, in the chapter itself, or somewhere else?\"*",
" 4. If no arc, outline, chapter, or beat file exists at all, tell the user there is no context to work from and ask them to describe the chapter's purpose and key events before proceeding.",
"5. Read the relevant file(s) to extract any existing beats for the target chapter.",
"6. If the chapter is already written, read it with `get_chapter` for scene structure and prose context.",
"7. Read any chapter-level memory or planning note if it exists (e.g. `.bindery/memories/chXX.md`).",
"",
"### Phase 4 — Load focused context (as needed)",
"8. If character-driven beats are involved: call `character_list`, then `character_get` for relevant profiles. Use `get_notes(category: \"Characters\")` only for older note layouts.",
"9. Call `search` to check for thematic echoes or parallel beats elsewhere in the book.",
"",
"### Phase 5 — Discuss",
"10. **all modes**: Align on the chapter's purpose before building beats — confirm or briefly discuss the key story beats and emotional arc. If the user has explicitly stated the chapter's purpose and key turning points, skip to Phase 6. (This is not a clarifying question about mode or target, but a contextual alignment step.)",
"11. **update/refine**: For each existing beat, discuss whether it still serves the chapter's/scene's purpose and arc. Propose specific additions, removals, or changes — discuss before applying.",
"",
"### Phase 6 — Build or update the beatmap",
"12. **create**: produce a full beatmap at the agreed detail level from the arc structure and gathered context (discussion already covered in Phase 5).",
"13. **update**: output the updated beatmap with the additions, moves, or removals agreed in Phase 5.",
"14. **refine**: output the refined beatmap with per-beat changes noted (improvements agreed in Phase 5).",
"15. **expand**: output the beatmap at the next detail level — 1-line beats become 1-paragraph beats. Each paragraph should add intent, emotional context, and immediate consequence beyond what the 1-line version stated. Discuss any beats that are unclear or underdeveloped before expanding them.",
"",
"## Output format",
"",
"Match the target detail level:",
"",
"**1-line beats** — numbered list, one sentence per beat, concrete and action-focused:",
"```",
"1. [Subject + action + immediate consequence.]",
"2. …",
"```",
"",
"**1-paragraph beats** — numbered list, one short paragraph per beat. Include: what happens, why it matters to the character, and how it sets up the next beat:",
"```",
"1. [What happens. How the character experiences it. What it changes or makes possible.]",
"2. …",
"```",
"",
"After the beatmap, add a short **Pacing note** (23 sentences): where the chapter breathes, where it accelerates, and any structural gaps.",
"",
"For **update** or **refine** mode, also include a change summary:",
"- `+` Added: …",
"- `~` Changed: …",
"- `-` Removed: …",
"",
"> If the user prefers a more annotated format (beat type, purpose), offer it — but don't impose it by default.",
"",
"## Rules",
"- This skill is for *discussing and planning* beats — it does not write prose",
"- Respect established world rules, character voices, and arc commitments",
"- Keep content appropriate for the configured target audience",
"- A standard chapter typically has 612 beats; short scenes may have 36",
].join('\n') + '\n';
export function render(_ctx: TemplateContext): string {
return CONTENT;
}

View file

@ -2,246 +2,255 @@ import type { TemplateContext, TemplateMeta } from '../context';
export const meta: TemplateMeta = {
file: '.claude/skills/proof-read/SKILL.md',
version: 5,
version: 6,
label: 'proof-read skill',
zip: '.claude/skills/proof-read.zip',
};
const CONTENT = [
"---",
"name: proof-read",
"description: Bindery workspace - Multi-perspective proofreading using isolated reader and author personas. Each persona runs as a scoped subagent with no arc, notes, or memory context — only the reading-text payload for the read-so-far experience (chapters 1..N). Use for /proof-read, \"proofread chapter X\", \"get reader feedback\", \"how does this land with readers\", \"simulate reader reactions\", or \"peer review\".",
"---",
"# Skill: /proof-read",
"",
"Simulates a panel of readers reviewing a chapter as genuine first-time readers — no arc knowledge, no notes, no memory of prior sessions. Each persona runs as an isolated subagent that only sees the reading-text payload so far (chapters 1..N) and their assigned role.",
"",
"The value is in the isolation. A reader doesn't know what the arc says should happen, what a character's backstory is, or what the chapter was *trying* to do. That's exactly the feedback you can't give yourself, and can't get from an agent that has been working on the book with you.",
"",
"## Prerequisites",
"This skill requires a Bindery workspace. If unsure, call `identify_book` to check. If no workspace is found, tell the user and stop.",
"",
"## Trigger",
"User says `/proof-read`, \"proofread chapter X\", \"get reader feedback\", \"how does this land with readers\", \"simulate reader reactions\", or \"peer review\".",
"",
"## Steps",
"",
"### Step 0: Load project context",
"",
"Before asking the user anything, read the project settings:",
"",
"```",
"get_text(\".bindery/settings.json\")",
"```",
"",
"Extract:",
"- `targetAudience` — used to calibrate reader personas (age, reading level)",
"- `genre` — used to construct the genre-fan persona and to generate author suggestions if needed",
"- `proof_read.authors` — the stored author panel for this project (may be absent)",
"",
"If `settings.json` has no `proof_read` section yet, that's expected on first run — handle it in the author setup step below.",
"",
"### Step 1: Author panel setup",
"",
"**If `proof_read.authors` is set:**",
"Present the stored authors and confirm:",
"> \"I have [Author A], [Author B], and [Author C] saved for this project. Shall I use them, or would you like to change the panel?\"",
"",
"If the user wants to change: follow the \"no authors stored\" flow below, then update settings.",
"",
"**If `proof_read.authors` is not set (first run):**",
"Ask:",
"> \"No author panel configured yet for this project. Would you like suggestions based on the genre, or do you have specific writers in mind?\"",
"",
"- If **suggestions**: generate 4-5 relevant author names based on the book's genre, audience, and tone (see Author Suggestions below). Present them with a one-line description each. Let the user pick 23.",
"- If **own names**: accept the user's list as-is.",
"",
"Once the panel is confirmed, store it back to settings:",
"",
"```",
"settings_update({ patch: { proof_read: { authors: [ { name: \"...\", known_for: \"...\", reads_for: \"...\" } ] } } })",
"```",
"",
"The `reads_for` field is a short phrase describing what this author's lens brings — e.g. \"pacing of reveals, handling of danger for the age group\". Generate it at storage time so it's available for subagent prompts without needing a web lookup later.",
"",
"### Step 2: Gather remaining parameters",
"",
"Ask:",
"1. Which chapter to focus on — or the whole book?",
"2. Which language? Default to the source language from `settings.json`; ask if ambiguous.",
"3. Quick run (2 readers + 1 author) or full run (all 4 readers + full author panel)?",
"",
"If the user invoked `/proof-read 7` or similar, the focus chapter is known — no need to ask.",
"",
"### Step 3: Fetch the reading context",
"",
"A real reader arrives at chapter N having read everything before it. Subagents receive the full text from chapter 1 up to and including the focus chapter — not a summary, not just the target chapter in isolation.",
"",
"**Why not a summary of prior chapters?** Any summary written by an agent who has worked on the book will carry arc knowledge — framing, foreshadowing, loaded context. It biases the subagent in ways a real reader wouldn't be. Full text preserves the isolation.",
"",
"**Why not have subagents call MCP themselves?** Subagents with MCP access could accidentally pull notes, arc files, or overviews. Using a pre-written staging file and passing only that payload to subagents reduces that risk and is the best available way in this workflow to keep them focused on reader-visible text.",
"",
"Use `get_book_until(chapterNumber: n, language)` to fetch all prior chapters in one call. If unavailable, loop `get_chapter(1)` through `get_chapter(n)` in the main agent. For a **whole-book** run, fetch all chapters.",
"",
"Once the text is retrieved, **write it to a staging file**:",
"`.bindery/proof-read-payload.md`",
"",
"If the file already exists from a previous run, overwrite it.",
"",
"> **Note:** This file should be gitignored (`.bindery/proof-read-payload.md`). If it is not already in the project's `.gitignore`, flag it to the user — it should not be committed.",
"",
"Modern context windows handle full books comfortably — a 20-chapter 12+ novel is roughly 60-80k words, well within range.",
"",
"**For a whole-book run:** fetch all chapters into a single staging file. Then run subagents in per-chapter batches: each batch reads the full staging file but focuses feedback on that chapter. Aggregate per chapter, then offer a cross-chapter summary when all batches are done. Warn the user upfront — this is expensive.",
"",
"### Step 4: Spawn all subagents in a single turn",
"",
"Launch all persona subagents in parallel. Each receives:",
"- Their persona description (constructed from project context — see Reader Personas and Author Personas below)",
"- The path to the staging file written in Step 3",
"- The review task (see Review Task Template) — which instructs them to read the staging file as their **only** file access",
"- An explicit reminder that they have no prior knowledge of this book beyond what they read from that file",
"",
"### Step 5: Aggregate",
"",
"Once all subagents return, aggregate across the full panel:",
"",
"1. **Consensus positives** — moments or elements praised by a multitude of readers. These are your strongest material.",
"2. **Consensus issues** — problems flagged by a multitude of readers. Highest priority to address.",
"3. **Notable divergences** — where one reader type loved something another didn't. Not automatically a problem, but a useful creative signal (e.g. a core reader engaged by a worldbuilding passage that lost the reluctant reader).",
"4. **Author notes** — surface separately. These are craft-level observations, not reader reactions, and shouldn't be averaged against them.",
"",
"Present individual reactions first (summarised), then the aggregated view. Close with a short prioritised action list.",
"",
"---",
"",
"## Reader Personas",
"",
"Reader personas are constructed from the project's `targetAudience` and `genre` settings — do not hardcode ages or genre references. Use the actual values from settings.",
"",
"The four reader roles stay stable, but R1 and R3 should be chosen relative to the book's genre rather than treated as fixed labels:",
"",
"**R1 — Core Reader**",
"A reader at the target age who actively seeks out this kind of book. If the project is fantasy, this is a fantasy reader; if it is realistic contemporary fiction, this is a realistic-fiction reader. They know what this corner does well, enjoy its native pleasures, and notice quickly when the execution is strong or weak.",
"",
"**R2 — Curious Reader**",
"A reader at the target age who reads regularly but not primarily in this genre. Open and engaged, but reacts as an outsider to genre conventions.",
"",
"**R3 — Opposite-Corner Reader**",
"A reader at the target age whose tastes pull away from the book's home genre. Their job is to test whether the text still works for someone who does not naturally prize this genre's default strengths. For fantasy, this might be a realism-first reader who cares most about emotional plausibility and character grounding. For realistic fiction, it should be a reader from a different corner, such as mystery, thriller, romance, horror, or speculative fiction, who wants a stronger external hook or a different kind of momentum.",
"",
"**R4 — Reluctant Reader**",
"A reader at the target age who reads when they have to. Will notice immediately if something drags or confuses. Short patience for exposition. Will find genuine excitement if it's there — but won't invent it.",
"",
"When building the subagent prompt, fill in the actual age range and genre from settings. Choose R3 as the deliberate contrast to the project's genre, not always as \"the realist\". For example, if `targetAudience` is \"12+\" and `genre` is \"sci-fi/fantasy crossover\", R1 becomes: *\"You are 12-13 years old. You read a lot and you love sci-fi and fantasy...\"* If the genre is realistic contemporary fiction, R3 should instead come from a different reading corner, such as mystery, thriller, or speculative fiction.",
"",
"---",
"",
"## Author Personas",
"",
"Author personas come from `proof_read.authors` in settings. Each entry has `name`, `known_for`, and `reads_for`. Use these fields directly in the subagent prompt — no need to reconstruct them.",
"",
"### Author Suggestions",
"",
"When the user asks for suggestions, generate a shortlist of 4-5 authors whose work overlaps meaningfully with the book's genre, tone, and target audience. Good criteria:",
"",
"- Writes for approximately the same age group",
"- Works in the same genre or a closely adjacent one",
"- Has a distinctive craft lens that adds something different from the others (e.g. one known for worldbuilding, one for pacing, one for character voice)",
"- Ideally at least one who writes in a \"neighbouring\" genre (e.g. for a fantasy book, a post-apocalyptic author) to get an outside-genre craft read",
"",
"Present each suggestion with: name, one well-known title, and what their lens would add to the review.",
"",
"---",
"",
"## Review Task Template",
"",
"For **reader personas**:",
"",
"> You are [PERSONA DESCRIPTION built from project settings].",
">",
"> You are reading [TARGET CHAPTER OR BOOK] from a [GENRE] novel aimed at [TARGET AUDIENCE] readers. You have no prior knowledge of this book — no plot summaries, no character guides, no notes. You are reading this cold, exactly as you would if you'd just picked it up.",
"> [CHAPTER NOTE: if the focus is a single chapter, say, \"you read up to and including chapter N, focus your feedback on chapter N\"]",
">",
"> The text is in the file at: `[STAGING FILE PATH]`",
"> Read that file using the `read_file` tool. **That is the only file you may access.** Do not call any other tool, MCP server, or external resource.",
">",
"> Give your honest reaction as this reader. Cover:",
"> 1. Your overall impression (1-2 sentences)",
"> 2. Moments that worked — where you were engaged, what you enjoyed",
"> 3. Moments that didn't land — confusion, slow patches, anything that pulled you out",
"> 4. Characters: did they feel real? Did you care what happened to them?",
"> 5. Specific lines or passages worth flagging (positive or negative) — quote them",
"> 6. Would you keep reading? Why or why not?",
">",
"> Be specific. Quote the text when it helps. Do not summarise the plot — react to it.",
"",
"For **author personas**:",
"",
"> You are reading this book as [AUTHOR NAME], author of [KNOWN_FOR], giving peer feedback to a fellow writer. The book is aimed at [TARGET AUDIENCE] readers. You have no prior knowledge of the manuscript beyond this text.",
"> [CHAPTER NOTE: if the focus is a single chapter, say, \"you read up to and including chapter N, focus your feedback on chapter N\"]",
">",
"> Your particular focus: [READS_FOR].",
">",
"> The manuscript is in the file at: `[STAGING FILE PATH]`",
"> Read that file using the `read_file` tool. **That is the only file you may access.** Do not call any other tool, MCP server, or external resource.",
">",
"> Give craft-level feedback: what's working and why, what isn't and how you'd think about fixing it. Voice, pacing, structure, dialogue, the handling of tension. Quote the text when useful. Be honest — this is peer review, not encouragement.",
"",
"---",
"",
"## Output Format",
"",
"```",
"## Proof-read: [Book title if available] / Chapter [N] — [Chapter title if available]",
"",
"### Reader reactions",
"",
"**R1 — Core reader**",
"[2-3 sentence summary. Key quote if strong.]",
"",
"**R2 — Curious reader**",
"...",
"",
"**R3 — Opposite-corner reader**",
"...",
"",
"**R4 — Reluctant reader**",
"...",
"",
"### Author peer review",
"",
"**[Author name]** ([known_for, short])",
"[Craft observations, 3-4 sentences]",
"",
"...",
"",
"### What landed (consensus — 3+ readers)",
"- [Specific moment or element] — flagged by [names]",
"- ...",
"",
"### What needs attention (consensus — 3+ readers)",
"- [Issue] — flagged by [names]",
"- ...",
"",
"### Divergences worth noting",
"- [Element] resonated with core readers but lost the opposite-corner / reluctant reader",
"- ...",
"",
"### Suggested actions",
"1. [Highest priority]",
"2. ...",
"```",
"",
"---",
"",
"## Quick Run",
"",
"For a faster pass: **R1** (core reader), **R4** (reluctant reader), and the first stored author. Two reader extremes plus a craft read — widest spread with fewest subagents.",
"",
"---",
"",
"## Notes for the agent",
"",
"- **Never** give subagents MCP access. The calling agent should write the reading text to `.bindery/proof-read-payload.md` and have subagents work only from that staged file. This reduces the risk of them pulling arc files, notes, or overviews, but treat it as a best-effort workflow unless access restrictions are enforced by the runtime.",
"- **Staging file:** overwrite it fresh each run so stale text from a previous session never bleeds in.",
"- **Multiple chapters:** Run each chapter as a separate parallel batch. Aggregate per chapter first, then offer a cross-chapter summary if the user asks.",
"- **Cost awareness:** Full run is 4 readers + N authors per chapter batch (where N = size of the stored author panel, typically 23). Mention the total subagent count before starting, especially for whole-book runs or long chapters.",
"- **Divergences are data, not problems.** A passage that splits readers along genre-familiarity lines might be exactly right for this book. Surface it, let the author decide.",
"- **Author panel changes:** If the user swaps authors mid-session, update `proof_read.authors` in settings before running so the change persists.",
].join('\n') + '\n';
export function render(_ctx: TemplateContext): string {
return `---
name: proof-read
description: Bindery workspace - Multi-perspective proofreading using isolated reader and author personas. Each persona runs as a scoped subagent with no arc, notes, or memory context only the reading-text payload for the read-so-far experience (chapters 1..N). Use for /proof-read, "proofread chapter X", "get reader feedback", "how does this land with readers", "simulate reader reactions", or "peer review".
---
# Skill: /proof-read
Simulates a panel of readers reviewing a chapter as genuine first-time readers no arc knowledge, no notes, no memory of prior sessions. Each persona runs as an isolated subagent that only sees the reading-text payload so far (chapters 1..N) and their assigned role.
The value is in the isolation. A reader doesn't know what the arc says should happen, what a character's backstory is, or what the chapter was *trying* to do. That's exactly the feedback you can't give yourself, and can't get from an agent that has been working on the book with you.
## Prerequisites
This skill requires a Bindery workspace. If unsure, call \`identify_book\` to check.
## Trigger
User says \`/proof-read\`, "proofread chapter X", "get reader feedback", "how does this land with readers", "simulate reader reactions", or "peer review".
## Steps
### Step 0: Load project context
Before asking the user anything, read the project settings:
\`\`\`
get_text(".bindery/settings.json")
\`\`\`
Extract:
- \`targetAudience\` — used to calibrate reader personas (age, reading level)
- \`genre\` — used to construct the genre-fan persona and to generate author suggestions if needed
- \`proof_read.authors\` — the stored author panel for this project (may be absent)
If \`settings.json\` has no \`proof_read\` section yet, that's expected on first run — handle it in the author setup step below.
### Step 1: Author panel setup
**If \`proof_read.authors\` is set:**
Present the stored authors and confirm:
> "I have [Author A], [Author B], and [Author C] saved for this project. Shall I use them, or would you like to change the panel?"
If the user wants to change: follow the "no authors stored" flow below, then update settings.
**If \`proof_read.authors\` is not set (first run):**
Ask:
> "No author panel configured yet for this project. Would you like suggestions based on the genre, or do you have specific writers in mind?"
- If **suggestions**: generate 4-5 relevant author names based on the book's genre, audience, and tone (see Author Suggestions below). Present them with a one-line description each. Let the user pick 23.
- If **own names**: accept the user's list as-is.
Once the panel is confirmed, store it back to settings:
\`\`\`
settings_update({ patch: { proof_read: { authors: [ { name: "...", known_for: "...", reads_for: "..." } ] } } })
\`\`\`
The \`reads_for\` field is a short phrase describing what this author's lens brings — e.g. "pacing of reveals, handling of danger for the age group". Generate it at storage time so it's available for subagent prompts without needing a web lookup later.
### Step 2: Gather remaining parameters
Ask:
1. Which chapter to focus on or the whole book?
2. Quick run (2 readers + 1 author) or full run (all 4 readers + full author panel)?
If the user invoked \`/proof-read 7\` or similar, the focus chapter is known — no need to ask.
### Step 3: Fetch the reading context
A real reader arrives at chapter N having read everything before it. Subagents receive the full text from chapter 1 up to and including the focus chapter not a summary, not just the target chapter in isolation.
**Why not a summary of prior chapters?** Any summary written by an agent who has worked on the book will carry arc knowledge framing, foreshadowing, loaded context. It biases the subagent in ways a real reader wouldn't be. Full text preserves the isolation.
**Why not have subagents call MCP themselves?** Subagents with MCP access could accidentally pull notes, arc files, or overviews. Using a pre-written staging file and passing only that payload to subagents reduces that risk and is the best available way in this workflow to keep them focused on reader-visible text.
Use \`get_book_until(chapterNumber: n, language)\` to fetch all prior chapters in one call. If unavailable, loop \`get_chapter(1)\` through \`get_chapter(n)\` in the main agent. For a **whole-book** run, fetch all chapters.
Once the text is retrieved, **write it to a staging file**:
\`.bindery/proof-read-payload.md\`
If the file already exists from a previous run, overwrite it.
Modern context windows handle full books comfortably a 20-chapter 12+ novel is roughly 60-80k words, well within range.
### Step 4: Spawn all subagents in a single turn
Launch all persona subagents in parallel. Each receives:
- Their persona description (constructed from project context see Reader Personas and Author Personas below)
- The path to the staging file written in Step 3
- The review task (see Review Task Template) which instructs them to read the staging file as their **only** file access
- An explicit reminder that they have no prior knowledge of this book beyond what they read from that file
### Step 5: Aggregate
Once all subagents return, aggregate across the full panel:
1. **Consensus positives** moments or elements praised by a multitude of readers. These are your strongest material.
2. **Consensus issues** problems flagged by a multitude of readers. Highest priority to address.
3. **Notable divergences** where one reader type loved something another didn't. Not automatically a problem, but a useful creative signal (e.g. a core reader engaged by a worldbuilding passage that lost the reluctant reader).
4. **Author notes** surface separately. These are craft-level observations, not reader reactions, and shouldn't be averaged against them.
Present individual reactions first (summarised), then the aggregated view. Close with a short prioritised action list.
---
## Reader Personas
Reader personas are constructed from the project's \`targetAudience\` and \`genre\` settings — do not hardcode ages or genre references. Use the actual values from settings.
The four reader roles stay stable, but R1 and R3 should be chosen relative to the book's genre rather than treated as fixed labels:
**R1 Core Reader**
A reader at the target age who actively seeks out this kind of book. If the project is fantasy, this is a fantasy reader; if it is realistic contemporary fiction, this is a realistic-fiction reader. They know what this corner does well, enjoy its native pleasures, and notice quickly when the execution is strong or weak.
**R2 Curious Reader**
A reader at the target age who reads regularly but not primarily in this genre. Open and engaged, but reacts as an outsider to genre conventions.
**R3 Opposite-Corner Reader**
A reader at the target age whose tastes pull away from the book's home genre. Their job is to test whether the text still works for someone who does not naturally prize this genre's default strengths. For fantasy, this might be a realism-first reader who cares most about emotional plausibility and character grounding. For realistic fiction, it should be a reader from a different corner, such as mystery, thriller, romance, horror, or speculative fiction, who wants a stronger external hook or a different kind of momentum.
**R4 Reluctant Reader**
A reader at the target age who reads when they have to. Will notice immediately if something drags or confuses. Short patience for exposition. Will find genuine excitement if it's there — but won't invent it.
When building the subagent prompt, fill in the actual age range and genre from settings. Choose R3 as the deliberate contrast to the project's genre, not always as "the realist". For example, if \`targetAudience\` is "12+" and \`genre\` is "sci-fi/fantasy crossover", R1 becomes: *"You are 12-13 years old. You read a lot and you love sci-fi and fantasy..."* If the genre is realistic contemporary fiction, R3 should instead come from a different reading corner, such as mystery, thriller, or speculative fiction.
---
## Author Personas
Author personas come from \`proof_read.authors\` in settings. Each entry has \`name\`, \`known_for\`, and \`reads_for\`. Use these fields directly in the subagent prompt — no need to reconstruct them.
### Author Suggestions
When the user asks for suggestions, generate a shortlist of 4-5 authors whose work overlaps meaningfully with the book's genre, tone, and target audience. Good criteria:
- Writes for approximately the same age group
- Works in the same genre or a closely adjacent one
- Has a distinctive craft lens that adds something different from the others (e.g. one known for worldbuilding, one for pacing, one for character voice)
- Ideally at least one who writes in a "neighbouring" genre (e.g. for a fantasy book, a post-apocalyptic author) to get an outside-genre craft read
Present each suggestion with: name, one well-known title, and what their lens would add to the review.
---
## Review Task Template
For **reader personas**:
> You are [PERSONA DESCRIPTION built from project settings].
>
> You are reading [TARGET CHAPTER OR BOOK] from a [GENRE] novel aimed at [TARGET AUDIENCE] readers. You have no prior knowledge of this book no plot summaries, no character guides, no notes. You are reading this cold, exactly as you would if you'd just picked it up.
> [CHAPTER NOTE: if the focus is a single chapter, say, "you read up to and including chapter N, focus your feedback on chapter N"]
>
> The text is in the file at: \`[STAGING FILE PATH]\`
> Read that file using the \`read_file\` tool. **That is the only file you may access.** Do not call any other tool, MCP server, or external resource.
>
> Give your honest reaction as this reader. Cover:
> 1. Your overall impression (1-2 sentences)
> 2. Moments that worked where you were engaged, what you enjoyed
> 3. Moments that didn't land confusion, slow patches, anything that pulled you out
> 4. Characters: did they feel real? Did you care what happened to them?
> 5. Specific lines or passages worth flagging (positive or negative) quote them
> 6. Would you keep reading? Why or why not?
>
> Be specific. Quote the text when it helps. Do not summarise the plot react to it.
For **author personas**:
> You are reading this book as [AUTHOR NAME], author of [KNOWN_FOR], giving peer feedback to a fellow writer. The book is aimed at [TARGET AUDIENCE] readers. You have no prior knowledge of the manuscript beyond this text.
> [CHAPTER NOTE: if the focus is a single chapter, say, "you read up to and including chapter N, focus your feedback on chapter N"]
>
> Your particular focus: [READS_FOR].
>
> The manuscript is in the file at: \`[STAGING FILE PATH]\`
> Read that file using the \`read_file\` tool. **That is the only file you may access.** Do not call any other tool, MCP server, or external resource.
>
> Give craft-level feedback: what's working and why, what isn't and how you'd think about fixing it. Voice, pacing, structure, dialogue, the handling of tension. Quote the text when useful. Be honest this is peer review, not encouragement.
---
## Output Format
\`\`\`
## Proof-read: [Book title if available] / Chapter [N] [Chapter title if available]
### Reader reactions
**R1 Core reader**
[2-3 sentence summary. Key quote if strong.]
**R2 Curious reader**
...
**R3 Opposite-corner reader**
...
**R4 Reluctant reader**
...
### Author peer review
**[Author name]** ([known_for, short])
[Craft observations, 3-4 sentences]
...
### What landed (consensus 3+ readers)
- [Specific moment or element] flagged by [names]
- ...
### What needs attention (consensus 3+ readers)
- [Issue] flagged by [names]
- ...
### Divergences worth noting
- [Element] resonated with core readers but lost the opposite-corner / reluctant reader
- ...
### Suggested actions
1. [Highest priority]
2. ...
\`\`\`
---
## Quick Run
For a faster pass: **R1** (core reader), **R4** (reluctant reader), and the first stored author. Two reader extremes plus a craft read widest spread with fewest subagents.
---
## Notes for the agent
- **Never** give subagents MCP access. The calling agent should write the reading text to \`.bindery/proof-read-payload.md\` and have subagents work only from that staged file. This reduces the risk of them pulling arc files, notes, or overviews, but treat it as a best-effort workflow unless access restrictions are enforced by the runtime.
- **Staging file:** overwrite it fresh each run so stale text from a previous session never bleeds in.
- **Multiple chapters:** Run each chapter as a separate parallel batch. Aggregate per chapter first, then offer a cross-chapter summary if the user asks.
- **Cost awareness:** Full run is 7 subagent calls per chapter (4 readers + 3 authors). Mention this if the user hasn't specified quick vs. full, especially for longer chapters.
- **Divergences are data, not problems.** A passage that splits readers along genre-familiarity lines might be exactly right for this book. Surface it, let the author decide.
- **Author panel changes:** If the user swaps authors mid-session, update \`proof_read.authors\` in settings before running so the change persists.`;
return CONTENT;
}

View file

@ -2,55 +2,60 @@ import type { TemplateContext, TemplateMeta } from '../context';
export const meta: TemplateMeta = {
file: '.claude/skills/read-aloud/SKILL.md',
version: 10,
version: 11,
label: 'read-aloud skill',
zip: '.claude/skills/read-aloud.zip',
};
const CONTENT = [
"---",
"name: read-aloud",
"description: Bindery workspace - Test how a chapter or passage sounds when read aloud — flags long sentences, staccato rhythm, complex vocabulary, and said-bookisms. Use for /read-aloud, \"reading test\", or \"how does this sound\".",
"---",
"# Skill: /read-aloud",
"",
"Test how a chapter sounds when read aloud — calibrated for books that may be read by an adult to a child, as well as for silent reading.",
"",
"## Prerequisites",
"This skill requires a Bindery workspace. If unsure, call `identify_book` to check. If no workspace is found, tell the user and stop.",
"",
"## Trigger",
"User says `/read-aloud`, \"reading test\", or \"how does this sound\".",
"",
"## Clarify first",
"- Whole chapter or specific passage?",
"",
"## Runtime context",
"Before reviewing, read \".bindery/settings.json\" with `get_text` to pick up the current book's target audience and genre.",
"",
"## Tools",
"Use these Bindery MCP tools:",
"- `get_chapter(chapterNumber, language)` — read the full chapter",
"- `get_text(identifier, startLine, endLine)` — read a specific passage by line range",
"",
"## What to check",
"- Sentences over ~30 words",
"- Sequences of 3+ short sentences (staccato)",
"- Vocabulary too complex for the book's configured target audience",
"- Said-bookisms in dialogue (\"she exclaimed breathlessly\" → prefer \"said\" or action beat)",
"- Paragraphs over 8 lines without a break",
"- Accidental word repetition within 2-3 sentences",
"- **Phonetic difficulty** — hard consonant clusters, rhyme-clash between adjacent words, or names/terms that are easy to mispronounce when reading aloud at pace",
"- **Dialogue speaker clarity** — can a reader tell who is speaking without scanning back? Flag exchanges of 3+ lines where no attribution is given and context alone may not be enough",
"",
"## Output format",
"",
"| Type | Location | Flagged text | Note |",
"|---|---|---|---|",
"| Long sentence | Para 3 | \"...\" (34 words) | Consider splitting |",
"",
"Brief overall impression (2-3 sentences) after the table.",
"",
"## Rules",
"- Focus on how it sounds when spoken — not a content review",
"- Suggestions are gentle (\"consider\", not \"must change\")",
].join('\n') + '\n';
export function render(_ctx: TemplateContext): string {
return `---
name: read-aloud
description: Bindery workspace - Test how a chapter or passage sounds when read aloud flags long sentences, staccato rhythm, complex vocabulary, and said-bookisms. Use for /read-aloud, "reading test", or "how does this sound".
---
# Skill: /read-aloud
Test how a chapter sounds when read aloud.
## Prerequisites
This skill requires a Bindery workspace. If unsure, call \`identify_book\` to check.
## Trigger
User says \`/read-aloud\`, "reading test", or "how does this sound".
## Clarify first
- Whole chapter or specific passage?
## Runtime context
Before reviewing, read ".bindery/settings.json" with \`get_text\` to pick up the current book's target audience and genre.
## Tools
Use these Bindery MCP tools:
- \`get_chapter(chapterNumber, language)\` — read the full chapter
- \`get_text(identifier, startLine, endLine)\` — read a specific passage by line range
## What to check
- Sentences over ~30 words
- Sequences of 3+ short sentences (staccato)
- Vocabulary too complex for the book's configured target audience
- Said-bookisms in dialogue ("she exclaimed breathlessly" prefer "said" or action beat)
- Paragraphs over 8 lines without a break
- Accidental word repetition within 2-3 sentences
## Output format
| Type | Location | Flagged text | Note |
|---|---|---|---|
| Long sentence | Para 3 | "..." (34 words) | Consider splitting |
Brief overall impression (2-3 sentences) after the table.
## Rules
- Focus on how it sounds when spoken not a content review
- Suggestions are gentle ("consider", not "must change")
`;
return CONTENT;
}

View file

@ -2,78 +2,93 @@ import type { TemplateContext, TemplateMeta } from '../context';
export const meta: TemplateMeta = {
file: '.claude/skills/read-in/SKILL.md',
version: 12,
version: 15,
label: 'read-in skill',
zip: '.claude/skills/read-in.zip',
};
const CONTENT = [
"---",
"name: read-in",
"description: Bindery workspace - Load project context at the start of a session — current focus, arc map, character index, memory, progress tracker, and chapter notes. Use for /read-in, \"get your bearings\", \"what were we doing\", or at the start of any working session.",
"---",
"# Skill: /read-in",
"",
"Load context and get your bearings before starting work.",
"",
"## Prerequisites",
"This skill requires a Bindery workspace. If unsure, call `identify_book` to check. If no workspace is found, tell the user and stop.",
"",
"## Trigger",
"User says `/read-in`, \"get your bearings\", \"what were we working on\", or at the start of a session.",
"",
"## Tools",
"Use these Bindery MCP tools:",
"- `update_workspace` — fetch and pull the workspace before loading context; also reports current branch versus the remote default branch",
"- `memory_list` — discover which memory files exist (`global.md`, `chXX.md` files)",
"- `get_text(identifier)` — read COWORK.md, settings, and memory files",
"- `arc_list` / `arc_get` — list and read structured arc files",
"- `character_list` / `character_get` — list and read structured character profiles",
"- `chapter_status_get(book)` — read the structured progress tracker",
"- `get_overview(language)` — list all acts and chapters (only if tracker is empty or sparse)",
"- `get_notes(category, name)` — look up key character or world notes if relevant to current focus",
"- `search(query, language)` — find relevant passages across the book based on current focus or open questions",
"- `get_chapter(chapterNumber, language)` — read a chapter if that's the current focus",
"",
"## Steps",
"",
"### 0. Sync repository",
"Call `update_workspace` before loading any context.",
"- If the update fails (for example: no remote, merge issue, or upstream problem), flag it to the user and stop — do not proceed with stale context.",
"- If the tool reports that the current branch differs from the remote default branch, mention that briefly so the user can decide whether to switch.",
"- If the tool reports that the workspace is already up to date, say nothing unless the branch status matters.",
"",
"### 1. Check for current focus",
"Use `get_text(\"COWORK.md\")` to read the current focus file (ignore if missing).",
"",
"### 2. Load global memory",
"Use `get_text(\".bindery/settings.json\")` first to pick up the current book's structure and conventions.",
"Then use `memory_list` to discover available memory files, and `get_text(\".bindery/memories/global.md\")` to load cross-chapter decisions.",
"",
"### 3. Read the arc and character map",
"Use `arc_list` and `arc_get` to load the structural map (`index.md`, `Overall.md`, and any relevant act/thread/chapter arc file). Use `character_list` to load the cast map; call `character_get` only for characters relevant to the current focus.",
"",
"### 4. Read the progress tracker",
"Use `chapter_status_get` to read current chapter progress. If it is empty or has fewer than 3 entries, also call `get_overview` for the full chapter listing.",
"",
"### 5. Determine working chapter",
"If COWORK.md names a chapter, use that.",
"Otherwise if the tracker has a single `in-progress` chapter, use that.",
"Otherwise — **ask the user**: \"Which chapter do you want to work on?\"",
"",
"### 6. Load chapter memory",
"- Once the chapter is known (e.g. chapter 10), check `memory_list` output for a matching file (`ch10.md`). If it exists, read it with `get_text(\".bindery/memories/ch10.md\")`.",
"- Read the full chapter text with `get_chapter` to have it fresh in context.",
"- **Cross-check memory against chapter text:** if the chapter contradicts something in the memory file (a character name, a decision, a detail), flag it explicitly in the summary — do not silently treat memory as correct. Memory records decisions as they stood at the time; the chapter is what was actually written.",
"",
"### 7. Story / Arc focus",
"Load additional context only if it is clearly relevant to the current chapter and focus:",
"- **Characters involved in the chapter**: call `character_get` for their profiles; use `get_notes(category: \"Characters\")` only for older note layouts",
"- **Open questions or unresolved details**: use `search` to find relevant earlier passages",
"",
"Do not load notes or search speculatively. If COWORK.md or the chapter memory names a specific open question, that is a good trigger; otherwise skip this step.",
"",
"### 8. Summarize",
"Output a short orientation (3-6 lines):",
"- Which chapter / scene we're in",
"- Status from the tracker (draft / in-progress / needs-review)",
"- Relevant arc file or open structural question",
"- Key open decisions from global memory relevant to this chapter",
"- Any chapter-specific notes from the chapter memory file; note if the memory file is absent or if the last entry appears old",
"- Any memory-vs-chapter discrepancies found in Step 5",
"- End with a phrase like: \"Ready — what would you like to work on?\"",
"",
"## Rules",
"- Do not load *all* chapter memories — only the one being worked on",
"- Keep the summary brief; this is orientation, not a full status report",
"- Do not suggest work or ask multiple questions — one question at most (which chapter?)",
].join('\n') + '\n';
export function render(_ctx: TemplateContext): string {
return `---
name: read-in
description: Bindery workspace - Load project context at the start of a session memory, progress tracker, and chapter notes. Use for /read-in, "get your bearings", "what were we doing", or at the start of any working session.
---
# Skill: /read-in
Load context and get your bearings before starting work.
## Prerequisites
This skill requires a Bindery workspace. If unsure, call \`identify_book\` to check.
## Trigger
User says \`/read-in\`, "get your bearings", "what were we working on", or at the start of a session.
## Tools
Use these Bindery MCP tools:
- \`update_workspace\` — fetch and pull the workspace before loading context; also reports current branch versus the remote default branch
- \`memory_list\` — discover which memory files exist (\`global.md\`, \`chXX.md\` files)
- \`get_text(identifier)\` — read COWORK.md and memory files
- \`chapter_status_get(book)\` — read the structured progress tracker
- \`get_overview(language)\` — list all acts and chapters (only if tracker is empty or sparse)
- \`get_notes(category, name)\` — look up key character or world notes if relevant to current focus
- \`search(query, language)\` — find relevant passages across the book based on current focus or open questions
- \`get_chapter(chapterNumber, language)\` — read a chapter if that's the current focus
## Steps
### 0. Sync repository
Call \`update_workspace\` before loading any context.
- If the update fails (for example: no remote, merge issue, or upstream problem), flag it to the user and stop do not proceed with stale context.
- If the tool reports that the current branch differs from the remote default branch, mention that briefly so the user can decide whether to switch.
- If the tool reports that the workspace is already up to date, say nothing unless the branch status matters.
### 1. Check for current focus
Use \`get_text("COWORK.md")\` to read the current focus file (ignore if missing).
### 2. Load global memory
Use \`get_text(".bindery/settings.json")\` first to pick up the current book's structure and conventions.
Then use \`memory_list\` to discover available memory files, and \`get_text(".bindery/memories/global.md")\` to load cross-chapter decisions.
### 3. Read the progress tracker
Use \`chapter_status_get\` to read current chapter progress. If it is empty or has fewer than 3 entries, also call \`get_overview\` for the full chapter listing.
### 4. Determine working chapter
If COWORK.md names a chapter, use that.
Otherwise if the tracker has a single \`in-progress\` chapter, use that.
Otherwise **ask the user**: "Which chapter do you want to work on?"
### 5. Load chapter memory
- Once the chapter is known (e.g. chapter 10), check \`memory_list\` output for a matching file (\`ch10.md\`). If it exists, read it with \`get_text(".bindery/memories/ch10.md")\`.
- Also read the full chapter text with \`get_chapter\` to have it fresh in context, and to check for any discrepancies with the memory file.
### 6. Story / Arc focus
Depending on the focus and open questions, use \`get_notes\` or \`search\` to load any additional relevant context.
### 7. Summarize
Output a short orientation (3-6 lines):
- Which chapter / scene we're in
- Status from the tracker (draft / in-progress / needs-review)
- Key open decisions from global memory relevant to this chapter
- Any chapter-specific notes from the chapter memory file
- End with a phrase like: "Ready — what would you like to work on?"
## Rules
- Do not load *all* chapter memories only the one being worked on
- Keep the summary brief; this is orientation, not a full status report
- Do not suggest work or ask multiple questions one question at most (which chapter?)
`;
return CONTENT;
}

View file

@ -2,75 +2,84 @@ import type { TemplateContext, TemplateMeta } from '../context';
export const meta: TemplateMeta = {
file: '.claude/skills/review/SKILL.md',
version: 13,
version: 14,
label: 'review skill',
zip: '.claude/skills/review.zip',
};
const CONTENT = [
"---",
"name: review",
"description: Bindery workspace - Review a chapter for language, arc consistency, and age-appropriateness. Use for /review, \"review chapter X\", \"quick review\", or \"review my changes\".",
"---",
"# Skill: /review",
"",
"Review a chapter and give structured feedback.",
"",
"## Prerequisites",
"This skill requires a Bindery workspace. If unsure, call `identify_book` to check. If no workspace is found, tell the user and stop.",
"",
"## Trigger",
"User says `/review`, \"review chapter X\", \"quick review\", or \"review my changes\".",
"",
"## Clarify first",
"- **Scope**: recent changes (`review my changes`), a specific chapter, or overall feedback?",
"- **Type**: **Full** (default) or **Quick** (language and typos only)? If not specified, default to Full.",
"- If the scope includes a translated chapter file, flag it and offer `/translation-review` instead.",
"",
"## Tools",
"Use these Bindery MCP tools to gather context:",
"- `get_review_text(autoStage: true, contextLines: 3)` — returns the git diff of uncommitted changes **plus** any regions wrapped in `<!-- Bindery: Review start -->` / `<!-- Bindery: Review stop -->` markers (works even on committed work). `autoStage: true` stages reviewed files **and** removes the marker lines from disk so the next call only shows new changes. Pass more contextLines when join points to existing prose need checking",
"- `get_chapter(chapterNumber, language)` — read the full chapter text",
"- `get_notes(category, name)` — look up character profiles (`category: \"Characters\"`) or world rules",
"- `search(query, language)` — find related passages across the book",
"- `git_snapshot(message)` — after a successful review, suggest saving a snapshot",
"",
"## Steps",
"",
"### 1. Load settings and context",
"Start by reading \".bindery/settings.json\" with `get_text(\".bindery/settings.json\")` to pick up the current book's target audience, genre, and story structure.",
"",
"Load the right context, pick any or all as needed:",
"- Read `.bindery/memories/global.md`",
"- Read `.bindery/memories/chXX.md` if it exists for chapter-specific context",
"- Use `get_chapter` to load the chapter",
"- For a Full review, read the relevant arc file from `Arc/`.",
"- For \"review my changes\", use `get_review_text` to get the diff",
"- If the diff includes translated chapter files, flag that and offer `/translation-review` for source-vs-translation feedback",
"",
"### 2. Perform the review",
"",
"**Quick** — language and typos only.",
"",
"**Full** — adds:",
"- Arc consistency with the arc file",
"- Age-appropriateness for the book's configured target audience",
"- Character consistency (`get_notes(category: \"Characters\")`)",
"- **Pacing** — are there sections that drag, rush, or repeat? Does the chapter breathe? Emotional beats and action beats should alternate",
"- **Show vs tell** — is character emotion or intent over-explained rather than shown through action, dialogue, or physical detail?",
"- **Scene purpose** — does the chapter earn its place? It should accomplish at least one of: advance the plot, reveal character, deepen the world. Flag if it does none of these",
"- **Tone** — does the voice and register stay consistent with the surrounding chapters and the book's configured genre?",
"",
"### 3. Output format",
"",
"| Category | Location | Before | Suggested | Reason |",
"|---|---|---|---|---|",
"| Language | Line X | ...original... | ...suggestion... | reason |",
"| Craft | Para 3 | — | — | Pacing: this paragraph restates what the previous one already established |",
"",
"- **Category** values for Full reviews: Language, Typo, Arc, Character, Pacing, Show/Tell, Scene, Tone, Age",
"- For craft issues (Pacing, Show/Tell, Scene, Tone) the Before/Suggested columns may be empty — use the Reason column to explain the observation",
"- Bold changed words in language suggestions",
"- End with a 23 sentence overall impression",
"",
"### 4. After review",
"If the review looks good, suggest: \"Want me to save a snapshot?\" (calls `git_snapshot`).",
"",
"## Rules",
"- Do not rewrite unless asked — suggest only",
].join('\n') + '\n';
export function render(_ctx: TemplateContext): string {
return `---
name: review
description: Bindery workspace - Review a chapter for language, arc consistency, and age-appropriateness. Use for /review, "review chapter X", "quick review", or "review my changes".
---
# Skill: /review
Review a chapter and give structured feedback.
## Prerequisites
This skill requires a Bindery workspace. If unsure, call \`identify_book\` to check.
## Trigger
User says \`/review\`, "review chapter X", "quick review", or "review my changes".
## Clarify first
- Changes, chapter, translation, or overall feedback?
- Type: **Full** (language + arc + age-appropriateness) or **Quick** (language and typos only)?
## Tools
Use these Bindery MCP tools to gather context:
- \`get_review_text(autoStage: true, contextLines: 3)\` — returns the git diff of uncommitted changes **plus** any regions wrapped in \`<!-- Bindery: Review start -->\` / \`<!-- Bindery: Review stop -->\` markers (works even on committed work). \`autoStage: true\` stages reviewed files **and** removes the marker lines from disk so the next call only shows new changes. Pass more contextLines when join points to existing prose need checking
- \`get_chapter(chapterNumber, language)\` — read the full chapter text
- \`get_notes(category, name)\` — look up character profiles (\`category: "Characters"\`) or world rules
- \`search(query, language)\` — find related passages across the book
- \`git_snapshot(message)\` — after a successful review, suggest saving a snapshot
## Steps
### 1. Load settings and context
Start by reading ".bindery/settings.json" with \
\`get_text(".bindery/settings.json")\` to pick up the current book's target audience, genre, and story structure.
Load the right context, pick any or all as needed:
- Read \`.bindery/memories/global.md\`
- Read \`.bindery/memories/chXX.md\` if it exists for chapter-specific context
- Use \`get_chapter\` to load the chapter
- For a Full review, read the relevant arc file from \`Arc/\`.
- For "review my changes", use \`get_review_text\` to get the diff
- If the diff includes translated chapter files, flag that and offer \`/translation-review\` for source-vs-translation feedback
### 2. Perform the review
**Quick** language and typos only.
**Full** adds:
- Arc consistency with the arc file
- Age-appropriateness for the book's configured target audience
- Character consistency (use \`get_notes(category: "Characters")\`)
### 3. Output format
| Location | Before | Suggested | Reason |
|---|---|---|---|
| Line X | ...original... | ...suggestion... | reason |
- Bold changed words
- Group by category for Full reviews
- End with a 2-3 sentence overall impression
### 4. After review
If the review looks good, suggest: "Want me to save a snapshot?" (calls \`git_snapshot\`).
## Rules
- Do not rewrite unless asked suggest only
`;
return CONTENT;
}

View file

@ -2,45 +2,74 @@ import type { TemplateContext, TemplateMeta } from '../context';
export const meta: TemplateMeta = {
file: '.claude/skills/status/SKILL.md',
version: 10,
version: 13,
label: 'status skill',
zip: '.claude/skills/status.zip',
};
const CONTENT = [
"---",
"name: status",
"description: Bindery workspace - Give a book progress snapshot — chapters done, in progress, and coming up. Use for /status, \"what's the book status\", or \"where are we\".",
"---",
"# Skill: /status",
"",
"Snapshot of the book's progress: what's done, in progress, and coming up.",
"",
"Primary use case: returning to a project after time away and needing to quickly understand where things stand before starting work.",
"",
"## Prerequisites",
"This skill requires a Bindery workspace. If unsure, call `identify_book` to check. If no workspace is found, tell the user and stop.",
"",
"## Trigger",
"User says `/status`, \"what's the book status\", or \"where are we\".",
"",
"## Tools",
"Use these Bindery MCP tools:",
"- `chapter_status_get(book)` — read the structured progress tracker from `.bindery/chapter-status.json`",
"- `chapter_status_update(book, chapters)` — upsert chapter progress entries (send only changed chapters)",
"- `get_overview(language)` — list all acts and chapters with titles",
"- `get_text(identifier)` — read COWORK.md, settings.json, and memory files",
"- `arc_list` / `arc_get` — inspect arc structure and relevant arc files",
"- `character_list` — inspect the cast index when character-profile coverage matters",
"- `memory_list` — discover which chapter memory files exist (`chXX.md`)",
"",
"## Steps",
"",
"1. Use `get_text(\".bindery/settings.json\")` to pick up the current book's structure and conventions.",
"2. Use `chapter_status_get` to read the current tracker. Use `memory_list` to check available memory files.",
"3. Use `get_text` to read COWORK.md (current focus), `.bindery/memories/global.md`, and for in-progress chapters `.bindery/memories/chXX.md`. Use `arc_list`/`arc_get` for arc context and `character_list` for cast coverage when needed.",
"4. Use `get_overview` for the full chapter listing if the tracker is empty or incomplete.",
"5. Check `Arc/` for what's planned vs written (`index.md`, `Overall.md`, and the relevant act/thread/chapter arc file).",
"6. Output: overall count / done / in-progress / coming up (next 2-3 chapters) / open questions.",
"7. If the tracker is out of date or missing entries, update it with `chapter_status_update` (upsert only the changed chapters).",
"",
"## Output",
"",
"Keep it scannable — bold headers, short lines. This is a working tool, not a narrative summary.",
"",
"```",
"**[Book title]** — status as of [today's date]",
"",
"**Progress:** X / Y chapters complete (Act I: X/Y · Act II: X/Y · Act III: X/Y)",
"",
"**Done:** Ch 1, 2, 3, 4 ...",
"**In progress:** Ch 10 — [chapter title] ([status note from tracker])",
"**Coming up:** Ch 11, 12, 13",
"",
"**Arc position:** [One sentence: where in the overall story the in-progress chapter sits — e.g. \"Midpoint of Act II, approaching the second turning point.\"]",
"",
"**Last session:** [Date of the most recent entry in global.md or the in-progress chapter memory file, if readable. If memory files are absent or undated, say so.]",
"",
"**Open questions:**",
"- [From global.md or chapter memory — unresolved decisions relevant to what comes next]",
"",
"**Current focus:** [From COWORK.md, if present]",
"```",
"",
"If the tracker was updated in Step 7, note which entries were added or changed.",
].join('\n') + '\n';
export function render(_ctx: TemplateContext): string {
return `---
name: status
description: Bindery workspace - Give a book progress snapshot chapters done, in progress, and coming up. Use for /status, "what's the book status", or "where are we".
---
# Skill: /status
Snapshot of the book's progress: what's done, in progress, and coming up.
## Prerequisites
This skill requires a Bindery workspace. If unsure, call \`identify_book\` to check.
## Trigger
User says \`/status\`, "what's the book status", or "where are we".
## Tools
Use these Bindery MCP tools:
- \`chapter_status_get(book)\` — read the structured progress tracker from \`.bindery/chapter-status.json\`
- \`chapter_status_update(book, chapters)\` — upsert chapter progress entries (send only changed chapters)
- \`get_overview(language)\` — list all acts and chapters with titles
- \`get_text(identifier)\` — read COWORK.md, settings.json, and memory files
- \`memory_list\` — discover which chapter memory files exist (\`chXX.md\`)
## Steps
1. Use \`get_text(".bindery/settings.json")\` to pick up the current book's structure and conventions.
2. Use \`chapter_status_get\` to read the current tracker. Use \`memory_list\` to check available memory files.
3. Use \`get_text\` to read COWORK.md (current focus), \`.bindery/memories/global.md\`, and for in-progress chapters \`.bindery/memories/chXX.md\`.
4. Use \`get_overview\` for the full chapter listing if the tracker is empty or incomplete.
5. Check \`Arc/\` for what's planned vs written (Overall.md + the relevant act file).
6. Output: overall count / done / in-progress / coming up (next 2-3 chapters) / open questions.
7. If the tracker is out of date or missing entries, update it with \`chapter_status_update\` (upsert only the changed chapters).
## Output
Keep it scannable bold headers, short lines. This is a working tool, not a narrative summary.
`;
return CONTENT;
}

View file

@ -2,60 +2,80 @@ import type { TemplateContext, TemplateMeta } from '../context';
export const meta: TemplateMeta = {
file: '.claude/skills/translate/SKILL.md',
version: 9,
version: 10,
label: 'translate skill',
zip: '.claude/skills/translate.zip',
};
const CONTENT = [
"---",
"name: translate",
"description: Bindery workspace - Translate a chapter or spot-check an existing translation using the Bindery translation table. Use for /translate, \"translate chapter X\", or \"help me with the translation\".",
"---",
"# Skill: /translate",
"",
"Translate a chapter or passage into the target language.",
"",
"## Prerequisites",
"This skill requires a Bindery workspace. If unsure, call `identify_book` to check. If no workspace is found, tell the user and stop.",
"",
"## Trigger",
"User says `/translate`, \"translate chapter X\", or \"help me with the translation\".",
"",
"## Clarify first",
"- Which chapter number and target language?",
"- Full translation or spot-check an existing translation? Default to spot-check if a chapter file already exists for the target language.",
"",
"## Tools",
"Use these Bindery MCP tools:",
"- `get_chapter(chapterNumber, language)` — read a chapter in any language (source or existing translation)",
"- `get_translation(targetLanguage)` — list glossary entries for a target language (e.g. `\"nl\"`)",
"- `get_translation(targetLanguage, word)` — look up a specific term; forgiving: case-insensitive, handles plurals and inflected forms",
"- `search(query, targetLanguage)` — verify how a term was rendered in other translated chapters",
"- `add_translation(targetLanguage, from, to)` — save a new glossary term pair when the user confirms a translation choice",
"",
"## Steps",
"",
"### 1. Load the translation table",
"Call `get_translation(targetLanguage)` to load all known glossary term mappings for the target language before translating anything.",
"",
"### 2. Load the chapter",
"Use `get_chapter(chapterNumber, sourceLanguage)` to read the source chapter.",
"For spot-check mode, also call `get_chapter(chapterNumber, targetLanguage)` to read the existing translation.",
"",
"### 3. Translate or review",
"**Full translation** — translate paragraph by paragraph, applying all terms from the glossary. Output the full result in a fenced ```markdown block for easy pasting.",
"",
"Translation guidelines:",
"- **Glossary terms**: apply exactly as stored — never invent alternatives for world-specific names and terms",
"- **Meaning over literal**: translate meaning and intent, not word-for-word; preserve the rhythm and feel of the prose",
"- **Idioms**: find a natural equivalent in the target language rather than translating literally",
"- **Dialogue**: preserve each character's voice — formal/informal register, sentence length, personality",
"- **HTML comments** (`<!-- ... -->`): these are writer notes, not prose. Preserve them exactly as-is in the output",
"- **Uncertain terms**: flag them inline with `[?]` rather than guessing; list them separately at the end for the user to confirm",
"",
"**Spot-check** — compare source and translation side-by-side. Check for:",
"- Glossary mismatches — world-specific terms rendered differently from the stored glossary",
"- Meaning drift — passages where the translation changes or loses the source intent",
"- Register shift — dialogue that changes a character's voice or formality level",
"- Literal translations that read unnaturally in the target language",
"- Untranslated or skipped passages",
"- HTML comments that were accidentally translated or removed",
"",
"Use a feedback table:",
"",
"| Location | Source | Current translation | Suggestion | Reason |",
"|---|---|---|---|---|",
"",
"### 4. Save confirmed terms",
"When the user confirms a new or corrected term translation, call `add_translation` to persist it as a glossary entry. For spelling variant rules (dialect substitutions applied at export), use `add_dialect` instead.",
"",
"## Rules",
"- Always load the translation table first — never invent translations for world-specific terms",
"- Flag uncertain terms with `[?]` rather than guessing",
"- Preserve HTML comments exactly — they are writer notes, not prose",
].join('\n') + '\n';
export function render(_ctx: TemplateContext): string {
return `---
name: translate
description: Bindery workspace - Translate a chapter or spot-check an existing translation using the Bindery translation table. Use for /translate, "translate chapter X", or "help me with the translation".
---
# Skill: /translate
Translate a chapter or passage into the target language.
## Prerequisites
This skill requires a Bindery workspace. If unsure, call \`identify_book\` to check.
## Trigger
User says \`/translate\`, "translate chapter X", or "help me with the translation".
## Clarify first
- Which chapter number and target language?
- Full translation or spot-check an existing translation? Default to spot-check if a chapter file already exists for the target language.
## Tools
Use these Bindery MCP tools:
- \`get_chapter(chapterNumber, language)\` — read a chapter in any language (source or existing translation)
- \`get_translation(targetLanguage)\` — list glossary entries for a target language (e.g. \`"nl"\`)
- \`get_translation(targetLanguage, word)\` — look up a specific term; forgiving: case-insensitive, handles plurals and inflected forms
- \`search(query, targetLanguage)\` — verify how a term was rendered in other translated chapters
- \`add_translation(targetLanguage, from, to)\` — save a new glossary term pair when the user confirms a translation choice
## Steps
### 1. Load the translation table
Call \`get_translation(targetLanguage)\` to load all known glossary term mappings for the target language before translating anything.
### 2. Load the chapter
Use \`get_chapter(chapterNumber, sourceLanguage)\` to read the source chapter.
For spot-check mode, also call \`get_chapter(chapterNumber, targetLanguage)\` to read the existing translation.
### 3. Translate or review
**Full translation** translate paragraph by paragraph, applying all terms from the glossary. Output the full result in a fenced \`\`\`markdown block for easy pasting.
**Spot-check** compare source and translation side-by-side. Use a feedback table:
| Location | Source | Current translation | Suggestion | Reason |
|---|---|---|---|---|
### 4. Save confirmed terms
When the user confirms a new or corrected term translation, call \`add_translation\` to persist it as a glossary entry. For spelling variant rules (dialect substitutions applied at export), use \`add_dialect\` instead.
## Rules
- Always load the translation table first never invent translations for world-specific terms
- Flag uncertain terms rather than guessing
`;
return CONTENT;
}

View file

@ -2,86 +2,104 @@ import type { TemplateContext, TemplateMeta } from '../context';
export const meta: TemplateMeta = {
file: '.claude/skills/translation-review/SKILL.md',
version: 2,
version: 3,
label: 'translation-review skill',
zip: '.claude/skills/translation-review.zip',
};
const CONTENT = [
"---",
"name: translation-review",
"description: Bindery workspace - Review a hand-crafted translation against the source language for fidelity, naturalness, and glossary consistency. Use for /translation-review, \"review my translation\", or \"what do you think\" when translation is the current focus.",
"---",
"# Skill: /translation-review",
"",
"Review a hand-crafted translation against the source.",
"",
"Use this when the user has written or updated the target-language text and wants structured feedback.",
"",
"## Prerequisites",
"This skill requires a Bindery workspace. If unsure, call `identify_book` to check. If no workspace is found, tell the user and stop.",
"",
"## Trigger",
"User says `/translation-review`, \"review my translation\", or \"what do you think\" when translation is the active focus.",
"",
"## Not this skill",
"- Generating translation text from scratch -> use `/translate`",
"- Reviewing source-language writing quality -> use `/review`",
"",
"## Tools",
"Use these Bindery MCP tools:",
"- `get_review_text(autoStage: true, contextLines: 3)` — git diff of uncommitted changes **plus** any regions wrapped in `<!-- Bindery: Review start -->` / `<!-- Bindery: Review stop -->` markers. Marker regions surface even after the author committed and continued elsewhere. `autoStage: true` stages files **and** consumes (removes) the marker lines so the next pass starts clean.",
"- `get_text(identifier, startLine?, endLine?)` — fetch matching source lines or focused ranges",
"- `get_translation(targetLanguage)` — load glossary terms for the target language before reviewing",
"- `get_chapter(chapterNumber, language)` — full chapter source/target pair for full spot-check mode",
"- `search(query, targetLanguage)` — verify how a term was used in previously translated chapters before flagging it",
"- `add_translation(targetLanguage, from, to)` — persist a confirmed glossary correction",
"",
"## Mode 1 - Scoped diff review (primary)",
"",
"### Steps",
"",
"1. Call `get_review_text`. The response has two sections: a `# Git diff` block and a `# Review markers` block (one or both may be empty).",
"2. If both sections are empty, report that nothing new has been translated yet.",
"3. Identify changed files and determine source/target language from available context: session file (for example COWORK.md), recent conversation, or ask the user if ambiguous.",
"4. If the target-language file changed (or has marker regions), capture the changed/marked target line range.",
"5. **Line parity matching** — attempt to fetch the corresponding source lines:",
" - First, assume line parity: call `get_text(sourceFile, startLine, endLine)` for the same range as the target.",
" - **If the content is a complete mismatch** (opening words differ significantly), the translation work may have added or removed lines. Search a window: fetch `get_text(sourceFile, startLine - 5, endLine + 5)` and scan for the target text within that range.",
" - **If still not found**, ask the user: \"I couldn't locate these source lines. Can you point me to the starting line number in the source file for this translation?\"",
"6. Load glossary entries via `get_translation(targetLanguage)`.",
"7. Use `search(query, targetLanguage)` when a term may have an established translation elsewhere in the book.",
"8. Compare source vs target and produce feedback using the table below. Also check:",
" - **HTML comments** (`<!-- ... -->`): these are writer notes and must appear unchanged in the translation file. If any are modified, moved, or removed in the diff, flag them",
"9. If source-language lines also changed, flag that and suggest `/review` for source-quality feedback.",
"",
"## Mode 2 - Full chapter spot-check",
"",
"Use this when the user asks for a full chapter comparison.",
"",
"1. Determine source language, target language, and chapter number.",
"2. Load glossary with `get_translation(targetLanguage)`.",
"3. Use `search(query, targetLanguage)` as needed to verify recurring terminology in earlier translated chapters.",
"4. Load chapters with `get_chapter(chapterNumber, sourceLanguage)` and `get_chapter(chapterNumber, targetLanguage)`.",
"5. Compare paragraph by paragraph and report findings with the same table. Check for:",
" - Glossary mismatches — world-specific terms that differ from the stored glossary",
" - Meaning drift — passages where translation changes or loses the source intent",
" - Register shift — dialogue that changes a character's voice or formality level",
" - Naturalness — phrasings that are technically correct but read awkwardly in the target language",
" - Untranslated or skipped passages",
" - HTML comments (`<!-- ... -->`) that were modified, moved, or removed",
"",
"## Output format",
"",
"| Category | Before (target) | After (target) | Reason |",
"|---|---|---|---|",
"| Glossary | Keep context short; bold only the changed words | Suggested wording | Term differs from stored glossary |",
"",
"**Category values:** Fidelity · Naturalness · Glossary · Register · Comment",
"",
"- **Fidelity** — meaning changed or lost relative to source",
"- **Naturalness** — correct meaning but reads awkwardly in the target language",
"- **Glossary** — world-specific term differs from the stored translation table",
"- **Register** — character voice or formality level shifted",
"- **Comment** — HTML writer note was modified, moved, or removed",
"",
"Also list glossary mismatches and untranslated world-specific terms explicitly below the table.",
"",
"## Cross-skill handoff",
"- If changed lines are only source-language files, suggest switching to `/review`.",
"- If both source and target changed, run translation-review findings first, then prompt whether to run `/review` for source edits too.",
"",
"## Rules",
"- Load glossary before reviewing and flag mismatches explicitly",
"- Suggest edits only; do not rewrite entire passages unless asked",
"- Bold only changed words in Before/After rows",
"- Mark uncertain calls as questions for user confirmation",
"- When the user confirms a corrected term, call `add_translation` before moving on",
"- Respond in the session language (usually source language)",
].join('\n') + '\n';
export function render(_ctx: TemplateContext): string {
return `---
name: translation-review
description: Bindery workspace - Review a hand-crafted translation against the source language for fidelity, naturalness, and glossary consistency. Use for /translation-review, "review my translation", or "what do you think" when translation is the current focus.
---
# Skill: /translation-review
Review a hand-crafted translation against the source.
Use this when the user has written or updated the target-language text and wants structured feedback.
## Prerequisites
This skill requires a Bindery workspace. If unsure, call \`identify_book\` to check.
## Trigger
User says \`/translation-review\`, "review my translation", or "what do you think" when translation is the active focus.
## Not this skill
- Generating translation text from scratch -> use \`/translate\`
- Reviewing source-language writing quality -> use \`/review\`
## Tools
Use these Bindery MCP tools:
- \`get_review_text(autoStage: true, contextLines: 3)\` — git diff of uncommitted changes **plus** any regions wrapped in \`<!-- Bindery: Review start -->\` / \`<!-- Bindery: Review stop -->\` markers. Marker regions surface even after the author committed and continued elsewhere. \`autoStage: true\` stages files **and** consumes (removes) the marker lines so the next pass starts clean.
- \`get_text(identifier, startLine?, endLine?)\` — fetch matching source lines or focused ranges
- \`get_translation(targetLanguage)\` — load glossary terms for the target language before reviewing
- \`get_chapter(chapterNumber, language)\` — full chapter source/target pair for full spot-check mode
- \`search(query, targetLanguage)\` — verify how a term was used in previously translated chapters before flagging it
- \`add_translation(targetLanguage, from, to)\` — persist a confirmed glossary correction
## Mode 1 - Scoped diff review (primary)
### Steps
1. Call \`get_review_text\`. The response has two sections: a \`# Git diff\` block and a \`# Review markers\` block (one or both may be empty).
2. If both sections are empty, report that nothing new has been translated yet.
3. Identify changed files and determine source/target language from available context: session file (for example COWORK.md), recent conversation, or ask the user if ambiguous.
4. If the target-language file changed (or has marker regions), capture the changed/marked target line range.
5. **Line parity matching** attempt to fetch the corresponding source lines:
- First, assume line parity: call \`get_text(sourceFile, startLine, endLine)\` for the same range as the target.
- **If the content is a complete mismatch** (opening words differ significantly), the translation work may have added or removed lines. Search a window: fetch \`get_text(sourceFile, startLine - 5, endLine + 5)\` and scan for the target text within that range.
- **If still not found**, ask the user: "I couldn't locate these source lines. Can you point me to the starting line number in the source file for this translation?"
6. Load glossary entries via \`get_translation(targetLanguage)\`.
7. Use \`search(query, targetLanguage)\` when a term may have an established translation elsewhere in the book.
8. Compare source vs target and produce feedback using the table below.
9. If source-language lines also changed, flag that and suggest \`/review\` for source-quality feedback.
## Mode 2 - Full chapter spot-check
Use this when the user asks for a full chapter comparison.
1. Determine source language, target language, and chapter number.
2. Load glossary with \`get_translation(targetLanguage)\`.
3. Use \`search(query, targetLanguage)\` as needed to verify recurring terminology in earlier translated chapters.
4. Load chapters with \`get_chapter(chapterNumber, sourceLanguage)\` and \`get_chapter(chapterNumber, targetLanguage)\`.
5. Compare paragraph by paragraph and report findings with the same table.
## Output format
| Before (target) | After (target) | Reason |
|---|---|---|
| Keep context short; bold only the changed words | Suggested wording | Fidelity, naturalness, glossary, or terminology consistency |
Also list glossary mismatches and untranslated world-specific terms explicitly.
## Cross-skill handoff
- If changed lines are only source-language files, suggest switching to \`/review\`.
- If both source and target changed, run translation-review findings first, then prompt whether to run \`/review\` for source edits too.
## Rules
- Load glossary before reviewing and flag mismatches explicitly
- Suggest edits only; do not rewrite entire passages unless asked
- Bold only changed words in Before/After rows
- Mark uncertain calls as questions for user confirmation
- When the user confirms a corrected term, call \`add_translation\` before moving on
- Respond in the session language (usually source language)
`;
return CONTENT;
}

View file

@ -12,10 +12,16 @@ import { afterEach, describe, expect, it } from 'vitest';
import {
getBinderyFolder,
getArcFolder,
getArcGranularity,
getBookTitleForLang,
getCharactersFolder,
getDefaultLanguage,
getDialectsForLanguage,
getNotesFolder,
getSessionFile,
getSettingsPath,
getStoryFolder,
getTranslationsPath,
readWorkspaceSettings,
type WorkspaceSettings,
@ -56,6 +62,39 @@ describe('path helpers', () => {
});
});
// ─── Authoring defaults ─────────────────────────────────────────────────────
describe('authoring defaults', () => {
it('returns opinionated defaults when settings are absent', () => {
expect(getStoryFolder(null)).toBe('Story');
expect(getNotesFolder(null)).toBe('Notes');
expect(getArcFolder(null)).toBe('Arc');
expect(getCharactersFolder(null)).toBe('Notes/Characters');
expect(getSessionFile(null)).toBe('COWORK.md');
expect(getArcGranularity(null)).toBe('act');
});
it('derives characters folder from a custom notes folder', () => {
expect(getCharactersFolder({ notesFolder: 'Reference' })).toBe('Reference/Characters');
});
it('uses explicit authoring paths when configured', () => {
const settings: WorkspaceSettings = {
storyFolder: 'Drafts',
notesFolder: 'Notes',
arcFolder: 'Structure',
charactersFolder: 'Cast',
sessionFile: 'SESSION.md',
arcGranularity: 'thread',
};
expect(getStoryFolder(settings)).toBe('Drafts');
expect(getArcFolder(settings)).toBe('Structure');
expect(getCharactersFolder(settings)).toBe('Cast');
expect(getSessionFile(settings)).toBe('SESSION.md');
expect(getArcGranularity(settings)).toBe('thread');
});
});
// ─── readWorkspaceSettings ────────────────────────────────────────────────────
describe('readWorkspaceSettings', () => {

View file

@ -14,6 +14,9 @@ function makeCtx(overrides: Partial<TemplateContext> = {}): TemplateContext {
storyFolder: 'Story',
notesFolder: 'Notes',
arcFolder: 'Arc',
charactersFolder: 'Notes/Characters',
sessionFile: 'COWORK.md',
arcGranularity: 'act',
memoriesFolder: '.bindery/memories',
languages: [{ code: 'EN', folderName: 'EN' }],
langList: 'EN (source)',
@ -40,6 +43,9 @@ function makeMinimalCtx(): TemplateContext {
storyFolder: 'Story',
notesFolder: 'Notes',
arcFolder: 'Arc',
charactersFolder: 'Notes/Characters',
sessionFile: 'COWORK.md',
arcGranularity: 'act',
memoriesFolder: '.bindery/memories',
languages: [],
langList: 'EN (source)',
@ -130,6 +136,8 @@ describe('renderTemplate — copilot', () => {
expect(result).toContain('.claude/skills/');
expect(result).toContain('/translation-review');
expect(result).toContain('/read-in');
expect(result).toContain('/plan-beats');
expect(result).toContain('/character-setup');
});
it('ends with a newline', () => {
@ -161,6 +169,8 @@ describe('renderTemplate — cursor', () => {
expect(result).toContain('.claude/skills/');
expect(result).toContain('/translation-review');
expect(result).toContain('/proof-read');
expect(result).toContain('/plan-beats');
expect(result).toContain('/character-setup');
});
it('ends with a newline', () => {
@ -198,6 +208,8 @@ describe('renderTemplate — agents', () => {
expect(result).toContain('.claude/skills/');
expect(result).toContain('/translation-review');
expect(result).toContain('/review');
expect(result).toContain('/plan-beats');
expect(result).toContain('/character-setup');
});
it('ends with a newline', () => {
@ -254,12 +266,12 @@ describe('renderTemplate — review skill', () => {
});
describe('renderTemplate — brainstorm skill', () => {
it('contains YAML front-matter, title, trigger, and steps', () => {
it('contains YAML front-matter, title, trigger, and phases', () => {
const result = renderTemplate('brainstorm', makeCtx());
expect(result).toContain('name: brainstorm');
expect(result).toContain('# Skill: /brainstorm');
expect(result).toContain('## Trigger');
expect(result).toContain('## Steps');
expect(result).toContain('## Phases');
expect(result).toContain('## Tools');
expect(result).toContain('## Rules');
});
@ -283,10 +295,66 @@ describe('renderTemplate — memory skill', () => {
it('references memory_append and memory_compact tools', () => {
const result = renderTemplate('memory', makeCtx());
expect(result).toContain('note_create');
expect(result).toContain('note_append');
expect(result).toContain('memory_append');
expect(result).toContain('memory_compact');
expect(result).toContain('memory_list');
});
it('describes the note and arc write-tool boundary accurately', () => {
const result = renderTemplate('memory', makeCtx());
expect(result).toContain('## Tool boundary');
expect(result).toContain('note_create');
expect(result).toContain('note_append');
expect(result).toContain('arc_create');
expect(result).toContain('character_update');
expect(result).toContain('no dedicated COWORK/session-focus write tools');
expect(result).not.toContain('## Future work');
});
});
describe('renderTemplate — character-setup skill', () => {
it('contains YAML front-matter, title, trigger, tools, steps, and rules', () => {
const result = renderTemplate('character-setup', makeCtx());
expect(result).toContain('name: character-setup');
expect(result).toContain('# Skill: /character-setup');
expect(result).toContain('argument-hint');
expect(result).toContain('## Trigger');
expect(result).toContain('## Tools');
expect(result).toContain('## Steps');
expect(result).toContain('## Rules');
});
it('uses the structured character tools', () => {
const result = renderTemplate('character-setup', makeCtx());
expect(result).toContain('character_list');
expect(result).toContain('character_get');
expect(result).toContain('character_create');
expect(result).toContain('character_update');
});
});
describe('renderTemplate — plan-beats skill', () => {
it('contains YAML front-matter, title, trigger, tools, steps, and rules', () => {
const result = renderTemplate('plan-beats', makeCtx());
expect(result).toContain('name: plan-beats');
expect(result).toContain('# Skill: /plan-beats');
expect(result).toContain('argument-hint');
expect(result).toContain('## Trigger');
expect(result).toContain('## Tools');
expect(result).toContain('## Steps');
expect(result).toContain('## Rules');
});
it('uses the current Bindery arc and character scaffold', () => {
const result = renderTemplate('plan-beats', makeCtx());
expect(result).toContain('Arc/index.md');
expect(result).toContain('Arc/Overall.md');
expect(result).toContain('Arc/Acts/');
expect(result).toContain('character_list');
expect(result).toContain('character_get');
});
});
describe('renderTemplate — translate skill', () => {
@ -431,12 +499,45 @@ describe('renderTemplate — bindery-readme', () => {
it('includes the workspace title and key capability sections', () => {
const result = renderTemplate('bindery-readme', makeCtx());
expect(result).toContain('Test Book');
expect(result).toContain('## Opinionated Authoring Layout');
expect(result).toContain('## VS Code / Obsidian commands');
expect(result).toContain('## MCP tools');
expect(result).toContain('## Tool Workflow Shortcuts');
expect(result).toContain('## Skill workflows');
expect(result).toContain('## Review markers');
});
it('documents the generated scaffold and setup tools', () => {
const result = renderTemplate('bindery-readme', makeCtx());
expect(result).toContain('COWORK.md');
expect(result).toContain('Arc/index.md');
expect(result).toContain('Arc/Overall.md');
expect(result).toContain('Notes/Characters/index.md');
expect(result).toContain('init_workspace');
expect(result).toContain('setup_ai_files');
expect(result).toContain('/plan-beats');
expect(result).toContain('/character-setup');
expect(result).toContain('note_list');
expect(result).toContain('note_append');
expect(result).toContain('character_list');
expect(result).toContain('arc_create');
expect(result).toContain('List/Create/Append Notes');
expect(result).toContain('List/Create/Update Character Profile');
expect(result).toContain('List/Create/Update Arc File');
expect(result).toContain('Show/Update Chapter Status');
expect(result).toContain('User-owned current focus');
expect(result).toContain('creates only a small neutral scaffold');
expect(result).not.toContain('Dedicated note/memory/status/arc/character host commands are still planned');
});
it('does not indent generated top-level README headings or table rows', () => {
const result = renderTemplate('bindery-readme', makeCtx());
expect(result).toContain('\n## Opinionated Authoring Layout\n');
expect(result).toContain('\n| Path | Purpose |\n');
expect(result).not.toContain('\n ## Opinionated');
expect(result).not.toContain('\n | Path | Purpose |');
});
it('documents review marker syntax and consumption behavior', () => {
const result = renderTemplate('bindery-readme', makeCtx());
expect(result).toContain('<!-- Bindery: Review start -->');

View file

@ -13,7 +13,18 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { zipSync, strToU8 } from 'fflate';
import { renderTemplate, FILE_VERSION_INFO, type TemplateContext } from './templates.js';
import {
renderTemplate,
FILE_VERSION_INFO,
getArcFolder,
getArcGranularity,
getCharactersFolder,
getNotesFolder,
getSessionFile,
getStoryFolder,
type TemplateContext,
type WorkspaceSettings,
} from './templates.js';
// ─── Public types ─────────────────────────────────────────────────────────────
@ -29,10 +40,12 @@ export type SkillTemplate =
| 'continuity'
| 'read-aloud'
| 'read-in'
| 'proof-read';
| 'proof-read'
| 'plan-beats'
| 'character-setup';
export const ALL_SKILLS: SkillTemplate[] = [
'review', 'brainstorm', 'memory', 'translate', 'translation-review', 'status', 'continuity', 'read-aloud', 'read-in', 'proof-read',
'review', 'brainstorm', 'memory', 'translate', 'translation-review', 'status', 'continuity', 'read-aloud', 'read-in', 'proof-read', 'plan-beats', 'character-setup',
];
export interface AiSetupOptions {
@ -66,25 +79,32 @@ export interface AiVersionFile {
// ─── Settings types ───────────────────────────────────────────────────────────
interface Settings {
bookTitle?: string | Record<string, string>;
author?: string;
description?: string;
genre?: string;
targetAudience?: string;
storyFolder?: string;
interface Settings extends Omit<WorkspaceSettings, 'languages'> {
languages?: Array<{ code: string; folderName: string }>;
}
function titleFromSetting(value: Settings['bookTitle']): string {
if (typeof value === 'string' && value.trim()) { return value.trim(); }
if (value && typeof value === 'object') {
const en = value['en'];
if (typeof en === 'string' && en.trim()) { return en.trim(); }
const first = Object.values(value).find(v => typeof v === 'string' && v.trim());
if (typeof first === 'string') { return first.trim(); }
}
return 'Untitled';
}
// ─── Context builder ──────────────────────────────────────────────────────────
function buildContext(s: Settings): TemplateContext {
const title = (typeof s.bookTitle === 'string' ? s.bookTitle : undefined) ?? 'Untitled';
const title = titleFromSetting(s.bookTitle);
const author = s.author ?? '';
const description = s.description ?? '';
const genre = s.genre ?? '';
const audience = s.targetAudience ?? '';
const storyFolder = s.storyFolder ?? 'Story';
const storyFolder = getStoryFolder(s);
const notesFolder = getNotesFolder(s);
const arcFolder = getArcFolder(s);
const languages = s.languages ?? [];
const langList = languages.length > 0
@ -93,7 +113,12 @@ function buildContext(s: Settings): TemplateContext {
return {
title, author, description, genre, audience,
storyFolder, notesFolder: 'Notes', arcFolder: 'Arc',
storyFolder,
notesFolder,
arcFolder,
charactersFolder: getCharactersFolder(s),
sessionFile: getSessionFile(s),
arcGranularity: getArcGranularity(s),
memoriesFolder: '.bindery/memories',
languages, langList,
hasMultiLang: languages.length > 1,

View file

@ -23,6 +23,18 @@ import {
toolGetBookUntil,
toolGetOverview,
toolGetNotes,
toolNoteList,
toolNoteGet,
toolNoteCreate,
toolNoteAppend,
toolCharacterList,
toolCharacterGet,
toolCharacterCreate,
toolCharacterUpdate,
toolArcList,
toolArcGet,
toolArcCreate,
toolArcUpdate,
toolSearch,
toolFormat,
toolGetReviewText,
@ -57,6 +69,36 @@ const bookSchema = z.string().describe(
'Book name as configured via --book args (e.g. "MyNovel"). Call list_books to see available names.'
);
const characterFields = {
role: z.string().optional().describe('Narrative role or function in the cast.'),
age: z.string().optional().describe('Age or age range.'),
origin: z.string().optional().describe('Origin, home, faction, or background source.'),
skills: z.string().optional().describe('Skills, powers, expertise, or capabilities.'),
strengths: z.string().optional().describe('Strengths or advantages.'),
weaknesses: z.string().optional().describe('Weaknesses, flaws, limits, or vulnerabilities.'),
personality: z.string().optional().describe('Personality notes.'),
background: z.string().optional().describe('Backstory and context.'),
narrativeArc: z.string().optional().describe('Character movement across the story.'),
appearanceNotes: z.string().optional().describe('Appearance, voice, gesture, and identifying details.'),
relationships: z.string().optional().describe('Relationships and dynamics with other characters.'),
firstAppearance: z.string().optional().describe('First chapter, scene, or note where this character appears.'),
openQuestions: z.string().optional().describe('Unresolved author questions about the character.'),
continuityNotes: z.string().optional().describe('Continuity constraints or established facts.'),
indexNotes: z.string().optional().describe('Short note for the character index row.'),
};
const arcFields = {
title: z.string().optional().describe('Arc file H1 title. Defaults to a title derived from the filename.'),
kind: z.string().optional().describe('Arc kind, e.g. overall, act, chapter, thread, or custom.'),
purpose: z.string().optional().describe('Purpose of this arc in the story structure.'),
majorBeats: z.string().optional().describe('Major beats for this arc.'),
characterMovement: z.string().optional().describe('Character movement caused by or tracked in this arc.'),
worldImplications: z.string().optional().describe('Setting, world, magic, technology, or culture implications.'),
unresolvedQuestions: z.string().optional().describe('Open plot or structure questions.'),
continuityRisks: z.string().optional().describe('Continuity risks to watch while drafting or revising.'),
linkedChapters: z.string().optional().describe('Linked chapter numbers, titles, or ranges.'),
};
function ok(text: string) { return { content: [{ type: 'text' as const, text }] }; }
function err(e: unknown) { return { content: [{ type: 'text' as const, text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true as const }; }
@ -202,6 +244,161 @@ server.registerTool('get_notes', {
try { return ok(toolGetNotes(resolveBook(book).root, { category, name })); } catch (e) { return err(e); }
});
server.registerTool('note_list', {
title: 'Note List',
description: 'List markdown note files under the configured notes folder, optionally filtered to a category folder.',
inputSchema: {
book: bookSchema,
category: z.string().optional().describe('Optional category/folder under Notes, e.g. Characters, World, Scenes, Research'),
},
annotations: { readOnlyHint: true },
}, ({ book, category }) => {
try { return ok(toolNoteList(resolveBook(book).root, { category })); } catch (e) { return err(e); }
});
server.registerTool('note_get', {
title: 'Note Get',
description: 'Read a single markdown note by path relative to the configured notes folder.',
inputSchema: {
book: bookSchema,
path: z.string().describe('Note path relative to the notes folder, e.g. Inbox.md or Characters/index.md'),
},
annotations: { readOnlyHint: true },
}, ({ book, path }) => {
try { return ok(toolNoteGet(resolveBook(book).root, { path })); } catch (e) { return err(e); }
});
server.registerTool('note_create', {
title: 'Note Create',
description: 'Create a markdown note under the configured notes folder. Refuses to overwrite unless overwrite is true.',
inputSchema: {
book: bookSchema,
path: z.string().describe('Note path relative to the notes folder, e.g. World/Rules.md'),
title: z.string().optional().describe('Optional H1 title. Defaults to a title derived from the filename.'),
content: z.string().optional().describe('Optional markdown body to write below the H1 title.'),
overwrite: z.boolean().optional().describe('Replace an existing note if true. Default false.'),
},
annotations: { destructiveHint: true },
}, ({ book, path, title, content, overwrite }) => {
try { return ok(toolNoteCreate(resolveBook(book).root, { path, title, content, overwrite })); } catch (e) { return err(e); }
});
server.registerTool('note_append', {
title: 'Note Append',
description: 'Append markdown content to a note under the configured notes folder, creating the file if needed.',
inputSchema: {
book: bookSchema,
path: z.string().describe('Note path relative to the notes folder, e.g. Inbox.md or World/Rules.md'),
content: z.string().describe('Markdown content to append.'),
heading: z.string().optional().describe('Optional H2 heading to insert before the appended content.'),
},
annotations: { destructiveHint: true },
}, ({ book, path, content, heading }) => {
try { return ok(toolNoteAppend(resolveBook(book).root, { path, content, heading })); } catch (e) { return err(e); }
});
server.registerTool('character_list', {
title: 'Character List',
description: 'List structured character profile files under the configured characters folder.',
inputSchema: {
book: bookSchema,
name: z.string().optional().describe('Optional character-name filter.'),
},
annotations: { readOnlyHint: true },
}, ({ book, name }) => {
try { return ok(toolCharacterList(resolveBook(book).root, { name })); } catch (e) { return err(e); }
});
server.registerTool('character_get', {
title: 'Character Get',
description: 'Read a structured character profile by character name.',
inputSchema: {
book: bookSchema,
name: z.string().describe('Character name. The tool resolves the matching slugged profile file.'),
},
annotations: { readOnlyHint: true },
}, ({ book, name }) => {
try { return ok(toolCharacterGet(resolveBook(book).root, { name })); } catch (e) { return err(e); }
});
server.registerTool('character_create', {
title: 'Character Create',
description: 'Create a structured character profile and update Notes/Characters/index.md.',
inputSchema: {
book: bookSchema,
name: z.string().describe('Character name. Used for the profile H1 and slugged filename.'),
...characterFields,
overwrite: z.boolean().optional().describe('Replace an existing profile if true. Default false.'),
},
annotations: { destructiveHint: true },
}, ({ book, ...args }) => {
try { return ok(toolCharacterCreate(resolveBook(book).root, args)); } catch (e) { return err(e); }
});
server.registerTool('character_update', {
title: 'Character Update',
description: 'Update known fields in a structured character profile and refresh the character index row.',
inputSchema: {
book: bookSchema,
name: z.string().describe('Character name. The tool resolves the matching slugged profile file.'),
...characterFields,
},
annotations: { destructiveHint: true },
}, ({ book, ...args }) => {
try { return ok(toolCharacterUpdate(resolveBook(book).root, args)); } catch (e) { return err(e); }
});
server.registerTool('arc_list', {
title: 'Arc List',
description: 'List structured arc files under the configured arc folder.',
inputSchema: {
book: bookSchema,
kind: z.string().optional().describe('Optional kind filter, e.g. overall, act, chapter, thread, custom.'),
},
annotations: { readOnlyHint: true },
}, ({ book, kind }) => {
try { return ok(toolArcList(resolveBook(book).root, { kind })); } catch (e) { return err(e); }
});
server.registerTool('arc_get', {
title: 'Arc Get',
description: 'Read a structured arc file by path relative to the configured arc folder.',
inputSchema: {
book: bookSchema,
path: z.string().describe('Arc path relative to the arc folder, e.g. Overall.md or Acts/act-i.md.'),
},
annotations: { readOnlyHint: true },
}, ({ book, path }) => {
try { return ok(toolArcGet(resolveBook(book).root, { path })); } catch (e) { return err(e); }
});
server.registerTool('arc_create', {
title: 'Arc Create',
description: 'Create a structured arc file under the configured arc folder and update Arc/index.md.',
inputSchema: {
book: bookSchema,
path: z.string().describe('Arc path relative to the arc folder, e.g. Acts/act-i.md.'),
...arcFields,
overwrite: z.boolean().optional().describe('Replace an existing arc file if true. Default false.'),
},
annotations: { destructiveHint: true },
}, ({ book, ...args }) => {
try { return ok(toolArcCreate(resolveBook(book).root, args)); } catch (e) { return err(e); }
});
server.registerTool('arc_update', {
title: 'Arc Update',
description: 'Update known fields in a structured arc file and refresh Arc/index.md.',
inputSchema: {
book: bookSchema,
path: z.string().describe('Arc path relative to the arc folder, e.g. Acts/act-i.md.'),
...arcFields,
},
annotations: { destructiveHint: true },
}, ({ book, ...args }) => {
try { return ok(toolArcUpdate(resolveBook(book).root, args)); } catch (e) { return err(e); }
});
server.registerTool('search', {
title: 'Search',
description: 'Search the book corpus using lexical BM25, semantic reranking, or full semantic search. If Ollama or a semantic index is unavailable, semantic modes fall back to lexical results with a warning.',
@ -378,7 +575,8 @@ server.registerTool('add_language', {
server.registerTool('init_workspace', {
title: 'Init Workspace',
description:
'Create or update .bindery/settings.json and .bindery/translations.json. ' +
'Create or update .bindery/settings.json, .bindery/translations.json, .bindery/README.md, ' +
'and the opinionated Arc, Notes, Characters, COWORK, memory, and chapter-status scaffold. ' +
'All arguments are optional — smart defaults are used for any omitted values. ' +
'Safe to run on an existing workspace: existing settings are preserved unless explicitly overridden. ' +
'Detects language folders in the story directory automatically.',
@ -414,12 +612,12 @@ server.registerTool('setup_ai_files', {
title: 'Setup AI Files',
description:
'Generate AI assistant instruction files (CLAUDE.md, .github/copilot-instructions.md, ' +
'.cursor/rules, AGENTS.md) and Claude skill templates from .bindery/settings.json. ' +
'.cursor/rules, AGENTS.md), Claude skill templates, skill zips, and the generated .bindery/README.md capability reference from .bindery/settings.json. ' +
'Run init_workspace first. Safe to run multiple times — skips existing files unless overwrite is true.',
inputSchema: {
book: bookSchema,
targets: z.array(z.string()).optional().describe('Which files to generate: claude, copilot, cursor, agents. Default: all.'),
skills: z.array(z.string()).optional().describe('Which Claude skills to generate: review, brainstorm, memory, translate, translation-review, status, continuity, read-aloud, read-in, proof-read. Default: all.'),
skills: z.array(z.string()).optional().describe('Which Claude skills to generate: review, brainstorm, memory, translate, translation-review, status, continuity, read-aloud, read-in, proof-read, plan-beats. Default: all.'),
overwrite: z.boolean().optional().describe('Overwrite existing files? Default false (skip existing).'),
},
annotations: { destructiveHint: true },

View file

@ -36,6 +36,15 @@ import {
import { probeTool, type ProbeResult } from './tool-probe.js';
import { BUILTIN_EN_GB_RULES, type TranslationRule } from './tools-dialect-defaults.js';
import { parseUnifiedDiff, formatReviewFiles } from './tools-diff.js';
import {
getArcFolder,
getArcGranularity,
getCharactersFolder,
getNotesFolder,
getSessionFile,
getStoryFolder,
type WorkspaceSettings,
} from '@bindery/core';
// Re-export so the VS Code extension can call this helper through the same
// `mcp-ts/out/tools` module it already loads for setup/health.
@ -55,8 +64,13 @@ function readJson<T>(filePath: string): T | null {
catch { return null; }
}
interface Settings {
interface Settings extends WorkspaceSettings {
storyFolder?: string;
notesFolder?: string;
arcFolder?: string;
charactersFolder?: string;
sessionFile?: string;
arcGranularity?: 'overall' | 'act' | 'chapter' | 'thread' | 'custom';
author?: string;
bookTitle?: string | Record<string, string>;
languages?: Array<{ code: string; folderName: string; chapterWord: string; actPrefix: string; prologueLabel: string; epilogueLabel: string }>;
@ -76,7 +90,7 @@ function readSettings(root: string): Settings | null {
}
function storyFolder(root: string): string {
return readSettings(root)?.storyFolder ?? 'Story';
return getStoryFolder(readSettings(root));
}
const ALL_AI_TARGETS: AiTarget[] = ['claude', 'copilot', 'cursor', 'agents'];
@ -851,6 +865,83 @@ export interface GetNotesArgs {
name?: string;
}
export interface NoteListArgs {
category?: string;
}
export interface NoteGetArgs {
path: string;
}
export interface NoteCreateArgs {
path: string;
title?: string;
content?: string;
overwrite?: boolean;
}
export interface NoteAppendArgs {
path: string;
content: string;
heading?: string;
}
export interface CharacterListArgs {
name?: string;
}
export interface CharacterGetArgs {
name: string;
}
export interface CharacterCreateArgs {
name: string;
role?: string;
age?: string;
origin?: string;
skills?: string;
strengths?: string;
weaknesses?: string;
personality?: string;
background?: string;
narrativeArc?: string;
appearanceNotes?: string;
relationships?: string;
firstAppearance?: string;
openQuestions?: string;
continuityNotes?: string;
indexNotes?: string;
overwrite?: boolean;
}
export interface CharacterUpdateArgs extends CharacterCreateArgs {
overwrite?: boolean;
}
export interface ArcListArgs {
kind?: string;
}
export interface ArcGetArgs {
path: string;
}
export interface ArcCreateArgs {
path: string;
title?: string;
kind?: string;
purpose?: string;
majorBeats?: string;
characterMovement?: string;
worldImplications?: string;
unresolvedQuestions?: string;
continuityRisks?: string;
linkedChapters?: string;
overwrite?: boolean;
}
export interface ArcUpdateArgs extends ArcCreateArgs {}
function extractNamedSections(content: string, nameFilter: string): string[] {
const lowerFilter = nameFilter.toLowerCase();
return content.split(/^#{1,3}\s+/m)
@ -859,7 +950,7 @@ function extractNamedSections(content: string, nameFilter: string): string[] {
}
export function toolGetNotes(root: string, args: GetNotesArgs): string {
const notesDir = path.join(root, 'Notes');
const notesDir = notesRoot(root);
const candidates: string[] = [];
if (fs.existsSync(notesDir)) {
@ -886,6 +977,108 @@ export function toolGetNotes(root: string, args: GetNotesArgs): string {
return results.join('\n\n---\n\n') || 'No matching notes found.';
}
export function toolNoteList(root: string, args: NoteListArgs): string {
const baseDir = notesRoot(root);
const listDir = args.category ? safeNoteDir(root, args.category) : baseDir;
if (!listDir) { return `Invalid note category: ${args.category}`; }
if (!fs.existsSync(listDir)) { return args.category ? `Note category not found: ${args.category}` : 'No notes folder found.'; }
const files: string[] = [];
collectAllMd(listDir, files);
if (files.length === 0) { return args.category ? `No notes found in category: ${args.category}` : 'No notes found.'; }
return files
.sort((a, b) => path.relative(baseDir, a).localeCompare(path.relative(baseDir, b), undefined, { numeric: true }))
.map(filePath => {
const rel = normalizeSlashes(path.relative(baseDir, filePath));
const title = firstH1(filePath);
const lineCount = fs.readFileSync(filePath, 'utf-8').split(/\r?\n/).length;
return `- ${rel}${title ? `${title}` : ''} (${lineCount} lines)`;
})
.join('\n');
}
export function toolNoteGet(root: string, args: NoteGetArgs): string {
const filePath = safeNoteFile(root, args.path);
if (!filePath) { return `Invalid note path: ${args.path}`; }
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { return `Note not found: ${normalizeNotePath(args.path)}`; }
return fs.readFileSync(filePath, 'utf-8');
}
export function toolNoteCreate(root: string, args: NoteCreateArgs): string {
const filePath = safeNoteFile(root, args.path);
if (!filePath) { return `Invalid note path: ${args.path}`; }
const rel = normalizeNotePath(args.path);
if (fs.existsSync(filePath) && !args.overwrite) {
return `Note already exists: ${rel}. Pass overwrite: true to replace it.`;
}
const title = (args.title ?? titleFromNotePath(rel)).trim();
const body = (args.content ?? '').trim();
const content = `# ${title}\n${body ? `\n${body}\n` : '\n'}`;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, 'utf-8');
return `${fs.existsSync(filePath) && args.overwrite ? 'Wrote' : 'Created'} note: ${rel}`;
}
export function toolNoteAppend(root: string, args: NoteAppendArgs): string {
const filePath = safeNoteFile(root, args.path);
if (!filePath) { return `Invalid note path: ${args.path}`; }
const rel = normalizeNotePath(args.path);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const existed = fs.existsSync(filePath);
if (!existed) {
fs.writeFileSync(filePath, `# ${titleFromNotePath(rel)}\n`, 'utf-8');
}
const heading = args.heading?.trim();
const addition = `${heading ? `\n## ${heading}\n` : '\n'}${args.content.trim()}\n`;
fs.appendFileSync(filePath, addition, 'utf-8');
const lineCount = fs.readFileSync(filePath, 'utf-8').split(/\r?\n/).length;
return `${existed ? 'Appended to' : 'Created and appended to'} note: ${rel} (${lineCount} lines).`;
}
function notesRoot(root: string): string {
return path.join(root, getNotesFolder(readSettings(root) ?? null));
}
function safeNoteDir(root: string, noteDir: string): string | null {
const baseDir = notesRoot(root);
const resolved = path.resolve(baseDir, noteDir);
const rel = path.relative(baseDir, resolved);
if (rel.startsWith('..') || path.isAbsolute(rel)) { return null; }
return resolved;
}
function safeNoteFile(root: string, notePath: string): string | null {
const baseDir = notesRoot(root);
const normalized = normalizeNotePath(notePath);
const resolved = path.resolve(baseDir, normalized);
const rel = path.relative(baseDir, resolved);
if (rel.startsWith('..') || path.isAbsolute(rel)) { return null; }
return resolved;
}
function normalizeNotePath(notePath: string): string {
const normalized = notePath.replace(/\\/g, '/').replace(/^\/+/, '');
return normalized.toLowerCase().endsWith('.md') ? normalized : `${normalized}.md`;
}
function normalizeSlashes(value: string): string {
return value.replace(/\\/g, '/');
}
function titleFromNotePath(notePath: string): string {
const base = path.basename(notePath, '.md');
return base
.replace(/[-_]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/\b\w/g, c => c.toUpperCase()) || 'Untitled Note';
}
function collectAllMd(dir: string, out: string[]): void {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
@ -894,6 +1087,399 @@ function collectAllMd(dir: string, out: string[]): void {
}
}
// ─── character_list / character_get / character_create / character_update ───
type CharacterProfile = {
name: string;
role?: string;
age?: string;
origin?: string;
skills?: string;
strengths?: string;
weaknesses?: string;
personality?: string;
background?: string;
narrativeArc?: string;
appearanceNotes?: string;
relationships?: string;
firstAppearance?: string;
openQuestions?: string;
continuityNotes?: string;
indexNotes?: string;
};
const CHARACTER_SECTION_FIELDS: Array<[keyof CharacterProfile, string]> = [
['personality', 'Personality'],
['background', 'Background'],
['narrativeArc', 'Narrative Arc'],
['appearanceNotes', 'Appearance Notes'],
['relationships', 'Relationships'],
['openQuestions', 'Open Questions'],
['continuityNotes', 'Continuity Notes'],
];
export function toolCharacterList(root: string, args: CharacterListArgs = {}): string {
const baseDir = charactersRoot(root);
if (!fs.existsSync(baseDir)) { return 'No character folder found.'; }
const files: string[] = [];
collectAllMd(baseDir, files);
const filter = args.name?.trim().toLowerCase();
const rows = files
.filter(filePath => path.basename(filePath).toLowerCase() !== 'index.md')
.map(filePath => ({ filePath, profile: parseCharacterProfile(fs.readFileSync(filePath, 'utf-8'), titleFromNotePath(filePath)) }))
.filter(({ filePath, profile }) => !filter || profile.name.toLowerCase().includes(filter) || path.basename(filePath).toLowerCase().includes(filter))
.sort((a, b) => a.profile.name.localeCompare(b.profile.name))
.map(({ filePath, profile }) => {
const rel = normalizeSlashes(path.relative(baseDir, filePath));
const details = [profile.role, profile.firstAppearance].filter(Boolean).join(' — ');
return `- ${profile.name} (${rel})${details ? `${details}` : ''}`;
});
return rows.join('\n') || (filter ? `No characters matched: ${args.name}` : 'No character profiles found.');
}
export function toolCharacterGet(root: string, args: CharacterGetArgs): string {
const filePath = findCharacterFile(root, args.name);
if (!filePath) { return `Character not found: ${args.name}`; }
return fs.readFileSync(filePath, 'utf-8');
}
export function toolCharacterCreate(root: string, args: CharacterCreateArgs): string {
const name = args.name.trim();
if (!name) { return 'Character name is required.'; }
const baseDir = charactersRoot(root);
const filePath = characterFilePath(root, name);
const rel = normalizeSlashes(path.relative(baseDir, filePath));
if (fs.existsSync(filePath) && !args.overwrite) {
return `Character already exists: ${name} (${rel}). Pass overwrite: true to replace it.`;
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const profile = characterProfileFromArgs(args);
fs.writeFileSync(filePath, renderCharacterProfile(profile), 'utf-8');
updateCharacterIndex(root, profile, rel);
return `${fs.existsSync(filePath) && args.overwrite ? 'Wrote' : 'Created'} character: ${name} (${rel})`;
}
export function toolCharacterUpdate(root: string, args: CharacterUpdateArgs): string {
const filePath = findCharacterFile(root, args.name);
if (!filePath) { return `Character not found: ${args.name}. Use character_create first.`; }
const existing = parseCharacterProfile(fs.readFileSync(filePath, 'utf-8'), args.name);
const updated = mergeDefined(existing, characterProfileFromArgs(args));
fs.writeFileSync(filePath, renderCharacterProfile(updated), 'utf-8');
updateCharacterIndex(root, updated, normalizeSlashes(path.relative(charactersRoot(root), filePath)));
return `Updated character: ${updated.name} (${normalizeSlashes(path.relative(charactersRoot(root), filePath))})`;
}
function charactersRoot(root: string): string {
return path.join(root, getCharactersFolder(readSettings(root) ?? null));
}
function characterSlug(name: string): string {
return name.trim()
.toLowerCase()
.replace(/['"`]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'character';
}
function characterFilePath(root: string, name: string): string {
return path.join(charactersRoot(root), `${characterSlug(name)}.md`);
}
function findCharacterFile(root: string, name: string): string | null {
const baseDir = charactersRoot(root);
if (!fs.existsSync(baseDir)) { return null; }
const slug = characterSlug(name);
const direct = path.join(baseDir, `${slug}.md`);
if (fs.existsSync(direct)) { return direct; }
const files: string[] = [];
collectAllMd(baseDir, files);
const lowerName = name.trim().toLowerCase();
return files.find(filePath => {
if (path.basename(filePath).toLowerCase() === 'index.md') { return false; }
const profile = parseCharacterProfile(fs.readFileSync(filePath, 'utf-8'), path.basename(filePath, '.md'));
return profile.name.toLowerCase() === lowerName || path.basename(filePath, '.md').toLowerCase() === slug;
}) ?? null;
}
function characterProfileFromArgs(args: CharacterCreateArgs): CharacterProfile {
return {
name: args.name.trim(),
role: trimOrUndefined(args.role),
age: trimOrUndefined(args.age),
origin: trimOrUndefined(args.origin),
skills: trimOrUndefined(args.skills),
strengths: trimOrUndefined(args.strengths),
weaknesses: trimOrUndefined(args.weaknesses),
personality: trimOrUndefined(args.personality),
background: trimOrUndefined(args.background),
narrativeArc: trimOrUndefined(args.narrativeArc),
appearanceNotes: trimOrUndefined(args.appearanceNotes),
relationships: trimOrUndefined(args.relationships),
firstAppearance: trimOrUndefined(args.firstAppearance),
openQuestions: trimOrUndefined(args.openQuestions),
continuityNotes: trimOrUndefined(args.continuityNotes),
indexNotes: trimOrUndefined(args.indexNotes),
};
}
function renderCharacterProfile(profile: CharacterProfile): string {
const tableRows = [
['Role', profile.role],
['Age', profile.age],
['Origin', profile.origin],
['Skills', profile.skills],
['Strengths', profile.strengths],
['Weaknesses', profile.weaknesses],
['First appearance', profile.firstAppearance],
].map(([label, value]) => `| ${label} | ${escapeTableCell(value ?? '')} |`);
const sections = CHARACTER_SECTION_FIELDS.map(([field, heading]) => `## ${heading}\n\n${profile[field] ?? ''}`);
return [`# ${profile.name}`, '', '| Field | Value |', '|---|---|', ...tableRows, '', ...sections, ''].join('\n');
}
function parseCharacterProfile(content: string, fallbackName: string): CharacterProfile {
const h1 = /^#\s+(.+)$/m.exec(content)?.[1]?.trim();
const table = parseMarkdownTable(content);
const sections = parseMarkdownSections(content);
return {
name: h1 || fallbackName,
role: table.get('role'),
age: table.get('age'),
origin: table.get('origin'),
skills: table.get('skills'),
strengths: table.get('strengths'),
weaknesses: table.get('weaknesses'),
firstAppearance: table.get('first appearance'),
personality: sections.get('personality'),
background: sections.get('background'),
narrativeArc: sections.get('narrative arc'),
appearanceNotes: sections.get('appearance notes'),
relationships: sections.get('relationships'),
openQuestions: sections.get('open questions'),
continuityNotes: sections.get('continuity notes'),
};
}
function updateCharacterIndex(root: string, profile: CharacterProfile, relPath: string): void {
const baseDir = charactersRoot(root);
const indexPath = path.join(baseDir, 'index.md');
fs.mkdirSync(baseDir, { recursive: true });
if (!fs.existsSync(indexPath)) { fs.writeFileSync(indexPath, characterIndexTemplate(), 'utf-8'); }
const row = `| [${profile.name}](${relPath}) | ${escapeTableCell(profile.role ?? '')} | ${escapeTableCell(profile.firstAppearance ?? '')} | ${escapeTableCell(profile.indexNotes ?? '')} |`;
const slug = characterSlug(profile.name);
const lines = fs.readFileSync(indexPath, 'utf-8').split(/\r?\n/)
.filter(line => !line.toLowerCase().includes(`](${slug}.md)`) && !line.toLowerCase().startsWith(`| ${profile.name.toLowerCase()} |`));
const separatorIndex = lines.findIndex(line => /^\|\s*-+\s*\|/.test(line));
if (separatorIndex >= 0) {
lines.splice(separatorIndex + 1, 0, row);
} else {
lines.push('', '| Character | Role | First appearance | Notes |', '|---|---|---|---|', row);
}
fs.writeFileSync(indexPath, lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n', 'utf-8');
}
function escapeTableCell(value: string): string {
return value.replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
}
// ─── arc_list / arc_get / arc_create / arc_update ───────────────────────────
type ArcProfile = {
title: string;
kind?: string;
purpose?: string;
majorBeats?: string;
characterMovement?: string;
worldImplications?: string;
unresolvedQuestions?: string;
continuityRisks?: string;
linkedChapters?: string;
};
const ARC_SECTION_FIELDS: Array<[keyof ArcProfile, string]> = [
['purpose', 'Purpose'],
['majorBeats', 'Major Beats'],
['characterMovement', 'Character Movement'],
['worldImplications', 'World / Setting Implications'],
['unresolvedQuestions', 'Unresolved Questions'],
['continuityRisks', 'Continuity Risks'],
['linkedChapters', 'Linked Chapters'],
];
export function toolArcList(root: string, args: ArcListArgs = {}): string {
const baseDir = arcRoot(root);
if (!fs.existsSync(baseDir)) { return 'No arc folder found.'; }
const files: string[] = [];
collectAllMd(baseDir, files);
const kindFilter = args.kind?.trim().toLowerCase();
const rows = files
.map(filePath => ({ filePath, profile: parseArcProfile(fs.readFileSync(filePath, 'utf-8'), titleFromNotePath(filePath)) }))
.filter(({ filePath, profile }) => !kindFilter || (profile.kind?.toLowerCase() === kindFilter) || inferredArcKind(baseDir, filePath) === kindFilter)
.sort((a, b) => path.relative(baseDir, a.filePath).localeCompare(path.relative(baseDir, b.filePath), undefined, { numeric: true }))
.map(({ filePath, profile }) => {
const rel = normalizeSlashes(path.relative(baseDir, filePath));
const kind = profile.kind ?? inferredArcKind(baseDir, filePath);
return `- ${rel}${profile.title}${kind ? ` (${kind})` : ''}`;
});
return rows.join('\n') || (kindFilter ? `No arc files matched kind: ${args.kind}` : 'No arc files found.');
}
export function toolArcGet(root: string, args: ArcGetArgs): string {
const filePath = safeArcFile(root, args.path);
if (!filePath) { return `Invalid arc path: ${args.path}`; }
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { return `Arc file not found: ${normalizeMarkdownPath(args.path)}`; }
return fs.readFileSync(filePath, 'utf-8');
}
export function toolArcCreate(root: string, args: ArcCreateArgs): string {
const filePath = safeArcFile(root, args.path);
if (!filePath) { return `Invalid arc path: ${args.path}`; }
const rel = normalizeMarkdownPath(args.path);
if (fs.existsSync(filePath) && !args.overwrite) {
return `Arc file already exists: ${rel}. Pass overwrite: true to replace it.`;
}
const profile = arcProfileFromArgs(args, titleFromNotePath(rel));
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, renderArcProfile(profile), 'utf-8');
updateArcIndex(root, profile, rel);
return `${fs.existsSync(filePath) && args.overwrite ? 'Wrote' : 'Created'} arc file: ${rel}`;
}
export function toolArcUpdate(root: string, args: ArcUpdateArgs): string {
const filePath = safeArcFile(root, args.path);
if (!filePath) { return `Invalid arc path: ${args.path}`; }
if (!fs.existsSync(filePath)) { return `Arc file not found: ${normalizeMarkdownPath(args.path)}. Use arc_create first.`; }
const existing = parseArcProfile(fs.readFileSync(filePath, 'utf-8'), titleFromNotePath(args.path));
const updated = mergeDefined(existing, arcProfileFromArgs(args, existing.title));
fs.writeFileSync(filePath, renderArcProfile(updated), 'utf-8');
updateArcIndex(root, updated, normalizeSlashes(path.relative(arcRoot(root), filePath)));
return `Updated arc file: ${normalizeSlashes(path.relative(arcRoot(root), filePath))}`;
}
function arcRoot(root: string): string {
return path.join(root, getArcFolder(readSettings(root) ?? null));
}
function safeArcFile(root: string, arcPath: string): string | null {
const baseDir = arcRoot(root);
const normalized = normalizeMarkdownPath(arcPath);
const resolved = path.resolve(baseDir, normalized);
const rel = path.relative(baseDir, resolved);
if (rel.startsWith('..') || path.isAbsolute(rel)) { return null; }
return resolved;
}
function normalizeMarkdownPath(markdownPath: string): string {
const normalized = markdownPath.replace(/\\/g, '/').replace(/^\/+/, '');
return normalized.toLowerCase().endsWith('.md') ? normalized : `${normalized}.md`;
}
function arcProfileFromArgs(args: ArcCreateArgs, fallbackTitle: string): ArcProfile {
return {
title: trimOrUndefined(args.title) ?? fallbackTitle,
kind: trimOrUndefined(args.kind),
purpose: trimOrUndefined(args.purpose),
majorBeats: trimOrUndefined(args.majorBeats),
characterMovement: trimOrUndefined(args.characterMovement),
worldImplications: trimOrUndefined(args.worldImplications),
unresolvedQuestions: trimOrUndefined(args.unresolvedQuestions),
continuityRisks: trimOrUndefined(args.continuityRisks),
linkedChapters: trimOrUndefined(args.linkedChapters),
};
}
function renderArcProfile(profile: ArcProfile): string {
const meta = profile.kind ? [`Kind: ${profile.kind}`, ''] : [];
const sections = ARC_SECTION_FIELDS.map(([field, heading]) => `## ${heading}\n\n${profile[field] ?? ''}`);
return [`# ${profile.title}`, '', ...meta, ...sections, ''].join('\n');
}
function parseArcProfile(content: string, fallbackTitle: string): ArcProfile {
const title = /^#\s+(.+)$/m.exec(content)?.[1]?.trim() || fallbackTitle;
const kind = /^Kind:\s+(.+)$/mi.exec(content)?.[1]?.trim();
const sections = parseMarkdownSections(content);
return {
title,
kind,
purpose: sections.get('purpose'),
majorBeats: sections.get('major beats'),
characterMovement: sections.get('character movement'),
worldImplications: sections.get('world / setting implications'),
unresolvedQuestions: sections.get('unresolved questions'),
continuityRisks: sections.get('continuity risks'),
linkedChapters: sections.get('linked chapters'),
};
}
function inferredArcKind(baseDir: string, filePath: string): string | undefined {
const rel = normalizeSlashes(path.relative(baseDir, filePath)).toLowerCase();
if (rel === 'overall.md') { return 'overall'; }
if (rel.startsWith('acts/')) { return 'act'; }
if (rel.startsWith('chapters/')) { return 'chapter'; }
if (rel.startsWith('threads/')) { return 'thread'; }
return undefined;
}
function updateArcIndex(root: string, profile: ArcProfile, relPath: string): void {
const baseDir = arcRoot(root);
const indexPath = path.join(baseDir, 'index.md');
fs.mkdirSync(baseDir, { recursive: true });
if (!fs.existsSync(indexPath)) { fs.writeFileSync(indexPath, arcIndexTemplate(getArcFolder(readSettings(root) ?? null)), 'utf-8'); }
if (normalizeSlashes(relPath).toLowerCase() === 'index.md') { return; }
const row = `- [${profile.title}](${relPath})${profile.kind ? `${profile.kind}` : ''}`;
const lines = fs.readFileSync(indexPath, 'utf-8').split(/\r?\n/)
.filter(line => !line.includes(`](${relPath})`));
lines.push(row);
fs.writeFileSync(indexPath, lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n', 'utf-8');
}
function parseMarkdownTable(content: string): Map<string, string> {
const values = new Map<string, string>();
for (const line of content.split(/\r?\n/)) {
const match = /^\|\s*([^|]+?)\s*\|\s*(.*?)\s*\|$/.exec(line);
if (!match) { continue; }
const key = match[1].trim().toLowerCase();
const value = match[2].replace(/<br>/g, '\n').replace(/\\\|/g, '|').trim();
if (key && key !== 'field' && !/^-+$/.test(key)) { values.set(key, value || undefined as never); }
}
return values;
}
function parseMarkdownSections(content: string): Map<string, string> {
const sections = new Map<string, string>();
const matches = [...content.matchAll(/^##\s+(.+)$/gm)];
for (let index = 0; index < matches.length; index++) {
const match = matches[index];
const next = matches[index + 1];
if (match.index === undefined) { continue; }
const start = match.index + match[0].length;
const end = next?.index ?? content.length;
const body = content.slice(start, end).trim();
sections.set(match[1].trim().toLowerCase(), body || undefined as never);
}
return sections;
}
function mergeDefined<T extends Record<string, unknown>>(base: T, patch: T): T {
const merged: Record<string, unknown> = { ...base };
for (const [key, value] of Object.entries(patch)) {
if (value !== undefined && value !== '') { merged[key] = value; }
}
return merged as T;
}
// ─── search ───────────────────────────────────────────────────────────────────
export interface SearchArgs {
@ -1590,9 +2176,12 @@ function detectWorkspaceLangs(
}
}
}
if (detected.length === 0 && existingLangs.length > 0) {
return existingLangs;
}
const base = detected.length > 0
? detected
: [{ code: 'EN', folderName: 'EN', chapterWord: 'Chapter', actPrefix: 'Act', prologueLabel: 'Prologue', epilogueLabel: 'Epilogue' }];
: [{ code: 'EN', folderName: 'EN', chapterWord: 'Chapter', actPrefix: 'Act', prologueLabel: 'Prologue', epilogueLabel: 'Epilogue', isDefault: true }];
return base.map(dl => {
const el = existingLangs.find(l => (l['code'] as string | undefined)?.toUpperCase() === dl.code);
return el ? { ...el, code: dl.code, folderName: dl.folderName } : (dl);
@ -1617,6 +2206,184 @@ function seedTranslations(translationsPath: string, languages: Array<Record<stri
return true;
}
function writeScaffoldFile(root: string, relPath: string, content: string): boolean {
const filePath = path.join(root, ...relPath.split('/'));
if (fs.existsSync(filePath)) { return false; }
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, 'utf-8');
return true;
}
function ensureScaffoldDir(root: string, relPath: string): boolean {
const dirPath = path.join(root, ...relPath.split('/'));
const existed = fs.existsSync(dirPath);
fs.mkdirSync(dirPath, { recursive: true });
return !existed;
}
function titleAsString(value: unknown, fallback: string): string {
if (typeof value === 'string' && value.trim()) { return value.trim(); }
if (value && typeof value === 'object') {
const titles = value as Record<string, unknown>;
const en = titles['en'];
if (typeof en === 'string' && en.trim()) { return en.trim(); }
const first = Object.values(titles).find(v => typeof v === 'string' && v.trim());
if (typeof first === 'string') { return first.trim(); }
}
return fallback;
}
function cowriteSessionFile(settings: Record<string, unknown>, _languages: Array<Record<string, unknown>>): string {
const title = titleAsString(settings['bookTitle'], 'Untitled');
return `# Session
Book: ${title}
This file is intentionally user-owned. Bindery creates it so authors and agents have a shared place for current focus and handoff notes, but personal workflow rules and collaboration preferences belong here only if the author chooses to add them.
## Current Focus
## Next Actions
## Open Questions
## Handoff Notes
## Personal Working Notes
`;
}
function arcIndexTemplate(arcFolderName: string): string {
return `# Arc Index
Use this folder for story architecture: premise, structure, act beats, chapter placement, and thread tracking.
## Core files
- [Overall](Overall.md) - whole-book arc, central promise, ending direction, and major turns.
- [Acts](Acts/) - act-level planning files. This is the default Bindery recommendation for novels.
## Optional structures
- \`${arcFolderName}/Chapters/\` - chapter-level planning for detailed outliners.
- \`${arcFolderName}/Threads/\` - theme, mystery, relationship, faction, or world-plot threads.
`;
}
function overallArcTemplate(): string {
return `# Overall Arc
## Premise
## Story Promise
## Major Turns
- Opening:
- First major turn:
- Midpoint:
- Crisis:
- Climax:
- Resolution:
## Character Movement
## World / Setting Movement
## Open Questions
## Continuity Risks
`;
}
function characterIndexTemplate(): string {
return `# Character Index
Use one file per character in this folder. Keep this index for quick cast navigation and role summaries.
| Character | Role | First appearance | Notes |
|---|---|---|---|
`;
}
function noteIndexTemplate(title: string, purpose: string): string {
return `# ${title}
${purpose}
`;
}
function memoryTemplate(title: string): string {
return `# Global Memory - ${title}
Use this file for durable cross-chapter decisions, recurring constraints, and story rules that should survive across sessions.
`;
}
function chapterStatusTemplate(): string {
return JSON.stringify({ schemaVersion: 1, updatedAt: new Date().toISOString().slice(0, 10), chapters: [] }, null, 2) + '\n';
}
function scaffoldOpinionatedWorkspace(root: string, settings: Record<string, unknown>, languages: Array<Record<string, unknown>>): string[] {
const created: string[] = [];
const typedSettings = settings as WorkspaceSettings;
const storyFolderName = getStoryFolder(typedSettings);
const notesFolderName = getNotesFolder(typedSettings);
const arcFolderName = getArcFolder(typedSettings);
const charactersFolderName = getCharactersFolder(typedSettings);
const sessionFileName = getSessionFile(typedSettings);
const title = titleAsString(settings['bookTitle'], path.basename(root));
for (const lang of languages) {
const folderName = typeof lang['folderName'] === 'string' ? lang['folderName'] : lang['code'];
if (typeof folderName === 'string' && folderName.trim()) {
const rel = `${storyFolderName}/${folderName.trim()}`;
if (ensureScaffoldDir(root, rel)) { created.push(`${rel}/`); }
}
}
const dirs = [
`${arcFolderName}/Acts`,
`${notesFolderName}/World`,
`${notesFolderName}/Scenes`,
`${notesFolderName}/Research`,
charactersFolderName,
'.bindery/memories/archive',
];
for (const dir of dirs) {
if (ensureScaffoldDir(root, dir)) { created.push(`${dir}/`); }
}
const files: Array<[string, string]> = [
[sessionFileName, cowriteSessionFile(settings, languages)],
[`${arcFolderName}/index.md`, arcIndexTemplate(arcFolderName)],
[`${arcFolderName}/Overall.md`, overallArcTemplate()],
[`${notesFolderName}/Inbox.md`, noteIndexTemplate('Inbox', 'Drop loose ideas, pasted mobile chats, and unsorted notes here. Process them into structured notes when ready.')],
[`${notesFolderName}/World/index.md`, noteIndexTemplate('World Notes', 'World rules, setting facts, magic/technology constraints, and culture notes.')],
[`${notesFolderName}/Scenes/index.md`, noteIndexTemplate('Scene Notes', 'Loose scene ideas, set pieces, fragments, and placement candidates.')],
[`${notesFolderName}/Research/index.md`, noteIndexTemplate('Research Notes', 'Research references, factual checks, and source links.')],
[`${charactersFolderName}/index.md`, characterIndexTemplate()],
['.bindery/memories/global.md', memoryTemplate(title)],
['.bindery/chapter-status.json', chapterStatusTemplate()],
];
for (const [relPath, content] of files) {
if (writeScaffoldFile(root, relPath, content)) { created.push(relPath); }
}
return created;
}
export function toolInitWorkspace(root: string, args: InitWorkspaceArgs): string {
const settingsPath = path.join(root, '.bindery', 'settings.json');
const translationsPath = path.join(root, '.bindery', 'translations.json');
@ -1629,11 +2396,21 @@ export function toolInitWorkspace(root: string, args: InitWorkspaceArgs): string
}
const storyFolderName = args.storyFolder ?? (existing['storyFolder'] as string | undefined) ?? 'Story';
const bookTitle = args.bookTitle ?? (existing['bookTitle'] as string | undefined) ?? path.basename(root);
const bookTitle = args.bookTitle ?? existing['bookTitle'] ?? path.basename(root);
const existingLangs = ((existing['languages'] as unknown[] | undefined) ?? []) as Array<Record<string, unknown>>;
const languages = detectWorkspaceLangs(path.join(root, storyFolderName), existingLangs);
const slug = bookTitle.replaceAll(/[^a-zA-Z0-9]+/g, '_').replaceAll(/^_|_$/g, '') || 'Book';
const settingsForDefaults: WorkspaceSettings = {
storyFolder: storyFolderName,
notesFolder: existing['notesFolder'] as string | undefined,
arcFolder: existing['arcFolder'] as string | undefined,
charactersFolder: existing['charactersFolder'] as string | undefined,
sessionFile: existing['sessionFile'] as string | undefined,
arcGranularity: existing['arcGranularity'] as WorkspaceSettings['arcGranularity'],
};
const slugSource = titleAsString(bookTitle, path.basename(root));
const slug = slugSource.replaceAll(/[^a-zA-Z0-9]+/g, '_').replaceAll(/^_|_$/g, '') || 'Book';
const settings: Record<string, unknown> = {
...existing,
bookTitle,
@ -1642,6 +2419,11 @@ export function toolInitWorkspace(root: string, args: InitWorkspaceArgs): string
...(args.description ? { description: args.description } : {}),
...(args.targetAudience ? { targetAudience: args.targetAudience }: {}),
storyFolder: storyFolderName,
notesFolder: existing['notesFolder'] ?? getNotesFolder(settingsForDefaults),
arcFolder: existing['arcFolder'] ?? getArcFolder(settingsForDefaults),
charactersFolder: existing['charactersFolder'] ?? getCharactersFolder(settingsForDefaults),
sessionFile: existing['sessionFile'] ?? getSessionFile(settingsForDefaults),
arcGranularity: existing['arcGranularity'] ?? getArcGranularity(settingsForDefaults),
mergedOutputDir: (existing['mergedOutputDir']) ?? 'Merged',
mergeFilePrefix: (existing['mergeFilePrefix']) ?? slug,
formatOnSave: (existing['formatOnSave']) ?? false,
@ -1662,6 +2444,9 @@ export function toolInitWorkspace(root: string, args: InitWorkspaceArgs): string
const engbSeeded = seedTranslations(translationsPath, languages);
const scaffoldCreated = scaffoldOpinionatedWorkspace(root, settings, languages);
created.push(...scaffoldCreated);
// Always (re)write the capabilities README so agents have a single canonical
// "what can Bindery do?" reference from the moment a workspace is initialized.
try { writeBinderyCapabilitiesReadme(root); created.push('.bindery/README.md'); }
@ -1673,7 +2458,7 @@ export function toolInitWorkspace(root: string, args: InitWorkspaceArgs): string
? '\n\nTip: AI instruction files (CLAUDE.md, skills, copilot-instructions.md) are not yet set up. Run setup_ai_files to generate them, or use "Bindery: Set Up AI Files" in VS Code.'
: '';
const engbNote = engbSeeded ? ' en-gb dialect seeded (75 rules).' : '';
return `${action}: ${created.join(', ')}. Book: "${bookTitle}", story folder: ${storyFolderName}/, languages: ${langNote}.${engbNote}${hint}`;
return `${action}: ${created.join(', ')}. Book: "${slugSource}", story folder: ${storyFolderName}/, languages: ${langNote}.${engbNote}${hint}`;
}
// ─── settings_update ───────────────────────────────────────────────────────

View file

@ -18,6 +18,9 @@ describe('templates shim', () => {
storyFolder: 'Story',
notesFolder: 'Notes',
arcFolder: 'Arc',
charactersFolder: 'Notes/Characters',
sessionFile: 'COWORK.md',
arcGranularity: 'act',
memoriesFolder: '.bindery/memories',
languages: [{ code: 'EN', folderName: 'EN' }],
langList: 'EN (source)',

View file

@ -6,6 +6,18 @@ import { afterEach, describe, expect, it } from 'vitest';
import {
toolGetChapter,
toolGetBookUntil,
toolArcCreate,
toolArcGet,
toolArcList,
toolArcUpdate,
toolCharacterCreate,
toolCharacterGet,
toolCharacterList,
toolCharacterUpdate,
toolNoteAppend,
toolNoteCreate,
toolNoteGet,
toolNoteList,
toolGetText,
toolIndexBuild,
toolSettingsUpdate,
@ -134,6 +146,111 @@ describe('mcp tools', () => {
expect(settings.proof_read?.authors?.[0]?.name).toBe('Author A');
});
it('creates, lists, reads, and appends notes inside the configured notes folder', () => {
const root = makeRoot();
write(path.join(root, '.bindery', 'settings.json'), JSON.stringify({ notesFolder: 'Book Notes' }, null, 2) + '\n');
const created = toolNoteCreate(root, {
path: 'World/rules',
title: 'World Rules',
content: '- The sky engine hums.',
});
expect(created).toContain('Created note: World/rules.md');
const listed = toolNoteList(root, { category: 'World' });
expect(listed).toContain('World/rules.md');
expect(listed).toContain('World Rules');
const appended = toolNoteAppend(root, {
path: 'World/rules.md',
heading: 'Confirmed',
content: '- The engine is audible near the tower.',
});
expect(appended).toContain('Appended to note: World/rules.md');
const content = toolNoteGet(root, { path: 'World/rules.md' });
expect(content).toContain('# World Rules');
expect(content).toContain('## Confirmed');
expect(content).toContain('The engine is audible');
});
it('prevents path traversal in note tools', () => {
const root = makeRoot();
write(path.join(path.dirname(root), 'outside.md'), '# Outside\n');
expect(toolNoteGet(root, { path: '../outside.md' })).toContain('Invalid note path');
expect(toolNoteCreate(root, { path: '../outside.md', content: 'Nope' })).toContain('Invalid note path');
expect(toolNoteAppend(root, { path: '../outside.md', content: 'Nope' })).toContain('Invalid note path');
});
it('creates, lists, reads, and updates character profiles', () => {
const root = makeRoot();
write(path.join(root, '.bindery', 'settings.json'), JSON.stringify({ charactersFolder: 'Notes/Cast' }, null, 2) + '\n');
const created = toolCharacterCreate(root, {
name: 'Ari Vale',
role: 'Navigator',
firstAppearance: 'Chapter 2',
personality: 'Quiet under pressure.',
indexNotes: 'Knows the old routes.',
});
expect(created).toContain('Created character: Ari Vale');
const listed = toolCharacterList(root, {});
expect(listed).toContain('Ari Vale');
expect(listed).toContain('ari-vale.md');
const updated = toolCharacterUpdate(root, {
name: 'Ari Vale',
strengths: 'Reads broken maps quickly.',
continuityNotes: 'Left-handed in Chapter 2.',
});
expect(updated).toContain('Updated character: Ari Vale');
const profile = toolCharacterGet(root, { name: 'Ari Vale' });
expect(profile).toContain('# Ari Vale');
expect(profile).toContain('| Role | Navigator |');
expect(profile).toContain('## Continuity Notes');
expect(profile).toContain('Left-handed');
const index = fs.readFileSync(path.join(root, 'Notes', 'Cast', 'index.md'), 'utf-8');
expect(index).toContain('[Ari Vale](ari-vale.md)');
});
it('creates, lists, reads, updates, and protects arc files', () => {
const root = makeRoot();
write(path.join(root, '.bindery', 'settings.json'), JSON.stringify({ arcFolder: 'Story Arc' }, null, 2) + '\n');
write(path.join(path.dirname(root), 'outside.md'), '# Outside\n');
const created = toolArcCreate(root, {
path: 'Acts/awakening',
title: 'Awakening',
kind: 'act',
purpose: 'Move the protagonist from denial to action.',
majorBeats: '- Refusal\n- First commitment',
});
expect(created).toContain('Created arc file: Acts/awakening.md');
const listed = toolArcList(root, { kind: 'act' });
expect(listed).toContain('Acts/awakening.md');
expect(listed).toContain('Awakening');
const updated = toolArcUpdate(root, {
path: 'Acts/awakening.md',
continuityRisks: '- Motivation must match Chapter 3.',
linkedChapters: 'Chapters 1-5',
});
expect(updated).toContain('Updated arc file: Acts/awakening.md');
const content = toolArcGet(root, { path: 'Acts/awakening.md' });
expect(content).toContain('# Awakening');
expect(content).toContain('## Continuity Risks');
expect(content).toContain('Chapter 3');
expect(toolArcGet(root, { path: '../outside.md' })).toContain('Invalid arc path');
expect(toolArcCreate(root, { path: '../outside.md', title: 'Nope' })).toContain('Invalid arc path');
});
it('ignores unsafe prototype-pollution keys in settings_update patches', () => {
const root = makeRoot();
write(path.join(root, '.bindery', 'settings.json'), JSON.stringify({

View file

@ -49,9 +49,80 @@ describe('toolInitWorkspace', () => {
) as Record<string, unknown>;
expect(settings['bookTitle']).toBe('My Novel');
expect(settings['storyFolder']).toBe('Story');
expect(settings['notesFolder']).toBe('Notes');
expect(settings['arcFolder']).toBe('Arc');
expect(settings['charactersFolder']).toBe('Notes/Characters');
expect(settings['sessionFile']).toBe('COWORK.md');
expect(settings['arcGranularity']).toBe('act');
expect(fs.existsSync(path.join(root, '.bindery', 'translations.json'))).toBe(true);
});
it('creates the opinionated authoring scaffold for a new workspace', () => {
const root = makeRoot();
const result = toolInitWorkspace(root, { bookTitle: 'My Novel' });
expect(result).toContain('COWORK.md');
expect(fs.existsSync(path.join(root, 'COWORK.md'))).toBe(true);
expect(fs.existsSync(path.join(root, 'Arc', 'index.md'))).toBe(true);
expect(fs.existsSync(path.join(root, 'Arc', 'Overall.md'))).toBe(true);
expect(fs.existsSync(path.join(root, 'Arc', 'Acts'))).toBe(true);
expect(fs.existsSync(path.join(root, 'Notes', 'Inbox.md'))).toBe(true);
expect(fs.existsSync(path.join(root, 'Notes', 'Characters', 'index.md'))).toBe(true);
expect(fs.existsSync(path.join(root, '.bindery', 'memories', 'global.md'))).toBe(true);
expect(fs.existsSync(path.join(root, '.bindery', 'chapter-status.json'))).toBe(true);
expect(fs.existsSync(path.join(root, 'Story', 'EN'))).toBe(true);
});
it('does not overwrite existing scaffold files', () => {
const root = makeRoot();
write(path.join(root, 'COWORK.md'), '# Existing focus\n');
toolInitWorkspace(root, { bookTitle: 'My Novel' });
expect(fs.readFileSync(path.join(root, 'COWORK.md'), 'utf-8')).toBe('# Existing focus\n');
});
it('creates a minimal user-owned session file without embedded assistant rules', () => {
const root = makeRoot();
toolInitWorkspace(root, { bookTitle: 'My Novel', genre: 'Fantasy', targetAudience: '12+' });
const session = fs.readFileSync(path.join(root, 'COWORK.md'), 'utf-8');
expect(session).toContain('# Session');
expect(session).toContain('Book: My Novel');
expect(session).toContain('intentionally user-owned');
expect(session).toContain('## Current Focus');
expect(session).toContain('## Handoff Notes');
expect(session).toContain('## Personal Working Notes');
expect(session).not.toContain('## Common tasks');
expect(session).not.toContain('## Notes for the assistant');
expect(session).not.toContain('Do not rewrite prose');
expect(session).not.toContain('Target audience');
expect(session).not.toContain('Fantasy');
});
it('uses configured authoring paths when updating an existing workspace', () => {
const root = makeRoot();
write(path.join(root, '.bindery', 'settings.json'), JSON.stringify({
bookTitle: 'Custom Paths',
storyFolder: 'Drafts',
notesFolder: 'Reference',
arcFolder: 'Structure',
charactersFolder: 'Reference/Cast',
sessionFile: 'SESSION.md',
arcGranularity: 'thread',
languages: [{ code: 'EN', folderName: 'English' }],
}) + '\n');
toolInitWorkspace(root, {});
expect(fs.existsSync(path.join(root, 'SESSION.md'))).toBe(true);
expect(fs.existsSync(path.join(root, 'Structure', 'index.md'))).toBe(true);
expect(fs.existsSync(path.join(root, 'Reference', 'Inbox.md'))).toBe(true);
expect(fs.existsSync(path.join(root, 'Reference', 'Cast', 'index.md'))).toBe(true);
expect(fs.existsSync(path.join(root, 'Drafts', 'English'))).toBe(true);
});
it('includes a Tip: hint for new workspaces', () => {
const root = makeRoot();
const result = toolInitWorkspace(root, {});

View file

@ -9,8 +9,9 @@ Works with any Markdown book project structured with the Bindery VS Code extensi
- **Chapter navigation** — jump to any chapter by number and language
- **Full-text search** — lexical BM25, semantic rerank, or full semantic search across the book corpus
- **Translation management** — list, look up, add, and update translation and dialect substitution rules
- **Opinionated authoring scaffold** — initialize Arc, Notes, Characters, COWORK, memory, and chapter-status files for agent-assisted writing
- **Session memory** — append, list, and compact persistent cross-session notes in `.bindery/memories/`
- **Workspace setup** — create or update `.bindery/settings.json` and scaffold AI instruction files
- **Workspace setup** — create or update `.bindery/settings.json`, `.bindery/translations.json`, `.bindery/README.md`, the opinionated authoring scaffold, and AI instruction files
- **Chapter status tracking** — record and query per-chapter progress (draft, in-progress, done, needs-review)
- **Typography formatting** — curly quotes, em-dashes, ellipses
- **Workspace sync** — fetch and pull the current branch before a session, with branch/default-branch reporting
@ -46,6 +47,28 @@ To install manually without using published Claude Connectors
Claude calls `list_books` to discover the book name, then `get_overview` to show
all acts and chapters with titles.
### Initialize an agent-ready book workspace
> "Set this folder up as a Bindery book"
Claude calls `init_workspace`. The tool creates `.bindery/settings.json`,
`.bindery/translations.json`, the generated `.bindery/README.md` capability
reference, and the default authoring scaffold: `COWORK.md`, `Arc/index.md`,
`Arc/Overall.md`, `Arc/Acts/`, `Notes/Inbox.md`, `Notes/Characters/index.md`,
structured note folders, `.bindery/memories/global.md`, and
`.bindery/chapter-status.json`. Existing files are preserved.
After that, Claude can call `setup_ai_files` to generate CLAUDE.md,
Copilot instructions, Cursor rules, AGENTS.md, and Claude skill templates.
### Answer what Bindery can do
> "What can Bindery do for this book?"
Claude reads `.bindery/README.md`. That generated file is the canonical local
capability reference for the book, including available commands, MCP tools,
skill workflows, and the current opinionated authoring layout.
### Search for a character's mentions
> "Where does Landa first meet the Keeper?"
@ -100,6 +123,12 @@ then calls `memory_append` with `file: "global.md"`, a short title, and the
decisions to record. The tool stamps the current date automatically — no manual
date formatting needed.
### Create or append a story note
> "Add this world detail to the notes"
Claude calls `note_list` or `note_get` to find the right target under the configured notes folder, then uses `note_create` for a new note or `note_append` for an existing one. Older note layouts remain readable through `get_notes`.
### Compact a memory file that has grown too large
> "The global memory file is getting long — please compact it"
@ -133,8 +162,8 @@ table with issue type, location, and the reference that contradicts it.
| `list_books` | List all configured book names |
| `identify_book` | Match a working directory to a book name |
| `health` | Server status: settings, index, embedding backend |
| `init_workspace` | Create or update `.bindery/settings.json` and `translations.json` with smart defaults |
| `setup_ai_files` | Generate AI instruction files (CLAUDE.md, copilot-instructions.md, .cursor/rules, AGENTS.md) and Claude skill templates |
| `init_workspace` | Create or update `.bindery/settings.json`, `translations.json`, generated `.bindery/README.md`, and the opinionated Arc / Notes / Characters / COWORK / memory / status scaffold |
| `setup_ai_files` | Generate AI instruction files (CLAUDE.md, copilot-instructions.md, .cursor/rules, AGENTS.md), Claude skill templates, skill zips, and refresh generated `.bindery/README.md` |
| `index_build` | Build or rebuild the lexical index and, when enabled, the semantic embedding index |
| `index_status` | Show lexical and semantic index status, build times, and stale hints |
| `get_text` | Read any file by relative path, with optional line range |
@ -142,6 +171,18 @@ table with issue type, location, and the reference that contradicts it.
| `get_book_until` | Chapters from a start chapter through N (inclusive), concatenated in order |
| `get_overview` | Chapter structure — acts, chapters, titles |
| `get_notes` | Notes/ files, filterable by category or name |
| `note_list` | List story note files under the configured notes folder |
| `note_get` | Read a single story note by path relative to the notes folder |
| `note_create` | Create a story note under the configured notes folder |
| `note_append` | Append markdown content to a story note, creating it if needed |
| `character_list` | List structured character profile files |
| `character_get` | Read a structured character profile by name |
| `character_create` | Create a character profile and update the character index |
| `character_update` | Update a character profile and refresh the character index row |
| `arc_list` | List structured arc files |
| `arc_get` | Read a structured arc file |
| `arc_create` | Create an arc file and update the arc index |
| `arc_update` | Update an arc file and refresh the arc index |
| `search` | Search in lexical, semantic-rerank, or full-semantic mode; semantic modes fall back to lexical with warnings |
| `format` | Apply typography formatting to a file or folder |
| `get_review_text` | Git diff of uncommitted changes plus any `<!-- Bindery: Review start/stop -->` marker regions; optional auto-staging consumes the markers |
@ -159,6 +200,8 @@ table with issue type, location, and the reference that contradicts it.
| `chapter_status_get` | Read the chapter progress tracker — returns entries grouped by status (done, in-progress, draft, planned, needs-review) |
| `chapter_status_update` | Upsert chapter progress entries — send only changed chapters; unmentioned entries are preserved |
Current boundary: Arc, character, note, memory, and chapter-status workflows are available through MCP tools. Dedicated session-focus, inbox-processing, and VS Code/Obsidian host command wrappers are planned but not yet part of this MCPB tool surface.
## Privacy Policy
Bindery MCP runs entirely on your local machine. No data is sent to external

View file

@ -61,8 +61,8 @@
{ "name": "list_books", "description": "List all books configured in the MCP server. Call this first to discover available book names." },
{ "name": "identify_book", "description": "Identify which book matches a working directory. Pass your cwd and the server matches by folder name or .bindery/settings.json." },
{ "name": "health", "description": "Check server status: active book, settings, search index, and embedding backend." },
{ "name": "init_workspace", "description": "Create or update .bindery/settings.json and translations.json. All arguments optional — smart defaults used if omitted. Detects language folders automatically. Safe to run on an existing workspace." },
{ "name": "setup_ai_files", "description": "Generate AI assistant instruction files (CLAUDE.md, copilot-instructions.md, .cursor/rules, AGENTS.md) and Claude skill templates from settings. Run init_workspace first. Skips existing files unless overwrite: true." },
{ "name": "init_workspace", "description": "Create or update .bindery/settings.json, translations.json, .bindery/README.md, and the opinionated Arc, Notes, Characters, COWORK, memory, and chapter-status scaffold. All arguments optional — smart defaults used if omitted. Detects language folders automatically. Safe to run on an existing workspace." },
{ "name": "setup_ai_files", "description": "Generate AI assistant instruction files (CLAUDE.md, copilot-instructions.md, .cursor/rules, AGENTS.md), Claude skill templates, skill zips, and the generated .bindery/README.md capability reference from settings. Run init_workspace first. Skips existing files unless overwrite: true." },
{ "name": "index_build", "description": "Build or rebuild the lexical search index and, when enabled, the semantic embedding index for a book." },
{ "name": "index_status", "description": "Show lexical and semantic index metadata, including stale-status hints." },
{ "name": "search", "description": "Search using lexical BM25, semantic reranking, or full semantic mode. Semantic modes fall back to lexical with a warning if Ollama or the semantic index is unavailable." },
@ -71,6 +71,18 @@
{ "name": "get_book_until", "description": "Fetch chapters from a starting chapter through a target chapter (inclusive), concatenated in reading order." },
{ "name": "get_overview", "description": "List the chapter structure (acts, chapters, titles) for one or all languages." },
{ "name": "get_notes", "description": "Read from Notes/, optionally filtered by category or character name." },
{ "name": "note_list", "description": "List markdown note files under the configured notes folder, optionally filtered to a category folder." },
{ "name": "note_get", "description": "Read a single markdown note by path relative to the configured notes folder." },
{ "name": "note_create", "description": "Create a markdown note under the configured notes folder. Refuses to overwrite unless overwrite is true." },
{ "name": "note_append", "description": "Append markdown content to a note under the configured notes folder, creating the file if needed." },
{ "name": "character_list", "description": "List structured character profile files under the configured characters folder." },
{ "name": "character_get", "description": "Read a structured character profile by character name." },
{ "name": "character_create", "description": "Create a structured character profile and update the character index." },
{ "name": "character_update", "description": "Update known fields in a structured character profile and refresh the character index row." },
{ "name": "arc_list", "description": "List structured arc files under the configured arc folder." },
{ "name": "arc_get", "description": "Read a structured arc file by path relative to the configured arc folder." },
{ "name": "arc_create", "description": "Create a structured arc file under the configured arc folder and update the arc index." },
{ "name": "arc_update", "description": "Update known fields in a structured arc file and refresh the arc index." },
{ "name": "format", "description": "Apply typography formatting (curly quotes, em-dashes, ellipses) to a file or folder." },
{ "name": "get_review_text", "description": "Structured review payload: git diff of uncommitted changes plus any regions wrapped in <!-- Bindery: Review start/stop --> markers (works even on committed work). Filter by language (EN, NL, ALL). autoStage stages reviewed files and consumes review markers." },
{ "name": "update_workspace", "description": "Fetch and pull the current git branch, report the current branch versus the remote default branch, and optionally switch branches before pulling." },

View file

@ -11,8 +11,19 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { App } from 'obsidian';
import { BINDERY_FOLDER, SETTINGS_FILENAME } from '@bindery/core';
import { renderTemplate, type TemplateContext } from '@bindery/core';
import {
BINDERY_FOLDER,
SETTINGS_FILENAME,
getArcFolder,
getArcGranularity,
getCharactersFolder,
getNotesFolder,
getSessionFile,
getStoryFolder,
renderTemplate,
type TemplateContext,
type WorkspaceSettings,
} from '@bindery/core';
export type AiTarget = 'claude' | 'copilot' | 'cursor' | 'agents';
export type SkillTemplate =
@ -25,11 +36,13 @@ export type SkillTemplate =
| 'continuity'
| 'read-aloud'
| 'read-in'
| 'proof-read';
| 'proof-read'
| 'plan-beats'
| 'character-setup';
export const ALL_SKILLS: SkillTemplate[] = [
'review', 'brainstorm', 'memory', 'translate', 'translation-review',
'status', 'continuity', 'read-aloud', 'read-in', 'proof-read',
'status', 'continuity', 'read-aloud', 'read-in', 'proof-read', 'plan-beats', 'character-setup',
];
export interface AiSetupResult {
@ -86,14 +99,15 @@ export function setupAiFiles(
* Build template context from settings.
*/
function buildContext(settings: Record<string, unknown>): TemplateContext {
const title = (typeof settings.bookTitle === 'string' ? settings.bookTitle : undefined) ?? 'Untitled';
const title = titleFromSetting(settings.bookTitle);
const author = (typeof settings.author === 'string' ? settings.author : undefined) ?? '';
const description = (typeof settings.description === 'string' ? settings.description : undefined) ?? '';
const genre = (typeof settings.genre === 'string' ? settings.genre : undefined) ?? '';
const audience = (typeof settings.targetAudience === 'string' ? settings.targetAudience : undefined) ?? '';
const storyFolder = (typeof settings.storyFolder === 'string' ? settings.storyFolder : undefined) ?? 'Story';
const notesFolder = 'Notes';
const arcFolder = 'Arc';
const pathSettings = settings as WorkspaceSettings;
const storyFolder = getStoryFolder(pathSettings);
const notesFolder = getNotesFolder(pathSettings);
const arcFolder = getArcFolder(pathSettings);
const memoriesFolder = '.bindery/memories';
const rawLanguages = settings.languages;
@ -118,6 +132,9 @@ function buildContext(settings: Record<string, unknown>): TemplateContext {
storyFolder,
notesFolder,
arcFolder,
charactersFolder: getCharactersFolder(pathSettings),
sessionFile: getSessionFile(pathSettings),
arcGranularity: getArcGranularity(pathSettings),
languages,
langList,
hasMultiLang: languages.length > 1,
@ -125,6 +142,18 @@ function buildContext(settings: Record<string, unknown>): TemplateContext {
};
}
function titleFromSetting(value: unknown): string {
if (typeof value === 'string' && value.trim()) { return value.trim(); }
if (value && typeof value === 'object') {
const titles = value as Record<string, unknown>;
const en = titles['en'];
if (typeof en === 'string' && en.trim()) { return en.trim(); }
const first = Object.values(titles).find(v => typeof v === 'string' && v.trim());
if (typeof first === 'string') { return first.trim(); }
}
return 'Untitled';
}
/**
* Write a single file, respecting overwrite flag.
*/

View file

@ -83,6 +83,74 @@ class TextPromptModal extends Modal {
}
}
interface AuthoringCharacterInput {
name: string;
role?: string;
firstAppearance?: string;
background?: string;
continuityNotes?: string;
}
interface AuthoringArcInput {
path: string;
title?: string;
kind?: string;
purpose?: string;
majorBeats?: string;
continuityRisks?: string;
}
interface AuthoringChapterStatusEntry {
number: number;
title: string;
language: string;
status: 'done' | 'in-progress' | 'draft' | 'planned' | 'needs-review';
wordCount?: number;
notes?: string;
}
interface AuthoringTools {
toolNoteList: (_root: string, _args: { category?: string }) => string;
toolNoteGet: (_root: string, _args: { path: string }) => string;
toolNoteCreate: (_root: string, _args: { path: string; title?: string; content?: string; overwrite?: boolean }) => string;
toolNoteAppend: (_root: string, _args: { path: string; content: string; heading?: string }) => string;
toolCharacterList: (_root: string, _args: { name?: string }) => string;
toolCharacterGet: (_root: string, _args: { name: string }) => string;
toolCharacterCreate: (_root: string, _args: AuthoringCharacterInput) => string;
toolCharacterUpdate: (_root: string, _args: AuthoringCharacterInput) => string;
toolArcList: (_root: string, _args: { kind?: string }) => string;
toolArcGet: (_root: string, _args: { path: string }) => string;
toolArcCreate: (_root: string, _args: AuthoringArcInput) => string;
toolArcUpdate: (_root: string, _args: AuthoringArcInput) => string;
toolMemoryList: (_root: string) => string;
toolMemoryAppend: (_root: string, _args: { file: string; title: string; content: string }) => string;
toolMemoryCompact: (_root: string, _args: { file: string; compacted_content: string }) => string;
toolChapterStatusGet: (_root: string) => string;
toolChapterStatusUpdate: (_root: string, _args: { chapters: AuthoringChapterStatusEntry[] }) => string;
}
function loadAuthoringTools(): AuthoringTools {
try {
// Keep this literal so esbuild can bundle the shared tools into the Obsidian plugin release.
// eslint-disable-next-line @typescript-eslint/no-require-imports -- runtime bridge to shared MCP tool module
return require('../../mcp-ts/out/tools') as AuthoringTools;
} catch {
// Fall back to runtime paths for local development and manually copied release layouts.
}
const candidates = [
path.join(__dirname, 'mcp-ts', 'out', 'tools'),
path.join(__dirname, '..', 'mcp-ts', 'out', 'tools'),
path.join(__dirname, '..', '..', 'mcp-ts', 'out', 'tools'),
];
const modulePath = candidates.find(candidate => fs.existsSync(candidate + '.js'));
if (!modulePath) {
throw new Error('Compiled Bindery authoring tools were not found. Run npm run compile --workspace=mcp-ts before using authoring commands.');
}
// eslint-disable-next-line @typescript-eslint/no-require-imports -- runtime bridge to shared MCP tool module
return require(modulePath) as AuthoringTools;
}
export default class BinderyPlugin extends Plugin {
settings: BinderySettings = { ...DEFAULT_SETTINGS };
@ -245,6 +313,25 @@ export default class BinderyPlugin extends Plugin {
callback: () => void this.findUsToUkCommand(),
});
// Authoring tool commands: mirrors the MCP/LM authoring surface.
this.addCommand({ id: 'note-list', name: 'List notes', callback: () => void this.noteListCommand() });
this.addCommand({ id: 'note-get', name: 'Open note tool output', callback: () => void this.noteGetCommand() });
this.addCommand({ id: 'note-create', name: 'Create note', callback: () => void this.noteCreateCommand() });
this.addCommand({ id: 'note-append', name: 'Append to note', callback: () => void this.noteAppendCommand() });
this.addCommand({ id: 'character-list', name: 'List characters', callback: () => void this.characterListCommand() });
this.addCommand({ id: 'character-get', name: 'Open character tool output', callback: () => void this.characterGetCommand() });
this.addCommand({ id: 'character-create', name: 'Create character profile', callback: () => void this.characterCreateCommand() });
this.addCommand({ id: 'character-update', name: 'Update character profile', callback: () => void this.characterUpdateCommand() });
this.addCommand({ id: 'arc-list', name: 'List arcs', callback: () => void this.arcListCommand() });
this.addCommand({ id: 'arc-get', name: 'Open arc tool output', callback: () => void this.arcGetCommand() });
this.addCommand({ id: 'arc-create', name: 'Create arc file', callback: () => void this.arcCreateCommand() });
this.addCommand({ id: 'arc-update', name: 'Update arc file', callback: () => void this.arcUpdateCommand() });
this.addCommand({ id: 'memory-list', name: 'List memories', callback: () => void this.memoryListCommand() });
this.addCommand({ id: 'memory-append', name: 'Append memory', callback: () => void this.memoryAppendCommand() });
this.addCommand({ id: 'memory-compact', name: 'Compact memory', callback: () => void this.memoryCompactCommand() });
this.addCommand({ id: 'chapter-status-get', name: 'Show chapter status', callback: () => void this.chapterStatusGetCommand() });
this.addCommand({ id: 'chapter-status-update', name: 'Update chapter status', callback: () => void this.chapterStatusUpdateCommand() });
// Show MCP config snippet — copies JSON to clipboard
this.addCommand({
id: 'show-mcp-config',
@ -491,6 +578,244 @@ export default class BinderyPlugin extends Plugin {
}
}
private getBookRoot(): string {
const vaultPath = this.getVaultBasePath();
return resolveBookRoot(vaultPath, this.settings.bookRoot);
}
private async copyText(text: string): Promise<boolean> {
const activeWindow = this.app.workspace?.containerEl.ownerDocument.defaultView;
const clipboard = activeWindow?.navigator?.clipboard;
if (!clipboard?.writeText) { return false; }
await clipboard.writeText(text);
return true;
}
private async showAuthoringResult(title: string, result: string): Promise<void> {
const text = result.trim() || '(no output)';
if (text.length > 250 || text.includes('\n')) {
const copied = await this.copyText(text);
this.notify(copied ? `${title}: copied result to clipboard` : `${title}: ${text.slice(0, 220)}`);
return;
}
this.notify(`${title}: ${text}`);
}
private async runAuthoringCommand(
title: string,
callback: (_root: string, _tools: AuthoringTools) => Promise<string | null> | string | null,
): Promise<void> {
try {
const result = await callback(this.getBookRoot(), loadAuthoringTools());
if (result !== null) { await this.showAuthoringResult(title, result); }
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`${title} failed: ${message}`);
this.notify(`${title} failed: ${message}`);
}
}
private async promptRequired(promptText: string, defaultValue = ''): Promise<string | null> {
const value = await this.promptString(promptText, defaultValue);
if (value === null) { return null; }
const trimmed = value.trim();
if (!trimmed) {
this.notify(`${promptText} is required`);
return null;
}
return trimmed;
}
private async promptOptional(promptText: string, defaultValue = ''): Promise<string | undefined | null> {
const value = await this.promptString(promptText, defaultValue);
if (value === null) { return null; }
return value.trim() || undefined;
}
private async promptCharacterInput(update: boolean): Promise<AuthoringCharacterInput | null> {
const name = await this.promptRequired('Character name:');
if (!name) { return null; }
const role = await this.promptOptional('Role (optional):');
if (role === null) { return null; }
const firstAppearance = await this.promptOptional('First appearance (optional):');
if (firstAppearance === null) { return null; }
const background = await this.promptOptional('Background or notes (optional):');
if (background === null) { return null; }
const continuityNotes = update ? await this.promptOptional('Continuity notes (optional):') : undefined;
if (continuityNotes === null) { return null; }
return { name, role, firstAppearance, background, continuityNotes };
}
private async promptArcInput(update: boolean): Promise<AuthoringArcInput | null> {
const arcPath = await this.promptRequired('Arc path relative to Arc folder:', update ? '' : 'Acts/Act_I.md');
if (!arcPath) { return null; }
const title = await this.promptOptional('Title (optional):');
if (title === null) { return null; }
const kind = await this.promptOptional('Kind (overall, act, chapter, thread, custom; optional):');
if (kind === null) { return null; }
const purpose = await this.promptOptional('Purpose (optional):');
if (purpose === null) { return null; }
const majorBeats = await this.promptOptional('Major beats (optional):');
if (majorBeats === null) { return null; }
const continuityRisks = update ? await this.promptOptional('Continuity risks (optional):') : undefined;
if (continuityRisks === null) { return null; }
return { path: arcPath, title, kind, purpose, majorBeats, continuityRisks };
}
private async promptChapterStatusEntry(): Promise<AuthoringChapterStatusEntry | null> {
const chapterNumberRaw = await this.promptRequired('Chapter number:');
if (!chapterNumberRaw) { return null; }
const chapterNumber = Number(chapterNumberRaw);
if (!Number.isInteger(chapterNumber) || chapterNumber < 0) {
this.notify('Chapter number must be a non-negative integer');
return null;
}
const title = await this.promptRequired('Chapter title:');
if (!title) { return null; }
const language = await this.promptOptional('Language code:', 'EN');
if (language === null) { return null; }
const status = await this.promptRequired('Status (done, in-progress, needs-review, draft, planned):', 'draft');
if (!status) { return null; }
if (!['done', 'in-progress', 'needs-review', 'draft', 'planned'].includes(status)) {
this.notify('Invalid chapter status');
return null;
}
const wordCountRaw = await this.promptOptional('Word count (optional):');
if (wordCountRaw === null) { return null; }
const notes = await this.promptOptional('Notes (optional):');
if (notes === null) { return null; }
let wordCount: number | undefined;
if (wordCountRaw) {
const parsedWordCount = Number(wordCountRaw);
if (!Number.isInteger(parsedWordCount) || parsedWordCount < 0) {
this.notify('Word count must be a non-negative integer');
return null;
}
wordCount = parsedWordCount;
}
return { number: chapterNumber, title, language: language ?? 'EN', status: status as AuthoringChapterStatusEntry['status'], wordCount, notes };
}
private async noteListCommand(): Promise<void> {
await this.runAuthoringCommand('Note list', (root, tools) => tools.toolNoteList(root, {}));
}
private async noteGetCommand(): Promise<void> {
await this.runAuthoringCommand('Note get', async (root, tools) => {
const notePath = await this.promptRequired('Note path relative to Notes folder:');
return notePath ? tools.toolNoteGet(root, { path: notePath }) : null;
});
}
private async noteCreateCommand(): Promise<void> {
await this.runAuthoringCommand('Note create', async (root, tools) => {
const notePath = await this.promptRequired('Note path relative to Notes folder:', 'Inbox.md');
if (!notePath) { return null; }
const title = await this.promptOptional('Title (optional):');
if (title === null) { return null; }
const content = await this.promptOptional('Initial content (optional):');
if (content === null) { return null; }
return tools.toolNoteCreate(root, { path: notePath, title, content });
});
}
private async noteAppendCommand(): Promise<void> {
await this.runAuthoringCommand('Note append', async (root, tools) => {
const notePath = await this.promptRequired('Note path relative to Notes folder:', 'Inbox.md');
if (!notePath) { return null; }
const heading = await this.promptOptional('Heading (optional):');
if (heading === null) { return null; }
const content = await this.promptRequired('Content to append:');
return content ? tools.toolNoteAppend(root, { path: notePath, heading, content }) : null;
});
}
private async characterListCommand(): Promise<void> {
await this.runAuthoringCommand('Character list', (root, tools) => tools.toolCharacterList(root, {}));
}
private async characterGetCommand(): Promise<void> {
await this.runAuthoringCommand('Character get', async (root, tools) => {
const name = await this.promptRequired('Character name:');
return name ? tools.toolCharacterGet(root, { name }) : null;
});
}
private async characterCreateCommand(): Promise<void> {
await this.runAuthoringCommand('Character create', async (root, tools) => {
const input = await this.promptCharacterInput(false);
return input ? tools.toolCharacterCreate(root, input) : null;
});
}
private async characterUpdateCommand(): Promise<void> {
await this.runAuthoringCommand('Character update', async (root, tools) => {
const input = await this.promptCharacterInput(true);
return input ? tools.toolCharacterUpdate(root, input) : null;
});
}
private async arcListCommand(): Promise<void> {
await this.runAuthoringCommand('Arc list', (root, tools) => tools.toolArcList(root, {}));
}
private async arcGetCommand(): Promise<void> {
await this.runAuthoringCommand('Arc get', async (root, tools) => {
const arcPath = await this.promptRequired('Arc path relative to Arc folder:');
return arcPath ? tools.toolArcGet(root, { path: arcPath }) : null;
});
}
private async arcCreateCommand(): Promise<void> {
await this.runAuthoringCommand('Arc create', async (root, tools) => {
const input = await this.promptArcInput(false);
return input ? tools.toolArcCreate(root, input) : null;
});
}
private async arcUpdateCommand(): Promise<void> {
await this.runAuthoringCommand('Arc update', async (root, tools) => {
const input = await this.promptArcInput(true);
return input ? tools.toolArcUpdate(root, input) : null;
});
}
private async memoryListCommand(): Promise<void> {
await this.runAuthoringCommand('Memory list', (root, tools) => tools.toolMemoryList(root));
}
private async memoryAppendCommand(): Promise<void> {
await this.runAuthoringCommand('Memory append', async (root, tools) => {
const file = await this.promptRequired('Memory file:', 'global.md');
if (!file) { return null; }
const title = await this.promptRequired('Session title:');
if (!title) { return null; }
const content = await this.promptRequired('Memory content:');
return content ? tools.toolMemoryAppend(root, { file, title, content }) : null;
});
}
private async memoryCompactCommand(): Promise<void> {
await this.runAuthoringCommand('Memory compact', async (root, tools) => {
const file = await this.promptRequired('Memory file:', 'global.md');
if (!file) { return null; }
const compactedContent = await this.promptRequired('Compacted content:');
return compactedContent ? tools.toolMemoryCompact(root, { file, compacted_content: compactedContent }) : null;
});
}
private async chapterStatusGetCommand(): Promise<void> {
await this.runAuthoringCommand('Chapter status', (root, tools) => tools.toolChapterStatusGet(root));
}
private async chapterStatusUpdateCommand(): Promise<void> {
await this.runAuthoringCommand('Chapter status update', async (root, tools) => {
const entry = await this.promptChapterStatusEntry();
return entry ? tools.toolChapterStatusUpdate(root, { chapters: [entry] }) : null;
});
}
private async promptString(promptText: string, defaultValue: string = ''): Promise<string | null> {
return new Promise((resolve) => {
new TextPromptModal(this.app, promptText, defaultValue, resolve).open();

View file

@ -0,0 +1,103 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
const repoRoot = process.cwd();
const vscodePackagePath = path.join(repoRoot, 'vscode-ext', 'package.json');
const obsidianMainPath = path.join(repoRoot, 'obsidian-plugin', 'src', 'main.ts');
const vscodePackage = JSON.parse(fs.readFileSync(vscodePackagePath, 'utf-8'));
const obsidianMain = fs.readFileSync(obsidianMainPath, 'utf-8');
const vscodeCommands = new Set(
(vscodePackage.contributes?.commands ?? [])
.map(command => command.command)
.filter(Boolean),
);
const obsidianCommands = new Set();
for (const match of obsidianMain.matchAll(/id:\s*['`]([^'`$]+)['`]/g)) {
obsidianCommands.add(match[1]);
}
if (obsidianMain.includes('`merge-${fmt}`')) {
for (const format of ['md', 'docx', 'epub', 'pdf', 'all']) {
obsidianCommands.add(`merge-${format}`);
}
}
const expectedPairs = [
['init', 'bindery.init', 'init-workspace'],
['setupAI', 'bindery.setupAI', 'setup-ai-files'],
['formatDocument', 'bindery.formatDocument', 'format-document'],
['formatFolder', 'bindery.formatFolder', 'format-folder'],
['mergeMarkdown', 'bindery.mergeMarkdown', 'merge-md'],
['mergeDocx', 'bindery.mergeDocx', 'merge-docx'],
['mergeEpub', 'bindery.mergeEpub', 'merge-epub'],
['mergePdf', 'bindery.mergePdf', 'merge-pdf'],
['mergeAll', 'bindery.mergeAll', 'merge-all'],
['findProbableUsToUkWords', 'bindery.findProbableUsToUkWords', 'find-us-to-uk-words'],
['addDialect', 'bindery.addDialect', 'add-dialect'],
['addTranslation', 'bindery.addTranslation', 'add-translation'],
['addLanguage', 'bindery.addLanguage', 'add-language'],
['openTranslations', 'bindery.openTranslations', 'open-translations'],
['startReviewMarker', 'bindery.startReviewMarker', 'start-review-marker'],
['stopReviewMarker', 'bindery.stopReviewMarker', 'stop-review-marker'],
['noteList', 'bindery.noteList', 'note-list'],
['noteGet', 'bindery.noteGet', 'note-get'],
['noteCreate', 'bindery.noteCreate', 'note-create'],
['noteAppend', 'bindery.noteAppend', 'note-append'],
['characterList', 'bindery.characterList', 'character-list'],
['characterGet', 'bindery.characterGet', 'character-get'],
['characterCreate', 'bindery.characterCreate', 'character-create'],
['characterUpdate', 'bindery.characterUpdate', 'character-update'],
['arcList', 'bindery.arcList', 'arc-list'],
['arcGet', 'bindery.arcGet', 'arc-get'],
['arcCreate', 'bindery.arcCreate', 'arc-create'],
['arcUpdate', 'bindery.arcUpdate', 'arc-update'],
['memoryList', 'bindery.memoryList', 'memory-list'],
['memoryAppend', 'bindery.memoryAppend', 'memory-append'],
['memoryCompact', 'bindery.memoryCompact', 'memory-compact'],
['chapterStatusGet', 'bindery.chapterStatusGet', 'chapter-status-get'],
['chapterStatusUpdate', 'bindery.chapterStatusUpdate', 'chapter-status-update'],
];
const exceptions = [
['registerMcp', 'bindery.registerMcp', null, 'VS Code-only MCP discovery writer'],
['showMcpConfig', null, 'show-mcp-config', 'Obsidian-only MCP snippet display'],
['quickActions', 'bindery.quickActions', null, 'VS Code-only status-bar quick menu'],
['addUkReplacement', 'bindery.addUkReplacement', null, 'VS Code backward-compatibility alias'],
];
const failures = [];
for (const [concept, vscodeCommand, obsidianCommand] of expectedPairs) {
if (!vscodeCommands.has(vscodeCommand)) {
failures.push(`${concept}: missing VS Code command ${vscodeCommand}`);
}
if (!obsidianCommands.has(obsidianCommand)) {
failures.push(`${concept}: missing Obsidian command ${obsidianCommand}`);
}
}
for (const [concept, vscodeCommand, obsidianCommand, reason] of exceptions) {
if (vscodeCommand && !vscodeCommands.has(vscodeCommand)) {
failures.push(`${concept}: missing expected VS Code exception ${vscodeCommand} (${reason})`);
}
if (obsidianCommand && !obsidianCommands.has(obsidianCommand)) {
failures.push(`${concept}: missing expected Obsidian exception ${obsidianCommand} (${reason})`);
}
}
console.log(`Checked ${expectedPairs.length} shared command concepts.`);
console.log(`Exceptions: ${exceptions.map(([concept]) => concept).join(', ')}`);
if (failures.length > 0) {
console.error('\nCommand parity check failed:');
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log('VS Code and Obsidian command surfaces agree.');

View file

@ -54,19 +54,19 @@ Cross-language glossary entries (e.g. EN→NL world terms) are also stored in `.
### AI Assistant Integration (MCP)
Bindery includes a bundled MCP server that makes your book's chapters, notes, search index, memory, and translation data available to AI assistants directly inside VS Code.
Bindery includes a bundled MCP server that makes your book's chapters, arc files, character notes, search index, memory, status tracker, and translation data available to AI assistants directly inside VS Code.
**GitHub Copilot Chat** — tools are registered automatically when the extension activates. Use `#bindery_search`, `#bindery_get_chapter`, etc. in chat.
**Claude for VS Code / Codex** — run `Bindery: Register MCP Server` once per workspace. This writes `.vscode/mcp.json` pointing to the bundled server. Both extensions pick this up automatically on next reload.
#### Available MCP tools (26)
#### Available MCP tools (38)
| Tool | Description |
|------|-------------|
| `bindery_health` | Check workspace status: settings, search index, and embedding backend |
| `bindery_init_workspace` | Create or update `.bindery/settings.json` and `translations.json` |
| `bindery_setup_ai_files` | Generate AI instruction files (CLAUDE.md, copilot-instructions.md, etc.) |
| `bindery_init_workspace` | Create or update `.bindery/settings.json`, `translations.json`, `.bindery/README.md`, and the opinionated Arc / Notes / Characters / COWORK / memory / status scaffold |
| `bindery_setup_ai_files` | Generate AI instruction files (CLAUDE.md, copilot-instructions.md, etc.), Claude skills, skill zips, and refresh generated `.bindery/README.md` |
| `bindery_settings_update` | Merge a partial patch into `.bindery/settings.json` |
| `bindery_index_build` | Build or rebuild the search index (lexical + optional semantic) |
| `bindery_index_status` | Show index metadata and stale-status hints |
@ -76,6 +76,18 @@ Bindery includes a bundled MCP server that makes your book's chapters, notes, se
| `bindery_get_book_until` | Chapters from a start through a target chapter, concatenated in order |
| `bindery_get_overview` | Chapter structure — acts, chapters, titles |
| `bindery_get_notes` | Notes files, filterable by category or character name |
| `bindery_note_list` | List story note files under the configured notes folder |
| `bindery_note_get` | Read a single story note by path |
| `bindery_note_create` | Create a story note under the configured notes folder |
| `bindery_note_append` | Append markdown content to a story note |
| `bindery_character_list` | List structured character profiles |
| `bindery_character_get` | Read a structured character profile by name |
| `bindery_character_create` | Create a character profile and update the character index |
| `bindery_character_update` | Update a character profile and refresh the index row |
| `bindery_arc_list` | List structured arc files |
| `bindery_arc_get` | Read a structured arc file |
| `bindery_arc_create` | Create an arc file and update the arc index |
| `bindery_arc_update` | Update an arc file and refresh the arc index |
| `bindery_format` | Apply typography formatting to a file or folder |
| `bindery_get_review_text` | Git diff of uncommitted changes **plus** any `<!-- Bindery: Review start/stop -->` regions (works on committed work too) |
| `bindery_update_workspace` | Fetch and pull the current branch, with branch/default-branch reporting |
@ -95,6 +107,8 @@ Bindery includes a bundled MCP server that makes your book's chapters, notes, se
For the standalone MCP server (Claude Desktop / Cowork), two additional tools are available: `list_books` and `identify_book` for multi-book discovery. See [mcpb/README.md](../mcpb/README.md) for full MCP documentation and usage examples.
These are agent-facing MCP / language-model tools. The Command Palette also exposes host commands for the same structured note, character, arc, memory, and chapter-status workflows. Host prompts cover common fields; agents can call the MCP/LM tools directly for complete structured payloads.
### File Discovery
The extension automatically discovers and orders your chapter files:
@ -119,25 +133,24 @@ All commands are available from the Command Palette (`Ctrl+Shift+P`) under the *
| Command | Description |
|---------|-------------|
| Command | Description | Default keybinding |
|---------|-------------|--------------------|
| `Initialize Workspace` | Create `.bindery/settings.json` and `translations.json` | — |
| `Setup AI Assistant Files` | Generate CLAUDE.md, copilot-instructions.md, .cursor/rules, AGENTS.md | — |
| `Register MCP Server` | Write `.vscode/mcp.json` for Claude / Codex MCP discovery | — |
| `Format Typography` | Apply typography formatting to the active markdown file | `Ctrl+K Ctrl+B` |
| `Format All Markdown in Folder` | Apply typography to all `.md` files in a folder | — |
| `Insert Review Start Marker (or wrap selection)` | Insert `<!-- Bindery: Review start -->`, or wrap the current selection in matched start/stop markers | `Ctrl+K Ctrl+,` |
| `Insert Review Stop Marker` | Insert `<!-- Bindery: Review stop -->` at the cursor | `Ctrl+K Ctrl+.` |
| `Merge Chapters → Markdown` | Merge chapters into a single `.md` file | — |
| `Merge Chapters → DOCX` | Merge chapters and export via Pandoc | — |
| `Merge Chapters → EPUB` | Merge chapters and export via Pandoc | — |
| `Merge Chapters → PDF` | Merge chapters via Pandoc + LibreOffice | — |
| `Merge Chapters → All Formats` | Export all configured formats at once | — |
| `Find Probable US→UK Words` | Scan `Story/EN` for likely US spellings | — |
| `Add Dialect Rule` | Add a dialect substitution rule (e.g. color→colour) | — |
| `Add Translation (Glossary)` | Add a cross-language glossary entry | — |
| `Add Language` | Add a new language and scaffold its story folder | — |
| `Open translations.json` | Open the translations file in the editor | — |
| `Initialize Workspace` | Create `.bindery/settings.json`, `translations.json`, `.bindery/README.md`, and the opinionated Arc / Notes / Characters / COWORK / memory / status scaffold |
| `Setup AI Assistant Files` | Generate CLAUDE.md, copilot-instructions.md, .cursor/rules, AGENTS.md, Claude skills, skill zips, and refresh generated `.bindery/README.md` |
| `Register MCP Server` | Write `.vscode/mcp.json` for Claude / Codex MCP discovery |
| `Format Typography` | Apply typography formatting to the active markdown file (`Ctrl+K Ctrl+B`) |
| `Format All Markdown in Folder` | Apply typography to all `.md` files in a folder |
| `Insert Review Start Marker (or wrap selection)` | Insert `<!-- Bindery: Review start -->`, or wrap the current selection in matched start/stop markers (`Ctrl+K Ctrl+,`) |
| `Insert Review Stop Marker` | Insert `<!-- Bindery: Review stop -->` at the cursor (`Ctrl+K Ctrl+.`) |
| `Merge Chapters → Markdown / DOCX / EPUB / PDF / All Formats` | Export configured languages and dialects |
| `Find Probable US→UK Words` | Scan `Story/EN` for likely US spellings |
| `Add Dialect Rule` | Add a dialect substitution rule (e.g. color→colour) |
| `Add Translation (Glossary)` | Add a cross-language glossary entry |
| `Add Language` | Add a new language and scaffold its story folder |
| `Open translations.json` | Open the translations file in the editor |
| `List Notes` / `Create Note` / `Append to Note` | Work with notes under the configured notes folder |
| `List Characters` / `Create Character Profile` / `Update Character Profile` | Maintain structured character profiles and the character index |
| `List Arcs` / `Create Arc File` / `Update Arc File` | Maintain structured story-architecture files and the arc index |
| `List Memories` / `Append Memory` / `Compact Memory` | Maintain durable `.bindery/memories/` files |
| `Show Chapter Status` / `Update Chapter Status` | Read or update `.bindery/chapter-status.json` |
Keybindings only fire while editing a markdown file (`editorTextFocus && resourceLangId == markdown`); rebind via **File → Preferences → Keyboard Shortcuts** if they conflict with another extension.
@ -148,6 +161,11 @@ Settings can be defined in `.bindery/settings.json` (preferred) or VS Code setti
| Setting | Default | Description |
|---------|---------|-------------|
| `bindery.storyFolder` | `"Story"` | Folder containing language subfolders |
| `notesFolder` in `.bindery/settings.json` | `"Notes"` | Story notes root used by generated AI guidance and MCP tools |
| `arcFolder` in `.bindery/settings.json` | `"Arc"` | Story architecture folder for overall, act, chapter, thread, or custom arcs |
| `charactersFolder` in `.bindery/settings.json` | `"Notes/Characters"` | Character index and one-profile-per-character folder |
| `sessionFile` in `.bindery/settings.json` | `"COWORK.md"` | User-owned current focus and handoff file; Bindery creates a minimal scaffold if missing |
| `arcGranularity` in `.bindery/settings.json` | `"act"` | Preferred planning granularity: overall, act, chapter, thread, or custom |
| `bindery.languages` | EN | Language configurations (see below) |
| `bindery.mergedOutputDir` | `"Merged"` | Output directory for merged files |
| `bindery.author` | `""` | Author name for EPUB/DOCX metadata |

View file

@ -237,6 +237,91 @@
"title": "Insert Review Stop Marker",
"category": "Bindery"
},
{
"command": "bindery.noteList",
"title": "List Notes",
"category": "Bindery"
},
{
"command": "bindery.noteGet",
"title": "Open Note Tool Output",
"category": "Bindery"
},
{
"command": "bindery.noteCreate",
"title": "Create Note",
"category": "Bindery"
},
{
"command": "bindery.noteAppend",
"title": "Append to Note",
"category": "Bindery"
},
{
"command": "bindery.characterList",
"title": "List Characters",
"category": "Bindery"
},
{
"command": "bindery.characterGet",
"title": "Open Character Tool Output",
"category": "Bindery"
},
{
"command": "bindery.characterCreate",
"title": "Create Character Profile",
"category": "Bindery"
},
{
"command": "bindery.characterUpdate",
"title": "Update Character Profile",
"category": "Bindery"
},
{
"command": "bindery.arcList",
"title": "List Arcs",
"category": "Bindery"
},
{
"command": "bindery.arcGet",
"title": "Open Arc Tool Output",
"category": "Bindery"
},
{
"command": "bindery.arcCreate",
"title": "Create Arc File",
"category": "Bindery"
},
{
"command": "bindery.arcUpdate",
"title": "Update Arc File",
"category": "Bindery"
},
{
"command": "bindery.memoryList",
"title": "List Memories",
"category": "Bindery"
},
{
"command": "bindery.memoryAppend",
"title": "Append Memory",
"category": "Bindery"
},
{
"command": "bindery.memoryCompact",
"title": "Compact Memory",
"category": "Bindery"
},
{
"command": "bindery.chapterStatusGet",
"title": "Show Chapter Status",
"category": "Bindery"
},
{
"command": "bindery.chapterStatusUpdate",
"title": "Update Chapter Status",
"category": "Bindery"
},
{
"command": "bindery.registerMcp",
"title": "Register MCP Server",
@ -434,6 +519,301 @@
}
}
},
{
"name": "bindery_note_list",
"tags": [
"bindery"
],
"displayName": "Bindery: Note List",
"toolReferenceName": "binderyNoteList",
"canBeReferencedInPrompt": true,
"modelDescription": "List markdown note files under the configured notes folder, optionally filtered to a category folder.",
"inputSchema": {
"type": "object",
"properties": {
"category": {
"type": "string",
"description": "Optional category/folder under Notes, e.g. Characters, World, Scenes, Research"
}
}
}
},
{
"name": "bindery_note_get",
"tags": [
"bindery"
],
"displayName": "Bindery: Note Get",
"toolReferenceName": "binderyNoteGet",
"canBeReferencedInPrompt": true,
"modelDescription": "Read a single markdown note by path relative to the configured notes folder.",
"inputSchema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Note path relative to the notes folder, e.g. Inbox.md or Characters/index.md"
}
},
"required": ["path"]
}
},
{
"name": "bindery_note_create",
"tags": [
"bindery"
],
"displayName": "Bindery: Note Create",
"toolReferenceName": "binderyNoteCreate",
"canBeReferencedInPrompt": true,
"modelDescription": "Create a markdown note under the configured notes folder. Refuses to overwrite unless overwrite is true.",
"inputSchema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Note path relative to the notes folder, e.g. World/Rules.md"
},
"title": {
"type": "string",
"description": "Optional H1 title. Defaults to a title derived from the filename."
},
"content": {
"type": "string",
"description": "Optional markdown body to write below the H1 title."
},
"overwrite": {
"type": "boolean",
"description": "Replace an existing note if true. Default false."
}
},
"required": ["path"]
}
},
{
"name": "bindery_note_append",
"tags": [
"bindery"
],
"displayName": "Bindery: Note Append",
"toolReferenceName": "binderyNoteAppend",
"canBeReferencedInPrompt": true,
"modelDescription": "Append markdown content to a note under the configured notes folder, creating the file if needed.",
"inputSchema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Note path relative to the notes folder, e.g. Inbox.md or World/Rules.md"
},
"content": {
"type": "string",
"description": "Markdown content to append."
},
"heading": {
"type": "string",
"description": "Optional H2 heading to insert before the appended content."
}
},
"required": ["path", "content"]
}
},
{
"name": "bindery_character_list",
"tags": [
"bindery"
],
"displayName": "Bindery: Character List",
"toolReferenceName": "binderyCharacterList",
"canBeReferencedInPrompt": true,
"modelDescription": "List structured character profile files under the configured characters folder.",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Optional character-name filter."
}
}
}
},
{
"name": "bindery_character_get",
"tags": [
"bindery"
],
"displayName": "Bindery: Character Get",
"toolReferenceName": "binderyCharacterGet",
"canBeReferencedInPrompt": true,
"modelDescription": "Read a structured character profile by character name.",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Character name. The tool resolves the matching slugged profile file."
}
},
"required": ["name"]
}
},
{
"name": "bindery_character_create",
"tags": [
"bindery"
],
"displayName": "Bindery: Character Create",
"toolReferenceName": "binderyCharacterCreate",
"canBeReferencedInPrompt": true,
"modelDescription": "Create a structured character profile and update Notes/Characters/index.md.",
"inputSchema": {
"type": "object",
"properties": {
"name": { "type": "string", "description": "Character name. Used for the profile H1 and slugged filename." },
"role": { "type": "string" },
"age": { "type": "string" },
"origin": { "type": "string" },
"skills": { "type": "string" },
"strengths": { "type": "string" },
"weaknesses": { "type": "string" },
"personality": { "type": "string" },
"background": { "type": "string" },
"narrativeArc": { "type": "string" },
"appearanceNotes": { "type": "string" },
"relationships": { "type": "string" },
"firstAppearance": { "type": "string" },
"openQuestions": { "type": "string" },
"continuityNotes": { "type": "string" },
"indexNotes": { "type": "string" },
"overwrite": { "type": "boolean", "description": "Replace an existing profile if true. Default false." }
},
"required": ["name"]
}
},
{
"name": "bindery_character_update",
"tags": [
"bindery"
],
"displayName": "Bindery: Character Update",
"toolReferenceName": "binderyCharacterUpdate",
"canBeReferencedInPrompt": true,
"modelDescription": "Update known fields in a structured character profile and refresh the character index row.",
"inputSchema": {
"type": "object",
"properties": {
"name": { "type": "string", "description": "Character name. The tool resolves the matching slugged profile file." },
"role": { "type": "string" },
"age": { "type": "string" },
"origin": { "type": "string" },
"skills": { "type": "string" },
"strengths": { "type": "string" },
"weaknesses": { "type": "string" },
"personality": { "type": "string" },
"background": { "type": "string" },
"narrativeArc": { "type": "string" },
"appearanceNotes": { "type": "string" },
"relationships": { "type": "string" },
"firstAppearance": { "type": "string" },
"openQuestions": { "type": "string" },
"continuityNotes": { "type": "string" },
"indexNotes": { "type": "string" }
},
"required": ["name"]
}
},
{
"name": "bindery_arc_list",
"tags": [
"bindery"
],
"displayName": "Bindery: Arc List",
"toolReferenceName": "binderyArcList",
"canBeReferencedInPrompt": true,
"modelDescription": "List structured arc files under the configured arc folder.",
"inputSchema": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"description": "Optional kind filter, e.g. overall, act, chapter, thread, custom."
}
}
}
},
{
"name": "bindery_arc_get",
"tags": [
"bindery"
],
"displayName": "Bindery: Arc Get",
"toolReferenceName": "binderyArcGet",
"canBeReferencedInPrompt": true,
"modelDescription": "Read a structured arc file by path relative to the configured arc folder.",
"inputSchema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Arc path relative to the arc folder, e.g. Overall.md or Acts/act-i.md."
}
},
"required": ["path"]
}
},
{
"name": "bindery_arc_create",
"tags": [
"bindery"
],
"displayName": "Bindery: Arc Create",
"toolReferenceName": "binderyArcCreate",
"canBeReferencedInPrompt": true,
"modelDescription": "Create a structured arc file under the configured arc folder and update Arc/index.md.",
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Arc path relative to the arc folder, e.g. Acts/act-i.md." },
"title": { "type": "string" },
"kind": { "type": "string" },
"purpose": { "type": "string" },
"majorBeats": { "type": "string" },
"characterMovement": { "type": "string" },
"worldImplications": { "type": "string" },
"unresolvedQuestions": { "type": "string" },
"continuityRisks": { "type": "string" },
"linkedChapters": { "type": "string" },
"overwrite": { "type": "boolean", "description": "Replace an existing arc file if true. Default false." }
},
"required": ["path"]
}
},
{
"name": "bindery_arc_update",
"tags": [
"bindery"
],
"displayName": "Bindery: Arc Update",
"toolReferenceName": "binderyArcUpdate",
"canBeReferencedInPrompt": true,
"modelDescription": "Update known fields in a structured arc file and refresh Arc/index.md.",
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Arc path relative to the arc folder, e.g. Acts/act-i.md." },
"title": { "type": "string" },
"kind": { "type": "string" },
"purpose": { "type": "string" },
"majorBeats": { "type": "string" },
"characterMovement": { "type": "string" },
"worldImplications": { "type": "string" },
"unresolvedQuestions": { "type": "string" },
"continuityRisks": { "type": "string" },
"linkedChapters": { "type": "string" }
},
"required": ["path"]
}
},
{
"name": "bindery_search",
"tags": [
@ -652,7 +1032,7 @@
"displayName": "Bindery: Setup AI Files",
"toolReferenceName": "binderySetupAiFiles",
"canBeReferencedInPrompt": true,
"modelDescription": "Generate AI assistant instruction files (CLAUDE.md, copilot-instructions.md, .cursor/rules, AGENTS.md) and Claude skill templates from .bindery/settings.json. Run init_workspace first. Skips existing files unless overwrite is true.",
"modelDescription": "Generate AI assistant instruction files (CLAUDE.md, copilot-instructions.md, .cursor/rules, AGENTS.md), Claude skill templates, skill zips, and the generated .bindery/README.md capability reference from .bindery/settings.json. Run init_workspace first. Skips existing files unless overwrite is true.",
"inputSchema": {
"type": "object",
"properties": {
@ -668,7 +1048,7 @@
"items": {
"type": "string"
},
"description": "Claude skills to include: review, brainstorm, memory, translate, translation-review, status, continuity, read-aloud, read-in, proof-read. Default: all."
"description": "Claude skills to include: review, brainstorm, memory, translate, translation-review, status, continuity, read-aloud, read-in, proof-read, plan-beats. Default: all."
},
"overwrite": {
"type": "boolean",
@ -707,7 +1087,7 @@
"displayName": "Bindery: Init Workspace",
"toolReferenceName": "binderyInitWorkspace",
"canBeReferencedInPrompt": true,
"modelDescription": "Create or update .bindery/settings.json and translations.json. All arguments optional — smart defaults used for any omitted values. Detects language folders automatically. Safe to run on an existing workspace.",
"modelDescription": "Create or update .bindery/settings.json, translations.json, .bindery/README.md, and the opinionated Arc, Notes, Characters, COWORK, memory, and chapter-status scaffold. All arguments optional — smart defaults used for any omitted values. Detects language folders automatically. Safe to run on an existing workspace.",
"inputSchema": {
"type": "object",
"properties": {

View file

@ -15,7 +15,16 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { WorkspaceSettings } from './workspace';
import { renderTemplate, type TemplateContext } from '@bindery/core';
import {
getArcFolder,
getArcGranularity,
getCharactersFolder,
getNotesFolder,
getSessionFile,
getStoryFolder,
renderTemplate,
type TemplateContext,
} from '@bindery/core';
// ─── Public types ─────────────────────────────────────────────────────────────
@ -46,10 +55,12 @@ export type SkillTemplate =
| 'continuity'
| 'read-aloud'
| 'read-in'
| 'proof-read';
| 'proof-read'
| 'plan-beats'
| 'character-setup';
export const ALL_SKILLS: SkillTemplate[] = [
'review', 'brainstorm', 'memory', 'translate', 'translation-review', 'status', 'continuity', 'read-aloud', 'read-in', 'proof-read',
'review', 'brainstorm', 'memory', 'translate', 'translation-review', 'status', 'continuity', 'read-aloud', 'read-in', 'proof-read', 'plan-beats', 'character-setup',
];
// ─── Entry point ──────────────────────────────────────────────────────────────
@ -90,21 +101,44 @@ export function setupAiFiles(options: AiSetupOptions): AiSetupResult {
// ─── Context builder ──────────────────────────────────────────────────────────
function buildContext(s: WorkspaceSettings): TemplateContext {
const title = (typeof s.bookTitle === 'string' ? s.bookTitle : undefined) ?? 'Untitled';
const title = titleFromSetting(s.bookTitle);
const author = s.author ?? '';
const description = s.description ?? '';
const genre = s.genre ?? '';
const audience = s.targetAudience ?? '';
const storyFolder = s.storyFolder ?? 'Story';
const notesFolder = 'Notes';
const arcFolder = 'Arc';
const storyFolder = getStoryFolder(s);
const notesFolder = getNotesFolder(s);
const arcFolder = getArcFolder(s);
const languages: Array<{ code: string; folderName: string }> = s.languages ?? [];
const langList = languages.length > 0
? languages.map((l, i) => i === 0 ? `${l.code} (source)` : `${l.code} (translation)`).join(', ')
: 'EN (source)';
return { title, author, description, genre, audience, storyFolder, notesFolder, arcFolder, languages, langList, hasMultiLang: languages.length > 1, memoriesFolder: '.bindery/memories' };
return {
title, author, description, genre, audience,
storyFolder,
notesFolder,
arcFolder,
charactersFolder: getCharactersFolder(s),
sessionFile: getSessionFile(s),
arcGranularity: getArcGranularity(s),
languages,
langList,
hasMultiLang: languages.length > 1,
memoriesFolder: '.bindery/memories',
};
}
function titleFromSetting(value: WorkspaceSettings['bookTitle']): string {
if (typeof value === 'string' && value.trim()) { return value.trim(); }
if (value && typeof value === 'object') {
const en = value['en'];
if (typeof en === 'string' && en.trim()) { return en.trim(); }
const first = Object.values(value).find(v => typeof v === 'string' && v.trim());
if (typeof first === 'string') { return first.trim(); }
}
return 'Untitled';
}
// ─── Helpers ──────────────────────────────────────────────────────────────────

View file

@ -65,6 +65,53 @@ interface McpToolsForAi {
toolHealth: (_root: string) => string;
toolSetupAiFiles: (_root: string, _args: { targets?: string[]; skills?: string[]; overwrite?: boolean }) => string;
writeBinderyCapabilitiesReadme: (_root: string) => void;
toolNoteList: (_root: string, _args: { category?: string }) => string;
toolNoteGet: (_root: string, _args: { path: string }) => string;
toolNoteCreate: (_root: string, _args: { path: string; title?: string; content?: string; overwrite?: boolean }) => string;
toolNoteAppend: (_root: string, _args: { path: string; content: string; heading?: string }) => string;
toolCharacterList: (_root: string, _args: { name?: string }) => string;
toolCharacterGet: (_root: string, _args: { name: string }) => string;
toolCharacterCreate: (_root: string, _args: AuthoringCharacterInput) => string;
toolCharacterUpdate: (_root: string, _args: AuthoringCharacterInput) => string;
toolArcList: (_root: string, _args: { kind?: string }) => string;
toolArcGet: (_root: string, _args: { path: string }) => string;
toolArcCreate: (_root: string, _args: AuthoringArcInput) => string;
toolArcUpdate: (_root: string, _args: AuthoringArcInput) => string;
toolMemoryList: (_root: string) => string;
toolMemoryAppend: (_root: string, _args: { file: string; title: string; content: string }) => string;
toolMemoryCompact: (_root: string, _args: { file: string; compacted_content: string }) => string;
toolChapterStatusGet: (_root: string) => string;
toolChapterStatusUpdate: (_root: string, _args: { chapters: AuthoringChapterStatusEntry[] }) => string;
}
interface AuthoringCharacterInput {
name: string;
role?: string;
firstAppearance?: string;
background?: string;
continuityNotes?: string;
indexNotes?: string;
overwrite?: boolean;
}
interface AuthoringArcInput {
path: string;
title?: string;
kind?: string;
purpose?: string;
majorBeats?: string;
continuityRisks?: string;
linkedChapters?: string;
overwrite?: boolean;
}
interface AuthoringChapterStatusEntry {
number: number;
title: string;
language: string;
status: 'done' | 'in-progress' | 'draft' | 'planned' | 'needs-review';
wordCount?: number;
notes?: string;
}
function loadMcpToolsForAi(extensionPath: string): McpToolsForAi {
@ -946,6 +993,7 @@ const SKILL_ITEMS: Array<{ label: string; description: string; value: SkillTempl
{ label: '/read-aloud', description: 'Reading-aloud test for a chapter or passage', value: 'read-aloud' },
{ label: '/read-in', description: 'Load context and get your bearings at the start of a session', value: 'read-in' },
{ label: '/proof-read', description: 'Multi-perspective proofread with reader and author personas', value: 'proof-read' },
{ label: '/plan-beats', description: 'Create or refine a chapter or scene beatmap', value: 'plan-beats' },
];
async function setupAiCommand(context?: vscode.ExtensionContext) {
@ -1279,6 +1327,249 @@ async function doMerge(outputTypes: OutputType[]) {
}
}
// ─── Commands: authoring tool wrappers ───────────────────────────────────────
function requireWorkspaceRoot(): string | undefined {
const root = getWorkspaceRoot();
if (!root) { vscode.window.showErrorMessage('No workspace folder open.'); return undefined; }
return root;
}
async function showAuthoringResult(title: string, result: string): Promise<void> {
const trimmed = result.trim();
const content = (trimmed || '(no output)') + '\n';
if (trimmed.length > 500 || trimmed.includes('\n')) {
const doc = await vscode.workspace.openTextDocument({ language: 'markdown', content });
await vscode.window.showTextDocument(doc, { preview: true });
return;
}
vscode.window.showInformationMessage(`Bindery: ${title}: ${trimmed || '(no output)'}`);
}
async function runAuthoringCommand(
context: vscode.ExtensionContext,
title: string,
callback: (_root: string, _tools: McpToolsForAi) => Promise<string | undefined> | string | undefined,
): Promise<void> {
const root = requireWorkspaceRoot();
if (!root) { return; }
try {
const result = await callback(root, loadMcpToolsForAi(context.extensionPath));
if (result !== undefined) { await showAuthoringResult(title, result); }
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
vscode.window.showErrorMessage(`Bindery: ${title} failed: ${message}`);
}
}
async function promptRequired(title: string, prompt: string, value = ''): Promise<string | undefined> {
const result = await vscode.window.showInputBox({ title, prompt, value });
if (result === undefined) { return undefined; }
const trimmed = result.trim();
if (!trimmed) {
vscode.window.showWarningMessage(`Bindery: ${prompt} is required.`);
return undefined;
}
return trimmed;
}
async function promptOptional(title: string, prompt: string, value = ''): Promise<string | undefined> {
const result = await vscode.window.showInputBox({ title, prompt, value });
if (result === undefined) { return undefined; }
return result.trim() || undefined;
}
async function promptCharacterInput(title: string, update: boolean): Promise<AuthoringCharacterInput | undefined> {
const name = await promptRequired(title, 'Character name');
if (!name) { return undefined; }
const role = await promptOptional(title, 'Role (optional)');
const firstAppearance = await promptOptional(title, 'First appearance (optional)');
const background = await promptOptional(title, 'Background or notes (optional)');
const continuityNotes = update ? await promptOptional(title, 'Continuity notes (optional)') : undefined;
return { name, role, firstAppearance, background, continuityNotes };
}
async function promptArcInput(title: string, update: boolean): Promise<AuthoringArcInput | undefined> {
const arcPath = await promptRequired(title, 'Arc path relative to Arc folder', update ? '' : 'Acts/Act_I.md');
if (!arcPath) { return undefined; }
const arcTitle = await promptOptional(title, 'Title (optional)');
const kindPick = await vscode.window.showQuickPick(
[
{ label: 'overall' },
{ label: 'act' },
{ label: 'chapter' },
{ label: 'thread' },
{ label: 'custom' },
{ label: 'Skip', description: 'Leave kind unchanged/empty' },
],
{ title, placeHolder: 'Arc kind' }
);
if (!kindPick) { return undefined; }
const purpose = await promptOptional(title, 'Purpose (optional)');
const majorBeats = await promptOptional(title, 'Major beats (optional)');
const continuityRisks = update ? await promptOptional(title, 'Continuity risks (optional)') : undefined;
return {
path: arcPath,
title: arcTitle,
kind: kindPick.label === 'Skip' ? undefined : kindPick.label,
purpose,
majorBeats,
continuityRisks,
};
}
async function promptChapterStatusEntry(title: string): Promise<AuthoringChapterStatusEntry | undefined> {
const chapterNumberRaw = await promptRequired(title, 'Chapter number');
if (!chapterNumberRaw) { return undefined; }
const chapterNumber = Number(chapterNumberRaw);
if (!Number.isInteger(chapterNumber) || chapterNumber < 0) {
vscode.window.showWarningMessage('Bindery: chapter number must be a non-negative integer.');
return undefined;
}
const chapterTitle = await promptRequired(title, 'Chapter title');
if (!chapterTitle) { return undefined; }
const language = await promptOptional(title, 'Language code', 'EN') ?? 'EN';
const statusPick = await vscode.window.showQuickPick(
[
{ label: 'done' as const },
{ label: 'in-progress' as const },
{ label: 'needs-review' as const },
{ label: 'draft' as const },
{ label: 'planned' as const },
],
{ title, placeHolder: 'Chapter status' }
);
if (!statusPick) { return undefined; }
const wordCountRaw = await promptOptional(title, 'Word count (optional)');
const notes = await promptOptional(title, 'Notes (optional)');
let wordCount: number | undefined;
if (wordCountRaw) {
const parsedWordCount = Number(wordCountRaw);
if (!Number.isInteger(parsedWordCount) || parsedWordCount < 0) {
vscode.window.showWarningMessage('Bindery: word count must be a non-negative integer.');
return undefined;
}
wordCount = parsedWordCount;
}
return { number: chapterNumber, title: chapterTitle, language, status: statusPick.label, wordCount, notes };
}
async function noteListCommand(context: vscode.ExtensionContext): Promise<void> {
await runAuthoringCommand(context, 'Note List', (_root, tools) => tools.toolNoteList(_root, {}));
}
async function noteGetCommand(context: vscode.ExtensionContext): Promise<void> {
await runAuthoringCommand(context, 'Note Get', async (root, tools) => {
const notePath = await promptRequired('Bindery: Get Note', 'Note path relative to Notes folder');
return notePath ? tools.toolNoteGet(root, { path: notePath }) : undefined;
});
}
async function noteCreateCommand(context: vscode.ExtensionContext): Promise<void> {
await runAuthoringCommand(context, 'Note Create', async (root, tools) => {
const notePath = await promptRequired('Bindery: Create Note', 'Note path relative to Notes folder', 'Inbox.md');
if (!notePath) { return undefined; }
const title = await promptOptional('Bindery: Create Note', 'Title (optional)');
const content = await promptOptional('Bindery: Create Note', 'Initial content (optional)');
return tools.toolNoteCreate(root, { path: notePath, title, content });
});
}
async function noteAppendCommand(context: vscode.ExtensionContext): Promise<void> {
await runAuthoringCommand(context, 'Note Append', async (root, tools) => {
const notePath = await promptRequired('Bindery: Append Note', 'Note path relative to Notes folder', 'Inbox.md');
if (!notePath) { return undefined; }
const heading = await promptOptional('Bindery: Append Note', 'Heading (optional)');
const content = await promptRequired('Bindery: Append Note', 'Content to append');
return content ? tools.toolNoteAppend(root, { path: notePath, heading, content }) : undefined;
});
}
async function characterListCommand(context: vscode.ExtensionContext): Promise<void> {
await runAuthoringCommand(context, 'Character List', (_root, tools) => tools.toolCharacterList(_root, {}));
}
async function characterGetCommand(context: vscode.ExtensionContext): Promise<void> {
await runAuthoringCommand(context, 'Character Get', async (root, tools) => {
const name = await promptRequired('Bindery: Get Character', 'Character name');
return name ? tools.toolCharacterGet(root, { name }) : undefined;
});
}
async function characterCreateCommand(context: vscode.ExtensionContext): Promise<void> {
await runAuthoringCommand(context, 'Character Create', async (root, tools) => {
const input = await promptCharacterInput('Bindery: Create Character', false);
return input ? tools.toolCharacterCreate(root, input) : undefined;
});
}
async function characterUpdateCommand(context: vscode.ExtensionContext): Promise<void> {
await runAuthoringCommand(context, 'Character Update', async (root, tools) => {
const input = await promptCharacterInput('Bindery: Update Character', true);
return input ? tools.toolCharacterUpdate(root, input) : undefined;
});
}
async function arcListCommand(context: vscode.ExtensionContext): Promise<void> {
await runAuthoringCommand(context, 'Arc List', (_root, tools) => tools.toolArcList(_root, {}));
}
async function arcGetCommand(context: vscode.ExtensionContext): Promise<void> {
await runAuthoringCommand(context, 'Arc Get', async (root, tools) => {
const arcPath = await promptRequired('Bindery: Get Arc', 'Arc path relative to Arc folder');
return arcPath ? tools.toolArcGet(root, { path: arcPath }) : undefined;
});
}
async function arcCreateCommand(context: vscode.ExtensionContext): Promise<void> {
await runAuthoringCommand(context, 'Arc Create', async (root, tools) => {
const input = await promptArcInput('Bindery: Create Arc', false);
return input ? tools.toolArcCreate(root, input) : undefined;
});
}
async function arcUpdateCommand(context: vscode.ExtensionContext): Promise<void> {
await runAuthoringCommand(context, 'Arc Update', async (root, tools) => {
const input = await promptArcInput('Bindery: Update Arc', true);
return input ? tools.toolArcUpdate(root, input) : undefined;
});
}
async function memoryListCommand(context: vscode.ExtensionContext): Promise<void> {
await runAuthoringCommand(context, 'Memory List', (_root, tools) => tools.toolMemoryList(_root));
}
async function memoryAppendCommand(context: vscode.ExtensionContext): Promise<void> {
await runAuthoringCommand(context, 'Memory Append', async (root, tools) => {
const file = await promptRequired('Bindery: Append Memory', 'Memory file', 'global.md');
if (!file) { return undefined; }
const title = await promptRequired('Bindery: Append Memory', 'Session title');
if (!title) { return undefined; }
const content = await promptRequired('Bindery: Append Memory', 'Memory content');
return content ? tools.toolMemoryAppend(root, { file, title, content }) : undefined;
});
}
async function memoryCompactCommand(context: vscode.ExtensionContext): Promise<void> {
await runAuthoringCommand(context, 'Memory Compact', async (root, tools) => {
const file = await promptRequired('Bindery: Compact Memory', 'Memory file', 'global.md');
if (!file) { return undefined; }
const compactedContent = await promptRequired('Bindery: Compact Memory', 'Compacted content');
return compactedContent ? tools.toolMemoryCompact(root, { file, compacted_content: compactedContent }) : undefined;
});
}
async function chapterStatusGetCommand(context: vscode.ExtensionContext): Promise<void> {
await runAuthoringCommand(context, 'Chapter Status', (_root, tools) => tools.toolChapterStatusGet(_root));
}
async function chapterStatusUpdateCommand(context: vscode.ExtensionContext): Promise<void> {
await runAuthoringCommand(context, 'Update Chapter Status', async (root, tools) => {
const entry = await promptChapterStatusEntry('Bindery: Update Chapter Status');
return entry ? tools.toolChapterStatusUpdate(root, { chapters: [entry] }) : undefined;
});
}
// ─── Activation ───────────────────────────────────────────────────────────────
export function activate(context: vscode.ExtensionContext) {
@ -1346,6 +1637,23 @@ export function activate(context: vscode.ExtensionContext) {
vscode.commands.registerCommand('bindery.openTranslations', openTranslationsCommand),
vscode.commands.registerCommand('bindery.startReviewMarker', startReviewMarkerCommand),
vscode.commands.registerCommand('bindery.stopReviewMarker', stopReviewMarkerCommand),
vscode.commands.registerCommand('bindery.noteList', () => noteListCommand(context)),
vscode.commands.registerCommand('bindery.noteGet', () => noteGetCommand(context)),
vscode.commands.registerCommand('bindery.noteCreate', () => noteCreateCommand(context)),
vscode.commands.registerCommand('bindery.noteAppend', () => noteAppendCommand(context)),
vscode.commands.registerCommand('bindery.characterList', () => characterListCommand(context)),
vscode.commands.registerCommand('bindery.characterGet', () => characterGetCommand(context)),
vscode.commands.registerCommand('bindery.characterCreate', () => characterCreateCommand(context)),
vscode.commands.registerCommand('bindery.characterUpdate', () => characterUpdateCommand(context)),
vscode.commands.registerCommand('bindery.arcList', () => arcListCommand(context)),
vscode.commands.registerCommand('bindery.arcGet', () => arcGetCommand(context)),
vscode.commands.registerCommand('bindery.arcCreate', () => arcCreateCommand(context)),
vscode.commands.registerCommand('bindery.arcUpdate', () => arcUpdateCommand(context)),
vscode.commands.registerCommand('bindery.memoryList', () => memoryListCommand(context)),
vscode.commands.registerCommand('bindery.memoryAppend', () => memoryAppendCommand(context)),
vscode.commands.registerCommand('bindery.memoryCompact', () => memoryCompactCommand(context)),
vscode.commands.registerCommand('bindery.chapterStatusGet', () => chapterStatusGetCommand(context)),
vscode.commands.registerCommand('bindery.chapterStatusUpdate', () => chapterStatusUpdateCommand(context)),
vscode.commands.registerCommand('bindery.registerMcp', () => registerMcpCommand(context)),
);

View file

@ -19,6 +19,46 @@ interface GetChapterInput { chapterNumber: number; language: string }
interface GetBookUntilInput { chapterNumber: number; language: string; startChapter?: number }
interface GetOverviewInput { language?: string; act?: number }
interface GetNotesInput { category?: string; name?: string }
interface NoteListInput { category?: string }
interface NoteGetInput { path: string }
interface NoteCreateInput { path: string; title?: string; content?: string; overwrite?: boolean }
interface NoteAppendInput { path: string; content: string; heading?: string }
interface CharacterListInput { name?: string }
interface CharacterGetInput { name: string }
interface CharacterWriteInput {
name: string;
role?: string;
age?: string;
origin?: string;
skills?: string;
strengths?: string;
weaknesses?: string;
personality?: string;
background?: string;
narrativeArc?: string;
appearanceNotes?: string;
relationships?: string;
firstAppearance?: string;
openQuestions?: string;
continuityNotes?: string;
indexNotes?: string;
overwrite?: boolean;
}
interface ArcListInput { kind?: string }
interface ArcGetInput { path: string }
interface ArcWriteInput {
path: string;
title?: string;
kind?: string;
purpose?: string;
majorBeats?: string;
characterMovement?: string;
worldImplications?: string;
unresolvedQuestions?: string;
continuityRisks?: string;
linkedChapters?: string;
overwrite?: boolean;
}
interface SearchInput { query: string; language?: string; maxResults?: number; mode?: 'lexical' | 'semantic_rerank' | 'full_semantic' }
interface FormatInput { filePath?: string; dryRun?: boolean; noRecurse?: boolean }
interface GetReviewTextInput { language?: string; contextLines?: number; autoStage?: boolean }
@ -45,6 +85,18 @@ interface McpTools {
toolGetBookUntil: (_root: string, _args: GetBookUntilInput) => string;
toolGetOverview: (_root: string, _args: GetOverviewInput) => string;
toolGetNotes: (_root: string, _args: GetNotesInput) => string;
toolNoteList: (_root: string, _args: NoteListInput) => string;
toolNoteGet: (_root: string, _args: NoteGetInput) => string;
toolNoteCreate: (_root: string, _args: NoteCreateInput) => string;
toolNoteAppend: (_root: string, _args: NoteAppendInput) => string;
toolCharacterList: (_root: string, _args: CharacterListInput) => string;
toolCharacterGet: (_root: string, _args: CharacterGetInput) => string;
toolCharacterCreate: (_root: string, _args: CharacterWriteInput) => string;
toolCharacterUpdate: (_root: string, _args: CharacterWriteInput) => string;
toolArcList: (_root: string, _args: ArcListInput) => string;
toolArcGet: (_root: string, _args: ArcGetInput) => string;
toolArcCreate: (_root: string, _args: ArcWriteInput) => string;
toolArcUpdate: (_root: string, _args: ArcWriteInput) => string;
toolSearch: (_root: string, _args: SearchInput) => Promise<string>;
toolFormat: (_root: string, _args: FormatInput) => string;
toolGetReviewText: (_root: string, _args: GetReviewTextInput) => string;
@ -136,6 +188,54 @@ export function registerLmTools(context: vscode.ExtensionContext): void {
invoke: (opts, _token) => ok(t.toolGetNotes(requireRoot(), opts.input)),
}),
vscode.lm.registerTool<NoteListInput>('bindery_note_list', {
invoke: (opts, _token) => ok(t.toolNoteList(requireRoot(), opts.input)),
}),
vscode.lm.registerTool<NoteGetInput>('bindery_note_get', {
invoke: (opts, _token) => ok(t.toolNoteGet(requireRoot(), opts.input)),
}),
vscode.lm.registerTool<NoteCreateInput>('bindery_note_create', {
invoke: (opts, _token) => ok(t.toolNoteCreate(requireRoot(), opts.input)),
}),
vscode.lm.registerTool<NoteAppendInput>('bindery_note_append', {
invoke: (opts, _token) => ok(t.toolNoteAppend(requireRoot(), opts.input)),
}),
vscode.lm.registerTool<CharacterListInput>('bindery_character_list', {
invoke: (opts, _token) => ok(t.toolCharacterList(requireRoot(), opts.input)),
}),
vscode.lm.registerTool<CharacterGetInput>('bindery_character_get', {
invoke: (opts, _token) => ok(t.toolCharacterGet(requireRoot(), opts.input)),
}),
vscode.lm.registerTool<CharacterWriteInput>('bindery_character_create', {
invoke: (opts, _token) => ok(t.toolCharacterCreate(requireRoot(), opts.input)),
}),
vscode.lm.registerTool<CharacterWriteInput>('bindery_character_update', {
invoke: (opts, _token) => ok(t.toolCharacterUpdate(requireRoot(), opts.input)),
}),
vscode.lm.registerTool<ArcListInput>('bindery_arc_list', {
invoke: (opts, _token) => ok(t.toolArcList(requireRoot(), opts.input)),
}),
vscode.lm.registerTool<ArcGetInput>('bindery_arc_get', {
invoke: (opts, _token) => ok(t.toolArcGet(requireRoot(), opts.input)),
}),
vscode.lm.registerTool<ArcWriteInput>('bindery_arc_create', {
invoke: (opts, _token) => ok(t.toolArcCreate(requireRoot(), opts.input)),
}),
vscode.lm.registerTool<ArcWriteInput>('bindery_arc_update', {
invoke: (opts, _token) => ok(t.toolArcUpdate(requireRoot(), opts.input)),
}),
vscode.lm.registerTool<SearchInput>('bindery_search', {
invoke: async (opts, _token) => ok(await t.toolSearch(requireRoot(), opts.input)),
}),