plotcoder-board 0.1.25 → 0.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,7 +41,7 @@ Every board verb goes through one command kernel, `src/board/reducer.js`, and th
41
41
 
42
42
  - **The wall.** Tap the words to type, drag the paper to move. Lasso to select, then Group. Drag a card's handle onto another card for an arrow. ⌘Z takes back any change, whichever door made it.
43
43
  - **`window.plotcoder`** on the page, for a console or a CDP session.
44
- - **The MCP server**, published to npm as `plotcoder-board`: `npx -y plotcoder-board@latest` is the server, `npx -y plotcoder-board@latest call <tool> '{json}'` one call from a shell, `npx -y plotcoder-board@latest serve` the hosted door on a port (a `Dockerfile` is here too). Inside the repo it is `scripts/plotcoder-mcp.mjs`, wired for Cursor in `.cursor/mcp.json` and for Claude Code in `.mcp.json` (run `npm ci` once first). A version tag (`v0.2.0`) publishes it, with `NPM_TOKEN` in the repo's secrets. Eighty-one tools: reading (`list_board`, `read_wall`, `read_pages`, `read_character`, `page_count`, `list_words`, `list_workflows`, `segment_brief`, `compare_structure`); the card, cast, place and when (`set_when`), group (with `add_to_group`) and arrow verbs, `set_logline`, `set_target`, `set_rank`, `set_plant` (with `later` and `at`) and `set_payoff`, `write_scene`, `edit_scene`, `move_scene`, `organize`, `apply_template` with `list_structures`, `save_structure`, `remove_structure`; `leave_question` and `ask_again`; `undo` and `redo`; the project's `list_boards`, `open_board`, `new_board`, `rename_board`, `delete_board`, `set_premise`, `rename_project` and the reminders; Fountain and Final Draft both ways, Markdown and plain text out (`export_markdown`, `export_text`); the production half (`lock_numbers`, `unlock_numbers`, `start_revision`, `end_revision`); the project as a file both ways (`export_project`, `import_project`); and, through the account door, `list_projects`, `open_project`, `new_project`, `delete_project`, `empty_account`, `delete_account`, `add_picture`, `add_take`, `list_takes`, `list_files`, `remove_file`, `build_segment`. If the dev app is open, a tool call lands on the wall within a second; if not, it edits the board file and the wall catches up on the next load.
44
+ - **The MCP server**, published to npm as `plotcoder-board`: `npx -y plotcoder-board@latest` is the server, `npx -y plotcoder-board@latest call <tool> '{json}'` one call from a shell, `npx -y plotcoder-board@latest serve` the hosted door on a port (a `Dockerfile` is here too). Inside the repo it is `scripts/plotcoder-mcp.mjs`, wired for Cursor in `.cursor/mcp.json` and for Claude Code in `.mcp.json` (run `npm ci` once first). A version tag (`v0.2.0`) publishes it, with `NPM_TOKEN` in the repo's secrets. Eighty-two tools: reading (`list_board`, `read_wall`, `read_pages`, `read_character`, `page_count`, `list_words`, `list_workflows`, `segment_brief`, `compare_structure`); the card, cast, place and when (`set_when`), group (with `add_to_group`) and arrow verbs, `set_logline`, `set_target`, `set_rank`, `set_open` (a card the writer has not decided), `set_plant` (with `later` and `at`) and `set_payoff`, `write_scene`, `edit_scene`, `move_scene`, `organize`, `apply_template` with `list_structures`, `save_structure`, `remove_structure`; `leave_question` and `ask_again`; `undo` and `redo`; the project's `list_boards`, `open_board`, `new_board`, `rename_board`, `delete_board`, `set_premise`, `rename_project` and the reminders; Fountain and Final Draft both ways, Markdown and plain text out (`export_markdown`, `export_text`); the production half (`lock_numbers`, `unlock_numbers`, `start_revision`, `end_revision`); the project as a file both ways (`export_project`, `import_project`); and, through the account door, `list_projects`, `open_project`, `new_project`, `delete_project`, `empty_account`, `delete_account`, `add_picture`, `add_take`, `list_takes`, `list_files`, `remove_file`, `build_segment`. If the dev app is open, a tool call lands on the wall within a second; if not, it edits the board file and the wall catches up on the next load.
45
45
  - **The account door.** With `PLOTCODER_EMAIL` and `PLOTCODER_PASSWORD` in the agent's environment — the writer's own — and no dev app answering, the same server works the writer's project on the account directly, and every change lands live on every open wall. `PLOTCODER_PROJECT` picks a project by name or id. No account yet? `claim_account` makes one with the writer's email and a password they chose. The on-ramp — the doors, what to call first, the rules — is in the app behind *Are you an agent? Start here* and served at [plotcoder.com/llms.txt](https://plotcoder.com/llms.txt), both from `src/board/agents.js`.
46
46
 
47
47
  An agent should call the tools, never fake mouse drags. The skill in `.cursor/skills/plotcoder-board/SKILL.md` says how; `.claude/skills/plotcoder-board` is a symlink to the same file.
@@ -100,4 +100,4 @@ next round into a test of `claim_account` instead of the door it meant to test.
100
100
 
101
101
  ## Status
102
102
 
103
- Version 0.1.25. A project of boards; sign in with your email and a password from the PlotCoder mark and your projects follow you to every device, share one with another writer by email and write it together live, or stay signed out and work on this device as before. Pages sit beside the wall: a scene's text lives on its card, measures it, paginates to the industry's rules, prints, goes out and comes in as Fountain or Final Draft, and goes out as Markdown or plain text for a collaborator in Google Docs. It installs as a progressive web app and opens offline; plotcoder.com serves over HTTPS. The wall, beats, card length, groups, arrows, pan and zoom, save and open, and the agent surface are in use.
103
+ Version 0.1.26. A project of boards; sign in with your email and a password from the PlotCoder mark and your projects follow you to every device, share one with another writer by email and write it together live, or stay signed out and work on this device as before. Pages sit beside the wall: a scene's text lives on its card, measures it, paginates to the industry's rules, prints, goes out and comes in as Fountain or Final Draft, and goes out as Markdown or plain text for a collaborator in Google Docs. It installs as a progressive web app and opens offline; plotcoder.com serves over HTTPS. The wall, beats, card length, groups, arrows, pan and zoom, save and open, and the agent surface are in use.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plotcoder-board",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite",
@@ -1039,11 +1039,12 @@ function summarize(state) {
1039
1039
  const revised = snap && (snap.headline !== note.headline || snap.change !== note.change || (snap.text ?? "") !== (note.text ?? "") || (snap.location ?? "") !== (note.location ?? "")) ? `, changed in ${state.revision.color}` : "";
1040
1040
  const place = note.location ? `, at: ${note.location}` : "";
1041
1041
  const when = note.when ? `, when: ${note.when}` : "";
1042
+ const openWord = note.open ? `, open: ${note.open}` : "";
1042
1043
  const count = formatPages(noteEighths(note));
1043
1044
  // A written card's estimate is kept underneath for when the text goes; say it, or it is invisible (round sixteen, entry 44).
1044
1045
  const underneath = isMeasured(note) && note.lengthEighths !== null ? `; the writer's estimate underneath: ${formatPages(note.lengthEighths)}` : "";
1045
1046
  const pages = isMeasured(note) ? `${count} ${count === "1" ? "page" : "pages"}, written${underneath}` : note.lengthEighths === null ? "about a page, unsized" : `${count} ${count === "1" ? "page" : "pages"}`;
1046
- return ` - ${note.id} [${note.rank ?? "scene"}, ${pages}${who}${place}${when}${plant}${pays}${revised}] — "${note.headline}" (${note.color}) at ${Math.round(note.x)},${Math.round(note.y)}`;
1047
+ return ` - ${note.id} [${note.rank ?? "scene"}, ${pages}${who}${place}${when}${openWord}${plant}${pays}${revised}] — "${note.headline}" (${note.color}) at ${Math.round(note.x)},${Math.round(note.y)}`;
1047
1048
  })
