plotcoder-board 0.1.30 → 0.1.31

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
@@ -104,4 +104,4 @@ next round into a test of `claim_account` instead of the door it meant to test.
104
104
 
105
105
  ## Status
106
106
 
107
- Version 0.1.30. 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.
107
+ Version 0.1.31. 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.30",
3
+ "version": "0.1.31",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite",
@@ -69,6 +69,8 @@ import {
69
69
  removeStructure,
70
70
  setActiveBoard,
71
71
  setPremise,
72
+ setPremiseOpen,
73
+ setBoardNameOpen,
72
74
  structureBeats,
73
75
  reidentifyProject,
74
76
  renameProject,
@@ -1063,7 +1065,7 @@ function summarize(state) {
1063
1065
  const snap = state.revision?.snapshot?.[note.id];
1064
1066
  const revised = snap && (snap.headline !== note.headline || snap.change !== note.change || (snap.text ?? "") !== (note.text ?? "") || (snap.location ?? "") !== (note.location ?? "")) ? `, changed in ${state.revision.color}` : "";
1065
1067
  const place = note.location ? `, at: ${note.location}` : "";
1066
- const when = note.when ? `, when: ${note.when}` : "";
1068
+ const when = note.when ? `, when: ${note.when}` : note.whenOpen ? `, when: open, by the writer's word — "${note.whenOpen}"` : "";
1067
1069
  const openWord = note.open ? `, open (the writer's words): "${note.open}"` : "";
1068
1070
  const count = formatPages(noteEighths(note));
1069
1071
  // A written card's estimate is kept underneath for when the text goes; say it, or it is invisible (round sixteen, entry 44).
@@ -1134,7 +1136,7 @@ function summarize(state) {
1134
1136
  const leftCount = (state.left ?? []).length;
1135
1137
  return [
1136
1138
  ...(isSampleWall(state) ? [SAMPLE_NOTE] : []),
1137
- `logline: ${state.logline ? `"${state.logline}"` : "(not set)"}`,
1139
+ `logline: ${state.loglineOpen ? `open, by the writer's word — "${state.loglineOpen}"` : state.logline ? `"${state.logline}"` : "(not set)"}`,
1138
1140
  ...production,
1139
1141
  `left, for now: ${leftCount ? `${leftCount} question(s) the writer left; read_wall lists them` : "none"}`,
1140
1142
  `beats: ${beats}, scenes: ${scenes}`,
@@ -1221,18 +1223,24 @@ server.registerTool(
1221
1223
  {
1222
1224
  title: "Set logline",
1223
1225
  description:
1224
- "Set the board's logline — the central question, what this story is arguing. One sentence. Every card on the wall should be checkable against it. Pass an empty string to clear it.",
1226
+ "Set the board's logline — the central question, what this story is arguing. One sentence. Every card on the wall should be checkable against it. Pass an empty string to clear it. Or leave it open: pass open with the writer's words for why there is no logline yet — \"two candidates, not chosen\" — and the reading lists it under open, by the writer's word, and asks nothing; text decides it and clears the words; open \"\" takes the words back and leaves the field blank. Only on the writer's word: an open field is theirs, never a guess of yours.",
1225
1227
  inputSchema: {
1226
- logline: z.string(),
1228
+ logline: z.string().optional(),
1229
+ open: z.string().optional(),
1227
1230
  },
1228
1231
  },
1229
1232
  async (args) => {
1230
- const { state, live } = await commit({ type: "set_logline", logline: args.logline });
1233
+ if (args.logline === undefined && args.open === undefined) return ok("Say which: logline (the sentence, or \"\" to clear it), or open (the writer's words for why there is none yet).");
1234
+ const { state, changed, live } = await commit({ type: "set_logline", ...(args.logline !== undefined ? { logline: args.logline } : {}), ...(args.open !== undefined ? { open: args.open } : {}) });
1235
+ if (!changed) return ok(args.logline === "" && !state.loglineOpen ? "Logline cleared." : "Logline unchanged: it already read that way.");
1236
+ if (state.loglineOpen) {
1237
+ return ok(`Logline left open, by the writer's word: "${state.loglineOpen}"${where(live)}. The reading lists it and asks nothing; set_logline with text decides it, open "" leaves it blank.`, { logline: state.logline, loglineOpen: state.loglineOpen });
1238
+ }
1231
1239
  return ok(
1232
1240
  state.logline
1233
1241
  ? `Logline set: "${state.logline}"${where(live)}.`
1234
- : "Logline cleared.",
1235
- { logline: state.logline },
1242
+ : `Logline cleared${where(live)}.`,
1243
+ { logline: state.logline, loglineOpen: state.loglineOpen },
1236
1244
  );
1237
1245
  },
1238
1246
  );
@@ -1563,11 +1571,19 @@ server.registerTool(
1563
1571
  const whose = runtimeKinds(state).replace(/^; /, "");
1564
1572
  // A beat's own pages are in no run (entry 48); say how many pages that is.
1565
1573
  const beatEighths = reading.beats.reduce((sum, beat) => sum + noteEighths(state.notes.find((note) => note.id === beat.id) ?? {}), 0);
1574
+ // Fields left open by the writer's word (R61): the logline and the whens are the reading's; the premise and the board's name are the project's.
1575
+ const openFieldLines = [
1576
+ ...reading.openFields.filter((field) => field.field === "logline").map((field) => ` - the logline — ${field.words}`),
1577
+ ...(projectForRead.premiseOpen ? [` - the premise — ${projectForRead.premiseOpen}`] : []),
1578
+ ...(readBoardMeta?.nameOpen ? [` - this board's name — ${readBoardMeta.nameOpen}`] : []),
1579
+ ...reading.openFields.filter((field) => field.field === "when").map((field) => ` - "${state.notes.find((note) => note.id === field.id)?.headline ?? field.id}" — when: ${field.words}`),
1580
+ ];
1566
1581
  const lines = [
1567
1582
  `PlotCoder wall (${door(live, base)})`,
1568
1583
  ...(state.lock ? [`numbers: locked since ${String(state.lock.at).slice(0, 10)}; read_pages shows each scene's number`] : []),
1569
- `board: "${readBoardMeta?.name ?? "?"}"${projectForRead.boards.length > 1 ? ` — board ${projectForRead.boards.findIndex((meta) => meta.id === readBoardMeta?.id) + 1} of ${projectForRead.boards.length} in the project "${projectForRead.name}"; open_board reads another` : ""}`,
1570
- `logline: ${state.logline ? `"${state.logline}"` : "(none yet)"}`,
1584
+ `board: "${readBoardMeta?.name ?? "?"}"${readBoardMeta?.nameOpen ? ` — its name is open, by the writer's word: "${readBoardMeta.nameOpen}"` : ""}${projectForRead.boards.length > 1 ? ` — board ${projectForRead.boards.findIndex((meta) => meta.id === readBoardMeta?.id) + 1} of ${projectForRead.boards.length} in the project "${projectForRead.name}"; open_board reads another` : ""}`,
1585
+ ...(projectForRead.premiseOpen ? [`premise: open, by the writer's word — "${projectForRead.premiseOpen}"`] : []),
1586
+ `logline: ${state.loglineOpen ? `open, by the writer's word — "${state.loglineOpen}"` : state.logline ? `"${state.logline}"` : "(none yet)"}`,
1571
1587
  "the cast and the places are list_board's, not the reading's",
1572
1588
  state.targetEighths === DEFAULT_TARGET_EIGHTHS
1573
1589
  ? `runtime: about ${formatPages(boardEighths(state))} pages (${whose || "no cards"}); no target set (set_target)`
@@ -1599,8 +1615,8 @@ server.registerTool(
1599
1615
  : [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)"]),
1600
1616
  ...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"}`),
1601
1617
  ...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`),
1602
- ...(reading.open.length
1603
- ? ["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}${item.hides.length ? ` (closed, it would be asked ${item.hides.map((kind) => ASK_WORDS[kind] ?? CHECK_WORDS[kind] ?? kind).join("; ")})` : ""}`)]
1618
+ ...(reading.open.length || openFieldLines.length
1619
+ ? ["open, by the writer's word (listed, not asked about while the words stand; set_open with \"\" closes a card, the field's own tool with open \"\" a field):", ...openFieldLines, ...reading.open.map((item) => ` - "${state.notes.find((note) => note.id === item.id)?.headline ?? item.id}" — ${item.words}${item.hides.length ? ` (closed, it would be asked ${item.hides.map((kind) => ASK_WORDS[kind] ?? CHECK_WORDS[kind] ?? kind).join("; ")})` : ""}`)]
1604
1620
  : []),
1605
1621
  ...(reading.threads.length
1606
1622
  ? ["threads (the writer's strings through the story; a loose end is asked about below):", ...reading.threads.map((thread) => ` - "${thread.name}": ${thread.ids.length ? thread.ids.map((id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`).join(" → ") : "no card yet"}${thread.startOpen ? " — starts nowhere yet" : ""}${thread.endOpen ? " — ends nowhere yet" : ""}`)]
@@ -1622,7 +1638,7 @@ server.registerTool(
1622
1638
  const counts = new Map();
1623
1639
  for (const finding of asked) counts.set(finding.kind, (counts.get(finding.kind) ?? 0) + 1);
1624
1640
  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}`;
1625
- })()}${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) => {
1641
+ })()}${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 || openFieldLines.length ? `; ${[reading.open.length ? `${reading.open.length} card${reading.open.length === 1 ? "" : "s"}` : "", openFieldLines.length ? `${openFieldLines.length} field${openFieldLines.length === 1 ? "" : "s"}` : ""].filter(Boolean).join(" and ")} 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) => {
1626
1642
  if (kind === "unlinked" && state.arrows.length === 0) return "no card without an arrow (not asked until half the cards are wired: no arrows yet)";
1627
1643
  if (kind === "unlinked") {
1628
1644
  const linked = new Set(state.arrows.flatMap((arrow) => [arrow.from, arrow.to]));
@@ -2708,16 +2724,21 @@ server.registerTool(
2708
2724
  {
2709
2725
  title: "Set when scenes happen",
2710
2726
  description:
2711
- "When one or more scenes happen, as the writer would say it — \"night\", \"day four, dawn\", \"the next morning\" — on the card beside its place, and printed after the place on every scene heading: THE PIER AT FENIT - NIGHT. Free text, the writer's phrase; an empty string clears it. This is where a scene's day and time live, not the headline, so the duplicate check never reads a day as a scene's words. create_note and update_note take when too; list_board shows it as when: …",
2712
- inputSchema: { ids: z.array(z.string()).min(1), when: z.string() },
2727
+ "When one or more scenes happen, as the writer would say it — \"night\", \"day four, dawn\", \"the next morning\" — on the card beside its place, and printed after the place on every scene heading: THE PIER AT FENIT - NIGHT. Free text, the writer's phrase; an empty string clears it. Or leave the when open: pass open with the writer's words for why it is not decided — \"after the break-in; which day\" — and the reading lists it under open, by the writer's word, while the card's other questions still stand; a when decides it, open \"\" leaves it blank. This is where a scene's day and time live, not the headline, so the duplicate check never reads a day as a scene's words. create_note and update_note take when too; list_board shows it as when: …",
2728
+ inputSchema: { ids: z.array(z.string()).min(1), when: z.string().optional(), open: z.string().optional() },
2713
2729
  },
2714
2730
  async (args) => {
2715
- const { state, changed, result, live } = await commit({ type: "set_when", ids: args.ids, when: args.when });
2731
+ if (args.when === undefined && args.open === undefined) return ok("Say which: when (the writer's phrase, or \"\" to clear it), or open (their words for why the when is not decided).");
2732
+ const { state, changed, result, live } = await commit({ type: "set_when", ids: args.ids, ...(args.when !== undefined ? { when: args.when } : {}), ...(args.open !== undefined ? { open: args.open } : {}) });
2716
2733
  if (!changed) {
2717
2734
  const missing = args.ids.filter((id) => !state.notes.some((note) => note.id === id));
2718
- 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" : "those cards already say"} "${args.when.trim()}".`);
2735
+ 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" : "those cards already say"} "${(args.when ?? args.open ?? "").trim()}".`);
2719
2736
  }
