plotcoder-board 0.1.13 → 0.1.14

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.13",
3
+ "version": "0.1.14",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite",
@@ -51,6 +51,7 @@ import { segmentBrief, WORKFLOWS } from "../src/board/workflows.js";
51
51
  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
+ import { compareStructure, describeComparison, MATCH_PAGES } from "../src/board/compareStructure.js";
54
55
  import { GAP, ROW_WIDTH, organizePoses } from "../src/board/organize.js";
55
56
  import { sceneLineCount } from "../src/board/paginate.js";
56
57
  import {
@@ -69,6 +70,11 @@ import {
69
70
  structureBeats,
70
71
  reidentifyProject,
71
72
  renameProject,
73
+ castElsewhere,
74
+ liftCast,
75
+ mergeRoster,
76
+ sameRoster,
77
+ withRoster,
72
78
  } from "../src/board/project.js";
73
79
 
74
80
  /**
@@ -626,7 +632,23 @@ async function openBoardEverywhere(project, boards, projectRev, base, boardId) {
626
632
  return { project: opened, state, live };
627
633
  }
628
634
 
635
+ /**
636
+ * The open board with the project's cast in it (R51). A project written
637
+ * before the cast moved to the record is lifted once, here, and written back.
638
+ */
629
639
  async function readBoard() {
640
+ const raw = await readBoardRaw();
641
+ const held = await readProject();
642
+ if (!Array.isArray(held.project.characters)) {
643
+ const boardId = raw.boardId ?? held.project.activeBoardId;
644
+ const lifted = liftCast(held.project, { ...held.boards, [boardId]: raw.state });
645
+ await writeProject(lifted.project, lifted.boards, held.rev, held.base);
646
+ return { ...raw, state: lifted.boards[boardId] ?? withRoster(raw.state, lifted.project) };
647
+ }
648
+ return { ...raw, state: withRoster(raw.state, held.project) };
649
+ }
650
+
651
+ async function readBoardRaw() {
630
652
  const viaAccount = await throughAccount(accountReadBoard);
631
653
  if (viaAccount) return viaAccount;
632
654
  const base = await findBridge();
@@ -653,7 +675,36 @@ async function readBoard() {
653
675
  return { ...file, base: null, live: false };
654
676
  }
655
677
 
656
- async function writeBoard(next, rev, base, boardId = null) {
678
+ /**
679
+ * Write the board, keeping the project's cast (R51) with it. A kernel
680
+ * command's result ("exact": commit, undo, redo) is the roster as the writer
681
+ * now wants it, removals included, and it is lifted onto the record. Any
682
+ * other state — a board opened, a file imported — joins the cast without
683
+ * shrinking it ("merge"), and is written composed with the record.
684
+ */
685
+ async function writeBoard(next, rev, base, boardId = null, roster = "merge") {
686
+ const held = await readProject();
687
+ let toWrite = next;
688
+ if (Array.isArray(held.project.characters)) {
689
+ let project = held.project;
690
+ if (roster === "exact") {
691
+ if (!sameRoster(project.characters, next.characters)) project = { ...project, characters: next.characters, updatedAt: new Date().toISOString() };
692
+ } else {
693
+ const merged = mergeRoster(project, next);
694
+ project = merged.project;
695
+ toWrite = merged.state;
696
+ }
697
+ if (project !== held.project) {
698
+ const boards = {};
699
+ for (const [id, state] of Object.entries(held.boards)) boards[id] = withRoster(state, project);
700
+ boards[boardId ?? project.activeBoardId] = withRoster(toWrite, project);
701
+ await writeProject(project, boards, held.rev, held.base);
702
+ }
703
+ }
704
+ return writeBoardRaw(toWrite, rev, base, boardId);
705
+ }
706
+
707
+ async function writeBoardRaw(next, rev, base, boardId = null) {
657
708
  if (base === ACCOUNT) return accountWriteBoard(next, rev, boardId);
658
709
  if (base) {
659
710
  try {
@@ -734,7 +785,7 @@ async function commit(command) {
734
785
  // command teaches the agent the board is in a state it is not.
735
786
  if (!changed) return { state: next, changed, result, live: base !== null };
736
787
 
737
- const live = await writeBoard(next, rev, base, boardId);
788
+ const live = await writeBoard(next, rev, base, boardId, "exact");
738
789
  trail.push({ before: state, after: canon(next), what: describeCommand(command) });
739
790
  if (trail.length > TRAIL_CAP) trail.shift();
740
791
  undone.length = 0;
@@ -873,7 +924,7 @@ function summarize(state) {
873
924
  ? `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`
874
925
  : `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)`,
875
926
  `notes: ${state.notes.length}, groups: ${state.groups.length}, arrows: ${state.arrows.length}, cast: ${state.characters.length}`,
876
- "cast:",
927
+ "cast (the project's; every board of it casts from here):",
877
928
  cast || " (no one yet — add_character to start the roster)",
878
929
  "places (each phrase is its own place, and the app relates none of them — if two are one place, set_location them the same):",
879
930
  places || " (no card says where it happens yet)",
@@ -1188,14 +1239,16 @@ server.registerTool(
1188
1239
  {
1189
1240
  title: "Read the wall",
1190
1241
  description:
1191
- "Read the board back: the beats in wall order (rows top to bottom, cards left to right), the pages of scenes between consecutive beats with the cards in each, every setup with the distance to its payoff, and the questions the wall raises — no beat marked yet; a run out of proportion with the others; beats back to back with nothing between them (a chain of them is one question); a card with a placeholder headline or no change line; a card no arrow touches; two headlines that read like the same scene; a group too long to be one sequence; a person in the cast on no card; a person gone for more than a third of the story and ten pages; a payoff before its setup on the wall; a folded card no setup arrow pays off; cards that say no place once any card has one. These are questions, not fixes: put them to the writer and do not act on them unasked. It says nothing about how many beats there should be, and neither should you. The prose carries every id; the JSON after it is the same reading for a program, and PLOTCODER_JSON=0 in the server's environment drops it.",
1242
+ "Read the board back: the beats in wall order (rows top to bottom, cards left to right), the pages of scenes between consecutive beats with the cards in each, every setup with the distance to its payoff, and the questions the wall raises — no beat marked yet; a run out of proportion with the others; beats back to back with nothing between them (a chain of them is one question); a card with a placeholder headline or no change line; a card no arrow touches; two headlines that read like the same scene; a group too long to be one sequence; a person in the cast on no card; a person gone for more than a third of the story and ten pages; a payoff before its setup on the wall; a folded card no setup arrow pays off; cards that say no place once any card has one. These are questions, not fixes: put them to the writer and do not act on them unasked. A question the writer answers with \"leave it\" is left with leave_question and listed under \"left, for now\" instead, until it would read differently. It says nothing about how many beats there should be, and neither should you. The prose carries every id; the JSON after it is the same reading for a program, and PLOTCODER_JSON=0 in the server's environment drops it.",
1192
1243
  inputSchema: {},
1193
1244
  },
1194
1245
  async () => {
1195
1246
  const { state, live, base, boardId: readBoardId } = await readBoard();
1196
1247
  const { project: projectForRead } = await readProject();
1197
1248
  const readBoardMeta = boardById(projectForRead, readBoardId ?? projectForRead.activeBoardId);
1198
- const reading = readWall(state);
1249
+ const { boards: boardsForRead } = await readProject();
1250
+ const elsewhereForRead = castElsewhere(projectForRead, boardsForRead, readBoardId ?? projectForRead.activeBoardId);
1251
+ const reading = readWall(state, { elsewhere: Object.keys(elsewhereForRead) });
1199
1252
  const runs = describeRuns(reading, state).map((line, index) => {
1200
1253
  const ids = reading.runs[index]?.ids ?? [];
1201
1254
  return ids.length ? `${line} — ${ids.map((id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`).join(", ")}` : line;
@@ -1238,13 +1291,20 @@ server.registerTool(
1238
1291
  "questions the wall raises:",
1239
1292
  ...(reading.findings.length
1240
1293
  ? reading.findings.map((finding) => ` - [${finding.kind}] ${finding.text}${finding.ids.length ? ` (ids: ${finding.ids.join(", ")})` : ""}`)
1241
- : [" (none that this reading can see)"]),
1294
+ : [reading.left.length ? " (none the writer has not left)" : " (none that this reading can see)"]),
1295
+ ...(reading.left.length
1296
+ ? [
1297
+ "left, for now (the writer's word; kept until the question would read differently, and ask_again brings one back):",
1298
+ ...reading.left.map((finding) => ` - [${finding.kind}] ${finding.text} (left ${String(finding.since).slice(0, 10)}${finding.ids.length ? `; ids: ${finding.ids.join(", ")}` : ""})`),
1299
+ ]
1300
+ : []),
1242
1301
  `checks: ${CHECKS.length} run — ${(() => {
1243
1302
  const asked = reading.findings;
1244
- if (asked.length === 0) return "asking nothing";
1303
+ const held = reading.left.length ? `, ${reading.left.length} left by the writer` : "";
1304
+ if (asked.length === 0) return `asking nothing${held}`;
1245
1305
  const counts = new Map();
1246
1306
  for (const finding of asked) counts.set(finding.kind, (counts.get(finding.kind) ?? 0) + 1);
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(", ")}`;
1307
+ return `asking ${asked.length} question${asked.length === 1 ? "" : "s"} of ${counts.size} kind${counts.size === 1 ? "" : "s"}: ${[...counts.entries()].map(([kind, n]) => (n > 1 ? `${kind} ×${n}` : kind)).join(", ")}${held}`;
1248
1308
  })()}; checked and clean: ${CHECKS.filter((kind) => !reading.findings.some((finding) => finding.kind === kind)).map((kind) => {
1249
1309
  if (kind === "unlinked" && state.arrows.length === 0) return "no card without an arrow (not asked: no arrows yet)";
1250
1310
  if (kind === "unplaced" && !state.notes.some((note) => (note.location ?? "").trim())) return "no card without a place (not asked: no card placed yet)";
@@ -1257,6 +1317,60 @@ server.registerTool(
1257
1317
  },
1258
1318
  );
1259
1319
 
1320
+ // --- Leaving a question (R53) ----------------------------------------
1321
+
1322
+ const sameList = (a, b) => a.length === b.length && a.every((id, index) => id === b[index]);
1323
+
1324
+ server.registerTool(
1325
+ "leave_question",
1326
+ {
1327
+ title: "Leave a question, for now",
1328
+ description:
1329
+ "Write the writer's word on a question the wall asks — \"leave it\" — so the reading stops asking it. Pass the question's kind as read_wall names it (sag, empty, unpaid, …) and, when that kind is asked more than once, its ids as read_wall lists them. The wall keeps the question and asks it again on its own the moment it would read differently — a card in it changes, a page moves, the median shifts — so a left question is never a dismissal; ask_again brings one back now. Only on the writer's word: never leave a question unasked.",
1330
+ inputSchema: { kind: z.string().min(1), ids: z.array(z.string()).optional() },
1331
+ },
1332
+ async (args) => {
1333
+ const { state } = await readBoard();
1334
+ const reading = readWall(state);
1335
+ const already = reading.left.filter((finding) => finding.kind === args.kind && (!args.ids || sameList(finding.ids, args.ids)));
1336
+ const matches = reading.findings.filter((finding) => finding.kind === args.kind && (!args.ids || sameList(finding.ids, args.ids)));
1337
+ if (matches.length === 0) {
1338
+ if (already.length) return ok(`Already left: [${args.kind}] ${already[0].text} It stays left until the question would read differently; ask_again brings it back.`);
1339
+ return ok(`The wall is not asking a question of kind "${args.kind}"${args.ids ? ` about ids ${args.ids.join(", ")}` : ""}. read_wall lists the questions it asks now, each with its kind and ids.`);
1340
+ }
1341
+ if (matches.length > 1) {
1342
+ return ok(`The wall asks ${matches.length} questions of kind "${args.kind}"; pass ids to say which:\n${matches.map((finding) => ` - ${finding.text} (ids: ${finding.ids.join(", ")})`).join("\n")}`);
1343
+ }
1344
+ const finding = matches[0];
1345
+ const { result, live } = await commit({ type: "leave_question", kind: finding.kind, ids: finding.ids, text: finding.text });
1346
+ return ok(
1347
+ `Left, for now: [${finding.kind}] ${finding.text}${where(live)} The wall stops asking it and keeps the writer's word; it asks again on its own when the question would read differently, and ask_again brings it back now. read_wall lists it under "left, for now".`,
1348
+ result,
1349
+ );
1350
+ },
1351
+ );
1352
+
1353
+ server.registerTool(
1354
+ "ask_again",
1355
+ {
1356
+ title: "Ask a left question again",
1357
+ description: "Take back a left question by its kind (and ids, when that kind was left more than once), so the wall asks it again now. Without ids, every left question of that kind comes back.",
1358
+ inputSchema: { kind: z.string().min(1), ids: z.array(z.string()).optional() },
1359
+ },
1360
+ async (args) => {
1361
+ const { state } = await readBoard();
1362
+ const held = (state.left ?? []).filter((item) => item.kind === args.kind && (!args.ids || sameList(item.ids, args.ids)));
1363
+ 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".`);
1364
+ const { result, live } = await commit({ type: "ask_again", kind: args.kind, ids: args.ids });
1365
+ const reading = readWall((await readBoard()).state);
1366
+ const back = reading.findings.filter((finding) => held.some((item) => item.kind === finding.kind && sameList(item.ids, finding.ids)));
1367
+ return ok(
1368
+ `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"}.`,
1369
+ result,
1370
+ );
1371
+ },
1372
+ );
1373
+
1260
1374
  server.registerTool(
1261
1375
  "move_scene",
1262
1376
  {
@@ -1403,7 +1517,7 @@ server.registerTool(
1403
1517
  "list_structures",
1404
1518
  {
1405
1519
  title: "List the structures",
1406
- description: "The structures apply_template can lay on a wall: the built-in ones, and the writer's own saved from their walls (save_structure), each with its beats.",
1520
+ description: "The structures apply_template can lay on a wall: the built-in ones, and the writer's own saved from their walls (save_structure), each with its beats. compare_structure sets one beside this wall's beats without laying anything.",
1407
1521
  inputSchema: {},
1408
1522
  },
1409
1523
  async () => {
@@ -1413,11 +1527,43 @@ server.registerTool(
1413
1527
  `the writer's own: ${own.length}`,
1414
1528
  ...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
1529
  `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`,
1530
+ "compare_structure sets one of these beside this wall's beats, page by page, and lays nothing",
1416
1531
  ];
1417
1532
  return ok(lines.join("\n"), { builtIn: TEMPLATES.map((template) => ({ id: template.id, name: template.name, beats: template.beats })), own });
1418
1533
  },
1419
1534
  );
1420
1535
 
1536
+ server.registerTool(
1537
+ "compare_structure",
1538
+ {
1539
+ title: "A structure beside the wall",
1540
+ description:
1541
+ "Set a structure beside this wall's beats without laying anything: each of the structure's beats with the page it falls near on this board's target, and the nearest of the wall's own beats within six pages — one to one, in order — with how far off it is (here, near, N pp early or late). A reading, like read_wall: nothing moves and no card is made. Takes a built-in structure by id (turns, three-acts, eight-sequences, fifteen-beats, story-circle) or one of the writer's own by name or id; turns is the default.",
1542
+ inputSchema: { structure: z.string().optional() },
1543
+ },
1544
+ async (args) => {
1545
+ const { state } = await readBoard();
1546
+ const { project } = await readProject();
1547
+ const wanted = (args.structure ?? "turns").trim().toLowerCase();
1548
+ const own = project.structures ?? [];
1549
+ const chosen =
1550
+ TEMPLATES.find((template) => template.id === wanted || template.name.toLowerCase() === wanted) ??
1551
+ own.find((structure) => structure.id === wanted || structure.name.toLowerCase() === wanted);
1552
+ if (!chosen) return ok(`No structure called "${args.structure}". list_structures names the built-in five and the writer's own.`);
1553
+ const comparison = compareStructure(state, chosen.beats);
1554
+ const beats = state.notes.filter((note) => note.rank === "beat").length;
1555
+ const lines = [
1556
+ `"${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:`,
1557
+ ...describeComparison(comparison).map((line) => ` - ${line}`),
1558
+ comparison.unmatched.length
1559
+ ? `beats of the wall no beat of the structure answers: ${comparison.unmatched.map((beat) => `"${beat.headline}" (p. ${beat.page})`).join(", ")}`
1560
+ : "every beat of the wall answers one of the structure's",
1561
+ 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.",
1562
+ ];
1563
+ return ok(lines.join("\n"), { structure: { id: chosen.id, name: chosen.name }, ...comparison });
1564
+ },
1565
+ );
1566
+
1421
1567
  server.registerTool(
1422
1568
  "save_structure",
1423
1569
  {
@@ -1916,7 +2062,7 @@ server.registerTool(
1916
2062
  trail.pop();
1917
2063
  undone.push(last);
1918
2064
  const { boardId } = await readBoard();
1919
- const live = await writeBoard(last.before, rev, base, boardId);
2065
+ const live = await writeBoard(last.before, rev, base, boardId, "exact");
1920
2066
  const orderLine = /^(move_scene|organize)/.test(last.what) ? ` Story order now: ${readingOrder(last.before.notes).map((note, index) => `${index + 1}. ${note.headline}`).join(", ")}.` : "";
1921
2067
  const cardsDiff = last.before.notes.length - state.notes.length;
1922
2068
  const countLine = cardsDiff > 0 ? ` ${cardsDiff} card(s) back.` : cardsDiff < 0 ? ` ${-cardsDiff} card(s) gone.` : "";
@@ -1942,7 +2088,7 @@ server.registerTool(
1942
2088
  }
1943
2089
  undone.pop();
1944
2090
  const after = normalizeState(JSON.parse(last.after));
1945
- const live = await writeBoard(after, rev, base, boardId);
2091
+ const live = await writeBoard(after, rev, base, boardId, "exact");
1946
2092
  trail.push(last);
1947
2093
  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 });
1948
2094
  },
@@ -2012,7 +2158,7 @@ server.registerTool(
2012
2158
  {
2013
2159
  title: "Add character",
2014
2160
  description:
2015
- "Add a person to the board's cast — the roster every card casts from. One record per person: the same name twice is refused and the existing record returned. Add someone here before casting them on a card.",
2161
+ "Add a person to the project's cast — one roster every board of the project casts from, so a person is one record across the pilot and the episodes after it. The same name twice is refused and the existing record returned. Add someone here before casting them on a card.",
2016
2162
  inputSchema: { name: z.string().min(1) },
2017
2163
  },
2018
2164
  async (args) => {
@@ -2022,7 +2168,7 @@ server.registerTool(
2022
2168
  ? ok(`Already in the cast as "${result.name}" (${result.id}). Use that id.`, result)
2023
2169
  : ok("No character added: the name was empty.");
2024
2170
  }
2025
- return ok(`Added "${result.name}" to the cast${where(live)}.`, result);
2171
+ return ok(`Added "${result.name}" to the project's cast${where(live)}; every board of the project casts from it.`, result);
2026
2172
  },
2027
2173
  );
2028
2174
 
@@ -2161,13 +2307,23 @@ server.registerTool(
2161
2307
  {
2162
2308
  title: "Remove character",
2163
2309
  description:
2164
- "Remove a person from the cast by id. They leave every card they were on. The cards themselves stay.",
2310
+ "Remove a person from the project's cast by id. They leave every card they were on here; the cards themselves stay. Refused while another board of the project has them on a card: take them off there first.",
2165
2311
  inputSchema: { id: z.string() },
2166
2312
  },
2167
2313
  async (args) => {
2314
+ const { state, boardId } = await readBoard();
2315
+ const person = state.characters.find((character) => character.id === args.id);
2316
+ if (!person) return ok(`No character with id ${args.id}. Call list_board for the cast.`);
2317
+ const { project, boards } = await readProject();
2318
+ const elsewhere = castElsewhere(project, boards, boardId ?? project.activeBoardId)[args.id] ?? [];
2319
+ if (elsewhere.length) {
2320
+ return ok(
2321
+ `"${person.name}" stays: the cast is the project's, and a person leaves it only when no board has them on a card — they are on ${elsewhere.map((item) => `${item.cards} card${item.cards === 1 ? "" : "s"} of "${item.board}"`).join(" and ")}. open_board there and cast them off those cards first, or leave them.`,
2322
+ );
2323
+ }
2168
2324
  const { changed, live } = await commit({ type: "remove_character", id: args.id });
2169
2325
  if (!changed) return ok(`No character with id ${args.id}. Call list_board for the cast.`);
2170
- return ok(`Removed from the cast and from every card${where(live)}.`);
2326
+ return ok(`Removed "${person.name}" from the project's cast and from every card${where(live)}.`);
2171
2327
  },
2172
2328
  );
2173
2329
 
@@ -2838,7 +2994,7 @@ server.registerTool(
2838
2994
  const fresh = { ...emptyState(), ...(target ? { targetEighths: target } : {}) };
2839
2995
  const { live } = await openBoardEverywhere(next, { ...boards, [board.id]: fresh }, rev, base, board.id);
2840
2996
  return ok(
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." : ""}`,
2997
+ `Added "${board.name}" (${board.id}) and opened it${where(live)}: every card call lands there now, and the writer's open wall switched with it; open_board "${project.boards.findIndex((item) => item.id === project.activeBoardId) + 1}" comes back. It is empty, and the project's cast is already there to cast from. The logline is the story's question when the writer has one — leave it empty rather than invent it — and the cards come next.${next.name === "Untitled project" ? " The project is still \"Untitled project\": rename_project names it." : ""}${next.boards.length === 2 && isSampleWall(isBoardState(boards[next.boards[0].id]) ? normalizeState(boards[next.boards[0].id]) : emptyState()) ? " The sample stays as Board 1; delete_board drops it." : ""}`,
2842
2998
  board,
2843
2999
  );
2844
3000
  },
@@ -0,0 +1,32 @@
1
+ // Type surface for compareStructure.js — a structure beside the wall (R52).
2
+
3
+ import type { BoardState } from "./reducer";
4
+
5
+ export declare const MATCH_PAGES: number;
6
+ export declare const NEAR_PAGES: number;
7
+
8
+ export type ComparedBeat = {
9
+ name: string;
10
+ at: number;
11
+ /** The page the structure's beat falls near on this board's target. */
12
+ page: number;
13
+ /** The wall's beat that answers it, or null. */
14
+ match: { id: string; headline: string; page: number } | null;
15
+ /** Pages the wall's beat is off by: negative is early. Null with no match. */
16
+ drift: number | null;
17
+ /** No match, and the page is past the story so far. */
18
+ beyond: boolean;
19
+ };
20
+
21
+ export type StructureComparison = {
22
+ rows: ComparedBeat[];
23
+ /** The wall's beats no beat of the structure took. */
24
+ unmatched: Array<{ id: string; headline: string; page: number }>;
25
+ /** Pages on the wall so far. */
26
+ soFar: number;
27
+ targetEighths: number;
28
+ };
29
+
30
+ export declare function compareStructure(state: BoardState, beats: ReadonlyArray<{ name: string; at: number }>): StructureComparison;
31
+ export declare function driftWord(drift: number | null | undefined): string | null;
32
+ export declare function describeComparison(comparison: StructureComparison): string[];
@@ -0,0 +1,85 @@
1
+ // A structure beside the wall (R52).
2
+ //
3
+ // A structure's beats each carry the share of the story they tend to fall
4
+ // near. Laying one out makes cards; this is the other thing the sheet's
5
+ // words promise — a comparison. Each of the structure's beats gets the page
6
+ // it falls near on this board's target, and the nearest of the wall's own
7
+ // beats within reach, one to one and in order, with how far off it is. A
8
+ // reading, like read_wall: it moves nothing and makes nothing.
9
+ //
10
+ // Pure and DOM-free, like the kernel: the sheet, the strip and the MCP
11
+ // server all read the same rows.
12
+
13
+ import { readingOrder } from "./readWall.js";
14
+ import { EIGHTHS_PER_PAGE, noteEighths } from "./reducer.js";
15
+ import { beatPage } from "./templates.js";
16
+
17
+ /** Within this many pages a beat of the wall answers a beat of the structure. */
18
+ export const MATCH_PAGES = 6;
19
+ /** Within this many pages the match is "near" rather than early or late. */
20
+ export const NEAR_PAGES = 2;
21
+
22
+ /**
23
+ * Set a structure's beats beside this wall's. `beats` are a template's or a
24
+ * saved structure's: `{ name, at }`. The wall's beats are taken in reading
25
+ * order; each of the structure's takes the nearest wall beat not yet taken
26
+ * and not before the last one taken, within MATCH_PAGES, so the matching
27
+ * never crosses.
28
+ */
29
+ export function compareStructure(state, beats) {
30
+ const order = readingOrder(state.notes);
31
+ const wallBeats = [];
32
+ let at = 0;
33
+ for (const note of order) {
34
+ if (note.rank === "beat") {
35
+ wallBeats.push({ id: note.id, headline: note.headline, page: Math.floor(at / EIGHTHS_PER_PAGE) + 1 });
36
+ }
37
+ at += noteEighths(note);
38
+ }
39
+ const soFar = Math.ceil(at / EIGHTHS_PER_PAGE);
40
+ let from = 0;
41
+ const taken = new Set();
42
+ const rows = beats.map((beat) => {
43
+ const page = beatPage(beat.at, state.targetEighths);
44
+ let best = -1;
45
+ for (let i = from; i < wallBeats.length; i += 1) {
46
+ const gap = Math.abs(wallBeats[i].page - page);
47
+ if (gap > MATCH_PAGES) continue;
48
+ if (best < 0 || gap < Math.abs(wallBeats[best].page - page)) best = i;
49
+ }
50
+ if (best >= 0) {
51
+ taken.add(best);
52
+ from = best + 1;
53
+ }
54
+ const match = best >= 0 ? wallBeats[best] : null;
55
+ return {
56
+ name: beat.name,
57
+ at: beat.at,
58
+ page,
59
+ match,
60
+ drift: match ? match.page - page : null,
61
+ // No match and the page is past the story so far: nothing is there yet.
62
+ beyond: !match && page > soFar,
63
+ };
64
+ });
65
+ const unmatched = wallBeats.filter((_, index) => !taken.has(index));
66
+ return { rows, unmatched, soFar, targetEighths: state.targetEighths };
67
+ }
68
+
69
+ /** The drift as a word or two: here, near, 3 pp early, 12 pp late. */
70
+ export function driftWord(drift) {
71
+ if (drift === null || drift === undefined) return null;
72
+ if (drift === 0) return "here";
73
+ if (Math.abs(drift) <= NEAR_PAGES) return "near";
74
+ return drift < 0 ? `${-drift} pp early` : `${drift} pp late`;
75
+ }
76
+
77
+ /** One line per row, for a reply or a sheet: the structure's beat, its page, and the wall's answer. */
78
+ export function describeComparison(comparison) {
79
+ return comparison.rows.map((row) => {
80
+ const head = `${row.name} (p. ${row.page})`;
81
+ if (row.match) return `${head} — yours: "${row.match.headline}" p. ${row.match.page} · ${driftWord(row.drift)}`;
82
+ if (row.beyond) return `${head} — nothing yet: past p. ${comparison.soFar}, the story so far`;
83
+ return `${head} — none of yours within ${MATCH_PAGES} pages`;
84
+ });
85
+ }
@@ -1,6 +1,6 @@
1
1
  // Type surface for project.js — the project record (R35).
2
2
 
3
- import type { BoardNote } from "./reducer";
3
+ import type { BoardCharacter, BoardNote, BoardState } from "./reducer";
4
4
 
5
5
  export declare const PROJECT_VERSION: number;
6
6
  export declare const DEFAULT_PROJECT_NAME: string;
@@ -22,6 +22,8 @@ export type ProjectRecord = {
22
22
  activeBoardId: string;
23
23
  /** A writer's own structures, saved from a wall's beats (Roadmap 2, item 7). */
24
24
  structures?: OwnStructure[];
25
+ /** The project's cast (R51): one roster every board draws from. Absent until liftCast has run. */
26
+ characters?: BoardCharacter[];
25
27
  createdAt: string;
26
28
  updatedAt: string;
27
29
  };
@@ -70,3 +72,19 @@ export declare function reidentifyProject(
70
72
  project: ProjectRecord,
71
73
  now?: string,
72
74
  ): ProjectRecord & { renamed: Record<string, string> };
75
+
76
+ /** A board's state composed with the project's cast (R51). */
77
+ export declare function withRoster(state: BoardState, project: ProjectRecord): BoardState;
78
+ export declare function sameRoster(a: BoardCharacter[] | undefined, b: BoardCharacter[] | undefined): boolean;
79
+ export declare function liftCast(
80
+ project: ProjectRecord,
81
+ boards: Record<string, BoardState>,
82
+ now?: string,
83
+ ): { project: ProjectRecord; boards: Record<string, BoardState>; changed: boolean };
84
+ export type CastElsewhere = Record<string, Array<{ board: string; boardId: string; cards: number }>>;
85
+ export declare function castElsewhere(project: ProjectRecord, boards: Record<string, BoardState>, activeBoardId: string): CastElsewhere;
86
+ export declare function mergeRoster(
87
+ project: ProjectRecord,
88
+ state: BoardState,
89
+ now?: string,
90
+ ): { project: ProjectRecord; state: BoardState };
@@ -9,7 +9,7 @@
9
9
  // Plain ESM with a sibling .d.ts, like the kernel, so the browser store and the
10
10
  // MCP server share one idea of what a project is. Keep it free of `window`.
11
11
 
12
- import { newId, noteEighths, nowIso } from "./reducer.js";
12
+ import { CHARACTER_FIELDS, fillCharacter, isCharacter, newId, normalizeState, noteEighths, nowIso, sameName } from "./reducer.js";
13
13
 
14
14
  export const PROJECT_VERSION = 2;
15
15
  export const DEFAULT_PROJECT_NAME = "Untitled project";
@@ -75,7 +75,7 @@ export function normalizeProject(value, now = nowIso()) {
75
75
  : [];
76
76
  // `renamed` is reidentifyProject's map for the store, never part of the record.
77
77
  const { renamed: _renamed, ...rest } = value;
78
- return {
78
+ const record = {
79
79
  ...rest,
80
80
  version: PROJECT_VERSION,
81
81
  name: trimmed(value.name, DEFAULT_PROJECT_NAME),
@@ -86,6 +86,138 @@ export function normalizeProject(value, now = nowIso()) {
86
86
  createdAt: typeof value.createdAt === "string" ? value.createdAt : now,
87
87
  updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : now,
88
88
  };
89
+ // The project's cast (R51). A record with none has not been lifted yet —
90
+ // its boards still carry their own rosters — and liftCast does that at the
91
+ // next load boundary; so absent stays absent, and never becomes [].
92
+ if (Array.isArray(value.characters)) record.characters = value.characters.filter(isCharacter).map(fillCharacter);
93
+ else delete record.characters;
94
+ return record;
95
+ }
96
+
97
+ // --- The project's cast (R51) -----------------------------------------
98
+ //
99
+ // One roster for the project, on the record, that every board draws from.
100
+ // A board's state still carries `characters`, but as a copy of the
101
+ // project's: every load boundary composes it with withRoster, and every
102
+ // store lifts a roster a kernel command changed back onto the record. So
103
+ // the kernel, the readings and the wall go on reading state.characters,
104
+ // and there is one Nessa across the pilot and episode two.
105
+
106
+ /** A board's state with the project's cast in it; unknown cast ids on cards drop. */
107
+ export function withRoster(state, project) {
108
+ if (!Array.isArray(project.characters) || sameRoster(project.characters, state.characters)) return state;
109
+ const next = normalizeState({ ...state, characters: project.characters });
110
+ return next.characters === project.characters ? next : { ...next, characters: project.characters };
111
+ }
112
+
113
+ /** True when two rosters are the same people with the same pages. */
114
+ export function sameRoster(a, b) {
115
+ if (a === b) return true;
116
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
117
+ return a.every((character, index) => {
118
+ const other = b[index];
119
+ return other && character.id === other.id && character.name === other.name && CHARACTER_FIELDS.every((field) => (character[field] ?? "") === (other[field] ?? ""));
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Lift the cast onto the project. A record that already has one composes
125
+ * every board with it. A record with none — written before R51 — takes the
126
+ * boards' rosters in board order and merges them by name: the first record
127
+ * of a name keeps its id, later ones fold into it (a page line fills from the
128
+ * first board that had it), and every card that cast a folded id casts the
129
+ * kept one. Returns the record, every board composed, and whether anything
130
+ * changed.
131
+ */
132
+ export function liftCast(project, boards, now = nowIso()) {
133
+ if (Array.isArray(project.characters)) {
134
+ let changed = false;
135
+ const out = {};
136
+ for (const [id, state] of Object.entries(boards)) {
137
+ const next = withRoster(state, project);
138
+ if (next !== state) changed = true;
139
+ out[id] = next;
140
+ }
141
+ return { project, boards: out, changed };
142
+ }
143
+ const roster = [];
144
+ const folded = {};
145
+ const order = [
146
+ ...project.boards.map((meta) => meta.id).filter((id) => boards[id]),
147
+ ...Object.keys(boards).filter((id) => !project.boards.some((meta) => meta.id === id)),
148
+ ];
149
+ for (const boardId of order) {
150
+ const map = {};
151
+ for (const character of boards[boardId].characters ?? []) {
152
+ if (!isCharacter(character)) continue;
153
+ const kept = roster.find((item) => sameName(item.name, character.name));
154
+ if (!kept) {
155
+ roster.push(fillCharacter({ ...character }));
156
+ continue;
157
+ }
158
+ map[character.id] = kept.id;
159
+ for (const field of CHARACTER_FIELDS) {
160
+ if (!kept[field].trim() && typeof character[field] === "string" && character[field].trim()) kept[field] = character[field];
161
+ }
162
+ }
163
+ folded[boardId] = map;
164
+ }
165
+ const lifted = { ...project, characters: roster, updatedAt: now };
166
+ const out = {};
167
+ for (const [boardId, state] of Object.entries(boards)) {
168
+ const map = folded[boardId] ?? {};
169
+ const notes = state.notes.map((note) => {
170
+ const ids = [...new Set(note.characterIds.map((id) => map[id] ?? id))];
171
+ return ids.length === note.characterIds.length && ids.every((id, index) => id === note.characterIds[index]) ? note : { ...note, characterIds: ids };
172
+ });
173
+ out[boardId] = normalizeState({ ...state, notes, characters: roster });
174
+ }
175
+ return { project: lifted, boards: out, changed: true };
176
+ }
177
+
178
+ /**
179
+ * A board with a roster of its own — an imported file, a board opened from
180
+ * elsewhere — joins the project's cast without shrinking it: a name the
181
+ * project already has keeps the project's record (its cards recast to that
182
+ * id), a new name is appended with the record it came with. Returns the
183
+ * record and the board composed with it.
184
+ */
185
+ export function mergeRoster(project, state, now = nowIso()) {
186
+ if (!Array.isArray(project.characters)) return { project, state };
187
+ const roster = [...project.characters];
188
+ const map = {};
189
+ let grew = false;
190
+ for (const character of state.characters ?? []) {
191
+ if (!isCharacter(character)) continue;
192
+ const kept = roster.find((item) => sameName(item.name, character.name));
193
+ if (kept) {
194
+ if (kept.id !== character.id) map[character.id] = kept.id;
195
+ continue;
196
+ }
197
+ roster.push(fillCharacter({ ...character }));
198
+ grew = true;
199
+ }
200
+ const next = grew ? { ...project, characters: roster, updatedAt: now } : project;
201
+ const notes = state.notes.map((note) => {
202
+ const ids = [...new Set(note.characterIds.map((id) => map[id] ?? id))];
203
+ return ids.length === note.characterIds.length && ids.every((id, index) => id === note.characterIds[index]) ? note : { ...note, characterIds: ids };
204
+ });
205
+ const recast = notes.some((note, index) => note !== state.notes[index]) ? { ...state, notes } : state;
206
+ return { project: next, state: withRoster(recast, next) };
207
+ }
208
+
209
+ /** Which other boards of the project have each person on a card: id -> [{ board, cards }]. */
210
+ export function castElsewhere(project, boards, activeBoardId) {
211
+ const map = {};
212
+ for (const meta of project.boards) {
213
+ if (meta.id === activeBoardId) continue;
214
+ const state = boards[meta.id];
215
+ if (!state) continue;
216
+ const counts = {};
217
+ for (const note of state.notes) for (const id of note.characterIds ?? []) counts[id] = (counts[id] ?? 0) + 1;
218
+ for (const [id, cards] of Object.entries(counts)) (map[id] ??= []).push({ board: meta.name, boardId: meta.id, cards });
219
+ }
220
+ return map;
89
221
  }
90
222
 
91
223
  function touch(project, patch, now) {
@@ -63,11 +63,20 @@ export type WallReading = {
63
63
  payoffs: Record<string, string[]>;
64
64
  /** Folded cards that pay off on another board of the project (R50): the card and the board. */
65
65
  later: { id: string; boardId: string }[];
66
+ /** The questions the wall asks now. A left one (R53) is not here while its words hold. */
66
67
  findings: Finding[];
68
+ /** Questions the writer has left, for now: the same question, with when it was left. */
69
+ left: Array<Finding & { since: string }>;
67
70
  };
68
71
 
69
72
  /** Rows top to bottom, cards left to right within a row. */
70
73
  export declare function readingOrder(notes: BoardNote[]): BoardNote[];
71
- export declare function readWall(state: BoardState): WallReading;
74
+ export declare function readWall(
75
+ state: BoardState,
76
+ options?: {
77
+ /** Cast ids on a card of another board of the project (R51): not asked about as uncast here. */
78
+ elsewhere?: string[];
79
+ },
80
+ ): WallReading;
72
81
  export declare function describeRuns(reading: WallReading, state: BoardState): string[];
73
82
  export declare function describeSetups(reading: WallReading, state: BoardState): string[];
@@ -110,7 +110,10 @@ function list(notes) {
110
110
  * Read the board. Returns the reading and the findings; see readWall.d.ts for
111
111
  * the shape. Never mutates the state.
112
112
  */
113
- export function readWall(state) {
113
+ export function readWall(state, options = {}) {
114
+ // People on a card of another board of the project (R51) are cast, and
115
+ // are not asked about here.
116
+ const elsewhere = new Set(Array.isArray(options.elsewhere) ? options.elsewhere : []);
114
117
  const order = readingOrder(state.notes);
115
118
  const beats = order.filter((note) => note.rank === "beat");
116
119
 
@@ -334,6 +337,7 @@ export function readWall(state) {
334
337
  for (const character of state.characters ?? []) {
335
338
  const scenes = order.filter((note) => note.characterIds?.includes(character.id));
336
339
  if (scenes.length === 0) {
340
+ if (elsewhere.has(character.id)) continue;
337
341
  findings.push({
338
342
  kind: "uncast",
339
343
  ids: [character.id],
@@ -372,6 +376,21 @@ export function readWall(state) {
372
376
  }
373
377
  }
374
378
 
379
+ // A question the writer has left (R53) is held back while it is still the
380
+ // same question — same kind, same cards, same words. The moment it would
381
+ // read differently (a page moved, a headline changed, the median shifted)
382
+ // it is a new question and is asked. The kernel never decides this; the
383
+ // reading does, on every read.
384
+ const left = [];
385
+ const asked = findings.filter((finding) => {
386
+ const entry = (state.left ?? []).find(
387
+ (item) => item.kind === finding.kind && sameList(item.ids, finding.ids) && item.text === finding.text,
388
+ );
389
+ if (!entry) return true;
390
+ left.push({ ...finding, since: entry.since });
391
+ return false;
392
+ });
393
+
375
394
  return {
376
395
  order: order.map((note) => note.id),
377
396
  beats: beats.map((note) => ({ id: note.id, headline: note.headline })),
@@ -379,10 +398,15 @@ export function readWall(state) {
379
398
  setups,
380
399
  payoffs,
381
400
  later,
382
- findings,
401
+ findings: asked,
402
+ left,
383
403
  };
384
404
  }
385
405
 
406
+ function sameList(a, b) {
407
+ return a.length === b.length && a.every((id, index) => id === b[index]);
408
+ }
409
+
386
410
  /** The setups as prose lines: what plants what, and how far apart. */
387
411
  export function describeSetups(reading, state) {
388
412
  const byId = new Map(state.notes.map((note) => [note.id, note]));
@@ -117,10 +117,25 @@ export type BoardState = {
117
117
  lock: import("./numbering").Lock | null;
118
118
  /** The revision in progress — a name, a colour, a snapshot — or null. */
119
119
  revision: import("./numbering").Revision | null;
120
+ /** Questions the writer has left, for now (R53): kept until the question would read differently. */
121
+ left: LeftQuestion[];
122
+ };
123
+
124
+ /** A question the wall asked and the writer left (R53). */
125
+ export type LeftQuestion = {
126
+ kind: string;
127
+ ids: string[];
128
+ /** The question's words when it was left; it comes back when they would differ. */
129
+ text: string;
130
+ since: string;
120
131
  };
121
132
 
122
133
  export type Pose = { id: string; x: number; y: number; rotate: number };
123
134
 
135
+ export declare function isCharacter(value: unknown): value is { id: string; name: string };
136
+ export declare function fillCharacter(character: { id: string; name: string } & Partial<BoardCharacter>): BoardCharacter;
137
+ export declare function sameName(a: string, b: string): boolean;
138
+
124
139
  export type Command =
125
140
  | { type: "set_logline"; logline: string }
126
141
  | { type: "set_rank"; ids: string[]; rank: NoteRank }
@@ -170,7 +185,9 @@ export type Command =
170
185
  | { type: "lock_numbers"; order?: string[] }
171
186
  | { type: "unlock_numbers" }
172
187
  | { type: "start_revision"; name: string; color?: string }
173
- | { type: "end_revision" };
188
+ | { type: "end_revision" }
189
+ | { type: "leave_question"; kind: string; ids: string[]; text: string }
190
+ | { type: "ask_again"; kind: string; ids?: string[] };
174
191
 
175
192
  export type CommandResult = {
176
193
  state: BoardState;
@@ -33,7 +33,7 @@ import { lockFrom, REVISION_COLORS } from "./numbering.js";
33
33
  // same person on every card. Long term the record grows — what they look like,
34
34
  // the details a writer needs to pull up — which is why it has an id and
35
35
  // timestamps now rather than being a word on a card.
36
- function isCharacter(value) {
36
+ export function isCharacter(value) {
37
37
  return Boolean(value) && typeof value.id === "string" && typeof value.name === "string";
38
38
  }
39
39
 
@@ -45,7 +45,7 @@ function isCharacter(value) {
45
45
  export const CHARACTER_FIELDS = ["looks", "voice", "wants", "needs", "notes"];
46
46
 
47
47
  /** A roster record with every page field present, so the page never reads undefined. */
48
- function fillCharacter(character) {
48
+ export function fillCharacter(character) {
49
49
  let filled = character;
50
50
  for (const field of CHARACTER_FIELDS) {
51
51
  if (typeof filled[field] !== "string") {
@@ -93,7 +93,7 @@ export function atPlace(note, place) {
93
93
  return Boolean(note.location) && samePlace(note.location, place);
94
94
  }
95
95
 
96
- function sameName(a, b) {
96
+ export function sameName(a, b) {
97
97
  return a.trim().toLowerCase() === b.trim().toLowerCase();
98
98
  }
99
99
 
@@ -101,6 +101,19 @@ function sameIds(a, b) {
101
101
  return a.length === b.length && a.every((id, index) => id === b[index]);
102
102
  }
103
103
 
104
+ /** A question the writer has left (R53): its kind, the cards it was about, and the words it had. */
105
+ function isLeftQuestion(value) {
106
+ return (
107
+ !!value &&
108
+ typeof value === "object" &&
109
+ typeof value.kind === "string" &&
110
+ Array.isArray(value.ids) &&
111
+ value.ids.every((id) => typeof id === "string") &&
112
+ typeof value.text === "string" &&
113
+ typeof value.since === "string"
114
+ );
115
+ }
116
+
104
117
  /** Keep only ids that name someone in the roster, once each, in the order given. */
105
118
  function knownCast(ids, characters) {
106
119
  if (!Array.isArray(ids)) return [];
@@ -213,6 +226,7 @@ export function emptyState() {
213
226
  arrows: [],
214
227
  lock: null,
215
228
  revision: null,
229
+ left: [],
216
230
  };
217
231
  }
218
232
 
@@ -259,6 +273,7 @@ export function seedState(now = nowIso()) {
259
273
  arrows: [],
260
274
  lock: null,
261
275
  revision: null,
276
+ left: [],
262
277
  };
263
278
  }
264
279
 
@@ -355,6 +370,10 @@ export function normalizeState(value) {
355
370
  // lock and no revision; both are null until a draft goes out.
356
371
  const lock = value.lock && typeof value.lock === "object" && value.lock.numbers ? value.lock : null;
357
372
  const revision = value.revision && typeof value.revision === "object" && typeof value.revision.name === "string" ? value.revision : null;
373
+ // Boards written before R53 have no left questions: nothing is left until
374
+ // the writer leaves it.
375
+ const left = Array.isArray(value.left) ? value.left.filter(isLeftQuestion) : [];
376
+ const leftPatched = !Array.isArray(value.left) || left.length !== value.left.length;
358
377
  if (
359
378
  value.logline === logline &&
360
379
  value.targetEighths === targetEighths &&
@@ -362,7 +381,8 @@ export function normalizeState(value) {
362
381
  !arrowsPatched &&
363
382
  !patched &&
364
383
  value.lock === lock &&
365
- value.revision === revision
384
+ value.revision === revision &&
385
+ !leftPatched
366
386
  ) {
367
387
  return value;
368
388
  }
@@ -375,6 +395,7 @@ export function normalizeState(value) {
375
395
  arrows: arrowsPatched ? arrows : value.arrows,
376
396
  lock,
377
397
  revision,
398
+ left,
378
399
  };
379
400
  }
380
401
 
@@ -879,6 +900,31 @@ export function applyCommand(state, command, now = nowIso()) {
879
900
  return { state: { ...state, revision: null }, changed: true, result: null };
880
901
  }
881
902
 
903
+ // Leaving a question (R53): the writer's word on a question the wall
904
+ // asks, written on the wall as the question's kind, the cards it was
905
+ // about and the words it had. The reading holds it back while a question
906
+ // with those words is still what the wall would ask, and asks again on
907
+ // its own the moment the question would read differently. Never a
908
+ // dismissal: the kernel records the word; the reading decides.
909
+ case "leave_question": {
910
+ const kind = typeof command.kind === "string" ? command.kind : "";
911
+ const ids = Array.isArray(command.ids) ? command.ids.filter((id) => typeof id === "string") : [];
912
+ const text = typeof command.text === "string" ? command.text : "";
913
+ if (!kind || !text) return { state, changed: false };
914
+ const entry = { kind, ids, text, since: now };
915
+ const rest = (state.left ?? []).filter((item) => !(item.kind === kind && sameIds(item.ids, ids)));
916
+ return { state: { ...state, left: [...rest, entry] }, changed: true, result: entry };
917
+ }
918
+
919
+ case "ask_again": {
920
+ const kind = typeof command.kind === "string" ? command.kind : "";
921
+ const ids = Array.isArray(command.ids) ? command.ids : null;
922
+ const gone = (state.left ?? []).filter((item) => item.kind === kind && (!ids || sameIds(item.ids, ids)));
923
+ if (gone.length === 0) return { state, changed: false };
924
+ const left = (state.left ?? []).filter((item) => !gone.includes(item));
925
+ return { state: { ...state, left }, changed: true, result: gone };
926
+ }
927
+
882
928
  // Where a scene happens (R37): one place on one or more cards; an empty
883
929
  // place clears it.
884
930
  case "set_location": {