plotcoder-board 0.1.28 → 0.1.29

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
@@ -41,7 +41,7 @@ Every board verb goes through one command kernel, `src/board/reducer.js`, and th
41
41
 
42
42
  - **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.
43
43
  - **`window.plotcoder`** on the page, for a console or a CDP session.
44
- - **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-two 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), `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.
44
+ - **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.
45
45
  - **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`.
46
46
 
47
47
  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.
@@ -100,4 +100,4 @@ next round into a test of `claim_account` instead of the door it meant to test.
100
100
 
101
101
  ## Status
102
102
 
103
- Version 0.1.28. 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.
103
+ Version 0.1.29. 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.28",
3
+ "version": "0.1.29",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite",
@@ -856,6 +856,10 @@ function describeCommand(command) {
856
856
  return "recolor_note";
857
857
  case "apply_poses":
858
858
  return "organize";
859
+ case "create_thread":
860
+ return `create_thread "${command.name ?? ""}"`;
861
+ case "update_thread":
862
+ return typeof command.startOpen === "boolean" || typeof command.endOpen === "boolean" ? "update_thread (an end tied or opened)" : "update_thread";
859
863
  default:
860
864
  return command.type;
861
865
  }
@@ -968,7 +972,7 @@ function isSampleWall(state) {
968
972
  return state.notes.map((note) => note.headline).sort().join("\n") === sample;
969
973
  }
970
974
  /** Every check read_wall runs, so silence can be named. */
971
- const CHECKS = ["unmarked", "sag", "empty", "unwritten", "unlinked", "duplicate", "sequence", "uncast", "nobody", "absent", "backwards", "unpaid", "unplanted", "unplaced"];
975
+ const CHECKS = ["unmarked", "sag", "empty", "unwritten", "unlinked", "duplicate", "sequence", "uncast", "nobody", "absent", "backwards", "unpaid", "unplanted", "unplaced", "loose"];
972
976
  /** What each check looks for, in words, so "clean" says what was checked rather than a kind's name. */
