plotcoder-board 0.1.19 → 0.1.20

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. Fourteen rounds have been run, the first three through a
54
+ just what produces it. Fifteen 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 thirteen is fixed or decided, and round fourteen's forty-nine were
57
- worked through the day after it ran. [`blind-runs/`](blind-runs/) holds the rules that keep a round honest,
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,
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.19. 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.20. 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.19",
3
+ "version": "0.1.20",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite",
@@ -33,6 +33,7 @@ import {
33
33
  NOTE_WIDTH,
34
34
  DEFAULT_TARGET_EIGHTHS,
35
35
  normalizeState,
36
+ newId,
36
37
  noteEighths,
37
38
  NOTE_COLORS,
38
39
  NOTE_RANKS,
@@ -564,7 +565,19 @@ async function throughAccount(read) {
564
565
  throw new DoorReply(accountRefusal ?? "The account door is shut.");
565
566
  }
566
567
 
568
+ /** The project as last read, so any reading of the open board can know who is cast on another board (R51) without a second read. */
569
+ let lastHeld = null;
567
570
  async function readProject() {
571
+ const held = await readProjectUncached();
572
+ lastHeld = held;
573
+ return held;
574
+ }
575
+ /** People on a card of another board of the project: the ids readWall must not ask about (round fifteen, entries 18 and 21). */
576
+ function elsewhereIds(boardId) {
577
+ if (!lastHeld?.project) return [];
578
+ return Object.keys(castElsewhere(lastHeld.project, lastHeld.boards, boardId ?? lastHeld.project.activeBoardId));
579
+ }
580
+ async function readProjectUncached() {
568
581
  const viaAccount = await throughAccount(accountReadProject);
569
582
  if (viaAccount) return viaAccount;
570
583
  const base = await findBridge();
@@ -738,8 +751,13 @@ async function writeBoardRaw(next, rev, base, boardId = null) {
738
751
  log("bridge write failed, falling back to file:", error);
739
752
  }
740
753
  }
741
- writeFileBoard(next, rev + 1, boardId);
742
- syncProjectFileBoard(boardId, next);
754
+ // A board file from before the project file has no id of its own; once a
755
+ // project file exists it is that project's open board, and it has to say so,
756
+ // or nothing written here reaches the project's copy and open_board later
757
+ // brings back a stale one (found by round fifteen's cross-board move).
758
+ const id = boardId ?? readFileProject()?.project.activeBoardId ?? null;
759
+ writeFileBoard(next, rev + 1, id);
760
+ syncProjectFileBoard(id, next);
743
761
  return false;
744
762
  }
745
763
 
@@ -765,9 +783,12 @@ const sinceRead = [];
765
783
  /** 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). */
766
784
  let lastChange = null;
767
785
  const findingKey = (finding) => `${finding.kind}|${finding.ids.join(",")}|${finding.text}`;