1048
1049
  .join("\n");
1049
1050
  const cast = state.characters
@@ -1311,6 +1312,7 @@ server.registerTool(
1311
1312
  before: z.string().optional().describe("Or before this card (id or headline)."),
1312
1313
  location: z.string().optional(),
1313
1314
  when: z.string().optional().describe('When the scene happens, as the writer says it — "night", "day four, dawn" — printed after the place on the scene heading.'),
1315
+ open: z.string().optional().describe("The writer's words for what is not decided about this card (R59) — \"where, and whether Ruth is there\" — so the card is born open: the reading lists it and asks nothing else of it until the words are cleared."),
1314
1316
  characters: z.array(z.string().min(1)).optional(),
1315
1317
  x: z.number().optional(),
1316
1318
  y: z.number().optional(),
@@ -1345,6 +1347,7 @@ server.registerTool(
1345
1347
  plants: args.plants,
1346
1348
  location: args.location,
1347
1349
  when: args.when,
1350
+ open: args.open,
1348
1351
  x: landing.x,
1349
1352
  y: landing.y,
1350
1353
  }).result;
@@ -1381,6 +1384,7 @@ server.registerTool(
1381
1384
  result?.plants ? "corner folded" : null,
1382
1385
  result?.location ? `at ${result.location}` : `no place yet${once("place", " (location here, or set_location)")}`,
1383
1386
  result?.when ? `when: ${result.when}` : null,
1387
+ result?.open ? `open: "${result.open}" (listed, not asked about)` : null,
1384
1388
  ].filter(Boolean).join(", ");
