plotcoder-board 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,7 +41,7 @@ Every board verb goes through one command kernel, `src/board/reducer.js`, and th
41
41
 
42
42
  - **The wall.** Tap the words to type, drag the paper to move. Lasso to select, then Group. Drag a card's handle onto another card for an arrow. ⌘Z takes back any change, whichever door made it.
43
43
  - **`window.plotcoder`** on the page, for a console or a CDP session.
44
- - **The MCP server**, published to npm as `plotcoder-board`: `npx -y plotcoder-board@latest` is the server, `npx -y plotcoder-board@latest call <tool> '{json}'` one call from a shell, `npx -y plotcoder-board@latest serve` the hosted door on a port (a `Dockerfile` is here too). Inside the repo it is `scripts/plotcoder-mcp.mjs`, wired for Cursor in `.cursor/mcp.json` and for Claude Code in `.mcp.json` (run `npm ci` once first). A version tag (`v0.2.0`) publishes it, with `NPM_TOKEN` in the repo's secrets. Seventy-two tools: reading (`list_board`, `read_wall`, `read_pages`, `read_character`, `page_count`, `list_words`, `list_workflows`, `segment_brief`); the card, cast, place, group and arrow verbs, `set_logline`, `set_target`, `set_rank`, `set_plant`, `write_scene`, `move_scene`, `organize`, `apply_template` with `list_structures`, `save_structure`, `remove_structure`; `undo` and `redo`; the project's `list_boards`, `open_board`, `new_board`, `rename_board`, `delete_board`, `set_premise`, `rename_project` and the reminders; Fountain and Final Draft both ways; the production half (`lock_numbers`, `unlock_numbers`, `start_revision`, `end_revision`); the project as a file both ways (`export_project`, `import_project`); and, through the account door, `list_projects`, `open_project`, `new_project`, `delete_project`, `empty_account`, `delete_account`, `add_picture`, `add_take`, `list_takes`, `list_files`, `remove_file`, `build_segment`. If the dev app is open, a tool call lands on the wall within a second; if not, it edits the board file and the wall catches up on the next load.
44
+ - **The MCP server**, published to npm as `plotcoder-board`: `npx -y plotcoder-board@latest` is the server, `npx -y plotcoder-board@latest call <tool> '{json}'` one call from a shell, `npx -y plotcoder-board@latest serve` the hosted door on a port (a `Dockerfile` is here too). Inside the repo it is `scripts/plotcoder-mcp.mjs`, wired for Cursor in `.cursor/mcp.json` and for Claude Code in `.mcp.json` (run `npm ci` once first). A version tag (`v0.2.0`) publishes it, with `NPM_TOKEN` in the repo's secrets. Seventy-eight tools: reading (`list_board`, `read_wall`, `read_pages`, `read_character`, `page_count`, `list_words`, `list_workflows`, `segment_brief`, `compare_structure`); the card, cast, place, group (with `add_to_group`) and arrow verbs, `set_logline`, `set_target`, `set_rank`, `set_plant`, `write_scene`, `move_scene`, `organize`, `apply_template` with `list_structures`, `save_structure`, `remove_structure`; `leave_question` and `ask_again`; `undo` and `redo`; the project's `list_boards`, `open_board`, `new_board`, `rename_board`, `delete_board`, `set_premise`, `rename_project` and the reminders; Fountain and Final Draft both ways, Markdown and plain text out (`export_markdown`, `export_text`); the production half (`lock_numbers`, `unlock_numbers`, `start_revision`, `end_revision`); the project as a file both ways (`export_project`, `import_project`); and, through the account door, `list_projects`, `open_project`, `new_project`, `delete_project`, `empty_account`, `delete_account`, `add_picture`, `add_take`, `list_takes`, `list_files`, `remove_file`, `build_segment`. If the dev app is open, a tool call lands on the wall within a second; if not, it edits the board file and the wall catches up on the next load.
45
45
  - **The account door.** With `PLOTCODER_EMAIL` and `PLOTCODER_PASSWORD` in the agent's environment — the writer's own — and no dev app answering, the same server works the writer's project on the account directly, and every change lands live on every open wall. `PLOTCODER_PROJECT` picks a project by name or id. No account yet? `claim_account` makes one with the writer's email and a password they chose. The on-ramp — the doors, what to call first, the rules — is in the app behind *Are you an agent? Start here* and served at [plotcoder.com/llms.txt](https://plotcoder.com/llms.txt), both from `src/board/agents.js`.
46
46
 
47
47
  An agent should call the tools, never fake mouse drags. The skill in `.cursor/skills/plotcoder-board/SKILL.md` says how; `.claude/skills/plotcoder-board` is a symlink to the same file.
@@ -51,9 +51,9 @@ 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. Three rounds have been run and all 73 of their
55
- findings are fixed; the fourth ran through the account door and its twenty-three
56
- are fixed too. [`blind-runs/`](blind-runs/) holds the rules that keep a round honest,
54
+ just what produces it. Thirteen rounds have been run, the first three through a
55
+ repo checkout and the rest through the account door; every finding from the
56
+ first twelve is fixed, and round thirteen's six chosen first are too. [`blind-runs/`](blind-runs/) holds the rules that keep a round honest,
57
57
  the table of rounds, and the next round's prompt with the test account filled in.
58
58
 
59
59
  A round works a **test account** — a throwaway marked on its writer row, and the
@@ -96,4 +96,4 @@ next round into a test of `claim_account` instead of the door it meant to test.
96
96
 
97
97
  ## Status
98
98
 
99
- Version 0.1.0. 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, and goes out and comes in as Fountain or Final Draft. 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.
99
+ Version 0.1.15. 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.14",
3
+ "version": "0.1.16",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite",
@@ -41,6 +41,7 @@ import {
41
41
  import { TEMPLATES } from "../src/board/templates.js";
42
42
  import { wordSentence, wordsAsText } from "../src/board/words.js";
43
43
  import { fromFountain, mergeFountain, toFountain } from "../src/board/fountain.js";
44
+ import { toMarkdown, toPlainText } from "../src/board/markdown.js";
44
45
  import { fromProjectFile, toProjectFile } from "../src/board/projectFile.js";
45
46
  import { describeSetAside, fromFdx, toFdx } from "../src/board/fdx.js";
46
47
  import { paginate } from "../src/board/paginate.js";
@@ -74,6 +75,7 @@ import {
74
75
  liftCast,
75
76
  mergeRoster,
76
77
  sameRoster,
78
+ scriptTitles,
77
79
  withRoster,
78
80
  } from "../src/board/project.js";
79
81
 
@@ -698,7 +700,18 @@ async function writeBoard(next, rev, base, boardId = null, roster = "merge") {
698
700
  const boards = {};
699
701
  for (const [id, state] of Object.entries(held.boards)) boards[id] = withRoster(state, project);
700
702
  boards[boardId ?? project.activeBoardId] = withRoster(toWrite, project);
703
+ // The order matters on the bridge: the board frame carries the exact roster
704
+ // and lands first, so the wall records one undo step holding the card, the
705
+ // cast and the person together (R33, R51); the project frame that follows
706
+ // then changes nothing. The account door keeps the project first, because
707
+ // its live path composes a board's cast against the project row it holds.
708
+ if (base === ACCOUNT) {
709
+ await writeProject(project, boards, held.rev, held.base);
710
+ return writeBoardRaw(toWrite, rev, base, boardId);
711
+ }
712
+ const live = await writeBoardRaw(toWrite, rev, base, boardId);
701
713
  await writeProject(project, boards, held.rev, held.base);
714
+ return live;
702
715
  }
703
716
  }
704
717
  return writeBoardRaw(toWrite, rev, base, boardId);
@@ -746,6 +759,9 @@ const TRAIL_CAP = 50;
746
759
  const trail = [];
747
760
  /** What undo took back, newest last; a new change of this server's clears it. */
748
761
  const undone = [];
762
+ /** The last read_wall's questions, and what changed since: a leave answers the reading in front of the agent (round thirteen, entry 19). */
763
+ let lastReading = null;
764
+ const sinceRead = [];
749
765
 
750
766
  function describeCommand(command) {
751
767
  switch (command.type) {
@@ -787,11 +803,42 @@ async function commit(command) {
787
803
 
788
804
  const live = await writeBoard(next, rev, base, boardId, "exact");
789
805
  trail.push({ before: state, after: canon(next), what: describeCommand(command) });
806
+ sinceRead.push(describeCommand(command));
790
807
  if (trail.length > TRAIL_CAP) trail.shift();
791
808
  undone.length = 0;
792
809
  return { state: next, changed, result, live };
793
810
  }
794
811
 
812
+ /**
813
+ * Several kernel commands as one change. Read once; `build(step, current)` applies
814
+ * each command through the kernel against the running state; write once. One tool
815
+ * call is then one frame on the bridge — one ⌘Z on the wall — and one entry on this
816
+ * server's own trail, however many commands it took: create_note with its cast,
817
+ * set_plant with later, move_scene, an import. Before this, ⌘Z on the wall took the
818
+ * cast off an agent's new card and left the card (R33).
819
+ */
820
+ async function commitAll(what, build) {
821
+ const { state, rev, base, boardId } = await readBoard();
822
+ let current = state;
823
+ let changed = false;
824
+ const step = (command) => {
825
+ const out = applyCommand(current, command);
826
+ if (out.changed) {
827
+ current = out.state;
828
+ changed = true;
829
+ }
830
+ return out;
831
+ };
832
+ const value = await build(step, () => current);
833
+ if (!changed) return { state: current, changed: false, value, live: base !== null };
834
+ const live = await writeBoard(current, rev, base, boardId, "exact");
835
+ trail.push({ before: state, after: canon(current), what });
836
+ sinceRead.push(what);
837
+ if (trail.length > TRAIL_CAP) trail.shift();
838
+ undone.length = 0;
839
+ return { state: current, changed: true, value, live };
840
+ }
841
+
795
842
  /** Said once per session: that cards stack until organize (round seven, finding 11). */
796
843
  /** Where a new card lands when the agent gives no position: after the last card in reading order, wrapping five wide, so cards never stack (round eleven, finding 14). */
797
844
  function nextPlace(state) {
@@ -1045,22 +1092,37 @@ server.registerTool(
1045
1092
  {
1046
1093
  title: "Set card length",
1047
1094
  description:
1048
- "Set how long cards run, in pages. An ordinary scene is about 1; a quick beat might be 0.25; a set piece might be 3 or 4. This is an estimate the writer owns — set it when you are told a length or when the card plainly describes one, and do not silently re-estimate a card someone has already sized.",
1095
+ "Set how long cards run, in pages. An ordinary scene is about 1; a quick beat might be 0.25; a set piece might be 3 or 4. This is an estimate the writer owns — set it when you are told a length or when the card plainly describes one, and do not silently re-estimate a card someone has already sized. Pass pages \"unsized\" (or 0) to take a length away: the card claims nothing again and reads as about a page, the way a new card does.",
1049
1096
  inputSchema: {
1050
1097
  ids: z.array(z.string()).min(1),
1051
- pages: pagesSchema,
1098
+ pages: z
1099
+ .union([pagesSchema, z.literal(0), z.literal("unsized"), z.null()])
1100
+ .describe('Pages; a fraction is fine. "unsized" (or 0) takes the length away.'),
1052
1101
  },
1053
1102
  },
1054
1103
  async (args) => {
1055
- const { state, result, live } = await commit({
1104
+ const unsizing = args.pages === "unsized" || args.pages === 0 || args.pages === null;
1105
+ const { state, changed, result, live } = await commit({
1056
1106
  type: "set_length",
1057
1107
  ids: args.ids,
1058
- lengthEighths: toEighths(args.pages),
1108
+ lengthEighths: unsizing ? null : toEighths(args.pages),
1059
1109
  });
1060
- return ok(
1061
- `${result?.length ?? 0} card(s) now run ${args.pages} page(s), the writer's estimate${where(live)}. The board runs about ${formatPages(boardEighths(state))} pages against a ${formatPages(state.targetEighths)}-page target.`,
1062
- result,
1063
- );
1110
+ if (!changed) {
1111
+ const these = args.ids.length === 1 ? "the card is" : "those cards are";
1112
+ return ok(
1113
+ unsizing
1114
+ ? `Nothing to unsize: ${these} unsized already, or not on the board (list_board for the ids).`
1115
+ : `Nothing changed: ${these} at ${args.pages} page(s) already, or not on the board (list_board for the ids).`,
1116
+ );
1117
+ }
1118
+ const runtime = `The board runs about ${formatPages(boardEighths(state))} pages against a ${formatPages(state.targetEighths)}-page target.`;
1119
+ if (unsizing) {
1120
+ return ok(
1121
+ `${result.length} card(s) unsized${where(live)}: no length claimed, so each reads as about a page until someone sizes it, and list_board says "unsized". ${runtime}`,
1122
+ result,
1123
+ );
1124
+ }
1125
+ return ok(`${result.length} card(s) now run ${args.pages} page(s), the writer's estimate${where(live)}. ${runtime}`, result);
1064
1126
  },
1065
1127
  );
1066
1128
 
@@ -1106,45 +1168,47 @@ server.registerTool(
1106
1168
  },
1107
1169
  async (args) => {
1108
1170
  const landing = args.x === undefined && args.y === undefined ? nextPlace((await readBoard()).state) : { x: args.x, y: args.y };
1109
- let { result, live } = await commit({
1110
- type: "create_note",
1111
- headline: args.headline,
1112
- change: args.change,
1113
- // One colour unless the agent chooses: a wall an agent builds in one go
1114
- // would otherwise stripe through the cycle, and a writer reads a pattern
1115
- // into it (round four, finding 17). The wall's own new-card button keeps
1116
- // cycling for a person adding cards by hand.
1117
- color: args.color ?? "yellow",
1118
- rank: args.rank,
1119
- lengthEighths: args.pages === undefined ? undefined : toEighths(args.pages),
1120
- plants: args.plants,
1121
- location: args.location,
1122
- x: landing.x,
1123
- y: landing.y,
1124
- });
1125
- let castLine = "";
1126
- if (args.characters && args.characters.length && result?.id) {
1127
- const added = [];
1128
- const ids = [];
1129
- for (const name of args.characters) {
1130
- const { state } = await readBoard();
1131
- const wanted = name.trim().toLowerCase();
1132
- let person = state.characters.find((item) => item.id === name) ?? state.characters.find((item) => item.name.trim().toLowerCase() === wanted);
1133
- if (!person) {
1134
- const made = await commit({ type: "add_character", name: name.trim() });
1135
- person = made.result;
1136
- if (person) added.push(`${person.name} (${person.id})`);
1171
+ const names = (args.characters ?? []).map((name) => name.trim()).filter(Boolean);
1172
+ const added = [];
1173
+ // The card, anyone new in its cast, and the casting land as one change, so
1174
+ // one ⌘Z on the wall takes back the whole call and not just the cast.
1175
+ const { value: result, live } = await commitAll(`create_note "${args.headline}"`, (step, current) => {
1176
+ let made = step({
1177
+ type: "create_note",
1178
+ headline: args.headline,
1179
+ change: args.change,
1180
+ // One colour unless the agent chooses: a wall an agent builds in one go
1181
+ // would otherwise stripe through the cycle, and a writer reads a pattern
1182
+ // into it (round four, finding 17). The wall's own new-card button keeps
1183
+ // cycling for a person adding cards by hand.
1184
+ color: args.color ?? "yellow",
1185
+ rank: args.rank,
1186
+ lengthEighths: args.pages === undefined ? undefined : toEighths(args.pages),
1187
+ plants: args.plants,
1188
+ location: args.location,
1189
+ x: landing.x,
1190
+ y: landing.y,
1191
+ }).result;
1192
+ if (names.length && made?.id) {
1193
+ const ids = [];
1194
+ for (const name of names) {
1195
+ const wanted = name.toLowerCase();
1196
+ let person = current().characters.find((item) => item.id === name) ?? current().characters.find((item) => item.name.trim().toLowerCase() === wanted);
1197
+ if (!person) {
1198
+ person = step({ type: "add_character", name }).result;
1199
+ if (person) added.push(`${person.name} (${person.id})`);
1200
+ }
1201
+ if (person && !ids.includes(person.id)) ids.push(person.id);
1202
+ }
1203
+ if (ids.length) {
1204
+ const cast = step({ type: "set_cast", ids: [made.id], characterIds: ids });
1205
+ // The card as it is now, cast and all, so the reply's JSON agrees with its prose.
1206
+ made = cast.state.notes.find((note) => note.id === made.id) ?? made;
1137
1207
  }
1138
- if (person) ids.push(person.id);
1139
- }
1140
- if (ids.length) {
1141
- const cast = await commit({ type: "set_cast", ids: [result.id], characterIds: ids });
1142
- // The card as it is now, cast and all, so the reply's JSON agrees with its prose.
1143
- const after = cast.state.notes.find((note) => note.id === result.id);
1144
- if (after) result = after;
1145
1208
  }
1146
- castLine = ` Cast: ${args.characters.map((name) => name.trim()).join(", ")}${added.length ? ` (added to the roster: ${added.join(", ")})` : ""}.`;
1147
- }
1209
+ return made;
1210
+ });
1211
+ const castLine = names.length && result?.id ? ` Cast: ${names.join(", ")}${added.length ? ` (added to the roster: ${added.join(", ")})` : ""}.` : "";
1148
1212
  const landed = [
1149
1213
  result?.rank === "beat" ? "a beat" : "a scene",
1150
1214
  result?.lengthEighths === null ? "about a page (unsized: the writer's guess until set_length)" : `${formatPages(noteEighths(result))} ${formatPages(noteEighths(result)) === "1" ? "page" : "pages"}`,
@@ -1222,13 +1286,20 @@ server.registerTool(
1222
1286
  {
1223
1287
  title: "Delete note",
1224
1288
  description:
1225
- "Remove a card from the board. Also removes any arrows touching it and drops it from groups.",
1289
+ "Remove a card from the board. Its arrows go with it and it leaves its group; the reply names each arrow by its cards and says what the group kept, and undo brings all of it back.",
1226
1290
  inputSchema: { id: z.string() },
1227
1291
  },
1228
1292
  async (args) => {
1229
- const { result } = await commit({ type: "delete_note", id: args.id });
1293
+ const { result, live } = await commit({ type: "delete_note", id: args.id });
1230
1294
  if (result === undefined) return ok(`No card with id ${args.id}.`);
1231
- return ok("Deleted card.", result);
1295
+ // Say what went with the card (round thirteen, entry 17).
1296
+ const arrows = result.arrows.length
1297
+ ? ` Took ${result.arrows.length === 1 ? "its arrow" : `its ${result.arrows.length} arrows`} with it: ${result.arrows.map((arrow) => `"${arrow.fromHeadline}" → "${arrow.toHeadline}" (${arrow.kind})`).join(", ")}.`
1298
+ : " No arrow touched it.";
1299
+ const groups = result.groups
1300
+ .map((group) => (group.dissolved ? ` Its group "${group.title}" dissolved: a frame needs two cards.` : ` Left its group "${group.title}", which keeps ${group.remaining} card${group.remaining === 1 ? "" : "s"}.`))
1301
+ .join("");
1302
+ return ok(`Deleted "${result.headline}"${where(live)}.${arrows}${groups}`, result);
1232
1303
  },
1233
1304
  );
1234
1305
 
@@ -1249,6 +1320,8 @@ server.registerTool(
1249
1320
  const { boards: boardsForRead } = await readProject();
1250
1321
  const elsewhereForRead = castElsewhere(projectForRead, boardsForRead, readBoardId ?? projectForRead.activeBoardId);
1251
1322
  const reading = readWall(state, { elsewhere: Object.keys(elsewhereForRead) });
1323
+ lastReading = { findings: reading.findings };
1324
+ sinceRead.length = 0;
1252
1325
  const runs = describeRuns(reading, state).map((line, index) => {
1253
1326
  const ids = reading.runs[index]?.ids ?? [];
1254
1327
  return ids.length ? `${line} — ${ids.map((id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`).join(", ")}` : line;
@@ -1326,7 +1399,7 @@ server.registerTool(
1326
1399
  {
1327
1400
  title: "Leave a question, for now",
1328
1401
  description:
1329
- "Write the writer's word on a question the wall asks — \"leave it\" — so the reading stops asking it. Pass the question's kind as read_wall names it (sag, empty, unpaid, …) and, when that kind is asked more than once, its ids as read_wall lists them. The wall keeps the question and asks it again on its own the moment it would read differently — a card in it changes, a page moves, the median shifts — so a left question is never a dismissal; ask_again brings one back now. Only on the writer's word: never leave a question unasked.",
1402
+ "Write the writer's word on a question the wall asks — \"leave it\" — so the reading stops asking it. Pass the question's kind as read_wall names it (sag, empty, unpaid, …) and, when that kind is asked more than once, its ids as read_wall lists them. A leave answers the reading in front of you: edits change the questions, so make the writer's changes first, read_wall, then leave what they still want left — a question that changed or went since the last reading is refused, with what it was. The wall keeps a left question and asks it again on its own the moment it would read differently — a card in it changes, a page moves, the median shifts — so a left question is never a dismissal; ask_again brings one back now. Only on the writer's word: never leave a question unasked.",
1330
1403
  inputSchema: { kind: z.string().min(1), ids: z.array(z.string()).optional() },
1331
1404
  },
1332
1405
  async (args) => {
@@ -1336,7 +1409,21 @@ server.registerTool(
1336
1409
  const matches = reading.findings.filter((finding) => finding.kind === args.kind && (!args.ids || sameList(finding.ids, args.ids)));
1337
1410
  if (matches.length === 0) {
1338
1411
  if (already.length) return ok(`Already left: [${args.kind}] ${already[0].text} It stays left until the question would read differently; ask_again brings it back.`);
1339
- return ok(`The wall is not asking a question of kind "${args.kind}"${args.ids ? ` about ids ${args.ids.join(", ")}` : ""}. read_wall lists the questions it asks now, each with its kind and ids.`);
1412
+ // The reading the agent was answering, when the last read_wall had this question (round thirteen, entry 19).
1413
+ const earlier = lastReading?.findings.find((finding) => finding.kind === args.kind && (!args.ids || sameList(finding.ids, args.ids)));
1414
+ const now = reading.findings.filter((finding) => finding.kind === args.kind);
1415
+ if (earlier) {
1416
+ const since = sinceRead.length
1417
+ ? `${sinceRead.length} change${sinceRead.length === 1 ? "" : "s"} landed since (${[...new Set(sinceRead)].join(", ")})`
1418
+ : "the wall changed elsewhere since";
1419
+ const state_ = now.length
1420
+ ? `the wall now asks ${now.length === 1 ? "it differently" : `${now.length} questions of that kind`}: ${now.map((finding) => `${finding.text} (ids: ${finding.ids.join(", ")})`).join("; ")}`
1421
+ : "the wall no longer asks it — the cards answered it";
1422
+ return ok(
1423
+ `Not left. When you last read the wall it asked [${earlier.kind}] ${earlier.text}${earlier.ids.length ? ` (ids: ${earlier.ids.join(", ")})` : ""}; ${since}, and ${state_}. A leave answers the reading in front of you: make the writer's edits first, read_wall, then leave what they still want left.`,
1424
+ );
1425
+ }
1426
+ return ok(`The wall is not asking a question of kind "${args.kind}"${args.ids ? ` about ids ${args.ids.join(", ")}` : ""}. read_wall lists the questions it asks now, each with its kind and ids.${sinceRead.length ? ` ${sinceRead.length} change(s) landed since the last read_wall, so read it again first.` : ""}`);
1340
1427
  }
1341
1428
  if (matches.length > 1) {
1342
1429
  return ok(`The wall asks ${matches.length} questions of kind "${args.kind}"; pass ids to say which:\n${matches.map((finding) => ` - ${finding.text} (ids: ${finding.ids.join(", ")})`).join("\n")}`);
@@ -1392,46 +1479,41 @@ server.registerTool(
1392
1479
  if (!state.arrows.some(isFollows)) {
1393
1480
  return ok("The wall has no follows arrows, so there is no story order to move within: create_arrow the sequence first, or move_note the card by position.");
1394
1481
  }
1395
- const trailBefore = trail.length;
1396
1482
  let removed = 0;
1397
1483
  let drawn = 0;
1398
- let live = false;
1399
- const step = async (command) => {
1400
- const done = await commit(command);
1401
- if (done.changed) {
1402
- live = done.live;
1403
- if (command.type === "delete_arrow") removed += 1;
1404
- if (command.type === "create_arrow") drawn += 1;
1405
- }
1406
- return done;
1407
- };
1408
- // Leave: what pointed at the card points at what the card pointed at.
1409
- const ins = state.arrows.filter((arrow) => isFollows(arrow) && arrow.to === card.id);
1410
- const outs = state.arrows.filter((arrow) => isFollows(arrow) && arrow.from === card.id);
1411
- for (const arrow of [...ins, ...outs]) await step({ type: "delete_arrow", id: arrow.id });
1412
- for (const before of ins) for (const after of outs) if (before.from !== after.to) await step({ type: "create_arrow", from: before.from, to: after.to, kind: "follows" });
1413
- // Land: between the target and what followed it (or what led to it).
1414
- const { state: mid } = await readBoard();
1415
- if (args.after) {
1416
- for (const arrow of mid.arrows.filter((item) => isFollows(item) && item.from === target.id && item.to !== card.id)) {
1417
- await step({ type: "delete_arrow", id: arrow.id });
1418
- await step({ type: "create_arrow", from: card.id, to: arrow.to, kind: "follows" });
1419
- }
1420
- await step({ type: "create_arrow", from: target.id, to: card.id, kind: "follows" });
1421
- } else {
1422
- for (const arrow of mid.arrows.filter((item) => isFollows(item) && item.to === target.id && item.from !== card.id)) {
1423
- await step({ type: "delete_arrow", id: arrow.id });
1424
- await step({ type: "create_arrow", from: arrow.from, to: card.id, kind: "follows" });
1484
+ // The whole move — its dozen arrows and the tidy — as one change: one frame on
1485
+ // the bridge, one ⌘Z on the wall, one step for undo here.
1486
+ const { state: final, live } = await commitAll(`move_scene "${card.headline}"`, (step, current) => {
1487
+ const run = (command) => {
1488
+ const done = step(command);
1489
+ if (done.changed) {
1490
+ if (command.type === "delete_arrow") removed += 1;
1491
+ if (command.type === "create_arrow") drawn += 1;
1492
+ }
1493
+ return done;
1494
+ };
1495
+ // Leave: what pointed at the card points at what the card pointed at.
1496
+ const ins = state.arrows.filter((arrow) => isFollows(arrow) && arrow.to === card.id);
1497
+ const outs = state.arrows.filter((arrow) => isFollows(arrow) && arrow.from === card.id);
1498
+ for (const arrow of [...ins, ...outs]) run({ type: "delete_arrow", id: arrow.id });
1499
+ for (const before of ins) for (const after of outs) if (before.from !== after.to) run({ type: "create_arrow", from: before.from, to: after.to, kind: "follows" });
1500
+ // Land: between the target and what followed it (or what led to it).
1501
+ const mid = current();
1502
+ if (args.after) {
1503
+ for (const arrow of mid.arrows.filter((item) => isFollows(item) && item.from === target.id && item.to !== card.id)) {
1504
+ run({ type: "delete_arrow", id: arrow.id });
1505
+ run({ type: "create_arrow", from: card.id, to: arrow.to, kind: "follows" });
1506
+ }
1507
+ run({ type: "create_arrow", from: target.id, to: card.id, kind: "follows" });
1508
+ } else {
1509
+ for (const arrow of mid.arrows.filter((item) => isFollows(item) && item.to === target.id && item.from !== card.id)) {
1510
+ run({ type: "delete_arrow", id: arrow.id });
1511
+ run({ type: "create_arrow", from: arrow.from, to: card.id, kind: "follows" });
1512
+ }
1513
+ run({ type: "create_arrow", from: card.id, to: target.id, kind: "follows" });
1425
1514
  }
1426
- await step({ type: "create_arrow", from: card.id, to: target.id, kind: "follows" });
1427
- }
1428
- const { state: linked } = await readBoard();
1429
- const tidied = await step({ type: "apply_poses", poses: organizePoses(linked, {}) });
1430
- const final = tidied.state;
1431
- // One step for undo: the whole move, not its dozen arrows.
1432
- trail.splice(trailBefore);
1433
- trail.push({ before: state, after: canon(final), what: `move_scene "${card.headline}"` });
1434
- undone.length = 0;
1515
+ run({ type: "apply_poses", poses: organizePoses(current(), {}) });
1516
+ });
1435
1517
  const order = readingOrder(final.notes);
1436
1518
  const group = final.groups.find((item) => item.noteIds.includes(card.id));
1437
1519
  const groupLine = group ? ` It is still in "${group.title || "an untitled group"}"; a frame does not follow a move, so say if the act or sequence should change.` : "";
@@ -1605,23 +1687,65 @@ server.registerTool(
1605
1687
  {
1606
1688
  title: "Export the wall as Fountain",
1607
1689
  description:
1608
- "The open board as a Fountain screenplay: a title page (with the premise and logline in its notes), beats as sections, one scene per card in wall order — a forced heading from the card's place (or its headline), the headline as a synopsis, the cast and the fold as notes, the change line as action. Plain text a writer can open in any Fountain editor. Pass a path to write a .fountain file; otherwise the text comes back.",
1690
+ "The open board as a Fountain screenplay: a title page (with the premise and logline in its notes), beats as sections, one scene per card in wall order — a forced heading from the card's place (or its headline), the headline as a synopsis, the cast and the fold as notes, the change line as action after the mark [Unwritten] until the scene is written. Titled for the project, a one-board film being its project. Plain text a writer can open in any Fountain editor. Pass a path to write a .fountain file; otherwise the text comes back.",
1609
1691
  inputSchema: { path: z.string().optional() },
1610
1692
  },
1611
1693
  async (args) => {
1612
1694
  const { state } = await readBoard();
1613
1695
  const { project } = await readProject();
1614
1696
  const board = project.boards.find((item) => item.id === project.activeBoardId);
1615
- const text = toFountain(state, {
1616
- title: board?.name,
1617
- project: project.boards.length > 1 && project.name !== "Untitled project" ? project.name : undefined,
1618
- premise: project.premise || undefined,
1619
- draftDate: new Date().toISOString(),
1620
- });
1697
+ const titles = scriptTitles(project, board);
1698
+ const text = toFountain(state, { ...titles, premise: project.premise || undefined, draftDate: new Date().toISOString() });
1699
+ if (args.path) {
1700
+ fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
1701
+ fs.writeFileSync(args.path, text);
1702
+ return ok(`Wrote ${text.split("\n").length} lines of Fountain, titled "${titles.title}", to ${args.path}.`);
1703
+ }
1704
+ return ok(text);
1705
+ },
1706
+ );
1707
+
1708
+ server.registerTool(
1709
+ "export_markdown",
1710
+ {
1711
+ title: "Export the wall as Markdown",
1712
+ description:
1713
+ "The open board as Markdown, for a collaborator who lives in Google Docs or the like: titled for the project — a one-board film is its project, and the board's name follows only when the project has several boards — the premise and the logline under it, beats as second-level headings, a third-level heading per scene from its place with its scene number, the headline as a synopsis line, then the scene's text — a speech as its cue in bold with the lines under it — or, unwritten, its change line in italics after the mark [Unwritten], so a reader can tell a placeholder from a page. Pass a path to write a .md file; otherwise the text comes back.",
1714
+ inputSchema: { path: z.string().optional() },
1715
+ },
1716
+ async (args) => {
1717
+ const { state } = await readBoard();
1718
+ const { project } = await readProject();
1719
+ const board = project.boards.find((item) => item.id === project.activeBoardId);
1720
+ const titles = scriptTitles(project, board);
1721
+ const text = toMarkdown(state, { ...titles, premise: project.premise || undefined });
1722
+ if (args.path) {
1723
+ fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
1724
+ fs.writeFileSync(args.path, text);
1725
+ return ok(`Wrote ${text.split("\n").length} lines of Markdown, titled "${titles.title}", to ${path.resolve(args.path)}.`);
1726
+ }
1727
+ return ok(text);
1728
+ },
1729
+ );
1730
+
1731
+ server.registerTool(
1732
+ "export_text",
1733
+ {
1734
+ title: "Export the script as plain text",
1735
+ description:
1736
+ "The open board's script as plain text, set as it prints: the paginator's lines at Courier's columns kept with spaces, scene numbers in both margins (the wall's order, or as locked), no page numbers, an unwritten scene's change line as action after the mark [Unwritten]. Titled for the project, a one-board film being its project. Pastes into anything and reads as a script wherever the font is monospaced. Pass a path to write a .txt file; otherwise the text comes back.",
1737
+ inputSchema: { path: z.string().optional() },
1738
+ },
1739
+ async (args) => {
1740
+ const { state } = await readBoard();
1741
+ const { project } = await readProject();
1742
+ const board = project.boards.find((item) => item.id === project.activeBoardId);
1743
+ const titles = scriptTitles(project, board);
1744
+ const text = toPlainText(state, titles);
1621
1745
  if (args.path) {
1622
1746
  fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
1623
1747
  fs.writeFileSync(args.path, text);
1624
- return ok(`Wrote ${text.split("\n").length} lines of Fountain to ${args.path}.`);
1748
+ return ok(`Wrote ${text.split("\n").length} lines of plain text, titled "${titles.title}", to ${path.resolve(args.path)}.`);
1625
1749
  }
1626
1750
  return ok(text);
1627
1751
  },
@@ -1696,8 +1820,10 @@ server.registerTool(
1696
1820
  const { state } = await readBoard();
1697
1821
  const parsed = fromFountain(source);
1698
1822
  const { commands, matched } = mergeFountain(state, parsed);
1699
- let live = false;
1700
- for (const command of commands) ({ live } = await commit(command));
1823
+ // The whole import as one change, so one undo takes every scene back.
1824
+ const { live } = await commitAll(`import_fountain (${parsed.scenes.length} scene(s))`, (step) => {
1825
+ for (const command of commands) step(command);
1826
+ });
1701
1827
  const written = commands.filter((command) => command.type === "set_text").length;
1702
1828
  const created = matched.filter((item) => item.created).length;
1703
1829
  return ok(
@@ -1762,18 +1888,19 @@ server.registerTool(
1762
1888
  {
1763
1889
  title: "Export as Final Draft",
1764
1890
  description:
1765
- "The open board as a Final Draft .fdx: a heading per card with its scene number by wall order, the scene's text as script paragraphs (action, character, parenthetical, dialogue, dual dialogue, transition) or the change line as action when unwritten, and a title page. Pass a path to write the file; otherwise the XML comes back.",
1891
+ "The open board as a Final Draft .fdx: a heading per card with its scene number by wall order, the scene's text as script paragraphs (action, character, parenthetical, dialogue, dual dialogue, transition) or the change line as action after the mark [Unwritten] when unwritten, and a title page for the project (a one-board film is its project). Pass a path to write the file; otherwise the XML comes back.",
1766
1892
  inputSchema: { path: z.string().optional() },
1767
1893
  },
1768
1894
  async (args) => {
1769
1895
  const { state } = await readBoard();
1770
1896
  const { project } = await readProject();
1771
1897
  const board = project.boards.find((item) => item.id === project.activeBoardId);
1772
- const xml = toFdx(state, { title: board?.name, project: project.boards.length > 1 ? project.name : undefined, draftDate: new Date().toISOString() });
1898
+ const titles = scriptTitles(project, board);
1899
+ const xml = toFdx(state, { ...titles, draftDate: new Date().toISOString() });
1773
1900
  if (args.path) {
1774
1901
  fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
1775
1902
  fs.writeFileSync(args.path, xml);
1776
- return ok(`Wrote a Final Draft file with ${state.notes.length} scene(s) to ${args.path}.`);
1903
+ return ok(`Wrote a Final Draft file with ${state.notes.length} scene(s), titled "${titles.title}", to ${args.path}.`);
1777
1904
  }
1778
1905
  return ok(xml);
1779
1906
  },
@@ -1793,8 +1920,10 @@ server.registerTool(
1793
1920
  const { state } = await readBoard();
1794
1921
  const parsed = fromFdx(source);
1795
1922
  const { commands, matched } = mergeFountain(state, parsed);
1796
- let live = false;
1797
- for (const command of commands) ({ live } = await commit(command));
1923
+ // The whole import as one change, so one undo takes every scene back.
1924
+ const { live } = await commitAll(`import_fdx (${parsed.scenes.length} scene(s))`, (step) => {
1925
+ for (const command of commands) step(command);
1926
+ });
1798
1927
  const written = commands.filter((command) => command.type === "set_text").length;
1799
1928
  const created = matched.filter((item) => item.created).length;
1800
1929
  const receipt = describeSetAside(parsed.setAside);
@@ -2107,39 +2236,40 @@ server.registerTool(
2107
2236
  },
2108
2237
  },
2109
2238
  async (args) => {
2110
- let { result, live, changed } = await commit({
2111
- type: "set_plant",
2112
- ids: args.ids,
2113
- plants: args.plants,
2114
- });
2115
- let laterLine = "";
2239
+ // A series plant (R50): the fold pays off on another board of the project.
2240
+ // The kernel cannot check the board exists; this door can, before anything lands.
2241
+ let target = null;
2242
+ let forgetting = false;
2116
2243
  if (args.plants && args.later !== undefined) {
2117
- // A series plant (R50): the fold pays off on another board of the project.
2118
- // The kernel cannot check the board exists; this door can.
2119
2244
  const { project } = await readProject();
2120
- const { state: now, boardId: current } = await readBoard();
2121
- const here = now.notes.filter((note) => args.ids.includes(note.id));
2245
+ const { boardId: current } = await readBoard();
2122
2246
  if (args.later.trim() === "") {
2123
- const cleared = await commit({ type: "set_payoff_board", ids: args.ids, boardId: null });
2124
- if (cleared.changed) {
2125
- result = cleared.result;
2126
- live = cleared.live;
2127
- changed = true;
2128
- laterLine = " The board it paid off on is forgotten; read_wall asks again until a setup arrow or a board pays it off.";
2129
- }
2247
+ forgetting = true;
2130
2248
  } else {
2131
- const target = findBoard(project, args.later);
2249
+ target = findBoard(project, args.later);
2132
2250
  if (!target) return ok(`No board called "${args.later}" yet. A fold pays off later on a board of the project: new_board "${args.later}" makes it (empty), open_board back to this one, then set_plant again with later.`);
2133
2251
  if (target.id === (current ?? project.activeBoardId)) return ok(`"${target.name}" is this board. A payoff on the same board is a setup arrow: create_arrow from the fold to the scene, kind 'setup'.`);
2134
- const named = await commit({ type: "set_payoff_board", ids: args.ids, boardId: target.id });
2135
- if (named.changed) {
2136
- result = named.result;
2137
- live = named.live;
2138
- changed = true;
2252
+ }
2253
+ }
2254
+ // The fold and the board it pays off on land as one change: one ⌘Z on the wall.
2255
+ const { value, live, changed } = await commitAll("set_plant", (step, current) => {
2256
+ let { result } = step({ type: "set_plant", ids: args.ids, plants: args.plants });
2257
+ let laterLine = "";
2258
+ if (forgetting) {
2259
+ const cleared = step({ type: "set_payoff_board", ids: args.ids, boardId: null });
2260
+ if (cleared.changed) {
2261
+ result = cleared.result;
2262
+ laterLine = " The board it paid off on is forgotten; read_wall asks again until a setup arrow or a board pays it off.";
2139
2263
  }
2264
+ } else if (target) {
2265
+ const named = step({ type: "set_payoff_board", ids: args.ids, boardId: target.id });
2266
+ if (named.changed) result = named.result;
2267
+ const here = current().notes.filter((note) => args.ids.includes(note.id));
2140
2268
  laterLine = ` ${here.length} card(s) pay off later, on "${target.name}": read_wall stops asking where they come back, and the card says so.`;
2141
2269
  }
2142
- }
2270
+ return { result, laterLine };
2271
+ });
2272
+ const { result, laterLine } = value;
2143
2273
  const count = result?.length ?? 0;
2144
2274
  if (!changed || count === 0) return ok("No change: those cards were already that way, or the ids are not on the board.");
2145
2275
  return ok(
@@ -2411,6 +2541,29 @@ server.registerTool(
2411
2541
  },
2412
2542
  );
2413
2543
 
2544
+ server.registerTool(
2545
+ "add_to_group",
2546
+ {
2547
+ title: "Add cards to a group",
2548
+ description:
2549
+ "Put one or more cards into a group that already exists — the agent's side of dragging a card into a frame. The frame reaches the cards where they are; nothing moves. A card is in one group at a time, so it leaves any other frame on the way, and a frame left with one card dissolves. Needs the group's id and the cards' ids from list_board; organize keeps a group together as a block.",
2550
+ inputSchema: { id: z.string(), noteIds: z.array(z.string()).min(1) },
2551
+ },
2552
+ async (args) => {
2553
+ const { state, changed, result, live } = await commit({ type: "add_to_group", id: args.id, noteIds: args.noteIds });
2554
+ if (!changed) {
2555
+ if (!state.groups.some((group) => group.id === args.id)) return ok(`No group with id ${args.id}. Call list_board for the real ids; create_group makes a new frame.`);
2556
+ const missing = args.noteIds.filter((id) => !state.notes.some((note) => note.id === id));
2557
+ return ok(missing.length ? `Nothing added: not on the board — ${missing.join(", ")}. Call list_board to check the ids.` : "Nothing added: those cards are in that group already.");
2558
+ }
2559
+ const names = result.added.map((id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`).join(", ");
2560
+ const left = result.left
2561
+ .map((group) => (group.dissolved ? ` "${group.title}" dissolved on the way: a frame needs two cards.` : ` Left "${group.title}", which keeps ${group.remaining} card${group.remaining === 1 ? "" : "s"}.`))
2562
+ .join("");
2563
+ return ok(`Added ${names} to "${result.group.title}", which now holds ${result.group.noteIds.length} cards${where(live)}. The frame reaches them where they are; organize lays the group out as a block.${left}`, result);
2564
+ },
2565
+ );
2566
+
2414
2567
  server.registerTool(
2415
2568
  "rename_group",
2416
2569
  {
package/src/board/fdx.js CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { parseScene, TRANSITION } from "./paginate.js";
14
14
  import { readingOrder } from "./readWall.js";
15
- import { sceneHeading } from "./fountain.js";
15
+ import { sceneHeading, standInFor } from "./fountain.js";
16
16
  import { sceneNumbers } from "./numbering.js";
17
17
 
18
18
  function escapeXml(text) {
@@ -60,7 +60,8 @@ export function toFdx(state, options = {}) {
60
60
  "<Text>",
61
61
  `<SceneProperties Length="" Page="" Title="${escapeXml(note.headline)}" />\n <Text>`,
62
62
  );
63
- const elements = parseScene(note.text && note.text.trim() ? note.text : note.change || "");
63
+ // Unwritten: the change line stands in as action, marked as every export marks it.
64
+ const elements = parseScene(note.text && note.text.trim() ? note.text : standInFor(note));
64
65
  for (let i = 0; i < elements.length; i += 1) {
65
66
  const element = elements[i];
66
67
  if (element.kind === "action") content += paragraph("Action", element.text);
@@ -3,6 +3,10 @@
3
3
  import type { BoardNote, BoardState } from "./reducer";
4
4
 
5
5
  export declare function sceneHeading(note: BoardNote): string;
6
+ /** The mark every export sets before an unwritten scene's change line. */
7
+ export declare const UNWRITTEN_MARK: string;
8
+ export declare function standInFor(note: Pick<BoardNote, "change">): string;
9
+ export declare function unmark(text: string | null | undefined): { text: string; marked: boolean };
6
10
 
7
11
  export declare function titlePage(titles: {
8
12
  title?: string;
@@ -26,6 +26,28 @@ export function sceneHeading(note) {
26
26
  return `.${upper(words)}`;
27
27
  }
28
28
 
29
+ /**
30
+ * The mark every export sets before an unwritten scene's change line, so a
31
+ * reader can tell a placeholder from a page (round thirteen, entry 27). One
32
+ * mark, the same words in Markdown, plain text, Fountain and Final Draft;
33
+ * coming back in, a body that is the mark and the card's change line is
34
+ * still unwritten.
35
+ */
36
+ export const UNWRITTEN_MARK = "[Unwritten]";
37
+
38
+ /** What stands in for an unwritten scene's body: the mark, then the change line. */
39
+ export function standInFor(note) {
40
+ const change = (note.change ?? "").trim();
41
+ return change ? `${UNWRITTEN_MARK} ${change}` : UNWRITTEN_MARK;
42
+ }
43
+
44
+ /** A body without its mark, and whether it carried one. */
45
+ export function unmark(text) {
46
+ const trimmed = (text ?? "").trim();
47
+ if (!trimmed.startsWith(UNWRITTEN_MARK)) return { text: trimmed, marked: false };
48
+ return { text: trimmed.slice(UNWRITTEN_MARK.length).trim(), marked: true };
49
+ }
50
+
29
51
  /** The title page block. `titles` is what the writer would put above the script. */
30
52
  export function titlePage({ title, credit, author, draftDate, notes }) {
31
53
  const lines = [];
@@ -89,8 +111,9 @@ export function toFountain(state, options = {}) {
89
111
  body.push(`[[${marks.join(" · ")}]]`);
90
112
  body.push("");
91
113
  }
92
- // The scene's text when it is written; the change line stands in until then.
93
- body.push(note.text && note.text.trim() ? note.text.trim() : note.change || "");
114
+ // The scene's text when it is written; the change line stands in until
115
+ // then, marked so a printed page never passes a placeholder off as a scene.
116
+ body.push(note.text && note.text.trim() ? note.text.trim() : standInFor(note));
94
117
  body.push("");
95
118
  }
96
119
 
@@ -206,9 +229,10 @@ export function mergeFountain(state, parsed) {
206
229
  if (found) {
207
230
  used.add(found.id);
208
231
  cursor = order.indexOf(found) + 1;
209
- // A scene whose body is the card's own change line is the export of an
210
- // unwritten card coming back: still unwritten, not a page.
211
- const standIn = !(found.text ?? "").trim() && sameWords(scene.text, found.change ?? "");
232
+ // A scene whose body is the mark, or the card's own change line, is the
233
+ // export of an unwritten card coming back: still unwritten, not a page.
234
+ const body = unmark(scene.text);
235
+ const standIn = !(found.text ?? "").trim() && (body.marked || sameWords(body.text, found.change ?? ""));
212
236
  if ((found.text ?? "") !== scene.text && !standIn) {
213
237
  commands.push({ type: "set_text", id: found.id, text: scene.text });
214
238
  }
@@ -220,13 +244,15 @@ export function mergeFountain(state, parsed) {
220
244
  const headline = scene.synopsis || titleCase(scene.heading);
221
245
  const isPlace = scene.forced && Boolean(scene.synopsis);
222
246
  const id = `scene-${Math.random().toString(36).slice(2, 8)}`;
247
+ // A marked body is an unwritten scene: its words are the change line, not a page.
248
+ const body = unmark(scene.text);
223
249
  commands.push({
224
250
  type: "create_note",
225
251
  id,
226
252
  headline,
227
- change: scene.text ? firstSentence(scene.text) : "What changes?",
253
+ change: body.marked ? body.text || "What changes?" : scene.text ? firstSentence(scene.text) : "What changes?",
228
254
  location: isPlace ? titleCase(scene.heading) : "",
229
- text: scene.text,
255
+ text: body.marked ? "" : scene.text,
230
256
  x: anchor ? anchor.x + 40 : 140,
231
257
  y: anchor ? anchor.y + 40 : 140,
232
258
  });
@@ -0,0 +1,15 @@
1
+ import type { Line } from "./paginate";
2
+ import type { BoardState } from "./reducer";
3
+
4
+ /** What a document carries above the script: the board's name, the project's when it has several boards, the premise. */
5
+ export type TakeOptions = { title?: string; project?: string; premise?: string };
6
+
7
+ /** The wall as Markdown (R54): title, premise, logline, beats as headings, a heading per scene, the text or the change line. */
8
+ export declare function toMarkdown(state: BoardState, options?: TakeOptions): string;
9
+
10
+ /** The script as plain text, set as it prints: Courier's columns kept with spaces, scene numbers in the margins. */
11
+ export declare function toPlainText(state: BoardState, options?: TakeOptions): string;
12
+
13
+ export declare const GUTTER: number;
14
+ export declare const COLUMN: Record<"character" | "more" | "parenthetical" | "dialogue", number>;
15
+ export declare function setLine(line: Line): string;
@@ -0,0 +1,179 @@
1
+ // Take the pages with you (R54): the wall as Markdown, and the script as
2
+ // plain text — for a collaborator who lives in Google Docs rather than in a
3
+ // screenwriting app. Both are pure and shared by every door: the Pages
4
+ // panel's sheet, the console, and the MCP server's export_markdown and
5
+ // export_text.
6
+ //
7
+ // Markdown is the wall read out, in wall order: the board as the title, the
8
+ // premise and the logline under it, beats as second-level headings, one
9
+ // third-level heading per scene from its place (with its scene number, locked
10
+ // or by wall order), the headline as a synopsis line, then the scene's text —
11
+ // set by what each element is, from the same rules the paginator reads by —
12
+ // or, unwritten, its change line. Nothing invisible: Markdown has no notes
13
+ // that print as nothing, so the cast and the fold stay on the wall.
14
+ //
15
+ // Plain text is the script as it prints: the paginator's lines, Courier's
16
+ // columns kept with spaces, scene numbers in both margins, no page numbers.
17
+ // It pastes into anything and reads as a script wherever the font is
18
+ // monospaced.
19
+
20
+ import { readingOrder } from "./readWall.js";
21
+ import { sceneHeading, standInFor } from "./fountain.js";
22
+ import { paginate, parseScene, WIDTH } from "./paginate.js";
23
+ import { sceneNumbers } from "./numbering.js";
24
+
25
+ function upper(text) {
26
+ return text.trim().replace(/\s+/g, " ").toUpperCase();
27
+ }
28
+
29
+ function documentTitle(options) {
30
+ const title = options.title || "Untitled";
31
+ return options.project && options.project !== title ? `${options.project} · ${title}` : title;
32
+ }
33
+
34
+ /** A scene's text as Markdown paragraphs: action as it is, a speech as its cue in bold with the lines hard-broken under it. */
35
+ function sceneMarkdown(text) {
36
+ const out = [];
37
+ for (const element of parseScene(text)) {
38
+ switch (element.kind) {
39
+ case "break":
40
+ out.push("---", "");
41
+ break;
42
+ case "speech": {
43
+ const lines = [`**${element.name}**`];
44
+ for (const part of element.parts) lines.push(part.kind === "parenthetical" ? `*${part.text}*` : part.text);
45
+ // Two trailing spaces: Markdown's line break, so the cue and its lines stay on their own lines.
46
+ out.push(lines.join(" \n"), "");
47
+ break;
48
+ }
49
+ case "centered":
50
+ case "transition":
51
+ case "action":
52
+ default:
53
+ out.push(element.text, "");
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /**
60
+ * The whole wall as Markdown.
61
+ *
62
+ * @param state the board
63
+ * @param options.title the board's name; options.project the project's name
64
+ * when the project has several boards; options.premise
65
+ */
66
+ export function toMarkdown(state, options = {}) {
67
+ const order = readingOrder(state.notes);
68
+ const numbers = sceneNumbers(order, state.lock);
69
+ const out = [`# ${documentTitle(options)}`, ""];
70
+ if (options.premise) out.push(`*${options.premise}*`, "");
71
+ if (state.logline) out.push(`**${state.logline}**`, "");
72
+ let beat = 0;
73
+ for (const note of order) {
74
+ if (note.rank === "beat") {
75
+ beat += 1;
76
+ out.push(`## ${beat}. ${note.headline || "Untitled beat"}`, "");
77
+ }
78
+ const heading = sceneHeading(note).slice(1);
79
+ out.push(`### ${numbers.get(note.id) ?? ""} · ${heading}`.replace(/^### · /, "### "), "");
80
+ if (note.headline && upper(note.headline) !== heading) out.push(`*${note.headline.trim()}*`, "");
81
+ if (note.text && note.text.trim()) out.push(...sceneMarkdown(note.text));
82
+ // Unwritten: the change line stands in, marked and in italics, so a reader
83
+ // in Docs can tell the one written scene from sixteen placeholders.
84
+ else out.push(`*${standInFor(note)}*`, "");
85
+ }
86
+ return `${out.join("\n").trimEnd()}\n`;
87
+ }
88
+
89
+ /** The column the script's body starts at: room for a scene number on the left. */
90
+ export const GUTTER = 5;
91
+
92
+ /** Where each kind of line starts, in characters from the body's left edge — the print stylesheet's columns. */
93
+ export const COLUMN = { character: 22, more: 22, parenthetical: 16, dialogue: 10 };
94
+
95
+ const DUAL_RIGHT = 32;
96
+ const DUAL_CUE = 8;
97
+
98
+ function pad(n) {
99
+ return " ".repeat(Math.max(0, n));
100
+ }
101
+
102
+ function centred(text, width = WIDTH.action) {
103
+ return pad(GUTTER + Math.floor((width - text.length) / 2)) + text;
104
+ }
105
+
106
+ /** One line of a dual-dialogue column, from the column's own left edge. */
107
+ function columnLine(line) {
108
+ if (!line || line.kind === "blank") return "";
109
+ const text = line.text ?? "";
110
+ if (line.kind === "character") return pad(DUAL_CUE) + text;
111
+ if (line.kind === "parenthetical") return pad(4) + text;
112
+ return text;
113
+ }
114
+
115
+ /** A printed line set with spaces. Exported for the test; the file is `toPlainText`. */
116
+ export function setLine(line) {
117
+ const text = line.text ?? "";
118
+ switch (line.kind) {
119
+ case "blank":
120
+ return "";
121
+ case "heading": {
122
+ const number = line.sceneNumber === null || line.sceneNumber === undefined ? "" : String(line.sceneNumber);
123
+ if (!number) return pad(GUTTER) + text;
124
+ return `${number.padEnd(GUTTER)}${text.padEnd(WIDTH.action)} ${number}`.trimEnd();
125
+ }
126
+ case "character":
127
+ case "more":
128
+ case "parenthetical":
129
+ case "dialogue":
130
+ return pad(GUTTER + COLUMN[line.kind]) + text;
131
+ case "transition":
132
+ return pad(GUTTER + WIDTH.action - text.length) + text;
133
+ case "centered":
134
+ return centred(text);
135
+ case "dual": {
136
+ const left = pad(GUTTER) + columnLine(line.left);
137
+ const right = columnLine(line.right);
138
+ return right ? `${left.padEnd(GUTTER + DUAL_RIGHT)}${right}` : left.trimEnd();
139
+ }
140
+ case "action":
141
+ default:
142
+ return pad(GUTTER) + text;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * The script as plain text, set as it prints.
148
+ *
149
+ * @param state the board
150
+ * @param options.title the board's name; options.project the project's name
151
+ * when the project has several boards
152
+ */
153
+ export function toPlainText(state, options = {}) {
154
+ const order = readingOrder(state.notes);
155
+ const numbers = sceneNumbers(order, state.lock);
156
+ const result = paginate(
157
+ order.map((note) => ({
158
+ id: note.id,
159
+ heading: sceneHeading(note).slice(1),
160
+ text: note.text,
161
+ change: standInFor(note),
162
+ written: Boolean(note.text && note.text.trim()),
163
+ number: numbers.get(note.id) ?? undefined,
164
+ })),
165
+ );
166
+ const title = options.title || "Untitled";
167
+ const out = [];
168
+ if (options.project && options.project !== title) {
169
+ out.push(centred(upper(options.project)), "", centred(title));
170
+ } else {
171
+ out.push(centred(upper(title)));
172
+ }
173
+ out.push("", "");
174
+ for (const page of result.pages) {
175
+ if (page.number > 1) out.push("");
176
+ for (const line of page.lines) out.push(setLine(line));
177
+ }
178
+ return `${out.join("\n").replace(/\n{4,}/g, "\n\n\n").trimEnd()}\n`;
179
+ }
@@ -67,6 +67,8 @@ export declare function setActiveBoard(project: ProjectRecord, id: string, now?:
67
67
  export declare function renameProject(project: ProjectRecord, name: string, now?: string): ProjectRecord;
68
68
  export declare function setPremise(project: ProjectRecord, premise: string, now?: string): ProjectRecord;
69
69
  export declare function boardById(project: ProjectRecord, id: string): BoardMeta | null;
70
+ /** What a script going out is called: a named project is the title, its board beside it only when the project has several. */
71
+ export declare function scriptTitles(project: ProjectRecord, board: BoardMeta | null | undefined): { title: string; project?: string };
70
72
  export declare function findBoard(project: ProjectRecord, key: string): BoardMeta | null;
71
73
  export declare function reidentifyProject(
72
74
  project: ProjectRecord,
@@ -289,6 +289,21 @@ export function setPremise(project, premise, now = nowIso()) {
289
289
  return touch(project, { premise: next }, now);
290
290
  }
291
291
 
292
+ /**
293
+ * What a script going out is called (round thirteen, entry 26): a named
294
+ * project is the title — a one-board film is its project — with the board's
295
+ * name beside it only when the project has several boards; an untitled
296
+ * project's board is the title. One rule for Markdown, plain text, Fountain
297
+ * and Final Draft, through every door.
298
+ */
299
+ export function scriptTitles(project, board) {
300
+ const boardName = (board?.name ?? "").trim() || "Untitled";
301
+ const named = typeof project?.name === "string" && project.name.trim() && project.name !== DEFAULT_PROJECT_NAME;
302
+ if (!named) return { title: boardName };
303
+ if ((project.boards ?? []).length > 1) return { title: boardName, project: project.name };
304
+ return { title: project.name };
305
+ }
306
+
292
307
  export function boardById(project, id) {
293
308
  return project.boards.find((board) => board.id === id) ?? null;
294
309
  }
@@ -139,7 +139,7 @@ export declare function sameName(a: string, b: string): boolean;
139
139
  export type Command =
140
140
  | { type: "set_logline"; logline: string }
141
141
  | { type: "set_rank"; ids: string[]; rank: NoteRank }
142
- | { type: "set_length"; ids: string[]; lengthEighths: number }
142
+ | { type: "set_length"; ids: string[]; lengthEighths: number | null }
143
143
  | { type: "set_target"; targetEighths: number }
144
144
  | {
145
145
  type: "create_note";
@@ -167,6 +167,7 @@ export type Command =
167
167
  | { type: "settle_note"; id: string }
168
168
  | { type: "create_group"; title?: string; noteIds: string[] }
169
169
  | { type: "ungroup"; id: string }
170
+ | { type: "add_to_group"; id: string; noteIds: string[] }
170
171
  | { type: "rename_group"; id: string; title: string }
171
172
  | { type: "create_arrow"; from: string; to: string; kind?: ArrowKind }
172
173
  | { type: "delete_arrow"; id: string }
@@ -497,11 +497,12 @@ export function applyCommand(state, command, now = nowIso()) {
497
497
  case "set_length": {
498
498
  const ids = new Set(command.ids);
499
499
  if (ids.size === 0) return { state, changed: false };
500
- const lengthEighths = clampEighths(
501
- command.lengthEighths,
502
- DEFAULT_NOTE_EIGHTHS,
503
- MAX_NOTE_EIGHTHS,
504
- );
500
+ // null unsizes: the card claims nothing again and reads as about a page
501
+ // (round thirteen, entry 16: there was no way back from a length).
502
+ const lengthEighths =
503
+ command.lengthEighths === null
504
+ ? null
505
+ : clampEighths(command.lengthEighths, DEFAULT_NOTE_EIGHTHS, MAX_NOTE_EIGHTHS);
505
506
  const touched = [];
506
507
  const notes = state.notes.map((note) => {
507
508
  if (!ids.has(note.id) || note.lengthEighths === lengthEighths) return note;
@@ -575,23 +576,32 @@ export function applyCommand(state, command, now = nowIso()) {
575
576
  }
576
577
 
577
578
  case "delete_note": {
578
- if (!state.notes.some((note) => note.id === command.id)) {
579
- return { state, changed: false };
580
- }
579
+ const gone = state.notes.find((note) => note.id === command.id);
580
+ if (!gone) return { state, changed: false };
581
+ const headlineOf = (id) => state.notes.find((note) => note.id === id)?.headline ?? id;
581
582
  const notes = state.notes.filter((note) => note.id !== command.id);
582
- const arrows = state.arrows.filter(
583
- (arrow) => arrow.from !== command.id && arrow.to !== command.id,
584
- );
583
+ const taken = state.arrows.filter((arrow) => arrow.from === command.id || arrow.to === command.id);
584
+ const arrows = state.arrows.filter((arrow) => !taken.includes(arrow));
585
+ const left = [];
585
586
  const groups = pruneGroups(
586
- state.groups.map((group) => ({
587
- ...group,
588
- noteIds: group.noteIds.filter((id) => id !== command.id),
589
- })),
587
+ state.groups.map((group) => {
588
+ if (!group.noteIds.includes(command.id)) return group;
589
+ const noteIds = group.noteIds.filter((id) => id !== command.id);
590
+ left.push({ id: group.id, title: group.title, remaining: noteIds.length, dissolved: noteIds.length < 2 });
591
+ return { ...group, noteIds };
592
+ }),
590
593
  );
594
+ // The result says what went with the card, so a door can say it too
595
+ // (round thirteen, entry 17: "Deleted card." and nothing of the arrows).
591
596
  return {
592
597
  state: { ...state, notes, arrows, groups },
593
598
  changed: true,
594
- result: { id: command.id },
599
+ result: {
600
+ id: command.id,
601
+ headline: gone.headline,
602
+ arrows: taken.map((arrow) => ({ ...arrow, fromHeadline: headlineOf(arrow.from), toHeadline: headlineOf(arrow.to) })),
603
+ groups: left,
604
+ },
595
605
  };
596
606
  }
597
607
 
@@ -672,6 +682,34 @@ export function applyCommand(state, command, now = nowIso()) {
672
682
  };
673
683
  }
674
684
 
685
+ case "add_to_group": {
686
+ // The agent's twin of dragging a card into a frame (round thirteen,
687
+ // entry 18). Membership only: the frame reaches the card where it is,
688
+ // and a card leaves any other group on the way, as create_group does.
689
+ const group = state.groups.find((item) => item.id === command.id);
690
+ if (!group) return { state, changed: false };
691
+ const joining = command.noteIds.filter(
692
+ (id, index) =>
693
+ command.noteIds.indexOf(id) === index &&
694
+ !group.noteIds.includes(id) &&
695
+ state.notes.some((note) => note.id === id),
696
+ );
697
+ if (joining.length === 0) return { state, changed: false };
698
+ const idSet = new Set(joining);
699
+ const left = [];
700
+ const groups = pruneGroups(
701
+ state.groups.map((existing) => {
702
+ if (existing.id === group.id) return { ...existing, noteIds: [...existing.noteIds, ...joining] };
703
+ if (!existing.noteIds.some((id) => idSet.has(id))) return existing;
704
+ const noteIds = existing.noteIds.filter((id) => !idSet.has(id));
705
+ left.push({ id: existing.id, title: existing.title, remaining: noteIds.length, dissolved: noteIds.length < 2 });
706
+ return { ...existing, noteIds };
707
+ }),
708
+ );
709
+ const joined = groups.find((item) => item.id === group.id);
710
+ return { state: { ...state, groups }, changed: true, result: { group: joined, added: joining, left } };
711
+ }
712
+
675
713
  case "rename_group": {
676
714
  let renamed = false;
677
715
  const groups = state.groups.map((group) => {