973
977
  const CHECK_WORDS = {
974
978
  unmarked: "a beat is marked",
@@ -985,6 +989,7 @@ const CHECK_WORDS = {
985
989
  unpaid: "no fold without a payoff",
986
990
  unplanted: "no payoff without its fold",
987
991
  unplaced: "no card without a place",
992
+ loose: "no thread with a loose end",
988
993
  };
989
994
  const SAMPLE_NOTE = "sample: this is the wall PlotCoder starts with (Maya, Tom, the letter); nothing here is the writer's. Replace it, or new_board.";
990
995
 
@@ -1134,6 +1139,8 @@ function summarize(state) {
1134
1139
  places || " (no card says where it happens yet)",
1135
1140
  "groups:",
1136
1141
  groups || " (no groups)",
1142
+ "threads (R60; a named string through cards, in story order; an open end is asked about by the reading):",
1143
+ ...((state.threads ?? []).length ? state.threads.map((thread) => ` - ${threadLine(state, thread)}`) : [" (no threads — create_thread names one)"]),
1137
1144
  "arrows:",
1138
1145
  arrows || " (no arrows)",
1139
1146
  ].join("\n");
@@ -1582,6 +1589,9 @@ server.registerTool(
1582
1589
  ...(reading.open.length
1583
1590
  ? ["open, by the writer's word (listed, not asked about while the words stand; set_open with \"\" closes):", ...reading.open.map((item) => ` - "${state.notes.find((note) => note.id === item.id)?.headline ?? item.id}" — ${item.words}${item.hides.length ? ` (would be asked, closed: ${item.hides.map((kind) => CHECK_WORDS[kind] ?? kind).join("; ")})` : ""}`)]
1584
1591
  : []),
1592
+ ...(reading.threads.length
1593
+ ? ["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" : ""}`)]
1594
+ : []),
1585
1595
  "questions the wall raises:",
1586
1596
  ...(reading.findings.length
1587
1597
  ? reading.findings.map((finding) => ` - [${finding.kind}] ${finding.text}${finding.ids.length ? ` (ids: ${finding.ids.join(", ")})` : ""}`)
@@ -3106,6 +3116,126 @@ server.registerTool(
3106
3116
  },
3107
3117
  );
3108
3118
 
3119
+ // --- Threads (R60) ----------------------------------------------------------
3120
+ //
3121
+ // A thread is a named string through cards, either end open until the writer
3122
+ // ties it: the record for a thing the writer knows the far end of — the
3123
+ // bucket in the last scene, the key that changes hands — and not where it is
3124
+ // first seen. A fold and a setup arrow are still how a plant and its payoff
3125
+ // are drawn; a thread is beside them, in the writer's words, and the reading
3126
+ // asks about each loose end from that end.
3127
+
3128
+ function threadByRef(state, ref) {
3129
+ const wanted = (ref ?? "").trim();
3130
+ if (!wanted) return null;
3131
+ return (state.threads ?? []).find((thread) => thread.id === wanted) ?? (state.threads ?? []).find((thread) => thread.name.trim().toLowerCase() === wanted.toLowerCase()) ?? null;
3132
+ }
3133
+
3134
+ function cardsByRef(state, refs) {
3135
+ const found = [];
3136
+ const missing = [];
3137
+ for (const ref of refs ?? []) {
3138
+ const wanted = (ref ?? "").trim();
3139
+ const note = state.notes.find((item) => item.id === wanted) ?? state.notes.find((item) => item.headline.trim().toLowerCase() === wanted.toLowerCase()) ?? null;
3140
+ if (note) found.push(note.id);
3141
+ else missing.push(ref);
3142
+ }
3143
+ return { found, missing };
3144
+ }
3145
+
3146
+ function threadLine(state, thread) {
3147
+ const order = storyOrder(state).filter((note) => thread.noteIds.includes(note.id));
3148
+ const cards = order.length ? order.map((note) => `"${note.headline}"`).join(" → ") : "no card yet";
3149
+ const ends = [thread.startOpen ? "starts nowhere yet" : null, thread.endOpen ? "ends nowhere yet" : null].filter(Boolean);
3150
+ return `"${thread.name}" (${thread.id}): ${cards}${ends.length ? ` — ${ends.join(", ")}` : order.length ? " — both ends tied" : ""}`;
3151
+ }
3152
+
3153
+ server.registerTool(
3154
+ "create_thread",
3155
+ {
3156
+ title: "Name a thread",
3157
+ description:
3158
+ "Name a thread — a thing that runs through the story and is first seen somewhere and comes out somewhere: \"the letter\", \"the shop's lease\", a subplot — and string it through the cards it touches, by id or headline, in story order. Say which end is not decided: startOpen when the writer knows where it comes out and not where it is first seen; endOpen when they know where it starts and not where it comes out. The reading asks about each open end from that end — \"where is it first seen?\" — until update_thread ties it, and lists every thread with its cards. A thread is beside the fold and the setup arrow, not instead of them: fold the card that plants and draw the setup arrow when both scenes exist; a thread is for the writer's word before they do, and for a strand a fold cannot hold. Only on the writer's word: a thread is theirs to name.",
3159
+ inputSchema: {
3160
+ name: z.string().min(1),
3161
+ cards: z.array(z.string()).optional(),
3162
+ startOpen: z.boolean().optional(),
3163
+ endOpen: z.boolean().optional(),
3164
+ },
3165
+ },
3166
+ async (args) => {
3167
+ const current = await readBoard();
3168
+ const { found, missing } = cardsByRef(current.state, args.cards);
3169
+ if (missing.length) return ok(`No thread made: not on the board — ${missing.map((ref) => `"${ref}"`).join(", ")}. Call list_board for the ids or the exact headlines.`);
3170
+ const { state, changed, result, live } = await commit({ type: "create_thread", name: args.name, noteIds: found, startOpen: args.startOpen === true, endOpen: args.endOpen === true });
3171
+ if (!changed) return ok("No thread made: a thread needs a name.");
3172
+ const asks = [result.startOpen ? "where it is first seen" : null, result.endOpen ? "where it comes out" : null].filter(Boolean);
3173
+ return ok(
3174
+ `Named the thread ${threadLine(state, result)}${where(live)}.${asks.length ? ` The reading asks ${asks.join(" and ")} until update_thread ties ${asks.length === 1 ? "that end" : "them"}.` : result.noteIds.length ? " Both ends are tied; the reading lists it and asks nothing." : " No card yet: the reading asks where it is first seen and where it comes out."} The wall draws it as a string through its cards, a loose end where one is open.`,
3175
+ result,
3176
+ );
3177
+ },
3178
+ );
3179
+
3180
+ server.registerTool(
3181
+ "update_thread",
3182
+ {
3183
+ title: "Tie or change a thread",
3184
+ description:
3185
+ "Change a thread by id or name: rename it, add cards (by id or headline) or remove them, or tie an end — startOpen false once the writer says where it is first seen, endOpen false once they say where it comes out; true reopens an end. Adding the card where a thing is first seen and tying the start is one call: add plus startOpen false. The reading stops asking about an end the moment it is tied.",
3186
+ inputSchema: {
3187
+ thread: z.string(),
3188
+ name: z.string().optional(),
3189
+ add: z.array(z.string()).optional(),
3190
+ remove: z.array(z.string()).optional(),
3191
+ startOpen: z.boolean().optional(),
3192
+ endOpen: z.boolean().optional(),
3193
+ },
3194
+ },
3195
+ async (args) => {
3196
+ const current = await readBoard();
3197
+ const thread = threadByRef(current.state, args.thread);
3198
+ if (!thread) return ok(`No thread called "${args.thread}". list_board names the threads on this board; create_thread names a new one.`);
3199
+ const add = cardsByRef(current.state, args.add);
3200
+ const remove = cardsByRef(current.state, args.remove);
3201
+ const missing = [...add.missing, ...remove.missing];
3202
+ if (missing.length) return ok(`Nothing changed: not on the board — ${missing.map((ref) => `"${ref}"`).join(", ")}. Call list_board for the ids or the exact headlines.`);
3203
+ const { state, changed, result, live } = await commit({
3204
+ type: "update_thread",
3205
+ id: thread.id,
3206
+ ...(args.name !== undefined ? { name: args.name } : {}),
3207
+ ...(add.found.length ? { add: add.found } : {}),
3208
+ ...(remove.found.length ? { remove: remove.found } : {}),
3209
+ ...(typeof args.startOpen === "boolean" ? { startOpen: args.startOpen } : {}),
3210
+ ...(typeof args.endOpen === "boolean" ? { endOpen: args.endOpen } : {}),
3211
+ });
3212
+ if (!changed) return ok(`Nothing changed: "${thread.name}" already reads that way.`);
3213
+ const tied = [result.before.startOpen && !result.thread.startOpen ? "its start" : null, result.before.endOpen && !result.thread.endOpen ? "its end" : null].filter(Boolean);
3214
+ const reopened = [!result.before.startOpen && result.thread.startOpen ? "its start" : null, !result.before.endOpen && result.thread.endOpen ? "its end" : null].filter(Boolean);
3215
+ return ok(
3216
+ `Now ${threadLine(state, result.thread)}${where(live)}.${tied.length ? ` Tied ${tied.join(" and ")}; the reading stops asking about ${tied.length === 1 ? "it" : "them"}.` : ""}${reopened.length ? ` Opened ${reopened.join(" and ")}; the reading asks about ${reopened.length === 1 ? "it" : "them"} again.` : ""}`,
3217
+ result.thread,
3218
+ );
3219
+ },
3220
+ );
3221
+
3222
+ server.registerTool(
3223
+ "delete_thread",
3224
+ {
3225
+ title: "Cut a thread",
3226
+ description: "Remove a thread by id or name. The cards stay; only the string and its name go. Only on the writer's word.",
3227
+ inputSchema: { thread: z.string() },
3228
+ },
3229
+ async (args) => {
3230
+ const current = await readBoard();
3231
+ const thread = threadByRef(current.state, args.thread);
3232
+ if (!thread) return ok(`No thread called "${args.thread}". list_board names the threads on this board.`);
3233
+ const { changed, result, live } = await commit({ type: "delete_thread", id: thread.id });
3234
+ if (!changed) return ok(`No thread called "${args.thread}".`);
3235
+ return ok(`Cut the thread "${result.name}"${where(live)}; its ${result.noteIds.length} card${result.noteIds.length === 1 ? "" : "s"} stay where they are.`, result);
3236
+ },
3237
+ );
3238
+
3109
3239
  // --- Groups -----------------------------------------------------------
3110
3240
 
3111
3241
  server.registerTool(
@@ -124,6 +124,8 @@ export function toFountain(state, options = {}) {
124
124
  if (cast.length) marks.push(`with ${cast.join(", ")}`);
125
125
  if (note.plants) marks.push("plants something to pay off later");
126
126
  if (note.open) marks.push(`open: ${note.open}`);
127
+ const onThreads = (state.threads ?? []).filter((thread) => thread.noteIds.includes(note.id)).map((thread) => thread.name);
128
+ if (onThreads.length) marks.push(`thread: ${onThreads.join(", ")}`);
127
129
  if (revisionMarksFor(marks, note)) marks.push(`changed in the ${state.revision.color} revision`);
128
130
  if (marks.length) {
129
131
  body.push(`[[${marks.join(" · ")}]]`);
@@ -33,11 +33,13 @@ export type FindingKind =
33
33
  /** A setup arrow whose payoff comes before its setup on the wall. */
34
34
  | "backwards"
35
35
  /** A card with a folded corner and no setup arrow leaving it. */
36
- | "unpaid";
36
+ | "unpaid"
37
+ /** A thread (R60) with an end the writer has not tied: where it is first seen, or where it comes out. */
38
+ | "loose";
37
39
 
38
40
  export type Finding = {
39
41
  kind: FindingKind;
40
- /** Card ids; a group id for "sequence"; a character id (then card ids) for "uncast" and "absent"; an arrow id (then card ids) for "backwards". Empty for "unmarked". */
42
+ /** Card ids; a group id for "sequence"; a character id (then card ids) for "uncast" and "absent"; an arrow id (then card ids) for "backwards"; a thread id (then card ids) for "loose". Empty for "unmarked". */
41
43
  ids: string[];
42
44
  /** The question, written for a writer. */
43
45
  text: string;
@@ -69,6 +71,8 @@ export type WallReading = {
69
71
  later: { id: string; boardId: string; noteId: string | null }[];
70
72
  /** Open cards (R59): the writer's words for what is not decided, in story order; not asked about while they stand. */
71
73
  open: Array<{ id: string; words: string; hides: FindingKind[] }>;
74
+ /** Threads (R60): each named string with its cards in story order and which ends are open. */
75
+ threads: Array<{ id: string; name: string; ids: string[]; startOpen: boolean; endOpen: boolean }>;
72
76
  /** Cards here that pay off a fold of another board (R58), composed by the door from the project. */
73
77
  paidBy: Array<{ id: string; fromBoardId: string; fromBoardName: string; fromNoteId: string; fromHeadline: string; fromColor: string }>;
74
78
  /** The questions the wall asks now. A left one (R53) is not here while its words hold. */
@@ -502,6 +502,30 @@ export function readWall(state, options = {}) {
502
502
  }
503
503
  }
504
504
 
505
+ // Threads (R60): a named string through cards, in story order, either end
506
+ // open until the writer ties it. The reading asks about each loose end from
507
+ // that end — the question a fold cannot ask, where a thing is first seen.
508
+ const threads = (state.threads ?? []).map((thread) => ({
509
+ id: thread.id,
510
+ name: thread.name,
511
+ ids: order.filter((note) => thread.noteIds.includes(note.id)).map((note) => note.id),
512
+ startOpen: thread.startOpen === true,
513
+ endOpen: thread.endOpen === true,
514
+ }));
515
+ for (const thread of threads) {
516
+ const first = thread.ids.length ? byId.get(thread.ids[0]) : null;
517
+ const last = thread.ids.length ? byId.get(thread.ids[thread.ids.length - 1]) : null;
518
+ if (!thread.ids.length) {
519
+ findings.push({ kind: "loose", ids: [thread.id], text: `"${thread.name}" runs through no card yet. Where is it first seen, and where does it come out?` });
520
+ } else if (thread.startOpen && thread.endOpen) {
521
+ findings.push({ kind: "loose", ids: [thread.id, ...thread.ids], text: `"${thread.name}" runs through ${list(thread.ids.map((id) => byId.get(id)))} and neither end is tied. Where is it first seen, and where does it come out?` });
522
+ } else if (thread.startOpen) {
523
+ findings.push({ kind: "loose", ids: [thread.id, first.id], text: `"${thread.name}" starts nowhere yet: it runs ${thread.ids.length === 1 ? "to" : "through"} ${list(thread.ids.map((id) => byId.get(id)))}. Where is it first seen?` });
524
+ } else if (thread.endOpen) {
525
+ findings.push({ kind: "loose", ids: [thread.id, last.id], text: `"${thread.name}" ends nowhere yet: it runs ${thread.ids.length === 1 ? "from" : "through"} ${list(thread.ids.map((id) => byId.get(id)))}. Where does it come out?` });
526
+ }
527
+ }
528
+
505
529
  // A question the writer has left (R53) is held back while it is still the
506
530
  // same question — same kind, same cards, same words. The moment it would
507
531
  // read differently (a page moved, a headline changed, the median shifted)
@@ -509,7 +533,8 @@ export function readWall(state, options = {}) {
509
533
  // reading does, on every read.
510
534
  const left = [];
511
535
  // A question whose every card is open is not asked (R59): the writer's word covers it.
512
- const openCards = openIds.size ? findings.filter((finding) => finding.ids.some((id) => byId.has(id)) && finding.ids.filter((id) => byId.has(id)).every((id) => openIds.has(id))) : [];
536
+ // A thread's loose end (R60) is the writer's own claim, asked whether or not its cards are open.
537
+ const openCards = openIds.size ? findings.filter((finding) => finding.kind !== "loose" && finding.ids.some((id) => byId.has(id)) && finding.ids.filter((id) => byId.has(id)).every((id) => openIds.has(id))) : [];
513
538
  const asked = findings.filter((finding) => {
514
539
  if (openCards.includes(finding)) return false;
515
540
  const entry = (state.left ?? []).find(
@@ -529,6 +554,7 @@ export function readWall(state, options = {}) {
529
554
  later,
530
555
  paidBy: paidHere,
531
556
  open: describeOpen(state, options, order, openIds),
557
+ threads,
532
558
  findings: asked,
533
559
  left,
534
560
  };
@@ -118,6 +118,8 @@ export type BoardState = {
118
118
  characters: BoardCharacter[];
119
119
  notes: BoardNote[];
120
120
  groups: BoardGroup[];
121
+ /** Threads (R60): named strings through cards, either end open until tied. */
122
+ threads: BoardThread[];
121
123
  arrows: BoardArrow[];
122
124
  /** Locked scene numbers, once a draft has gone out (Roadmap 2, item 8); null until then. */
123
125
  lock: import("./numbering").Lock | null;
@@ -127,6 +129,17 @@ export type BoardState = {
127
129
  left: LeftQuestion[];
128
130
  };
129
131
 
132
+ /** A thread (R60): a name, the cards it runs through, and whether either end is still open. */
133
+ export type BoardThread = {
134
+ id: string;
135
+ name: string;
136
+ noteIds: string[];
137
+ /** Where it is first seen is not decided. */
138
+ startOpen: boolean;
139
+ /** Where it comes out is not decided. */
140
+ endOpen: boolean;
141
+ };
142
+
130
143
  /** A question the wall asked and the writer left (R53). */
131
144
  export type LeftQuestion = {
132
145
  kind: string;
@@ -179,6 +192,9 @@ export type Command =
179
192
  | { type: "ungroup"; id: string }
180
193
  | { type: "add_to_group"; id: string; noteIds: string[] }
181
194
  | { type: "rename_group"; id: string; title: string }
195
+ | { type: "create_thread"; name: string; noteIds?: string[]; startOpen?: boolean; endOpen?: boolean; id?: string }
196
+ | { type: "update_thread"; id: string; name?: string; noteIds?: string[]; add?: string[]; remove?: string[]; startOpen?: boolean; endOpen?: boolean }
197
+ | { type: "delete_thread"; id: string }
182
198
  | { type: "create_arrow"; from: string; to: string; kind?: ArrowKind }
183
199
  | { type: "delete_arrow"; id: string }
184
200
  | { type: "set_arrow_kind"; id: string; kind: ArrowKind }
@@ -223,6 +223,7 @@ export function emptyState() {
223
223
  characters: [],
224
224
  notes: [],
225
225
  groups: [],
226
+ threads: [],
226
227
  arrows: [],
227
228
  lock: null,
228
229
  revision: null,
@@ -277,6 +278,7 @@ export function seedState(now = nowIso()) {
277
278
  mk("letter-aloud", "The letter is read aloud", "The plan dies in the room.", "blue", 196, 340, 0.8, 3, ["maya", "tom"]),
278
279
  ],
279
280
  groups: [],
281
+ threads: [],
280
282
  arrows: [],
281
283
  lock: null,
282
284
  revision: null,
@@ -390,6 +392,25 @@ export function normalizeState(value) {
390
392
  // the writer leaves it.
391
393
  const left = Array.isArray(value.left) ? value.left.filter(isLeftQuestion) : [];
392
394
  const leftPatched = !Array.isArray(value.left) || left.length !== value.left.length;
395
+ // Boards written before R60 have no threads: a wall has none until the
396
+ // writer names one. A thread that names a card the board no longer holds
397
+ // drops that card; a thread with no name is not a thread.
398
+ const noteIdSet = new Set(notes.map((note) => note.id));
399
+ let threadsPatched = !Array.isArray(value.threads);
400
+ const threads = (Array.isArray(value.threads) ? value.threads : [])
401
+ .map((thread) => {
402
+ if (!thread || typeof thread !== "object" || typeof thread.id !== "string" || typeof thread.name !== "string" || !thread.name.trim()) {
403
+ threadsPatched = true;
404
+ return null;
405
+ }
406
+ const noteIds = Array.isArray(thread.noteIds) ? thread.noteIds.filter((id, index) => typeof id === "string" && noteIdSet.has(id) && thread.noteIds.indexOf(id) === index) : [];
407
+ const startOpen = thread.startOpen === true;
408
+ const endOpen = thread.endOpen === true;
409
+ if (Array.isArray(thread.noteIds) && noteIds.length === thread.noteIds.length && thread.startOpen === startOpen && thread.endOpen === endOpen) return thread;
410
+ threadsPatched = true;
411
+ return { ...thread, noteIds, startOpen, endOpen };
412
+ })
413
+ .filter(Boolean);
393
414
  if (
394
415
  value.logline === logline &&
395
416
  value.targetEighths === targetEighths &&
@@ -398,7 +419,8 @@ export function normalizeState(value) {
398
419
  !patched &&
399
420
  value.lock === lock &&
400
421
  value.revision === revision &&
401
- !leftPatched
422
+ !leftPatched &&
423
+ !threadsPatched
402
424
  ) {
403
425
  return value;
404
426
  }
@@ -412,6 +434,7 @@ export function normalizeState(value) {
412
434
  lock,
413
435
  revision,
414
436
  left,
437
+ threads,
415
438
  };
416
439
  }
417
440
 
@@ -435,6 +458,11 @@ function cleanOpen(value) {
435
458
  return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
436
459
  }
437
460
 
461
+ /** A thread's name as the writer typed it, one line, spaces collapsed (R60). */
462
+ function cleanThreadName(value) {
463
+ return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
464
+ }
465
+
438
466
  /** A when as the writer typed it, one line, spaces collapsed. */
439
467
  function cleanWhen(value) {
440
468
  return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
@@ -631,10 +659,19 @@ export function applyCommand(state, command, now = nowIso()) {
631
659
  return { ...group, noteIds };
632
660
  }),
633
661
  );
662
+ // A thread through the card keeps its name and loses the card (R60);
663
+ // the thread stays, with one card fewer, so the writer can retie it.
664
+ const threadsLeft = [];
665
+ const threads = (state.threads ?? []).map((thread) => {
666
+ if (!thread.noteIds.includes(command.id)) return thread;
667
+ const noteIds = thread.noteIds.filter((id) => id !== command.id);
668
+ threadsLeft.push({ id: thread.id, name: thread.name, remaining: noteIds.length });
669
+ return { ...thread, noteIds };
670
+ });
634
671
  // The result says what went with the card, so a door can say it too
635
672
  // (round thirteen, entry 17: "Deleted card." and nothing of the arrows).
636
673
  return {
637
- state: { ...state, notes, arrows, groups },
674
+ state: { ...state, notes, arrows, groups, threads },
638
675
  changed: true,
639
676
  result: {
640
677
  id: command.id,
@@ -647,6 +684,7 @@ export function applyCommand(state, command, now = nowIso()) {
647
684
  arrows: taken.map((arrow) => ({ ...arrow, fromHeadline: headlineOf(arrow.from), toHeadline: headlineOf(arrow.to) })),
648
685
  joined: joined ? { ...joined, fromHeadline: headlineOf(joined.from), toHeadline: headlineOf(joined.to) } : null,
649
686
  groups: left,
687
+ threads: threadsLeft,
650
688
  },
651
689
  };
652
690
  }
@@ -699,6 +737,50 @@ export function applyCommand(state, command, now = nowIso()) {
699
737
  return { state: { ...state, groups }, changed: true };
700
738
  }
701
739
 
740
+ // A thread (R60): a named string through cards, either end open until the
741
+ // writer ties it. A record of its own beside the fold and the setup arrow;
742
+ // the reading asks about each loose end from that end.
743
+ case "create_thread": {
744
+ const name = cleanThreadName(command.name);
745
+ if (!name) return { state, changed: false };
746
+ const noteIds = (command.noteIds ?? []).filter((id, index, all) => all.indexOf(id) === index && state.notes.some((note) => note.id === id));
747
+ const thread = {
748
+ id: typeof command.id === "string" && command.id && !(state.threads ?? []).some((item) => item.id === command.id) ? command.id : newId(),
749
+ name,
750
+ noteIds,
751
+ startOpen: command.startOpen === true,
752
+ endOpen: command.endOpen === true,
753
+ };
754
+ return { state: { ...state, threads: [...(state.threads ?? []), thread] }, changed: true, result: thread };
755
+ }
756
+
757
+ case "update_thread": {
758
+ const current = (state.threads ?? []).find((thread) => thread.id === command.id);
759
+ if (!current) return { state, changed: false };
760
+ const exists = (id) => state.notes.some((note) => note.id === id);
761
+ let noteIds = Array.isArray(command.noteIds) ? command.noteIds.filter((id, index, all) => all.indexOf(id) === index && exists(id)) : [...current.noteIds];
762
+ if (Array.isArray(command.add)) for (const id of command.add) if (exists(id) && !noteIds.includes(id)) noteIds.push(id);
763
+ if (Array.isArray(command.remove)) noteIds = noteIds.filter((id) => !command.remove.includes(id));
764
+ const name = command.name === undefined ? current.name : cleanThreadName(command.name) || current.name;
765
+ const startOpen = typeof command.startOpen === "boolean" ? command.startOpen : current.startOpen;
766
+ const endOpen = typeof command.endOpen === "boolean" ? command.endOpen : current.endOpen;
767
+ if (name === current.name && startOpen === current.startOpen && endOpen === current.endOpen && sameIds(noteIds, current.noteIds) && noteIds.length === current.noteIds.length) {
768
+ return { state, changed: false };
769
+ }
770
+ const next = { ...current, name, noteIds, startOpen, endOpen };
771
+ return {
772
+ state: { ...state, threads: state.threads.map((thread) => (thread.id === command.id ? next : thread)) },
773
+ changed: true,
774
+ result: { thread: next, before: current },
775
+ };
776
+ }
777
+
778
+ case "delete_thread": {
779
+ const gone = (state.threads ?? []).find((thread) => thread.id === command.id);
780
+ if (!gone) return { state, changed: false };
781
+ return { state: { ...state, threads: state.threads.filter((thread) => thread.id !== command.id) }, changed: true, result: gone };
782
+ }
783
+
702
784
  case "create_group": {
703
785
  const noteIds = command.noteIds.filter((id) =>
704
786
  state.notes.some((note) => note.id === id),
@@ -68,6 +68,11 @@ export const WORD_GROUPS = [
68
68
  name: "Open",
69
69
  sentence: "A card the writer has not decided, in their words on its edge: the reading lists it and asks nothing else of it until the words are cleared.",
70
70
  },
71
+ {
72
+ id: "thread",
73
+ name: "A thread",
74
+ sentence: "A named string through the cards a thing runs through — the letter, the key, a subplot — with either end open until the writer ties it. Drawn on the wall as a dashed string, a ring where an end is loose; the reading asks where a loose thread is first seen, or where it comes out.",
75
+ },
71
76
  {
72
77
  id: "corner",
73
78
  name: "The folded corner",
@@ -15,7 +15,7 @@ export const WORKFLOWS = [
15
15
  id: "break-a-treatment",
16
16
  name: "Break a treatment into a wall",
17
17
  ask: "Here is a treatment. Break it into a wall: one card per scene with a headline and what changes, the cast on each card, the places, and the major turns marked as beats.",
18
- tools: ["list_words", "read_wall", "list_reminders", "list_board", "new_project", "new_board", "rename_project", "rename_board", "set_target", "set_logline", "set_premise", "create_note", "add_character", "cast", "update_character", "set_location", "set_when", "set_rank", "set_length", "set_plant", "create_arrow", "create_group", "organize"],
18
+ tools: ["list_words", "read_wall", "list_reminders", "list_board", "new_project", "new_board", "rename_project", "rename_board", "set_target", "set_logline", "set_premise", "create_note", "add_character", "cast", "update_character", "set_location", "set_when", "set_rank", "set_length", "set_plant", "create_thread", "create_arrow", "create_group", "organize"],
19
19
  then: "Start where the wall will live: on the account, new_project names it; in a folder, new_board for the writer's wall, then rename_project. Read the wall (read_wall) and say what it asks. A treatment is cards, one create_note each, with characters, location, rank and plants on the call; import_fountain is the door for pages, not a treatment — a scene's text measures its card.",
20
20
  // What a treatment should say (R49): eleven blind runs asked the writer
21
21
  // the same questions at the end of every build. Each is a fact the wall
@@ -30,7 +30,7 @@ export const WORKFLOWS = [
30
30
  { question: "Where does each scene happen?", hint: "In your own words. A scene that moves through one location is still one place.", tool: "set_location" },
31
31
  { question: "When does a scene happen, where that matters?", hint: "That night; the fourth of October. It goes beside the place, never in the headline.", tool: "set_when" },
32
32
  { question: "Who is in each scene, and what do we call them?", hint: "A full name, or a role for someone unnamed — the man in 42. And who is only spoken of, never in a scene? They go in someone's notes, not the cast.", tool: "add_character, cast, update_character" },
33
- { question: "What is planted, and where does it pay off?", hint: "Name the episode when it pays off outside this one, so the fold is deliberate and the wall knows where to look.", tool: "set_plant with later, create_arrow" },
33
+ { question: "What is planted, and where does it pay off?", hint: "Name the episode when it pays off outside this one, so the fold is deliberate and the wall knows where to look. A thing whose far end you know and not its first sighting — the key, the bucket — is a thread with an open start.", tool: "set_plant with later, create_arrow; create_thread" },
34
34
  { question: "Which scenes do you already know run long or short?", hint: "A day in the story is not a page count; leave the rest unsized.", tool: "set_length" },
35
35
  { question: "What are the project and the board called?", hint: "The series, and this episode.", tool: "rename_project, rename_board" },
36
36
  { question: "What must not be invented?", hint: "Looks and voices are yours until you say; so is anything the treatment does not state.", tool: "update_character, later" },