plotcoder-board 0.1.33 → 0.1.35

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
@@ -51,7 +51,7 @@ An agent should call the tools, never fake mouse drags. The skill in `.cursor/sk
51
51
  A **blind run** is a fresh agent given the on-ramp and a treatment and nothing
52
52
  else, asked to build a wall and to keep a log of everything that made the job
53
53
  harder than it should have been. The friction log is the product; the wall is
54
- just what produces it. Nineteen rounds have been run, the first three through a
54
+ just what produces it. Twenty rounds have been run, the first three through a
55
55
  repo checkout and the rest through the account door; every finding from the
56
56
  first seventeen is fixed or decided. Round seventeen, the first to start from
57
57
  a page of notes instead of a treatment, found the wall has no way to hold a
@@ -59,7 +59,8 @@ maybe — the open card, R59, built from it; round eighteen confirmed that a
59
59
  thread is not a fold — the thread, R60, built from it, a named string through
60
60
  cards with either end open until the writer ties it; round nineteen measured
61
61
  the thread and found the fold beside it cannot say which of two things a
62
- scene plants. [`blind-runs/`](blind-runs/) holds the rules that keep a round honest,
62
+ scene plants the open field, R61, and the fold's words, R62, built from it,
63
+ and round twenty measured both. [`blind-runs/`](blind-runs/) holds the rules that keep a round honest,
63
64
  the table of rounds, and the next round's prompt with the test account filled in.
64
65
 
65
66
  A round works a **test account** — a throwaway marked on its writer row, and the
@@ -104,4 +105,4 @@ next round into a test of `claim_account` instead of the door it meant to test.
104
105
 
105
106
  ## Status
106
107
 
107
- Version 0.1.33. 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.
108
+ Version 0.1.35. 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.33",
3
+ "version": "0.1.35",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite",
@@ -71,6 +71,7 @@ import {
71
71
  setPremise,
72
72
  setPremiseOpen,
73
73
  setBoardNameOpen,
74
+ setProjectNameOpen,
74
75
  structureBeats,
75
76
  reidentifyProject,
76
77
  renameProject,
@@ -995,16 +996,16 @@ const CHECK_WORDS = {
995
996
  };
996
997
  /** What an open card would be asked once closed, as the question and not the check's clean form (round nineteen, entry 31). */
997
998
  const ASK_WORDS = {
998
- unwritten: "its change line",
999
- unlinked: "what comes before and after it",
1000
- duplicate: "which of two alike headlines it is",
999
+ unwritten: "its change line (update_note)",
1000
+ unlinked: "what comes before and after it (move_scene, or create_arrow)",
1001
+ duplicate: "which of two alike headlines it is (update_note)",
1001
1002
  sequence: "whether its group is one sequence",
1002
- nobody: "who is in it",
1003
+ nobody: "who is in it (cast)",
1003
1004
  absent: "who has been gone too long",
1004
1005
  backwards: "why its payoff comes before its setup",
1005
- unpaid: "where its fold pays off",
1006
- unplanted: "what plants its payoff",
1007
- unplaced: "where it happens",
1006
+ unpaid: "where its fold pays off (create_arrow, kind setup)",
1007
+ unplanted: "what plants its payoff (set_plant on the card that does)",
1008
+ unplaced: "where it happens (set_location)",
1008
1009
  };
1009
1010
  const SAMPLE_NOTE = "sample: this is the wall PlotCoder starts with (Maya, Tom, the letter); nothing here is the writer's. Replace it, or new_board.";
1010
1011
 
@@ -1065,7 +1066,7 @@ function summarize(state) {
1065
1066
  const pays = paidByHere.filter((item) => item.id === note.id).map((item) => `, pays off "${item.fromHeadline}" from "${item.fromBoardName}" (${episodeLabel(lastHeld.project, lastHeld.boards, item.fromBoardId, item.fromNoteId)})`).join("");
1066
1067
  const snap = state.revision?.snapshot?.[note.id];
1067
1068
  const revised = snap && (snap.headline !== note.headline || snap.change !== note.change || (snap.text ?? "") !== (note.text ?? "") || (snap.location ?? "") !== (note.location ?? "")) ? `, changed in ${state.revision.color}` : "";
1068
- const place = note.location ? `, at: ${note.location}` : "";
1069
+ const place = note.location ? `, at: ${note.location}` : note.locationOpen ? `, at: open, by the writer's word — "${note.locationOpen}"` : "";
1069
1070
  const when = note.when ? `, when: ${note.when}` : note.whenOpen ? `, when: open, by the writer's word — "${note.whenOpen}"` : "";
1070
1071
  const openWord = note.open ? `, open (the writer's words): "${note.open}"` : "";
1071
1072
  const count = formatPages(noteEighths(note));
