plotcoder-board 0.1.11 → 0.1.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plotcoder-board",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite",
@@ -52,6 +52,7 @@ import { DEFAULT_REMINDERS, titleFromBody } from "../src/board/reminders.js";
52
52
  import crypto from "node:crypto";
53
53
  import { describeRuns, describeSetups, readWall } from "../src/board/readWall.js";
54
54
  import { GAP, ROW_WIDTH, organizePoses } from "../src/board/organize.js";
55
+ import { sceneLineCount } from "../src/board/paginate.js";
55
56
  import {
56
57
  addBoard,
57
58
  addStructure,
@@ -434,14 +435,16 @@ async function accountReadProject() {
434
435
  const { data, error } = await accountDoor.client.from("projects").select("id, record, reminders, rev").eq("id", accountDoor.projectId).maybeSingle();
435
436
  if (error || !data || !isProjectRecord(data.record)) throw new Error(error?.message ?? "the project is gone from the account");
436
437
  const project = normalizeProject(data.record);
437
- const rows = await accountDoor.client.from("boards").select("id, state, rev").eq("project_id", project.id);
438
+ const rows = await accountDoor.client.from("boards").select("id, state, rev, updated_at").eq("project_id", project.id);
438
439
  const boards = {};
439
440
  const revs = {};
441
+ const changedAt = {};
440
442
  for (const row of rows.data ?? []) {
441
443
  if (isBoardState(row.state)) boards[row.id] = normalizeState(row.state);
442
444
  revs[row.id] = row.rev;
445
+ if (row.updated_at) changedAt[row.id] = row.updated_at;
443
446
  }
444
- return { project, boards, revs, reminders: Array.isArray(data.reminders) ? data.reminders : null, rev: data.rev, base: ACCOUNT, live: ACCOUNT };
447
+ return { project, boards, revs, changedAt, reminders: Array.isArray(data.reminders) ? data.reminders : null, rev: data.rev, base: ACCOUNT, live: ACCOUNT };
445
448
  }
446
449
 
447
450
  async function accountWriteProject(project, boards, rev, reminders) {
@@ -786,7 +789,7 @@ const CHECK_WORDS = {
786
789
  unwritten: "no card without a headline or change line",
787
790
  unlinked: "no card without an arrow",
788
791
  duplicate: "no two headlines alike",
789
- sequence: "no group too long for one sequence",
792
+ sequence: "no group too long for one sequence (act groups are not asked)",
790
793
  uncast: "nobody in the cast on no card",
791
794
  absent: "nobody gone for a third of the story",
792
795
  backwards: "no payoff before its setup",
@@ -803,11 +806,13 @@ function summarize(state) {
803
806
  .map((note) => {
804
807
  const cast = note.characterIds.map((id) => nameOf.get(id) ?? id);
805
808
  const who = cast.length ? `, cast: ${cast.join(", ")}` : "";
806
- const plant = note.plants ? ", plants" : "";
809
+ const plant = note.plants ? (note.payoffBoardId ? ", plants → pays off later" : ", plants") : "";
810
+ const snap = state.revision?.snapshot?.[note.id];
811
+ const revised = snap && (snap.headline !== note.headline || snap.change !== note.change || (snap.text ?? "") !== (note.text ?? "") || (snap.location ?? "") !== (note.location ?? "")) ? `, changed in ${state.revision.color}` : "";
807
812
  const place = note.location ? `, at: ${note.location}` : "";
808
813
  const count = formatPages(noteEighths(note));
809
- const pages = `${count} ${count === "1" ? "page" : "pages"}${isMeasured(note) ? ", written" : note.lengthEighths === null ? ", unsized" : ""}`;
810
- return ` - ${note.id} [${note.rank ?? "scene"}, ${pages}${who}${place}${plant}] — "${note.headline}" (${note.color}) at ${Math.round(note.x)},${Math.round(note.y)}`;
814
+ const pages = isMeasured(note) ? `${count} ${count === "1" ? "page" : "pages"}, written` : note.lengthEighths === null ? "about a page, unsized" : `${count} ${count === "1" ? "page" : "pages"}`;
815
+ return ` - ${note.id} [${note.rank ?? "scene"}, ${pages}${who}${place}${plant}${revised}] — "${note.headline}" (${note.color}) at ${Math.round(note.x)},${Math.round(note.y)}`;
811
816
  })
812
817
  .join("\n");
813
818
  const cast = state.characters
@@ -841,7 +846,9 @@ function summarize(state) {
841
846
  ` - ${group.id} — "${group.title}" holds ${group.noteIds.length}: ${group.noteIds.join(", ")}`,
842
847
  )
843
848
  .join("\n");
844
- const arrows = state.arrows
849
+ const storyIndex = new Map(readingOrder(state.notes).map((note, index) => [note.id, index]));
850
+ const arrows = [...state.arrows]
851
+ .sort((a, b) => (storyIndex.get(a.from) ?? Infinity) - (storyIndex.get(b.from) ?? Infinity) || (a.kind === "setup") - (b.kind === "setup"))
845
852
  .map(
846
853
  (arrow) =>
847
854
  ` - ${arrow.id} [${arrow.kind ?? "follows"}] — ${arrow.from} → ${arrow.to} ("${headline(arrow.from)}" ${arrow.kind === "setup" ? "sets up" : "→"} "${headline(arrow.to)}")`,
@@ -931,7 +938,7 @@ server.registerTool(
931
938
  : "board";
932
939
  return ok(
933
940
  `PlotCoder ${which} (${door(live, base)})\n${summarize(state)}`,
934
- state,
941
+ { ...state, revision: state.revision ? { name: state.revision.name, color: state.revision.color, since: state.revision.since } : null, notes: state.notes.map((note) => ({ ...note, eighths: noteEighths(note), measured: isMeasured(note) })) },
935
942
  );
936
943
  },
937
944
  );
@@ -976,7 +983,7 @@ server.registerTool(
976
983
  });
977
984
  const { beats, scenes } = countRanks(state);
978
985
  return ok(
979
- `${result?.length ?? 0} card(s) are now ${args.rank}${where(live)}. The board holds ${beats} beats and ${scenes} scenes.`,
986
+ `${result?.length ?? 0} card(s) are now ${args.rank}${where(live)}. The board holds ${beats} beats and ${scenes} scenes. The rows are as they were; organize lays a row per beat.`,
980
987
  result,
981
988
  );
982
989
  },
@@ -1000,7 +1007,7 @@ server.registerTool(
1000
1007
  lengthEighths: toEighths(args.pages),
1001
1008
  });
1002
1009
  return ok(
1003
- `${result?.length ?? 0} card(s) now run about ${args.pages} page(s)${where(live)}. The board runs about ${formatPages(boardEighths(state))} pages against a ${formatPages(state.targetEighths)}-page target.`,
1010
+ `${result?.length ?? 0} card(s) now run ${args.pages} page(s), the writer's estimate${where(live)}. The board runs about ${formatPages(boardEighths(state))} pages against a ${formatPages(state.targetEighths)}-page target.`,
1004
1011
  result,
1005
1012
  );
1006
1013
  },
