plotcoder-board 0.1.38 → 0.1.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,7 +43,7 @@ Every board verb goes through one command kernel, `src/board/reducer.js`, and th
43
43
 
44
44
  - **The wall.** Tap the words to type, drag the paper to move. Lasso to select, then Group. Drag a card's handle onto another card for an arrow. ⌘Z takes back any change, whichever door made it.
45
45
  - **`window.plotcoder`** on the page, for a console or a CDP session.
46
- - **The MCP server**, published to npm as `plotcoder-board`: `npx -y plotcoder-board@latest` is the server, `npx -y plotcoder-board@latest call <tool> '{json}'` one call from a shell, `npx -y plotcoder-board@latest serve` the hosted door on a port (a `Dockerfile` is here too). Inside the repo it is `scripts/plotcoder-mcp.mjs`, wired for Cursor in `.cursor/mcp.json` and for Claude Code in `.mcp.json` (run `npm ci` once first). A version tag (`v0.2.0`) publishes it, with `NPM_TOKEN` in the repo's secrets. Eighty-five tools: reading (`list_board`, `read_wall`, `read_pages`, `read_character`, `page_count`, `list_words`, `list_workflows`, `segment_brief`, `compare_structure`); the card, cast, place and when (`set_when`), group (with `add_to_group`) and arrow verbs, `set_logline`, `set_target`, `set_rank`, `set_open` (a card the writer has not decided), `create_thread`, `update_thread` and `delete_thread` (a named string through cards, either end open until tied), `set_plant` (with `later` and `at`) and `set_payoff`, `write_scene`, `edit_scene`, `move_scene`, `organize`, `apply_template` with `list_structures`, `save_structure`, `remove_structure`; `leave_question` and `ask_again`; `undo` and `redo`; the project's `list_boards`, `open_board`, `new_board`, `rename_board`, `delete_board`, `set_premise`, `rename_project` and the reminders; Fountain and Final Draft both ways, Markdown and plain text out (`export_markdown`, `export_text`); the production half (`lock_numbers`, `unlock_numbers`, `start_revision`, `end_revision`); the project as a file both ways (`export_project`, `import_project`); and, through the account door, `list_projects`, `open_project`, `new_project`, `delete_project`, `empty_account`, `delete_account`, `add_picture`, `add_take`, `list_takes`, `list_files`, `remove_file`, `build_segment`. If the dev app is open, a tool call lands on the wall within a second; if not, it edits the board file and the wall catches up on the next load.
46
+ - **The MCP server**, published to npm as `plotcoder-board`: `npx -y plotcoder-board@latest` is the server, `npx -y plotcoder-board@latest call <tool> '{json}'` one call from a shell, `npx -y plotcoder-board@latest serve` the hosted door on a port (a `Dockerfile` is here too). Inside the repo it is `scripts/plotcoder-mcp.mjs`, wired for Cursor in `.cursor/mcp.json` and for Claude Code in `.mcp.json` (run `npm ci` once first). A version tag (`v0.2.0`) publishes it, with `NPM_TOKEN` in the repo's secrets. Ninety tools: reading (`list_board`, `read_wall`, `read_project`, `read_pages`, `read_character`, `page_count`, `list_words`, `list_workflows`, `segment_brief`, `compare_structure`); the card, cast, place and when (`set_when`), group (with `add_to_group`) and arrow verbs, `set_logline`, `set_target`, `set_rank`, `set_open` (a card the writer has not decided), `create_thread`, `update_thread` and `delete_thread` (a named string through cards, either end open until tied), `set_plant` (with `later`, `at` and `what`) and `set_payoff`, `set_alternative` and `choose_version` (two versions of one scene, one chosen), `write_scene`, `edit_scene`, `move_scene`, `organize`, `apply_template` with `list_structures`, `save_structure`, `remove_structure`; `leave_question` and `ask_again`; `undo` and `redo`; the project's `list_boards`, `open_board`, `new_board`, `rename_board`, `delete_board`, `set_premise`, `rename_project` and the reminders; Fountain and Final Draft both ways, Markdown and plain text out (`export_markdown`, `export_text`); the production half (`lock_numbers`, `unlock_numbers`, `start_revision`, `end_revision`); the project as a file both ways (`export_project`, `import_project`); and, through the account door, `list_projects`, `open_project`, `new_project`, `delete_project`, `empty_account`, `delete_account`, `add_picture`, `add_take`, `list_takes`, `list_files`, `remove_file`, `build_segment`. If the dev app is open, a tool call lands on the wall within a second; if not, it edits the board file and the wall catches up on the next load.
47
47
  - **The account door.** With `PLOTCODER_EMAIL` and `PLOTCODER_PASSWORD` in the agent's environment — the writer's own — and no dev app answering, the same server works the writer's project on the account directly, and every change lands live on every open wall. `PLOTCODER_PROJECT` picks a project by name or id. No account yet? `claim_account` makes one with the writer's email and a password they chose. The on-ramp — the doors, what to call first, the rules — is in the app behind *Are you an agent? Start here* and served at [plotcoder.com/llms.txt](https://plotcoder.com/llms.txt), both from `src/board/agents.js`.
48
48
 
49
49
  An agent should call the tools, never fake mouse drags. The skill in `.cursor/skills/plotcoder-board/SKILL.md` says how; `.claude/skills/plotcoder-board` is a symlink to the same file.
@@ -107,4 +107,4 @@ next round into a test of `claim_account` instead of the door it meant to test.
107
107
 
108
108
  ## Status
109
109
 
110
- Version 0.1.38. A project of boards; sign in with your email and a password from the PlotCoder mark and your projects follow you to every device, share one with another writer by email and write it together live, or stay signed out and work on this device as before. Pages sit beside the wall: a scene's text lives on its card, measures it, paginates to the industry's rules, prints, goes out and comes in as Fountain or Final Draft, and goes out as Markdown or plain text for a collaborator in Google Docs. It installs as a progressive web app and opens offline; plotcoder.com serves over HTTPS. The wall, beats, card length, groups, arrows, pan and zoom, save and open, and the agent surface are in use.
110
+ Version 0.1.39. A project of boards; sign in with your email and a password from the PlotCoder mark and your projects follow you to every device, share one with another writer by email and write it together live, or stay signed out and work on this device as before. Pages sit beside the wall: a scene's text lives on its card, measures it, paginates to the industry's rules, prints, goes out and comes in as Fountain or Final Draft, and goes out as Markdown or plain text for a collaborator in Google Docs. It installs as a progressive web app and opens offline; plotcoder.com serves over HTTPS. The wall, beats, card length, groups, arrows, pan and zoom, save and open, and the agent surface are in use.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plotcoder-board",
3
- "version": "0.1.38",
3
+ "version": "0.1.39",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite",
@@ -32,6 +32,7 @@ import {
32
32
  NOTE_HEIGHT,
33
33
  NOTE_WIDTH,
34
34
  DEFAULT_TARGET_EIGHTHS,
35
+ DEFAULT_NOTE_EIGHTHS,
35
36
  normalizeState,
36
37
  newId,
37
38
  noteEighths,
@@ -43,6 +44,7 @@ import { TEMPLATES } from "../src/board/templates.js";
43
44
  import { wordSentence, wordsAsText } from "../src/board/words.js";
44
45
  import { fromFountain, mergeFountain, toFountain } from "../src/board/fountain.js";
45
46
  import { toMarkdown, toPlainText } from "../src/board/markdown.js";
47
+ import { cameraLines, cameraVerbs } from "../src/board/camera.js";
46
48
  import { fromProjectFile, toProjectFile } from "../src/board/projectFile.js";
47
49
  import { describeSetAside, fromFdx, toFdx } from "../src/board/fdx.js";
48
50
  import { paginate } from "../src/board/paginate.js";
@@ -806,6 +808,8 @@ const undone = [];
806
808
  /** The last read_wall's questions, and what changed since: a leave answers the reading in front of the agent (round thirteen, entry 19). */
807
809
  let lastReading = null;
808
810
  const sinceRead = [];
811
+ /** Whether this session has read a wall yet: until it has, a write's tail counts the questions and points at read_wall; after, it quotes them (the handover's call 2). */
812
+ let readOnce = false;
809
813
  /** What the last write did to the wall's questions and runtime, said once on that write's tail (round fourteen, entries 18, 19, 42). */
810
814
  let lastChange = null;
811
815
  // The same question in the same words is the same question, whatever order its names come in (round seventeen, entry 21).
@@ -833,7 +837,13 @@ function changeNote() {
833
837
  lastChange = null;
834
838
  if (!change) return "";
835
839
  const parts = [];
836
- if (change.gone.length || change.came.length) {
840
+ // Before the session's first reading a tail counts and points: a build is
841
+ // twelve writes whose quoted questions the next write answers (round
842
+ // fifteen 9, eighteen 20). After it, a change is to something read, and
843
+ // the tail quotes it.
844
+ if (!readOnce && (change.gone.length || change.came.length)) {
845
+ parts.push(`the wall's questions have changed since your last read_wall: ${change.asks} now${change.came.length ? `, ${change.came.length} of them new` : ""} — read_wall lists them`);
846
+ } else if (change.gone.length || change.came.length) {
837
847
  parts.push(
838
848
  `the wall now asks ${change.asks} question${change.asks === 1 ? "" : "s"}${change.gone.length ? ` (gone: ${change.gone.map((finding) => `[${finding.kind}] ${finding.text.replace(/\.$/, "")}`).join(" ")})` : ""}${change.came.length ? ` (new: ${change.came.map((finding) => `[${finding.kind}] ${finding.text.replace(/\.$/, "")}`).join(" ")})` : ""}`,
839
849
  );
@@ -964,8 +974,27 @@ function workingProject(id, name, count) {
964
974
  }
965
975
 
966
976
  /** Where a change landed, for the tail of a tool's reply. */
977
+ /** Who has a wall of the working project open on a screen now, from the presence the server already follows (the handover's call 4); never the agent itself. */
978
+ function presentPeople() {
979
+ try {
980
+ const state = accountDoor?.channel?.presenceState?.() ?? {};
981
+ const names = new Set();
982
+ for (const entries of Object.values(state)) for (const entry of entries ?? []) if (entry && typeof entry.name === "string" && !/^an agent, as /.test(entry.name)) names.add(entry.name);
983
+ return [...names];
984
+ } catch {
985
+ return [];
986
+ }
987
+ }
988
+
989
+ /** The account tail says what the wall shows: open on whose screen, or no wall open, never a bare "live". */
990
+ function accountTail() {
991
+ const people = presentPeople();
992
+ if (!people.length) return " (saved to the account; no wall open right now — it shows the moment one opens)";
993
+ return ` (saved to the account; open on ${people.length === 1 ? `${people[0]}'s screen` : `${people.length} screens: ${people.join(", ")}`} now)`;
994
+ }
995
+
967
996
  function where(live) {
968
- const tail = live === ACCOUNT ? " (saved to the account; live on every open wall)" : live ? " (visible on the open board)" : " (written to file; the wall shows it the next time the app runs from this folder)";
997
+ const tail = live === ACCOUNT ? accountTail() : live ? " (visible on the open board)" : " (written to file; the wall shows it the next time the app runs from this folder)";
969
998
  return `${tail}${changeNote()}`;
970
999
  }
971
1000
 
@@ -1038,6 +1067,25 @@ function stillOpen(notes) {
1038
1067
  return ` ${open.length === 1 ? `"${open[0].headline}" is still open (${open[0].open})` : `${open.length} of them are still open`}: the words stay until set_open "" clears them, and the wall asks nothing else of ${open.length === 1 ? "it" : "them"} until then.`;
1039
1068
  }
1040
1069
 
1070
+ /** "20 under", "3 over" or "on it", against a target in eighths. */
1071
+ function againstWord(state, targetEighths) {
1072
+ const total = boardEighths(state);
1073
+ return total > targetEighths ? `${formatPages(total - targetEighths)} over` : total < targetEighths ? `${formatPages(targetEighths - total)} under` : "on it";
1074
+ }
1075
+
1076
+ /** The written scenes measured under the page they were read as (the handover's call 7, four rounds): a sketch is counted as measured and named. */
1077
+ function sketches(state) {
1078
+ return state.notes.filter((note) => isMeasured(note) && noteEighths(note) < (note.lengthEighths ?? DEFAULT_NOTE_EIGHTHS));
1079
+ }
1080
+
1081
+ /** The runtime's second number while a sketch stands: what the cards would come to if each sketch ran to the page it was read as. */
1082
+ function sketchLine(state) {
1083
+ const found = sketches(state);
1084
+ if (!found.length) return "";
1085
+ const ifRan = boardEighths(state) + found.reduce((sum, note) => sum + ((note.lengthEighths ?? DEFAULT_NOTE_EIGHTHS) - noteEighths(note)), 0);
1086
+ return `; ${found.length} written scene${found.length === 1 ? " is a sketch" : "s are sketches"}, measured under the page ${found.length === 1 ? "it was" : "they were"} read as: about ${formatPages(ifRan)} pages if ${found.length === 1 ? "it" : "they"} ran to that`;
1087
+ }
1088
+
1041
1089
  /** What kinds of number a runtime folds together: measured from text, set by the writer, or the default page (round fifteen, entry 39). */
1042
1090
  function runtimeKinds(state) {
1043
1091
  const measured = state.notes.filter((note) => isMeasured(note));
@@ -1072,7 +1120,8 @@ function summarize(state) {
1072
1120
  const count = formatPages(noteEighths(note));
1073
1121
  // A written card's estimate is kept underneath for when the text goes; say it, or it is invisible (round sixteen, entry 44).
1074
1122
  const underneath = isMeasured(note) && note.lengthEighths !== null ? `; the writer's estimate underneath: ${formatPages(note.lengthEighths)}` : "";
1075
- const pages = isMeasured(note) ? `${count} ${count === "1" ? "page" : "pages"}, written${underneath}` : note.lengthEighths === null ? "about a page, unsized" : `${count} ${count === "1" ? "page" : "pages"}`;
1123
+ const sketch = isMeasured(note) && noteEighths(note) < (note.lengthEighths ?? DEFAULT_NOTE_EIGHTHS) ? " (a sketch: under the page it was read as)" : "";
1124
+ const pages = isMeasured(note) ? `${count} ${count === "1" ? "page" : "pages"}, written${sketch}${underneath}` : note.lengthEighths === null ? "about a page, unsized" : `${count} ${count === "1" ? "page" : "pages"}`;
1076
1125
  return ` - ${note.id} [${note.rank ?? "scene"}, ${pages}${who}${place}${when}${openWord}${plant}${pays}${revised}] — "${note.headline}" (${note.color}) at ${Math.round(note.x)},${Math.round(note.y)}`;
1077
1126
  })
1078
1127
  .join("\n");
@@ -1142,12 +1191,17 @@ function summarize(state) {
1142
1191
  ...production,
1143
1192
  `left, for now: ${leftCount ? `${leftCount} question(s) the writer left; read_wall lists them` : "none"}`,
1144
1193
  `beats: ${beats}, scenes: ${scenes}`,
1145
- state.targetEighths === DEFAULT_TARGET_EIGHTHS
1146
- ? `runtime: about ${formatPages(runtime)} pages (an estimate from the cards; a page runs about a minute); no target set set_target for a pilot (60) or a half-hour (30); against the feature default of 120 it would be ${formatPages(-over)} under${runtimeKinds(state)}`
1147
- : `runtime: about ${formatPages(runtime)} pages of the ${formatPages(state.targetEighths)}-page target the writer set (set_target changes it) — ${over > 0 ? `${formatPages(over)} over` : over < 0 ? `${formatPages(-over)} under` : "on it"} (an estimate from the cards; a page runs about a minute; page_count is the script so far)${runtimeKinds(state)}`,
1194
+ state.targetOpen
1195
+ ? `runtime: about ${formatPages(runtime)} pages (an estimate from the cards; a page runs about a minute); target open, by the writer's word "${state.targetOpen}" (against 30 it would be ${againstWord(state, 30 * EIGHTHS_PER_PAGE)}; against 120, ${againstWord(state, 120 * EIGHTHS_PER_PAGE)}; set_target decides it)${runtimeKinds(state)}${sketchLine(state)}`
1196
+ : state.targetEighths === DEFAULT_TARGET_EIGHTHS
1197
+ ? `runtime: about ${formatPages(runtime)} pages (an estimate from the cards; a page runs about a minute); no target set — set_target for a pilot (60) or a half-hour (30); against the feature default of 120 it would be ${formatPages(-over)} under${runtimeKinds(state)}${sketchLine(state)}`
1198
+ : `runtime: about ${formatPages(runtime)} pages of the ${formatPages(state.targetEighths)}-page target the writer set (set_target changes it) — ${over > 0 ? `${formatPages(over)} over` : over < 0 ? `${formatPages(-over)} under` : "on it"} (an estimate from the cards; a page runs about a minute; page_count is the script so far)${runtimeKinds(state)}${sketchLine(state)}`,
1148
1199
  `notes: ${state.notes.length}, groups: ${state.groups.length}, arrows: ${state.arrows.length}, cast: ${state.characters.length}`,
1149
1200
  "cards (in story order — the follows arrows over the rows; each with its id):",
1150
1201
  notes || " (no cards)",
1202
+ ...(state.notes.some((note) => note.alternativeOf)
1203
+ ? ["versions, not chosen (behind their front cards; out of the order, the count and the pages; choose_version decides):", ...state.notes.filter((note) => note.alternativeOf).map((note) => ` - ${note.id} — "${note.headline}", a version of "${state.notes.find((item) => item.id === note.alternativeOf)?.headline ?? note.alternativeOf}"`)]
1204
+ : []),
1151
1205
  "rows on the wall (top to bottom, left to right; ★ a beat):",
1152
1206
  ...(rowLines.length ? rowLines : [" (no cards)"]),
1153
1207
  "cast (the project's; every board of it casts from here; read_character reads a person's whole page, update_character writes it):",
@@ -1316,15 +1370,20 @@ server.registerTool(
1316
1370
  {
1317
1371
  title: "Set target length",
1318
1372
  description:
1319
- "Set the board's target script length, in pages or in minutes (a page runs about a minute): 120 for a feature, 30 for a half-hour, 60 for an hour drama. This is what the runtime estimate is measured against.",
1320
- inputSchema: { pages: pagesSchema.optional(), minutes: z.number().positive().optional() },
1373
+ "Set the board's target script length, in pages or in minutes (a page runs about a minute): 120 for a feature, 30 for a half-hour, 60 for an hour drama. This is what the runtime estimate is measured against. Or leave the target open: pass open with the writer's words for why it is not decided — \"half-hour or feature\" — and the reading lists it under open, by the writer's word, and reads the runtime against both defaults while the words stand; a number decides it, open \"\" takes the words back.",
1374
+ inputSchema: { pages: pagesSchema.optional(), minutes: z.number().positive().optional(), open: z.string().optional() },
1321
1375
  },
1322
1376
  async (args) => {
1323
- if (args.pages === undefined && args.minutes === undefined) return ok("Say the target in pages or in minutes.");
1324
- const { state, live } = await commit({
1377
+ if (args.pages === undefined && args.minutes === undefined && args.open === undefined) return ok("Say the target in pages or in minutes, or open with the writer's words for why it is not decided.");
1378
+ const { state, changed, live } = await commit({
1325
1379
  type: "set_target",
1326
- targetEighths: toEighths(args.pages ?? args.minutes),
1380
+ ...(args.pages !== undefined || args.minutes !== undefined ? { targetEighths: toEighths(args.pages ?? args.minutes) } : {}),
1381
+ ...(args.open !== undefined ? { open: args.open } : {}),
1327
1382
  });
1383
+ if (!changed) return ok("Target unchanged: it already read that way.");
1384
+ if (state.targetOpen) {
1385
+ return ok(`Target left open, by the writer's word: "${state.targetOpen}"${where(live)}. The reading lists it and reads the cards against a half-hour and a feature meanwhile: about ${formatPages(boardEighths(state))} pages — against 30, ${againstWord(state, 30 * EIGHTHS_PER_PAGE)}; against 120, ${againstWord(state, 120 * EIGHTHS_PER_PAGE)}. set_target with pages or minutes decides it.`, { targetEighths: state.targetEighths, targetOpen: state.targetOpen });
1386
+ }
1328
1387
  return ok(
1329
1388
  `Target is ${formatPages(state.targetEighths)} pages${where(live)}. The cards add up to about ${formatPages(boardEighths(state))} — ${boardEighths(state) > state.targetEighths ? `${formatPages(boardEighths(state) - state.targetEighths)} over` : `${formatPages(state.targetEighths - boardEighths(state))} under`}.`,
1330
1389
  { targetEighths: state.targetEighths },
@@ -1567,6 +1626,7 @@ server.registerTool(
1567
1626
  const reading = readWall(state, { elsewhere: Object.keys(elsewhereForRead), laterBoards: laterBoards(projectForRead, boardsNow), paidBy: landingsOn(projectForRead, boardsNow, readId).paid });
1568
1627
  lastReading = { findings: reading.findings };
1569
1628
  sinceRead.length = 0;
1629
+ readOnce = true;
1570
1630
  const runs = describeRuns(reading, state).map((line, index) => {
1571
1631
  const ids = reading.runs[index]?.ids ?? [];
1572
1632
  return ids.length ? `${line} — ${ids.map((id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`).join(", ")}` : line;
@@ -1582,6 +1642,7 @@ server.registerTool(
1582
1642
  // Fields left open by the writer's word (R61): the logline and the whens are the reading's; the premise and the board's name are the project's.
1583
1643
  const openFieldLines = [
1584
1644
  ...(projectForRead.nameOpen ? [` - the project's name — ${projectForRead.nameOpen}`] : []),
1645
+ ...(state.targetOpen ? [` - the target — ${state.targetOpen}`] : []),
1585
1646
  ...reading.openFields.filter((field) => field.field === "logline").map((field) => ` - the logline — ${field.words}`),
1586
1647
  ...(projectForRead.premiseOpen ? [` - the premise — ${projectForRead.premiseOpen}`] : []),
1587
1648
  ...(readBoardMeta?.nameOpen ? [` - this board's name — ${readBoardMeta.nameOpen}`] : []),
@@ -1610,9 +1671,11 @@ server.registerTool(
1610
1671
  ...(projectForRead.premiseOpen ? [`premise: open, by the writer's word — "${projectForRead.premiseOpen}"`] : []),
1611
1672
  `logline: ${state.loglineOpen ? `open, by the writer's word — "${state.loglineOpen}"` : state.logline ? `"${state.logline}"` : "(none yet)"}`,
1612
1673
  "the cast and the places are list_board's, not the reading's",
1613
- state.targetEighths === DEFAULT_TARGET_EIGHTHS
1614
- ? `runtime: about ${formatPages(boardEighths(state))} pages (${whose || "no cards"}); no target set (set_target)`
1615
- : `runtime: about ${formatPages(boardEighths(state))} pages of the ${formatPages(state.targetEighths)}-page target the writer set (set_target changes it) — ${boardEighths(state) > state.targetEighths ? `${formatPages(boardEighths(state) - state.targetEighths)} over` : boardEighths(state) < state.targetEighths ? `${formatPages(state.targetEighths - boardEighths(state))} under` : "on it"} (${whose || "no cards"}; page_count is the script so far)`,
1674
+ state.targetOpen
1675
+ ? `runtime: about ${formatPages(boardEighths(state))} pages (${whose || "no cards"}); target open, by the writer's word — "${state.targetOpen}" (against 30 it would be ${againstWord(state, 30 * EIGHTHS_PER_PAGE)}; against 120, ${againstWord(state, 120 * EIGHTHS_PER_PAGE)})${sketchLine(state)}`
1676
+ : state.targetEighths === DEFAULT_TARGET_EIGHTHS
1677
+ ? `runtime: about ${formatPages(boardEighths(state))} pages (${whose || "no cards"}); no target set (set_target)${sketchLine(state)}`
1678
+ : `runtime: about ${formatPages(boardEighths(state))} pages of the ${formatPages(state.targetEighths)}-page target the writer set (set_target changes it) — ${boardEighths(state) > state.targetEighths ? `${formatPages(boardEighths(state) - state.targetEighths)} over` : boardEighths(state) < state.targetEighths ? `${formatPages(state.targetEighths - boardEighths(state))} under` : "on it"} (${whose || "no cards"}; page_count is the script so far)${sketchLine(state)}`,
1616
1679
  `groups: ${
1617
1680
  state.groups.length
1618
1681
  ? state.groups
@@ -1643,6 +1706,9 @@ server.registerTool(
1643
1706
  ...(reading.open.length || openFieldLines.length
1644
1707
  ? ["open, by the writer's word (listed, not asked about while the words stand; set_open with \"\" closes a card, the field's own tool with open \"\" a field):", ...openFieldLines, ...reading.open.map((item) => ` - "${state.notes.find((note) => note.id === item.id)?.headline ?? item.id}" — ${item.words}${item.hides.length ? ` (closed, it would be asked ${item.hides.map((kind) => ASK_WORDS[kind] ?? CHECK_WORDS[kind] ?? kind).join("; ")})` : ""}`)]
1645
1708
  : []),
1709
+ ...(reading.versions.length
1710
+ ? ["two versions, not chosen (the front card is in the story; choose_version decides):", ...reading.versions.map((pair) => ` - "${state.notes.find((note) => note.id === pair.id)?.headline ?? pair.id}" or ${pair.alternatives.map((id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`).join(" or ")}`)]
1711
+ : []),
1646
1712
  ...(reading.threads.length
1647
1713
  ? ["threads (the writer's strings through the story; a loose end is asked about below):", ...reading.threads.map((thread) => ` - "${thread.name}": ${thread.ids.length ? thread.ids.map((id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`).join(" → ") : "no card yet"}${thread.startOpen ? " — starts nowhere yet" : ""}${thread.endOpen ? " — ends nowhere yet" : ""}${!thread.startOpen && !thread.endOpen && thread.ids.length >= 2 ? ` — both ends tied, about ${formatPages(thread.apart)} pages apart` : ""}`)]
1648
1714
  : []),
@@ -2223,7 +2289,7 @@ server.registerTool(
2223
2289
  }
2224
2290
  const printed = sceneLineCount(args.text);
2225
2291
  return ok(
2226
- `Wrote "${result.headline}": ${printed} line(s) as they print (headings, blank lines and wrapped dialogue counted), measured at ${formatPages(noteEighths(result))} of a 55-line page, rounded to the nearest eighth and never below one eighth${where(live)}.${revisionMark(state, result.id)}${once("heading-from-place", " The heading comes from the card's place and when, so the text starts with the action.")} While the text stands the wall reads the measure, not the estimate${(() => {
2292
+ `Wrote "${result.headline}": ${printed} line(s) as they print (headings, blank lines and wrapped dialogue counted), measured at ${formatPages(noteEighths(result))} of a 55-line page, rounded to the nearest eighth and never below one eighth${noteEighths(result) < (result.lengthEighths ?? DEFAULT_NOTE_EIGHTHS) ? " — a sketch: shorter than the page it was read as; the wall counts the measure and says so" : ""}${(() => { const found = cameraLines(result.text); return found.length ? ` — ${found.length} line${found.length === 1 ? "" : "s"} the camera cannot see (${cameraVerbs(found).join(", ")}): the reminder "Write for the camera" is the house's; show it or cut it, on the writer's word` : ""; })()}${where(live)}.${revisionMark(state, result.id)}${once("heading-from-place", " The heading comes from the card's place and when, so the text starts with the action.")} While the text stands the wall reads the measure, not the estimate${(() => {
2227
2293
  // How far the measure sits from what the card was read as before (round eighteen, entry 43): the writer's estimate, or the page an unsized card is read as.
2228
2294
  const before = result.lengthEighths !== null ? result.lengthEighths : 8;
2229
2295
  const label = result.lengthEighths !== null ? `the writer's ${formatPages(result.lengthEighths)} pages` : "the page an unsized card is read as";
@@ -2290,6 +2356,7 @@ server.registerTool(
2290
2356
  let index = 0;
2291
2357
  // The changed lines of the scene being printed, starred in the right margin as plain text stars them (round sixteen, entry 32).
2292
2358
  let changedTexts = new Set();
2359
+ let cameraByText = new Map();
2293
2360
  for (const line of text.split("\n")) {
2294
2361
  if (/^\.(?!\.)/.test(line) && index < ids.length) {
2295
2362
  const note = state.notes.find((item) => item.id === ids[index]);
@@ -2300,9 +2367,14 @@ server.registerTool(
2300
2367
  const sourceLines = (note?.text ?? "").split("\n");
2301
2368
  changedTexts = new Set([...(mark?.lines ?? [])].map((at) => sourceLines[at]).filter((item) => item && item.trim()));
2302
2369
  const revised = mark?.revised ? ` · changed in the ${state.revision.color} revision${changedTexts.size ? ` (${changedTexts.size} line${changedTexts.size === 1 ? "" : "s"} starred below)` : ""}` : "";
2303
- lines.push(`${line} [[id: ${note?.id ?? "?"} · ${note && isMeasured(note) ? "measured" : "estimated"} ${formatPages(note ? noteEighths(note) : 0)}pp${standIn}${numbered}${revised}]]`);
2370
+ // The lines the camera cannot see (the handover's call 6): marked on the page, never asked on the wall.
2371
+ const camera = note ? cameraLines(note.text ?? "") : [];
2372
+ cameraByText = new Map(camera.map((item) => [item.line, item.verbs]));
2373
+ const cameraNote = camera.length ? ` · camera: ${camera.length} line${camera.length === 1 ? "" : "s"} it cannot see (${cameraVerbs(camera).join(", ")})` : "";
2374
+ lines.push(`${line} [[id: ${note?.id ?? "?"} · ${note && isMeasured(note) ? "measured" : "estimated"} ${formatPages(note ? noteEighths(note) : 0)}pp${standIn}${numbered}${revised}${cameraNote}]]`);
2304
2375
  } else {
2305
- lines.push(changedTexts.has(line) ? `${line} *` : line);
2376
+ const verbs = cameraByText.get(line.trim());
2377
+ lines.push(`${line}${changedTexts.has(line) ? " *" : ""}${verbs ? ` ◂ ${verbs.join(", ")}` : ""}`);
2306
2378
  }
2307
2379
  }
2308
2380
  return ok(lines.join("\n"));
@@ -2785,6 +2857,56 @@ server.registerTool(
2785
2857
  // The open card (R59): the writer's word that a card is not decided. The
2786
2858
  // per-card twin of leave_question — that one leaves a question, this one
2787
2859
  // leaves a card.
2860
+ /**
2861
+ * Two versions of one scene (R65, the handover's call 5 on Robert's word):
2862
+ * a card set behind another as its other version leaves the story — the
2863
+ * order, the count, the pages, every export — and waits there; the reading
2864
+ * lists the pair and asks nothing of it until the writer chooses.
2865
+ */
2866
+ server.registerTool(
2867
+ "set_alternative",
2868
+ {
2869
+ title: "Another version of a scene",
2870
+ description:
2871
+ "Set a card behind another as its other version — two endings, two ways a scene could go — by id or headline. The version leaves the story: out of the order, the count, the pages and every export; its follows arrows are dropped (setup arrows stay). The wall draws it tucked behind its sibling; read_wall lists the pair under \"two versions, not chosen\" and asks nothing of it; choose_version decides. of: \"\" takes a card out from behind and it stands as a plain card again. Only on the writer's word: two versions the notes hold, never two the agent could not choose between.",
2872
+ inputSchema: { id: z.string(), of: z.string() },
2873
+ },
2874
+ async (args) => {
2875
+ const { state: current } = await readBoard();
2876
+ const byRef = (ref) => current.notes.find((note) => note.id === ref) ?? current.notes.find((note) => note.headline.trim().toLowerCase() === ref.trim().toLowerCase()) ?? null;
2877
+ const card = byRef(args.id);
2878
+ if (!card) return ok(`No card with id or headline "${args.id}". Call list_board.`);
2879
+ const front = args.of.trim() ? byRef(args.of) : null;
2880
+ if (args.of.trim() && !front) return ok(`No card with id or headline "${args.of}". Call list_board.`);
2881
+ if (front?.alternativeOf) return ok(`"${front.headline}" is itself a version of another card; set the version behind the front card, "${current.notes.find((note) => note.id === front.alternativeOf)?.headline ?? front.alternativeOf}".`);
2882
+ if (front && current.notes.some((note) => note.alternativeOf === card.id)) return ok(`"${card.headline}" has a version behind it already; choose_version there first.`);
2883
+ const { state, changed, result, live } = await commit({ type: "set_alternative", id: card.id, of: front ? front.id : null });
2884
+ if (!changed) return ok(front ? `"${card.headline}" already stands behind "${front.headline}".` : `"${card.headline}" is not a version of another card.`);
2885
+ if (!front) return ok(`"${card.headline}" stands as a plain card again, unwired${where(live)}: create_arrow or move_scene puts it in the order.`, result);
2886
+ return ok(`"${card.headline}" is now the other version of "${front.headline}"${where(live)}: out of the order, the count and the pages${result.arrowsDropped ? `, ${result.arrowsDropped} follows arrow${result.arrowsDropped === 1 ? "" : "s"} dropped` : ""}. The reading lists the pair as two versions, not chosen, and asks nothing of it; choose_version decides. The wall draws it tucked behind its sibling.`, result);
2887
+ },
2888
+ );
2889
+
2890
+ server.registerTool(
2891
+ "choose_version",
2892
+ {
2893
+ title: "Choose a version",
2894
+ description:
2895
+ "Choose one of two versions of a scene, by id or headline: the chosen card is the scene, in the front card's place — its arrows, its rank, its group; the other goes, or with keep true stands beside it as a plain unwired card. Only on the writer's word.",
2896
+ inputSchema: { id: z.string(), keep: z.boolean().optional() },
2897
+ },
2898
+ async (args) => {
2899
+ const { state: current } = await readBoard();
2900
+ const card = current.notes.find((note) => note.id === args.id) ?? current.notes.find((note) => note.headline.trim().toLowerCase() === args.id.trim().toLowerCase());
2901
+ if (!card) return ok(`No card with id or headline "${args.id}". Call list_board.`);
2902
+ const other = card.alternativeOf ? current.notes.find((note) => note.id === card.alternativeOf) : current.notes.find((note) => note.alternativeOf === card.id);
2903
+ if (!other) return ok(`"${card.headline}" has no other version; nothing to choose.`);
2904
+ const { changed, result, live } = await commit({ type: "choose_version", id: card.id, keep: args.keep === true });
2905
+ if (!changed) return ok("Nothing chosen.");
2906
+ return ok(`Chose "${card.headline}"${result.steppedForward ? ` — it steps forward into "${other.headline}"'s place, with its arrows, rank and group` : ""}${where(live)}. "${other.headline}" ${result.kept ? "stands beside it as a plain card, unwired" : "is gone"}.`, result);
2907
+ },
2908
+ );
2909
+
2788
2910
  server.registerTool(
2789
2911
  "set_open",
2790
2912
  {
@@ -3735,7 +3857,7 @@ server.registerTool(
3735
3857
  return ok(
3736
3858
  [
3737
3859
  `projects: ${projects.length} (as ${account.email})${projects.length === 0 ? ` — ${noProjectYet()}` : ""}`,
3738
- ...projects.map((row) => ` - ${row.id} — "${row.record.name}"${row.id === account.projectId ? " (working)" : ""}: ${row.record.boards.length} board(s) · ${(row.people ?? []).join(", ")}`),
3860
+ ...projects.map((row) => ` - ${row.id} — "${row.record.name}"${row.id === account.projectId ? " (working)" : ""}: ${row.record.boards.length} board(s) · ${(row.people ?? []).join(", ")}${row.id === account.projectId ? (() => { const people = presentPeople(); return people.length ? ` · open now on ${people.length} screen${people.length === 1 ? "" : "s"}: ${people.join(", ")}` : " · no wall open right now"; })() : ""}`),
3739
3861
  ].join("\n"),
3740
3862
  projects.map((row) => ({ id: row.id, name: row.record.name, boards: row.record.boards.length, people: row.people })),
3741
3863
  );
@@ -3813,7 +3935,7 @@ server.registerTool(
3813
3935
  title: "Start a project",
3814
3936
  description:
3815
3937
  "Through the account door: start a new project of the writer's with this name — one empty board, nothing on it — and work it from now on. The writer sees it under Projects on every device. A title not decided: open with the writer's words (\"The Allotments, or Plot 14\") instead of a name, and the project starts as Untitled project with those words beside it. board names the first board; boardOpen leaves its name open in the writer's words instead (\"the pilot, or the film\"), so a board born from a maybe is not silently \"Board 1\".",
3816
- inputSchema: { name: z.string().min(1).optional(), open: z.string().optional(), board: z.string().optional(), boardOpen: z.string().optional(), pages: pagesSchema.optional(), minutes: z.number().positive().optional() },
3938
+ inputSchema: { name: z.string().min(1).optional(), open: z.string().optional(), board: z.string().optional(), boardOpen: z.string().optional(), pages: pagesSchema.optional(), minutes: z.number().positive().optional(), targetOpen: z.string().optional().describe("The writer's words for why the length is not decided — \"half-hour or feature\" — so the target is born open instead of the feature default standing unsaid.") },
3817
3939
  },
3818
3940
  async (args) => {
3819
3941
  const account = await findAccount();
@@ -3829,12 +3951,12 @@ server.registerTool(
3829
3951
  const inserted = await account.client.from("projects").insert({ id: record.id, record, reminders: null, rev: 1 });
3830
3952
  if (inserted.error) return ok(`Could not start the project: ${inserted.error.message}`);
3831
3953
  const target = args.pages ?? args.minutes;
3832
- const state = target === undefined ? emptyState() : { ...emptyState(), targetEighths: toEighths(target) };
3954
+ const state = { ...emptyState(), ...(target === undefined ? {} : { targetEighths: toEighths(target) }), ...(args.targetOpen?.trim() && target === undefined ? { targetOpen: args.targetOpen.trim().replace(/\s+/g, " ") } : {}) };
3833
3955
  const board = await account.client.from("boards").insert({ id: record.activeBoardId, project_id: record.id, state, rev: 1, updated_by: null });
3834
3956
  if (board.error) return ok(`Started "${record.name}" but could not make its first board: ${board.error.message}`);
3835
3957
  workingProject(record.id, record.name, (account.projectCount ?? 0) + 1);
3836
3958
  joinPresence(record.id);
3837
- const targetLine = target === undefined ? ` Its target is ${formatPages(state.targetEighths)} pages, the default for a feature; set_target for a pilot or a half-hour, or pass pages or minutes here.` : ` Its target is ${formatPages(state.targetEighths)} pages.`;
3959
+ const targetLine = state.targetOpen ? ` Its target is left open, by the writer's word: "${state.targetOpen}"; the reading reads the cards against a half-hour and a feature until set_target decides it.` : target === undefined ? ` Its target is ${formatPages(state.targetEighths)} pages, the default for a feature; set_target for a pilot or a half-hour, or pass pages or minutes here.` : ` Its target is ${formatPages(state.targetEighths)} pages.`;
3838
3960
  const first = record.boards[0];
3839
3961
  const nameOpenLine = `${record.nameOpen ? ` The project's name is left open, by the writer's word: "${record.nameOpen}"; rename_project decides it.` : ""}${first.nameOpen ? ` The board's name is left open, by the writer's word: "${first.nameOpen}"; rename_board decides it.` : ""}`;
3840
3962
  return ok(`Started "${record.name}" (${record.id}) with its first board "${first.name}" (${first.id}), and working it now, as ${account.email}.${targetLine}${nameOpenLine}${oneCallHint(record)}`, { id: record.id, name: record.name, boardId: first.id, boardName: first.name, boardNameOpen: first.nameOpen ?? "", targetEighths: state.targetEighths });
@@ -23,7 +23,7 @@ export const AGENTS = {
23
23
  {
24
24
  id: "hosted",
25
25
  name: "The hosted door, with nothing installed",
26
- text: "Where someone runs PlotCoder's server for you — npx -y plotcoder-board@latest serve puts it on a port, and the repo has a Dockerfile — an MCP client connects over HTTP with the writer's sign-in on the request: claude mcp add plotcoder --transport http https://<that host>/mcp --header \"Authorization: Basic <base64 of email:password>\", then start the session again. The same server, the same tools, the account as the wall, no disk. PlotCoder does not run a public one yet; the address is the writer's to give.",
26
+ text: "Where someone runs PlotCoder's server for you — npx -y plotcoder-board@latest serve puts it on a port, and the repo has a Dockerfile — an MCP client connects over HTTP with the writer's sign-in on the request: claude mcp add plotcoder --transport http https://<that host>/mcp --header \"Authorization: Basic <base64 of email:password>\", then start the session again. The same server, the same tools, the account as the wall, no disk. PlotCoder runs one, as a Supabase Edge Function: https://kmpahjsggbleygsnuwug.supabase.co/functions/v1/mcp — claude mcp add plotcoder --transport http https://kmpahjsggbleygsnuwug.supabase.co/functions/v1/mcp --header \"Authorization: Basic <base64 of email:password>\", or in the Claude desktop app, Settings › Connectors › add a custom connector with that address and header: the app keeps a connector in its own settings, where a stdio block in a file can vanish. The password rides in the header, base64 over https, as the env block carries it; share a machine and know it.",
27
27
  },
28
28
  {
29
29
  id: "account",
@@ -0,0 +1,4 @@
1
+ // Type surface for camera.js — the lines the camera cannot see.
2
+ export type CameraLine = { at: number; line: string; verbs: string[] };
3
+ export declare function cameraLines(text: string): CameraLine[];
4
+ export declare function cameraVerbs(found: CameraLine[]): string[];
@@ -0,0 +1,52 @@
1
+ // What the camera cannot see (the handover's call 6, round eighteen entry
2
+ // 44): a line of action that says what someone knows, feels, thinks or
3
+ // wants is a fact the page states and the screen cannot show. The house
4
+ // principle "write for the camera and the cut" is a reminder every project
5
+ // starts with; this marks the lines it is about, on the page and in the
6
+ // write's reply, and never asks a question on the wall — a sentence on a
7
+ // page is the writer's. Only action is read: a character cue and the
8
+ // dialogue under it are theirs to say.
9
+
10
+ /** Interior verbs, as they appear in present- and past-tense action. */
11
+ const INTERIOR = [
12
+ "knows", "knew", "feels", "felt", "thinks", "thought", "remembers", "remembered",
13
+ "realises", "realizes", "realised", "realized", "wants", "wanted", "decides", "decided",
14
+ "understands", "understood", "wonders", "wondered", "hopes", "hoped", "believes", "believed",
15
+ "fears", "feared", "regrets", "regretted", "loves", "loved", "hates", "hated",
16
+ ];
17
+ const INTERIOR_RE = new RegExp(`\\b(${INTERIOR.join("|")})\\b`, "gi");
18
+
19
+ /** A character cue: capitals and little else, short, no ending full stop; dialogue follows it until a blank line. */
20
+ function isCue(line) {
21
+ const trimmed = line.trim();
22
+ if (!trimmed || trimmed.length > 40) return false;
23
+ if (/[.!?]$/.test(trimmed) && !/\(V\.O\.\)|\(O\.S\.\)|\(CONT'D\)$/i.test(trimmed)) return false;
24
+ const letters = trimmed.replace(/[^A-Za-z]/g, "");
25
+ return letters.length >= 2 && letters === letters.toUpperCase();
26
+ }
27
+
28
+ /**
29
+ * The action lines the camera cannot see, with the verbs that say so:
30
+ * `{ at, line, verbs }` per line, in order. Headings (a leading dot),
31
+ * cues, dialogue and parentheticals are skipped.
32
+ */
33
+ export function cameraLines(text) {
34
+ const lines = (text ?? "").split("\n");
35
+ const found = [];
36
+ let inDialogue = false;
37
+ for (const [at, line] of lines.entries()) {
38
+ const trimmed = line.trim();
39
+ if (!trimmed) { inDialogue = false; continue; }
40
+ if (trimmed.startsWith(".") || trimmed.startsWith("(")) continue;
41
+ if (isCue(trimmed)) { inDialogue = true; continue; }
42
+ if (inDialogue) continue;
43
+ const verbs = [...new Set([...trimmed.matchAll(INTERIOR_RE)].map((match) => match[1].toLowerCase()))];
44
+ if (verbs.length) found.push({ at, line: trimmed, verbs });
45
+ }
46
+ return found;
47
+ }
48
+
49
+ /** The verbs across every marked line, once each, in order of first use. */
50
+ export function cameraVerbs(found) {
51
+ return [...new Set(found.flatMap((item) => item.verbs))];
52
+ }
@@ -73,6 +73,8 @@ export type WallReading = {
73
73
  open: Array<{ id: string; words: string; hides: FindingKind[] }>;
74
74
  /** Fields left open by the writer's word (R61): the board's logline, and each card's when, in story order. Listed, not asked. */
75
75
  openFields: Array<{ field: "logline"; words: string } | { field: "location"; id: string; words: string } | { field: "when"; id: string; words: string }>;
76
+ /** Two versions of one scene (R65): each front card with the versions behind it, in story order; listed, never asked. */
77
+ versions: Array<{ id: string; alternatives: string[] }>;
76
78
  /** Threads (R60): each named string with its cards in story order and which ends are open. */
77
79
  threads: Array<{ id: string; name: string; ids: string[]; startOpen: boolean; endOpen: boolean; /** Eighths from the first card's start to the last card's start; 0 with fewer than two cards. */ apart: number }>;
78
80
  /** Cards here that pay off a fold of another board (R58), composed by the door from the project. */
@@ -516,6 +516,8 @@ export function readWall(state, options = {}) {
516
516
  paidBy: paidHere,
517
517
  open: describeOpen(state, options, order, openIds),
518
518
  openFields: describeOpenFields(state, order),
519
+ // Two versions of one scene (R65): the front card and the one behind it, in story order; listed, never asked.
520
+ versions: order.filter((note) => state.notes.some((item) => item.alternativeOf === note.id)).map((note) => ({ id: note.id, alternatives: state.notes.filter((item) => item.alternativeOf === note.id).map((item) => item.id) })),
519
521
  threads,
520
522
  findings: asked,
521
523
  left,
@@ -81,6 +81,8 @@ export type BoardNote = {
81
81
  plants: boolean;
82
82
  /** What the fold plants, in the writer's words (R62), or empty; never set on an unfolded card. */
83
83
  plantsWhat: string;
84
+ /** Another version of another card (R65): the id of the card this one stands behind, or null. A version is out of the order, the count and the pages until chosen. */
85
+ alternativeOf: string | null;
84
86
  /** When folded: the id of another board of the project where it pays off (R50), or null. */
85
87
  payoffBoardId: string | null;
86
88
  /** The scene on that board that pays it off (R58), or null while the board is only a promise. */
@@ -120,6 +122,8 @@ export type BoardState = {
120
122
  logline: string;
121
123
  /** The writer's words for why there is no logline yet (R61), or empty. */
122
124
  loglineOpen: string;
125
+ /** The writer's words for why the target is not decided, or empty; the number stands as the default meanwhile. */
126
+ targetOpen: string;
123
127
  /** Target script length in eighths of a page; 120 pages for a feature (R25). */
124
128
  targetEighths: number;
125
129
  /** The roster: every person in the story, whether or not they are on a card yet (R29). */
@@ -169,7 +173,7 @@ export type Command =
169
173
  | { type: "set_logline"; logline?: string; open?: string }
170
174
  | { type: "set_rank"; ids: string[]; rank: NoteRank }
171
175
  | { type: "set_length"; ids: string[]; lengthEighths: number | null }
172
- | { type: "set_target"; targetEighths: number }
176
+ | { type: "set_target"; targetEighths?: number; open?: string }
173
177
  | {
174
178
  type: "create_note";
175
179
  id?: string;
@@ -218,6 +222,8 @@ export type Command =
218
222
  | { type: "set_cast"; ids: string[]; characterIds: string[] }
219
223
  | { type: "set_plant"; ids: string[]; plants?: boolean; what?: string }
220
224
  | { type: "set_open"; ids: string[]; open: string }
225
+ | { type: "set_alternative"; id: string; of: string | null }
226
+ | { type: "choose_version"; id: string; keep?: boolean }
221
227
  | { type: "set_payoff_board"; ids: string[]; boardId: string | null; noteId?: string | null }
222
228
  | { type: "set_location"; ids: string[]; location?: string; open?: string }
223
229
  | { type: "set_when"; ids: string[]; when?: string; open?: string }
@@ -250,3 +256,8 @@ export declare function applyCommand(
250
256
  command: Command,
251
257
  now?: string,
252
258
  ): CommandResult;
259
+
260
+ /** Rows top to bottom, cards left to right within a row (moved here for R62). */
261
+ export declare function readingOrder(notes: BoardNote[]): BoardNote[];
262
+ /** The follows arrows where they exist, reading order where they do not; versions of other cards left out (R56, R65). */
263
+ export declare function storyOrder(state: BoardState, ids?: string[]): BoardNote[];
@@ -167,7 +167,7 @@ export function isMeasured(note) {
167
167
  }
168
168
 
169
169
  export function boardEighths(state) {
170
- return state.notes.reduce((total, note) => total + noteEighths(note), 0);
170
+ return state.notes.reduce((total, note) => total + (note.alternativeOf ? 0 : noteEighths(note)), 0);
171
171
  }
172
172
 
173
173
  /**
@@ -220,6 +220,7 @@ export function emptyState() {
220
220
  return {
221
221
  logline: "",
222
222
  targetEighths: DEFAULT_TARGET_EIGHTHS,
223
+ targetOpen: "",
223
224
  loglineOpen: "",
224
225
  characters: [],
225
226
  notes: [],
@@ -256,6 +257,8 @@ export function seedState(now = nowIso()) {
256
257
  plants: false,
257
258
  // What the fold plants, in the writer's words (R62), or nothing.
258
259
  plantsWhat: "",
260
+ // Another version of another card (R65): the card this one stands behind, or null.
261
+ alternativeOf: null,
259
262
  // A fold that pays off on another board — a later episode — names it here;
260
263
  // null claims nothing (R50). The scene there that pays it off, once one
261
264
  // does (R58); null while the board is a promise.
@@ -274,6 +277,7 @@ export function seedState(now = nowIso()) {
274
277
  // new writer finds out the logline is there at all.
275
278
  logline: "",
276
279
  targetEighths: DEFAULT_TARGET_EIGHTHS,
280
+ targetOpen: "",
277
281
  loglineOpen: "",
278
282
  // Two people, cast on the cards, so a new writer sees what the roster is for.
279
283
  characters: [
@@ -349,7 +353,8 @@ export function normalizeState(value) {
349
353
  // Cards written before R25 have no length; a scene is about a page. Cards
350
354
  // written before R29 have no cast; nobody is in the scene until someone is.
351
355
  let patched = false;
352
- const notes = value.notes.map((note) => {
356
+
357
+ const rawIds = new Set((value.notes ?? []).map((item) => item?.id).filter((id) => typeof id === "string")); const notes = value.notes.map((note) => {
353
358
  const rank = note && NOTE_RANKS.includes(note.rank) ? note.rank : "scene";
354
359
  // Unsized stays unsized: null (or no field, before R25) claims nothing and
355
360
  // reads as about a page. A number is the writer's estimate, kept in range.
@@ -368,6 +373,8 @@ export function normalizeState(value) {
368
373
  const payoffNoteId = payoffBoardId && typeof note?.payoffNoteId === "string" && note.payoffNoteId ? note.payoffNoteId : null;
369
374
  // Cards written before R59 are not open; a card claims to be decided until the writer says otherwise.
370
375
  const open = typeof note?.open === "string" ? note.open : "";
376
+ // Cards written before R65 are versions of nothing; a version whose sibling is gone, or of itself, stands as a plain card.
377
+ const alternativeOf = typeof note?.alternativeOf === "string" && note.alternativeOf !== note?.id && rawIds.has(note.alternativeOf) ? note.alternativeOf : null;
371
378
  // Cards written before R37 have no place; a scene is nowhere until it is.
372
379
  const location = typeof note?.location === "string" ? note.location : "";
373
380
  // Cards written before R61's edge have no open place; a place is decided or blank until the writer says otherwise.
@@ -386,6 +393,7 @@ export function normalizeState(value) {
386
393
  sameIds(note.characterIds, characterIds) &&
387
394
  note.plants === plants &&
388
395
  note.plantsWhat === plantsWhat &&
396
+ note.alternativeOf === alternativeOf &&
389
397
  note.payoffBoardId === payoffBoardId &&
390
398
  note.payoffNoteId === payoffNoteId &&
391
399
  note.open === open &&
@@ -398,8 +406,17 @@ export function normalizeState(value) {
398
406
  return note;
399
407
  }
400
408
  patched = true;
401
- return { ...note, rank, lengthEighths, characterIds, plants, plantsWhat, payoffBoardId, payoffNoteId, open, location, locationOpen, when, whenOpen, text };
409
+ return { ...note, rank, lengthEighths, characterIds, plants, plantsWhat, alternativeOf, payoffBoardId, payoffNoteId, open, location, locationOpen, when, whenOpen, text };
402
410
  });
411
+ // A version of a version is a version of the front card, so the pair stays a pair.
412
+ for (const [index, note] of notes.entries()) {
413
+ if (!note.alternativeOf) continue;
414
+ const front = notes.find((item) => item.id === note.alternativeOf);
415
+ if (front?.alternativeOf) {
416
+ patched = true;
417
+ notes[index] = { ...note, alternativeOf: front.alternativeOf === note.id ? null : front.alternativeOf };
418
+ }
419
+ }
403
420
 
404
421
  // Boards written before the production half (Roadmap 2, item 8) have no
405
422
  // lock and no revision; both are null until a draft goes out.
@@ -430,10 +447,13 @@ export function normalizeState(value) {
430
447
  .filter(Boolean);
431
448
  // Boards written before R61 have no open logline; a logline is decided or blank until the writer says otherwise.
432
449
  const loglineOpen = typeof value.loglineOpen === "string" ? value.loglineOpen : "";
450
+ // A target left open in the writer's words (the handover's calls, 2026-09-19): the number stands as the default meanwhile.
451
+ const targetOpen = typeof value.targetOpen === "string" ? value.targetOpen : "";
433
452
  if (
434
453
  value.logline === logline &&
435
454
  value.loglineOpen === loglineOpen &&
436
455
  value.targetEighths === targetEighths &&
456
+ value.targetOpen === targetOpen &&
437
457
  !rosterPatched &&
438
458
  !arrowsPatched &&
439
459
  !patched &&
@@ -449,6 +469,7 @@ export function normalizeState(value) {
449
469
  logline,
450
470
  loglineOpen,
451
471
  targetEighths,
472
+ targetOpen,
452
473
  characters,
453
474
  notes: patched ? notes : value.notes,
454
475
  arrows: arrowsPatched ? arrows : value.arrows,
@@ -491,6 +512,11 @@ function cleanOpen(value) {
491
512
  /** Cards within half a card's height of each other sit on one row. */
492
513
  const ROW_TOLERANCE = NOTE_HEIGHT / 2;
493
514
 
515
+ /** A card that is another version of a card is not in the story (R65): not in the order, the count or the pages, until chosen. */
516
+ export function inStory(note) {
517
+ return !note.alternativeOf;
518
+ }
519
+
494
520
  export function readingOrder(notes) {
495
521
  const byTop = [...notes].sort((a, b) => a.y - b.y || a.x - b.x);
496
522
  const rows = [];
@@ -518,7 +544,7 @@ export function readingOrder(notes) {
518
544
  */
519
545
  export function storyOrder(state, ids) {
520
546
  const scope = ids ? new Set(ids) : null;
521
- const notes = state.notes.filter((note) => !scope || scope.has(note.id));
547
+ const notes = state.notes.filter((note) => inStory(note) && (!scope || scope.has(note.id)));
522
548
  const reading = readingOrder(notes);
523
549
  const byId = new Map(reading.map((note) => [note.id, note]));
524
550
  const rank = new Map(reading.map((note, index) => [note.id, index]));
@@ -635,6 +661,7 @@ export function applyCommand(state, command, now = nowIso()) {
635
661
  ? null
636
662
  : clampEighths(command.lengthEighths, DEFAULT_NOTE_EIGHTHS, MAX_NOTE_EIGHTHS),
637
663
  characterIds: knownCast(command.characterIds, state.characters ?? []),
664
+ alternativeOf: null,
638
665
  plants: command.plants === true || Boolean(cleanOpen(command.plantsWhat)),
639
666
  // What it plants, in the writer's words (R62): naming a plant folds the card.
640
667
  plantsWhat: command.plants === false ? "" : cleanOpen(command.plantsWhat),
@@ -716,19 +743,14 @@ export function applyCommand(state, command, now = nowIso()) {
716
743
  }
717
744
 
718
745
  case "set_target": {
719
- const targetEighths = clampEighths(
720
- command.targetEighths,
721
- DEFAULT_TARGET_EIGHTHS,
722
- MAX_TARGET_EIGHTHS,
723
- );
724
- if (targetEighths === (state.targetEighths ?? DEFAULT_TARGET_EIGHTHS)) {
725
- return { state, changed: false };
726
- }
727
- return {
728
- state: { ...state, targetEighths },
729
- changed: true,
730
- result: { targetEighths },
731
- };
746
+ // A number decides the target and clears the open words; open words
747
+ // leave the number where it is (the default, or the last one set) and
748
+ // say the writer has not decided; open "" takes the words back.
749
+ const hasOpen = typeof command.open === "string";
750
+ const targetOpen = hasOpen ? cleanOpen(command.open) : typeof command.targetEighths === "number" ? "" : (state.targetOpen ?? "");
751
+ const targetEighths = typeof command.targetEighths === "number" ? clampEighths(command.targetEighths, DEFAULT_TARGET_EIGHTHS, MAX_TARGET_EIGHTHS) : state.targetEighths;
752
+ if (targetEighths === state.targetEighths && targetOpen === (state.targetOpen ?? "")) return { state, changed: false };
753
+ return { state: { ...state, targetEighths, targetOpen }, changed: true, result: { targetEighths, targetOpen } };
732
754
  }
733
755
 
734
756
  case "set_rank": {
@@ -780,7 +802,7 @@ export function applyCommand(state, command, now = nowIso()) {
780
802
  const gone = state.notes.find((note) => note.id === command.id);
781
803
  if (!gone) return { state, changed: false };
782
804
  const headlineOf = (id) => state.notes.find((note) => note.id === id)?.headline ?? id;
783
- const notes = state.notes.filter((note) => note.id !== command.id);
805
+ let notes = state.notes.filter((note) => note.id !== command.id);
784
806
  const taken = state.arrows.filter((arrow) => arrow.from === command.id || arrow.to === command.id);
785
807
  let arrows = state.arrows.filter((arrow) => !taken.includes(arrow));
786
808
  // A card wired into a chain — one follows in, one follows out — leaves
@@ -804,6 +826,8 @@ export function applyCommand(state, command, now = nowIso()) {
804
826
  );
805
827
  // A thread through the card keeps its name and loses the card (R60);
806
828
  // the thread stays, with one card fewer, so the writer can retie it.
829
+ // A version whose front card goes stands as a plain card (R65).
830
+ notes = notes.map((note) => (note.alternativeOf === command.id ? { ...note, alternativeOf: null } : note));
807
831
  const threadsLeft = [];
808
832
  const threads = (state.threads ?? []).map((thread) => {
809
833
  if (!thread.noteIds.includes(command.id)) return thread;
@@ -880,6 +904,60 @@ export function applyCommand(state, command, now = nowIso()) {
880
904
  return { state: { ...state, groups }, changed: true };
881
905
  }
882
906
 
907
+ // Two versions of one scene (R65): a card set behind another as its
908
+ // alternative leaves the story — the order, the count, the pages — and
909
+ // waits there until the writer chooses one. The reading lists the pair.
910
+ case "set_alternative": {
911
+ const note = state.notes.find((item) => item.id === command.id);
912
+ if (!note) return { state, changed: false };
913
+ const of = typeof command.of === "string" && command.of ? command.of : null;
914
+ if (of === null) {
915
+ if (!note.alternativeOf) return { state, changed: false };
916
+ const notes = state.notes.map((item) => (item.id === command.id ? bump(item, { alternativeOf: null }, now) : item));
917
+ return { state: { ...state, notes }, changed: true, result: { id: command.id, of: null, arrowsDropped: 0 } };
918
+ }
919
+ const front = state.notes.find((item) => item.id === of);
920
+ if (!front || of === command.id || front.alternativeOf || state.notes.some((item) => item.alternativeOf === command.id)) return { state, changed: false };
921
+ if (note.alternativeOf === of) return { state, changed: false };
922
+ // Out of the story: its follows arrows go with it; setup arrows are claims and stay.
923
+ const arrows = state.arrows.filter((arrow) => arrow.kind === "setup" || (arrow.from !== command.id && arrow.to !== command.id));
924
+ const notes = state.notes.map((item) => (item.id === command.id ? bump(item, { alternativeOf: of }, now) : item));
925
+ return { state: { ...state, notes, arrows }, changed: true, result: { id: command.id, of, arrowsDropped: state.arrows.length - arrows.length } };
926
+ }
927
+
928
+ // Choose one of two versions (R65): the chosen card is the scene, in the
929
+ // front card's place; the other goes, or stands beside it as a plain
930
+ // card when the writer says keep.
931
+ case "choose_version": {
932
+ const chosen = state.notes.find((item) => item.id === command.id);
933
+ if (!chosen) return { state, changed: false };
934
+ const other = chosen.alternativeOf ? state.notes.find((item) => item.id === chosen.alternativeOf) : state.notes.find((item) => item.alternativeOf === chosen.id);
935
+ if (!other) return { state, changed: false };
936
+ const frontId = chosen.alternativeOf ? other.id : chosen.id;
937
+ let notes = state.notes;
938
+ let arrows = state.arrows;
939
+ let groups = state.groups;
940
+ if (chosen.alternativeOf) {
941
+ // The alternative steps forward: the front's arrows, place in the order, rank and group are its now.
942
+ arrows = arrows.map((arrow) => ({ ...arrow, from: arrow.from === frontId ? chosen.id : arrow.from, to: arrow.to === frontId ? chosen.id : arrow.to })).filter((arrow) => arrow.from !== arrow.to);
943
+ groups = groups.map((group) => (group.noteIds.includes(frontId) ? { ...group, noteIds: group.noteIds.map((id) => (id === frontId ? chosen.id : id)) } : group));
944
+ notes = notes.map((item) => (item.id === chosen.id ? bump(item, { alternativeOf: null, x: other.x, y: other.y, rank: other.rank, z: other.z }, now) : item));
945
+ }
946
+ const keep = command.keep === true;
947
+ if (keep) {
948
+ notes = notes.map((item) => (item.id === other.id ? bump(item, { alternativeOf: null, x: item.x + 40, y: item.y + 40 }, now) : item));
949
+ if (chosen.alternativeOf) {
950
+ arrows = arrows.filter((arrow) => arrow.kind === "setup" || (arrow.from !== other.id && arrow.to !== other.id));
951
+ groups = groups.map((group) => (group.noteIds.includes(other.id) ? { ...group, noteIds: group.noteIds.filter((id) => id !== other.id) } : group));
952
+ }
953
+ } else {
954
+ notes = notes.filter((item) => item.id !== other.id);
955
+ arrows = arrows.filter((arrow) => arrow.from !== other.id && arrow.to !== other.id);
956
+ groups = pruneGroups(groups.map((group) => ({ ...group, noteIds: group.noteIds.filter((id) => id !== other.id) })));
957
+ }
958
+ return { state: { ...state, notes, arrows, groups }, changed: true, result: { chosen: chosen.id, other: other.id, kept: keep, steppedForward: Boolean(chosen.alternativeOf) } };
959
+ }
960
+
883
961
  // A thread (R60): a named string through cards, either end open until the
884
962
  // writer ties it. A record of its own beside the fold and the setup arrow;
885
963
  // the reading asks about each loose end from that end.
@@ -1167,6 +1245,7 @@ export function applyCommand(state, command, now = nowIso()) {
1167
1245
  characterIds: [],
1168
1246
  plants: false,
1169
1247
  plantsWhat: "",
1248
+ alternativeOf: null,
1170
1249
  payoffBoardId: null,
1171
1250
  payoffNoteId: null,
1172
1251
  open: "",