plotcoder-board 0.1.10 → 0.1.12

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.10",
3
+ "version": "0.1.12",
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,7 +806,7 @@ 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") : "";
807
810
  const place = note.location ? `, at: ${note.location}` : "";
808
811
  const count = formatPages(noteEighths(note));
809
812
  const pages = `${count} ${count === "1" ? "page" : "pages"}${isMeasured(note) ? ", written" : note.lengthEighths === null ? ", unsized" : ""}`;
@@ -841,7 +844,9 @@ function summarize(state) {
841
844
  ` - ${group.id} — "${group.title}" holds ${group.noteIds.length}: ${group.noteIds.join(", ")}`,
842
845
  )
843
846
  .join("\n");
844
- const arrows = state.arrows
847
+ const storyIndex = new Map(readingOrder(state.notes).map((note, index) => [note.id, index]));
848
+ const arrows = [...state.arrows]
849
+ .sort((a, b) => (storyIndex.get(a.from) ?? Infinity) - (storyIndex.get(b.from) ?? Infinity) || (a.kind === "setup") - (b.kind === "setup"))
845
850
  .map(
846
851
  (arrow) =>
847
852
  ` - ${arrow.id} [${arrow.kind ?? "follows"}] — ${arrow.from} → ${arrow.to} ("${headline(arrow.from)}" ${arrow.kind === "setup" ? "sets up" : "→"} "${headline(arrow.to)}")`,
@@ -931,7 +936,7 @@ server.registerTool(
931
936
  : "board";
932
937
  return ok(
933
938
  `PlotCoder ${which} (${door(live, base)})\n${summarize(state)}`,
934
- state,
939
+ { ...state, notes: state.notes.map((note) => ({ ...note, eighths: noteEighths(note), measured: isMeasured(note) })) },
935
940
  );
936
941
  },
937
942
  );
@@ -976,7 +981,7 @@ server.registerTool(
976
981
  });
977
982
  const { beats, scenes } = countRanks(state);
978
983
  return ok(
979
- `${result?.length ?? 0} card(s) are now ${args.rank}${where(live)}. The board holds ${beats} beats and ${scenes} scenes.`,
984
+ `${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
985
  result,
981
986
  );
982
987
  },
@@ -1000,7 +1005,7 @@ server.registerTool(
1000
1005
  lengthEighths: toEighths(args.pages),
1001
1006
  });
1002
1007
  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.`,
1008
+ `${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
1009
  result,
1005
1010
  );
1006
1011
  },
@@ -1185,6 +1190,7 @@ server.registerTool(
1185
1190
  },
1186
1191
  async () => {
1187
1192
  const { state, live, base } = await readBoard();
1193
+ const { project: projectForRead } = await readProject();
1188
1194
  const reading = readWall(state);
1189
1195
  const runs = describeRuns(reading, state).map((line, index) => {
1190
1196
  const ids = reading.runs[index]?.ids ?? [];
@@ -1222,6 +1228,7 @@ server.registerTool(
1222
1228
  ...(reading.setups.length
1223
1229
  ? describeSetups(reading, state).map((line) => ` - ${line}`)
1224
1230
  : [" (no arrow is marked as a setup)"]),
1231
+ ...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
1232
  "questions the wall raises:",
1226
1233
  ...(reading.findings.length
1227
1234
  ? reading.findings.map((finding) => ` - [${finding.kind}] ${finding.text}${finding.ids.length ? ` (ids: ${finding.ids.join(", ")})` : ""}`)
@@ -1301,8 +1308,10 @@ server.registerTool(
1301
1308
  trail.push({ before: state, after: canon(final), what: `move_scene "${card.headline}"` });
1302
1309
  undone.length = 0;
1303
1310
  const order = readingOrder(final.notes);
1311
+ const group = final.groups.find((item) => item.noteIds.includes(card.id));
1312
+ 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
1313
  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.`,
1314
+ `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(", ")}.${groupLine} One undo takes the whole move back.`,
1306
1315
  { order: order.map((note) => note.id) },
1307
1316
  );
