plotcoder-board 0.1.32 → 0.1.34

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.32. 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.34. 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.32",
3
+ "version": "0.1.34",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite",
@@ -995,16 +995,16 @@ const CHECK_WORDS = {
995
995
  };
996
996
  /** What an open card would be asked once closed, as the question and not the check's clean form (round nineteen, entry 31). */
997
997
  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",
998
+ unwritten: "its change line (update_note)",
999
+ unlinked: "what comes before and after it (move_scene, or create_arrow)",
1000
+ duplicate: "which of two alike headlines it is (update_note)",
1001
1001
  sequence: "whether its group is one sequence",
1002
- nobody: "who is in it",
1002
+ nobody: "who is in it (cast)",
1003
1003
  absent: "who has been gone too long",
1004
1004
  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",
1005
+ unpaid: "where its fold pays off (create_arrow, kind setup)",
1006
+ unplanted: "what plants its payoff (set_plant on the card that does)",
1007
+ unplaced: "where it happens (set_location)",
1008
1008
  };
1009
1009
  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
1010
 
@@ -1143,7 +1143,7 @@ function summarize(state) {
1143
1143
  `beats: ${beats}, scenes: ${scenes}`,
1144
1144
  state.targetEighths === DEFAULT_TARGET_EIGHTHS
1145
1145
  ? `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)}`,
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; page_count is the script so far)${runtimeKinds(state)}`,
1147
1147
  `notes: ${state.notes.length}, groups: ${state.groups.length}, arrows: ${state.arrows.length}, cast: ${state.characters.length}`,
1148
1148
  "cards (in story order — the follows arrows over the rows; each with its id):",
1149
1149
  notes || " (no cards)",
@@ -1344,7 +1344,8 @@ server.registerTool(
1344
1344
  rank: rankSchema.optional(),
1345
1345
  pages: pagesSchema.optional(),
1346
1346
  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."),
1347
+ plantsWhat: z.string().optional(),
1348
+ 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
1349
  before: z.string().optional().describe("Or before this card (id or headline)."),
1349
1350
  location: z.string().optional(),
1350
1351
  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.'),
@@ -1578,7 +1579,21 @@ server.registerTool(
1578
1579
  ...reading.openFields.filter((field) => field.field === "logline").map((field) => ` - the logline — ${field.words}`),
1579
1580
  ...(projectForRead.premiseOpen ? [` - the premise — ${projectForRead.premiseOpen}`] : []),
1580
1581
  ...(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}`),
1582
+ // Whens left open in the same words are one line, not one per card (round twenty, entry 22).
1583
+ ...(() => {
1584
+ const byWords = new Map();
1585
+ for (const field of reading.openFields.filter((item) => item.field === "when")) {
1586
+ const key = field.words.toLowerCase();
1587
+ if (!byWords.has(key)) byWords.set(key, { words: field.words, ids: [] });
1588
+ byWords.get(key).ids.push(field.id);
1589
+ }
1590
+ const name = (id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`;
1591
+ return [...byWords.values()].map((group) =>
1592
+ group.ids.length === 1
1593
+ ? ` - ${name(group.ids[0])} — when: ${group.words}`
1594
+ : ` - 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(", ")}`}`,
1595
+ );
1596
+ })(),
1582
1597
  ];