1385
1389
  // Where it landed matters only until the tidy, so the reply says the rule once and never the coordinates (round fourteen, entry 11).
1386
1390
  const placed = beside
@@ -1551,6 +1555,9 @@ server.registerTool(
1551
1555
  : [reading.paidBy.length ? " (no setup arrow on this board; what pays off a fold of another board is listed below)" : " (no arrow is marked as a setup)"]),
1552
1556
  ...reading.later.map((item) => ` - "${state.notes.find((note) => note.id === item.id)?.headline ?? item.id}" is folded and pays off later, on "${boardById(projectForRead, item.boardId)?.name ?? item.boardId}"${item.noteId ? `, at ${episodeLabel(projectForRead, boardsNow, item.boardId, item.noteId)} "${boardsNow[item.boardId]?.notes?.find((note) => note.id === item.noteId)?.headline ?? item.noteId}"` : " — no scene there claims it yet"}`),
1553
1557
  ...reading.paidBy.map((item) => ` - "${state.notes.find((note) => note.id === item.id)?.headline ?? item.id}" pays off "${item.fromHeadline}" from "${item.fromBoardName}" (${episodeLabel(projectForRead, boardsNow, item.fromBoardId, item.fromNoteId)}), one board earlier`),
1558
+ ...(reading.open.length
1559
+ ? ["open, by the writer's word (listed, not asked about while the words stand; set_open with \"\" closes):", ...reading.open.map((item) => ` - "${state.notes.find((note) => note.id === item.id)?.headline ?? item.id}" — ${item.words}`)]
1560
+ : []),
1554
1561
  "questions the wall raises:",