1308
1317
  },
@@ -1467,7 +1476,7 @@ server.registerTool(
1467
1476
  {
1468
1477
  title: "Write a scene",
1469
1478
  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.",
1479
+ "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
1480
  inputSchema: { id: z.string(), text: z.string() },
1472
1481
  },
1473
1482
  async (args) => {
@@ -1476,10 +1485,10 @@ server.registerTool(
1476
1485
  if (!result) return ok(`No card with id ${args.id}. Call list_board.`);
1477
1486
  return ok(`Nothing changed: "${result.headline}" already reads that way.`);
1478
1487
  }
1479
- const lines = args.text.split("\n").filter((line) => line.trim()).length;
1488
+ const printed = sceneLineCount(args.text);
1480
1489
  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,
1490
+ `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; its estimate is untouched underneath.`,
1491
+ { ...result, eighths: noteEighths(result), measured: true, printedLines: printed },
1483
1492
  );
1484
1493
  },
1485
1494
  );
@@ -1505,7 +1514,8 @@ server.registerTool(
1505
1514
  if (/^\.(?!\.)/.test(line) && index < ids.length) {
1506
1515
  const note = state.notes.find((item) => item.id === ids[index]);
1507
1516
  index += 1;
1508
- lines.push(`${line} [[id: ${note?.id ?? "?"} · ${note && isMeasured(note) ? "measured" : "estimated"} ${formatPages(note ? noteEighths(note) : 0)}pp]]`);
1517
+ const standIn = note && !(note.location ?? "").trim() ? " · no place: the headline stands in for the heading" : "";
1518
+ lines.push(`${line} [[id: ${note?.id ?? "?"} · ${note && isMeasured(note) ? "measured" : "estimated"} ${formatPages(note ? noteEighths(note) : 0)}pp${standIn}]]`);
1509
1519
  } else {
1510
1520
  lines.push(line);
1511
1521
  }
@@ -1564,7 +1574,7 @@ server.registerTool(
1564
1574
  WORKFLOWS.map(
1565
1575
  (workflow) =>
1566
1576
  `- ${workflow.id} — ${workflow.name}\n ask: "${workflow.ask}"\n tools: ${workflow.tools.join(", ")}\n keep: ${workflow.then}${
1567
- workflow.needs ? `\n the treatment should say (ask the writer for what it leaves open; invent none of it):\n${workflow.needs.map((need) => ` - ${need.ask} → ${need.tool}`).join("\n")}` : ""
1577
+ workflow.needs ? `\n the treatment should answer (ask the writer for what it leaves open; invent none of it):\n${workflow.needs.map((need) => ` - ${need.question} ${need.hint} → ${need.tool}`).join("\n")}` : ""
1568
1578
  }`,
1569
1579
  ).join("\n"),
1570
1580
  WORKFLOWS,
@@ -1889,10 +1899,9 @@ server.registerTool(
1889
1899
  undone.push(last);
1890
1900
  const { boardId } = await readBoard();
1891
1901
  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
- );
1902
+ const orderLine = /^(move_scene|organize)/.test(last.what) ? ` Story order now: ${readingOrder(last.before.notes).map((note, index) => `${index + 1}. ${note.headline}`).join(", ")}.` : "";
1903
+ 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`;
1904
+ return ok(`Undid ${last.what}${where(live)}.${orderLine} ${more}.`, last.before);
1896
1905
  },
1897
1906
  );
1898
1907
 
@@ -1924,23 +1933,52 @@ server.registerTool(
1924
1933
  {
1925
1934
  title: "Fold the corner",
1926
1935
  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.`,
1936
+ `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, and the wall stops asking where it comes back; later '' forgets it. Folding never moves a card.`,
1928
1937
  inputSchema: {
1929
1938
  ids: z.array(z.string()).min(1),
1930
1939
  plants: z.boolean(),
1940
+ later: z.string().optional(),
1931
1941
  },
1932
1942
  },
1933
1943
  async (args) => {
1934
- const { result, live } = await commit({
1944
+ let { result, live, changed } = await commit({
1935
1945
  type: "set_plant",
1936
1946
  ids: args.ids,
1937
1947
  plants: args.plants,
1938
1948
  });
1949
+ let laterLine = "";
1950
+ if (args.plants && args.later !== undefined) {
1951
+ // A series plant (R50): the fold pays off on another board of the project.
1952
+ // The kernel cannot check the board exists; this door can.
1953
+ const { project } = await readProject();
1954
+ const { state: now, boardId: current } = await readBoard();
1955
+ const here = now.notes.filter((note) => args.ids.includes(note.id));
1956
+ if (args.later.trim() === "") {
1957
+ const cleared = await commit({ type: "set_payoff_board", ids: args.ids, boardId: null });
1958
+ if (cleared.changed) {
1959
+ result = cleared.result;
1960
+ live = cleared.live;
1961
+ changed = true;
1962
+ laterLine = " The board it paid off on is forgotten; read_wall asks again until a setup arrow or a board pays it off.";
1963
+ }
1964
+ } else {
1965
+ const target = findBoard(project, args.later);
1966
+ if (!target) return ok(`No board matches "${args.later}". Call list_boards for the project's boards; a fold pays off later on one of them.`);
1967
+ 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'.`);
1968
+ const named = await commit({ type: "set_payoff_board", ids: args.ids, boardId: target.id });
1969
+ if (named.changed) {
1970
+ result = named.result;
1971
+ live = named.live;
1972
+ changed = true;
1973
+ }
1974
+ laterLine = ` ${here.length} card(s) pay off later, on "${target.name}": read_wall stops asking where they come back, and the card says so.`;
1975
+ }
1976
+ }
1939
1977
  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.");
1978
+ if (!changed || count === 0) return ok("No change: those cards were already that way, or the ids are not on the board.");
1941
1979
  return ok(
1942
1980
  args.plants
1943
- ? `${count} card(s) now plant something${where(live)}. read_wall will ask about each until a setup arrow pays it off.`
1981
+ ? `${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
1982
  : `${count} card(s) no longer marked as planting${where(live)}.`,
1945
1983
  result,
1946
1984
  );
@@ -1977,6 +2015,7 @@ server.registerTool(
1977
2015
  inputSchema: { id: z.string(), name: z.string().min(1) },
1978
2016
  },
1979
2017
  async (args) => {
2018
+ const { state: before } = await readBoard();
1980
2019
  const { state, changed, result, live } = await commit({
1981
2020
  type: "rename_character",
1982
2021
  id: args.id,
@@ -1989,7 +2028,10 @@ server.registerTool(
1989
2028
  : ok(`No character with id ${args.id}. Call list_board for the cast.`);
1990
2029
  }
1991
2030
  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);
2031
+ const oldName = (before.characters.find((item) => item.id === result.id)?.name ?? "").trim();
2032
+ const stale = oldName ? CHARACTER_FIELDS.filter((field) => (result[field] ?? "").toLowerCase().includes(oldName.toLowerCase())) : [];
2033
+ const staleLine = stale.length ? ` The page's ${stale.join(", ")} still mention${stale.length === 1 ? "s" : ""} "${oldName}"; the page is untouched.` : "";
2034
+ return ok(`Renamed to "${result.name}" (${result.id})${where(live)}; the name changed on ${followed} card${followed === 1 ? "" : "s"}.${staleLine}`, result);
1993
2035
  },
1994
2036
  );
1995
2037
 
@@ -2297,7 +2339,7 @@ server.registerTool(
2297
2339
 
2298
2340
  // --- The project ------------------------------------------------------
2299
2341
 
2300
- function describeBoards(project, boards) {
2342
+ function describeBoards(project, boards, changedAt = null) {
2301
2343
  return project.boards
2302
2344
  .map((board, index) => {
2303
2345
  const state = boards[board.id];
@@ -2306,7 +2348,8 @@ function describeBoards(project, boards) {
2306
2348
  state && isBoardState(state)
2307
2349
  ? `${state.notes.length} cards, about ${formatPages(boardEighths(normalizeState(state)))} of ${formatPages(normalizeState(state).targetEighths)} pages`
2308
2350
  : "no cards";
2309
- return ` ${index + 1}. ${board.id} "${board.name}"${open}: ${shape}`;
2351
+ const changed = changedAt?.[board.id] ? `, last changed ${changedAt[board.id]}` : "";
2352
+ return ` ${index + 1}. ${board.id} — "${board.name}"${open}: ${shape}${changed}`;
2310
2353
  })
2311
2354
  .join("\n");
2312
2355
  }
@@ -2320,13 +2363,13 @@ server.registerTool(
2320
2363
  inputSchema: {},
2321
2364
  },
2322
2365
  async () => {
2323
- const { project, boards, live, base } = await readProject();
2366
+ const { project, boards, live, base, changedAt } = await readProject();
2324
2367
  return ok(
2325
2368
  [
2326
2369
  `Project "${project.name}" (${door(live, base)})`,
2327
2370
  `premise: ${project.premise ? `"${project.premise}"` : "(not set)"}`,
2328
2371
  `boards: ${project.boards.length}`,
2329
- describeBoards(project, boards),
2372
+ describeBoards(project, boards, changedAt),
2330
2373
  ].join("\n"),
2331
2374
  project,
2332
2375
  );
@@ -2665,7 +2708,7 @@ server.registerTool(
2665
2708
  const { project, boards, reminders } = await readProject();
2666
2709
  const file = toProjectFile({ project, boards, reminders: reminders ?? null });
2667
2710
  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`;
2711
+ const what = `"${project.name}": ${project.boards.length} board(s), ${cards} card(s)${reminders?.length ? `, ${reminders.length} reminder(s)` : ", 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
2712
  if (args.path) {
2670
2713
  fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
2671
2714
  fs.writeFileSync(args.path, JSON.stringify(file, null, 2));
@@ -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
 
@@ -307,8 +307,11 @@ export function readWall(state) {
307
307
  // pays off at the cash and again at the initials), and both count.
308
308
  payoffs[note.id] = heads;
309
309
  }
310
+ // A fold that pays off on another board (R50) is not unpaid: it is listed
311
+ // under `later`, and the door that knows the project names the board.
312
+ const later = order.filter((note) => note.plants && note.payoffBoardId).map((note) => ({ id: note.id, boardId: note.payoffBoardId }));
310
313
  for (const note of order) {
311
- if (note.plants && !paysOff.has(note.id)) {
314
+ if (note.plants && !paysOff.has(note.id) && !note.payoffBoardId) {
312
315
  findings.push({
313
316
  kind: "unpaid",
314
317
  ids: [note.id],
@@ -372,6 +375,7 @@ export function readWall(state) {
372
375
  runs,
373
376
  setups,
374
377
  payoffs,
378
+ later,
375
379
  findings,
376
380
  };
377
381
  }
@@ -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,
@@ -811,6 +818,7 @@ export function applyCommand(state, command, now = nowIso()) {
811
818
  lengthEighths: null,
812
819
  characterIds: [],
813
820
  plants: false,
821
+ payoffBoardId: null,
814
822
  location: "",
815
823
  text: "",
816
824
  createdAt: now,
@@ -895,8 +903,30 @@ export function applyCommand(state, command, now = nowIso()) {
895
903
  const plants = command.plants === true;
896
904
  const touched = [];
897
905
  const notes = state.notes.map((note) => {
898
- if (!ids.has(note.id) || note.plants === plants) return note;
899
- const next = bump(note, { plants }, now);
906
+ if (!ids.has(note.id)) return note;
907
+ // Unfolding forgets where it paid off; a claim that no longer stands.
908
+ const payoffBoardId = plants ? note.payoffBoardId : null;
909
+ if (note.plants === plants && note.payoffBoardId === payoffBoardId) return note;
910
+ const next = bump(note, { plants, payoffBoardId }, now);
911
+ touched.push(next);
912
+ return next;
913
+ });
914
+ if (touched.length === 0) return { state, changed: false };
915
+ return { state: { ...state, notes }, changed: true, result: touched };
916
+ }
917
+
918
+ // A fold that pays off on another board of the project (R50): the wall
919
+ // stops asking where it comes back, and the reading says where. The
920
+ // kernel cannot check the board exists; the door that knows the project
921
+ // does. Null takes the claim back.
922
+ case "set_payoff_board": {
923
+ const ids = new Set(command.ids);
924
+ if (ids.size === 0) return { state, changed: false };
925
+ const payoffBoardId = typeof command.boardId === "string" && command.boardId ? command.boardId : null;
926
+ const touched = [];
927
+ const notes = state.notes.map((note) => {
928
+ if (!ids.has(note.id) || !note.plants || note.payoffBoardId === payoffBoardId) return note;
929
+ const next = bump(note, { payoffBoardId }, now);
900
930
  touched.push(next);
901
931
  return next;
902
932
  });
@@ -12,7 +12,7 @@ export type Workflow = {
12
12
  /** The rule the agent keeps while doing it. */
13
13
  then: string;
14
14
  /** What the writer's material should say for the workflow to need no questions back, and the tool each answer lands in (R49). */
15
- needs?: { ask: string; tool: string }[];
15
+ needs?: { question: string; hint: string; tool: string }[];
16
16
  };
17
17
 
18
18
  export declare const WORKFLOWS: readonly Workflow[];
@@ -23,17 +23,17 @@ export const WORKFLOWS = [
23
23
  // who answers them in the treatment gets a wall with no questions back;
24
24
  // an agent asks the ones the treatment leaves open, and invents none.
25
25
  needs: [
26
- { ask: "How long is it: an hour, a half-hour, a feature, or a page count?", tool: "set_target" },
27
- { ask: "The central question in one sentence, and the series premise above it if there is one.", tool: "set_logline, set_premise" },
28
- { ask: "Which scenes are the turns the beats or 'propose them and I will strike'.", tool: "set_rank" },
29
- { ask: "The acts, if it has them, and where each break falls.", tool: "create_group" },
30
- { ask: "Where each scene happens, in the writer's words; a scene that moves through one location is one place.", tool: "set_location" },
31
- { ask: "When a scene happens, where it matters that night, the fourth of October.", tool: "the headline, for now" },
32
- { ask: "Who is in each scene, with the name to use a full name, or the role name for someone unnamed — and who is only spoken of, which goes in notes.", tool: "add_character, cast, update_character" },
33
- { ask: "What is planted, and where it pays off including 'later in the series', so the fold is deliberate.", tool: "set_plant, create_arrow" },
34
- { ask: "Any scene already known to run long or short; a duration in the story is not a length.", tool: "set_length" },
35
- { ask: "What the project and the board are called.", tool: "rename_project, rename_board" },
36
- { ask: "What not to invent: looks and voices are the writer's until they say.", tool: "update_character, later" },
26
+ { question: "How long is it?", hint: "An hour, a half-hour, a feature or a page count, if you have one.", tool: "set_target" },
27
+ { question: "What is the central question, in one sentence?", hint: "And if this is one episode of something, what is the series about?", tool: "set_logline, set_premise" },
28
+ { question: "Which scenes are the turns?", hint: "Name them, or say \"propose them and I will strike\".", tool: "set_rank" },
29
+ { question: "Does it have acts?", hint: "If so, where does each break fall?", tool: "create_group" },
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
+ { 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
+ { question: "Who is in each scene, and what do we call them?", hint: "A full name, or a role for someone unnamed — the man in 42. And who is only spoken of, never in a scene? They go in someone's notes, not the cast.", tool: "add_character, cast, update_character" },
33
+ { question: "What is planted, and where does it pay off?", hint: "Name the episode when it pays off outside this one, so the fold is deliberate and the wall knows where to look.", tool: "set_plant with later, create_arrow" },
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
+ { question: "What are the project and the board called?", hint: "The series, and this episode.", tool: "rename_project, rename_board" },
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" },
37
37
  ],
38
38
  },
39
39
  {