plotcoder-board 0.1.22 → 0.1.23

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
@@ -51,10 +51,10 @@ 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. Fifteen rounds have been run, the first three through a
54
+ just what produces it. Sixteen 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 fourteen is fixed or decided, and thirty-four of round fifteen's
57
- forty-five were fixed the same evening, while it ran. [`blind-runs/`](blind-runs/) holds the rules that keep a round honest,
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,
58
58
  the table of rounds, and the next round's prompt with the test account filled in.
59
59
 
60
60
  A round works a **test account** — a throwaway marked on its writer row, and the
@@ -99,4 +99,4 @@ next round into a test of `claim_account` instead of the door it meant to test.
99
99
 
100
100
  ## Status
101
101
 
102
- Version 0.1.22. 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.
102
+ Version 0.1.23. 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.22",
3
+ "version": "0.1.23",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite",
@@ -55,7 +55,7 @@ import crypto from "node:crypto";
55
55
  import { describeRuns, describeSetups, readWall } from "../src/board/readWall.js";
56
56
  import { compareStructure, describeComparison, MATCH_PAGES } from "../src/board/compareStructure.js";
57
57
  import { GAP, ROW_WIDTH, organizePoses } from "../src/board/organize.js";
58
- import { sceneLineCount } from "../src/board/paginate.js";
58
+ import { parseScene, sceneLineCount } from "../src/board/paginate.js";
59
59
  import {
60
60
  addBoard,
61
61
  addStructure,
@@ -831,12 +831,13 @@ function changeNote() {
831
831
  const parts = [];
832
832
  if (change.gone.length || change.came.length) {
833
833
  parts.push(
834
- `the wall now asks ${change.asks} question${change.asks === 1 ? "" : "s"}${change.gone.length ? ` (gone: ${change.gone.map((finding) => `[${finding.kind}]`).join(" ")})` : ""}${change.came.length ? ` (new: ${change.came.map((finding) => `[${finding.kind}] ${finding.text}`).join(" ")})` : ""}`,
834
+ `the wall now asks ${change.asks} question${change.asks === 1 ? "" : "s"}${change.gone.length ? ` (gone: ${change.gone.map((finding) => `[${finding.kind}] ${finding.text}`).join(" ")})` : ""}${change.came.length ? ` (new: ${change.came.map((finding) => `[${finding.kind}] ${finding.text}`).join(" ")})` : ""}`,
835
835
  );
836
836
  }
837
837
  if (change.leftAfter !== change.leftBefore) parts.push(`left, for now: ${change.leftAfter} (was ${change.leftBefore})`);
838
838
  if (change.eighthsAfter !== change.eighthsBefore) parts.push(`runtime now about ${formatPages(change.eighthsAfter)} of ${formatPages(change.target)} pages`);
839
- return parts.length ? ` ${parts.join("; ")}.` : "";
839
+ // No full stop of its own: the reply it rides on ends the sentence (round sixteen, entry 11).
840
+ return parts.length ? `; ${parts.join("; ")}` : "";
840
841
  }
841
842
  /** Said once per session, so a reply does not repeat its advice eighteen times (round thirteen, entry 10). */
842
843
  const saidOnce = new Set();
@@ -985,13 +986,35 @@ const SAMPLE_NOTE = "sample: this is the wall PlotCoder starts with (Maya, Tom,
985
986
 
986
987
  // --- Reporting -------------------------------------------------------------
987
988
 
989
+ /**
990
+ * The cues in a scene's text against the cast (round sixteen, entry 30): a cue
991
+ * matches a person by the whole name, so DANA is not "Dana Kerr", and the
992
+ * reply says which cues found nobody and whom they nearly named.
993
+ */
994
+ function cueReport(state, text) {
995
+ const names = [...new Set(parseScene(text ?? "").filter((element) => element.kind === "speech" && element.name).map((element) => element.name.replace(/\s*\(.*\)\s*$/, "").trim()).filter(Boolean))];
996
+ if (!names.length) return "";
997
+ const cast = state.characters ?? [];
998
+ const parts = names.map((cue) => {
999
+ const whole = cast.find((person) => person.name.trim().toLowerCase() === cue.toLowerCase());
1000
+ if (whole) return `${cue} (in the cast)`;
1001
+ const near = cast.filter((person) => person.name.toLowerCase().split(/\s+/).includes(cue.toLowerCase()));
1002
+ return near.length
1003
+ ? `${cue} (nobody by that whole name — cues match by the whole name, and the cast has ${near.map((person) => `"${person.name}"`).join(", ")}: cue ${near.length === 1 ? near[0].name.toUpperCase() : "the whole name"})`
1004
+ : `${cue} (nobody in the cast)`;
1005
+ });
1006
+ return ` Cues: ${parts.join("; ")}.`;
1007
+ }
1008
+
988
1009
  /** What kinds of number a runtime folds together: measured from text, set by the writer, or the default page (round fifteen, entry 39). */
989
1010
  function runtimeKinds(state) {
990
- const measured = state.notes.filter((note) => isMeasured(note)).length;
991
- const sized = state.notes.filter((note) => !isMeasured(note) && note.lengthEighths !== null).length;
992
- const unsized = state.notes.length - measured - sized;
1011
+ const measured = state.notes.filter((note) => isMeasured(note));
1012
+ const sized = state.notes.filter((note) => !isMeasured(note) && note.lengthEighths !== null);
1013
+ const unsized = state.notes.filter((note) => !isMeasured(note) && note.lengthEighths === null);
993
1014
  if (!state.notes.length) return "";
994
- return `; of its ${state.notes.length} cards, ${measured} measured from written text, ${sized} sized by the writer, ${unsized} unsized and read as a page each`;
1015
+ const sum = (notes) => formatPages(notes.reduce((total, note) => total + noteEighths(note), 0));
1016
+ // Pages per kind, not only cards (round sixteen, entry 43).
1017
+ return `; of its ${state.notes.length} cards, ${measured.length} measured from written text (${sum(measured)} pages), ${sized.length} sized by the writer (${sum(sized)}), ${unsized.length} unsized and read as a page each (${sum(unsized)})`;
995
1018
  }
996
1019
 
997
1020
  function summarize(state) {
@@ -1013,7 +1036,9 @@ function summarize(state) {
1013
1036
  const place = note.location ? `, at: ${note.location}` : "";
1014
1037
  const when = note.when ? `, when: ${note.when}` : "";
1015
1038
  const count = formatPages(noteEighths(note));
1016
- const pages = isMeasured(note) ? `${count} ${count === "1" ? "page" : "pages"}, written` : note.lengthEighths === null ? "about a page, unsized" : `${count} ${count === "1" ? "page" : "pages"}`;
1039
+ // A written card's estimate is kept underneath for when the text goes; say it, or it is invisible (round sixteen, entry 44).
1040
+ const underneath = isMeasured(note) && note.lengthEighths !== null ? `; the writer's estimate underneath: ${formatPages(note.lengthEighths)}` : "";
1041
+ const pages = isMeasured(note) ? `${count} ${count === "1" ? "page" : "pages"}, written${underneath}` : note.lengthEighths === null ? "about a page, unsized" : `${count} ${count === "1" ? "page" : "pages"}`;
1017
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)}`;
1018
1043
  })
1019
1044
  .join("\n");
@@ -1024,7 +1049,9 @@ function summarize(state) {
1024
1049
  // brief and who is still a name.
1025
1050
  const page = filledCharacterFields(character);
1026
1051
  const brief = page.length ? ` · page: ${page.join(", ")}` : " · page: empty";
1027
- return ` - ${character.id} "${character.name}" on ${on} card${on === 1 ? "" : "s"}${brief}`;
1052
+ // On this board, and on the others (R51): a per-board count beside a project-wide check read as a contradiction (round sixteen, entry 18).
1053
+ const away = lastHeld?.project ? (castElsewhere(lastHeld.project, lastHeld.boards, lastHeld.project.activeBoardId)[character.id] ?? []).reduce((sum, item) => sum + item.cards, 0) : 0;
1054
+ return ` - ${character.id} — "${character.name}" on ${on} card${on === 1 ? "" : "s"} of this board${away ? ` and ${away} of other boards` : ""}${brief}`;
1028
1055
  })
1029
1056
  .join("\n");
1030
1057
  const placeCounts = new Map();
@@ -1276,6 +1303,8 @@ server.registerTool(
1276
1303
  rank: rankSchema.optional(),
1277
1304
  pages: pagesSchema.optional(),
1278
1305
  plants: z.boolean().optional(),
1306
+ after: z.string().optional().describe("Wire the new scene into the story after this card (id or headline): one call, one number under a lock. Needs follows arrows on the wall."),
1307
+ before: z.string().optional().describe("Or before this card (id or headline)."),
1279
1308
  location: z.string().optional(),
1280
1309
  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.'),
1281
1310
  characters: z.array(z.string().min(1)).optional(),
@@ -1284,11 +1313,19 @@ server.registerTool(
1284
1313
  },
1285
1314
  },
1286
1315
  async (args) => {
1287
- const landing = args.x === undefined && args.y === undefined ? nextPlace((await readBoard()).state) : { x: args.x, y: args.y };
1316
+ const { state } = await readBoard();
1317
+ const landing = args.x === undefined && args.y === undefined ? nextPlace(state) : { x: args.x, y: args.y };
1288
1318
  const names = (args.characters ?? []).map((name) => name.trim()).filter(Boolean);
1289
1319
  const added = [];
1290
1320
  // The card, anyone new in its cast, and the casting land as one change, so
1291
1321
  // one ⌘Z on the wall takes back the whole call and not just the cast.
1322
+ if (args.after && args.before) return ok("Say where: after one card, or before one, not both.");
1323
+ // The card beside which the new scene goes (round sixteen, entry 36): by id or headline, on this board.
1324
+ const besideKey = (args.after ?? args.before ?? "").trim();
1325
+ const beside = besideKey ? state.notes.find((note) => note.id === besideKey) ?? state.notes.find((note) => note.headline.trim().toLowerCase() === besideKey.toLowerCase()) ?? null : null;
1326
+ if (besideKey && !beside) return ok(`No card with id or headline "${besideKey}" on this board. Call list_board.`);
1327
+ const wallHasFollows = state.arrows.some((arrow) => arrow.kind !== "setup");
1328
+ let joinedGroup = null;
1292
1329
  const { value: result, live, state: after } = await commitAll(`create_note "${args.headline}"`, (step, current) => {
1293
1330
  let made = step({
1294
1331
  type: "create_note",
@@ -1324,6 +1361,12 @@ server.registerTool(
1324
1361
  made = cast.state.notes.find((note) => note.id === made.id) ?? made;
1325
1362
  }
1326
1363
  }
1364
+ // Wired into the story where the writer said, in the same frame.
1365
+ if (beside && made?.id && wallHasFollows) {
1366
+ joinedGroup = landBeside((command) => step(command), current, made.id, beside, Boolean(args.after));
1367
+ step({ type: "apply_poses", poses: organizePoses(current(), {}) });
1368
+ made = current().notes.find((note) => note.id === made.id) ?? made;
1369
+ }
1327
1370
  return made;
1328
1371
  });
1329
1372
  const castLine = names.length && result?.id ? ` Cast: ${names.join(", ")}${added.length ? ` (added to the roster: ${added.join(", ")})` : ""}.` : "";
@@ -1336,9 +1379,13 @@ server.registerTool(
1336
1379
  result?.when ? `when: ${result.when}` : null,
1337
1380
  ].filter(Boolean).join(", ");
1338
1381
  // Where it landed matters only until the tidy, so the reply says the rule once and never the coordinates (round fourteen, entry 11).
1339
- const placed = args.x === undefined && args.y === undefined ? once("placed", " Placed after the last card in story order; organize lays the wall out along the arrows.") : "";
1382
+ const placed = beside
1383
+ ? wallHasFollows
1384
+ ? ` Wired ${args.after ? "after" : "before"} "${beside.headline}" in the story${joinedGroup ? `, in "${joinedGroup}"` : ""}, and the wall tidied.`
1385
+ : ` The wall has no follows arrows, so "${args.after ? "after" : "before"}" has no story to land in: it sits after the last card; create_arrow the sequence, then move_scene.`
1386
+ : args.x === undefined && args.y === undefined ? ` Placed after the last card in story order.${once("placed", " organize lays the wall out along the arrows.")}` : "";
1340
1387
  // Under a lock a new scene has a letter, not a number: say it, since the board is the only other place to learn it (round fourteen, entry 44).
1341
- const numbered = after?.lock && result?.id ? ` Numbered ${sceneNumbers(storyOrder(after), after.lock).get(result.id)} (the numbers are locked; a new scene's letter is its place between locked ones, and follows the scene if it moves).` : "";
1388
+ const numbered = after?.lock && result?.id ? ` Numbered ${sceneNumbers(storyOrder(after), after.lock).get(result.id)} (the numbers are locked; a new scene's letter is its place between locked ones now, worked out again from where it sits if it moves; the locked numbers never move).` : "";
1342
1389
  return ok(`Created card ${result?.id ?? ""}: ${landed}${where(live)}.${castLine}${placed}${numbered}`, result);
1343
1390
  },
1344
1391
  );