2720
2737
  const when = result[0]?.when ?? "";
2738
+ const whenOpen = result[0]?.whenOpen ?? "";
2739
+ if (whenOpen) {
2740
+ return ok(`${result.length} card(s) have their when left open, by the writer's word: "${whenOpen}"${where(live)}. The reading lists it and asks nothing; the heading prints no time; set_when with a when decides it, open "" leaves it blank. The card is still asked about everything else.${stillOpen(result)}`, result);
2741
+ }
2721
2742
  return ok(
2722
2743
  when
2723
2744
  ? `${result.length} card(s) now happen ${/^(at|on|in|by|the)\b/i.test(when) ? "" : "at "}"${when}"${where(live)}. The heading prints as ${sceneHeading(result[0]).slice(1)}.${stillOpen(result)}`
@@ -2964,7 +2985,7 @@ server.registerTool(
2964
2985
  });
2965
2986
  const total = parts.reduce((sum, part) => sum + part.on.length, 0);
2966
2987
  // An open card reads as open on a person's page too (round eighteen, entry 53).
2967
- const where_ = (note) => [note.location ? `at ${note.location}` : "", note.when ? note.when : "", note.rank === "beat" ? "beat" : "", note.open ? `open: "${note.open}"` : ""].filter(Boolean).join(" · ");
2988
+ const where_ = (note) => [note.location ? `at ${note.location}` : "", note.when ? note.when : note.whenOpen ? `when open: "${note.whenOpen}"` : "", note.rank === "beat" ? "beat" : "", note.open ? `open: "${note.open}"` : ""].filter(Boolean).join(" · ");
2968
2989
  // A read opens with the door it came through, like every reading (round sixteen, entry 28); one scene a line (29).