768
- function noteChange(before, after) {
769
- const was = readWall(before);
770
- const now = readWall(after);
786
+ function noteChange(before, after, boardId = null) {
787
+ // The same reading read_wall gives: a person cast on another board is not
788
+ // asked about, so a write's tail never names a question the reading does not.
789
+ const options = { elsewhere: elsewhereIds(boardId) };
790
+ const was = readWall(before, options);
791
+ const now = readWall(after, options);
771
792
  const wasKeys = new Set(was.findings.map(findingKey));
772
793
  const nowKeys = new Set(now.findings.map(findingKey));
773
794
  lastChange = {
@@ -845,7 +866,7 @@ async function commit(command) {
845
866
  const live = await writeBoard(next, rev, base, boardId, "exact");
846
867
  trail.push({ before: state, after: canon(next), what: describeCommand(command) });
847
868
  sinceRead.push(describeCommand(command));
848
- noteChange(state, next);
869
+ noteChange(state, next, boardId);
849
870
  if (trail.length > TRAIL_CAP) trail.shift();
850
871
  undone.length = 0;
851
872
  return { state: next, changed, result, live };
@@ -877,7 +898,7 @@ async function commitAll(what, build) {
877
898
  const live = await writeBoard(current, rev, base, boardId, "exact");
878
899
  trail.push({ before: state, after: canon(current), what });
879
900
  sinceRead.push(what);
880
- noteChange(state, current);
901
+ noteChange(state, current, boardId);
881
902
  if (trail.length > TRAIL_CAP) trail.shift();
882
903
  undone.length = 0;
883
904
  return { state: current, changed: true, value, live };
@@ -932,7 +953,7 @@ const CHECK_WORDS = {
932
953
  unlinked: "no card without an arrow",
933
954
  duplicate: "no two headlines alike",
934
955
  sequence: "no group too long for one sequence (act groups are not asked)",
935
- uncast: "nobody in the cast on no card",
956
+ uncast: "nobody in the cast on no card of the project",
936
957
  absent: "nobody gone for a third of the story",
937
958
  backwards: "no payoff before its setup",
938
959
  unpaid: "no fold without a payoff",
@@ -942,13 +963,23 @@ const SAMPLE_NOTE = "sample: this is the wall PlotCoder starts with (Maya, Tom,
942
963
 
943
964
  // --- Reporting -------------------------------------------------------------
944
965
 
966
+ /** What kinds of number a runtime folds together: measured from text, set by the writer, or the default page (round fifteen, entry 39). */
967
+ function runtimeKinds(state) {
968
+ const measured = state.notes.filter((note) => isMeasured(note)).length;
969
+ const sized = state.notes.filter((note) => !isMeasured(note) && note.lengthEighths !== null).length;
970
+ const unsized = state.notes.length - measured - sized;
971
+ if (!state.notes.length) return "";
972
+ 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`;
973
+ }
974
+
945
975
  function summarize(state) {
946
976
  const nameOf = new Map(state.characters.map((character) => [character.id, character.name]));
947
977
  const notes = storyOrder(state)
948
978
  .map((note) => {
949
979
  const cast = note.characterIds.map((id) => nameOf.get(id) ?? id);
950
980
  const who = cast.length ? `, cast: ${cast.join(", ")}` : "";
951
- const plant = note.plants ? (note.payoffBoardId ? ", plants pays off later" : ", plants") : "";
981
+ // The board it pays off on, named here as read_wall names it (round fifteen, entry 44).
982
+ const plant = note.plants ? (note.payoffBoardId ? `, plants → pays off later on "${lastHeld?.project?.boards?.find((meta) => meta.id === note.payoffBoardId)?.name ?? note.payoffBoardId}"` : ", plants") : "";
952
983
  const snap = state.revision?.snapshot?.[note.id];
953
984
  const revised = snap && (snap.headline !== note.headline || snap.change !== note.change || (snap.text ?? "") !== (note.text ?? "") || (snap.location ?? "") !== (note.location ?? "")) ? `, changed in ${state.revision.color}` : "";
954
985
  const place = note.location ? `, at: ${note.location}` : "";
@@ -1023,8 +1054,8 @@ function summarize(state) {
1023
1054
  `left, for now: ${leftCount ? `${leftCount} question(s) the writer left; read_wall lists them` : "none"}`,
1024
1055
  `beats: ${beats}, scenes: ${scenes}`,
1025
1056
  state.targetEighths === DEFAULT_TARGET_EIGHTHS
1026
- ? `runtime: about ${formatPages(runtime)} pages (an estimate from the cards; a page runs about a minute); no target set — set_target for a pilot (60) or a half-hour (30); against the feature default of 120 it would be ${formatPages(-over)} under`
1027
- : `runtime: about ${formatPages(runtime)} pages of a ${formatPages(state.targetEighths)}-page target — ${over > 0 ? `${formatPages(over)} over` : over < 0 ? `${formatPages(-over)} under` : "on it"} (an estimate from the cards; a page runs about a minute)`,
1057
+ ? `runtime: about ${formatPages(runtime)} pages (an estimate from the cards; a page runs about a minute); no target set — set_target for a pilot (60) or a half-hour (30); against the feature default of 120 it would be ${formatPages(-over)} under${runtimeKinds(state)}`
1058
+ : `runtime: about ${formatPages(runtime)} pages of a ${formatPages(state.targetEighths)}-page target — ${over > 0 ? `${formatPages(over)} over` : over < 0 ? `${formatPages(-over)} under` : "on it"} (an estimate from the cards; a page runs about a minute)${runtimeKinds(state)}`,
1028
1059
  `notes: ${state.notes.length}, groups: ${state.groups.length}, arrows: ${state.arrows.length}, cast: ${state.characters.length}`,
1029
1060
  "cards (in story order — the follows arrows over the rows; each with its id):",
1030
1061
  notes || " (no cards)",
@@ -1370,7 +1401,11 @@ server.registerTool(
1370
1401
  .map((group) => (group.dissolved ? ` Its group "${group.title}" dissolved: a frame needs two cards.` : ` Left its group "${group.title}", which keeps ${group.remaining} card${group.remaining === 1 ? "" : "s"}.`))
1371
1402
  .join("");
1372
1403
  const joined = result.joined ? ` The chain is joined behind it: "${result.joined.fromHeadline}" → "${result.joined.toHeadline}" (follows).` : "";
1373
- return ok(`Deleted "${result.headline}"${where(live)}.${arrows}${joined}${groups}`, result);
1404
+ // The fold and where it paid off went with the card (round fifteen, entry 17).
1405
+ const fold = result.plants
1406
+ ? ` Its folded corner went with it${result.payoffBoardId ? ` — it paid off later, on "${(await readProject()).project.boards.find((meta) => meta.id === result.payoffBoardId)?.name ?? result.payoffBoardId}"` : ""}; nothing on the wall plants that now.`
1407
+ : "";
1408
+ return ok(`Deleted "${result.headline}"${where(live)}.${arrows}${joined}${groups}${fold}`, result);
1374
1409
  },
1375
1410
  );
1376
1411
 
@@ -1481,8 +1516,9 @@ server.registerTool(
1481
1516
  async (args) => {
1482
1517
  const wanted = args.questions?.length ? args.questions : args.kind ? [{ kind: args.kind, ids: args.ids, why: args.why }] : [];
1483
1518
  if (!wanted.length) return ok("Say which question: its kind as read_wall names it (and ids when that kind is asked more than once), or a list under questions.");
1484
- const { state } = await readBoard();
1485
- const reading = readWall(state);
1519
+ const { state, boardId: leaveBoardId } = await readBoard();
1520
+ const readOptions = { elsewhere: elsewhereIds(leaveBoardId) };
1521
+ const reading = readWall(state, readOptions);
1486
1522
  const replies = [];
1487
1523
  const toLeave = [];
1488
1524
  for (const want of wanted) {
@@ -1522,7 +1558,7 @@ server.registerTool(
1522
1558
  after = out.state;
1523
1559
  for (const item of toLeave) replies.push(`Left, for now: [${item.finding.kind}] ${item.finding.text}${item.why ? ` — "${item.why}"` : ""}`);
1524
1560
  }
1525
- const still = readWall(after).findings;
1561
+ const still = readWall(after, readOptions).findings;
1526
1562
  const tail = toLeave.length
1527
1563
  ? `${where(live)} ${once("leave-rule", "The wall keeps the writer's word and asks a left question again on its own when it would read differently; ask_again brings one back now. ")}The wall still asks ${still.length === 0 ? "nothing" : `${still.length}: ${still.map((finding) => `[${finding.kind}] ${finding.text}`).join(" ")}`}.`
1528
1564
  : "";
@@ -1542,7 +1578,8 @@ server.registerTool(
1542
1578
  const held = (state.left ?? []).filter((item) => item.kind === args.kind && (!args.ids || sameList(item.ids, args.ids)));
1543
1579
  if (held.length === 0) return ok(`Nothing of kind "${args.kind}"${args.ids ? ` about ids ${args.ids.join(", ")}` : ""} is left. read_wall lists what is, under "left, for now".`);
1544
1580
  const { result, live } = await commit({ type: "ask_again", kind: args.kind, ids: args.ids });
1545
- const reading = readWall((await readBoard()).state);
1581
+ const again = await readBoard();
1582
+ const reading = readWall(again.state, { elsewhere: elsewhereIds(again.boardId) });
1546
1583
  const back = reading.findings.filter((finding) => held.some((item) => item.kind === finding.kind && sameList(item.ids, finding.ids)));
1547
1584
  return ok(
1548
1585
  `Asked again${where(live)}: ${held.length} question${held.length === 1 ? "" : "s"} of kind "${args.kind}" ${held.length === 1 ? "is" : "are"} no longer left${back.length ? ` — the wall asks ${back.length === 1 ? "it" : `${back.length} of them`} now: ${back.map((finding) => finding.text).join(" ")}` : " — and the wall no longer asks it; the question had already changed"}.`,
@@ -1551,15 +1588,117 @@ server.registerTool(
1551
1588
  },
1552
1589
  );
1553
1590
 
1591
+ /**
1592
+ * A scene moves to another board of the project (round fifteen, entry 16: a
1593
+ * writer's "the shim should open episode two" had no tool, and the way round
1594
+ * was a delete and a recreate by hand). Two frames, one per board: the card
1595
+ * leaves the open board with everything a delete takes, and lands on the
1596
+ * target with its own record — cast, place, when, rank, length, text, fold —
1597
+ * wired after or before a card there, or at the head of that board's story.
1598
+ */
1599
+ async function moveAcrossBoards(args, target, open, held) {
1600
+ if (args.after && args.before) return ok("Say where: after one card's id, or before one, not both.");
1601
+ const card = open.state.notes.find((note) => note.id === args.id);
1602
+ if (!card) return ok(`No card with id ${args.id}. Call list_board.`);
1603
+ const fromMeta = boardById(held.project, open.boardId ?? held.project.activeBoardId);
1604
+ const targetState = isBoardState(held.boards[target.id]) ? normalizeState(held.boards[target.id]) : emptyState();
1605
+ const anchorId = args.after ?? args.before;
1606
+ const anchor = anchorId ? targetState.notes.find((note) => note.id === anchorId) : null;
1607
+ if (anchorId && !anchor) return ok(`No card with id ${anchorId} on "${target.name}". open_board there and list_board for its ids, or leave after and before out to land at the head of its story.`);
1608
+ // Leave: one frame on the board it is on.
1609
+ let taken = null;
1610
+ await commitAll(`move_scene "${card.headline}" to "${target.name}" (leave)`, (step) => {
1611
+ taken = step({ type: "delete_note", id: card.id }).result;
1612
+ });
1613
+ // Open the board it is going to, everywhere.
1614
+ const now = await readProject();
1615
+ await openBoardEverywhere(now.project, now.boards, now.rev, now.base, target.id);
1616
+ // Land: one frame there.
1617
+ const isFollows = (arrow) => arrow.kind !== "setup";
1618
+ let landedId = card.id;
1619
+ let joinedGroup = null;
1620
+ let headOf = null;
1621
+ let forgotLater = false;
1622
+ const { state: final, live } = await commitAll(`move_scene "${card.headline}" to "${target.name}" (land)`, (step, current) => {
1623
+ const here = current();
1624
+ landedId = here.notes.some((note) => note.id === card.id) ? newId() : card.id;
1625
+ step({
1626
+ type: "create_note",
1627
+ id: landedId,
1628
+ headline: card.headline,
1629
+ change: card.change,
1630
+ color: card.color,
1631
+ rank: card.rank,
1632
+ lengthEighths: card.lengthEighths,
1633
+ characterIds: card.characterIds,
1634
+ plants: card.plants,
1635
+ location: card.location,
1636
+ when: card.when,
1637
+ text: card.text,
1638
+ ...nextPlace(here),
1639
+ });
1640
+ if (card.plants && card.payoffBoardId && card.payoffBoardId !== target.id) step({ type: "set_payoff_board", ids: [landedId], boardId: card.payoffBoardId });
1641
+ if (card.plants && card.payoffBoardId === target.id) forgotLater = true;
1642
+ if (anchor) {
1643
+ const mid = current();
1644
+ if (args.after) {
1645
+ for (const arrow of mid.arrows.filter((item) => isFollows(item) && item.from === anchor.id)) {
1646
+ step({ type: "delete_arrow", id: arrow.id });
1647
+ step({ type: "create_arrow", from: landedId, to: arrow.to, kind: "follows" });
1648
+ }
1649
+ step({ type: "create_arrow", from: anchor.id, to: landedId, kind: "follows" });
1650
+ } else {
1651
+ for (const arrow of mid.arrows.filter((item) => isFollows(item) && item.to === anchor.id)) {
1652
+ step({ type: "delete_arrow", id: arrow.id });
1653
+ step({ type: "create_arrow", from: arrow.from, to: landedId, kind: "follows" });
1654
+ }
1655
+ step({ type: "create_arrow", from: landedId, to: anchor.id, kind: "follows" });
1656
+ }
1657
+ const anchorGroup = current().groups.find((group) => group.noteIds.includes(anchor.id));
1658
+ if (anchorGroup) {
1659
+ step({ type: "add_to_group", id: anchorGroup.id, noteIds: [landedId] });
1660
+ joinedGroup = anchorGroup.title || "an untitled group";
1661
+ }
1662
+ } else {
1663
+ const head = storyOrder(here)[0];
1664
+ if (head && here.arrows.some(isFollows)) {
1665
+ step({ type: "create_arrow", from: landedId, to: head.id, kind: "follows" });
1666
+ headOf = head.headline;
1667
+ }
1668
+ }
1669
+ step({ type: "apply_poses", poses: organizePoses(current(), {}) });
1670
+ });
1671
+ const order = storyOrder(final);
1672
+ const arrows = taken?.arrows?.length
1673
+ ? ` 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}"` : ""}.`
1674
+ : ` No arrow touched it on "${fromMeta.name}".`;
1675
+ 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("");
1676
+ const landed = anchor
1677
+ ? `${args.after ? "after" : "before"} "${anchor.headline}"${joinedGroup ? `, in "${joinedGroup}"` : ""}`
1678
+ : headOf ? `at the head of the story, before "${headOf}"` : "as the only card wired to nothing yet";
1679
+ 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.") : "";
1680
+ return ok(
1681
+ `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.`,
1682
+ { id: landedId, board: target.id, order: order.map((note) => note.id) },
1683
+ );
1684
+ }
1685
+
1554
1686
  server.registerTool(
1555
1687
  "move_scene",
1556
1688
  {
1557
1689
  title: "Move a scene in the story",
1558
1690
  description:
1559
- "Move a card to another place in the story order — after one card, or before one — by rewiring its follows arrows and tidying the wall along them, as one step that undo takes back whole. The story order is the follows arrows: the card leaves its place (what pointed at it now points at what it pointed at) and lands between the target and what followed it. A person does this by dragging in the outline. Needs a wall with follows arrows; on a wall without any, create_arrow the sequence first, or move_note by position.",
1560
- inputSchema: { id: z.string(), after: z.string().optional(), before: z.string().optional() },
1691
+ "Move a card to another place in the story order — after one card, or before one — by rewiring its follows arrows and tidying the wall along them, as one step that undo takes back whole. The story order is the follows arrows: the card leaves its place (what pointed at it now points at what it pointed at) and lands between the target and what followed it. A person does this by dragging in the outline. Needs a wall with follows arrows; on a wall without any, create_arrow the sequence first, or move_note by position. To another board of the project: pass board (name, id or number from list_boards) and, optionally, after or before a card there; with neither the card lands at the head of that board's story. Across boards the card keeps its cast, place, when, rank, length, text and fold; its arrows stay behind, and that board is then the open one. Undo is per board: one step there, one on the board it left.",
1692
+ inputSchema: { id: z.string(), after: z.string().optional(), before: z.string().optional(), board: z.union([z.string().min(1), z.number()]).optional() },
1561
1693
  },
1562
1694
  async (args) => {
1695
+ if (args.board !== undefined) {
1696
+ const held = await readProject();
1697
+ const target = findBoard(held.project, String(args.board));
1698
+ if (!target) return ok(`No board matches "${args.board}". Call list_boards for the real ones.`);
1699
+ const open = await readBoard();
1700
+ if (target.id !== (open.boardId ?? held.project.activeBoardId)) return moveAcrossBoards(args, target, open, held);
1701
+ }
1563
1702
  if (!args.after === !args.before) return ok("Say where: after one card's id, or before one, not both.");
1564
1703
  const { state } = await readBoard();
1565
1704
  const find = (id) => state.notes.find((note) => note.id === id);
@@ -1711,7 +1850,8 @@ server.registerTool(
1711
1850
  const lines = [
1712
1851
  `the writer's own: ${own.length}`,
1713
1852
  ...own.map((structure) => ` - ${structure.id} — "${structure.name}" (${structure.beats.length} beats: ${structure.beats.map((beat) => `${beat.name} at ${Math.round(beat.at * 100)}%`).join(", ")})`),
1714
- `built in: ${TEMPLATES.length} — ${TEMPLATES.map((template) => `${template.id} "${template.name}" (${template.beats.length} beats)`).join(", ")}; each beat's name, prompt and place in the story are in the JSON`,
1853
+ `built in: ${TEMPLATES.length}`,
1854
+ ...TEMPLATES.map((template) => ` - ${template.id} — "${template.name}" (${template.beats.length} beats: ${template.beats.map((beat) => `${beat.name} at ${Math.round(beat.at * 100)}%`).join(", ")})`),
1715
1855
  "compare_structure sets one of these beside this wall's beats, page by page, and lays nothing",
1716
1856
  ];
1717
1857
  return ok(lines.join("\n"), { builtIn: TEMPLATES.map((template) => ({ id: template.id, name: template.name, beats: template.beats })), own });
@@ -1737,12 +1877,15 @@ server.registerTool(
1737
1877
  if (!chosen) return ok(`No structure called "${args.structure}". list_structures names the built-in five and the writer's own.`);
1738
1878
  const comparison = compareStructure(state, chosen.beats);
1739
1879
  const beats = state.notes.filter((note) => note.rank === "beat").length;
1880
+ const allMeasured = state.notes.length > 0 && state.notes.every((note) => isMeasured(note));
1881
+ const short = state.targetEighths > 0 && boardEighths(state) * 2 < state.targetEighths;
1740
1882
  const lines = [
1741
- `"${chosen.name}" beside this wall's ${beats} beat${beats === 1 ? "" : "s"}, of ${formatPages(state.targetEighths)} pages (the story so far runs to p. ${comparison.soFar}); a match is the nearest of the wall's beats within ${MATCH_PAGES} pages, one to one and in order:`,
1883
+ `"${chosen.name}" beside this wall's ${beats} beat${beats === 1 ? "" : "s"}, of ${formatPages(state.targetEighths)} pages (the story so far runs to p. ${comparison.soFar}${allMeasured ? ", measured" : ", an estimate: unsized cards read as a page each"}); a match is the nearest of the wall's beats within ${MATCH_PAGES} pages, one to one and in order:`,
1742
1884
  ...describeComparison(comparison).map((line) => ` - ${line}`),
1743
1885
  comparison.unmatched.length
1744
1886
  ? `beats of the wall no beat of the structure answers: ${comparison.unmatched.map((beat) => `"${beat.headline}" (p. ${beat.page})`).join(", ")}`
1745
1887
  : "every beat of the wall answers one of the structure's",
1888
+ ...(short ? [`the wall runs to less than half its target, so its beats sit early and the ${MATCH_PAGES}-page window pairs them with the structure's first beats by arithmetic; the pairing says more once the cards are sized or written, and whether a turn is missing is the writer's call, not this reading's`] : []),
1746
1889
  beats === 0 ? "No card on this board is marked as a beat (set_rank), so there is nothing to compare; apply_template lays the structure's beats to fill." : "Nothing moved and nothing was made: this is a reading. apply_template lays the beats as cards when the writer wants them.",
1747
1890
  ];
1748
1891
  return ok(lines.join("\n"), { structure: { id: chosen.id, name: chosen.name }, ...comparison });
@@ -1870,7 +2013,7 @@ server.registerTool(
1870
2013
  }
1871
2014
  const printed = sceneLineCount(args.text);
1872
2015
  return ok(
1873
- `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${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 card is measured, not estimated; the estimate underneath is untouched.`,
2016
+ `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.`,
1874
2017
  { ...result, eighths: noteEighths(result), measured: true, printedLines: printed },
1875
2018
  );
1876
2019
  },
@@ -2026,7 +2169,7 @@ server.registerTool(
2026
2169
  {
2027
2170
  title: "Export as Final Draft",
2028
2171
  description:
2029
- "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 for the project (a one-board film is its project). Pass a path to write the file; otherwise the XML comes back.",
2172
+ "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.",
2030
2173
  inputSchema: { path: z.string().optional() },
2031
2174
  },
2032
2175
  async (args) => {
@@ -2035,12 +2178,14 @@ server.registerTool(
2035
2178
  const board = project.boards.find((item) => item.id === project.activeBoardId);
2036
2179
  const titles = scriptTitles(project, board);
2037
2180
  const xml = toFdx(state, { ...titles, draftDate: new Date().toISOString() });
2181
+ // The file's name, so an agent writing it by hand has one (round fifteen, entry 34).
2182
+ const filename = `${titles.title}${titles.episode ? ` - ${board?.name ?? ""}` : ""}`.replace(/[\\/:*?"<>|]+/g, " ").replace(/\s+/g, " ").trim() + ".fdx";
2038
2183
  if (args.path) {
2039
2184
  fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
2040
2185
  fs.writeFileSync(args.path, xml);
2041
- return ok(`Wrote a Final Draft file with ${state.notes.length} scene(s), titled "${titles.title}", to ${args.path}.`);
2186
+ return ok(`Wrote a Final Draft file with ${state.notes.length} scene(s), titled "${titles.title}"${titles.episode ? ` (${titles.episode})` : ""}, to ${args.path}.`);
2042
2187
  }
2043
- return ok(xml);
2188
+ return ok(xml.replace(/^(<\?xml[^>]*\?>\n)/, `$1<!-- Save as: ${filename.replace(/--/g, "- -")} -->\n`));
2044
2189
  },
2045
2190
  );
2046
2191
 
@@ -2467,7 +2612,7 @@ server.registerTool(
2467
2612
  ? ok(`Already in the cast as "${result.name}" (${result.id}). Use that id.`, result)
2468
2613
  : ok("No character added: the name was empty.");
2469
2614
  }
2470
- return ok(`Added "${result.name}" to the project's cast${where(live)}; every board of the project casts from it.`, result);
2615
+ return ok(`Added "${result.name}" (id ${result.id}) to the project's cast${where(live)}; every board of the project casts from it.`, result);
2471
2616
  },
2472
2617
  );
2473
2618
 
@@ -2505,22 +2650,37 @@ server.registerTool(
2505
2650
  {
2506
2651
  title: "Read a person's page",
2507
2652
  description:
2508
- "Read one person's page back, by id or by name: the five lines — looks, voice, wants, needs, notes — as they stand, and which cards the person is on. list_board says only which lines are written; this says what they say.",
2653
+ "Read one person's page back, by id or by name: the five lines — looks, voice, wants, needs, notes — as they stand, and every card the person is on across every board of the project, in story order, each with its place, when and rank. The cast is the project's (one record, one page), so this reads all of it; list_board says only which lines are written.",
2509
2654
  inputSchema: { id: z.string().optional(), name: z.string().optional() },
2510
2655
  },
2511
2656
  async (args) => {
2512
2657
  const key = (args.id ?? args.name ?? "").trim();
2513
2658
  if (!key) return ok("Say who: the person's id or name from list_board.");
2514
- const { state } = await readBoard();
2659
+ const { state, boardId } = await readBoard();
2660
+ const { project, boards } = await readProject();
2515
2661
  const wanted = key.toLowerCase();
2516
2662
  const person = state.characters.find((item) => item.id === key) ?? state.characters.find((item) => item.name.trim().toLowerCase() === wanted);
2517
2663
  if (!person) return ok(`Nobody called "${key}" in the cast. Call list_board for the cast, or add_character.`);
2518
- const on = storyOrder(state).filter((note) => note.characterIds.includes(person.id));
2664
+ // The person's part is the project's, not one board's (R51; round fifteen,
2665
+ // entries 22 and 23): every board, in the project's order, the open one read live.
2666
+ const openId = boardId ?? project.activeBoardId;
2667
+ const parts = project.boards.map((meta) => {
2668
+ const held = meta.id === openId ? state : isBoardState(boards[meta.id]) ? normalizeState(boards[meta.id]) : emptyState();
2669
+ const on = storyOrder(held).filter((note) => (note.characterIds ?? []).includes(person.id));
2670
+ return { meta, on };
2671
+ });
2672
+ const total = parts.reduce((sum, part) => sum + part.on.length, 0);
2673
+ const where_ = (note) => [note.location ? `at ${note.location}` : "", note.when ? note.when : "", note.rank === "beat" ? "beat" : ""].filter(Boolean).join(" · ");
2519
2674
  const lines = [
2520
- `${person.name} (${person.id}) — on ${on.length} card${on.length === 1 ? "" : "s"}${on.length ? `, in story order: ${on.map((note) => `"${note.headline}"`).join(", ")}` : ""}`,
2675
+ `${person.name} (${person.id}) — on ${total} card${total === 1 ? "" : "s"} across ${project.boards.length} board${project.boards.length === 1 ? "" : "s"} of the project`,
2521
2676
  ...CHARACTER_FIELDS.map((field) => ` ${field}: ${(person[field] ?? "").trim() || "(empty)"}`),
2677
+ ...parts.map((part) =>
2678
+ part.on.length
2679
+ ? ` "${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("; ")}`
2680
+ : ` "${part.meta.name}": on no card`,
2681
+ ),
2522
2682
  ];
2523
- return ok(lines.join("\n"), { ...person, cards: on.map((note) => note.id) });
2683
+ 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) })) });
2524
2684
  },
2525
2685
  );
2526
2686
 
@@ -2706,7 +2866,7 @@ server.registerTool(
2706
2866
  : "No group made: a group needs at least two cards.",
2707
2867
  );
2708
2868
  }
2709
- return ok(`Grouped ${result.noteIds.length} cards as "${result.title}"${where(live)}.`, result);
2869
+ return ok(`Grouped ${result.noteIds.length} cards as "${result.title}" (group id ${result.id})${where(live)}.`, result);
2710
2870
  },
2711
2871
  );
