plotcoder-board 0.1.15 → 0.1.17

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-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.
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. 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,
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.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.
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.15",
3
+ "version": "0.1.17",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite",
@@ -75,6 +75,7 @@ import {
75
75
  liftCast,
76
76
  mergeRoster,
77
77
  sameRoster,
78
+ scriptTitles,
78
79
  withRoster,
79
80
  } from "../src/board/project.js";
80
81
 
@@ -758,6 +759,9 @@ const TRAIL_CAP = 50;
758
759
  const trail = [];
759
760
  /** What undo took back, newest last; a new change of this server's clears it. */
760
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 = [];
761
765
 
762
766
  function describeCommand(command) {
763
767
  switch (command.type) {
@@ -799,6 +803,7 @@ async function commit(command) {
799
803
 
800
804
  const live = await writeBoard(next, rev, base, boardId, "exact");
801
805
  trail.push({ before: state, after: canon(next), what: describeCommand(command) });
806
+ sinceRead.push(describeCommand(command));
802
807
  if (trail.length > TRAIL_CAP) trail.shift();
803
808
  undone.length = 0;
804
809
  return { state: next, changed, result, live };
@@ -828,6 +833,7 @@ async function commitAll(what, build) {
828
833
  if (!changed) return { state: current, changed: false, value, live: base !== null };
829
834
  const live = await writeBoard(current, rev, base, boardId, "exact");
830
835
  trail.push({ before: state, after: canon(current), what });
836
+ sinceRead.push(what);
831
837
  if (trail.length > TRAIL_CAP) trail.shift();
832
838
  undone.length = 0;
833
839
  return { state: current, changed: true, value, live };
@@ -1086,22 +1092,37 @@ server.registerTool(
1086
1092
  {
1087
1093
  title: "Set card length",
1088
1094
  description:
1089
- "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.",
1090
1096
  inputSchema: {
1091
1097
  ids: z.array(z.string()).min(1),
1092
- 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.'),
1093
1101
  },
1094
1102
  },
1095
1103
  async (args) => {
1096
- 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({
1097
1106
  type: "set_length",
1098
1107
  ids: args.ids,
1099
- lengthEighths: toEighths(args.pages),
1108
+ lengthEighths: unsizing ? null : toEighths(args.pages),
1100
1109
  });
1101
- return ok(
1102
- `${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.`,
1103
- result,
1104
- );
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);
1105
1126
  },
1106
1127
  );
1107
1128
 
@@ -1265,13 +1286,20 @@ server.registerTool(
1265
1286
  {
1266
1287
  title: "Delete note",
1267
1288
  description:
1268
- "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.",
1269
1290
  inputSchema: { id: z.string() },
1270
1291
  },
1271
1292
  async (args) => {
1272
- const { result } = await commit({ type: "delete_note", id: args.id });
1293
+ const { result, live } = await commit({ type: "delete_note", id: args.id });
1273
1294
  if (result === undefined) return ok(`No card with id ${args.id}.`);
1274
- 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);
1275
1303
  },
1276
1304
  );
1277
1305
 
@@ -1292,6 +1320,8 @@ server.registerTool(
1292
1320
  const { boards: boardsForRead } = await readProject();
1293
1321
  const elsewhereForRead = castElsewhere(projectForRead, boardsForRead, readBoardId ?? projectForRead.activeBoardId);
1294
1322
  const reading = readWall(state, { elsewhere: Object.keys(elsewhereForRead) });
1323
+ lastReading = { findings: reading.findings };
1324
+ sinceRead.length = 0;
1295
1325
  const runs = describeRuns(reading, state).map((line, index) => {
1296
1326
  const ids = reading.runs[index]?.ids ?? [];
1297
1327
  return ids.length ? `${line} — ${ids.map((id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`).join(", ")}` : line;
@@ -1369,7 +1399,7 @@ server.registerTool(
1369
1399
  {
1370
1400
  title: "Leave a question, for now",
1371
1401
  description:
1372
- "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.",
1373
1403
  inputSchema: { kind: z.string().min(1), ids: z.array(z.string()).optional() },
1374
1404
  },
1375
1405
  async (args) => {
@@ -1379,7 +1409,21 @@ server.registerTool(
1379
1409
  const matches = reading.findings.filter((finding) => finding.kind === args.kind && (!args.ids || sameList(finding.ids, args.ids)));
1380
1410
  if (matches.length === 0) {
1381
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.`);
1382
- 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.` : ""}`);
1383
1427
  }