2969
2990
  const lines = [
2970
2991
  `PlotCoder cast (${door(live, base)})`,
@@ -3430,7 +3451,8 @@ function describeBoards(project, boards, changedAt = null) {
3430
3451
  ? `${state.notes.length} cards, about ${formatPages(boardEighths(normalizeState(state)))} of ${formatPages(normalizeState(state).targetEighths)} pages`
3431
3452
  : "no cards";
3432
3453
  const changed = changedAt?.[board.id] ? `, last changed ${changedAt[board.id]}` : "";
3433
- return ` ${index + 1}. ${board.id} "${board.name}"${open}: ${shape}${changed}`;
3454
+ const nameOpen = board.nameOpen ? ` (name open, by the writer's word: "${board.nameOpen}")` : "";
3455
+ return ` ${index + 1}. ${board.id} — "${board.name}"${nameOpen}${open}: ${shape}${changed}`;
3434
3456
  })
3435
3457
  .join("\n");
3436
3458
  }
@@ -3448,7 +3470,7 @@ server.registerTool(
3448
3470
  return ok(
3449
3471
  [
3450
3472
  `Project "${project.name}" (${door(live, base)})`,
3451
- `premise: ${project.premise ? `"${project.premise}"` : "(not set)"}`,
3473
+ `premise: ${project.premiseOpen ? `open, by the writer's word — "${project.premiseOpen}"` : project.premise ? `"${project.premise}"` : "(not set)"}`,
3452
3474
  `boards: ${project.boards.length}`,
3453
3475
  describeBoards(project, boards, changedAt),
3454
3476
  // The project's length as one line, so a series is not arithmetic by hand (round fifteen, entry 38).
@@ -3472,14 +3494,16 @@ server.registerTool(
3472
3494
  {
3473
3495
  title: "Set the project's premise",
3474
3496
  description:
3475
- "Set the project's premise: the line above every board's logline, held by the project whatever its board count — what a series is about, or what is true before a film starts ('the winter the shop closes'). An empty string clears it. Boards keep their own loglines.",
3476
- inputSchema: { premise: z.string() },
3497
+ "Set the project's premise: the line above every board's logline, held by the project whatever its board count — what a series is about, or what is true before a film starts ('the winter the shop closes'). An empty string clears it. Or leave it open: pass open with the writer's words for why there is no premise yet — \"the buyer: housing, or a supermarket\" — and the reading lists it under open, by the writer's word; a premise decides it, open \"\" leaves it blank. Boards keep their own loglines.",
3498
+ inputSchema: { premise: z.string().optional(), open: z.string().optional() },
3477
3499
  },
3478
3500
  async (args) => {
3501
+ if (args.premise === undefined && args.open === undefined) return ok("Say which: premise (the line, or \"\" to clear it), or open (the writer's words for why there is none yet).");
3479
3502
  const { project, boards, rev, base, live } = await readProject();
3480
- const next = setPremise(project, args.premise);
3503
+ const next = args.open !== undefined ? setPremiseOpen(project, args.open) : setPremise(project, args.premise);
3481
3504
  if (next === project) return ok("Premise unchanged.");
3482
3505
  await writeProject(next, boards, rev, base);
3506
+ if (next.premiseOpen) return ok(`Premise left open, by the writer's word: "${next.premiseOpen}"${where(live)}. The reading lists it and asks nothing; set_premise with a line decides it, open "" leaves it blank.`, next);
3483
3507
  return ok(`Premise ${next.premise ? `set to "${next.premise}"` : "cleared"}${where(live)}.`, next);
3484
3508
  },
3485
3509
  );
@@ -3678,14 +3702,16 @@ server.registerTool(
3678
3702
  {
3679
3703
  title: "Start a project",
3680
3704
  description:
3681
- "Through the account door: start a new project of the writer's with this name — one empty board, nothing on it — and work it from now on. The writer sees it under Projects on every device.",
3682
- inputSchema: { name: z.string().min(1), board: z.string().optional(), pages: pagesSchema.optional(), minutes: z.number().positive().optional() },
3705
+ "Through the account door: start a new project of the writer's with this name — one empty board, nothing on it — and work it from now on. The writer sees it under Projects on every device. board names the first board; boardOpen leaves its name open in the writer's words instead (\"the title, or Feature\"), so a board born from a maybe is not silently \"Board 1\".",
3706
+ inputSchema: { name: z.string().min(1), board: z.string().optional(), boardOpen: z.string().optional(), pages: pagesSchema.optional(), minutes: z.number().positive().optional() },
3683
3707
  },
3684
3708
  async (args) => {
3685
3709
  const account = await findAccount();
3686
3710
  if (!account) return shut("No account door: there is one project here, the open one. Set PLOTCODER_EMAIL and PLOTCODER_PASSWORD to start another on the writer's account.");
3687
3711
  let record = renameProject(emptyProject(), args.name.trim());
3688
3712
  if (args.board?.trim()) record = renameBoard(record, record.activeBoardId, args.board.trim());
3713
+ // A board born from a maybe is born open on its name (R61): boardOpen holds the writer's words.
3714
+ if (args.boardOpen?.trim()) record = setBoardNameOpen(record, record.activeBoardId, args.boardOpen);
3689
3715
  const inserted = await account.client.from("projects").insert({ id: record.id, record, reminders: null, rev: 1 });
3690
3716
  if (inserted.error) return ok(`Could not start the project: ${inserted.error.message}`);
3691
3717
  const target = args.pages ?? args.minutes;
@@ -3696,7 +3722,8 @@ server.registerTool(
3696
3722
  joinPresence(record.id);
3697
3723
  const targetLine = target === undefined ? ` Its target is ${formatPages(state.targetEighths)} pages, the default for a feature; set_target for a pilot or a half-hour, or pass pages or minutes here.` : ` Its target is ${formatPages(state.targetEighths)} pages.`;
3698
3724
  const first = record.boards[0];
3699
- return ok(`Started "${record.name}" (${record.id}) with its first board "${first.name}" (${first.id}), and working it now, as ${account.email}.${targetLine}${oneCallHint(record)}`, { id: record.id, name: record.name, boardId: first.id, boardName: first.name, targetEighths: state.targetEighths });
3725
+ const nameOpenLine = first.nameOpen ? ` The board's name is left open, by the writer's word: "${first.nameOpen}"; rename_board decides it.` : "";
3726
+ return ok(`Started "${record.name}" (${record.id}) with its first board "${first.name}" (${first.id}), and working it now, as ${account.email}.${targetLine}${nameOpenLine}${oneCallHint(record)}`, { id: record.id, name: record.name, boardId: first.id, boardName: first.name, boardNameOpen: first.nameOpen ?? "", targetEighths: state.targetEighths });
3700
3727
  },
3701
3728
  );
3702
3729
 
@@ -3894,19 +3921,19 @@ server.registerTool(
3894
3921
  {
3895
3922
  title: "New board",
3896
3923
  description:
3897
- "Add a board to the project and open it: an empty wall with the logline placeholder, under the same premise, with the same target length as the board that was open. Nothing else is touched — the other boards stay as they are. Name it for what it is: an episode, a draft, a story.",
3898
- inputSchema: { name: z.string().optional() },
3924
+ "Add a board to the project and open it: an empty wall with the logline placeholder, under the same premise, with the same target length as the board that was open. Nothing else is touched — the other boards stay as they are. Name it for what it is: an episode, a draft, a story; or pass open with the writer's words for why the name is not decided, and it is born open on its name.",
3925
+ inputSchema: { name: z.string().optional(), open: z.string().optional() },
3899
3926
  },
3900
3927
  async (args) => {
3901
3928
  const { project, boards, rev, base } = await readProject();
3902
3929
  const previous = boards[project.activeBoardId];
3903
3930
  const target =
3904
3931
  previous && isBoardState(previous) ? normalizeState(previous).targetEighths : undefined;
3905
- const { project: next, board } = addBoard(project, args.name ?? "");
3932
+ const { project: next, board } = addBoard(project, args.name ?? "", undefined, args.open ?? "");
3906
3933
  const fresh = { ...emptyState(), ...(target ? { targetEighths: target } : {}) };
3907
3934
  const { live } = await openBoardEverywhere(next, { ...boards, [board.id]: fresh }, rev, base, board.id);
3908
3935
  return ok(
3909
- `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} returns to "${project.boards.find((item) => item.id === project.activeBoardId)?.name ?? "the one before"}". 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." : ""}`,
3936
+ `Added "${board.name}" (${board.id})${board.nameOpen ? ` — its name left open, by the writer's word: "${board.nameOpen}"` : ""} 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} returns to "${project.boards.find((item) => item.id === project.activeBoardId)?.name ?? "the one before"}". 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." : ""}`,
3910
3937
  board,
3911
3938
  );
3912
3939
  },
@@ -3916,17 +3943,25 @@ server.registerTool(
3916
3943
  "rename_board",
3917
3944
  {
3918
3945
  title: "Rename board",
3919
- description: "Rename a board of the project by id, name, or number.",
3920
- inputSchema: { board: z.union([z.string().min(1), z.number()]), name: z.string().min(1) },
3946
+ description: "Rename a board of the project by id, name, or number. Or leave its name open: pass open with the writer's words for why it is not decided — \"the title, or Feature\" — and the name stands as it is (every reply still calls it that) while the reading lists the words; a name decides it, open \"\" takes the words back.",
3947
+ inputSchema: { board: z.union([z.string().min(1), z.number()]), name: z.string().min(1).optional(), open: z.string().optional() },
3921
3948
  },
3922
3949
  async (args) => {
3950
+ if (args.name === undefined && args.open === undefined) return ok("Say which: name, or open (the writer's words for why the name is not decided).");
3923
3951
  const { project, boards, rev, base } = await readProject();
3924
3952
  const target = findBoard(project, String(args.board));
3925
3953
  if (!target) return ok(`No board matches "${args.board}". Call list_boards for the real ones.`);
3954
+ if (args.name === undefined) {
3955
+ const next = setBoardNameOpen(project, target.id, args.open);
3956
+ if (next === project) return ok(`"${target.name}" already reads that way.`);
3957
+ const live = await writeProject(next, boards, rev, base);
3958
+ const words = boardById(next, target.id)?.nameOpen ?? "";
3959
+ return ok(words ? `Board "${target.name}" (${target.id}) keeps its name and its name is left open, by the writer's word: "${words}"${where(live)}. The reading lists it; rename_board with a name decides it.` : `Board "${target.name}" (${target.id}) is no longer open on its name${where(live)}.`, { id: target.id, name: target.name, nameOpen: words });
3960
+ }
3926
3961
  const next = renameBoard(project, target.id, args.name);
3927
3962
  if (next === project) return ok(`"${target.name}" already has that name.`);
3928
3963
  const live = await writeProject(next, boards, rev, base);
3929
- return ok(`Renamed board "${target.name}" (${target.id}) to "${args.name.trim()}"${where(live)}.`, { id: target.id, name: args.name.trim() });
3964
+ return ok(`Renamed board "${target.name}" (${target.id}) to "${args.name.trim()}"${where(live)}.${target.nameOpen ? " Its name is decided; the open words are gone." : ""}`, { id: target.id, name: args.name.trim() });
3930
3965
  },
3931
3966
  );
3932
3967
 
@@ -55,7 +55,7 @@ export const AGENTS = {
55
55
  "No opinions about how many beats there should be. Marking the turns a treatment plainly makes is reading it, not an opinion: mark them, say which, and let the writer strike or add.",
56
56
  "Page counts are estimates.",
57
57
  "Ask before delete_board, delete_project, empty_account, delete_account, unlock_numbers, remove_file, an import_project that replaces, or claim_account — the writer gives the email and the password; never invent one. export_project first, when something might be wanted back.",
58
- "Do not invent people or a logline. What the treatment states — an age, a job, a bad knee — is not invented: it goes in the person's notes. An unnamed person is named by their role — Dana's mother, the dispatcher — which is a name until the writer gives one. A scene is one place and one stretch of time; a new place or time is a new card. A beat is a whole card; a setup arrow lands on the scene's card, so a payoff never needs a card of its own. Acts are groups titled Act one, Act two, when the treatment has them; the wall never asks whether an act is a sequence. Paper colour means nothing to the app. Under target is a fact to report plainly, like over; neither is a verdict. A thing the writer has not decided is an open card in their words (set_open), never a guess to fill the field; a thing whose far end the writer knows and not where it is first seen — the letter, the ring — is a thread (create_thread) with an open start, and the wall asks from that end.",
58
+ "Do not invent people or a logline. What the treatment states — an age, a job, a bad knee — is not invented: it goes in the person's notes. An unnamed person is named by their role — Dana's mother, the dispatcher — which is a name until the writer gives one. A scene is one place and one stretch of time; a new place or time is a new card. A beat is a whole card; a setup arrow lands on the scene's card, so a payoff never needs a card of its own. Acts are groups titled Act one, Act two, when the treatment has them; the wall never asks whether an act is a sequence. Paper colour means nothing to the app. Under target is a fact to report plainly, like over; neither is a verdict. A thing the writer has not decided is an open card in their words (set_open), or an open field — the logline, the premise, a when, a board's name take open beside the value — never a guess to fill the field; a thing whose far end the writer knows and not where it is first seen — the letter, the ring — is a thread (create_thread) with an open start, and the wall asks from that end.",
59
59
  ],
60
60
  person:
61
61
  "Give your agent the account door only on a machine you trust; it signs in as you and shows under People as “an agent, as you” while it runs. Wire the server before you start the agent's session, with the two sign-in lines beside it, and the agent has every tool from its first message; wired from inside a session, the server connects only on the next one. Your agent can also make your account: give it your email and a password of your choosing.",
@@ -124,6 +124,7 @@ export function toFountain(state, options = {}) {
124
124
  if (cast.length) marks.push(`with ${cast.join(", ")}`);
125
125
  if (note.plants) marks.push("plants something to pay off later");
126
126
  if (note.open) marks.push(`open: ${note.open}`);
127
+ if (note.whenOpen) marks.push(`when open: ${note.whenOpen}`);
127
128
  const onThreads = (state.threads ?? []).filter((thread) => thread.noteIds.includes(note.id)).map((thread) => thread.name);
128
129
  if (onThreads.length) marks.push(`thread: ${onThreads.join(", ")}`);
129
130
  if (revisionMarksFor(marks, note)) marks.push(`changed in the ${state.revision.color} revision`);
@@ -8,6 +8,8 @@ export declare const DEFAULT_PROJECT_NAME: string;
8
8
  export type BoardMeta = {
9
9
  id: string;
10
10
  name: string;
11
+ /** The writer's words for why the name is not decided (R61), or empty; the name stands meanwhile. */
12
+ nameOpen: string;
11
13
  createdAt: string;
12
14
  updatedAt: string;
13
15
  };
@@ -18,6 +20,8 @@ export type ProjectRecord = {
18
20
  id: string;
19
21
  name: string;
20
22
  premise: string;
23
+ /** The writer's words for why there is no premise yet (R61), or empty. */
24
+ premiseOpen: string;
21
25
  boards: BoardMeta[];
22
26
  activeBoardId: string;
23
27
  /** A writer's own structures, saved from a wall's beats (Roadmap 2, item 7). */
@@ -41,7 +45,7 @@ export declare function addStructure(
41
45
  ): { project: ProjectRecord; structure: OwnStructure };
42
46
  export declare function removeStructure(project: ProjectRecord, id: string, now?: string): ProjectRecord;
43
47
 
44
- export declare function newBoardMeta(name: string, now?: string): BoardMeta;
48
+ export declare function newBoardMeta(name: string, now?: string, nameOpen?: string): BoardMeta;
45
49
  export declare function emptyProject(now?: string): ProjectRecord;
46
50
  export declare function isProjectRecord(value: unknown): value is ProjectRecord;
47
51
  export declare function normalizeProject(value: unknown, now?: string): ProjectRecord;
@@ -49,6 +53,7 @@ export declare function addBoard(
49
53
  project: ProjectRecord,
50
54
  name: string,
51
55
  now?: string,
56
+ nameOpen?: string,
52
57
  ): { project: ProjectRecord; board: BoardMeta };
53
58
  export declare function renameBoard(
54
59
  project: ProjectRecord,
@@ -66,6 +71,10 @@ export declare function moveBoard(
66
71
  export declare function setActiveBoard(project: ProjectRecord, id: string, now?: string): ProjectRecord;
67
72
  export declare function renameProject(project: ProjectRecord, name: string, now?: string): ProjectRecord;
68
73
  export declare function setPremise(project: ProjectRecord, premise: string, now?: string): ProjectRecord;
74
+ /** The writer's words for why there is no premise yet (R61); words clear the premise, "" takes them back. */
75
+ export declare function setPremiseOpen(project: ProjectRecord, words: string, now?: string): ProjectRecord;
76
+ /** The writer's words for why a board's name is not decided (R61); the name stands meanwhile. */
77
+ export declare function setBoardNameOpen(project: ProjectRecord, id: string, words: string, now?: string): ProjectRecord;
69
78
  export declare function boardById(project: ProjectRecord, id: string): BoardMeta | null;
70
79
  /** What a script going out is called: a named project is the title, its board beside it only when the project has several. */
71
80
  export declare function scriptTitles(project: ProjectRecord, board: BoardMeta | null | undefined): { title: string; episode?: string };
@@ -18,8 +18,13 @@ function trimmed(value, fallback) {
18
18
  return typeof value === "string" && value.trim() ? value.trim() : fallback;
19
19
  }
20
20
 
21
- export function newBoardMeta(name, now = nowIso()) {
22
- return { id: newId(), name: trimmed(name, "Board"), createdAt: now, updatedAt: now };
21
+ export function newBoardMeta(name, now = nowIso(), nameOpen = "") {
22
+ return { id: newId(), name: trimmed(name, "Board"), nameOpen: openWords(nameOpen), createdAt: now, updatedAt: now };
23
+ }
24
+
25
+ /** The writer's words for why a field is not decided (R61), one line, spaces collapsed; empty is decided or blank. */
26
+ function openWords(value) {
27
+ return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
23
28
  }
24
29
 
25
30
  export function emptyProject(now = nowIso()) {
@@ -29,6 +34,7 @@ export function emptyProject(now = nowIso()) {
29
34
  id: newId(),
30
35
  name: DEFAULT_PROJECT_NAME,
31
36
  premise: "",
37
+ premiseOpen: "",
32
38
  boards: [board],
33
39
  activeBoardId: board.id,
34
40
  createdAt: now,
@@ -60,6 +66,8 @@ export function normalizeProject(value, now = nowIso()) {
60
66
  const boards = value.boards.filter(isBoardMeta).map((board) => ({
61
67
  ...board,
62
68
  name: trimmed(board.name, "Board"),
69
+ // A board named before R61 has no open name; the name stands until the writer says it is not decided.
70
+ nameOpen: openWords(board.nameOpen),
63
71
  createdAt: typeof board.createdAt === "string" ? board.createdAt : now,
64
72
  updatedAt: typeof board.updatedAt === "string" ? board.updatedAt : now,
65
73
  }));
@@ -80,6 +88,8 @@ export function normalizeProject(value, now = nowIso()) {
80
88
  version: PROJECT_VERSION,
81
89
  name: trimmed(value.name, DEFAULT_PROJECT_NAME),
82
90
  premise: typeof value.premise === "string" ? value.premise.trim() : "",
91
+ // A project written before R61 has no open premise (R61).
92
+ premiseOpen: openWords(value.premiseOpen),
83
93
  boards,
84
94
  activeBoardId,
85
95
  structures,
@@ -261,9 +271,9 @@ function touch(project, patch, now) {
261
271
  }
262
272
 
263
273
  /** Add a board after the others and open it. Returns the project and the new board. */
264
- export function addBoard(project, name, now = nowIso()) {
274
+ export function addBoard(project, name, now = nowIso(), nameOpen = "") {
265
275
  const fallback = `Board ${project.boards.length + 1}`;
266
- const board = newBoardMeta(trimmed(name, fallback), now);
276
+ const board = newBoardMeta(trimmed(name, fallback), now, nameOpen);
267
277
  return {
268
278
  project: touch(project, { boards: [...project.boards, board], activeBoardId: board.id }, now),
269
279
  board,
@@ -275,9 +285,26 @@ export function renameBoard(project, id, name, now = nowIso()) {
275
285
  if (!next) return project;
276
286
  let changed = false;
277
287
  const boards = project.boards.map((board) => {
278
- if (board.id !== id || board.name === next) return board;
288
+ if (board.id !== id || (board.name === next && !(board.nameOpen ?? ""))) return board;
279
289
  changed = true;
280
- return { ...board, name: next, updatedAt: now };
290
+ // A name decides the field: the open words go (R61).
291
+ return { ...board, name: next, nameOpen: "", updatedAt: now };
292
+ });
293
+ return changed ? touch(project, { boards }, now) : project;
294
+ }
295
+
296
+ /**
297
+ * The writer's words for why a board's name is not decided (R61), or "" to
298
+ * take them back. The name stands as it is — "Board 1" is still what every
299
+ * reply calls it — but the reading lists the words and the crumb draws them.
300
+ */
301
+ export function setBoardNameOpen(project, id, words, now = nowIso()) {
302
+ const next = openWords(words);
303
+ let changed = false;
304
+ const boards = project.boards.map((board) => {
305
+ if (board.id !== id || (board.nameOpen ?? "") === next) return board;
306
+ changed = true;
307
+ return { ...board, nameOpen: next, updatedAt: now };
281
308
  });
282
309
  return changed ? touch(project, { boards }, now) : project;
283
310
  }
@@ -321,8 +348,18 @@ export function renameProject(project, name, now = nowIso()) {
321
348
 
322
349
  export function setPremise(project, premise, now = nowIso()) {
323
350
  const next = typeof premise === "string" ? premise.trim() : "";
324
- if (next === project.premise) return project;
325
- return touch(project, { premise: next }, now);
351
+ // A premise decides the field: the open words go (R61); clearing it leaves them.
352
+ const premiseOpen = next ? "" : (project.premiseOpen ?? "");
353
+ if (next === project.premise && premiseOpen === (project.premiseOpen ?? "")) return project;
354
+ return touch(project, { premise: next, premiseOpen }, now);
355
+ }
356
+
357
+ /** The writer's words for why there is no premise yet (R61), or "" to take them back; words clear the premise. */
358
+ export function setPremiseOpen(project, words, now = nowIso()) {
359
+ const next = openWords(words);
360
+ const premise = next ? "" : project.premise;
361
+ if (next === (project.premiseOpen ?? "") && premise === project.premise) return project;
362
+ return touch(project, { premise, premiseOpen: next }, now);
326
363
  }
327
364
 
328
365
  /**
@@ -71,6 +71,8 @@ export type WallReading = {
71
71
  later: { id: string; boardId: string; noteId: string | null }[];
72
72
  /** Open cards (R59): the writer's words for what is not decided, in story order; not asked about while they stand. */
73
73
  open: Array<{ id: string; words: string; hides: FindingKind[] }>;
74
+ /** Fields left open by the writer's word (R61): the board's logline, and each card's when, in story order. Listed, not asked. */
75
+ openFields: Array<{ field: "logline"; words: string } | { field: "when"; id: string; words: string }>;
74
76
  /** Threads (R60): each named string with its cards in story order and which ends are open. */
75
77
  threads: Array<{ id: string; name: string; ids: string[]; startOpen: boolean; endOpen: boolean }>;
76
78
  /** Cards here that pay off a fold of another board (R58), composed by the door from the project. */
@@ -557,6 +557,7 @@ export function readWall(state, options = {}) {
557
557
  later,
558
558
  paidBy: paidHere,
559
559
  open: describeOpen(state, options, order, openIds),
560
+ openFields: describeOpenFields(state, order),
560
561
  threads,
561
562
  findings: asked,
562
563
  left,
@@ -568,6 +569,19 @@ export function readWall(state, options = {}) {
568
569
  * closed (round eighteen, entry 27): the reading of the same wall with the
569
570
  * words cleared, read once more, so the writer can see what the words hide.
570
571
  */
572
+ /**
573
+ * The fields the writer has left open (R61), in the writer's words: the
574
+ * board's logline, and a card's when, in story order. The premise and the
575
+ * board's name live on the project, and the door adds them. Listed, never
576
+ * asked about: no check asks about a missing logline or when.
577
+ */
578
+ function describeOpenFields(state, order) {
579
+ const fields = [];
580
+ if ((state.loglineOpen ?? "").trim()) fields.push({ field: "logline", words: state.loglineOpen.trim() });
581
+ for (const note of order) if ((note.whenOpen ?? "").trim()) fields.push({ field: "when", id: note.id, words: note.whenOpen.trim() });
582
+ return fields;
583
+ }
584
+
571
585
  /** The question kinds an open card does not silence: about the story around it, not the card (R59, R60). */
572
586
  const ASKED_OF_OPEN_CARDS = new Set(["loose", "empty", "sag"]);
573
587
 
@@ -89,6 +89,8 @@ export type BoardNote = {
89
89
  location: string;
90
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. */
91
91
  when: string;
92
+ /** The writer's words for why the when is not decided (R61), or empty; while they stand the when is blank and the reading lists them. */
93
+ whenOpen: string;
92
94
  /** The scene's text in Fountain (R23 b): action, cues, dialogue; empty until written. */
93
95
  text: string;
94
96
  createdAt: string;
@@ -112,6 +114,8 @@ export type BoardArrow = {
112
114
  export type BoardState = {
113
115
  /** The board's central question — what this story is arguing (R19). */
114
116
  logline: string;
117
+ /** The writer's words for why there is no logline yet (R61), or empty. */
118
+ loglineOpen: string;
115
119
  /** Target script length in eighths of a page; 120 pages for a feature (R25). */
116
120
  targetEighths: number;
117
121
  /** The roster: every person in the story, whether or not they are on a card yet (R29). */
@@ -158,7 +162,7 @@ export declare function fillCharacter(character: { id: string; name: string } &
158
162
  export declare function sameName(a: string, b: string): boolean;
159
163
 
160
164
  export type Command =
161
- | { type: "set_logline"; logline: string }
165
+ | { type: "set_logline"; logline?: string; open?: string }
162
166
  | { type: "set_rank"; ids: string[]; rank: NoteRank }
163
167
  | { type: "set_length"; ids: string[]; lengthEighths: number | null }
164
168
  | { type: "set_target"; targetEighths: number }
@@ -177,6 +181,7 @@ export type Command =
177
181
  plants?: boolean;
178
182
  location?: string;
179
183
  when?: string;
184
+ whenOpen?: string;
180
185
  open?: string;
181
186
  text?: string;
182
187
  }
@@ -208,7 +213,7 @@ export type Command =
208
213
  | { type: "set_open"; ids: string[]; open: string }
209
214
  | { type: "set_payoff_board"; ids: string[]; boardId: string | null; noteId?: string | null }
210
215
  | { type: "set_location"; ids: string[]; location: string }
211
- | { type: "set_when"; ids: string[]; when: string }
216
+ | { type: "set_when"; ids: string[]; when?: string; open?: string }
212
217
  | { type: "apply_template"; template: string; beats?: Array<{ name: string; prompt: string; at: number }> }
213
218
  | { type: "set_text"; id: string; text: string }
214
219
  | { type: "lock_numbers"; order?: string[] }
@@ -220,6 +220,7 @@ export function emptyState() {
220
220
  return {
221
221
  logline: "",
222
222
  targetEighths: DEFAULT_TARGET_EIGHTHS,
223
+ loglineOpen: "",
223
224
  characters: [],
224
225
  notes: [],
225
226
  groups: [],
@@ -247,6 +248,8 @@ export function seedState(now = nowIso()) {
247
248
  characterIds,
248
249
  location: "",
249
250
  when: "",
251
+ // The writer's words for why the when is not decided (R61), or nothing.
252
+ whenOpen: "",
250
253
  text: "",
251
254
  plants: false,
252
255
  // A fold that pays off on another board — a later episode — names it here;
@@ -267,6 +270,7 @@ export function seedState(now = nowIso()) {
267
270
  // new writer finds out the logline is there at all.
268
271
  logline: "",
269
272
  targetEighths: DEFAULT_TARGET_EIGHTHS,
273
+ loglineOpen: "",
270
274
  // Two people, cast on the cards, so a new writer sees what the roster is for.
271
275
  characters: [
272
276
  fillCharacter({ id: "maya", name: "Maya", createdAt: now, updatedAt: now }),
@@ -362,6 +366,8 @@ export function normalizeState(value) {
362
366
  const location = typeof note?.location === "string" ? note.location : "";
363
367
  // Cards written before R55 have no when; a scene is at no time until it is.
364
368
  const when = typeof note?.when === "string" ? note.when : "";
369
+ // Cards written before R61 have no open when; a when is decided or blank until the writer says otherwise.
370
+ const whenOpen = typeof note?.whenOpen === "string" ? note.whenOpen : "";
365
371
  // Cards written before pages (R23 b) have no text; a scene is unwritten until it is.
366
372
  const text = typeof note?.text === "string" ? note.text : "";
367
373
  if (
@@ -376,12 +382,13 @@ export function normalizeState(value) {
376
382
  note.open === open &&
377
383
  note.location === location &&
378
384
  note.when === when &&
385
+ note.whenOpen === whenOpen &&
379
386
  note.text === text
380
387
  ) {
381
388
  return note;
382
389
  }
383
390
  patched = true;
384
- return { ...note, rank, lengthEighths, characterIds, plants, payoffBoardId, payoffNoteId, open, location, when, text };
391
+ return { ...note, rank, lengthEighths, characterIds, plants, payoffBoardId, payoffNoteId, open, location, when, whenOpen, text };
385
392
  });
386
393
 
387
394
  // Boards written before the production half (Roadmap 2, item 8) have no
@@ -411,8 +418,11 @@ export function normalizeState(value) {
411
418
  return { ...thread, noteIds, startOpen, endOpen };
412
419
  })
413
420
  .filter(Boolean);
421
+ // Boards written before R61 have no open logline; a logline is decided or blank until the writer says otherwise.
422
+ const loglineOpen = typeof value.loglineOpen === "string" ? value.loglineOpen : "";
414
423
  if (
415
424
  value.logline === logline &&
425
+ value.loglineOpen === loglineOpen &&
416
426
  value.targetEighths === targetEighths &&
417
427
  !rosterPatched &&
418
428
  !arrowsPatched &&
@@ -427,6 +437,7 @@ export function normalizeState(value) {
427
437
  return {
428
438
  ...value,
429
439
  logline,
440
+ loglineOpen,
430
441
  targetEighths,
431
442
  characters,
432
443
  notes: patched ? notes : value.notes,
@@ -474,10 +485,15 @@ function pruneGroups(groups) {
474
485
 
475
486
  export function applyCommand(state, command, now = nowIso()) {
476
487
  switch (command.type) {
488
+ // The logline, or the writer's words for why there is none yet (R61): a
489
+ // value clears the open words, open words clear the value, and open ""
490
+ // leaves the field blank.
477
491
  case "set_logline": {
478
- const logline = typeof command.logline === "string" ? command.logline.trim() : "";
479
- if (logline === (state.logline ?? "")) return { state, changed: false };
480
- return { state: { ...state, logline }, changed: true, result: { logline } };
492
+ const hasOpen = typeof command.open === "string";
493
+ const logline = hasOpen && command.open.trim() ? "" : typeof command.logline === "string" ? command.logline.trim() : (state.logline ?? "");
494
+ const loglineOpen = hasOpen ? cleanOpen(command.open) : logline ? "" : (state.loglineOpen ?? "");
495
+ if (logline === (state.logline ?? "") && loglineOpen === (state.loglineOpen ?? "")) return { state, changed: false };
496
+ return { state: { ...state, logline, loglineOpen }, changed: true, result: { logline, loglineOpen } };
481
497
  }
482
498
 
483
499
  case "create_note": {
@@ -501,7 +517,8 @@ export function applyCommand(state, command, now = nowIso()) {
501
517
  payoffNoteId: null,
502
518
  open: cleanOpen(command.open),
503
519
  location: cleanPlace(command.location),
504
- when: cleanWhen(command.when),
520
+ when: cleanOpen(command.whenOpen) ? "" : cleanWhen(command.when),
521
+ whenOpen: cleanOpen(command.whenOpen),
505
522
  text: typeof command.text === "string" ? command.text : "",
506
523
  z: maxZ(state.notes) + 1,
507
524
  createdAt: now,
@@ -1015,6 +1032,7 @@ export function applyCommand(state, command, now = nowIso()) {
1015
1032
  open: "",
1016
1033
  location: "",
1017
1034
  when: "",
1035
+ whenOpen: "",
1018
1036
  text: "",
1019
1037
  createdAt: now,
1020
1038
  updatedAt: now,
@@ -1121,11 +1139,21 @@ export function applyCommand(state, command, now = nowIso()) {
1121
1139
  case "set_when": {
1122
1140
  const ids = new Set(command.ids);
1123
1141
  if (ids.size === 0) return { state, changed: false };
1124
- const when = cleanWhen(command.when);
1142
+ // The when, or the writer's words for why there is none yet (R61): a
1143
+ // value clears the open words, open words clear the value, open "" leaves
1144
+ // the when blank.
1145
+ const hasOpen = typeof command.open === "string";
1146
+ const whenOpen = hasOpen ? cleanOpen(command.open) : null;
1147
+ const when = hasOpen ? (whenOpen ? "" : typeof command.when === "string" ? cleanWhen(command.when) : null) : cleanWhen(command.when);
1125
1148
  const touched = [];
1126
1149
  const notes = state.notes.map((note) => {
1127
- if (!ids.has(note.id) || note.when === when) return note;
1128
- const next = bump(note, { when }, now);
1150
+ if (!ids.has(note.id)) return note;
1151
+ const patch = {
1152
+ when: when === null ? (note.when ?? "") : when,
1153
+ whenOpen: whenOpen === null ? (when ? "" : (note.whenOpen ?? "")) : whenOpen,
1154
+ };
1155
+ if (patch.when === (note.when ?? "") && patch.whenOpen === (note.whenOpen ?? "")) return note;
1156
+ const next = bump(note, patch, now);
1129
1157
  touched.push(next);
1130
1158
  return next;
1131
1159
  });
@@ -66,7 +66,7 @@ export const WORD_GROUPS = [
66
66
  {
67
67
  id: "open",
68
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.",
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. A field can be open the same way — the logline, the premise, a card's when, a board's name — the words where the value would be, listed and not asked.",
70
70
  },
71
71
  {
72
72
  id: "thread",
@@ -24,15 +24,15 @@ export const WORKFLOWS = [
24
24
  // an agent asks the ones the treatment leaves open, and invents none.
25
25
  needs: [
26
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" },
27
+ { question: "What is the central question, in one sentence?", hint: "And if this is one episode of something, what is the series about? Not decided: either takes open with the writer's words, and the reading lists it.", tool: "set_logline, set_premise" },
28
28
  { question: "Which scenes are the turns?", hint: "Name them, say \"propose them and I will strike\", or say \"mark none yet\" — every card stays a scene and the reading asks once for a beat until you do.", tool: "set_rank" },
29
29
  { question: "Does it have acts?", hint: "If so, where does each break fall?", tool: "create_group" },
30
30
  { question: "Where does each scene happen?", hint: "In your own words. A scene that moves through one location is still one place.", tool: "set_location" },
31
- { question: "When does a scene happen, where that matters?", hint: "That night; the fourth of October. It goes beside the place, never in the headline.", tool: "set_when" },
31
+ { question: "When does a scene happen, where that matters?", hint: "That night; the fourth of October. It goes beside the place, never in the headline. Not decided: set_when with open and the writer's words, and the card is still asked about the rest.", tool: "set_when" },
32
32
  { question: "Who is in each scene, and what do we call them?", hint: "A full name, or a role for someone unnamed — the man in 42. And who is only spoken of, never in a scene? They go in someone's notes, not the cast.", tool: "add_character, cast, update_character" },
33
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. A thing whose far end you know and not its first sighting — the key, the bucket — is a thread with an open start.", tool: "set_plant with later, create_arrow; create_thread" },
34
34
  { question: "Which scenes do you already know run long or short?", hint: "A day in the story is not a page count; leave the rest unsized.", tool: "set_length" },
35
- { question: "What are the project and the board called?", hint: "The series, and this episode.", tool: "rename_project, rename_board" },
35
+ { question: "What are the project and the board called?", hint: "The series, and this episode. A board's name not decided: rename_board, new_project and new_board take open with the writer's words.", tool: "rename_project, rename_board" },
36
36
  { question: "What must not be invented?", hint: "Looks and voices are yours until you say; so is anything the treatment does not state.", tool: "update_character, later" },
37
37
  ],
38
38
  },