plotcoder-board 0.1.24 → 0.1.26

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-one 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_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-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.
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.
@@ -51,10 +51,11 @@ An agent should call the tools, never fake mouse drags. The skill in `.cursor/sk
51
51
  A **blind run** is a fresh agent given the on-ramp and a treatment and nothing
52
52
  else, asked to build a wall and to keep a log of everything that made the job
53
53
  harder than it should have been. The friction log is the product; the wall is
54
- just what produces it. Sixteen rounds have been run, the first three through a
54
+ just what produces it. Seventeen rounds have been run, the first three through a
55
55
  repo checkout and the rest through the account door; every finding from the
56
- first fifteen is fixed or decided, and forty-one of round sixteen's
57
- forty-eight were fixed the same night, while it ran. [`blind-runs/`](blind-runs/) holds the rules that keep a round honest,
56
+ first sixteen is fixed or decided, and round seventeen, the first to start from
57
+ a page of notes instead of a treatment, found the wall has no way to hold a
58
+ maybe — the open card, R59, proposed and mocked from it. [`blind-runs/`](blind-runs/) holds the rules that keep a round honest,
58
59
  the table of rounds, and the next round's prompt with the test account filled in.
59
60
 
60
61
  A round works a **test account** — a throwaway marked on its writer row, and the
@@ -99,4 +100,4 @@ next round into a test of `claim_account` instead of the door it meant to test.
99
100
 
100
101
  ## Status
101
102
 
102
- Version 0.1.24. 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.26. 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.24",
3
+ "version": "0.1.26",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite",
@@ -805,7 +805,8 @@ let lastReading = null;
805
805
  const sinceRead = [];
806
806
  /** 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). */
807
807
  let lastChange = null;
808
- const findingKey = (finding) => `${finding.kind}|${finding.ids.join(",")}|${finding.text}`;
808
+ // The same question in the same words is the same question, whatever order its names come in (round seventeen, entry 21).
809
+ const findingKey = (finding) => `${finding.kind}|${[...finding.ids].sort().join(",")}`;
809
810
  function noteChange(before, after, boardId = null) {
810
811
  // The same reading read_wall gives: a person cast on another board is not
811
812
  // asked about, so a write's tail never names a question the reading does not.
@@ -967,9 +968,10 @@ function isSampleWall(state) {
967
968
  return state.notes.map((note) => note.headline).sort().join("\n") === sample;
968
969
  }
969
970
  /** Every check read_wall runs, so silence can be named. */
970
- const CHECKS = ["sag", "empty", "unwritten", "unlinked", "duplicate", "sequence", "uncast", "absent", "backwards", "unpaid", "unplaced"];
971
+ const CHECKS = ["unmarked", "sag", "empty", "unwritten", "unlinked", "duplicate", "sequence", "uncast", "nobody", "absent", "backwards", "unpaid", "unplanted", "unplaced"];
971
972
  /** What each check looks for, in words, so "clean" says what was checked rather than a kind's name. */
972
973
  const CHECK_WORDS = {
974
+ unmarked: "a beat is marked",
973
975
  sag: "no run out of proportion",
974
976
  empty: "no beats back to back",
975
977
  unwritten: "no card without a headline or change line",
@@ -977,9 +979,11 @@ const CHECK_WORDS = {
977
979
  duplicate: "no two headlines alike",
978
980
  sequence: "no group too long for one sequence (act groups are not asked)",
979
981
  uncast: "nobody in the cast on no card of the project",
982
+ nobody: "no card with nobody in it",
980
983
  absent: "nobody gone for a third of the story",
981
984
  backwards: "no payoff before its setup",
982
985
  unpaid: "no fold without a payoff",
986
+ unplanted: "no payoff without its fold",
983
987
  unplaced: "no card without a place",
984
988
  };
985
989
  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.";
@@ -1035,11 +1039,12 @@ function summarize(state) {
1035
1039
  const revised = snap && (snap.headline !== note.headline || snap.change !== note.change || (snap.text ?? "") !== (note.text ?? "") || (snap.location ?? "") !== (note.location ?? "")) ? `, changed in ${state.revision.color}` : "";
1036
1040
  const place = note.location ? `, at: ${note.location}` : "";
1037
1041
  const when = note.when ? `, when: ${note.when}` : "";
1042
+ const openWord = note.open ? `, open: ${note.open}` : "";
1038
1043
  const count = formatPages(noteEighths(note));
1039
1044
  // A written card's estimate is kept underneath for when the text goes; say it, or it is invisible (round sixteen, entry 44).
1040
1045
  const underneath = isMeasured(note) && note.lengthEighths !== null ? `; the writer's estimate underneath: ${formatPages(note.lengthEighths)}` : "";
1041
1046
  const pages = isMeasured(note) ? `${count} ${count === "1" ? "page" : "pages"}, written${underneath}` : note.lengthEighths === null ? "about a page, unsized" : `${count} ${count === "1" ? "page" : "pages"}`;