2712
2872
 
@@ -2876,6 +3036,16 @@ server.registerTool(
2876
3036
  `premise: ${project.premise ? `"${project.premise}"` : "(not set)"}`,
2877
3037
  `boards: ${project.boards.length}`,
2878
3038
  describeBoards(project, boards, changedAt),
3039
+ // The project's length as one line, so a series is not arithmetic by hand (round fifteen, entry 38).
3040
+ ...(project.boards.length > 1
3041
+ ? (() => {
3042
+ const states = project.boards.map((meta) => (isBoardState(boards[meta.id]) ? normalizeState(boards[meta.id]) : null)).filter(Boolean);
3043
+ const pages = states.reduce((sum, state) => sum + boardEighths(state), 0);
3044
+ const target = states.reduce((sum, state) => sum + state.targetEighths, 0);
3045
+ const cards = states.reduce((sum, state) => sum + state.notes.length, 0);
3046
+ return [`the whole project: ${cards} cards, about ${formatPages(pages)} of ${formatPages(target)} pages across ${states.length} boards (each board's runtime is an estimate unless every scene is written)`];
3047
+ })()
3048
+ : []),
2879
3049
  ].join("\n"),
2880
3050
  project,
2881
3051
  );
@@ -2911,7 +3081,11 @@ server.registerTool(
2911
3081
  const next = renameProject(project, args.name);
2912
3082
  if (next === project) return ok("Project name unchanged.");
2913
3083
  await writeProject(next, boards, rev, base);
2914
- return ok(`Project renamed to "${next.name}"${where(live)}.`, next);
3084
+ // The line at the head of every reply names the project it works; it must
3085
+ // follow the rename (round fifteen, entry 40).
3086
+ if (accountDoor) workingProject(next.id, next.name);
3087
+ const several = (next.boards ?? []).length > 1;
3088
+ return ok(`Project renamed to "${next.name}"${where(live)}. It shows at the head of every reply, in list_boards and read_wall, and as the title of every script out${several ? `, where each board follows it as an episode line (Episode 1 of ${next.boards.length} · ${next.boards[0].name})` : ""}. The boards keep their names.`, next);
2915
3089
  },
