plotcoder-board 0.1.14 → 0.1.15

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-seven 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 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. Twelve rounds have been run, the first three through a
55
+ repo checkout and the rest through the account door, and every finding they
56
+ logged is fixed. [`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.14. 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.15",
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";
@@ -698,7 +699,18 @@ async function writeBoard(next, rev, base, boardId = null, roster = "merge") {
698
699
  const boards = {};
699
700
  for (const [id, state] of Object.entries(held.boards)) boards[id] = withRoster(state, project);
700
701
  boards[boardId ?? project.activeBoardId] = withRoster(toWrite, project);
702
+ // The order matters on the bridge: the board frame carries the exact roster
703
+ // and lands first, so the wall records one undo step holding the card, the
704
+ // cast and the person together (R33, R51); the project frame that follows
705
+ // then changes nothing. The account door keeps the project first, because
706
+ // its live path composes a board's cast against the project row it holds.
707
+ if (base === ACCOUNT) {
708
+ await writeProject(project, boards, held.rev, held.base);
709
+ return writeBoardRaw(toWrite, rev, base, boardId);
710
+ }
711
+ const live = await writeBoardRaw(toWrite, rev, base, boardId);
701
712
  await writeProject(project, boards, held.rev, held.base);
713
+ return live;
702
714
  }
703
715
  }
704
716
  return writeBoardRaw(toWrite, rev, base, boardId);
@@ -792,6 +804,35 @@ async function commit(command) {
792
804
  return { state: next, changed, result, live };
793
805
  }
794
806
 
807
+ /**
808
+ * Several kernel commands as one change. Read once; `build(step, current)` applies
809
+ * each command through the kernel against the running state; write once. One tool
810
+ * call is then one frame on the bridge — one ⌘Z on the wall — and one entry on this
811
+ * server's own trail, however many commands it took: create_note with its cast,
812
+ * set_plant with later, move_scene, an import. Before this, ⌘Z on the wall took the
813
+ * cast off an agent's new card and left the card (R33).
814
+ */
815
+ async function commitAll(what, build) {
816
+ const { state, rev, base, boardId } = await readBoard();
817
+ let current = state;
818
+ let changed = false;
819
+ const step = (command) => {
820
+ const out = applyCommand(current, command);
821
+ if (out.changed) {
822
+ current = out.state;
823
+ changed = true;
824
+ }
825
+ return out;
826
+ };
827
+ const value = await build(step, () => current);
828
+ if (!changed) return { state: current, changed: false, value, live: base !== null };
829
+ const live = await writeBoard(current, rev, base, boardId, "exact");
830
+ trail.push({ before: state, after: canon(current), what });
831
+ if (trail.length > TRAIL_CAP) trail.shift();
832
+ undone.length = 0;
833
+ return { state: current, changed: true, value, live };
834
+ }
835
+
795
836
  /** Said once per session: that cards stack until organize (round seven, finding 11). */
796
837
  /** 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
838
  function nextPlace(state) {
@@ -1106,45 +1147,47 @@ server.registerTool(
1106
1147
  },
1107
1148
  async (args) => {
1108
1149
  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})`);