1042
- return ` - ${note.id} [${note.rank ?? "scene"}, ${pages}${who}${place}${when}${plant}${pays}${revised}] — "${note.headline}" (${note.color}) at ${Math.round(note.x)},${Math.round(note.y)}`;
1047
+ 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)}`;
1043
1048
  })
1044
1049
  .join("\n");
1045
1050
  const cast = state.characters
@@ -1307,6 +1312,7 @@ server.registerTool(
1307
1312
  before: z.string().optional().describe("Or before this card (id or headline)."),
1308
1313
  location: z.string().optional(),
1309
1314
  when: z.string().optional().describe('When the scene happens, as the writer says it — "night", "day four, dawn" — printed after the place on the scene heading.'),
1315
+ open: z.string().optional().describe("The writer's words for what is not decided about this card (R59) — \"where, and whether Ruth is there\" — so the card is born open: the reading lists it and asks nothing else of it until the words are cleared."),
1310
1316
  characters: z.array(z.string().min(1)).optional(),
1311
1317
  x: z.number().optional(),
1312
1318
  y: z.number().optional(),
@@ -1341,6 +1347,7 @@ server.registerTool(
1341
1347
  plants: args.plants,
1342
1348
  location: args.location,
1343
1349
  when: args.when,
1350
+ open: args.open,
1344
1351
  x: landing.x,
1345
1352
  y: landing.y,
1346
1353
  }).result;
@@ -1377,6 +1384,7 @@ server.registerTool(
1377
1384
  result?.plants ? "corner folded" : null,
1378
1385
  result?.location ? `at ${result.location}` : `no place yet${once("place", " (location here, or set_location)")}`,
1379
1386
  result?.when ? `when: ${result.when}` : null,
1387
+ result?.open ? `open: "${result.open}" (listed, not asked about)` : null,
1380
1388
  ].filter(Boolean).join(", ");
1381
1389
  // Where it landed matters only until the tidy, so the reply says the rule once and never the coordinates (round fourteen, entry 11).
1382
1390
  const placed = beside
@@ -1491,7 +1499,7 @@ server.registerTool(
1491
1499
  {
1492
1500
  title: "Read the wall",
1493
1501
  description:
1494
- "Read the board back: the beats in wall order (rows top to bottom, cards left to right), the pages of scenes between consecutive beats with the cards in each, every setup with the distance to its payoff, and the questions the wall raises — no beat marked yet; a run out of proportion with the others; beats back to back with nothing between them (a chain of them is one question); a card with a placeholder headline or no change line; a card no arrow touches; two headlines that read like the same scene; a group too long to be one sequence; a person in the cast on no card; a person gone for more than a third of the story and ten pages; a payoff before its setup on the wall; a folded card no setup arrow pays off; cards that say no place once any card has one. These are questions, not fixes: put them to the writer and do not act on them unasked. A question the writer answers with \"leave it\" is left with leave_question and listed under \"left, for now\" instead, until it would read differently. It says nothing about how many beats there should be, and neither should you. The prose carries every id; PLOTCODER_JSON=1 in the server's environment adds the same reading as JSON after it, for a program.",
1502
+ "Read the board back: the beats in wall order (rows top to bottom, cards left to right), the pages of scenes between consecutive beats with the cards in each, every setup with the distance to its payoff, and the questions the wall raises — no beat marked yet; a run out of proportion with the others; beats back to back with nothing between them (a chain of them is one question); a card with a placeholder headline or no change line; a card no arrow touches; two headlines that read like the same scene; a group too long to be one sequence; a person in the cast on no card; a person gone for more than a third of the story and ten pages; a payoff before its setup on the wall; a folded card no setup arrow pays off; a setup arrow leaving a card that is not folded; a card with nobody in it once the wall has a cast; cards that say no place once any card has one. These are questions, not fixes: put them to the writer and do not act on them unasked. A question the writer answers with \"leave it\" is left with leave_question and listed under \"left, for now\" instead, until it would read differently. It says nothing about how many beats there should be, and neither should you. The prose carries every id; PLOTCODER_JSON=1 in the server's environment adds the same reading as JSON after it, for a program.",
1495
1503
  inputSchema: {},
1496
1504
  },
1497
1505
  async () => {
@@ -1519,7 +1527,7 @@ server.registerTool(
1519
1527
  "the cast and the places are list_board's, not the reading's",
1520
1528
  state.targetEighths === DEFAULT_TARGET_EIGHTHS
1521
1529
  ? `runtime: about ${formatPages(boardEighths(state))} pages; no target set (set_target)`
1522
- : `runtime: about ${formatPages(boardEighths(state))} pages of a ${formatPages(state.targetEighths)}-page target — ${boardEighths(state) > state.targetEighths ? `${formatPages(boardEighths(state) - state.targetEighths)} over` : boardEighths(state) < state.targetEighths ? `${formatPages(state.targetEighths - boardEighths(state))} under` : "on it"}`,
1530
+ : `runtime: about ${formatPages(boardEighths(state))} pages of a ${formatPages(state.targetEighths)}-page target — ${boardEighths(state) > state.targetEighths ? `${formatPages(boardEighths(state) - state.targetEighths)} over` : boardEighths(state) < state.targetEighths ? `${formatPages(state.targetEighths - boardEighths(state))} under` : "on it"} (the number to use until the scenes are written; page_count is the script so far)`,
1523
1531
  `groups: ${
1524
1532
  state.groups.length
1525
1533
  ? state.groups
@@ -1532,6 +1540,8 @@ server.registerTool(
1532
1540
  : "(none)"
1533
1541
  }`,
1534
1542
  `pages: ${written === 0 ? "all estimates — no scene is written yet, so every card is the writer's guess" : written === state.notes.length ? "measured — every scene is written" : `estimates — ${written} of ${state.notes.length} cards are written${written <= 5 ? ` (${state.notes.filter((note) => isMeasured(note)).map((note) => `"${note.headline}"`).join(", ")})` : ""}, the rest are guesses`}`,
1543
+ // A wall with cards and no follows arrows has no story order yet; say so rather than read the rows as one (round seventeen, entries 10, 11).
1544
+ `story order: ${state.notes.length > 1 && !state.arrows.some((arrow) => arrow.kind !== "setup") ? "unset — no follows arrows, so the rows stand in for it; create_arrow the sequence and the reading, the numbers and every export follow the arrows" : "the follows arrows, and the rows where they say nothing"}`,
1535
1545
  `beats in wall order: ${
1536
1546
  reading.beats.length
1537
1547
  ? reading.beats.map((beat) => `"${beat.headline}"`).join(", ")
@@ -1545,6 +1555,9 @@ server.registerTool(
1545
1555
  : [reading.paidBy.length ? " (no setup arrow on this board; what pays off a fold of another board is listed below)" : " (no arrow is marked as a setup)"]),
1546
1556
  ...reading.later.map((item) => ` - "${state.notes.find((note) => note.id === item.id)?.headline ?? item.id}" is folded and pays off later, on "${boardById(projectForRead, item.boardId)?.name ?? item.boardId}"${item.noteId ? `, at ${episodeLabel(projectForRead, boardsNow, item.boardId, item.noteId)} "${boardsNow[item.boardId]?.notes?.find((note) => note.id === item.noteId)?.headline ?? item.noteId}"` : " — no scene there claims it yet"}`),
1547
1557
  ...reading.paidBy.map((item) => ` - "${state.notes.find((note) => note.id === item.id)?.headline ?? item.id}" pays off "${item.fromHeadline}" from "${item.fromBoardName}" (${episodeLabel(projectForRead, boardsNow, item.fromBoardId, item.fromNoteId)}), one board earlier`),
1558
+ ...(reading.open.length
1559
+ ? ["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}`)]
1560
+ : []),
1548
1561
  "questions the wall raises:",
1549
1562
  ...(reading.findings.length
1550
1563
  ? reading.findings.map((finding) => ` - [${finding.kind}] ${finding.text}${finding.ids.length ? ` (ids: ${finding.ids.join(", ")})` : ""}`)
@@ -1562,8 +1575,12 @@ server.registerTool(
1562
1575
  const counts = new Map();
1563
1576
  for (const finding of asked) counts.set(finding.kind, (counts.get(finding.kind) ?? 0) + 1);
1564
1577
  return `asking ${asked.length} question${asked.length === 1 ? "" : "s"} of ${counts.size} kind${counts.size === 1 ? "" : "s"}: ${[...counts.entries()].map(([kind, n]) => (n > 1 ? `${kind} ×${n}` : kind)).join(", ")}${held}`;
1565
- })()}${reading.left.length ? `; left by the writer, so not clean: ${[...new Set(reading.left.map((finding) => finding.kind))].map((kind) => `[${kind}]`).join(" ")}` : ""}; checked and clean: ${CHECKS.filter((kind) => !reading.findings.some((finding) => finding.kind === kind) && !reading.left.some((finding) => finding.kind === kind)).map((kind) => {
1566
- if (kind === "unlinked" && state.arrows.length === 0) return "no card without an arrow (not asked: no arrows yet)";
1578
+ })()}${reading.left.length ? `; left by the writer, so not clean: ${[...new Set(reading.left.map((finding) => finding.kind))].map((kind) => `[${kind}]`).join(" ")}` : ""}${reading.open.length ? `; ${reading.open.length} card${reading.open.length === 1 ? "" : "s"} open by the writer's word, not asked` : ""}; checked and clean: ${CHECKS.filter((kind) => !reading.findings.some((finding) => finding.kind === kind) && !reading.left.some((finding) => finding.kind === kind)).map((kind) => {
1579
+ if (kind === "unlinked" && state.arrows.length === 0) return "no card without an arrow (not asked until half the cards are wired: no arrows yet)";
1580
+ if (kind === "unlinked") {
1581
+ const linked = new Set(state.arrows.flatMap((arrow) => [arrow.from, arrow.to]));
1582
+ if (linked.size * 2 < state.notes.length) return `no card without an arrow (not asked until half the cards are wired: ${linked.size} of ${state.notes.length} are)`;
1583
+ }
1567
1584
  if (kind === "unplaced" && !state.notes.some((note) => (note.location ?? "").trim())) return "no card without a place (not asked: no card placed yet)";
1568
1585
  if (kind === "sequence" && state.groups.length === 0) return "no group too long for one sequence (not asked: no groups)";
1569
1586
  return CHECK_WORDS[kind];
@@ -2653,6 +2670,33 @@ server.registerTool(
2653
2670
  },
2654
2671
  );
2655
2672
 
2673
+ // The open card (R59): the writer's word that a card is not decided. The
2674
+ // per-card twin of leave_question — that one leaves a question, this one
2675
+ // leaves a card.
2676
+ server.registerTool(
2677
+ "set_open",
2678
+ {
2679
+ title: "Leave a card open",
2680
+ description:
2681
+ "Mark one or more cards open, with the writer's words for what is not decided — \"where, and whether Ruth is there\", \"the buyer\". An open card is listed by read_wall under its own head and asked nothing else of while the words stand: not its place, not its change line, not its arrows. It is still counted, in the order, and a page. open \"\" closes the card and its questions come back on their own. Only on the writer's word — a card is open because they said so, never because you could not decide; where the notes have two versions, ask, and if they say leave it, this is how.",
2682
+ inputSchema: { ids: z.array(z.string()).min(1), open: z.string() },
2683
+ },
2684
+ async (args) => {
2685
+ const { state, changed, result, live } = await commit({ type: "set_open", ids: args.ids, open: args.open });
2686
+ if (!changed) {
2687
+ const missing = args.ids.filter((id) => !state.notes.some((note) => note.id === id));
2688
+ return ok(missing.length ? `No card with id ${missing.join(", ")}. Call list_board for the real ids.` : `Nothing changed: ${args.ids.length === 1 ? "the card already says that" : "those cards already say that"}.`);
2689
+ }
2690
+ const words = result[0]?.open ?? "";
2691
+ return ok(
2692
+ words
2693
+ ? `${result.length} card(s) open: "${words}"${where(live)}. The reading lists ${result.length === 1 ? "it" : "them"} under "open, by the writer's word" and asks nothing else of ${result.length === 1 ? "it" : "them"} while the words stand; the card wears the words on its edge. set_open with "" closes it.`
2694
+ : `${result.length} card(s) closed${where(live)}: decided, so the wall's questions about ${result.length === 1 ? "it" : "them"} come back on their own.`,
2695
+ result,
2696
+ );
2697
+ },
2698
+ );
2699
+
2656
2700
  server.registerTool(
2657
2701
  "set_plant",
2658
2702
  {
@@ -2985,34 +3029,30 @@ server.registerTool(
2985
3029
  {
2986
3030
  title: "Cast a scene",
2987
3031
  description:
2988
- "Set who is in one or more cards of the open board (open_board first for another board's cards). Takes card ids and character names or ids; the list replaces the card's cast, so pass everyone who is in the scene. An empty list clears it. Names must already be in the cast add_character first and the tool says which names it did not know.",
3032
+ "Set who is in one or more cards of the open board (open_board first for another board's cards). Takes card ids and character names or ids; the list replaces the card's cast, so pass everyone who is in the scene. An empty list clears it. A name not yet in the cast is added to it, as create_note does, and the reply says so the writer named them, so it is not inventing; a role is a name.",
2989
3033
  inputSchema: {
2990
3034
  noteIds: z.array(z.string()).min(1),
2991
3035
  characters: z.array(z.string()),
2992
3036
  },
2993
3037
  },
2994
3038
  async (args) => {
2995
- const { state: before } = await readBoard();
2996
- const unknown = [];
2997
- const characterIds = [];
2998
- for (const who of args.characters) {
2999
- const match = before.characters.find(
3000
- (character) =>
3001
- character.id === who || character.name.trim().toLowerCase() === who.trim().toLowerCase(),
3002
- );
3003
- if (match) characterIds.push(match.id);
3004
- else unknown.push(who);
3005
- }
3006
- if (unknown.length > 0) {
3007
- return ok(
3008
- `No cast set: not in the cast — ${unknown.map((name) => `"${name}"`).join(", ")}. Call add_character for each, then cast again.`,
3009
- );
3010
- }
3011
- const { state, changed, result, live } = await commit({
3012
- type: "set_cast",
3013
- ids: args.noteIds,
3014
- characterIds,
3039
+ // A name the cast does not have is added to it in the same frame, as
3040
+ // create_note does — two tools, one rule (round seventeen, entry 14).
3041
+ const added = [];
3042
+ const { state, changed, value: result, live } = await commitAll(`cast ${args.noteIds.length} card(s)`, (step, current) => {
3043
+ const characterIds = [];
3044
+ for (const who of args.characters) {
3045
+ const wanted = who.trim().toLowerCase();
3046
+ let person = current().characters.find((character) => character.id === who || character.name.trim().toLowerCase() === wanted);
3047
+ if (!person && wanted) {
3048
+ person = step({ type: "add_character", name: who.trim() }).result;
3049
+ if (person) added.push(`${person.name} (${person.id})`);
3050
+ }
3051
+ if (person && !characterIds.includes(person.id)) characterIds.push(person.id);
3052
+ }
3053
+ return step({ type: "set_cast", ids: args.noteIds, characterIds }).result;
3015
3054
  });
3055
+ const characterIds = (result ?? []).length ? result[0].characterIds : [];
3016
3056
  if (!changed) {
3017
3057
  const missing = args.noteIds.filter((id) => !state.notes.some((note) => note.id === id));
3018
3058
  return ok(
@@ -3025,7 +3065,7 @@ server.registerTool(
3025
3065
  (id) => state.characters.find((character) => character.id === id)?.name ?? id,
3026
3066
  );
3027
3067
  return ok(
3028
- `${result.length} card(s) now cast ${names.length ? names.join(", ") : "nobody"}: ${result.map((note) => `"${note.headline}"`).join(", ")}${where(live)}.`,
3068
+ `${result.length} card(s) now cast ${names.length ? names.join(", ") : "nobody"}: ${result.map((note) => `"${note.headline}"`).join(", ")}${added.length ? ` (added to the cast: ${added.join(", ")})` : ""}${where(live)}.`,
3029
3069
  result,
3030
3070
  );
3031
3071
  },
@@ -3251,7 +3291,7 @@ server.registerTool(
3251
3291
  {
3252
3292
  title: "Set the project's premise",
3253
3293
  description:
3254
- "Set the project's premise: the series- or story-level line above every board's logline. An empty string clears it. Boards keep their own loglines.",
3294
+ "Set the project's premise: the line above every board's logline, held by the project whatever its board count — what a series is about, or what is true before a film starts ('the third year; the crowns can be cut for the first time'). An empty string clears it. Boards keep their own loglines.",
3255
3295
  inputSchema: { premise: z.string() },
3256
3296
  },
3257
3297
  async (args) => {
@@ -7,7 +7,7 @@
7
7
 
8
8
  export const AGENTS = {
9
9
  lead:
10
- "A storyline wall. Cards are scenes, beats are the big turns, arrows say what follows or pays off what. An agent driven by a person has every tool a person here has; the person directs, the agent operates. Call the tools; never fake a mouse. Already have the plotcoder-board tools in front of you? Then your way in is: read the guide, make the four calls under Call these first, and skip the doors between — they are for wiring a server in.",
10
+ "A storyline wall. Cards are scenes, beats are the big turns, arrows say what follows or pays off what. An agent driven by a person has every tool a person here has; the person directs, the agent operates. Call the tools; never fake a mouse. Already have the plotcoder-board tools in front of you? Then your way in is: read the guide, make the five calls under Call these first, and skip the doors at the end — they are for wiring a server in.",
11
11
  doors: [
12
12
  {
13
13
  id: "mcp",
@@ -42,7 +42,7 @@ export const AGENTS = {
42
42
  text: "Skip this when the account is the wall. Without an account, a wall is a folder: any folder, empty is fine — choose one that will outlive your session, never a scratch one. The app run from that folder shows the wall, and the server writes it there (PLOTCODER_ROOT, or the folder it is run from). A fresh folder holds the sample; new_board for the writer's wall, then rename_project. No app running? export_fountain is the wall in order, as text.",
43
43
  },
44
44
  ],
45
- firstNote: "Make these five before anything else; none depends on another, so any order is fine. list_words and list_workflows are the app's and read no project, and their first line says so; every other reading opens with the door it came through — the project it read and how many the account holds — and a write's tail says where it landed; list_projects lists the projects. read_wall, list_reminders and list_board are about the wall you will work, so after open_project or open_board make those three again, and after new_project read the wall once it holds cards. An emptied account has nothing to read: go straight to new_project (name, pages, and board for the first board's name). On an account with no project yet, read_wall has nothing to read and says so, and list_reminders gives the house principles every project starts with. No server in front of you, and no shell to take the shell door? Nothing gets you in from inside the session: say so, and ask the person to wire the server and start a new session.",
45
+ firstNote: "Make these five before anything else; none depends on another, so any order is fine. list_words and list_workflows are the app's and read no project, and their first line says so; every other reading opens with the door it came through — the project it read and how many the account holds — and a write's tail says where it landed; list_projects lists the projects. read_wall, list_reminders and list_board are about the wall you will work, so after open_project or open_board make those three again, and after new_project read the wall once it holds cards. An emptied account has nothing to read: go straight to new_project: a name is enough; pages sets the target and board names the first board when the writer has them, and set_target and rename_board set them later. On an account with no project yet, read_wall has nothing to read and says so, and list_reminders gives the house principles every project starts with. No server in front of you, and no shell to take the shell door? Nothing gets you in from inside the session: say so, and ask the person to wire the server and start a new session.",
46
46
  first: [
47
47
  { tool: "list_words", why: "the room's words, the app's meaning." },
48
48
  { tool: "read_wall", why: "the reading: the beats, the runs, the setups, and what the wall asks. The records — every card, the cast, the places, the rows — are list_board's. A fresh folder holds a sample wall (Maya, Tom, the letter) and the reading says so only when it is the sample; it is not the writer's." },
@@ -20,6 +20,10 @@ export type FindingKind =
20
20
  | "unlinked"
21
21
  /** Two headlines read like the same scene. */
22
22
  | "duplicate"
23
+ /** A setup arrow leaves a card whose corner is not folded: a payoff with no fold. */
24
+ | "unplanted"
25
+ /** A card with nobody in it, on a wall that has a cast. */
26
+ | "nobody"
23
27
  /** A group runs too long to be one sequence. */
24
28
  | "sequence"
25
29
  /** A character in the roster who is on no card. */
@@ -63,6 +67,8 @@ export type WallReading = {
63
67
  payoffs: Record<string, string[]>;
64
68
  /** Folded cards that pay off on another board of the project (R50): the card, the board, and the scene there that claims it (R58) or null while the board is a promise. */
65
69
  later: { id: string; boardId: string; noteId: string | null }[];
70
+ /** Open cards (R59): the writer's words for what is not decided, in story order; not asked about while they stand. */
71
+ open: Array<{ id: string; words: string }>;
66
72
  /** Cards here that pay off a fold of another board (R58), composed by the door from the project. */
67
73
  paidBy: Array<{ id: string; fromBoardId: string; fromBoardName: string; fromNoteId: string; fromHeadline: string; fromColor: string }>;
68
74
  /** The questions the wall asks now. A left one (R53) is not here while its words hold. */
@@ -17,7 +17,7 @@
17
17
  // before any tidy — the arrows are the writer's claim about the order, and
18
18
  // where they say nothing the positions decide.
19
19
 
20
- import { boardEighths, EIGHTHS_PER_PAGE, formatPages, NOTE_HEIGHT, noteEighths } from "./reducer.js";
20
+ import { isMeasured, boardEighths, EIGHTHS_PER_PAGE, formatPages, NOTE_HEIGHT, noteEighths } from "./reducer.js";
21
21
 
22
22
  /** What create_note writes before a person has. */
23
23
  export const PLACEHOLDER_HEADLINE = "New beat";
@@ -197,6 +197,10 @@ export function readWall(state, options = {}) {
197
197
  if (from !== null && cards > 0) runs.push({ from, to: null, eighths, cards, ids });
198
198
 
199
199
  const findings = [];
200
+ // Open cards (R59): the writer's word that a card is not decided. Listed,
201
+ // and asked nothing else while the words stand — the card's own "leave it".
202
+ const openIds = new Set(order.filter((note) => (note.open ?? "").trim()).map((note) => note.id));
203
+ const askable = (note) => !openIds.has(note.id);
200
204
  const byId = new Map(state.notes.map((note) => [note.id, note]));
201
205
  const headline = (id) => (id ? byId.get(id)?.headline ?? id : null);
202
206
  const position = new Map(order.map((note, index) => [note.id, index]));
@@ -282,7 +286,7 @@ export function readWall(state, options = {}) {
282
286
  // A card that says no place, once the writer has started placing cards.
283
287
  // One question however many there are; a wall with no places at all is a
284
288
  // wall the writer has not placed yet, and is not asked.
285
- const unplaced = order.filter((note) => !(note.location ?? "").trim());
289
+ const unplaced = order.filter((note) => askable(note) && !(note.location ?? "").trim());
286
290
  if (unplaced.length > 0 && unplaced.length < order.length) {
287
291
  findings.push({
288
292
  kind: "unplaced",
@@ -296,6 +300,7 @@ export function readWall(state, options = {}) {
296
300
 
297
301
  // A card that has not earned its place yet.
298
302
  for (const note of order) {
303
+ if (!askable(note)) continue;
299
304
  const change = (note.change ?? "").trim();
300
305
  const title = (note.headline ?? "").trim();
301
306
  if (title === "" || title === PLACEHOLDER_HEADLINE) {
@@ -320,7 +325,7 @@ export function readWall(state, options = {}) {
320
325
  linked.add(arrow.to);
321
326
  }
322
327
  if (state.notes.length > 0 && linked.size * 2 >= state.notes.length) {
323
- const loose = order.filter((note) => !linked.has(note.id));
328
+ const loose = order.filter((note) => askable(note) && !linked.has(note.id));
324
329
  if (loose.length > 0) {
325
330
  findings.push({
326
331
  kind: "unlinked",
@@ -359,6 +364,20 @@ export function readWall(state, options = {}) {
359
364
  }
360
365
  }
361
366
 
367
+ // A payoff with no fold (round seventeen, entry 12): a setup arrow leaves a
368
+ // card whose corner is not folded. The arrow says "this pays off", the
369
+ // card says nothing was planted — ask which.
370
+ for (const setup of setups) {
371
+ const tail = byId.get(setup.from);
372
+ if (tail && !tail.plants) {
373
+ findings.push({
374
+ kind: "unplanted",
375
+ ids: [setup.id, setup.from],
376
+ text: `${quote(tail)} pays off at ${quote(byId.get(setup.to))} by a setup arrow, but its corner is not folded. Fold it, or is the arrow wrong?`,
377
+ });
378
+ }
379
+ }
380
+
362
381
  // A folded corner nothing has paid off (R31). The fold says "this plants
363
382
  // something"; a setup arrow leaving the card is the payoff. Until one does,
364
383
  // the debt is open.
@@ -416,6 +435,19 @@ export function readWall(state, options = {}) {
416
435
  const hereIds = new Set(order.map((note) => note.id));
417
436
  const paidHere = paidBy.filter((item) => hereIds.has(item.id));
418
437
 
438
+ // A card with nobody in it, once the wall has a cast (round seventeen,
439
+ // entry 28): a scene nobody is in passed every check.
440
+ if ((state.characters ?? []).length > 0) {
441
+ const empty = order.filter((note) => askable(note) && !(note.characterIds ?? []).length);
442
+ if (empty.length) {
443
+ findings.push({
444
+ kind: "nobody",
445
+ ids: empty.map((note) => note.id),
446
+ text: `${empty.length === 1 ? `${quote(empty[0])} has nobody in it` : `${empty.length} cards have nobody in them: ${empty.map((note) => quote(note)).join(", ")}`}. Who is in the scene${empty.length === 1 ? "" : "s"}?`,
447
+ });
448
+ }
449
+ }
450
+
419
451
  // The cast (R29): someone who vanishes for a stretch, or never appears.
420
452
  const total = boardEighths(state);
421
453
  const at = new Map();
@@ -472,7 +504,10 @@ export function readWall(state, options = {}) {
472
504
  // it is a new question and is asked. The kernel never decides this; the
473
505
  // reading does, on every read.
474
506
  const left = [];
507
+ // A question whose every card is open is not asked (R59): the writer's word covers it.
508
+ 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))) : [];
475
509
  const asked = findings.filter((finding) => {
510
+ if (openCards.includes(finding)) return false;
476
511
  const entry = (state.left ?? []).find(
477
512
  (item) => item.kind === finding.kind && sameList(item.ids, finding.ids) && item.text === finding.text,
478
513
  );
@@ -489,6 +524,7 @@ export function readWall(state, options = {}) {
489
524
  payoffs,
490
525
  later,
491
526
  paidBy: paidHere,
527
+ open: order.filter((note) => openIds.has(note.id)).map((note) => ({ id: note.id, words: note.open.trim() })),
492
528
  findings: asked,
493
529
  left,
494
530
  };
@@ -525,6 +561,10 @@ export function describeRuns(reading, state) {
525
561
  ? `After "${name(run.from)}"`
526
562
  : `"${name(run.from)}" → "${name(run.to)}"`;
527
563
  const count = run.cards === 1 ? "1 card" : `${run.cards} cards`;
528
- return `${span}: about ${pages(run.eighths)} pages, ${count}`;
564
+ // Whose number a run's pages are (round seventeen, entry 39): measured
565
+ // from written text, or the cards' guess, or some of each.
566
+ const written = (run.ids ?? []).filter((id) => isMeasured(byId.get(id) ?? {})).length;
567
+ const whose = !run.cards ? "" : written === run.cards ? ", measured" : written ? `, ${written} of ${run.cards} measured` : ", estimated";
568
+ return `${span}: about ${pages(run.eighths)} pages, ${count}${whose}`;
529
569
  });
530
570
  }
@@ -83,6 +83,8 @@ export type BoardNote = {
83
83
  payoffBoardId: string | null;
84
84
  /** The scene on that board that pays it off (R58), or null while the board is only a promise. */
85
85
  payoffNoteId: string | null;
86
+ /** The writer's words for what is not decided about this card (R59), or empty: the reading lists an open card and asks nothing else of it. */
87
+ open: string;
86
88
  /** Where the scene happens (R37): a phrase in the writer's words; empty until set. */
87
89
  location: string;
88
90
  /** When the scene happens, as the writer says it — "night", "day four, dawn" — printed after the place on the heading (R55). Empty when unsaid. */
@@ -162,6 +164,7 @@ export type Command =
162
164
  plants?: boolean;
163
165
  location?: string;
164
166
  when?: string;
167
+ open?: string;
165
168
  text?: string;
166
169
  }
167
170
  | { type: "update_note"; id: string; headline?: string; change?: string; location?: string; when?: string }
@@ -186,6 +189,7 @@ export type Command =
186
189
  | ({ type: "update_character"; id: string } & Partial<Record<CharacterField, string>>)
187
190
  | { type: "set_cast"; ids: string[]; characterIds: string[] }
188
191
  | { type: "set_plant"; ids: string[]; plants: boolean }
192
+ | { type: "set_open"; ids: string[]; open: string }
189
193
  | { type: "set_payoff_board"; ids: string[]; boardId: string | null; noteId?: string | null }
190
194
  | { type: "set_location"; ids: string[]; location: string }
191
195
  | { type: "set_when"; ids: string[]; when: string }
@@ -253,6 +253,10 @@ export function seedState(now = nowIso()) {
253
253
  // does (R58); null while the board is a promise.
254
254
  payoffBoardId: null,
255
255
  payoffNoteId: null,
256
+ // Open: the writer's words for what is not decided about this card, or
257
+ // nothing (R59). While they stand the reading lists the card and asks
258
+ // nothing else of it.
259
+ open: "",
256
260
  createdAt: now,
257
261
  updatedAt: now,
258
262
  });
@@ -350,6 +354,8 @@ export function normalizeState(value) {
350
354
  const payoffBoardId = plants && typeof note?.payoffBoardId === "string" && note.payoffBoardId ? note.payoffBoardId : null;
351
355
  // Cards written before R58 name a board and no scene on it.
352
356
  const payoffNoteId = payoffBoardId && typeof note?.payoffNoteId === "string" && note.payoffNoteId ? note.payoffNoteId : null;
357
+ // Cards written before R59 are not open; a card claims to be decided until the writer says otherwise.
358
+ const open = typeof note?.open === "string" ? note.open : "";
353
359
  // Cards written before R37 have no place; a scene is nowhere until it is.
354
360
  const location = typeof note?.location === "string" ? note.location : "";
355
361
  // Cards written before R55 have no when; a scene is at no time until it is.
@@ -365,6 +371,7 @@ export function normalizeState(value) {
365
371
  note.plants === plants &&
366
372
  note.payoffBoardId === payoffBoardId &&
367
373
  note.payoffNoteId === payoffNoteId &&
374
+ note.open === open &&
368
375
  note.location === location &&
369
376
  note.when === when &&
370
377
  note.text === text
@@ -372,7 +379,7 @@ export function normalizeState(value) {
372
379
  return note;
373
380
  }
374
381
  patched = true;
375
- return { ...note, rank, lengthEighths, characterIds, plants, payoffBoardId, payoffNoteId, location, when, text };
382
+ return { ...note, rank, lengthEighths, characterIds, plants, payoffBoardId, payoffNoteId, open, location, when, text };
376
383
  });
377
384
 
378
385
  // Boards written before the production half (Roadmap 2, item 8) have no
@@ -423,6 +430,11 @@ function bump(note, patch, now) {
423
430
  return { ...note, ...patch, updatedAt: now };
424
431
  }
425
432
 
433
+ /** The writer's words for what is open about a card, one line, spaces collapsed; empty closes it (R59). */
434
+ function cleanOpen(value) {
435
+ return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
436
+ }
437
+
426
438
  /** A when as the writer typed it, one line, spaces collapsed. */
427
439
  function cleanWhen(value) {
428
440
  return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
@@ -459,6 +471,7 @@ export function applyCommand(state, command, now = nowIso()) {
459
471
  plants: command.plants === true,
460
472
  payoffBoardId: null,
461
473
  payoffNoteId: null,
474
+ open: cleanOpen(command.open),
462
475
  location: cleanPlace(command.location),
463
476
  when: cleanWhen(command.when),
464
477
  text: typeof command.text === "string" ? command.text : "",
@@ -917,6 +930,7 @@ export function applyCommand(state, command, now = nowIso()) {
917
930
  plants: false,
918
931
  payoffBoardId: null,
919
932
  payoffNoteId: null,
933
+ open: "",
920
934
  location: "",
921
935
  when: "",
922
936
  text: "",
@@ -1037,6 +1051,23 @@ export function applyCommand(state, command, now = nowIso()) {
1037
1051
  return { state: { ...state, notes }, changed: true, result: touched };
1038
1052
  }
1039
1053
 
1054
+ // Leave a card open (R59): the writer's words for what is not decided,
1055
+ // or nothing. A claim about the card, like rank: it never moves it.
1056
+ case "set_open": {
1057
+ const ids = new Set(command.ids);
1058
+ if (ids.size === 0) return { state, changed: false };
1059
+ const open = cleanOpen(command.open);
1060
+ const touched = [];
1061
+ const notes = state.notes.map((note) => {
1062
+ if (!ids.has(note.id) || (note.open ?? "") === open) return note;
1063
+ const next = bump(note, { open }, now);
1064
+ touched.push(next);
1065
+ return next;
1066
+ });
1067
+ if (touched.length === 0) return { state, changed: false };
1068
+ return { state: { ...state, notes }, changed: true, result: touched };
1069
+ }
1070
+
1040
1071
  // Fold the corner (R31): this card plants something. A claim about the
1041
1072
  // card, like rank, so it never moves it and never touches its arrows.
1042
1073
  case "set_plant": {
@@ -36,7 +36,7 @@ export const WORD_GROUPS = [
36
36
  {
37
37
  id: "premise",
38
38
  name: "The premise",
39
- sentence: "Above the logline when a project has several boards: the line the whole series is about.",
39
+ sentence: "The line above every board's logline, the project's whatever its board count: what a series is about, or what is true before a film starts.",
40
40
  },
41
41
  {
42
42
  id: "beat",
@@ -63,6 +63,11 @@ export const WORD_GROUPS = [
63
63
  sentence: "The second line. If nothing is different after the scene, the scene is usually decoration.",
64
64
  target: "change",
65
65
  },
66
+ {
67
+ id: "open",
68
+ name: "Open",
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
+ },
66
71
  {
67
72
  id: "corner",
68
73
  name: "The folded corner",