2916
3090
  );
2917
3091
 
@@ -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 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, open_board, new_project or empty_account, make those two again. 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), then read_wall and list_reminders again. 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 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.",
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." },
@@ -64,16 +64,19 @@ export const AGENTS = {
64
64
 
65
65
  /** The on-ramp as one text: the file at /llms.txt, and what an agent reads. */
66
66
  export function agentsAsText() {
67
- const lines = ["# PlotCoder — for agents", "", AGENTS.lead, "", `The guide: ${AGENTS.guide}. Read it once, before your first call if you can; it is the whole and this page is its first page, and where the two differ, the guide wins. Then Call these first, below. The doors between are for wiring a server in; skip them when the tools are already in front of you.`, "", "## Doors"];
68
- for (const door of AGENTS.doors) {
69
- lines.push(`- ${door.name}: ${door.text}`);
70
- if (door.code) lines.push("", "```", door.code, "```", "");
71
- }
72
- lines.push("## Call these first");
67
+ // The calls and the rules first, the doors after: an agent with the tools in
68
+ // front of it reads two pages of wiring it was told to skip before it reached
69
+ // the first call (round fifteen, entry 1).
70
+ const lines = ["# PlotCoder — for agents", "", AGENTS.lead, "", `The guide: ${AGENTS.guide}. Read it once, before your first call if you can; it is the whole and this page is its first page, and where the two differ, the guide wins. Then Call these first, below. The doors at the end are for wiring a server in; skip them when the tools are already in front of you.`, "", "## Call these first"];
73
71
  AGENTS.first.forEach((item, index) => lines.push(`${index + 1}. ${item.tool} — ${item.why}`));
74
72
  lines.push("", AGENTS.firstNote);
75
73
  lines.push("", "## Rules");
76
74
  for (const rule of AGENTS.rules) lines.push(`- ${rule}`);
77
- lines.push("", "## For the person", AGENTS.person, "", "## The guide", AGENTS.guide, "");
75
+ lines.push("", "## Doors");
76
+ for (const door of AGENTS.doors) {
77
+ lines.push(`- ${door.name}: ${door.text}`);
78
+ if (door.code) lines.push("", "```", door.code, "```", "");
79
+ }
80
+ lines.push("## For the person", AGENTS.person, "", "## The guide", AGENTS.guide, "");
78
81
  return lines.join("\n");
79
82
  }
@@ -5,7 +5,7 @@ import type { FountainScene } from "./fountain";
5
5
 
6
6
  export declare function toFdx(
7
7
  state: BoardState,
8
- options?: { title?: string; project?: string; author?: string; draftDate?: string },
8
+ options?: { title?: string; episode?: string; author?: string; draftDate?: string },
9
9
  ): string;
10
10
 
11
11
  export type SetAside = {
package/src/board/fdx.js CHANGED
@@ -88,10 +88,11 @@ export function toFdx(state, options = {}) {
88
88
 
89
89
  const title = [];
90
90
  if (options.title) title.push(paragraph("General", options.title, ' Alignment="Center"'));
91
- if (options.project && options.project !== options.title) title.push(paragraph("General", `An episode of ${options.project}`, ' Alignment="Center"'));
91
+ if (options.episode) title.push(paragraph("General", options.episode, ' Alignment="Center"'));
92
92
  if (options.author) title.push(paragraph("General", `Written by ${options.author}`, ' Alignment="Center"'));
93
93
  if (options.draftDate) title.push(paragraph("General", options.draftDate.slice(0, 10)));
94
- title.push(paragraph("General", state.lock ? `Scene numbers locked ${String(state.lock.at).slice(0, 10)}.` : "Scene numbers follow the wall's order and are not locked."));
94
+ // A lock is a fact about the document; that there is none is the app's business, not the title page's (round fifteen, entry 36).
95
+ if (state.lock) title.push(paragraph("General", `Scene numbers locked ${String(state.lock.at).slice(0, 10)}.`));
95
96
  if (state.revision) title.push(paragraph("General", `${revisionLine(state)}; changed paragraphs are marked.`));
96
97
  // The revision set Final Draft shows its marks from (round fourteen, entry 45).
97
98
  const revisions = state.revision
@@ -12,6 +12,7 @@ export declare function unmark(text: string | null | undefined): { text: string;
12
12
 
13
13
  export declare function titlePage(titles: {
14
14
  title?: string;
15
+ episode?: string;
15
16
  credit?: string;
16
17
  author?: string;
17
18
  draftDate?: string;
@@ -22,7 +23,7 @@ export type FountainOptions = {
22
23
  /** The board's name. */
23
24
  title?: string;
24
25
  /** The project's name, when the board is one of several. */
25
- project?: string;
26
+ episode?: string;
26
27
  premise?: string;
27
28
  author?: string;
28
29
  /** ISO date string; only the date is printed. */
@@ -61,9 +61,10 @@ export function unmark(text) {
61
61
  }
62
62
 
63
63
  /** The title page block. `titles` is what the writer would put above the script. */
64
- export function titlePage({ title, credit, author, draftDate, notes }) {
64
+ export function titlePage({ title, episode, credit, author, draftDate, notes }) {
65
65
  const lines = [];
66
66
  if (title) lines.push(`Title: ${title}`);
67
+ if (episode) lines.push(`Episode: ${episode}`);
67
68
  if (credit) lines.push(`Credit: ${credit}`);
68
69
  if (author) lines.push(`Author: ${author}`);
69
70
  if (draftDate) lines.push(`Draft date: ${draftDate}`);
@@ -97,7 +98,7 @@ export function toFountain(state, options = {}) {
97
98
 
98
99
  const head = titlePage({
99
100
  title: options.title || "Untitled",
100
- credit: options.project && options.project !== options.title ? `An episode of ${options.project}` : undefined,
101
+ episode: options.episode,
101
102
  author: options.author,
102
103
  draftDate: options.draftDate ? options.draftDate.slice(0, 10) : undefined,
103
104
  notes,
@@ -2,7 +2,7 @@ import type { Line } from "./paginate";
2
2
  import type { BoardState } from "./reducer";
3
3
 
4
4
  /** What a document carries above the script: the board's name, the project's when it has several boards, the premise. */
5
- export type TakeOptions = { title?: string; project?: string; premise?: string };
5
+ export type TakeOptions = { title?: string; episode?: string; premise?: string };
6
6
 
7
7
  /** The wall as Markdown (R54): title, premise, logline, beats as headings, a heading per scene, the text or the change line. */
8
8
  export declare function toMarkdown(state: BoardState, options?: TakeOptions): string;
@@ -28,7 +28,7 @@ function upper(text) {
28
28
 
29
29
  function documentTitle(options) {
30
30
  const title = options.title || "Untitled";
31
- return options.project && options.project !== title ? `${options.project} · ${title}` : title;
31
+ return options.episode ? `${title} · ${options.episode}` : title;
32
32
  }
33
33
 
34
34
  /** A scene's text as Markdown paragraphs: action as it is, a speech as its cue in bold with the lines hard-broken under it. */
@@ -177,8 +177,8 @@ export function toPlainText(state, options = {}) {
177
177
  );
178
178
  const title = options.title || "Untitled";
179
179
  const out = [];
180
- if (options.project && options.project !== title) {
181
- out.push(centred(upper(options.project)), "", centred(title));
180
+ if (options.episode) {
181
+ out.push(centred(upper(title)), "", centred(options.episode));
182
182
  } else {
183
183
  out.push(centred(upper(title)));
184
184
  }
@@ -68,7 +68,7 @@ export declare function renameProject(project: ProjectRecord, name: string, now?
68
68
  export declare function setPremise(project: ProjectRecord, premise: string, now?: string): ProjectRecord;
69
69
  export declare function boardById(project: ProjectRecord, id: string): BoardMeta | null;
70
70
  /** What a script going out is called: a named project is the title, its board beside it only when the project has several. */
71
- export declare function scriptTitles(project: ProjectRecord, board: BoardMeta | null | undefined): { title: string; project?: string };
71
+ export declare function scriptTitles(project: ProjectRecord, board: BoardMeta | null | undefined): { title: string; episode?: string };
72
72
  export declare function findBoard(project: ProjectRecord, key: string): BoardMeta | null;
73
73
  export declare function reidentifyProject(
74
74
  project: ProjectRecord,
@@ -300,7 +300,15 @@ export function scriptTitles(project, board) {
300
300
  const boardName = (board?.name ?? "").trim() || "Untitled";
301
301
  const named = typeof project?.name === "string" && project.name.trim() && project.name !== DEFAULT_PROJECT_NAME;
302
302
  if (!named) return { title: boardName };
303
- if ((project.boards ?? []).length > 1) return { title: boardName, project: project.name };
303
+ const boards = project.boards ?? [];
304
+ if (boards.length > 1) {
305
+ // A series: the project is the title and the board is the episode line,
306
+ // numbered in the project's order, so one file says which episode it is
307
+ // (round fifteen, entries 32 and 37).
308
+ const index = boards.findIndex((item) => item.id === board?.id);
309
+ const number = index >= 0 ? `Episode ${index + 1} of ${boards.length}` : "An episode";
310
+ return { title: project.name, episode: `${number} · ${boardName}` };
311
+ }
304
312
  return { title: project.name };
305
313
  }
306
314
 
@@ -230,9 +230,14 @@ export function readWall(state, options = {}) {
230
230
  const note = byId.get(id);
231
231
  return note && (note.lengthEighths !== null || (note.text ?? "").trim());
232
232
  }));
233
- if (between.length >= 2 && claimed) {
234
- const typical = median(between.map((run) => run.eighths));
235
- const longest = between.reduce((top, run) => (run.eighths > top.eighths ? run : top));
233
+ // The typical run is the median of the runs that hold a card: an empty run
234
+ // is a question of its own ("empty"), and counting it here made a small
235
+ // wall's one ordinary scene read as a sag against a median of an eighth
236
+ // (round fifteen, entry 10).
237
+ const filled = between.filter((run) => run.ids.length > 0);
238
+ if (filled.length >= 2 && claimed) {
239
+ const typical = median(filled.map((run) => run.eighths));
240
+ const longest = filled.reduce((top, run) => (run.eighths > top.eighths ? run : top));
236
241
  if (typical > 0 && longest.eighths > SAG_RATIO * typical) {
237
242
  findings.push({
238
243
  kind: "sag",
@@ -620,6 +620,10 @@ export function applyCommand(state, command, now = nowIso()) {
620
620
  result: {
621
621
  id: command.id,
622
622
  headline: gone.headline,
623
+ // The fold and where it paid off go with the card too; a door that
624
+ // says what went should say these (round fifteen, entry 17).
625
+ plants: gone.plants === true,
626
+ payoffBoardId: gone.payoffBoardId ?? null,
623
627
  arrows: taken.map((arrow) => ({ ...arrow, fromHeadline: headlineOf(arrow.from), toHeadline: headlineOf(arrow.to) })),
624
628
  joined: joined ? { ...joined, fromHeadline: headlineOf(joined.from), toHeadline: headlineOf(joined.to) } : null,
625
629
  groups: left,