@@ -1112,7 +1119,7 @@ server.registerTool(
1112
1119
  },
1113
1120
  },
1114
1121
  async (args) => {
1115
- const { result } = await commit({
1122
+ const { state, result, live } = await commit({
1116
1123
  type: "update_note",
1117
1124
  id: args.id,
1118
1125
  headline: args.headline,
@@ -1120,7 +1127,8 @@ server.registerTool(
1120
1127
  location: args.location,
1121
1128
  });
1122
1129
  if (result === undefined) return ok(`No card with id ${args.id}.`);
1123
- return ok("Updated card.", result);
1130
+ const mark = state.revision && state.revision.snapshot?.[result.id] && (state.revision.snapshot[result.id].headline !== result.headline || state.revision.snapshot[result.id].change !== result.change) ? ` Marked changed in the ${state.revision.color} revision "${state.revision.name}".` : "";
1131
+ return ok(`Updated "${result.headline}"${args.change !== undefined ? `: change line "${result.change}"` : ""}${where(live)}.${mark}`, result);
1124
1132
  },
1125
1133
  );
1126
1134
 
@@ -1184,7 +1192,9 @@ server.registerTool(
1184
1192
  inputSchema: {},
1185
1193
  },
1186
1194
  async () => {
1187
- const { state, live, base } = await readBoard();
1195
+ const { state, live, base, boardId: readBoardId } = await readBoard();
1196
+ const { project: projectForRead } = await readProject();
1197
+ const readBoardMeta = boardById(projectForRead, readBoardId ?? projectForRead.activeBoardId);
1188
1198
  const reading = readWall(state);
1189
1199
  const runs = describeRuns(reading, state).map((line, index) => {
1190
1200
  const ids = reading.runs[index]?.ids ?? [];
@@ -1194,6 +1204,8 @@ server.registerTool(
1194
1204
  const written = state.notes.filter((note) => isMeasured(note)).length;
1195
1205
  const lines = [
1196
1206
  `PlotCoder wall (${door(live, base)})`,
1207
+ ...(state.lock ? [`numbers: locked since ${String(state.lock.at).slice(0, 10)}; read_pages shows each scene's number`] : []),
1208
+ `board: "${readBoardMeta?.name ?? "?"}"${projectForRead.boards.length > 1 ? ` — ${projectForRead.boards.length} boards in "${projectForRead.name}"; open_board reads another` : ""}`,
1197
1209
  `logline: ${state.logline ? `"${state.logline}"` : "(none yet)"}`,
1198
1210
  "the cast and the places are list_board's, not the reading's",
1199
1211
  state.targetEighths === DEFAULT_TARGET_EIGHTHS
@@ -1222,17 +1234,23 @@ server.registerTool(
1222
1234
  ...(reading.setups.length
1223
1235
  ? describeSetups(reading, state).map((line) => ` - ${line}`)
1224
1236
  : [" (no arrow is marked as a setup)"]),
1237
+ ...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}"`),
1225
1238
  "questions the wall raises:",
1226
1239
  ...(reading.findings.length
1227
1240
  ? reading.findings.map((finding) => ` - [${finding.kind}] ${finding.text}${finding.ids.length ? ` (ids: ${finding.ids.join(", ")})` : ""}`)
1228
1241
  : [" (none that this reading can see)"]),
1229
1242
  `checks: ${CHECKS.length} run — ${(() => {
1230
- const asked = reading.findings.filter((finding) => CHECKS.includes(finding.kind));
1243
+ const asked = reading.findings;
1231
1244
  if (asked.length === 0) return "asking nothing";
1232
1245
  const counts = new Map();
1233
1246
  for (const finding of asked) counts.set(finding.kind, (counts.get(finding.kind) ?? 0) + 1);
1234
1247
  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(", ")}`;
1235
- })()}; checked and clean: ${CHECKS.filter((kind) => !reading.findings.some((finding) => finding.kind === kind)).map((kind) => CHECK_WORDS[kind]).join("; ") || "(nothing — every check found something)"}`,
1248
+ })()}; checked and clean: ${CHECKS.filter((kind) => !reading.findings.some((finding) => finding.kind === kind)).map((kind) => {
1249
+ if (kind === "unlinked" && state.arrows.length === 0) return "no card without an arrow (not asked: no arrows yet)";
1250
+ if (kind === "unplaced" && !state.notes.some((note) => (note.location ?? "").trim())) return "no card without a place (not asked: no card placed yet)";
1251
+ if (kind === "sequence" && state.groups.length === 0) return "no group too long for one sequence (not asked: no groups)";
1252
+ return CHECK_WORDS[kind];
1253
+ }).join("; ") || "(nothing — every check found something)"}`,
1236
1254
  ];
1237
1255
  if (isSampleWall(state)) lines.unshift(SAMPLE_NOTE);
1238
1256
  return ok(lines.join("\n"), { ...reading, sample: isSampleWall(state) });
@@ -1301,8 +1319,10 @@ server.registerTool(
1301
1319
  trail.push({ before: state, after: canon(final), what: `move_scene "${card.headline}"` });
1302
1320
  undone.length = 0;
1303
1321
  const order = readingOrder(final.notes);
1322
+ const group = final.groups.find((item) => item.noteIds.includes(card.id));
1323
+ const groupLine = 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.` : "";
1304
1324
  return ok(
1305
- `Moved "${card.headline}" to ${args.after ? "after" : "before"} "${target.headline}": ${removed} arrow(s) removed, ${drawn} drawn, the wall tidied along them${where(live)}. Story order now: ${order.map((note, index) => `${index + 1}. ${note.headline}`).join(", ")}. One undo takes the whole move back.`,
1325
+ `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.`,
1306
1326
  { order: order.map((note) => note.id) },
1307
1327
  );
1308
1328
  },
@@ -1390,10 +1410,9 @@ server.registerTool(
1390
1410
  const { project } = await readProject();
1391
1411
  const own = project.structures ?? [];
1392
1412
  const lines = [
1393
- `built in: ${TEMPLATES.length}`,
1394
- ...TEMPLATES.map((template) => ` - ${template.id} — "${template.name}" (${template.beats.length} beats)`),
1395
1413
  `the writer's own: ${own.length}`,
1396
- ...own.map((structure) => ` - ${structure.id} — "${structure.name}" (${structure.beats.length} beats: ${structure.beats.map((beat) => beat.name).join(", ")})`),
1414
+ ...own.map((structure) => ` - ${structure.id} — "${structure.name}" (${structure.beats.length} beats: ${structure.beats.map((beat) => `${beat.name} at ${Math.round(beat.at * 100)}%`).join(", ")})`),
1415
+ `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`,
1397
1416
  ];
1398
1417
  return ok(lines.join("\n"), { builtIn: TEMPLATES.map((template) => ({ id: template.id, name: template.name, beats: template.beats })), own });
1399
1418
  },
@@ -1415,7 +1434,7 @@ server.registerTool(
1415
1434
  const { project, boards, rev, base, live } = await readProject();
1416
1435
  const { project: next, structure } = addStructure(project, args.name, beats);
1417
1436
  await writeProject(next, boards, rev, base);
1418
- return ok(`Saved "${structure.name}" with ${beats.length} beats${where(live)}: ${beats.map((beat) => beat.name).join(", ")}.`, structure);
1437
+ return ok(`Saved "${structure.name}" with ${beats.length} beats${where(live)}: ${beats.map((beat) => `${beat.name} at ${Math.round(beat.at * 100)}%`).join(", ")}. Each beat's prompt is its change line here; apply_template lays the same turns on another board, the next episode's, as beat cards to fill.`, structure);
1419
1438
  },
1420
1439
  );
1421
1440
 
@@ -1467,7 +1486,7 @@ server.registerTool(
1467
1486
  {
1468
1487
  title: "Write a scene",
1469
1488
  description:
1470
- "Write a card's scene text in Fountain — action, character cues in capitals, dialogue under them — onto the card by id. The card is then measured (its lines against a page) instead of estimated. An empty string clears it. Read read_pages first so the scene fits what is around it, and do not write scenes the writer has not asked for.",
1489
+ "Write a card's scene text in Fountain — action, character cues in capitals, dialogue under them — onto the card by id; the scene heading comes from the card's place, so start with the action. The card is then measured (its lines as they print against a 55-line page) instead of estimated. An empty string clears it. Read read_pages first so the scene fits what is around it, and do not write scenes the writer has not asked for.",
1471
1490
  inputSchema: { id: z.string(), text: z.string() },
1472
1491
  },
1473
1492
  async (args) => {
@@ -1476,10 +1495,10 @@ server.registerTool(
1476
1495
  if (!result) return ok(`No card with id ${args.id}. Call list_board.`);
1477
1496
  return ok(`Nothing changed: "${result.headline}" already reads that way.`);
1478
1497
  }
1479
- const lines = args.text.split("\n").filter((line) => line.trim()).length;
1498
+ const printed = sceneLineCount(args.text);
1480
1499
  return ok(
1481
- `Wrote "${result.headline}": ${lines} line(s), measured at ${formatPages(noteEighths(result))} of a page (a page is 55 lines of Courier 12; a fraction is rounded up to an eighth)${where(live)}. While the text stands the card is measured, not estimated; its estimate is untouched underneath.`,
1482
- result,
1500
+ `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)}. The heading comes from the card's place, so the text starts with the action. While the text stands the card is measured, not estimated; every reading uses the measure (eighths here), and the estimate underneath (lengthEighths) is untouched.`,
1501
+ { ...result, eighths: noteEighths(result), measured: true, printedLines: printed },
1483
1502
  );
1484
1503
  },
1485
1504
  );
@@ -1499,13 +1518,16 @@ server.registerTool(
1499
1518
  const text = toFountain(state, { title: board?.name, premise: project.premise || undefined });
1500
1519
  const parsed = fromFountain(text);
1501
1520
  const ids = mergeFountain(state, parsed).matched.map((item) => item.id);
1521
+ const pageNumbers = state.lock ? sceneNumbers(readingOrder(state.notes), state.lock) : null;
1502
1522
  const lines = [];
1503
1523
  let index = 0;
1504
1524
  for (const line of text.split("\n")) {
1505
1525
  if (/^\.(?!\.)/.test(line) && index < ids.length) {
1506
1526
  const note = state.notes.find((item) => item.id === ids[index]);
1507
1527
  index += 1;
1508
- lines.push(`${line} [[id: ${note?.id ?? "?"} · ${note && isMeasured(note) ? "measured" : "estimated"} ${formatPages(note ? noteEighths(note) : 0)}pp]]`);
1528
+ const standIn = note && !(note.location ?? "").trim() ? " · no place: the headline stands in for the heading" : "";
1529
+ const numbered = note && pageNumbers?.get(note.id) ? ` · locked no. ${pageNumbers.get(note.id)}` : "";
1530
+ lines.push(`${line} [[id: ${note?.id ?? "?"} · ${note && isMeasured(note) ? "measured" : "estimated"} ${formatPages(note ? noteEighths(note) : 0)}pp${standIn}${numbered}]]`);
1509
1531
  } else {
1510
1532
  lines.push(line);
1511
1533
  }
@@ -1583,7 +1605,7 @@ server.registerTool(
1583
1605
  const { state } = await readBoard();
1584
1606
  const { project } = await readProject();
1585
1607
  const board = project.boards.find((item) => item.id === project.activeBoardId);
1586
- const brief = segmentBrief(state, args.ids, { title: board?.name });
1608
+ const brief = segmentBrief(state, args.ids, { title: board?.name, boards: project.boards });
1587
1609
  if (!brief) return ok(`No cards with ids ${args.ids.join(", ")}. Call list_board.`);
1588
1610
  return ok(brief);
1589
1611
  },
@@ -1662,7 +1684,7 @@ server.registerTool(
1662
1684
  const note = unwritten
1663
1685
  ? [`${unwritten} of ${order.length} scenes are unwritten and count as one line each here, 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.`]
1664
1686
  : [];
1665
- return ok([`pages: ${result.pageCount} of ${Math.round(state.targetEighths / 8)}`, ...note, ...lines].join("\n"), result.scenes);
1687
+ return ok([`pages: ${result.pageCount} of ${Math.round(state.targetEighths / 8)}`, `scene numbers here are ${state.lock ? "the locked numbers" : "wall order (not locked)"}`, ...note, ...lines].join("\n"), result.scenes);
1666
1688
  },
1667
1689
  );
1668
1690
 
@@ -1687,8 +1709,10 @@ server.registerTool(
1687
1709
  "unlock_numbers",
1688
1710
  { title: "Unlock the scene numbers", description: "Numbers follow the wall's order again.", inputSchema: {} },
1689
1711
  async () => {
1712
+ const { state: before } = await readBoard();
1713
+
1690
1714
  const { changed, live } = await commit({ type: "unlock_numbers" });
1691
- return ok(changed ? `Unlocked${where(live)}.` : "The numbers were not locked.");
1715
+ return ok(changed ? `Unlocked ${Object.keys(before.lock?.numbers ?? {}).length} scene number(s)${where(live)}; scenes number by wall order again.` : "The numbers were not locked.");
1692
1716
  },
1693
1717
  );
1694
1718
 
@@ -1698,11 +1722,13 @@ server.registerTool(
1698
1722
  title: "Start a revision",
1699
1723
  description:
1700
1724
  `Name a revision and give it one of the industry's colours (${REVISION_COLORS.join(", ")}). Every card is snapshotted; from then on a changed line prints in the colour with a star in the margin, and a changed card wears the colour on the wall.`,
1701
- inputSchema: { name: z.string().min(1), color: z.string().optional() },
1725
+ inputSchema: { name: z.string().optional(), color: z.string().optional() },
1702
1726
  },
1703
1727
  async (args) => {
1704
- const { changed, result, live } = await commit({ type: "start_revision", name: args.name, color: args.color });
1705
- if (!changed) return ok("No revision started: give it a name.");
1728
+ const revisionName = (args.name ?? "").trim() || (args.color ? `${args.color.charAt(0).toUpperCase()}${args.color.slice(1)}` : "");
1729
+ if (!revisionName) return ok("No revision started: give it a name, or a colour to name it after.");
1730
+ const { changed, result, live } = await commit({ type: "start_revision", name: revisionName, color: args.color });
1731
+ if (!changed) return ok("No revision started: one is already in progress; end_revision first.");
1706
1732
  return ok(`Started the ${result.color} revision "${result.name}"${where(live)}.`, { name: result.name, color: result.color, since: result.since });
1707
1733
  },
1708
1734
  );
@@ -1711,8 +1737,10 @@ server.registerTool(
1711
1737
  "end_revision",
1712
1738
  { title: "End the revision", description: "The marks come off; the snapshot is dropped.", inputSchema: {} },
1713
1739
  async () => {
1740
+ const { state: before } = await readBoard();
1741
+
1714
1742
  const { changed, live } = await commit({ type: "end_revision" });
1715
- return ok(changed ? `Revision ended${where(live)}.` : "No revision in progress.");
1743
+ return ok(changed ? `Revision "${before.revision?.name ?? ""}" (${before.revision?.color ?? ""}) ended${where(live)}: its marks come off and its snapshot is dropped.` : "No revision in progress.");
1716
1744
  },
1717
1745
  );
1718
1746
 
@@ -1730,7 +1758,7 @@ server.registerTool(
1730
1758
  const { state } = await readBoard();
1731
1759
  const { project } = await readProject();
1732
1760
  const board = project.boards.find((item) => item.id === project.activeBoardId);
1733
- const brief = segmentBrief(state, args.ids, { title: board?.name });
1761
+ const brief = segmentBrief(state, args.ids, { title: board?.name, boards: project.boards });
1734
1762
  if (!brief) return ok(`No cards with ids ${args.ids.join(", ")}. Call list_board.`);
1735
1763
  const provider = env.PLOTCODER_VIDEO_PROVIDER;
1736
1764
  if (!provider) {
@@ -1889,10 +1917,11 @@ server.registerTool(
1889
1917
  undone.push(last);
1890
1918
  const { boardId } = await readBoard();
1891
1919
  const live = await writeBoard(last.before, rev, base, boardId);
1892
- return ok(
1893
- `Undid ${last.what}${where(live)}. ${trail.length} more of mine can be undone.`,
1894
- last.before,
1895
- );
1920
+ const orderLine = /^(move_scene|organize)/.test(last.what) ? ` Story order now: ${readingOrder(last.before.notes).map((note, index) => `${index + 1}. ${note.headline}`).join(", ")}.` : "";
1921
+ const cardsDiff = last.before.notes.length - state.notes.length;
1922
+ const countLine = cardsDiff > 0 ? ` ${cardsDiff} card(s) back.` : cardsDiff < 0 ? ` ${-cardsDiff} card(s) gone.` : "";
1923
+ 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`;
1924
+ 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 });
1896
1925
  },
1897
1926
  );
1898
1927
 
@@ -1915,7 +1944,7 @@ server.registerTool(
1915
1944
  const after = normalizeState(JSON.parse(last.after));
1916
1945
  const live = await writeBoard(after, rev, base, boardId);
1917
1946
  trail.push(last);
1918
- return ok(`Redid ${last.what}${where(live)}. ${undone.length} more can be redone.`, after);
1947
+ return ok(`Redid ${last.what}${where(live)}. ${undone.length} more can be redone. list_board has the board.`, { redid: last.what, notes: after.notes.length, arrows: after.arrows.length, groups: after.groups.length });
1919
1948
  },
1920
1949
  );
1921
1950
 
@@ -1924,23 +1953,52 @@ server.registerTool(
1924
1953
  {
1925
1954
  title: "Fold the corner",
1926
1955
  description:
1927
- `Fold the corner of cards — mark them as planting something — or unfold them. ${wordSentence("corner")} The setup arrow is create_arrow with kind 'setup'. Folding never moves a card.`,
1956
+ `Fold the corner of cards — mark them as planting something — or unfold them. ${wordSentence("corner")} The setup arrow is create_arrow with kind 'setup'. A fold that pays off in a later episode: pass later, another board of the project by name, id or number — a board that exists; new_board makes one — and the wall stops asking where it comes back, listing the card under the reading's 'later' instead of 'payoffs'; later '' forgets it. Folding never moves a card.`,
1928
1957
  inputSchema: {
1929
1958
  ids: z.array(z.string()).min(1),
1930
1959
  plants: z.boolean(),
1960
+ later: z.string().optional(),
1931
1961
  },
1932
1962
  },
1933
1963
  async (args) => {
1934
- const { result, live } = await commit({
1964
+ let { result, live, changed } = await commit({
1935
1965
  type: "set_plant",
1936
1966
  ids: args.ids,
1937
1967
  plants: args.plants,
1938
1968
  });
1969
+ let laterLine = "";
1970
+ if (args.plants && args.later !== undefined) {
1971
+ // A series plant (R50): the fold pays off on another board of the project.
1972
+ // The kernel cannot check the board exists; this door can.
1973
+ const { project } = await readProject();
1974
+ const { state: now, boardId: current } = await readBoard();
1975
+ const here = now.notes.filter((note) => args.ids.includes(note.id));
1976
+ if (args.later.trim() === "") {
1977
+ const cleared = await commit({ type: "set_payoff_board", ids: args.ids, boardId: null });
1978
+ if (cleared.changed) {
1979
+ result = cleared.result;
1980
+ live = cleared.live;
1981
+ changed = true;
1982
+ laterLine = " The board it paid off on is forgotten; read_wall asks again until a setup arrow or a board pays it off.";
1983
+ }
1984
+ } else {
1985
+ const target = findBoard(project, args.later);
1986
+ if (!target) return ok(`No board called "${args.later}" yet. A fold pays off later on a board of the project: new_board "${args.later}" makes it (empty), open_board back to this one, then set_plant again with later.`);
1987
+ if (target.id === (current ?? project.activeBoardId)) return ok(`"${target.name}" is this board. A payoff on the same board is a setup arrow: create_arrow from the fold to the scene, kind 'setup'.`);
1988
+ const named = await commit({ type: "set_payoff_board", ids: args.ids, boardId: target.id });
1989
+ if (named.changed) {
1990
+ result = named.result;
1991
+ live = named.live;
1992
+ changed = true;
1993
+ }
1994
+ laterLine = ` ${here.length} card(s) pay off later, on "${target.name}": read_wall stops asking where they come back, and the card says so.`;
1995
+ }
1996
+ }
1939
1997
  const count = result?.length ?? 0;
1940
- if (count === 0) return ok("No change: those cards were already that way, or the ids are not on the board.");
1998
+ if (!changed || count === 0) return ok("No change: those cards were already that way, or the ids are not on the board.");
1941
1999
  return ok(
1942
2000
  args.plants
1943
- ? `${count} card(s) now plant something${where(live)}. read_wall will ask about each until a setup arrow pays it off.`
2001
+ ? `${count} card(s) now plant something${where(live)}.${laterLine || " read_wall will ask about each until a setup arrow pays it off, or later names the board it pays off on."}`
1944
2002
  : `${count} card(s) no longer marked as planting${where(live)}.`,
1945
2003
  result,
1946
2004
  );
@@ -1977,6 +2035,7 @@ server.registerTool(
1977
2035
  inputSchema: { id: z.string(), name: z.string().min(1) },
1978
2036
  },
1979
2037
  async (args) => {
2038
+ const { state: before } = await readBoard();
1980
2039
  const { state, changed, result, live } = await commit({
1981
2040
  type: "rename_character",
1982
2041
  id: args.id,
@@ -1989,7 +2048,10 @@ server.registerTool(
1989
2048
  : ok(`No character with id ${args.id}. Call list_board for the cast.`);
1990
2049
  }
1991
2050
  const followed = state.notes.filter((note) => note.characterIds.includes(result.id)).length;
1992
- return ok(`Renamed to "${result.name}" (${result.id})${where(live)}; the name changed on ${followed} card${followed === 1 ? "" : "s"}.`, result);
2051
+ const oldName = (before.characters.find((item) => item.id === result.id)?.name ?? "").trim();
2052
+ const stale = oldName ? CHARACTER_FIELDS.filter((field) => (result[field] ?? "").toLowerCase().includes(oldName.toLowerCase())) : [];
2053
+ const staleLine = stale.length ? ` The page's ${stale.join(", ")} still mention${stale.length === 1 ? "s" : ""} "${oldName}"; the page is untouched.` : "";
2054
+ return ok(`Renamed to "${result.name}" (${result.id})${where(live)}; the name changed on ${followed} card${followed === 1 ? "" : "s"}.${staleLine}`, result);
1993
2055
  },
1994
2056
  );
1995
2057
 
@@ -2238,6 +2300,7 @@ server.registerTool(
2238
2300
  inputSchema: { from: z.string(), to: z.string(), kind: arrowKindSchema.optional() },
2239
2301
  },
2240
2302
  async (args) => {
2303
+ const { boardId } = await readBoard();
2241
2304
  const { state, changed, result, live } = await commit({
2242
2305
  type: "create_arrow",
2243
2306
  from: args.from,
@@ -2248,13 +2311,19 @@ server.registerTool(
2248
2311
  // Say which of the three reasons it was. "Something went wrong" makes an
2249
2312
  // agent retry the same call; naming the cause makes it fix the input.
2250
2313
  const onBoard = (id) => state.notes.some((note) => note.id === id);
2314
+ const { project: wholeProject, boards: allBoards } = await readProject();
2315
+ const elsewhere = (id) => wholeProject.boards.find((meta) => meta.id !== (boardId ?? wholeProject.activeBoardId) && isBoardState(allBoards[meta.id]) && allBoards[meta.id].notes.some((note) => note.id === id));
2316
+ const missing = (id) => {
2317
+ const other = elsewhere(id);
2318
+ return other ? `card ${id} is on another board, "${other.name}" — an arrow stays on one board; a fold that pays off there is set_plant with later: "${other.name}"` : `there is no card with id ${id}`;
2319
+ };
2251
2320
  const why =
2252
2321
  args.from === args.to
2253
2322
  ? "a card cannot point at itself"
2254
2323
  : !onBoard(args.from)
2255
- ? `there is no card with id ${args.from}`
2324
+ ? missing(args.from)
2256
2325
  : !onBoard(args.to)
2257
- ? `there is no card with id ${args.to}`
2326
+ ? missing(args.to)
2258
2327
  : "that arrow already exists";
2259
2328
  return ok(`No arrow drawn: ${why}. Call list_board to check.`);
2260
2329
  }
@@ -2297,7 +2366,7 @@ server.registerTool(
2297
2366
 
2298
2367
  // --- The project ------------------------------------------------------
2299
2368
 
2300
- function describeBoards(project, boards) {
2369
+ function describeBoards(project, boards, changedAt = null) {
2301
2370
  return project.boards
2302
2371
  .map((board, index) => {
2303
2372
  const state = boards[board.id];
@@ -2306,7 +2375,8 @@ function describeBoards(project, boards) {
2306
2375
  state && isBoardState(state)
2307
2376
  ? `${state.notes.length} cards, about ${formatPages(boardEighths(normalizeState(state)))} of ${formatPages(normalizeState(state).targetEighths)} pages`
2308
2377
  : "no cards";
2309
- return ` ${index + 1}. ${board.id} "${board.name}"${open}: ${shape}`;
2378
+ const changed = changedAt?.[board.id] ? `, last changed ${changedAt[board.id]}` : "";
2379
+ return ` ${index + 1}. ${board.id} — "${board.name}"${open}: ${shape}${changed}`;
2310
2380
  })
2311
2381
  .join("\n");
2312
2382
  }
@@ -2320,13 +2390,13 @@ server.registerTool(
2320
2390
  inputSchema: {},
2321
2391
  },
2322
2392
  async () => {
2323
- const { project, boards, live, base } = await readProject();
2393
+ const { project, boards, live, base, changedAt } = await readProject();
2324
2394
  return ok(
2325
2395
  [
2326
2396
  `Project "${project.name}" (${door(live, base)})`,
2327
2397
  `premise: ${project.premise ? `"${project.premise}"` : "(not set)"}`,
2328
2398
  `boards: ${project.boards.length}`,
2329
- describeBoards(project, boards),
2399
+ describeBoards(project, boards, changedAt),
2330
2400
  ].join("\n"),
2331
2401
  project,
2332
2402
  );
@@ -2401,8 +2471,8 @@ server.registerTool(
2401
2471
  const own = list.filter((item) => !item.builtIn).length;
2402
2472
  return ok(
2403
2473
  [
2404
- `reminders on "${project.name}" (${door(live, base)}): ${list.length} — ${list.length - own} the house principles the app starts with (built in), ${own} the writer's own${own === 0 ? "; add_reminder adds one the writer asks to keep" : ""}`,
2405
- ...list.map((item) => ` - ${item.id}${item.builtIn ? " (built in)" : ""} — ${item.title}: ${item.body}`),
2474
+ `reminders on "${project.name}" (${door(live, base)}): ${list.length} — ${list.length - own} the house principles the app starts with (built in), ${own} the writer's own${own === 0 ? "; add_reminder adds one the writer asks to keep" : ""}. Reminders live on the project and go with it`,
2475
+ ...list.map((item) => ` - ${item.id}${item.builtIn ? " (built in)" : ""} — ${item.body.replace(/\.$/, "").startsWith(item.title.replace(/\.$/, "")) ? item.body : `${item.title}: ${item.body}`}`),
2406
2476
  ].join("\n"),
2407
2477
  list,
2408
2478
  );
@@ -2541,12 +2611,13 @@ server.registerTool(
2541
2611
  title: "Start a project",
2542
2612
  description:
2543
2613
  "Through the account door: start a new project of the writer's with this name — one empty board, nothing on it — and work it from now on. The writer sees it under Projects on every device.",
2544
- inputSchema: { name: z.string().min(1), pages: pagesSchema.optional(), minutes: z.number().positive().optional() },
2614
+ inputSchema: { name: z.string().min(1), board: z.string().optional(), pages: pagesSchema.optional(), minutes: z.number().positive().optional() },
2545
2615
  },
2546
2616
  async (args) => {
2547
2617
  const account = await findAccount();
2548
2618
  if (!account) return shut("No account door: there is one project here, the open one. Set PLOTCODER_EMAIL and PLOTCODER_PASSWORD to start another on the writer's account.");
2549
- const record = renameProject(emptyProject(), args.name.trim());
2619
+ let record = renameProject(emptyProject(), args.name.trim());
2620
+ if (args.board?.trim()) record = renameBoard(record, record.activeBoardId, args.board.trim());
2550
2621
  const inserted = await account.client.from("projects").insert({ id: record.id, record, reminders: null, rev: 1 });
2551
2622
  if (inserted.error) return ok(`Could not start the project: ${inserted.error.message}`);
2552
2623
  const target = args.pages ?? args.minutes;
@@ -2602,7 +2673,7 @@ server.registerTool(
2602
2673
  for (const row of owned) plans.push(await deletionPlan(row));
2603
2674
  const survive = shared.length ? ` ${shared.length} project(s) shared with the writer by others stay: ${shared.map((row) => `"${row.record.name}"`).join(", ")}.` : "";
2604
2675
  if (plans.length === 0) return ok(`The account holds nothing of the writer's own to delete.${survive}`);
2605
- if (!args.confirm) return ok(`Emptying the account deletes ${plans.length} project(s) of the writer's own: ${plans.map(describePlan).join("; ")}. Cannot be undone. Ask the writer; export_project each first if they might want them back; then pass confirm: true.${survive}`, plans);
2676
+ if (!args.confirm) return ok(`Emptying the account deletes ${plans.length} project(s) of the writer's own: ${plans.map(describePlan).join("; ")}. Cannot be undone. Ask the writer, or if they have already said so, pass confirm: true now; export_project each first if they might want them back.${survive}`, plans);
2606
2677
  for (const plan of plans) await deleteProjectRows(plan);
2607
2678
  const next = await workWhatIsLeft(plans.map((plan) => plan.id));
2608
2679
  return ok(`Emptied the account as ${account.email}: deleted ${plans.map(describePlan).join("; ")}.${survive}${next}`, plans);
@@ -2665,7 +2736,9 @@ server.registerTool(
2665
2736
  const { project, boards, reminders } = await readProject();
2666
2737
  const file = toProjectFile({ project, boards, reminders: reminders ?? null });
2667
2738
  const cards = countCards(boards);
2668
- const what = `"${project.name}": ${project.boards.length} board(s), ${cards} card(s)${reminders?.length ? `, ${reminders.length} reminder(s)` : ""}${project.structures?.length ? `, ${project.structures.length} structure(s)` : ""}. Pictures and takes on the account are not in the file`;
2739
+ const ownReminders = (reminders ?? []).filter((item) => !item.builtIn).length;
2740
+ const builtInReminders = (reminders ?? []).length - ownReminders;
2741
+ const what = `"${project.name}": ${project.boards.length} board(s) — ${project.boards.map((meta) => `"${meta.name}" (${isBoardState(boards[meta.id]) ? boards[meta.id].notes.length : 0} cards)`).join(", ")} — ${cards} card(s) in all${reminders?.length ? `, ${reminders.length} reminder(s) (${builtInReminders} built in, ${ownReminders} the writer's own)` : ", the six built-in reminders come with every project and no reminders of the writer's own (none to write)"}${project.structures?.length ? `, ${project.structures.length} structure(s)` : ", no structures of the writer's own (none to write)"}. Pictures and takes on the account are not in the file`;
2669
2742
  if (args.path) {
2670
2743
  fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
2671
2744
  fs.writeFileSync(args.path, JSON.stringify(file, null, 2));
@@ -2765,7 +2838,7 @@ server.registerTool(
2765
2838
  const fresh = { ...emptyState(), ...(target ? { targetEighths: target } : {}) };
2766
2839
  const { live } = await openBoardEverywhere(next, { ...boards, [board.id]: fresh }, rev, base, board.id);
2767
2840
  return ok(
2768
- `Added "${board.name}" (${board.id}) and opened it${where(live)}. It is empty. 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." : ""}`,
2841
+ `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. 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." : ""}`,
2769
2842
  board,
2770
2843
  );
2771
2844
  },
@@ -42,12 +42,12 @@ 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: "These four are about the wall you will work, so after open_project, open_board, new_project or empty_account, read_wall again. 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, then the four 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. They are about the wall you will work, so after open_project, open_board, new_project or empty_account, read_wall again. 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, then the four 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.",
46
46
  first: [
47
47
  { tool: "list_words", why: "the room's words, the app's meaning." },
48
- { tool: "read_wall", why: "what is here, and what it asks. A fresh folder holds a sample wall (Maya, Tom, the letter) and says so; it is not the writer's." },
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 — are list_board's. A fresh folder holds a sample wall (Maya, Tom, the letter) and says so; it is not the writer's." },
49
49
  { tool: "list_workflows", why: "what a writer can ask you for." },
50
- { tool: "list_reminders", why: "the house principles the app starts with, and the writer's own; read them before you change anything." },
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
51
  ],
52
52
  rules: [
53
53
  "Questions, not fixes, until the writer says.",
@@ -64,7 +64,7 @@ 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, read once before anything: ${AGENTS.guide}. The guide is the whole and this page is its first page; where the two differ, the guide wins.`, "", "## Doors"];
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
68
  for (const door of AGENTS.doors) {
69
69
  lines.push(`- ${door.name}: ${door.text}`);
70
70
  if (door.code) lines.push("", "```", door.code, "```", "");
@@ -61,6 +61,8 @@ export type WallReading = {
61
61
  /** Every planted card: the scene that pays it off (first setup arrow, by wall order), or null while unpaid. */
62
62
  /** For each folded card, the cards its setup arrows land on, in wall order; empty when unpaid. */
63
63
  payoffs: Record<string, string[]>;
64
+ /** Folded cards that pay off on another board of the project (R50): the card and the board. */
65
+ later: { id: string; boardId: string }[];
64
66
  findings: Finding[];
65
67
  };
66
68
 
@@ -304,11 +304,17 @@ export function readWall(state) {
304
304
  .map((arrow) => arrow.to)
305
305
  .sort((a, b) => (wallIndex.get(a) ?? Infinity) - (wallIndex.get(b) ?? Infinity));
306
306
  // Every payoff, in wall order: a card can plant two things (the ledger
307
- // pays off at the cash and again at the initials), and both count.
307
+ // pays off at the cash and again at the initials), and both count. A
308
+ // fold that pays off only on another board is not here at all — it is
309
+ // under `later` — so the two never disagree about one card.
310
+ if (!heads.length && note.payoffBoardId) continue;
308
311
  payoffs[note.id] = heads;
309
312
  }
313
+ // A fold that pays off on another board (R50) is not unpaid: it is listed
314
+ // under `later`, and the door that knows the project names the board.
315
+ const later = order.filter((note) => note.plants && note.payoffBoardId).map((note) => ({ id: note.id, boardId: note.payoffBoardId }));
310
316
  for (const note of order) {
311
- if (note.plants && !paysOff.has(note.id)) {
317
+ if (note.plants && !paysOff.has(note.id) && !note.payoffBoardId) {
312
318
  findings.push({
313
319
  kind: "unpaid",
314
320
  ids: [note.id],
@@ -372,6 +378,7 @@ export function readWall(state) {
372
378
  runs,
373
379
  setups,
374
380
  payoffs,
381
+ later,
375
382
  findings,
376
383
  };
377
384
  }
@@ -79,6 +79,8 @@ export type BoardNote = {
79
79
  characterIds: string[];
80
80
  /** The corner is folded: this card plants something that must pay off (R31). */
81
81
  plants: boolean;
82
+ /** When folded: the id of another board of the project where it pays off (R50), or null. */
83
+ payoffBoardId: string | null;
82
84
  /** Where the scene happens (R37): a phrase in the writer's words; empty until set. */
83
85
  location: string;
84
86
  /** The scene's text in Fountain (R23 b): action, cues, dialogue; empty until written. */
@@ -161,6 +163,7 @@ export type Command =
161
163
  | ({ type: "update_character"; id: string } & Partial<Record<CharacterField, string>>)
162
164
  | { type: "set_cast"; ids: string[]; characterIds: string[] }
163
165
  | { type: "set_plant"; ids: string[]; plants: boolean }
166
+ | { type: "set_payoff_board"; ids: string[]; boardId: string | null }
164
167
  | { type: "set_location"; ids: string[]; location: string }
165
168
  | { type: "apply_template"; template: string; beats?: Array<{ name: string; prompt: string; at: number }> }
166
169
  | { type: "set_text"; id: string; text: string }
@@ -233,6 +233,9 @@ export function seedState(now = nowIso()) {
233
233
  location: "",
234
234
  text: "",
235
235
  plants: false,
236
+ // A fold that pays off on another board — a later episode — names it here;
237
+ // null claims nothing (R50).
238
+ payoffBoardId: null,
236
239
  createdAt: now,
237
240
  updatedAt: now,
238
241
  });
@@ -325,6 +328,8 @@ export function normalizeState(value) {
325
328
  const characterIds = knownCast(note?.characterIds, characters);
326
329
  // Cards written before R31 have no fold; a plant is a claim you make.
327
330
  const plants = note?.plants === true;
331
+ // Cards written before R50 pay off on their own board or not at all.
332
+ const payoffBoardId = plants && typeof note?.payoffBoardId === "string" && note.payoffBoardId ? note.payoffBoardId : null;
328
333
  // Cards written before R37 have no place; a scene is nowhere until it is.
329
334
  const location = typeof note?.location === "string" ? note.location : "";
330
335
  // Cards written before pages (R23 b) have no text; a scene is unwritten until it is.
@@ -336,13 +341,14 @@ export function normalizeState(value) {
336
341
  Array.isArray(note.characterIds) &&
337
342
  sameIds(note.characterIds, characterIds) &&
338
343
  note.plants === plants &&
344
+ note.payoffBoardId === payoffBoardId &&
339
345
  note.location === location &&
340
346
  note.text === text
341
347
  ) {
342
348
  return note;
343
349
  }
344
350
  patched = true;
345
- return { ...note, rank, lengthEighths, characterIds, plants, location, text };
351
+ return { ...note, rank, lengthEighths, characterIds, plants, payoffBoardId, location, text };
346
352
  });
347
353
 
348
354
  // Boards written before the production half (Roadmap 2, item 8) have no
@@ -416,6 +422,7 @@ export function applyCommand(state, command, now = nowIso()) {
416
422
  : clampEighths(command.lengthEighths, DEFAULT_NOTE_EIGHTHS, MAX_NOTE_EIGHTHS),
417
423
  characterIds: knownCast(command.characterIds, state.characters ?? []),
418
424
  plants: command.plants === true,
425
+ payoffBoardId: null,
419
426
  location: cleanPlace(command.location),
420
427
  text: typeof command.text === "string" ? command.text : "",
421
428
  z: maxZ(state.notes) + 1,
@@ -802,7 +809,8 @@ export function applyCommand(state, command, now = nowIso()) {
802
809
  id: newId(),
803
810
  headline: item.name,
804
811
  change: item.prompt,
805
- color: NOTE_COLORS[(state.notes.length + index) % NOTE_COLORS.length],
812
+ // One colour: paper means nothing to the app, and a structure is not a pattern (round eleven).
813
+ color: "yellow",
806
814
  x: left + (index % 5) * (NOTE_WIDTH + 28),
807
815
  y: top + Math.floor(index / 5) * (NOTE_HEIGHT + 40),
808
816
  rotate: ((index % 5) - 2) * 0.8,
@@ -811,6 +819,7 @@ export function applyCommand(state, command, now = nowIso()) {
811
819
  lengthEighths: null,
812
820
  characterIds: [],
813
821
  plants: false,
822
+ payoffBoardId: null,
814
823
  location: "",
815
824
  text: "",
816
825
  createdAt: now,
@@ -895,8 +904,30 @@ export function applyCommand(state, command, now = nowIso()) {
895
904
  const plants = command.plants === true;
896
905
  const touched = [];
897
906
  const notes = state.notes.map((note) => {
898
- if (!ids.has(note.id) || note.plants === plants) return note;
899
- const next = bump(note, { plants }, now);
907
+ if (!ids.has(note.id)) return note;
908
+ // Unfolding forgets where it paid off; a claim that no longer stands.
909
+ const payoffBoardId = plants ? note.payoffBoardId : null;
910
+ if (note.plants === plants && note.payoffBoardId === payoffBoardId) return note;
911
+ const next = bump(note, { plants, payoffBoardId }, now);
912
+ touched.push(next);
913
+ return next;
914
+ });
915
+ if (touched.length === 0) return { state, changed: false };
916
+ return { state: { ...state, notes }, changed: true, result: touched };
917
+ }
918
+
919
+ // A fold that pays off on another board of the project (R50): the wall
920
+ // stops asking where it comes back, and the reading says where. The
921
+ // kernel cannot check the board exists; the door that knows the project
922
+ // does. Null takes the claim back.
923
+ case "set_payoff_board": {
924
+ const ids = new Set(command.ids);
925
+ if (ids.size === 0) return { state, changed: false };
926
+ const payoffBoardId = typeof command.boardId === "string" && command.boardId ? command.boardId : null;
927
+ const touched = [];
928
+ const notes = state.notes.map((note) => {
929
+ if (!ids.has(note.id) || !note.plants || note.payoffBoardId === payoffBoardId) return note;
930
+ const next = bump(note, { payoffBoardId }, now);
900
931
  touched.push(next);
901
932
  return next;
902
933
  });
@@ -22,5 +22,5 @@ export declare function workflowById(id: string): Workflow | null;
22
22
  export declare function segmentBrief(
23
23
  state: BoardState,
24
24
  ids: string[],
25
- options?: { title?: string },
25
+ options?: { title?: string; boards?: { id: string; name: string }[] },
26
26
  ): string | null;
@@ -30,7 +30,7 @@ export const WORKFLOWS = [
30
30
  { question: "Where does each scene happen?", hint: "In your own words. A scene that moves through one location is still one place.", tool: "set_location" },
31
31
  { question: "When does a scene happen, where that matters?", hint: "That night; the fourth of October. It goes in the headline for now.", tool: "the headline" },
32
32
  { question: "Who is in each scene, and what do we call them?", hint: "A full name, or a role for someone unnamed — the man in 42. And who is only spoken of, never in a scene? They go in someone's notes, not the cast.", tool: "add_character, cast, update_character" },
33
- { question: "What is planted, and where does it pay off?", hint: "Say \"later in the series\" when it pays off outside this episode, so the fold is deliberate.", tool: "set_plant, create_arrow" },
33
+ { question: "What is planted, and where does it pay off?", hint: "Name the episode when it pays off outside this one, so the fold is deliberate and the wall knows where to look.", tool: "set_plant with later, create_arrow" },
34
34
  { question: "Which scenes do you already know run long or short?", hint: "A day in the story is not a page count; leave the rest unsized.", tool: "set_length" },
35
35
  { question: "What are the project and the board called?", hint: "The series, and this episode.", tool: "rename_project, rename_board" },
36
36
  { question: "What must not be invented?", hint: "Looks and voices are yours until you say; so is anything the treatment does not state.", tool: "update_character, later" },
@@ -91,6 +91,7 @@ function personLine(character) {
91
91
  if (character.voice) lines.push(`voice: ${character.voice}`);
92
92
  if (character.wants) lines.push(`wants: ${character.wants}`);
93
93
  if (character.needs) lines.push(`needs: ${character.needs}`);
94
+ if (character.notes) lines.push(`notes: ${character.notes}`);
94
95
  return `${character.name}${lines.length ? ` — ${lines.join("; ")}` : " — (no page yet)"}`;
95
96
  }
96
97
 
@@ -120,7 +121,12 @@ export function segmentBrief(state, ids, options = {}) {
120
121
  lines.push("");
121
122
  lines.push(`SCENE: ${note.headline}${note.location ? ` — at ${note.location}` : ""}`);
122
123
  lines.push(`WHAT CHANGES: ${note.change}`);
123
- if (note.plants) lines.push("PLANTS: something here pays off later; keep it visible.");
124
+ if (note.plants) {
125
+ const heads = state.arrows.filter((arrow) => arrow.kind === "setup" && arrow.from === note.id).map((arrow) => byId.get(arrow.to)?.headline).filter(Boolean);
126
+ const later = note.payoffBoardId ? (options.boards ?? []).find((board) => board.id === note.payoffBoardId)?.name ?? "a later board" : null;
127
+ const where = heads.length ? `pays off at ${heads.map((headline) => `"${headline}"`).join(" and ")}` : later ? `pays off later, on "${later}"` : "pays off later, nowhere yet";
128
+ lines.push(`PLANTS: something here ${where}; keep it visible.`);
129
+ }
124
130
  if (note.text && note.text.trim()) {
125
131
  lines.push("SCRIPT:");
126
132
  lines.push(note.text.trim());
@@ -129,6 +135,6 @@ export function segmentBrief(state, ids, options = {}) {
129
135
  }
130
136
  }
131
137
  lines.push("");
132
- lines.push(`AFTER: ${notes.at(-1).change}`);
138
+ lines.push(`AFTER: ${notes.at(-1).change}${notes.length === 1 ? " (the change line, until the scene is written)" : ""}`);
133
139
  return lines.join("\n");
134
140
  }