1555
1562
  ...(reading.findings.length
1556
1563
  ? reading.findings.map((finding) => ` - [${finding.kind}] ${finding.text}${finding.ids.length ? ` (ids: ${finding.ids.join(", ")})` : ""}`)
@@ -1568,7 +1575,7 @@ server.registerTool(
1568
1575
  const counts = new Map();
1569
1576
  for (const finding of asked) counts.set(finding.kind, (counts.get(finding.kind) ?? 0) + 1);
1570
1577
  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}`;
1571
- })()}${reading.left.length ? `; left by the writer, so not clean: ${[...new Set(reading.left.map((finding) => finding.kind))].map((kind) => `[${kind}]`).join(" ")}` : ""}; checked and clean: ${CHECKS.filter((kind) => !reading.findings.some((finding) => finding.kind === kind) && !reading.left.some((finding) => finding.kind === kind)).map((kind) => {
1578
+ })()}${reading.left.length ? `; left by the writer, so not clean: ${[...new Set(reading.left.map((finding) => finding.kind))].map((kind) => `[${kind}]`).join(" ")}` : ""}${reading.open.length ? `; ${reading.open.length} card${reading.open.length === 1 ? "" : "s"} open by the writer's word, not asked` : ""}; checked and clean: ${CHECKS.filter((kind) => !reading.findings.some((finding) => finding.kind === kind) && !reading.left.some((finding) => finding.kind === kind)).map((kind) => {
1572
1579
  if (kind === "unlinked" && state.arrows.length === 0) return "no card without an arrow (not asked until half the cards are wired: no arrows yet)";
1573
1580
  if (kind === "unlinked") {
1574
1581
  const linked = new Set(state.arrows.flatMap((arrow) => [arrow.from, arrow.to]));
@@ -2663,6 +2670,33 @@ server.registerTool(
2663
2670
  },
2664
2671
  );
2665
2672
 
2673
+ // The open card (R59): the writer's word that a card is not decided. The
2674
+ // per-card twin of leave_question — that one leaves a question, this one
2675
+ // leaves a card.
2676
+ server.registerTool(
2677
+ "set_open",
2678
+ {
2679
+ title: "Leave a card open",
2680
+ description:
2681
+ "Mark one or more cards open, with the writer's words for what is not decided — \"where, and whether Ruth is there\", \"the buyer\". An open card is listed by read_wall under its own head and asked nothing else of while the words stand: not its place, not its change line, not its arrows. It is still counted, in the order, and a page. open \"\" closes the card and its questions come back on their own. Only on the writer's word — a card is open because they said so, never because you could not decide; where the notes have two versions, ask, and if they say leave it, this is how.",
2682
+ inputSchema: { ids: z.array(z.string()).min(1), open: z.string() },
2683
+ },
2684
+ async (args) => {
2685
+ const { state, changed, result, live } = await commit({ type: "set_open", ids: args.ids, open: args.open });
2686
+ if (!changed) {
2687
+ const missing = args.ids.filter((id) => !state.notes.some((note) => note.id === id));
2688
+ return ok(missing.length ? `No card with id ${missing.join(", ")}. Call list_board for the real ids.` : `Nothing changed: ${args.ids.length === 1 ? "the card already says that" : "those cards already say that"}.`);
2689
+ }
2690
+ const words = result[0]?.open ?? "";
2691
+ return ok(
2692
+ words
2693
+ ? `${result.length} card(s) open: "${words}"${where(live)}. The reading lists ${result.length === 1 ? "it" : "them"} under "open, by the writer's word" and asks nothing else of ${result.length === 1 ? "it" : "them"} while the words stand; the card wears the words on its edge. set_open with "" closes it.`
2694
+ : `${result.length} card(s) closed${where(live)}: decided, so the wall's questions about ${result.length === 1 ? "it" : "them"} come back on their own.`,
2695
+ result,
2696
+ );
2697
+ },
2698
+ );
2699
+
2666
2700
  server.registerTool(
2667
2701
  "set_plant",
2668
2702
  {
@@ -67,6 +67,8 @@ export type WallReading = {
67
67
  payoffs: Record<string, string[]>;
68
68
  /** Folded cards that pay off on another board of the project (R50): the card, the board, and the scene there that claims it (R58) or null while the board is a promise. */
69
69
  later: { id: string; boardId: string; noteId: string | null }[];
70
+ /** Open cards (R59): the writer's words for what is not decided, in story order; not asked about while they stand. */
71
+ open: Array<{ id: string; words: string }>;
70
72
  /** Cards here that pay off a fold of another board (R58), composed by the door from the project. */
71
73
  paidBy: Array<{ id: string; fromBoardId: string; fromBoardName: string; fromNoteId: string; fromHeadline: string; fromColor: string }>;
72
74
  /** The questions the wall asks now. A left one (R53) is not here while its words hold. */
@@ -197,6 +197,10 @@ export function readWall(state, options = {}) {
197
197
  if (from !== null && cards > 0) runs.push({ from, to: null, eighths, cards, ids });
198
198
 
199
199
  const findings = [];
200
+ // Open cards (R59): the writer's word that a card is not decided. Listed,
201
+ // and asked nothing else while the words stand — the card's own "leave it".
202
+ const openIds = new Set(order.filter((note) => (note.open ?? "").trim()).map((note) => note.id));
203
+ const askable = (note) => !openIds.has(note.id);
200
204
  const byId = new Map(state.notes.map((note) => [note.id, note]));
201
205
  const headline = (id) => (id ? byId.get(id)?.headline ?? id : null);
202
206
  const position = new Map(order.map((note, index) => [note.id, index]));
@@ -282,7 +286,7 @@ export function readWall(state, options = {}) {
282
286
  // A card that says no place, once the writer has started placing cards.
283
287
  // One question however many there are; a wall with no places at all is a
284
288
  // wall the writer has not placed yet, and is not asked.
285
- const unplaced = order.filter((note) => !(note.location ?? "").trim());
289
+ const unplaced = order.filter((note) => askable(note) && !(note.location ?? "").trim());
286
290
  if (unplaced.length > 0 && unplaced.length < order.length) {
287
291
  findings.push({
288
292
  kind: "unplaced",
@@ -296,6 +300,7 @@ export function readWall(state, options = {}) {
296
300
 
297
301
  // A card that has not earned its place yet.
298
302
  for (const note of order) {
303
+ if (!askable(note)) continue;
299
304
  const change = (note.change ?? "").trim();
300
305
  const title = (note.headline ?? "").trim();
301
306
  if (title === "" || title === PLACEHOLDER_HEADLINE) {
@@ -320,7 +325,7 @@ export function readWall(state, options = {}) {
320
325
  linked.add(arrow.to);
321
326
  }
322
327
  if (state.notes.length > 0 && linked.size * 2 >= state.notes.length) {
323
- const loose = order.filter((note) => !linked.has(note.id));
328
+ const loose = order.filter((note) => askable(note) && !linked.has(note.id));
324
329
  if (loose.length > 0) {
325
330
  findings.push({
326
331
  kind: "unlinked",
@@ -433,7 +438,7 @@ export function readWall(state, options = {}) {
433
438
  // A card with nobody in it, once the wall has a cast (round seventeen,
434
439
  // entry 28): a scene nobody is in passed every check.
435
440
  if ((state.characters ?? []).length > 0) {
436
- const empty = order.filter((note) => !(note.characterIds ?? []).length);
441
+ const empty = order.filter((note) => askable(note) && !(note.characterIds ?? []).length);
437
442
  if (empty.length) {
438
443
  findings.push({
439
444
  kind: "nobody",
@@ -499,7 +504,10 @@ export function readWall(state, options = {}) {
499
504
  // it is a new question and is asked. The kernel never decides this; the
500
505
  // reading does, on every read.
501
506
  const left = [];
507
+ // A question whose every card is open is not asked (R59): the writer's word covers it.
508
+ const openCards = openIds.size ? findings.filter((finding) => finding.ids.some((id) => byId.has(id)) && finding.ids.filter((id) => byId.has(id)).every((id) => openIds.has(id))) : [];
502
509
  const asked = findings.filter((finding) => {
510
+ if (openCards.includes(finding)) return false;
503
511
  const entry = (state.left ?? []).find(
504
512
  (item) => item.kind === finding.kind && sameList(item.ids, finding.ids) && item.text === finding.text,
505
513
  );
@@ -516,6 +524,7 @@ export function readWall(state, options = {}) {
516
524
  payoffs,
517
525
  later,
518
526
  paidBy: paidHere,
527
+ open: order.filter((note) => openIds.has(note.id)).map((note) => ({ id: note.id, words: note.open.trim() })),
519
528
  findings: asked,
520
529
  left,
521
530
  };
@@ -83,6 +83,8 @@ export type BoardNote = {
83
83
  payoffBoardId: string | null;
84
84
  /** The scene on that board that pays it off (R58), or null while the board is only a promise. */
85
85
  payoffNoteId: string | null;
86
+ /** The writer's words for what is not decided about this card (R59), or empty: the reading lists an open card and asks nothing else of it. */
87
+ open: string;
86
88
  /** Where the scene happens (R37): a phrase in the writer's words; empty until set. */
87
89
  location: string;
88
90
  /** When the scene happens, as the writer says it — "night", "day four, dawn" — printed after the place on the heading (R55). Empty when unsaid. */
@@ -162,6 +164,7 @@ export type Command =
162
164
  plants?: boolean;
163
165
  location?: string;
164
166
  when?: string;
167
+ open?: string;
165
168
  text?: string;
166
169
  }
167
170
  | { type: "update_note"; id: string; headline?: string; change?: string; location?: string; when?: string }
@@ -186,6 +189,7 @@ export type Command =
186
189
  | ({ type: "update_character"; id: string } & Partial<Record<CharacterField, string>>)
187
190
  | { type: "set_cast"; ids: string[]; characterIds: string[] }
188
191
  | { type: "set_plant"; ids: string[]; plants: boolean }
192
+ | { type: "set_open"; ids: string[]; open: string }
189
193
  | { type: "set_payoff_board"; ids: string[]; boardId: string | null; noteId?: string | null }
190
194
  | { type: "set_location"; ids: string[]; location: string }
191
195
  | { type: "set_when"; ids: string[]; when: string }
@@ -253,6 +253,10 @@ export function seedState(now = nowIso()) {
253
253
  // does (R58); null while the board is a promise.
254
254
  payoffBoardId: null,
255
255
  payoffNoteId: null,
256
+ // Open: the writer's words for what is not decided about this card, or
257
+ // nothing (R59). While they stand the reading lists the card and asks
258
+ // nothing else of it.
259
+ open: "",
256
260
  createdAt: now,
257
261
  updatedAt: now,
258
262
  });
@@ -350,6 +354,8 @@ export function normalizeState(value) {
350
354
  const payoffBoardId = plants && typeof note?.payoffBoardId === "string" && note.payoffBoardId ? note.payoffBoardId : null;
351
355
  // Cards written before R58 name a board and no scene on it.
352
356
  const payoffNoteId = payoffBoardId && typeof note?.payoffNoteId === "string" && note.payoffNoteId ? note.payoffNoteId : null;
357
+ // Cards written before R59 are not open; a card claims to be decided until the writer says otherwise.
358
+ const open = typeof note?.open === "string" ? note.open : "";
353
359
  // Cards written before R37 have no place; a scene is nowhere until it is.
354
360
  const location = typeof note?.location === "string" ? note.location : "";
355
361
  // Cards written before R55 have no when; a scene is at no time until it is.
@@ -365,6 +371,7 @@ export function normalizeState(value) {
365
371
  note.plants === plants &&
366
372
  note.payoffBoardId === payoffBoardId &&
367
373
  note.payoffNoteId === payoffNoteId &&
374
+ note.open === open &&
368
375
  note.location === location &&
369
376
  note.when === when &&
370
377
  note.text === text
@@ -372,7 +379,7 @@ export function normalizeState(value) {
372
379
  return note;
373
380
  }
374
381
  patched = true;
375
- return { ...note, rank, lengthEighths, characterIds, plants, payoffBoardId, payoffNoteId, location, when, text };
382
+ return { ...note, rank, lengthEighths, characterIds, plants, payoffBoardId, payoffNoteId, open, location, when, text };
376
383
  });
377
384
 
378
385
  // Boards written before the production half (Roadmap 2, item 8) have no
@@ -423,6 +430,11 @@ function bump(note, patch, now) {
423
430
  return { ...note, ...patch, updatedAt: now };
424
431
  }
425
432
 
433
+ /** The writer's words for what is open about a card, one line, spaces collapsed; empty closes it (R59). */
434
+ function cleanOpen(value) {
435
+ return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
436
+ }
437
+
426
438
  /** A when as the writer typed it, one line, spaces collapsed. */
427
439
  function cleanWhen(value) {
428
440
  return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
@@ -459,6 +471,7 @@ export function applyCommand(state, command, now = nowIso()) {
459
471
  plants: command.plants === true,
460
472
  payoffBoardId: null,
461
473
  payoffNoteId: null,
474
+ open: cleanOpen(command.open),
462
475
  location: cleanPlace(command.location),
463
476
  when: cleanWhen(command.when),
464
477
  text: typeof command.text === "string" ? command.text : "",
@@ -917,6 +930,7 @@ export function applyCommand(state, command, now = nowIso()) {
917
930
  plants: false,
918
931
  payoffBoardId: null,
919
932
  payoffNoteId: null,
933
+ open: "",
920
934
  location: "",
921
935
  when: "",
922
936
  text: "",
@@ -1037,6 +1051,23 @@ export function applyCommand(state, command, now = nowIso()) {
1037
1051
  return { state: { ...state, notes }, changed: true, result: touched };
1038
1052
  }
1039
1053
 
1054
+ // Leave a card open (R59): the writer's words for what is not decided,
1055
+ // or nothing. A claim about the card, like rank: it never moves it.
1056
+ case "set_open": {
1057
+ const ids = new Set(command.ids);
1058
+ if (ids.size === 0) return { state, changed: false };
1059
+ const open = cleanOpen(command.open);
1060
+ const touched = [];
1061
+ const notes = state.notes.map((note) => {
1062
+ if (!ids.has(note.id) || (note.open ?? "") === open) return note;
1063
+ const next = bump(note, { open }, now);
1064
+ touched.push(next);
1065
+ return next;
1066
+ });
1067
+ if (touched.length === 0) return { state, changed: false };
1068
+ return { state: { ...state, notes }, changed: true, result: touched };
1069
+ }
1070
+
1040
1071
  // Fold the corner (R31): this card plants something. A claim about the
1041
1072
  // card, like rank, so it never moves it and never touches its arrows.
1042
1073
  case "set_plant": {
@@ -63,6 +63,11 @@ export const WORD_GROUPS = [
63
63
  sentence: "The second line. If nothing is different after the scene, the scene is usually decoration.",
64
64
  target: "change",
65
65
  },
66
+ {
67
+ id: "open",
68
+ name: "Open",
69
+ sentence: "A card the writer has not decided, in their words on its edge: the reading lists it and asks nothing else of it until the words are cleared.",
70
+ },
66
71
  {
67
72
  id: "corner",
68
73
  name: "The folded corner",