1583
1598
  const lines = [
1584
1599
  `PlotCoder wall (${door(live, base)})`,
@@ -1589,7 +1604,7 @@ server.registerTool(
1589
1604
  "the cast and the places are list_board's, not the reading's",
1590
1605
  state.targetEighths === DEFAULT_TARGET_EIGHTHS
1591
1606
  ? `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)`,
1607
+ : `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
1608
  `groups: ${
1594
1609
  state.groups.length
1595
1610
  ? state.groups
@@ -1621,7 +1636,7 @@ server.registerTool(
1621
1636
  ? ["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
1637
  : []),
1623
1638
  ...(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" : ""}`)]
1639
+ ? ["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
1640
  : []),
1626
1641
  "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
1642
  ...(reading.findings.length
@@ -2024,11 +2039,15 @@ server.registerTool(
2024
2039
  async () => {
2025
2040
  const { project } = await readProject();
2026
2041
  const own = project.structures ?? [];
2042
+ // Pages beside the percentages, against the open board's target, so the two readings agree without arithmetic (round twenty, entry 57).
2043
+ const { state } = await readBoard();
2044
+ const target = state?.targetEighths ?? DEFAULT_TARGET_EIGHTHS;
2045
+ const at = (beat) => `${beat.name} at ${Math.round(beat.at * 100)}% (p. ${Math.floor((beat.at * target) / EIGHTHS_PER_PAGE) + 1})`;
2027
2046
  const lines = [
2028
2047
  `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(", ")})`),
2048
+ ...own.map((structure) => ` - ${structure.id} — "${structure.name}" (${structure.beats.length} beats: ${structure.beats.map(at).join(", ")})`),
2030
2049
  `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(", ")})`),
2050
+ ...TEMPLATES.map((template) => ` - ${template.id} — "${template.name}" (${template.beats.length} beats: ${template.beats.map(at).join(", ")})`),
2032
2051
  "compare_structure sets one of these beside this wall's beats, page by page, and lays nothing",
2033
2052
  ];
2034
2053
  return ok(lines.join("\n"), { builtIn: TEMPLATES.map((template) => ({ id: template.id, name: template.name, beats: template.beats })), own });
@@ -2057,10 +2076,11 @@ server.registerTool(
2057
2076
  const allMeasured = state.notes.length > 0 && state.notes.every((note) => isMeasured(note));
2058
2077
  const short = state.targetEighths > 0 && boardEighths(state) * 2 < state.targetEighths;
2059
2078
  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:`,
2079
+ `"${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
2080
  // On a wall under half its target the pairing is arithmetic; say so before the rows, not after them (round eighteen, entry 52).
2062
2081
  ...(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}`),
2082
+ // On a thin wall a match is arithmetic, and the row says so where it says "here" (round twenty, entry 54).
2083
+ ...describeComparison(comparison).map((line) => ` - ${short ? line.replace(/\bhere\b/, "here, by arithmetic") : line}`),
2064
2084
  comparison.unmatched.length
2065
2085
  ? `beats of the wall no beat of the structure answers: ${comparison.unmatched.map((beat) => `"${beat.headline}" (p. ${beat.page})`).join(", ")}`
2066
2086
  : "every beat of the wall answers one of the structure's",
@@ -2726,7 +2746,7 @@ server.registerTool(
2726
2746
  {
2727
2747
  title: "Set when scenes happen",
2728
2748
  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: …",
2749
+ "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
2750
  inputSchema: { ids: z.array(z.string()).min(1), when: z.string().optional(), open: z.string().optional() },
2731
2751
  },
2732
2752
  async (args) => {
@@ -2995,7 +3015,10 @@ server.registerTool(
2995
3015
  const lines = [
2996
3016
  `PlotCoder cast (${door(live, base)})`,
2997
3017
  `${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)"}`),
3018
+ // Four empty lines say the same thing four times (round twenty, entry 56): once is enough, and it says what empty means.
3019
+ ...(CHARACTER_FIELDS.every((field) => !(person[field] ?? "").trim())
3020
+ ? [` ${CHARACTER_FIELDS.join(", ")}: (empty — nothing given yet, nothing invented; update_character fills a line)`]
3021
+ : CHARACTER_FIELDS.map((field) => ` ${field}: ${(person[field] ?? "").trim() || "(empty)"}`)),
2999
3022
  ...parts.flatMap((part) =>
3000
3023
  part.on.length
3001
3024
  ? [` "${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)})` : ""}`)]
@@ -3191,7 +3214,8 @@ function foldLine(state, fold, thread) {
3191
3214
  const head = (id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`;
3192
3215
  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
3216
  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(", ")}.` : "";
3217
+ 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.` : "";
3218
+ return did.length ? ` Tied at both ends, so it is the fold's now: ${did.join(", ")}.${adjacent}` : adjacent;
3195
3219
  }
3196
3220
 
3197
3221
  function threadLine(state, thread) {
@@ -3508,7 +3532,7 @@ server.registerTool(
3508
3532
  {
3509
3533
  title: "Set the project's premise",
3510
3534
  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.",
3535
+ "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
3536
  inputSchema: { premise: z.string().optional(), open: z.string().optional() },
3513
3537
  },
3514
3538
  async (args) => {
@@ -3716,7 +3740,7 @@ server.registerTool(
3716
3740
  {
3717
3741
  title: "Start a project",
3718
3742
  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\".",
3743
+ "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 pilot, or the film\"), so a board born from a maybe is not silently \"Board 1\".",
3720
3744
  inputSchema: { name: z.string().min(1), board: z.string().optional(), boardOpen: z.string().optional(), pages: pagesSchema.optional(), minutes: z.number().positive().optional() },
3721
3745
  },
3722
3746
  async (args) => {
@@ -3837,7 +3861,7 @@ server.registerTool(
3837
3861
  {
3838
3862
  title: "Save the project as a file",
3839
3863
  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.`,
3864
+ `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
3865
  inputSchema: { path: z.string().optional() },
3842
3866
  },
3843
3867
  async (args) => {
@@ -3957,7 +3981,7 @@ server.registerTool(
3957
3981
  "rename_board",
3958
3982
  {
3959
3983
  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.",
3984
+ 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
3985
  inputSchema: { board: z.union([z.string().min(1), z.number()]), name: z.string().min(1).optional(), open: z.string().optional() },
3962
3986
  },
3963
3987
  async (args) => {
@@ -74,7 +74,7 @@ export type WallReading = {
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
75
  openFields: Array<{ field: "logline"; 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
  };
@@ -323,8 +274,12 @@ export function readWall(state, options = {}) {
323
274
  }
324
275
 
325
276
  // Only once the wall uses arrows does a card without one mean anything.
277
+ // A setup arrow is a claim, not a place in the story (round twenty, entry
278
+ // 25): a card touched only by one still has no order, so only follows
279
+ // arrows link a card.
326
280
  const linked = new Set();
327
281
  for (const arrow of state.arrows) {
282
+ if (arrow.kind === "setup") continue;
328
283
  linked.add(arrow.from);
329
284
  linked.add(arrow.to);
330
285
  }
@@ -339,14 +294,17 @@ export function readWall(state, options = {}) {
339
294
  }
340
295
  }
341
296
 
342
- // Two cards doing the same job.
297
+ // Two cards doing the same job. The cast's names are not the scene's words
298
+ // (round twenty, entry 15): "Con gives Ruth the key" and "Con gives Ruth
299
+ // his tools" share their people, not their job.
300
+ const nameWords = new Set((state.characters ?? []).flatMap((person) => words(person.name ?? "")));
343
301
  for (let i = 0; i < order.length; i += 1) {
344
302
  for (let j = i + 1; j < order.length; j += 1) {
345
303
  const a = order[i];
346
304
  const b = order[j];
347
305
  // A leading "Day three." is the guide's convention for when a scene
348
306
  // happens, not the scene's words (round fourteen, entry 13).
349
- if (sameScene(a.headline.replace(DAY_PREFIX, ""), b.headline.replace(DAY_PREFIX, ""))) {
307
+ if (sameScene(a.headline.replace(DAY_PREFIX, ""), b.headline.replace(DAY_PREFIX, ""), nameWords)) {
350
308
  findings.push({
351
309
  kind: "duplicate",
352
310
  ids: [a.id, b.id],
@@ -505,13 +463,12 @@ export function readWall(state, options = {}) {
505
463
  // Threads (R60): a named string through cards, in story order, either end
506
464
  // open until the writer ties it. The reading asks about each loose end from
507
465
  // 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
- }));
466
+ const threads = (state.threads ?? []).map((thread) => {
467
+ const ids = order.filter((note) => thread.noteIds.includes(note.id)).map((note) => note.id);
468
+ // How far the string runs, in the same estimated pages a setup's distance uses (round twenty, entry 44).
469
+ const apart = ids.length >= 2 ? (startAt.get(ids[ids.length - 1]) ?? 0) - (startAt.get(ids[0]) ?? 0) : 0;
470
+ return { id: thread.id, name: thread.name, ids, startOpen: thread.startOpen === true, endOpen: thread.endOpen === true, apart };
471
+ });
515
472
  for (const thread of threads) {
516
473
  const first = thread.ids.length ? byId.get(thread.ids[0]) : null;
517
474
  const last = thread.ids.length ? byId.get(thread.ids[thread.ids.length - 1]) : null;
@@ -635,6 +592,7 @@ export function describeRuns(reading, state) {
635
592
  // from written text, or the cards' guess, or some of each.
636
593
  const written = (run.ids ?? []).filter((id) => isMeasured(byId.get(id) ?? {})).length;
637
594
  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}`;
595
+ const amount = pages(run.eighths);
596
+ return `${span}: about ${amount} ${amount === "1" ? "page" : "pages"}, ${count}${whose}`;
639
597
  });
640
598
  }
@@ -474,6 +474,73 @@ function cleanOpen(value) {
474
474
  return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
475
475
  }
476
476
 
477
+
478
+ // --- Order (R56, moved here for R62) -------------------------------------
479
+ //
480
+ // Story order is the follows arrows where they exist and the wall's reading
481
+ // order — rows top to bottom, cards left to right — where they do not. The
482
+ // reading composes everything from it; the kernel needs it to hold a
483
+ // thread's cards in story order, so the tie rule folds the first card and
484
+ // not the last one the writer happened to add.
485
+
486
+ /** Cards within half a card's height of each other sit on one row. */
487
+ const ROW_TOLERANCE = NOTE_HEIGHT / 2;
488
+
489
+ export function readingOrder(notes) {
490
+ const byTop = [...notes].sort((a, b) => a.y - b.y || a.x - b.x);
491
+ const rows = [];
492
+ let row = null;
493
+ for (const note of byTop) {
494
+ if (row && note.y - row.top <= ROW_TOLERANCE) {
495
+ row.notes.push(note);
496
+ } else {
497
+ row = { top: note.y, notes: [note] };
498
+ rows.push(row);
499
+ }
500
+ }
501
+ return rows.flatMap((band) => band.notes.sort((a, b) => a.x - b.x || a.y - b.y));
502
+ }
503
+
504
+ /**
505
+ * Cards in reading order: banded into rows by y, then left to right. A free
506
+ * wall has no rows, so this is the order a person's eye takes across it.
507
+ */
508
+ /**
509
+ * Story order: reading order, with each follows arrow pulling its source in
510
+ * front of its target; a pair pointing both ways is a tie and reading order
511
+ * keeps it. The order organize lays the wall out in, and the order every
512
+ * reading, numbering and page uses (R56). Returns the notes.
513
+ */
514
+ export function storyOrder(state, ids) {
515
+ const scope = ids ? new Set(ids) : null;
516
+ const notes = state.notes.filter((note) => !scope || scope.has(note.id));
517
+ const reading = readingOrder(notes);
518
+ const byId = new Map(reading.map((note) => [note.id, note]));
519
+ const rank = new Map(reading.map((note, index) => [note.id, index]));
520
+ const preds = new Map(reading.map((note) => [note.id, []]));
521
+ for (const arrow of state.arrows ?? []) {
522
+ if (arrow.kind === "setup") continue;
523
+ if (!rank.has(arrow.from) || !rank.has(arrow.to)) continue;
524
+ preds.get(arrow.to).push(arrow.from);
525
+ }
526
+ for (const list of preds.values()) list.sort((a, b) => rank.get(a) - rank.get(b));
527
+ const placed = new Set();
528
+ const visiting = new Set();
529
+ const order = [];
530
+ function visit(id) {
531
+ if (placed.has(id) || visiting.has(id)) return;
532
+ visiting.add(id);
533
+ for (const from of preds.get(id)) {
534
+ if (preds.get(from).includes(id)) continue;
535
+ visit(from);
536
+ }
537
+ visiting.delete(id);
538
+ placed.add(id);
539
+ order.push(byId.get(id));
540
+ }
541
+ for (const note of reading) visit(note.id);
542
+ return order;
543
+ }
477
544
  /**
478
545
  * The combine log's rule, both halves (R60, R62). A thread tied at both ends
479
546
  * through two or more cards is a plant between two scenes that exist, and
@@ -504,16 +571,20 @@ function tieIntoFold(state, thread, now) {
504
571
  notes: next.notes.map((note) => (note.id === firstId ? bump(note, { plants: true, plantsWhat: thread.name }, now) : note)),
505
572
  };
506
573
  }
507
- const hasArrow = next.arrows.some((arrow) => arrow.kind === "setup" && arrow.from === firstId && arrow.to === lastId);
574
+ // One arrow per direction between two cards (R15): a follows arrow already
575
+ // running from the first card to the last means the payoff is the very next
576
+ // scene, and the setup cannot be drawn over it.
577
+ const existing = next.arrows.find((arrow) => arrow.from === firstId && arrow.to === lastId);
508
578
  let arrow = null;
509
- if (!hasArrow) {
579
+ const adjacent = Boolean(existing && existing.kind !== "setup");
580
+ if (!existing) {
510
581
  const drawn = applyCommand(next, { type: "create_arrow", from: firstId, to: lastId, kind: "setup" }, now);
511
582
  if (drawn.changed) {
512
583
  next = drawn.state;
513
584
  arrow = { from: firstId, to: lastId };
514
585
  }
515
586
  }
516
- return { state: next, fold: { kept: false, firstId, lastId, folded, named, arrow } };
587
+ return { state: next, fold: { kept: false, firstId, lastId, folded, named, arrow, adjacent } };
517
588
  }
518
589
 
519
590
  /** A thread's name as the writer typed it, one line, spaces collapsed (R60). */
@@ -809,7 +880,9 @@ export function applyCommand(state, command, now = nowIso()) {
809
880
  case "create_thread": {
810
881
  const name = cleanThreadName(command.name);
811
882
  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));
883
+ const wanted = (command.noteIds ?? []).filter((id, index, all) => all.indexOf(id) === index && state.notes.some((note) => note.id === id));
884
+ // Held in story order, whatever order the writer named them (R62).
885
+ const noteIds = storyOrder(state, wanted).map((note) => note.id);
813
886
  const thread = {
814
887
  id: typeof command.id === "string" && command.id && !(state.threads ?? []).some((item) => item.id === command.id) ? command.id : newId(),
815
888
  name,
@@ -828,6 +901,9 @@ export function applyCommand(state, command, now = nowIso()) {
828
901
  let noteIds = Array.isArray(command.noteIds) ? command.noteIds.filter((id, index, all) => all.indexOf(id) === index && exists(id)) : [...current.noteIds];
829
902
  if (Array.isArray(command.add)) for (const id of command.add) if (exists(id) && !noteIds.includes(id)) noteIds.push(id);
830
903
  if (Array.isArray(command.remove)) noteIds = noteIds.filter((id) => !command.remove.includes(id));
904
+ // Held in story order, whatever order the cards were added (R62): the
905
+ // rule below folds the first card in the story, not the last one named.
906
+ noteIds = storyOrder(state, noteIds).map((note) => note.id);
831
907
  const name = command.name === undefined ? current.name : cleanThreadName(command.name) || current.name;
832
908
  const startOpen = typeof command.startOpen === "boolean" ? command.startOpen : current.startOpen;
833
909
  const endOpen = typeof command.endOpen === "boolean" ? command.endOpen : current.endOpen;
@@ -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" },