1384
1428
  if (matches.length > 1) {
1385
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")}`);
@@ -1643,23 +1687,19 @@ server.registerTool(
1643
1687
  {
1644
1688
  title: "Export the wall as Fountain",
1645
1689
  description:
1646
- "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.",
1647
1691
  inputSchema: { path: z.string().optional() },
1648
1692
  },
1649
1693
  async (args) => {
1650
1694
  const { state } = await readBoard();
1651
1695
  const { project } = await readProject();
1652
1696
  const board = project.boards.find((item) => item.id === project.activeBoardId);
1653
- const text = toFountain(state, {
1654
- title: board?.name,
1655
- project: project.boards.length > 1 && project.name !== "Untitled project" ? project.name : undefined,
1656
- premise: project.premise || undefined,
1657
- draftDate: new Date().toISOString(),
1658
- });
1697
+ const titles = scriptTitles(project, board);
1698
+ const text = toFountain(state, { ...titles, premise: project.premise || undefined, draftDate: new Date().toISOString() });
1659
1699
  if (args.path) {
1660
1700
  fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
1661
1701
  fs.writeFileSync(args.path, text);
1662
- return ok(`Wrote ${text.split("\n").length} lines of Fountain to ${args.path}.`);
1702
+ return ok(`Wrote ${text.split("\n").length} lines of Fountain, titled "${titles.title}", to ${args.path}.`);
1663
1703
  }
1664
1704
  return ok(text);
1665
1705
  },
@@ -1670,22 +1710,19 @@ server.registerTool(
1670
1710
  {
1671
1711
  title: "Export the wall as Markdown",
1672
1712
  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.",
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.",
1674
1714
  inputSchema: { path: z.string().optional() },
1675
1715
  },
1676
1716
  async (args) => {
1677
1717
  const { state } = await readBoard();
1678
1718
  const { project } = await readProject();
1679
1719
  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
- });
1720
+ const titles = scriptTitles(project, board);
1721
+ const text = toMarkdown(state, { ...titles, premise: project.premise || undefined });
1685
1722
  if (args.path) {
1686
1723
  fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
1687
1724
  fs.writeFileSync(args.path, text);
1688
- return ok(`Wrote ${text.split("\n").length} lines of Markdown to ${path.resolve(args.path)}.`);
1725
+ return ok(`Wrote ${text.split("\n").length} lines of Markdown, titled "${titles.title}", to ${path.resolve(args.path)}.`);
1689
1726
  }
1690
1727
  return ok(text);
1691
1728
  },
@@ -1696,21 +1733,19 @@ server.registerTool(
1696
1733
  {
1697
1734
  title: "Export the script as plain text",
1698
1735
  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.",
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.",
1700
1737
  inputSchema: { path: z.string().optional() },
1701
1738
  },
1702
1739
  async (args) => {
1703
1740
  const { state } = await readBoard();
1704
1741
  const { project } = await readProject();
1705
1742
  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
- });
1743
+ const titles = scriptTitles(project, board);
1744
+ const text = toPlainText(state, titles);
1710
1745
  if (args.path) {
1711
1746
  fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
1712
1747
  fs.writeFileSync(args.path, text);
1713
- return ok(`Wrote ${text.split("\n").length} lines of plain text to ${path.resolve(args.path)}.`);
1748
+ return ok(`Wrote ${text.split("\n").length} lines of plain text, titled "${titles.title}", to ${path.resolve(args.path)}.`);
1714
1749
  }
1715
1750
  return ok(text);
1716
1751
  },
@@ -1853,18 +1888,19 @@ server.registerTool(
1853
1888
  {
1854
1889
  title: "Export as Final Draft",
1855
1890
  description:
1856
- "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.",
1857
1892
  inputSchema: { path: z.string().optional() },
1858
1893
  },
1859
1894
  async (args) => {
1860
1895
  const { state } = await readBoard();
1861
1896
  const { project } = await readProject();
1862
1897
  const board = project.boards.find((item) => item.id === project.activeBoardId);
1863
- 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() });
1864
1900
  if (args.path) {
1865
1901
  fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
1866
1902
  fs.writeFileSync(args.path, xml);
1867
- 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}.`);
1868
1904
  }
1869
1905
  return ok(xml);
1870
1906
  },
@@ -2505,6 +2541,29 @@ server.registerTool(
2505
2541
  },
2506
2542
  );
2507
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
+
2508
2567
  server.registerTool(
2509
2568
  "rename_group",
2510
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
  });
@@ -18,7 +18,7 @@
18
18
  // monospaced.
19
19
 
20
20
  import { readingOrder } from "./readWall.js";
21
- import { sceneHeading } from "./fountain.js";
21
+ import { sceneHeading, standInFor } from "./fountain.js";
22
22
  import { paginate, parseScene, WIDTH } from "./paginate.js";
23
23
  import { sceneNumbers } from "./numbering.js";
24
24
 
@@ -79,7 +79,9 @@ export function toMarkdown(state, options = {}) {
79
79
  out.push(`### ${numbers.get(note.id) ?? ""} · ${heading}`.replace(/^### · /, "### "), "");
80
80
  if (note.headline && upper(note.headline) !== heading) out.push(`*${note.headline.trim()}*`, "");
81
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(), "");
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)}*`, "");
83
85
  }
84
86
  return `${out.join("\n").trimEnd()}\n`;
85
87
  }
@@ -156,7 +158,7 @@ export function toPlainText(state, options = {}) {
156
158
  id: note.id,
157
159
  heading: sceneHeading(note).slice(1),
158
160
  text: note.text,
159
- change: note.change,
161
+ change: standInFor(note),
160
162
  written: Boolean(note.text && note.text.trim()),
161
163
  number: numbers.get(note.id) ?? undefined,
162
164
  })),
@@ -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) => {