@@ -1467,7 +1514,7 @@ server.registerTool(
1467
1514
  const lines = [
1468
1515
  `PlotCoder wall (${door(live, base)})`,
1469
1516
  ...(state.lock ? [`numbers: locked since ${String(state.lock.at).slice(0, 10)}; read_pages shows each scene's number`] : []),
1470
- `board: "${readBoardMeta?.name ?? "?"}"${projectForRead.boards.length > 1 ? ` — ${projectForRead.boards.length} boards in "${projectForRead.name}"; open_board reads another` : ""}`,
1517
+ `board: "${readBoardMeta?.name ?? "?"}"${projectForRead.boards.length > 1 ? ` — board ${projectForRead.boards.findIndex((meta) => meta.id === readBoardMeta?.id) + 1} of ${projectForRead.boards.length} in the project "${projectForRead.name}"; open_board reads another` : ""}`,
1471
1518
  `logline: ${state.logline ? `"${state.logline}"` : "(none yet)"}`,
1472
1519
  "the cast and the places are list_board's, not the reading's",
1473
1520
  state.targetEighths === DEFAULT_TARGET_EIGHTHS
@@ -1495,7 +1542,7 @@ server.registerTool(
1495
1542
  `setups and payoffs${written < state.notes.length ? " (distances in estimated pages)" : ""}:`,
1496
1543
  ...(reading.setups.length
1497
1544
  ? describeSetups(reading, state).map((line) => ` - ${line}`)
1498
- : [" (no arrow is marked as a setup)"]),
1545
+ : [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)"]),
1499
1546
  ...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"}`),
1500
1547
  ...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`),
1501
1548
  "questions the wall raises:",
@@ -1515,7 +1562,7 @@ server.registerTool(
1515
1562
  const counts = new Map();
1516
1563
  for (const finding of asked) counts.set(finding.kind, (counts.get(finding.kind) ?? 0) + 1);
1517
1564
  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}`;
1518
- })()}${reading.left.length ? `; left by the writer, not clean: ${[...new Set(reading.left.map((finding) => finding.kind))].join(", ")}` : ""}; checked and clean: ${CHECKS.filter((kind) => !reading.findings.some((finding) => finding.kind === kind) && !reading.left.some((finding) => finding.kind === kind)).map((kind) => {
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) => {
1519
1566
  if (kind === "unlinked" && state.arrows.length === 0) return "no card without an arrow (not asked: no arrows yet)";
1520
1567
  if (kind === "unplaced" && !state.notes.some((note) => (note.location ?? "").trim())) return "no card without a place (not asked: no card placed yet)";
1521
1568
  if (kind === "sequence" && state.groups.length === 0) return "no group too long for one sequence (not asked: no groups)";
@@ -1619,6 +1666,36 @@ server.registerTool(
1619
1666
  },
1620
1667
  );
1621
1668
 
1669
+ /**
1670
+ * Land a card beside another in the story (R56): between the target and what
1671
+ * followed it (after) or what led to it (before), rewiring the follows arrows,
1672
+ * and into the target's group so the tidy keeps the act as a block. Shared by
1673
+ * move_scene and by create_note with after/before (round sixteen, entry 36).
1674
+ */
1675
+ function landBeside(run, current, cardId, target, after) {
1676
+ const isFollows = (arrow) => arrow.kind !== "setup";
1677
+ const mid = current();
1678
+ if (after) {
1679
+ for (const arrow of mid.arrows.filter((item) => isFollows(item) && item.from === target.id && item.to !== cardId)) {
1680
+ run({ type: "delete_arrow", id: arrow.id });
1681
+ run({ type: "create_arrow", from: cardId, to: arrow.to, kind: "follows" });
1682
+ }
1683
+ run({ type: "create_arrow", from: target.id, to: cardId, kind: "follows" });
1684
+ } else {
1685
+ for (const arrow of mid.arrows.filter((item) => isFollows(item) && item.to === target.id && item.from !== cardId)) {
1686
+ run({ type: "delete_arrow", id: arrow.id });
1687
+ run({ type: "create_arrow", from: arrow.from, to: cardId, kind: "follows" });
1688
+ }
1689
+ run({ type: "create_arrow", from: cardId, to: target.id, kind: "follows" });
1690
+ }
1691
+ const targetGroup = current().groups.find((group) => group.noteIds.includes(target.id));
1692
+ if (targetGroup && !targetGroup.noteIds.includes(cardId)) {
1693
+ run({ type: "add_to_group", id: targetGroup.id, noteIds: [cardId] });
1694
+ return targetGroup.title || "an untitled group";
1695
+ }
1696
+ return null;
1697
+ }
1698
+
1622
1699
  /**
1623
1700
  * A scene moves to another board of the project (round fifteen, entry 16: a
1624
1701
  * writer's "the shim should open episode two" had no tool, and the way round
@@ -1700,16 +1777,17 @@ async function moveAcrossBoards(args, target, open, held) {
1700
1777
  step({ type: "apply_poses", poses: organizePoses(current(), {}) });
1701
1778
  });
1702
1779
  const order = storyOrder(final);
1780
+ // "Left behind" read as "kept" (round sixteen, entry 22): the arrows are dropped, and the reply says so.
1703
1781
  const arrows = taken?.arrows?.length
1704
- ? ` Left behind on "${fromMeta.name}": ${taken.arrows.map((arrow) => `"${arrow.fromHeadline}" → "${arrow.toHeadline}" (${arrow.kind}${arrow.kind === "setup" && arrow.to === card.id ? "; that fold is unpaid again" : ""})`).join(", ")}${taken.joined ? `; the chain is joined behind it, "${taken.joined.fromHeadline}" → "${taken.joined.toHeadline}"` : ""}.`
1782
+ ? ` Dropped on "${fromMeta.name}", because an arrow joins two cards of one wall: ${taken.arrows.map((arrow) => `"${arrow.fromHeadline}" → "${arrow.toHeadline}" (${arrow.kind}${arrow.kind === "setup" && arrow.to === card.id ? "; that fold is unpaid again" : ""})`).join(", ")}${taken.joined ? `; the chain there is joined behind it, "${taken.joined.fromHeadline}" → "${taken.joined.toHeadline}"` : ""}.`
1705
1783
  : ` No arrow touched it on "${fromMeta.name}".`;
1706
1784
  const groups = (taken?.groups ?? []).map((group) => (group.dissolved ? ` Its group "${group.title}" there dissolved: a frame needs two cards.` : ` It left its group "${group.title}" there, which keeps ${group.remaining} card${group.remaining === 1 ? "" : "s"}.`)).join("");
1707
1785
  const landed = anchor
1708
- ? `${args.after ? "after" : "before"} "${anchor.headline}"${joinedGroup ? `, in "${joinedGroup}"` : ""}`
1709
- : headOf ? `at the head of the story, before "${headOf}"` : "as the only card wired to nothing yet";
1786
+ ? `${args.after ? "after" : "before"} "${anchor.headline}", with the follows arrows rewired around it${joinedGroup ? `, in "${joinedGroup}"` : `; "${anchor.headline}" is in no group, so this card joined none`}`
1787
+ : headOf ? `at the head of the story, with a follows arrow drawn from it to "${headOf}"; it is in no group` : "as the only card wired to nothing yet";
1710
1788
  const fold = card.plants ? (forgotLater ? " It paid off later on this board, so that mark is forgotten: draw the setup arrow here." : " Its folded corner came with it; a setup arrow does not cross boards, so draw the payoff here if it is here.") : "";
1711
1789
  return ok(
1712
- `Moved "${card.headline}" from "${fromMeta.name}" to "${target.name}", with its cast, place, when, rank, length${card.text ? ", text" : ""} and colour; it is card ${landedId} there${where(live)}.${arrows}${groups} It landed ${landed}, and the wall was tidied.${fold} Story order on "${target.name}" now: ${order.map((note, index) => `${index + 1}. ${note.headline}`).join(", ")}. "${target.name}" is the open board now. Undo is per board: undo here takes back the landing; open_board "${fromMeta.name}" and undo takes back the leaving.`,
1790
+ `Moved "${card.headline}" from "${fromMeta.name}" to "${target.name}", with its cast, place, when, rank, length${card.text ? ", text" : ""}, colour${card.plants ? " and folded corner" : ""}; it is card ${landedId} there${where(live)}.${arrows}${groups} It landed ${landed}, and the wall was tidied.${fold} Story order on "${target.name}" now: ${order.map((note, index) => `${index + 1}. ${note.headline}`).join(", ")}. "${target.name}" is the open board now. Undo is per board: undo here takes back the landing; open_board "${fromMeta.name}" and undo takes back the leaving.`,
1713
1791
  { id: landedId, board: target.id, order: order.map((note) => note.id) },
1714
1792
  );
1715
1793
  }
@@ -1761,37 +1839,20 @@ server.registerTool(
1761
1839
  const outs = state.arrows.filter((arrow) => isFollows(arrow) && arrow.from === card.id);
1762
1840
  for (const arrow of [...ins, ...outs]) run({ type: "delete_arrow", id: arrow.id });
1763
1841
  for (const before of ins) for (const after of outs) if (before.from !== after.to) run({ type: "create_arrow", from: before.from, to: after.to, kind: "follows" });
1764
- // Land: between the target and what followed it (or what led to it).
1765
- const mid = current();
1766
- if (args.after) {
1767
- for (const arrow of mid.arrows.filter((item) => isFollows(item) && item.from === target.id && item.to !== card.id)) {
1768
- run({ type: "delete_arrow", id: arrow.id });
1769
- run({ type: "create_arrow", from: card.id, to: arrow.to, kind: "follows" });
1770
- }
1771
- run({ type: "create_arrow", from: target.id, to: card.id, kind: "follows" });
1772
- } else {
1773
- for (const arrow of mid.arrows.filter((item) => isFollows(item) && item.to === target.id && item.from !== card.id)) {
1774
- run({ type: "delete_arrow", id: arrow.id });
1775
- run({ type: "create_arrow", from: arrow.from, to: card.id, kind: "follows" });
1776
- }
1777
- run({ type: "create_arrow", from: card.id, to: target.id, kind: "follows" });
1778
- }
1779
- // Landing beside a card of an act puts the scene in that act, or the tidy
1780
- // keeps the act as a block and lays the scene past it (round fourteen, entry 38).
1781
- const targetGroup = current().groups.find((group) => group.noteIds.includes(target.id));
1782
- if (targetGroup && !targetGroup.noteIds.includes(card.id)) {
1783
- run({ type: "add_to_group", id: targetGroup.id, noteIds: [card.id] });
1784
- joinedGroup = targetGroup.title || "an untitled group";
1785
- }
1842
+ // Land: between the target and what followed it (or what led to it), and
1843
+ // into the act it lands beside (round fourteen, entry 38).
1844
+ joinedGroup = landBeside(run, current, card.id, target, Boolean(args.after));
1786
1845
  run({ type: "apply_poses", poses: organizePoses(current(), {}) });
1787
1846
  });
1788
1847
  const order = storyOrder(final);
1848
+ // Under a lock the letter is worked out again from where the scene landed (round sixteen, entry 35).
1849
+ const lockedNow = final.lock ? ` Under the lock it is now ${sceneNumbers(order, final.lock).get(card.id)}; the locked numbers never move, and an added scene's letter is worked out from where it sits.` : "";
1789
1850
  const group = final.groups.find((item) => item.noteIds.includes(card.id));
1790
1851
  const groupLine = joinedGroup
1791
1852
  ? ` It joined "${joinedGroup}", the group it landed in, so the tidy keeps it with the act.`
1792
1853
  : group ? ` It is still in "${group.title || "an untitled group"}"; a frame does not follow a move, so say if the act or sequence should change.` : "";
1793
1854
  return ok(
1794
- `Moved "${card.headline}" to ${args.after ? "after" : "before"} "${target.headline}": ${removed} follows arrow(s) removed, ${drawn} drawn, setup arrows untouched, the wall tidied along them${where(live)}. Story order now: ${order.map((note, index) => `${index + 1}. ${note.headline}`).join(", ")}.${groupLine} One undo takes the whole move back.`,
1855
+ `Moved "${card.headline}" to ${args.after ? "after" : "before"} "${target.headline}": ${removed} follows arrow(s) removed, ${drawn} drawn, setup arrows untouched, the wall tidied along them${where(live)}. Story order now: ${order.map((note, index) => `${index + 1}. ${note.headline}`).join(", ")}.${groupLine}${lockedNow} One undo takes the whole move back.`,
1795
1856
  { order: order.map((note) => note.id) },
1796
1857
  );
1797
1858
  },
@@ -1964,7 +2025,7 @@ server.registerTool(
1964
2025
  {
1965
2026
  title: "Export the wall as Fountain",
1966
2027
  description:
1967
- "The open board as a Fountain screenplay: a title page (with the premise and logline in its notes), beats as sections, one scene per card in wall order — a forced heading from the card's place (or its headline), the headline as a synopsis, the cast and the fold as notes, the change line as action after the mark [Unwritten] until the scene is written. Titled for the project, a one-board film being its project. Plain text a writer can open in any Fountain editor. Pass a path to write a .fountain file; otherwise the text comes back.",
2028
+ "The open board as a Fountain screenplay: a title page (with the premise and logline in its notes), beats as sections, one scene per card in wall order — a forced heading from the card's place (or its headline), the headline as a synopsis, the cast and the fold as notes, the change line as action after the mark [Unwritten] until the scene is written. Titled for the project, a one-board film being its project. Plain text a writer can open in any Fountain editor. Pass a path (relative to the server's folder) to write a .fountain file; otherwise the text comes back.",
1968
2029
  inputSchema: { path: z.string().optional() },
1969
2030
  },
1970
2031
  async (args) => {
@@ -1987,7 +2048,7 @@ server.registerTool(
1987
2048
  {
1988
2049
  title: "Export the wall as Markdown",
1989
2050
  description:
1990
- "The open board as Markdown, for a collaborator who lives in Google Docs or the like: titled for the project — a one-board film is its project, and the board's name follows only when the project has several boards — the premise and the logline under it, beats as second-level headings, a third-level heading per scene from its place with its scene number, the headline as a synopsis line, then the scene's text — a speech as its cue in bold with the lines under it — or, unwritten, its change line after the mark [Unwritten] in bold, so a reader can tell a placeholder from a page. Carries the beats and every headline; does not carry the cast or the fold (Fountain's notes do). In Google Docs, Paste from Markdown keeps the headings. Pass a path to write a .md file; otherwise the text comes back.",
2051
+ "The open board as Markdown, for a collaborator who lives in Google Docs or the like: titled for the project — a one-board film is its project, and the board's name follows only when the project has several boards — the premise and the logline under it, beats as second-level headings, a third-level heading per scene from its place with its scene number, the headline as a synopsis line, then the scene's text — a speech as its cue in bold with the lines under it — or, unwritten, its change line after the mark [Unwritten] in bold, so a reader can tell a placeholder from a page. Carries the beats and every headline; does not carry the cast or the fold (Fountain's notes do). In Google Docs, Paste from Markdown keeps the headings. Pass a path (relative to the server's folder) to write a .md file; otherwise the text comes back.",
1991
2052
  inputSchema: { path: z.string().optional() },
1992
2053
  },
1993
2054
  async (args) => {
@@ -2010,7 +2071,7 @@ server.registerTool(
2010
2071
  {
2011
2072
  title: "Export the script as plain text",
2012
2073
  description:
2013
- "The open board's script as plain text, set as it prints: the paginator's lines at Courier's columns kept with spaces, scene numbers in both margins (the wall's order, or as locked), no page numbers, an unwritten scene's change line as action after the mark [Unwritten], a revision's stars in the right margin. The script and nothing else: no headlines, no beats, no cast — the heading is the place and the when. Titled for the project, a one-board film being its project. Pastes into anything and reads as a script wherever the font is monospaced. Pass a path to write a .txt file; otherwise the text comes back.",
2074
+ "The open board's script as plain text, set as it prints: the paginator's lines at Courier's columns kept with spaces, scene numbers in both margins (the wall's order, or as locked), no page numbers, an unwritten scene's change line as action after the mark [Unwritten], a revision's stars in the right margin. The script and nothing else: no headlines, no beats, no cast — the heading is the place and the when. Titled for the project, a one-board film being its project. Pastes into anything and reads as a script wherever the font is monospaced. Pass a path (relative to the server's folder) to write a .txt file; otherwise the text comes back.",
2014
2075
  inputSchema: { path: z.string().optional() },
2015
2076
  },
2016
2077
  async (args) => {
@@ -2044,7 +2105,7 @@ server.registerTool(
2044
2105
  }
2045
2106
  const printed = sceneLineCount(args.text);
2046
2107
  return ok(
2047
- `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${result.lengthEighths !== null ? ` (the writer's ${formatPages(result.lengthEighths)} pages)` : ""}; the estimate is kept for when the text goes, and set_length changes it.`,
2108
+ `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${result.lengthEighths !== null ? ` (the writer's ${formatPages(result.lengthEighths)} pages)` : ""}; the estimate is kept for when the text goes, and set_length changes it.${cueReport(state, result.text)}`,
2048
2109
  { ...result, eighths: noteEighths(result), measured: true, printedLines: printed },
2049
2110
  );
2050
2111
  },
@@ -2074,9 +2135,11 @@ server.registerTool(
2074
2135
  const count = text.split(args.find).length - 1;
2075
2136
  if (count === 0) return ok(`"${args.find}" is not in "${note.headline}"'s text. read_pages shows the scene as it stands.`);
2076
2137
  if (count > 1) return ok(`"${args.find}" occurs ${count} times in "${note.headline}"; give more of the line so it occurs once.`);
2138
+ const linesBefore = sceneLineCount(text);
2077
2139
  const { state, result, live } = await commit({ type: "set_text", id: note.id, text: text.replace(args.find, args.replace) });
2140
+ const linesAfter = sceneLineCount(result.text);
2078
2141
  return ok(
2079
- `Changed one line of "${result.headline}": "${args.find}" → "${args.replace}"${where(live)}. Now ${sceneLineCount(result.text)} line(s) as they print, measured at ${formatPages(noteEighths(result))} of a page.${revisionMark(state, result.id)}`,
2142
+ `Changed one line of "${result.headline}": "${args.find}" → "${args.replace}"${where(live)}. Now ${linesAfter} line(s) as they print${linesAfter !== linesBefore ? ` (was ${linesBefore}: a line wraps differently now)` : ""}, measured at ${formatPages(noteEighths(result))} of a page.${revisionMark(state, result.id)}${cueReport(state, result.text)}`,
2080
2143
  { ...result, eighths: noteEighths(result), measured: true },
2081
2144
  );
2082
2145
  },
@@ -2101,16 +2164,21 @@ server.registerTool(
2101
2164
  const pageNumbers = state.lock ? sceneNumbers(storyOrder(state), state.lock) : null;
2102
2165
  const lines = [];
2103
2166
  let index = 0;
2167
+ // The changed lines of the scene being printed, starred in the right margin as plain text stars them (round sixteen, entry 32).
2168
+ let changedTexts = new Set();
2104
2169
  for (const line of text.split("\n")) {
2105
2170
  if (/^\.(?!\.)/.test(line) && index < ids.length) {
2106
2171
  const note = state.notes.find((item) => item.id === ids[index]);
2107
2172
  index += 1;
2108
2173
  const standIn = note && !(note.location ?? "").trim() ? " · no place: the headline stands in for the heading" : "";
2109
2174
  const numbered = note && pageNumbers?.get(note.id) ? ` · locked no. ${pageNumbers.get(note.id)}` : "";
2110
- const revised = note && marks.get(note.id)?.revised ? ` · changed in the ${state.revision.color} revision` : "";
2175
+ const mark = note ? marks.get(note.id) : null;
2176
+ const sourceLines = (note?.text ?? "").split("\n");
2177
+ changedTexts = new Set([...(mark?.lines ?? [])].map((at) => sourceLines[at]).filter((item) => item && item.trim()));
2178
+ const revised = mark?.revised ? ` · changed in the ${state.revision.color} revision${changedTexts.size ? ` (${changedTexts.size} line${changedTexts.size === 1 ? "" : "s"} starred below)` : ""}` : "";
2111
2179
  lines.push(`${line} [[id: ${note?.id ?? "?"} · ${note && isMeasured(note) ? "measured" : "estimated"} ${formatPages(note ? noteEighths(note) : 0)}pp${standIn}${numbered}${revised}]]`);
2112
2180
  } else {
2113
- lines.push(line);
2181
+ lines.push(changedTexts.has(line) ? `${line} *` : line);
2114
2182
  }
2115
2183
  }
2116
2184
  return ok(lines.join("\n"));
@@ -2200,7 +2268,7 @@ server.registerTool(
2200
2268
  {
2201
2269
  title: "Export as Final Draft",
2202
2270
  description:
2203
- "The open board as a Final Draft .fdx: a heading per card with its scene number by wall order, the scene's text as script paragraphs (action, character, parenthetical, dialogue, dual dialogue, transition) or the change line as action after the mark [Unwritten] when unwritten, and a title page: the project's name, and for a series the episode line (Episode 2 of 6 · its name). One board per file; a series is one file per episode. Pass a path to write the file; otherwise the XML comes back with the file's name in a comment on its second line.",
2271
+ "The open board as a Final Draft .fdx: a heading per card with its scene number (the locked numbers under a lock, else story order), the scene's text as script paragraphs (action, character, parenthetical, dialogue, dual dialogue, transition) or the change line as action after the mark [Unwritten] when unwritten, and a title page: the project's name, and for a series the episode line (Episode 2 of 6 · its name). One board per file; a series is one file per episode. A lock's date and a revision's name print on the title page, and changed paragraphs carry the revision's mark. Pass a path to write the file (a relative path resolves from the server's folder); otherwise the XML comes back with the file's name in a comment on its second line, and the reply with a path repeats that name.",
2204
2272
  inputSchema: { path: z.string().optional() },
2205
2273
  },
2206
2274
  async (args) => {
@@ -2214,7 +2282,7 @@ server.registerTool(
2214
2282
  if (args.path) {
2215
2283
  fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
2216
2284
  fs.writeFileSync(args.path, xml);
2217
- return ok(`Wrote a Final Draft file with ${state.notes.length} scene(s), titled "${titles.title}"${titles.episode ? ` (${titles.episode})` : ""}, to ${args.path}.`);
2285
+ return ok(`Wrote a Final Draft file with ${state.notes.length} scene(s), titled "${titles.title}"${titles.episode ? ` (${titles.episode})` : ""}, to ${args.path}; the app's own name for it is "${filename}".${state.lock ? " The title page says when the numbers were locked." : ""}${state.revision ? ` The ${state.revision.color} revision is declared in the file and its changed paragraphs marked.` : ""}`);
2218
2286
  }
2219
2287
  return ok(xml.replace(/^(<\?xml[^>]*\?>\n)/, `$1<!-- Save as: ${filename.replace(/--/g, "- -")} -->\n`));
2220
2288
  },
@@ -2266,13 +2334,11 @@ server.registerTool(
2266
2334
  return ` - ${scene.number}. ${note?.headline ?? scene.id} (${scene.id}) — p. ${scene.page}${scene.endPage !== scene.page ? `–${scene.endPage}` : ""}`;
2267
2335
  });
2268
2336
  const unwritten = order.filter((note) => !(note.text && note.text.trim())).length;
2269
- if (order.length > 0 && unwritten === order.length) {
2270
- return ok(
2271
- `No pages to count yet: none of the ${order.length} scenes is written. The runtime is list_board's estimate from the cards' lengths — about ${formatPages(boardEighths(state))} of ${formatPages(state.targetEighths)} pages.`,
2272
- { pageCount: 0, unwritten, scenes: [] },
2273
- );
2274
- }
2275
- const note = unwritten
2337
+ // An unwritten board still paginates, as the guide says it prints: every
2338
+ // change line as action (round sixteen, entry 37).
2339
+ const note = order.length > 0 && unwritten === order.length
2340
+ ? [`None of the ${order.length} scenes is written: every scene sets its change line as action, marked [Unwritten], a few lines each — so this is the wall as pages, not a script; the runtime estimate from the cards is about ${formatPages(boardEighths(state))} of ${formatPages(state.targetEighths)} pages.`]
2341
+ : unwritten
2276
2342
  ? [`${unwritten} of ${order.length} scenes are unwritten and set their change line as action, marked [Unwritten], a few lines each — so this is the script so far, not the runtime: the estimate from the cards is about ${formatPages(boardEighths(state))} pages, the number to use until the scenes are written.`]
2277
2343
  : [];
2278
2344
  return ok([...note, `pages: ${result.pageCount} of ${Math.round(state.targetEighths / 8)}`, `scene numbers here are ${state.lock ? "the locked numbers" : "story order (not locked)"}`, ...lines].join("\n"), result.scenes);
@@ -2292,7 +2358,9 @@ server.registerTool(
2292
2358
  const order = storyOrder(state).map((note) => note.id);
2293
2359
  const { changed, result, live } = await commit({ type: "lock_numbers", order });
2294
2360
  if (!changed) return ok("Nothing to lock.");
2295
- return ok(`Locked ${Object.keys(result.numbers).length} scene number(s)${where(live)}.`, result);
2361
+ // Which number went on which scene (round sixteen, entry 34).
2362
+ const byNumber = Object.entries(result.numbers).map(([id, number]) => `${number} "${state.notes.find((note) => note.id === id)?.headline ?? id}"`);
2363
+ return ok(`Locked ${Object.keys(result.numbers).length} scene number(s)${where(live)}: ${byNumber.join(", ")}. A scene added now gets a letter for where it sits — 3A between 3 and 4 — worked out again if it moves; the locked numbers never move. Every script out carries these numbers, and its title page says when they were locked.`, result);
2296
2364
  },
2297
2365
  );
2298
2366
 
@@ -2510,14 +2578,31 @@ server.registerTool(
2510
2578
  const live = await writeBoard(last.before, rev, base, boardId, "exact");
2511
2579
  const orderLine = /^(move_scene|organize)/.test(last.what) ? ` Story order now: ${storyOrder(last.before).map((note, index) => `${index + 1}. ${note.headline}`).join(", ")}.` : "";
2512
2580
  const cardsDiff = last.before.notes.length - state.notes.length;
2513
- const arrowsDiff = last.before.arrows.length - state.arrows.length;
2581
+ // The arrows by name, back and gone, not the net (round sixteen, entry 45).
2582
+ const headlineIn = (board, id) => board.notes.find((note) => note.id === id)?.headline ?? id;
2583
+ const arrowName = (board, arrow) => `"${headlineIn(board, arrow.from)}" → "${headlineIn(board, arrow.to)}" (${arrow.kind ?? "follows"})`;
2584
+ const arrowsBack = last.before.arrows.filter((arrow) => !state.arrows.some((item) => item.id === arrow.id)).map((arrow) => arrowName(last.before, arrow));
2585
+ const arrowsGone = state.arrows.filter((arrow) => !last.before.arrows.some((item) => item.id === arrow.id)).map((arrow) => arrowName(state, arrow));
2514
2586
  const groupsChanged = last.before.groups
2515
2587
  .filter((group) => { const now = state.groups.find((item) => item.id === group.id); return !now || now.noteIds.length !== group.noteIds.length; })
2516
2588
  .map((group) => `"${group.title}" (${group.noteIds.length} cards)`);
2517
- const withIt = [arrowsDiff > 0 ? `${arrowsDiff} arrow(s) back` : arrowsDiff < 0 ? `${-arrowsDiff} arrow(s) gone` : null, groupsChanged.length ? `groups as they were: ${groupsChanged.join(", ")}` : null].filter(Boolean);
2589
+ const withIt = [
2590
+ arrowsBack.length ? `${arrowsBack.length} arrow(s) back: ${arrowsBack.join(", ")}` : null,
2591
+ arrowsGone.length ? `${arrowsGone.length} arrow(s) gone: ${arrowsGone.join(", ")}` : null,
2592
+ groupsChanged.length ? `groups as they were: ${groupsChanged.join(", ")}` : null,
2593
+ ].filter(Boolean);
2518
2594
  const countLine = `${cardsDiff > 0 ? ` ${cardsDiff} card(s) back` : cardsDiff < 0 ? ` ${-cardsDiff} card(s) gone` : ""}${withIt.length ? `${cardsDiff ? ", with " : " "}${withIt.join("; ")}` : ""}${cardsDiff || withIt.length ? "." : ""}`;
2519
- const more = trail.length >= TRAIL_CAP ? `${trail.length} more of mine can be undone — the most I keep, so the oldest have gone` : `${trail.length} more of mine can be undone`;
2520
- return ok(`Undid ${last.what}${where(live)}.${orderLine}${countLine} ${more}. list_board has the board.`, { undid: last.what, notes: last.before.notes.length, arrows: last.before.arrows.length, groups: last.before.groups.length });
2595
+ // Under a lock, the letters that changed with the undo (entry 46).
2596
+ let lockLine = "";
2597
+ if (last.before.lock) {
2598
+ const was = sceneNumbers(storyOrder(state), state.lock ?? last.before.lock);
2599
+ const now = sceneNumbers(storyOrder(last.before), last.before.lock);
2600
+ const moved = last.before.notes.filter((note) => was.get(note.id) && now.get(note.id) && was.get(note.id) !== now.get(note.id)).map((note) => `"${note.headline}" is ${now.get(note.id)} again (was ${was.get(note.id)})`);
2601
+ lockLine = moved.length ? ` Under the lock, ${moved.join("; ")}.` : "";
2602
+ }
2603
+ // Whose trail the count is (entry 47): this session's, across the boards.
2604
+ const more = `${trail.length} more of this session's changes can be undone, across the boards — each only on the board it was made on, and only while that board is as the change left it${trail.length >= TRAIL_CAP ? "; that is the most I keep, so the oldest have gone" : ""}`;
2605
+ return ok(`Undid ${last.what}${where(live)}.${orderLine}${countLine}${lockLine} ${more}. list_board has the board.`, { undid: last.what, notes: last.before.notes.length, arrows: last.before.arrows.length, groups: last.before.groups.length });
2521
2606
  },
2522
2607
  );
2523
2608
 
@@ -2613,8 +2698,11 @@ server.registerTool(
2613
2698
  }
2614
2699
  // The fold and the board it pays off on land as one change: one ⌘Z on the wall.
2615
2700
  const { value, live, changed } = await commitAll("set_plant", (step, current) => {
2701
+ // What the folds claimed before, so the reply can say what a new claim replaced (round sixteen, entry 26).
2702
+ const before = new Map(current().notes.filter((note) => args.ids.includes(note.id)).map((note) => [note.id, { plants: note.plants, boardId: note.payoffBoardId, noteId: note.payoffNoteId }]));
2703
+ const alreadyFolded = args.plants && args.ids.every((id) => before.get(id)?.plants);
2616
2704
  let { result } = step({ type: "set_plant", ids: args.ids, plants: args.plants });
2617
- let laterLine = "";
2705
+ let laterLine = alreadyFolded ? " (already folded)" : "";
2618
2706
  if (forgetting) {
2619
2707
  const cleared = step({ type: "set_payoff_board", ids: args.ids, boardId: null });
2620
2708
  if (cleared.changed) {
@@ -2625,11 +2713,16 @@ server.registerTool(
2625
2713
  const named = step({ type: "set_payoff_board", ids: args.ids, boardId: target.id, noteId: atNote?.id ?? null });
2626
2714
  if (named.changed) result = named.result;
2627
2715
  const here = current().notes.filter((note) => args.ids.includes(note.id));
2628
- laterLine = atNote
2629
- ? ` ${here.length} card(s) pay off at "${atNote.headline}" on "${target.name}": both boards' readings name it, and that card says what it pays off.`
2716
+ // The claim that stood before, when this one replaces it.
2717
+ const replaced = [...before.values()].filter((was) => was.boardId && (was.boardId !== target.id || (was.noteId && was.noteId !== (atNote?.id ?? null))));
2718
+ const replacedLine = replaced.length
2719
+ ? ` That replaces the earlier claim${replaced.length === 1 ? "" : "s"}: ${replaced.map((was) => `${was.noteId ? `"${(isBoardState(lastHeld?.boards?.[was.boardId]) ? lastHeld.boards[was.boardId].notes.find((note) => note.id === was.noteId)?.headline : null) ?? was.noteId}" on` : "a scene to come on"} "${lastHeld?.project?.boards?.find((meta) => meta.id === was.boardId)?.name ?? was.boardId}"`).join(", ")}, which no longer pays anything off.`
2720
+ : "";
2721
+ laterLine = `${laterLine}${atNote
2722
+ ? ` ${here.length} fold(s) ${here.length === 1 ? "is" : "are"} paid off at "${atNote.headline}" on "${target.name}": both boards' readings name it, and that card says what it pays off.`
2630
2723
  : atClearing
2631
- ? ` ${here.length} card(s) pay off later, on "${target.name}", and no scene there is claimed: read_wall asks which once that board holds cards.`
2632
- : ` ${here.length} card(s) pay off later, on "${target.name}": the card says so, and read_wall asks which scene once that board holds cards — set_plant with at, or set_payoff from that board, names it.`;
2724
+ ? ` ${here.length} fold(s) pay off later, on "${target.name}", and no scene there is claimed: read_wall asks which once that board holds cards.`
2725
+ : ` ${here.length} fold(s) pay off later, on "${target.name}": the card says so, and read_wall asks which scene once that board holds cards — set_plant with at, or set_payoff from that board, names it.`}${replacedLine}`;
2633
2726
  }
2634
2727
  return { result, laterLine };
2635
2728
  });
@@ -2683,11 +2776,14 @@ server.registerTool(
2683
2776
  });
2684
2777
  const back = await readProject();
2685
2778
  const { live } = await openBoardEverywhere(back.project, back.boards, back.rev, back.base, hereId);
2779
+ // The write landed on the fold's board, so its change note is that board's (round sixteen, entry 16).
2780
+ const noteThere = changeNote();
2781
+ const thereLine = noteThere ? ` On "${source.name}"${noteThere}.` : "";
2686
2782
  if (!changed) return ok(`No change: ${clearing ? "nothing was claimed" : `"${folds[0].headline}" already pays off at "${card.headline}"`}.`);
2687
2783
  return ok(
2688
2784
  clearing
2689
- ? `"${card.headline}" no longer pays off ${folds.map((fold) => `"${fold.headline}"`).join(", ")} from "${source.name}"; ${folds.length === 1 ? "that fold is" : "those folds are"} a promise on this board again${where(live)}. This board is open again; undo on "${source.name}" takes it back.`
2690
- : `"${card.headline}" pays off "${folds[0].headline}" from "${source.name}" (${episodeLabel(project, { ...boards, [hereId]: here }, source.id, folds[0].id)})${where(live)}. The fold's card there says "paid off in ${episodeLabel(project, { ...boards, [hereId]: here }, hereId, card.id)}", this card says what it pays off, and both readings list it. This board is open again; undo on "${source.name}" takes the claim back.`,
2785
+ ? `"${card.headline}" no longer pays off ${folds.map((fold) => `"${fold.headline}"`).join(", ")} from "${source.name}"; ${folds.length === 1 ? "that fold is" : "those folds are"} a promise on this board again${where(live)}.${thereLine} This board is open again; undo on "${source.name}" takes it back.`
2786
+ : `"${card.headline}" pays off "${folds[0].headline}" from "${source.name}" (${episodeLabel(project, { ...boards, [hereId]: here }, source.id, folds[0].id)})${where(live)}. The fold's card there says "paid off in ${episodeLabel(project, { ...boards, [hereId]: here }, hereId, card.id)}", this card says what it pays off, and both readings list it.${thereLine} This board is open again; undo on "${source.name}" takes the claim back.`,
2691
2787
  { fold: folds.map((fold) => fold.id), board: source.id, card: card.id },
2692
2788
  );
2693
2789
  },
@@ -2708,7 +2804,7 @@ server.registerTool(
2708
2804
  ? ok(`Already in the cast as "${result.name}" (${result.id}). Use that id.`, result)
2709
2805
  : ok("No character added: the name was empty.");
2710
2806
  }
2711
- return ok(`Added "${result.name}" (id ${result.id}) to the project's cast${where(live)}; every board of the project casts from it.`, result);
2807
+ return ok(`Added "${result.name}" (id ${result.id}) to the project's cast; every board of the project casts from it${where(live)}.`, result);
2712
2808
  },
2713
2809
  );
2714
2810
 
@@ -2752,7 +2848,7 @@ server.registerTool(
2752
2848
  async (args) => {
2753
2849
  const key = (args.id ?? args.name ?? "").trim();
2754
2850
  if (!key) return ok("Say who: the person's id or name from list_board.");
2755
- const { state, boardId } = await readBoard();
2851
+ const { state, boardId, live, base } = await readBoard();
2756
2852
  const { project, boards } = await readProject();
2757
2853
  const wanted = key.toLowerCase();
2758
2854
  const person = state.characters.find((item) => item.id === key) ?? state.characters.find((item) => item.name.trim().toLowerCase() === wanted);
@@ -2767,13 +2863,15 @@ server.registerTool(
2767
2863
  });
2768
2864
  const total = parts.reduce((sum, part) => sum + part.on.length, 0);
2769
2865
  const where_ = (note) => [note.location ? `at ${note.location}` : "", note.when ? note.when : "", note.rank === "beat" ? "beat" : ""].filter(Boolean).join(" · ");
2866
+ // A read opens with the door it came through, like every reading (round sixteen, entry 28); one scene a line (29).
2770
2867
  const lines = [
2868
+ `PlotCoder cast (${door(live, base)})`,
2771
2869
  `${person.name} (${person.id}) — on ${total} card${total === 1 ? "" : "s"} across ${project.boards.length} board${project.boards.length === 1 ? "" : "s"} of the project`,
2772
2870
  ...CHARACTER_FIELDS.map((field) => ` ${field}: ${(person[field] ?? "").trim() || "(empty)"}`),
2773
- ...parts.map((part) =>
2871
+ ...parts.flatMap((part) =>
2774
2872
  part.on.length
2775
- ? ` "${part.meta.name}", ${part.on.length} card${part.on.length === 1 ? "" : "s"} in story order: ${part.on.map((note, index) => `${index + 1}. "${note.headline}"${where_(note) ? ` (${where_(note)})` : ""}`).join("; ")}`
2776
- : ` "${part.meta.name}": on no card`,
2873
+ ? [` "${part.meta.name}", ${part.on.length} card${part.on.length === 1 ? "" : "s"} in story order:`, ...part.on.map((note, index) => ` ${index + 1}. "${note.headline}"${where_(note) ? ` (${where_(note)})` : ""}`)]
2874
+ : [` "${part.meta.name}": on no card`],
2777
2875
  ),
2778
2876
  ];
2779
2877
  return ok(lines.join("\n"), { ...person, cards: parts.flatMap((part) => part.on.map((note) => note.id)), boards: parts.map((part) => ({ id: part.meta.id, name: part.meta.name, cards: part.on.map((note) => note.id) })) });
@@ -2887,7 +2985,7 @@ server.registerTool(
2887
2985
  {
2888
2986
  title: "Cast a scene",
2889
2987
  description:
2890
- "Set who is in one or more 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.",
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.",
2891
2989
  inputSchema: {
2892
2990
  noteIds: z.array(z.string()).min(1),
2893
2991
  characters: z.array(z.string()),
@@ -2927,7 +3025,7 @@ server.registerTool(
2927
3025
  (id) => state.characters.find((character) => character.id === id)?.name ?? id,
2928
3026
  );
2929
3027
  return ok(
2930
- `${result.length} card(s) now cast ${names.length ? names.join(", ") : "nobody"}${where(live)}.`,
3028
+ `${result.length} card(s) now cast ${names.length ? names.join(", ") : "nobody"}: ${result.map((note) => `"${note.headline}"`).join(", ")}${where(live)}.`,
2931
3029
  result,
2932
3030
  );
2933
3031
  },
@@ -3587,7 +3685,7 @@ server.registerTool(
3587
3685
  const fresh = { ...emptyState(), ...(target ? { targetEighths: target } : {}) };
3588
3686
  const { live } = await openBoardEverywhere(next, { ...boards, [board.id]: fresh }, rev, base, board.id);
3589
3687
  return ok(
3590
- `Added "${board.name}" (${board.id}) and opened it${where(live)}: every card call lands there now, and the writer's open wall switched with it; open_board "${project.boards.findIndex((item) => item.id === project.activeBoardId) + 1}" comes back. It is empty, and the project's cast is already there to cast from. The logline is the story's question when the writer has one — leave it empty rather than invent it — and the cards come next.${next.name === "Untitled project" ? " The project is still \"Untitled project\": rename_project names it." : ""}${next.boards.length === 2 && isSampleWall(isBoardState(boards[next.boards[0].id]) ? normalizeState(boards[next.boards[0].id]) : emptyState()) ? " The sample stays as Board 1; delete_board drops it." : ""}`,
3688
+ `Added "${board.name}" (${board.id}) and opened it${where(live)}: every card call lands there now, and the writer's open wall switched with it; open_board ${project.boards.findIndex((item) => item.id === project.activeBoardId) + 1} returns to "${project.boards.find((item) => item.id === project.activeBoardId)?.name ?? "the one before"}". It is empty, and the project's cast is already there to cast from. The logline is the story's question when the writer has one — leave it empty rather than invent it — and the cards come next.${next.name === "Untitled project" ? " The project is still \"Untitled project\": rename_project names it." : ""}${next.boards.length === 2 && isSampleWall(isBoardState(boards[next.boards[0].id]) ? normalizeState(boards[next.boards[0].id]) : emptyState()) ? " The sample stays as Board 1; delete_board drops it." : ""}`,
3591
3689
  board,
3592
3690
  );
3593
3691
  },
@@ -42,12 +42,13 @@ 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 four 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; read_wall and list_reminders are about the wall you will work, so after open_project or open_board make those two again, and after new_project read the wall once it holds cards. An emptied account has nothing to read: go straight to new_project. Every reply's first line names the project it read and how many the account holds; list_projects lists them. 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; new_project (name, pages, and board for the first board's name). The reading holds the beats and the runs; the ids of every card, the cast and the places are list_board's, so make that your fifth call before you touch anything. 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 (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.",
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." },
49
49
  { tool: "list_workflows", why: "what a writer can ask you for." },
50
50
  { tool: "list_reminders", why: "the house principles the app starts with, and the writer's own; read them before you change anything. Reminders live on the project and go with it." },
51
+ { tool: "list_board", why: "the records — every card, the cast, the places, the rows — with their ids, which the reading does not carry and every write needs." },
51
52
  ],
52
53
  rules: [
53
54
  "Questions, not fixes, until the writer says.",
@@ -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", "new_project", "new_board", "rename_project", "set_target", "create_note", "list_board", "add_character", "cast", "update_character", "set_location", "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_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