@@ -1143,7 +1144,7 @@ function summarize(state) {
1143
1144
  `beats: ${beats}, scenes: ${scenes}`,
1144
1145
  state.targetEighths === DEFAULT_TARGET_EIGHTHS
1145
1146
  ? `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${runtimeKinds(state)}`
1146
- : `runtime: about ${formatPages(runtime)} pages of the ${formatPages(state.targetEighths)}-page target the writer set (set_target changes it) — ${over > 0 ? `${formatPages(over)} over` : over < 0 ? `${formatPages(-over)} under` : "on it"} (an estimate from the cards; a page runs about a minute)${runtimeKinds(state)}`,
1147
+ : `runtime: about ${formatPages(runtime)} pages of the ${formatPages(state.targetEighths)}-page target the writer set (set_target changes it) — ${over > 0 ? `${formatPages(over)} over` : over < 0 ? `${formatPages(-over)} under` : "on it"} (an estimate from the cards; a page runs about a minute; page_count is the script so far)${runtimeKinds(state)}`,
1147
1148
  `notes: ${state.notes.length}, groups: ${state.groups.length}, arrows: ${state.arrows.length}, cast: ${state.characters.length}`,
1148
1149
  "cards (in story order — the follows arrows over the rows; each with its id):",
1149
1150
  notes || " (no cards)",
@@ -1344,7 +1345,8 @@ server.registerTool(
1344
1345
  rank: rankSchema.optional(),
1345
1346
  pages: pagesSchema.optional(),
1346
1347
  plants: z.boolean().optional(),
1347
- after: z.string().optional().describe("Wire the new scene into the story after this card (id or headline): one call, one number under a lock. Needs follows arrows on the wall."),
1348
+ plantsWhat: z.string().optional(),
1349
+ after: z.string().optional().describe("Wire the new scene into the story after this card (id or headline): one call, one number under a lock. On a wall with no follows arrows yet this draws the first, so a wall can be built in order from its second card."),
1348
1350
  before: z.string().optional().describe("Or before this card (id or headline)."),
1349
1351
  location: z.string().optional(),
1350
1352
  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.'),
@@ -1575,21 +1577,38 @@ server.registerTool(
1575
1577
  const beatEighths = reading.beats.reduce((sum, beat) => sum + noteEighths(state.notes.find((note) => note.id === beat.id) ?? {}), 0);
1576
1578
  // 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.
1577
1579
  const openFieldLines = [
1580
+ ...(projectForRead.nameOpen ? [` - the project's name — ${projectForRead.nameOpen}`] : []),
1578
1581
  ...reading.openFields.filter((field) => field.field === "logline").map((field) => ` - the logline — ${field.words}`),
1579
1582
  ...(projectForRead.premiseOpen ? [` - the premise — ${projectForRead.premiseOpen}`] : []),
1580
1583
  ...(readBoardMeta?.nameOpen ? [` - this board's name — ${readBoardMeta.nameOpen}`] : []),
1581
- ...reading.openFields.filter((field) => field.field === "when").map((field) => ` - "${state.notes.find((note) => note.id === field.id)?.headline ?? field.id}" — when: ${field.words}`),
1584
+ ...reading.openFields.filter((field) => field.field === "location").map((field) => ` - "${state.notes.find((note) => note.id === field.id)?.headline ?? field.id}" — where: ${field.words}`),
1585
+ // Whens left open in the same words are one line, not one per card (round twenty, entry 22).
1586
+ ...(() => {
1587
+ const byWords = new Map();
1588
+ for (const field of reading.openFields.filter((item) => item.field === "when")) {
1589
+ const key = field.words.toLowerCase();
1590
+ if (!byWords.has(key)) byWords.set(key, { words: field.words, ids: [] });
1591
+ byWords.get(key).ids.push(field.id);
1592
+ }
1593
+ const name = (id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`;
1594
+ return [...byWords.values()].map((group) =>
1595
+ group.ids.length === 1
1596
+ ? ` - ${name(group.ids[0])} — when: ${group.words}`
1597
+ : ` - when, on ${group.ids.length} cards${group.ids.length === state.notes.length ? " (every card)" : ""} — ${group.words}${group.ids.length === state.notes.length ? "" : `: ${group.ids.map(name).join(", ")}`}`,
1598
+ );
1599
+ })(),
1582
1600
  ];
1583
1601
  const lines = [
1584
1602
  `PlotCoder wall (${door(live, base)})`,
1585
1603
  ...(state.lock ? [`numbers: locked since ${String(state.lock.at).slice(0, 10)}; read_pages shows each scene's number`] : []),
1586
1604
  `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` : ""}`,
1605
+ ...(projectForRead.nameOpen ? [`project: "${projectForRead.name}" — its name is open, by the writer's word: "${projectForRead.nameOpen}"`] : []),
1587
1606
  ...(projectForRead.premiseOpen ? [`premise: open, by the writer's word — "${projectForRead.premiseOpen}"`] : []),
1588
1607
  `logline: ${state.loglineOpen ? `open, by the writer's word — "${state.loglineOpen}"` : state.logline ? `"${state.logline}"` : "(none yet)"}`,
1589
1608
  "the cast and the places are list_board's, not the reading's",
1590
1609
  state.targetEighths === DEFAULT_TARGET_EIGHTHS
1591
1610
  ? `runtime: about ${formatPages(boardEighths(state))} pages (${whose || "no cards"}); no target set (set_target)`
1592
- : `runtime: about ${formatPages(boardEighths(state))} pages of the ${formatPages(state.targetEighths)}-page target the writer set (set_target changes it) — ${boardEighths(state) > state.targetEighths ? `${formatPages(boardEighths(state) - state.targetEighths)} over` : boardEighths(state) < state.targetEighths ? `${formatPages(state.targetEighths - boardEighths(state))} under` : "on it"} (${whose || "no cards"}; page_count is the script so far)`,
1611
+ : `runtime: about ${formatPages(boardEighths(state))} pages of the ${formatPages(state.targetEighths)}-page target the writer set (set_target changes it) — ${boardEighths(state) > state.targetEighths ? `${formatPages(boardEighths(state) - state.targetEighths)} over` : boardEighths(state) < state.targetEighths ? `${formatPages(state.targetEighths - boardEighths(state))} under` : "on it"} (${whose || "no cards"}; page_count is the script so far)`,
1593
1612
  `groups: ${
1594
1613
  state.groups.length
1595
1614
  ? state.groups
@@ -1621,7 +1640,7 @@ server.registerTool(
1621
1640
  ? ["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("; ")})` : ""}`)]
1622
1641
  : []),
1623
1642
  ...(reading.threads.length
1624
- ? ["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" : ""}`)]
1643
+ ? ["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" : ""}${!thread.startOpen && !thread.endOpen && thread.ids.length >= 2 ? ` — both ends tied, about ${formatPages(thread.apart)} pages apart` : ""}`)]
1625
1644
  : []),
1626
1645
  "questions the wall raises (each stands on every reading until the wall changes to answer it, or the writer leaves it — leave_question, with their reason):",
1627
1646
  ...(reading.findings.length
@@ -2024,11 +2043,15 @@ server.registerTool(
2024
2043
  async () => {
2025
2044
  const { project } = await readProject();
2026
2045
  const own = project.structures ?? [];
2046
+ // Pages beside the percentages, against the open board's target, so the two readings agree without arithmetic (round twenty, entry 57).
2047
+ const { state } = await readBoard();
2048
+ const target = state?.targetEighths ?? DEFAULT_TARGET_EIGHTHS;
2049
+ const at = (beat) => `${beat.name} at ${Math.round(beat.at * 100)}% (p. ${Math.floor((beat.at * target) / EIGHTHS_PER_PAGE) + 1})`;
2027
2050
  const lines = [
2028
2051
  `the writer's own: ${own.length}`,
2029
- ...own.map((structure) => ` - ${structure.id} — "${structure.name}" (${structure.beats.length} beats: ${structure.beats.map((beat) => `${beat.name} at ${Math.round(beat.at * 100)}%`).join(", ")})`),
2052
+ ...own.map((structure) => ` - ${structure.id} — "${structure.name}" (${structure.beats.length} beats: ${structure.beats.map(at).join(", ")})`),
2030
2053
  `built in: ${TEMPLATES.length}`,
2031
- ...TEMPLATES.map((template) => ` - ${template.id} — "${template.name}" (${template.beats.length} beats: ${template.beats.map((beat) => `${beat.name} at ${Math.round(beat.at * 100)}%`).join(", ")})`),
2054
+ ...TEMPLATES.map((template) => ` - ${template.id} — "${template.name}" (${template.beats.length} beats: ${template.beats.map(at).join(", ")})`),
2032
2055
  "compare_structure sets one of these beside this wall's beats, page by page, and lays nothing",
2033
2056
  ];
2034
2057
  return ok(lines.join("\n"), { builtIn: TEMPLATES.map((template) => ({ id: template.id, name: template.name, beats: template.beats })), own });
@@ -2057,10 +2080,11 @@ server.registerTool(
2057
2080
  const allMeasured = state.notes.length > 0 && state.notes.every((note) => isMeasured(note));
2058
2081
  const short = state.targetEighths > 0 && boardEighths(state) * 2 < state.targetEighths;
2059
2082
  const lines = [
2060
- `"${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}, the page its last card ends on — read_wall counts the same cards as about ${formatPages(boardEighths(state))} pages${allMeasured ? ", measured" : ", an estimate: unsized cards read as a page each"}); a match is the nearest of the wall's beats within ${MATCH_PAGES} pages, one to one and in order:`,
2083
+ `"${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}, the page its last card ends on — read_wall counts the same cards as about ${formatPages(boardEighths(state))} pages, and page_count the script so far${allMeasured ? ", measured" : ", an estimate: unsized cards read as a page each"}); a match is the nearest of the wall's beats within ${MATCH_PAGES} pages, one to one and in order:`,
2061
2084
  // On a wall under half its target the pairing is arithmetic; say so before the rows, not after them (round eighteen, entry 52).
2062
2085
  ...(short ? [`the wall runs to less than half its target, so its beats sit early and the ${MATCH_PAGES}-page window pairs them with the structure's first beats by arithmetic; the pairing says more once the cards are sized or written, and whether a turn is missing is the writer's call, not this reading's`] : []),
2063
- ...describeComparison(comparison).map((line) => ` - ${line}`),
2086
+ // On a thin wall a match is arithmetic, and the row says so where it says "here" (round twenty, entry 54).
2087
+ ...describeComparison(comparison).map((line) => ` - ${short ? line.replace(/\bhere\b/, "here, by arithmetic") : line}`),
2064
2088
  comparison.unmatched.length
2065
2089
  ? `beats of the wall no beat of the structure answers: ${comparison.unmatched.map((beat) => `"${beat.headline}" (p. ${beat.page})`).join(", ")}`
2066
2090
  : "every beat of the wall answers one of the structure's",
@@ -2726,7 +2750,7 @@ server.registerTool(
2726
2750
  {
2727
2751
  title: "Set when scenes happen",
2728
2752
  description:
2729
- "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: …",
2753
+ "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 party; which night\" — 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: …",
2730
2754
  inputSchema: { ids: z.array(z.string()).min(1), when: z.string().optional(), open: z.string().optional() },
2731
2755
  },
2732
2756
  async (args) => {
@@ -2990,12 +3014,15 @@ server.registerTool(
2990
3014
  });
2991
3015
  const total = parts.reduce((sum, part) => sum + part.on.length, 0);
2992
3016
  // An open card reads as open on a person's page too (round eighteen, entry 53).
2993
- 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(" · ");
3017
+ const where_ = (note) => [note.location ? `at ${note.location}` : note.locationOpen ? `where open: "${note.locationOpen}"` : "", note.when ? note.when : note.whenOpen ? `when open: "${note.whenOpen}"` : "", note.rank === "beat" ? "beat" : "", note.open ? `open: "${note.open}"` : ""].filter(Boolean).join(" · ");
2994
3018
  // A read opens with the door it came through, like every reading (round sixteen, entry 28); one scene a line (29).
2995
3019
  const lines = [
2996
3020
  `PlotCoder cast (${door(live, base)})`,
2997
3021
  `${person.name} (${person.id}) — on ${total} card${total === 1 ? "" : "s"} across ${project.boards.length} board${project.boards.length === 1 ? "" : "s"} of the project`,
2998
- ...CHARACTER_FIELDS.map((field) => ` ${field}: ${(person[field] ?? "").trim() || "(empty)"}`),
3022
+ // Four empty lines say the same thing four times (round twenty, entry 56): once is enough, and it says what empty means.
3023
+ ...(CHARACTER_FIELDS.every((field) => !(person[field] ?? "").trim())
3024
+ ? [` ${CHARACTER_FIELDS.join(", ")}: (empty — nothing given yet, nothing invented; update_character fills a line)`]
3025
+ : CHARACTER_FIELDS.map((field) => ` ${field}: ${(person[field] ?? "").trim() || "(empty)"}`)),
2999
3026
  ...parts.flatMap((part) =>
3000
3027
  part.on.length
3001
3028
  ? [` "${part.meta.name}", ${part.on.length} card${part.on.length === 1 ? "" : "s"} in story order:`, ...part.on.map((note, index) => ` ${index + 1}. "${note.headline}"${where_(note) ? ` (${where_(note)})` : ""}`)]
@@ -3064,14 +3091,16 @@ server.registerTool(
3064
3091
  {
3065
3092
  title: "Set where scenes happen",
3066
3093
  description:
3067
- "Set the place of one or more cards: where the scene happens, as the writer would say it ('the piano shop', 'the flat, kitchen') — a phrase, not a slugline. The same phrase on several cards is one place in the Cast panel; an empty string clears it. list_board shows each card's place as 'at: …'.",
3068
- inputSchema: { ids: z.array(z.string()).min(1), location: z.string() },
3094
+ "Set the place of one or more cards: where the scene happens, as the writer would say it ('the piano shop', 'the flat, kitchen') — a phrase, not a slugline. The same phrase on several cards is one place in the Cast panel; an empty string clears it. Or leave the place open: pass open with the writer's words for why it is not decided — \"where it happens\" — and the reading lists it under open, by the writer's word, and stops asking where, while the card's other questions stand; a place decides it, open \"\" leaves it blank. list_board shows each card's place as 'at: …'.",
3095
+ inputSchema: { ids: z.array(z.string()).min(1), location: z.string().optional(), open: z.string().optional() },
3069
3096
  },
3070
3097
  async (args) => {
3098
+ if (args.location === undefined && args.open === undefined) return ok("Say which: location (the place, or \"\" to clear it), or open (the writer's words for why it is not decided).");
3071
3099
  const { state, changed, result, live } = await commit({
3072
3100
  type: "set_location",
3073
3101
  ids: args.ids,
3074
- location: args.location,
3102
+ ...(args.location !== undefined ? { location: args.location } : {}),
3103
+ ...(args.open !== undefined ? { open: args.open } : {}),
3075
3104
  });
3076
3105
  if (!changed) {
3077
3106
  const known = args.ids.filter((id) => state.notes.some((note) => note.id === id));
@@ -3079,6 +3108,10 @@ server.registerTool(
3079
3108
  return ok("No place changed: those cards already read that way.");
3080
3109
  }
3081
3110
  const place = result[0].location;
3111
+ const placeOpen = result[0].locationOpen ?? "";
3112
+ if (placeOpen) {
3113
+ return ok(`${result.length} card(s) have their place left open, by the writer's word: "${placeOpen}"${where(live)}. The reading lists it and stops asking where; the heading prints the headline in its stead; set_location with a place decides it, open "" leaves it blank. The card is still asked about everything else.${stillOpen(result)}`, result);
3114
+ }
3082
3115
  return ok(
3083
3116
  `${result.length} card(s) now ${place ? `at ${place}` : "nowhere"}${where(live)}.${nearPlaces(state, place)}${stillOpen(result)}`,
3084
3117
  result,
@@ -3191,7 +3224,8 @@ function foldLine(state, fold, thread) {
3191
3224
  const head = (id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`;
3192
3225
  if (fold.kept) return ` ${head(fold.firstId)} is folded for ${fold.what}, so "${thread.name}" stays a thread and no arrow is drawn: a card has one fold.`;
3193
3226
  const did = [fold.folded ? `folded ${head(fold.firstId)}` : null, fold.named ? `named its fold "${thread.name}"` : null, fold.arrow ? `drew the setup arrow to ${head(fold.lastId)}` : null].filter(Boolean);
3194
- return did.length ? ` Tied at both ends, so it is the fold's now: ${did.join(", ")}.` : "";
3227
+ const adjacent = fold.adjacent ? ` No setup arrow: a follows arrow already runs from ${head(fold.firstId)} to ${head(fold.lastId)}, so the payoff is the very next scene and the fold says so on its own.` : "";
3228
+ return did.length ? ` Tied at both ends, so it is the fold's now: ${did.join(", ")}.${adjacent}` : adjacent;
3195
3229
  }
3196
3230
 
3197
3231
  function threadLine(state, thread) {
@@ -3483,7 +3517,7 @@ server.registerTool(
3483
3517
  const { project, boards, live, base, changedAt } = await readProject();
3484
3518
  return ok(
3485
3519
  [
3486
- `Project "${project.name}" (${door(live, base)})`,
3520
+ `Project "${project.name}"${project.nameOpen ? ` — its name is open, by the writer's word: "${project.nameOpen}"` : ""} (${door(live, base)})`,
3487
3521
  `premise: ${project.premiseOpen ? `open, by the writer's word — "${project.premiseOpen}"` : project.premise ? `"${project.premise}"` : "(not set)"}`,
3488
3522
  `boards: ${project.boards.length}`,
3489
3523
  describeBoards(project, boards, changedAt),
@@ -3508,7 +3542,7 @@ server.registerTool(
3508
3542
  {
3509
3543
  title: "Set the project's premise",
3510
3544
  description:
3511
- "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.",
3545
+ "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: a sale, or a lease\" — 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.",
3512
3546
  inputSchema: { premise: z.string().optional(), open: z.string().optional() },
3513
3547
  },
3514
3548
  async (args) => {
@@ -3526,11 +3560,18 @@ server.registerTool(
3526
3560
  "rename_project",
3527
3561
  {
3528
3562
  title: "Rename the project",
3529
- description: "Rename the project — the name at the top of the wall, over every board.",
3530
- inputSchema: { name: z.string().min(1) },
3563
+ description: "Rename the project — the name at the top of the wall, over every board. Or leave its name open: pass open with the writer's words for why the title is not decided — \"The Allotments, or Plot 14\" — and the name stands as it is while the reading lists the words; a name decides it, open \"\" takes the words back.",
3564
+ inputSchema: { name: z.string().min(1).optional(), open: z.string().optional() },
3531
3565
  },
3532
3566
  async (args) => {
3567
+ if (args.name === undefined && args.open === undefined) return ok("Say which: name, or open (the writer's words for why the title is not decided).");
3533
3568
  const { project, boards, rev, base, live } = await readProject();
3569
+ if (args.name === undefined) {
3570
+ const opened = setProjectNameOpen(project, args.open);
3571
+ if (opened === project) return ok("Project name unchanged: it already reads that way.");
3572
+ await writeProject(opened, boards, rev, base);
3573
+ return ok(opened.nameOpen ? `The project keeps its name, "${opened.name}", and its name is left open, by the writer's word: "${opened.nameOpen}"${where(live)}. The reading lists it; rename_project with a name decides it.` : `The project's name, "${opened.name}", is no longer open${where(live)}.`, { name: opened.name, nameOpen: opened.nameOpen });
3574
+ }
3534
3575
  const next = renameProject(project, args.name);
3535
3576
  if (next === project) return ok("Project name unchanged.");
3536
3577
  await writeProject(next, boards, rev, base);
@@ -3538,7 +3579,7 @@ server.registerTool(
3538
3579
  // follow the rename (round fifteen, entry 40).
3539
3580
  if (accountDoor) workingProject(next.id, next.name);
3540
3581
  const several = (next.boards ?? []).length > 1;
3541
- return ok(`Project renamed to "${next.name}"${where(live)}. It shows at the head of every reply, in list_boards and read_wall, and as the title of every script out${several ? `, where each board follows it as an episode line (Episode 1 of ${next.boards.length} · ${next.boards[0].name})` : ""}. The boards keep their names.`, next);
3582
+ return ok(`Project renamed to "${next.name}"${where(live)}.${project.nameOpen ? " Its name is decided; the open words are gone." : ""} It shows at the head of every reply, in list_boards and read_wall, and as the title of every script out${several ? `, where each board follows it as an episode line (Episode 1 of ${next.boards.length} · ${next.boards[0].name})` : ""}. The boards keep their names.`, next);
3542
3583
  },
3543
3584
  );
3544
3585
 
@@ -3716,13 +3757,17 @@ server.registerTool(
3716
3757
  {
3717
3758
  title: "Start a project",
3718
3759
  description:
3719
- "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\".",
3720
- inputSchema: { name: z.string().min(1), board: z.string().optional(), boardOpen: z.string().optional(), pages: pagesSchema.optional(), minutes: z.number().positive().optional() },
3760
+ "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. A title not decided: open with the writer's words (\"The Allotments, or Plot 14\") instead of a name, and the project starts as Untitled project with those words beside it. board names the first board; boardOpen leaves its name open in the writer's words instead (\"the pilot, or the film\"), so a board born from a maybe is not silently \"Board 1\".",
3761
+ inputSchema: { name: z.string().min(1).optional(), open: z.string().optional(), board: z.string().optional(), boardOpen: z.string().optional(), pages: pagesSchema.optional(), minutes: z.number().positive().optional() },
3721
3762
  },
3722
3763
  async (args) => {
3723
3764
  const account = await findAccount();
3724
3765
  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.");
3725
- let record = renameProject(emptyProject(), args.name.trim());
3766
+ if (!args.name?.trim() && !args.open?.trim()) return ok("Say which: name (the title), or open (the writer's words for why the title is not decided — the project starts as \"Untitled project\" with those words beside it).");
3767
+ let record = emptyProject();
3768
+ if (args.name?.trim()) record = renameProject(record, args.name.trim());
3769
+ // A project born from a maybe is born open on its name (R61's edge): open holds the writer's words.
3770
+ if (args.open?.trim()) record = setProjectNameOpen(record, args.open);
3726
3771
  if (args.board?.trim()) record = renameBoard(record, record.activeBoardId, args.board.trim());
3727
3772
  // A board born from a maybe is born open on its name (R61): boardOpen holds the writer's words.
3728
3773
  if (args.boardOpen?.trim()) record = setBoardNameOpen(record, record.activeBoardId, args.boardOpen);
@@ -3736,7 +3781,7 @@ server.registerTool(
3736
3781
  joinPresence(record.id);
3737
3782
  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.`;
3738
3783
  const first = record.boards[0];
3739
- const nameOpenLine = first.nameOpen ? ` The board's name is left open, by the writer's word: "${first.nameOpen}"; rename_board decides it.` : "";
3784
+ const nameOpenLine = `${record.nameOpen ? ` The project's name is left open, by the writer's word: "${record.nameOpen}"; rename_project decides it.` : ""}${first.nameOpen ? ` The board's name is left open, by the writer's word: "${first.nameOpen}"; rename_board decides it.` : ""}`;
3740
3785
  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 });
3741
3786
  },
3742
3787
  );
@@ -3837,7 +3882,7 @@ server.registerTool(
3837
3882
  {
3838
3883
  title: "Save the project as a file",
3839
3884
  description:
3840
- `The project the server is working, as the file Save project writes and Open project takes: the record, every board with its cards, the reminders and the writer's structures. Pass path to write it (a .json) — an absolute path, since a relative one resolves from the folder the server was started in, which is ${process.cwd()}, not yours; without a path, the reply's JSON is the file. Pictures and takes on the account are not in the file. Works through every door.`,
3885
+ `The project the server is working, as the file Save project writes and Open project takes: the record, every board with its cards, the reminders and the writer's structures. Pass path to write it (a .json) — an absolute path, since a relative one resolves from the folder the server was started in, which is ${process.cwd()} — this session's own folder when the server was started from it, and somewhere else when it was not; without a path, the reply's JSON is the file. Pictures and takes on the account are not in the file. Works through every door.`,
3841
3886
  inputSchema: { path: z.string().optional() },
3842
3887
  },
3843
3888
  async (args) => {
@@ -3957,7 +4002,7 @@ server.registerTool(
3957
4002
  "rename_board",
3958
4003
  {
3959
4004
  title: "Rename board",
3960
- 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.",
4005
+ 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 pilot, or the film\" — 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.",
3961
4006
  inputSchema: { board: z.union([z.string().min(1), z.number()]), name: z.string().min(1).optional(), open: z.string().optional() },
3962
4007
  },
3963
4008
  async (args) => {
@@ -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), 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.",
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 card's place or when, a board's or the project'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(note.plantsWhat ? `plants ${note.plantsWhat}` : "plants something to pay off later");
126
126
  if (note.open) marks.push(`open: ${note.open}`);
127
+ if (note.locationOpen) marks.push(`place open: ${note.locationOpen}`);
127
128
  if (note.whenOpen) marks.push(`when open: ${note.whenOpen}`);
128
129
  const onThreads = (state.threads ?? []).filter((thread) => thread.noteIds.includes(note.id)).map((thread) => thread.name);
129
130
  if (onThreads.length) marks.push(`thread: ${onThreads.join(", ")}`);
@@ -19,6 +19,8 @@ export type ProjectRecord = {
19
19
  version: number;
20
20
  id: string;
21
21
  name: string;
22
+ /** The writer's words for why the project's name is not decided (R61's edge), or empty; the name stands meanwhile. */
23
+ nameOpen: string;
22
24
  premise: string;
23
25
  /** The writer's words for why there is no premise yet (R61), or empty. */
24
26
  premiseOpen: string;
@@ -75,6 +77,8 @@ export declare function setPremise(project: ProjectRecord, premise: string, now?
75
77
  export declare function setPremiseOpen(project: ProjectRecord, words: string, now?: string): ProjectRecord;
76
78
  /** The writer's words for why a board's name is not decided (R61); the name stands meanwhile. */
77
79
  export declare function setBoardNameOpen(project: ProjectRecord, id: string, words: string, now?: string): ProjectRecord;
80
+ /** The writer's words for why the project's name is not decided (R61's edge); the name stands meanwhile. */
81
+ export declare function setProjectNameOpen(project: ProjectRecord, words: string, now?: string): ProjectRecord;
78
82
  export declare function boardById(project: ProjectRecord, id: string): BoardMeta | null;
79
83
  /** What a script going out is called: a named project is the title, its board beside it only when the project has several. */
80
84
  export declare function scriptTitles(project: ProjectRecord, board: BoardMeta | null | undefined): { title: string; episode?: string };
@@ -33,6 +33,7 @@ export function emptyProject(now = nowIso()) {
33
33
  version: PROJECT_VERSION,
34
34
  id: newId(),
35
35
  name: DEFAULT_PROJECT_NAME,
36
+ nameOpen: "",
36
37
  premise: "",
37
38
  premiseOpen: "",
38
39
  boards: [board],
@@ -87,6 +88,8 @@ export function normalizeProject(value, now = nowIso()) {
87
88
  ...rest,
88
89
  version: PROJECT_VERSION,
89
90
  name: trimmed(value.name, DEFAULT_PROJECT_NAME),
91
+ // A project named before R61's edge has no open name; the name stands until the writer says it is not decided.
92
+ nameOpen: openWords(value.nameOpen),
90
93
  premise: typeof value.premise === "string" ? value.premise.trim() : "",
91
94
  // A project written before R61 has no open premise (R61).
92
95
  premiseOpen: openWords(value.premiseOpen),
@@ -342,8 +345,16 @@ export function setActiveBoard(project, id, now = nowIso()) {
342
345
 
343
346
  export function renameProject(project, name, now = nowIso()) {
344
347
  const next = trimmed(name, "");
345
- if (!next || next === project.name) return project;
346
- return touch(project, { name: next }, now);
348
+ if (!next || (next === project.name && !(project.nameOpen ?? ""))) return project;
349
+ // A name decides the field: the open words go (R61's edge).
350
+ return touch(project, { name: next, nameOpen: "" }, now);
351
+ }
352
+
353
+ /** The writer's words for why the project's name is not decided (R61's edge), or "" to take them back; the name stands meanwhile. */
354
+ export function setProjectNameOpen(project, words, now = nowIso()) {
355
+ const next = openWords(words);
356
+ if (next === (project.nameOpen ?? "")) return project;
357
+ return touch(project, { nameOpen: next }, now);
347
358
  }
348
359
 
349
360
  export function setPremise(project, premise, now = nowIso()) {
@@ -72,9 +72,9 @@ export type WallReading = {
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
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 }>;
75
+ openFields: Array<{ field: "logline"; words: string } | { field: "location"; id: string; words: string } | { field: "when"; id: string; words: string }>;
76
76
  /** Threads (R60): each named string with its cards in story order and which ends are open. */
77
- threads: Array<{ id: string; name: string; ids: string[]; startOpen: boolean; endOpen: boolean }>;
77
+ threads: Array<{ id: string; name: string; ids: string[]; startOpen: boolean; endOpen: boolean; /** Eighths from the first card's start to the last card's start; 0 with fewer than two cards. */ apart: number }>;
78
78
  /** Cards here that pay off a fold of another board (R58), composed by the door from the project. */
79
79
  paidBy: Array<{ id: string; fromBoardId: string; fromBoardName: string; fromNoteId: string; fromHeadline: string; fromColor: string }>;
80
80
  /** The questions the wall asks now. A left one (R53) is not here while its words hold. */
@@ -17,7 +17,11 @@
17
17
  // before any tidy — the arrows are the writer's claim about the order, and
18
18
  // where they say nothing the positions decide.
19
19
 
20
- import { isMeasured, boardEighths, EIGHTHS_PER_PAGE, formatPages, NOTE_HEIGHT, noteEighths } from "./reducer.js";
20
+ import { isMeasured, boardEighths, EIGHTHS_PER_PAGE, formatPages, noteEighths, readingOrder, storyOrder } from "./reducer.js";
21
+
22
+ // The two orders live in the kernel (R62: a thread's cards are held in story
23
+ // order), and every reader still imports them from here.
24
+ export { readingOrder, storyOrder };
21
25
 
22
26
  /** What create_note writes before a person has. */
23
27
  export const PLACEHOLDER_HEADLINE = "New beat";
@@ -29,7 +33,6 @@ const SAG_RATIO = 2;
29
33
  // Longer than this and a group is probably two sequences wearing one frame.
30
34
  const SEQUENCE_MAX_EIGHTHS = 20 * EIGHTHS_PER_PAGE;
31
35
  // Two cards whose tops are within half a card of each other share a row.
32
- const ROW_TOLERANCE = NOTE_HEIGHT / 2;
33
36
  // A character gone for more than this share of the story is worth asking about.
34
37
  const ABSENCE_FRACTION = 1 / 3;
35
38
  // ...and at least this long, so a short wall of one-page estimates does not
@@ -45,64 +48,12 @@ const FILLER = new Set([
45
48
  "about", "into", "from", "by", "his", "her", "hers", "its", "their", "is", "it",
46
49
  ]);
47
50
 
48
- /**
49
- * Cards in reading order: banded into rows by y, then left to right. A free
50
- * wall has no rows, so this is the order a person's eye takes across it.
51
- */
52
- /**
53
- * Story order: reading order, with each follows arrow pulling its source in
54
- * front of its target; a pair pointing both ways is a tie and reading order
55
- * keeps it. The order organize lays the wall out in, and the order every
56
- * reading, numbering and page uses (R56). Returns the notes.
57
- */
58
- export function storyOrder(state, ids) {
59
- const scope = ids ? new Set(ids) : null;
60
- const notes = state.notes.filter((note) => !scope || scope.has(note.id));
61
- const reading = readingOrder(notes);
62
- const byId = new Map(reading.map((note) => [note.id, note]));
63
- const rank = new Map(reading.map((note, index) => [note.id, index]));
64
- const preds = new Map(reading.map((note) => [note.id, []]));
65
- for (const arrow of state.arrows ?? []) {
66
- if (arrow.kind === "setup") continue;
67
- if (!rank.has(arrow.from) || !rank.has(arrow.to)) continue;
68
- preds.get(arrow.to).push(arrow.from);
69
- }
70
- for (const list of preds.values()) list.sort((a, b) => rank.get(a) - rank.get(b));
71
- const placed = new Set();
72
- const visiting = new Set();
73
- const order = [];
74
- function visit(id) {
75
- if (placed.has(id) || visiting.has(id)) return;
76
- visiting.add(id);
77
- for (const from of preds.get(id)) {
78
- if (preds.get(from).includes(id)) continue;
79
- visit(from);
80
- }
81
- visiting.delete(id);
82
- placed.add(id);
83
- order.push(byId.get(id));
84
- }
85
- for (const note of reading) visit(note.id);
86
- return order;
87
- }
51
+
88
52
 
89
53
  /** The leading "Day three, night." of a headline, the convention the guide asks for until a card has a when: not a scene's words. */
90
54
  const DAY_PREFIX = /^\s*day\s+[\w-]+(?:\s*,\s*[\w\s-]+?)?\s*[.:]\s*/i;
91
55
 
92
- export function readingOrder(notes) {
93
- const byTop = [...notes].sort((a, b) => a.y - b.y || a.x - b.x);
94
- const rows = [];
95
- let row = null;
96
- for (const note of byTop) {
97
- if (row && note.y - row.top <= ROW_TOLERANCE) {
98
- row.notes.push(note);
99
- } else {
100
- row = { top: note.y, notes: [note] };
101
- rows.push(row);
102
- }
103
- }
104
- return rows.flatMap((band) => band.notes.sort((a, b) => a.x - b.x || a.y - b.y));
105
- }
56
+
106
57
 
107
58
  function words(text) {
108
59
  return (text ?? "")
@@ -112,9 +63,9 @@ function words(text) {
112
63
  .filter((word) => word && !FILLER.has(word));
113
64
  }
114
65
 
115
- function sameScene(a, b) {
116
- const wa = words(a);
117
- const wb = words(b);
66
+ function sameScene(a, b, ignore = new Set()) {
67
+ const wa = words(a).filter((word) => !ignore.has(word));
68
+ const wb = words(b).filter((word) => !ignore.has(word));
118
69
  if (wa.length === 0 || wb.length === 0) return false;
119
70
  if (wa.join(" ") === wb.join(" ")) return true;
120
71
  if (wa.length < 2 || wb.length < 2) return false;
@@ -265,7 +216,7 @@ export function readWall(state, options = {}) {
265
216
  // On a wall where most runs hold nothing, the turns are back to back by
266
217
  // construction, and the question stands until scenes go in; say so rather
267
218
  // than ask it as if it were a choice (round eighteen, entry 29).
268
- const thin = between.length >= 2 && between.filter((run) => run.cards === 0).length * 2 > between.length ? " Most runs hold nothing yet, so this stands until scenes go in." : "";
219
+ const thin = between.length >= 2 && between.filter((run) => run.cards === 0).length * 2 > between.length ? " Most runs hold nothing yet, so this stands until scenes go in, or until the writer leaves it as the pace." : "";
269
220
  const askChain = () => {
270
221
  if (chain.length === 0) return;
271
222
  const ids = [chain[0].from, ...chain.map((run) => run.to)];
@@ -275,8 +226,8 @@ export function readWall(state, options = {}) {
275
226
  ids,
276
227
  text:
277
228
  ids.length === 2
278
- ? `Nothing runs between ${names[0]} and ${names[1]}: two turns back to back. Are they one beat, or is a scene missing?${thin}`
279
- : `Nothing runs between ${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}: ${countWord(ids.length)} turns back to back. Are some of them one beat, or are scenes missing between them?${thin}`,
229
+ ? `Nothing runs between ${names[0]} and ${names[1]}: two turns back to back. Are they one beat, is a scene missing, or is that the pace?${thin}`
230
+ : `Nothing runs between ${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}: ${countWord(ids.length)} turns back to back. Are some of them one beat, are scenes missing between them, or is that the pace?${thin}`,
280
231
  });
281
232
  chain = [];
282
233
  };
@@ -290,7 +241,8 @@ export function readWall(state, options = {}) {
290
241
  // A card that says no place, once the writer has started placing cards.
291
242
  // One question however many there are; a wall with no places at all is a
292
243
  // wall the writer has not placed yet, and is not asked.
293
- const unplaced = order.filter((note) => askable(note) && !(note.location ?? "").trim());
244
+ // A place left open in the writer's words (R61's edge) is listed, not asked.
245
+ const unplaced = order.filter((note) => askable(note) && !(note.location ?? "").trim() && !(note.locationOpen ?? "").trim());
294
246
  if (unplaced.length > 0 && unplaced.length < order.length) {
295
247
  findings.push({
296
248
  kind: "unplaced",
@@ -323,8 +275,12 @@ export function readWall(state, options = {}) {
323
275
  }
324
276
 
325
277
  // Only once the wall uses arrows does a card without one mean anything.
278
+ // A setup arrow is a claim, not a place in the story (round twenty, entry
279
+ // 25): a card touched only by one still has no order, so only follows
280
+ // arrows link a card.
326
281
  const linked = new Set();
327
282
  for (const arrow of state.arrows) {
283
+ if (arrow.kind === "setup") continue;
328
284
  linked.add(arrow.from);
329
285
  linked.add(arrow.to);
330
286
  }
@@ -339,14 +295,17 @@ export function readWall(state, options = {}) {
339
295
  }
340
296
  }
341
297
 
342
- // Two cards doing the same job.
298
+ // Two cards doing the same job. The cast's names are not the scene's words
299
+ // (round twenty, entry 15): "Con gives Ruth the key" and "Con gives Ruth
300
+ // his tools" share their people, not their job.
301
+ const nameWords = new Set((state.characters ?? []).flatMap((person) => words(person.name ?? "")));
343
302
  for (let i = 0; i < order.length; i += 1) {
344
303
  for (let j = i + 1; j < order.length; j += 1) {
345
304
  const a = order[i];
346
305
  const b = order[j];
347
306
  // A leading "Day three." is the guide's convention for when a scene
348
307
  // happens, not the scene's words (round fourteen, entry 13).
349
- if (sameScene(a.headline.replace(DAY_PREFIX, ""), b.headline.replace(DAY_PREFIX, ""))) {
308
+ if (sameScene(a.headline.replace(DAY_PREFIX, ""), b.headline.replace(DAY_PREFIX, ""), nameWords)) {
350
309
  findings.push({
351
310
  kind: "duplicate",
352
311
  ids: [a.id, b.id],
@@ -505,13 +464,12 @@ export function readWall(state, options = {}) {
505
464
  // Threads (R60): a named string through cards, in story order, either end
506
465
  // open until the writer ties it. The reading asks about each loose end from
507
466
  // that end — the question a fold cannot ask, where a thing is first seen.
508
- const threads = (state.threads ?? []).map((thread) => ({
509
- id: thread.id,
510
- name: thread.name,
511
- ids: order.filter((note) => thread.noteIds.includes(note.id)).map((note) => note.id),
512
- startOpen: thread.startOpen === true,
513
- endOpen: thread.endOpen === true,
514
- }));
467
+ const threads = (state.threads ?? []).map((thread) => {
468
+ const ids = order.filter((note) => thread.noteIds.includes(note.id)).map((note) => note.id);
469
+ // How far the string runs, in the same estimated pages a setup's distance uses (round twenty, entry 44).
470
+ const apart = ids.length >= 2 ? (startAt.get(ids[ids.length - 1]) ?? 0) - (startAt.get(ids[0]) ?? 0) : 0;
471
+ return { id: thread.id, name: thread.name, ids, startOpen: thread.startOpen === true, endOpen: thread.endOpen === true, apart };
472
+ });
515
473
  for (const thread of threads) {
516
474
  const first = thread.ids.length ? byId.get(thread.ids[0]) : null;
517
475
  const last = thread.ids.length ? byId.get(thread.ids[thread.ids.length - 1]) : null;
@@ -578,6 +536,7 @@ export function readWall(state, options = {}) {
578
536
  function describeOpenFields(state, order) {
579
537
  const fields = [];
580
538
  if ((state.loglineOpen ?? "").trim()) fields.push({ field: "logline", words: state.loglineOpen.trim() });
539
+ for (const note of order) if ((note.locationOpen ?? "").trim()) fields.push({ field: "location", id: note.id, words: note.locationOpen.trim() });
581
540
  for (const note of order) if ((note.whenOpen ?? "").trim()) fields.push({ field: "when", id: note.id, words: note.whenOpen.trim() });
582
541
  return fields;
583
542
  }
@@ -635,6 +594,7 @@ export function describeRuns(reading, state) {
635
594
  // from written text, or the cards' guess, or some of each.
636
595
  const written = (run.ids ?? []).filter((id) => isMeasured(byId.get(id) ?? {})).length;
637
596
  const whose = !run.cards ? "" : written === run.cards ? ", measured" : written ? `, ${written} of ${run.cards} measured` : ", estimated";
638
- return `${span}: about ${pages(run.eighths)} pages, ${count}${whose}`;
597
+ const amount = pages(run.eighths);
598
+ return `${span}: about ${amount} ${amount === "1" ? "page" : "pages"}, ${count}${whose}`;
639
599
  });
640
600
  }
@@ -93,6 +93,8 @@ export type BoardNote = {
93
93
  when: string;
94
94
  /** 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. */
95
95
  whenOpen: string;
96
+ /** The writer's words for why the place is not decided (R61's edge), or empty; while they stand the place is blank, listed, and not asked. */
97
+ locationOpen: string;
96
98
  /** The scene's text in Fountain (R23 b): action, cues, dialogue; empty until written. */
97
99
  text: string;
98
100
  createdAt: string;
@@ -186,6 +188,7 @@ export type Command =
186
188
  location?: string;
187
189
  when?: string;
188
190
  whenOpen?: string;
191
+ locationOpen?: string;
189
192
  open?: string;
190
193
  text?: string;
191
194
  }
@@ -216,7 +219,7 @@ export type Command =
216
219
  | { type: "set_plant"; ids: string[]; plants?: boolean; what?: string }
217
220
  | { type: "set_open"; ids: string[]; open: string }
218
221
  | { type: "set_payoff_board"; ids: string[]; boardId: string | null; noteId?: string | null }
219
- | { type: "set_location"; ids: string[]; location: string }
222
+ | { type: "set_location"; ids: string[]; location?: string; open?: string }
220
223
  | { type: "set_when"; ids: string[]; when?: string; open?: string }
221
224
  | { type: "apply_template"; template: string; beats?: Array<{ name: string; prompt: string; at: number }> }
222
225
  | { type: "set_text"; id: string; text: string }
@@ -247,6 +247,8 @@ export function seedState(now = nowIso()) {
247
247
  lengthEighths: null,
248
248
  characterIds,
249
249
  location: "",
250
+ // The writer's words for why the place is not decided (R61), or nothing.
251
+ locationOpen: "",
250
252
  when: "",
251
253
  // The writer's words for why the when is not decided (R61), or nothing.
252
254
  whenOpen: "",
@@ -368,6 +370,8 @@ export function normalizeState(value) {
368
370
  const open = typeof note?.open === "string" ? note.open : "";
369
371
  // Cards written before R37 have no place; a scene is nowhere until it is.
370
372
  const location = typeof note?.location === "string" ? note.location : "";
373
+ // Cards written before R61's edge have no open place; a place is decided or blank until the writer says otherwise.
374
+ const locationOpen = typeof note?.locationOpen === "string" ? note.locationOpen : "";
371
375
  // Cards written before R55 have no when; a scene is at no time until it is.
372
376
  const when = typeof note?.when === "string" ? note.when : "";
373
377
  // Cards written before R61 have no open when; a when is decided or blank until the writer says otherwise.
@@ -386,6 +390,7 @@ export function normalizeState(value) {
386
390
  note.payoffNoteId === payoffNoteId &&
387
391
  note.open === open &&
388
392
  note.location === location &&
393
+ note.locationOpen === locationOpen &&
389
394
  note.when === when &&
390
395
  note.whenOpen === whenOpen &&
391
396
  note.text === text
@@ -393,7 +398,7 @@ export function normalizeState(value) {
393
398
  return note;
394
399
  }
395
400
  patched = true;
396
- return { ...note, rank, lengthEighths, characterIds, plants, plantsWhat, payoffBoardId, payoffNoteId, open, location, when, whenOpen, text };
401
+ return { ...note, rank, lengthEighths, characterIds, plants, plantsWhat, payoffBoardId, payoffNoteId, open, location, locationOpen, when, whenOpen, text };
397
402
  });
398
403
 
399
404
  // Boards written before the production half (Roadmap 2, item 8) have no
@@ -474,6 +479,73 @@ function cleanOpen(value) {
474
479
  return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
475
480
  }
476
481
 
482
+
483
+ // --- Order (R56, moved here for R62) -------------------------------------
484
+ //
485
+ // Story order is the follows arrows where they exist and the wall's reading
486
+ // order — rows top to bottom, cards left to right — where they do not. The
487
+ // reading composes everything from it; the kernel needs it to hold a
488
+ // thread's cards in story order, so the tie rule folds the first card and
489
+ // not the last one the writer happened to add.
490
+
491
+ /** Cards within half a card's height of each other sit on one row. */
492
+ const ROW_TOLERANCE = NOTE_HEIGHT / 2;
493
+
494
+ export function readingOrder(notes) {
495
+ const byTop = [...notes].sort((a, b) => a.y - b.y || a.x - b.x);
496
+ const rows = [];
497
+ let row = null;
498
+ for (const note of byTop) {
499
+ if (row && note.y - row.top <= ROW_TOLERANCE) {
500
+ row.notes.push(note);
501
+ } else {
502
+ row = { top: note.y, notes: [note] };
503
+ rows.push(row);
504
+ }
505
+ }
506
+ return rows.flatMap((band) => band.notes.sort((a, b) => a.x - b.x || a.y - b.y));
507
+ }
508
+
509
+ /**
510
+ * Cards in reading order: banded into rows by y, then left to right. A free
511
+ * wall has no rows, so this is the order a person's eye takes across it.
512
+ */
513
+ /**
514
+ * Story order: reading order, with each follows arrow pulling its source in
515
+ * front of its target; a pair pointing both ways is a tie and reading order
516
+ * keeps it. The order organize lays the wall out in, and the order every
517
+ * reading, numbering and page uses (R56). Returns the notes.
518
+ */
519
+ export function storyOrder(state, ids) {
520
+ const scope = ids ? new Set(ids) : null;
521
+ const notes = state.notes.filter((note) => !scope || scope.has(note.id));
522
+ const reading = readingOrder(notes);
523
+ const byId = new Map(reading.map((note) => [note.id, note]));
524
+ const rank = new Map(reading.map((note, index) => [note.id, index]));
525
+ const preds = new Map(reading.map((note) => [note.id, []]));
526
+ for (const arrow of state.arrows ?? []) {
527
+ if (arrow.kind === "setup") continue;
528
+ if (!rank.has(arrow.from) || !rank.has(arrow.to)) continue;
529
+ preds.get(arrow.to).push(arrow.from);
530
+ }
531
+ for (const list of preds.values()) list.sort((a, b) => rank.get(a) - rank.get(b));
532
+ const placed = new Set();
533
+ const visiting = new Set();
534
+ const order = [];
535
+ function visit(id) {
536
+ if (placed.has(id) || visiting.has(id)) return;
537
+ visiting.add(id);
538
+ for (const from of preds.get(id)) {
539
+ if (preds.get(from).includes(id)) continue;
540
+ visit(from);
541
+ }
542
+ visiting.delete(id);
543
+ placed.add(id);
544
+ order.push(byId.get(id));
545
+ }
546
+ for (const note of reading) visit(note.id);
547
+ return order;
548
+ }
477
549
  /**
478
550
  * The combine log's rule, both halves (R60, R62). A thread tied at both ends
479
551
  * through two or more cards is a plant between two scenes that exist, and
@@ -504,16 +576,20 @@ function tieIntoFold(state, thread, now) {
504
576
  notes: next.notes.map((note) => (note.id === firstId ? bump(note, { plants: true, plantsWhat: thread.name }, now) : note)),
505
577
  };
506
578
  }
507
- const hasArrow = next.arrows.some((arrow) => arrow.kind === "setup" && arrow.from === firstId && arrow.to === lastId);
579
+ // One arrow per direction between two cards (R15): a follows arrow already
580
+ // running from the first card to the last means the payoff is the very next
581
+ // scene, and the setup cannot be drawn over it.
582
+ const existing = next.arrows.find((arrow) => arrow.from === firstId && arrow.to === lastId);
508
583
  let arrow = null;
509
- if (!hasArrow) {
584
+ const adjacent = Boolean(existing && existing.kind !== "setup");
585
+ if (!existing) {
510
586
  const drawn = applyCommand(next, { type: "create_arrow", from: firstId, to: lastId, kind: "setup" }, now);
511
587
  if (drawn.changed) {
512
588
  next = drawn.state;
513
589
  arrow = { from: firstId, to: lastId };
514
590
  }
515
591
  }
516
- return { state: next, fold: { kept: false, firstId, lastId, folded, named, arrow } };
592
+ return { state: next, fold: { kept: false, firstId, lastId, folded, named, arrow, adjacent } };
517
593
  }
518
594
 
519
595
  /** A thread's name as the writer typed it, one line, spaces collapsed (R60). */
@@ -565,7 +641,8 @@ export function applyCommand(state, command, now = nowIso()) {
565
641
  payoffBoardId: null,
566
642
  payoffNoteId: null,
567
643
  open: cleanOpen(command.open),
568
- location: cleanPlace(command.location),
644
+ location: cleanOpen(command.locationOpen) ? "" : cleanPlace(command.location),
645
+ locationOpen: cleanOpen(command.locationOpen),
569
646
  when: cleanOpen(command.whenOpen) ? "" : cleanWhen(command.when),
570
647
  whenOpen: cleanOpen(command.whenOpen),
571
648
  text: typeof command.text === "string" ? command.text : "",
@@ -809,7 +886,9 @@ export function applyCommand(state, command, now = nowIso()) {
809
886
  case "create_thread": {
810
887
  const name = cleanThreadName(command.name);
811
888
  if (!name) return { state, changed: false };
812
- const noteIds = (command.noteIds ?? []).filter((id, index, all) => all.indexOf(id) === index && state.notes.some((note) => note.id === id));
889
+ const wanted = (command.noteIds ?? []).filter((id, index, all) => all.indexOf(id) === index && state.notes.some((note) => note.id === id));
890
+ // Held in story order, whatever order the writer named them (R62).
891
+ const noteIds = storyOrder(state, wanted).map((note) => note.id);
813
892
  const thread = {
814
893
  id: typeof command.id === "string" && command.id && !(state.threads ?? []).some((item) => item.id === command.id) ? command.id : newId(),
815
894
  name,
@@ -828,6 +907,9 @@ export function applyCommand(state, command, now = nowIso()) {
828
907
  let noteIds = Array.isArray(command.noteIds) ? command.noteIds.filter((id, index, all) => all.indexOf(id) === index && exists(id)) : [...current.noteIds];
829
908
  if (Array.isArray(command.add)) for (const id of command.add) if (exists(id) && !noteIds.includes(id)) noteIds.push(id);
830
909
  if (Array.isArray(command.remove)) noteIds = noteIds.filter((id) => !command.remove.includes(id));
910
+ // Held in story order, whatever order the cards were added (R62): the
911
+ // rule below folds the first card in the story, not the last one named.
912
+ noteIds = storyOrder(state, noteIds).map((note) => note.id);
831
913
  const name = command.name === undefined ? current.name : cleanThreadName(command.name) || current.name;
832
914
  const startOpen = typeof command.startOpen === "boolean" ? command.startOpen : current.startOpen;
833
915
  const endOpen = typeof command.endOpen === "boolean" ? command.endOpen : current.endOpen;
@@ -1089,6 +1171,7 @@ export function applyCommand(state, command, now = nowIso()) {
1089
1171
  payoffNoteId: null,
1090
1172
  open: "",
1091
1173
  location: "",
1174
+ locationOpen: "",
1092
1175
  when: "",
1093
1176
  whenOpen: "",
1094
1177
  text: "",
@@ -1180,11 +1263,21 @@ export function applyCommand(state, command, now = nowIso()) {
1180
1263
  case "set_location": {
1181
1264
  const ids = new Set(command.ids);
1182
1265
  if (ids.size === 0) return { state, changed: false };
1183
- const location = cleanPlace(command.location);
1266
+ // The place, or the writer's words for why there is none yet (R61's
1267
+ // edge, round twenty entry 16): words clear the place, a place clears
1268
+ // the words, open "" with a place does both in one command.
1269
+ const hasOpen = typeof command.open === "string";
1270
+ const locationOpen = hasOpen ? cleanOpen(command.open) : null;
1271
+ const location = hasOpen ? (locationOpen ? "" : typeof command.location === "string" ? cleanPlace(command.location) : null) : cleanPlace(command.location);
1184
1272
  const touched = [];
1185
1273
  const notes = state.notes.map((note) => {
1186
- if (!ids.has(note.id) || note.location === location) return note;
1187
- const next = bump(note, { location }, now);
1274
+ if (!ids.has(note.id)) return note;
1275
+ const patch = {
1276
+ location: location === null ? (note.location ?? "") : location,
1277
+ locationOpen: locationOpen === null ? (location ? "" : (note.locationOpen ?? "")) : locationOpen,
1278
+ };
1279
+ if (patch.location === (note.location ?? "") && patch.locationOpen === (note.locationOpen ?? "")) return note;
1280
+ const next = bump(note, patch, now);
1188
1281
  touched.push(next);
1189
1282
  return next;
1190
1283
  });
@@ -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. 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.",
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 place or when, a board's or the project's name — the words where the value would be, listed and not asked.",
70
70
  },
71
71
  {
72
72
  id: "thread",
@@ -77,7 +77,7 @@ export const WORD_GROUPS = [
77
77
  id: "corner",
78
78
  name: "The folded corner",
79
79
  sentence:
80
- "This card plants something — a gun on the wall — that must pay off later, and can say what, in the writer’s words: Plants · the wrong tools. The card says unpaid, and the bar’s Asks line keeps asking where it comes back, until a setup arrow leaves it. One thing, three words: the corner is folded, the tool’s flag is plants, the wall’s question is unpaid.",
80
+ "This card plants something — a gun on the wall — that must pay off later, and can say what, in the writer’s words: Plants · the letter. The card says unpaid, and the bar’s Asks line keeps asking where it comes back, until a setup arrow leaves it. One thing, three words: the corner is folded, the tool’s flag is plants, the wall’s question is unpaid.",
81
81
  target: "corner",
82
82
  },
83
83
  {
@@ -25,6 +25,7 @@ export const WORKFLOWS = [
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
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
+ { question: "In what order do the scenes come?", hint: "The follows arrows are the order, and the wall reads, numbers and prints by them: create_note with after wires each card as it lands, create_arrow draws one, move_scene moves a scene. Not decided for a card: leave it unwired and say so; the reading asks what comes before and after it.", tool: "create_note, create_arrow, move_scene" },
28
29
  { 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
30
  { question: "Does it have acts?", hint: "If so, where does each break fall?", tool: "create_group" },
30
31
  { 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" },