1150
+ const names = (args.characters ?? []).map((name) => name.trim()).filter(Boolean);
1151
+ const added = [];
1152
+ // The card, anyone new in its cast, and the casting land as one change, so
1153
+ // one ⌘Z on the wall takes back the whole call and not just the cast.
1154
+ const { value: result, live } = await commitAll(`create_note "${args.headline}"`, (step, current) => {
1155
+ let made = step({
1156
+ type: "create_note",
1157
+ headline: args.headline,
1158
+ change: args.change,
1159
+ // One colour unless the agent chooses: a wall an agent builds in one go
1160
+ // would otherwise stripe through the cycle, and a writer reads a pattern
1161
+ // into it (round four, finding 17). The wall's own new-card button keeps
1162
+ // cycling for a person adding cards by hand.
1163
+ color: args.color ?? "yellow",
1164
+ rank: args.rank,
1165
+ lengthEighths: args.pages === undefined ? undefined : toEighths(args.pages),
1166
+ plants: args.plants,
1167
+ location: args.location,
1168
+ x: landing.x,
1169
+ y: landing.y,
1170
+ }).result;
1171
+ if (names.length && made?.id) {
1172
+ const ids = [];
1173
+ for (const name of names) {
1174
+ const wanted = name.toLowerCase();
1175
+ let person = current().characters.find((item) => item.id === name) ?? current().characters.find((item) => item.name.trim().toLowerCase() === wanted);
1176
+ if (!person) {
1177
+ person = step({ type: "add_character", name }).result;
1178
+ if (person) added.push(`${person.name} (${person.id})`);
1179
+ }
1180
+ if (person && !ids.includes(person.id)) ids.push(person.id);
1181
+ }
1182
+ if (ids.length) {
1183
+ const cast = step({ type: "set_cast", ids: [made.id], characterIds: ids });
1184
+ // The card as it is now, cast and all, so the reply's JSON agrees with its prose.
1185
+ made = cast.state.notes.find((note) => note.id === made.id) ?? made;
1137
1186
  }
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
1187
  }
1146
- castLine = ` Cast: ${args.characters.map((name) => name.trim()).join(", ")}${added.length ? ` (added to the roster: ${added.join(", ")})` : ""}.`;
1147
- }
1188
+ return made;
1189
+ });
1190
+ const castLine = names.length && result?.id ? ` Cast: ${names.join(", ")}${added.length ? ` (added to the roster: ${added.join(", ")})` : ""}.` : "";
1148
1191
  const landed = [
1149
1192
  result?.rank === "beat" ? "a beat" : "a scene",
1150
1193
  result?.lengthEighths === null ? "about a page (unsized: the writer's guess until set_length)" : `${formatPages(noteEighths(result))} ${formatPages(noteEighths(result)) === "1" ? "page" : "pages"}`,
@@ -1392,46 +1435,41 @@ server.registerTool(
1392
1435
  if (!state.arrows.some(isFollows)) {
1393
1436
  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
1437
  }
1395
- const trailBefore = trail.length;
1396
1438
  let removed = 0;
1397
1439
  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" });
1440
+ // The whole move — its dozen arrows and the tidy — as one change: one frame on
1441
+ // the bridge, one ⌘Z on the wall, one step for undo here.
1442
+ const { state: final, live } = await commitAll(`move_scene "${card.headline}"`, (step, current) => {
1443
+ const run = (command) => {
1444
+ const done = step(command);
1445
+ if (done.changed) {
1446
+ if (command.type === "delete_arrow") removed += 1;
1447
+ if (command.type === "create_arrow") drawn += 1;
1448
+ }
1449
+ return done;
1450
+ };
1451
+ // Leave: what pointed at the card points at what the card pointed at.
1452
+ const ins = state.arrows.filter((arrow) => isFollows(arrow) && arrow.to === card.id);
1453
+ const outs = state.arrows.filter((arrow) => isFollows(arrow) && arrow.from === card.id);
1454
+ for (const arrow of [...ins, ...outs]) run({ type: "delete_arrow", id: arrow.id });
1455
+ 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" });
1456
+ // Land: between the target and what followed it (or what led to it).
1457
+ const mid = current();
1458
+ if (args.after) {
1459
+ for (const arrow of mid.arrows.filter((item) => isFollows(item) && item.from === target.id && item.to !== card.id)) {
1460
+ run({ type: "delete_arrow", id: arrow.id });
1461
+ run({ type: "create_arrow", from: card.id, to: arrow.to, kind: "follows" });
1462
+ }
1463
+ run({ type: "create_arrow", from: target.id, to: card.id, kind: "follows" });
1464
+ } else {
1465
+ for (const arrow of mid.arrows.filter((item) => isFollows(item) && item.to === target.id && item.from !== card.id)) {
1466
+ run({ type: "delete_arrow", id: arrow.id });
1467
+ run({ type: "create_arrow", from: arrow.from, to: card.id, kind: "follows" });
1468
+ }
1469
+ run({ type: "create_arrow", from: card.id, to: target.id, kind: "follows" });
1425
1470
  }
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;
1471
+ run({ type: "apply_poses", poses: organizePoses(current(), {}) });
1472
+ });
1435
1473
  const order = readingOrder(final.notes);
1436
1474
  const group = final.groups.find((item) => item.noteIds.includes(card.id));
1437
1475
  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.` : "";
@@ -1627,6 +1665,57 @@ server.registerTool(
1627
1665
  },
1628
1666
  );
1629
1667
 
1668
+ server.registerTool(
1669
+ "export_markdown",
1670
+ {
1671
+ title: "Export the wall as Markdown",
1672
+ description:
1673
+ "The open board as Markdown, for a collaborator who lives in Google Docs or the like: the board as the title (the project's name before it 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. Pass a path to write a .md file; otherwise the text comes back.",
1674
+ inputSchema: { path: z.string().optional() },
1675
+ },
1676
+ async (args) => {
1677
+ const { state } = await readBoard();
1678
+ const { project } = await readProject();
1679
+ const board = project.boards.find((item) => item.id === project.activeBoardId);
1680
+ const text = toMarkdown(state, {
1681
+ title: board?.name,
1682
+ project: project.boards.length > 1 && project.name !== "Untitled project" ? project.name : undefined,
1683
+ premise: project.premise || undefined,
1684
+ });
1685
+ if (args.path) {
1686
+ fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
1687
+ fs.writeFileSync(args.path, text);
1688
+ return ok(`Wrote ${text.split("\n").length} lines of Markdown to ${path.resolve(args.path)}.`);
1689
+ }
1690
+ return ok(text);
1691
+ },
1692
+ );
1693
+
1694
+ server.registerTool(
1695
+ "export_text",
1696
+ {
1697
+ title: "Export the script as plain text",
1698
+ description:
1699
+ "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. 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.",
1700
+ inputSchema: { path: z.string().optional() },
1701
+ },
1702
+ async (args) => {
1703
+ const { state } = await readBoard();
1704
+ const { project } = await readProject();
1705
+ const board = project.boards.find((item) => item.id === project.activeBoardId);
1706
+ const text = toPlainText(state, {
1707
+ title: board?.name,
1708
+ project: project.boards.length > 1 && project.name !== "Untitled project" ? project.name : undefined,
1709
+ });
1710
+ if (args.path) {
1711
+ fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
1712
+ fs.writeFileSync(args.path, text);
1713
+ return ok(`Wrote ${text.split("\n").length} lines of plain text to ${path.resolve(args.path)}.`);
1714
+ }
1715
+ return ok(text);
1716
+ },
1717
+ );
1718
+
1630
1719
  server.registerTool(
1631
1720
  "write_scene",
1632
1721
  {
@@ -1696,8 +1785,10 @@ server.registerTool(
1696
1785
  const { state } = await readBoard();
1697
1786
  const parsed = fromFountain(source);
1698
1787
  const { commands, matched } = mergeFountain(state, parsed);
1699
- let live = false;
1700
- for (const command of commands) ({ live } = await commit(command));
1788
+ // The whole import as one change, so one undo takes every scene back.
1789
+ const { live } = await commitAll(`import_fountain (${parsed.scenes.length} scene(s))`, (step) => {
1790
+ for (const command of commands) step(command);
1791
+ });
1701
1792
  const written = commands.filter((command) => command.type === "set_text").length;
1702
1793
  const created = matched.filter((item) => item.created).length;
1703
1794
  return ok(
@@ -1793,8 +1884,10 @@ server.registerTool(
1793
1884
  const { state } = await readBoard();
1794
1885
  const parsed = fromFdx(source);
1795
1886
  const { commands, matched } = mergeFountain(state, parsed);
1796
- let live = false;
1797
- for (const command of commands) ({ live } = await commit(command));
1887
+ // The whole import as one change, so one undo takes every scene back.
1888
+ const { live } = await commitAll(`import_fdx (${parsed.scenes.length} scene(s))`, (step) => {
1889
+ for (const command of commands) step(command);
1890
+ });
1798
1891
  const written = commands.filter((command) => command.type === "set_text").length;
1799
1892
  const created = matched.filter((item) => item.created).length;
1800
1893
  const receipt = describeSetAside(parsed.setAside);
@@ -2107,39 +2200,40 @@ server.registerTool(
2107
2200
  },
2108
2201
  },
2109
2202
  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 = "";
2203
+ // A series plant (R50): the fold pays off on another board of the project.
2204
+ // The kernel cannot check the board exists; this door can, before anything lands.
2205
+ let target = null;
2206
+ let forgetting = false;
2116
2207
  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
2208
  const { project } = await readProject();
2120
- const { state: now, boardId: current } = await readBoard();
2121
- const here = now.notes.filter((note) => args.ids.includes(note.id));
2209
+ const { boardId: current } = await readBoard();
2122
2210
  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
- }
2211
+ forgetting = true;
2130
2212
  } else {
2131
- const target = findBoard(project, args.later);
2213
+ target = findBoard(project, args.later);
2132
2214
  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
2215
  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;
2216
+ }
2217
+ }
2218
+ // The fold and the board it pays off on land as one change: one ⌘Z on the wall.
2219
+ const { value, live, changed } = await commitAll("set_plant", (step, current) => {
2220
+ let { result } = step({ type: "set_plant", ids: args.ids, plants: args.plants });
2221
+ let laterLine = "";
2222
+ if (forgetting) {
2223
+ const cleared = step({ type: "set_payoff_board", ids: args.ids, boardId: null });
2224
+ if (cleared.changed) {
2225
+ result = cleared.result;
2226
+ laterLine = " The board it paid off on is forgotten; read_wall asks again until a setup arrow or a board pays it off.";
2139
2227
  }
2228
+ } else if (target) {
2229
+ const named = step({ type: "set_payoff_board", ids: args.ids, boardId: target.id });
2230
+ if (named.changed) result = named.result;
2231
+ const here = current().notes.filter((note) => args.ids.includes(note.id));
2140
2232
  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
2233
  }
2142
- }
2234
+ return { result, laterLine };
2235
+ });
2236
+ const { result, laterLine } = value;
2143
2237
  const count = result?.length ?? 0;
2144
2238
  if (!changed || count === 0) return ok("No change: those cards were already that way, or the ids are not on the board.");
2145
2239
  return ok(
@@ -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,177 @@
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 } 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
+ else if (note.change && note.change.trim()) out.push(note.change.trim(), "");
83
+ }
84
+ return `${out.join("\n").trimEnd()}\n`;
85
+ }
86
+
87
+ /** The column the script's body starts at: room for a scene number on the left. */
88
+ export const GUTTER = 5;
89
+
90
+ /** Where each kind of line starts, in characters from the body's left edge — the print stylesheet's columns. */
91
+ export const COLUMN = { character: 22, more: 22, parenthetical: 16, dialogue: 10 };
92
+
93
+ const DUAL_RIGHT = 32;
94
+ const DUAL_CUE = 8;
95
+
96
+ function pad(n) {
97
+ return " ".repeat(Math.max(0, n));
98
+ }
99
+
100
+ function centred(text, width = WIDTH.action) {
101
+ return pad(GUTTER + Math.floor((width - text.length) / 2)) + text;
102
+ }
103
+
104
+ /** One line of a dual-dialogue column, from the column's own left edge. */
105
+ function columnLine(line) {
106
+ if (!line || line.kind === "blank") return "";
107
+ const text = line.text ?? "";
108
+ if (line.kind === "character") return pad(DUAL_CUE) + text;
109
+ if (line.kind === "parenthetical") return pad(4) + text;
110
+ return text;
111
+ }
112
+
113
+ /** A printed line set with spaces. Exported for the test; the file is `toPlainText`. */
114
+ export function setLine(line) {
115
+ const text = line.text ?? "";
116
+ switch (line.kind) {
117
+ case "blank":
118
+ return "";
119
+ case "heading": {
120
+ const number = line.sceneNumber === null || line.sceneNumber === undefined ? "" : String(line.sceneNumber);
121
+ if (!number) return pad(GUTTER) + text;
122
+ return `${number.padEnd(GUTTER)}${text.padEnd(WIDTH.action)} ${number}`.trimEnd();
123
+ }
124
+ case "character":
125
+ case "more":
126
+ case "parenthetical":
127
+ case "dialogue":
128
+ return pad(GUTTER + COLUMN[line.kind]) + text;
129
+ case "transition":
130
+ return pad(GUTTER + WIDTH.action - text.length) + text;
131
+ case "centered":
132
+ return centred(text);
133
+ case "dual": {
134
+ const left = pad(GUTTER) + columnLine(line.left);
135
+ const right = columnLine(line.right);
136
+ return right ? `${left.padEnd(GUTTER + DUAL_RIGHT)}${right}` : left.trimEnd();
137
+ }
138
+ case "action":
139
+ default:
140
+ return pad(GUTTER) + text;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * The script as plain text, set as it prints.
146
+ *
147
+ * @param state the board
148
+ * @param options.title the board's name; options.project the project's name
149
+ * when the project has several boards
150
+ */
151
+ export function toPlainText(state, options = {}) {
152
+ const order = readingOrder(state.notes);
153
+ const numbers = sceneNumbers(order, state.lock);
154
+ const result = paginate(
155
+ order.map((note) => ({
156
+ id: note.id,
157
+ heading: sceneHeading(note).slice(1),
158
+ text: note.text,
159
+ change: note.change,
160
+ written: Boolean(note.text && note.text.trim()),
161
+ number: numbers.get(note.id) ?? undefined,
162
+ })),
163
+ );
164
+ const title = options.title || "Untitled";
165
+ const out = [];
166
+ if (options.project && options.project !== title) {
167
+ out.push(centred(upper(options.project)), "", centred(title));
168
+ } else {
169
+ out.push(centred(upper(title)));
170
+ }
171
+ out.push("", "");
172
+ for (const page of result.pages) {
173
+ if (page.number > 1) out.push("");
174
+ for (const line of page.lines) out.push(setLine(line));
175
+ }
176
+ return `${out.join("\n").replace(/\n{4,}/g, "\n\n\n").trimEnd()}\n`;
177
+ }