plotcoder-board 0.1.38 → 0.1.39
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 +2 -2
- package/package.json +1 -1
- package/scripts/plotcoder-mcp-server.mjs +143 -21
- package/src/board/agents.js +1 -1
- package/src/board/camera.d.ts +4 -0
- package/src/board/camera.js +52 -0
- package/src/board/readWall.d.ts +2 -0
- package/src/board/readWall.js +2 -0
- package/src/board/reducer.d.ts +12 -1
- package/src/board/reducer.js +97 -18
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ Every board verb goes through one command kernel, `src/board/reducer.js`, and th
|
|
|
43
43
|
|
|
44
44
|
- **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.
|
|
45
45
|
- **`window.plotcoder`** on the page, for a console or a CDP session.
|
|
46
|
-
- **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.
|
|
46
|
+
- **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. Ninety tools: reading (`list_board`, `read_wall`, `read_project`, `read_pages`, `read_character`, `page_count`, `list_words`, `list_workflows`, `segment_brief`, `compare_structure`); the card, cast, place and when (`set_when`), group (with `add_to_group`) and arrow verbs, `set_logline`, `set_target`, `set_rank`, `set_open` (a card the writer has not decided), `create_thread`, `update_thread` and `delete_thread` (a named string through cards, either end open until tied), `set_plant` (with `later`, `at` and `what`) and `set_payoff`, `set_alternative` and `choose_version` (two versions of one scene, one chosen), `write_scene`, `edit_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.
|
|
47
47
|
- **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`.
|
|
48
48
|
|
|
49
49
|
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.
|
|
@@ -107,4 +107,4 @@ next round into a test of `claim_account` instead of the door it meant to test.
|
|
|
107
107
|
|
|
108
108
|
## Status
|
|
109
109
|
|
|
110
|
-
Version 0.1.
|
|
110
|
+
Version 0.1.39. 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
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
NOTE_HEIGHT,
|
|
33
33
|
NOTE_WIDTH,
|
|
34
34
|
DEFAULT_TARGET_EIGHTHS,
|
|
35
|
+
DEFAULT_NOTE_EIGHTHS,
|
|
35
36
|
normalizeState,
|
|
36
37
|
newId,
|
|
37
38
|
noteEighths,
|
|
@@ -43,6 +44,7 @@ import { TEMPLATES } from "../src/board/templates.js";
|
|
|
43
44
|
import { wordSentence, wordsAsText } from "../src/board/words.js";
|
|
44
45
|
import { fromFountain, mergeFountain, toFountain } from "../src/board/fountain.js";
|
|
45
46
|
import { toMarkdown, toPlainText } from "../src/board/markdown.js";
|
|
47
|
+
import { cameraLines, cameraVerbs } from "../src/board/camera.js";
|
|
46
48
|
import { fromProjectFile, toProjectFile } from "../src/board/projectFile.js";
|
|
47
49
|
import { describeSetAside, fromFdx, toFdx } from "../src/board/fdx.js";
|
|
48
50
|
import { paginate } from "../src/board/paginate.js";
|
|
@@ -806,6 +808,8 @@ const undone = [];
|
|
|
806
808
|
/** The last read_wall's questions, and what changed since: a leave answers the reading in front of the agent (round thirteen, entry 19). */
|
|
807
809
|
let lastReading = null;
|
|
808
810
|
const sinceRead = [];
|
|
811
|
+
/** Whether this session has read a wall yet: until it has, a write's tail counts the questions and points at read_wall; after, it quotes them (the handover's call 2). */
|
|
812
|
+
let readOnce = false;
|
|
809
813
|
/** What the last write did to the wall's questions and runtime, said once on that write's tail (round fourteen, entries 18, 19, 42). */
|
|
810
814
|
let lastChange = null;
|
|
811
815
|
// The same question in the same words is the same question, whatever order its names come in (round seventeen, entry 21).
|
|
@@ -833,7 +837,13 @@ function changeNote() {
|
|
|
833
837
|
lastChange = null;
|
|
834
838
|
if (!change) return "";
|
|
835
839
|
const parts = [];
|
|
836
|
-
|
|
840
|
+
// Before the session's first reading a tail counts and points: a build is
|
|
841
|
+
// twelve writes whose quoted questions the next write answers (round
|
|
842
|
+
// fifteen 9, eighteen 20). After it, a change is to something read, and
|
|
843
|
+
// the tail quotes it.
|
|
844
|
+
if (!readOnce && (change.gone.length || change.came.length)) {
|
|
845
|
+
parts.push(`the wall's questions have changed since your last read_wall: ${change.asks} now${change.came.length ? `, ${change.came.length} of them new` : ""} — read_wall lists them`);
|
|
846
|
+
} else if (change.gone.length || change.came.length) {
|
|
837
847
|
parts.push(
|
|
838
848
|
`the wall now asks ${change.asks} question${change.asks === 1 ? "" : "s"}${change.gone.length ? ` (gone: ${change.gone.map((finding) => `[${finding.kind}] ${finding.text.replace(/\.$/, "")}`).join(" ")})` : ""}${change.came.length ? ` (new: ${change.came.map((finding) => `[${finding.kind}] ${finding.text.replace(/\.$/, "")}`).join(" ")})` : ""}`,
|
|
839
849
|
);
|
|
@@ -964,8 +974,27 @@ function workingProject(id, name, count) {
|
|
|
964
974
|
}
|
|
965
975
|
|
|
966
976
|
/** Where a change landed, for the tail of a tool's reply. */
|
|
977
|
+
/** Who has a wall of the working project open on a screen now, from the presence the server already follows (the handover's call 4); never the agent itself. */
|
|
978
|
+
function presentPeople() {
|
|
979
|
+
try {
|
|
980
|
+
const state = accountDoor?.channel?.presenceState?.() ?? {};
|
|
981
|
+
const names = new Set();
|
|
982
|
+
for (const entries of Object.values(state)) for (const entry of entries ?? []) if (entry && typeof entry.name === "string" && !/^an agent, as /.test(entry.name)) names.add(entry.name);
|
|
983
|
+
return [...names];
|
|
984
|
+
} catch {
|
|
985
|
+
return [];
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/** The account tail says what the wall shows: open on whose screen, or no wall open, never a bare "live". */
|
|
990
|
+
function accountTail() {
|
|
991
|
+
const people = presentPeople();
|
|
992
|
+
if (!people.length) return " (saved to the account; no wall open right now — it shows the moment one opens)";
|
|
993
|
+
return ` (saved to the account; open on ${people.length === 1 ? `${people[0]}'s screen` : `${people.length} screens: ${people.join(", ")}`} now)`;
|
|
994
|
+
}
|
|
995
|
+
|
|
967
996
|
function where(live) {
|
|
968
|
-
const tail = live === ACCOUNT ?
|
|
997
|
+
const tail = live === ACCOUNT ? accountTail() : live ? " (visible on the open board)" : " (written to file; the wall shows it the next time the app runs from this folder)";
|
|
969
998
|
return `${tail}${changeNote()}`;
|
|
970
999
|
}
|
|
971
1000
|
|
|
@@ -1038,6 +1067,25 @@ function stillOpen(notes) {
|
|
|
1038
1067
|
return ` ${open.length === 1 ? `"${open[0].headline}" is still open (${open[0].open})` : `${open.length} of them are still open`}: the words stay until set_open "" clears them, and the wall asks nothing else of ${open.length === 1 ? "it" : "them"} until then.`;
|
|
1039
1068
|
}
|
|
1040
1069
|
|
|
1070
|
+
/** "20 under", "3 over" or "on it", against a target in eighths. */
|
|
1071
|
+
function againstWord(state, targetEighths) {
|
|
1072
|
+
const total = boardEighths(state);
|
|
1073
|
+
return total > targetEighths ? `${formatPages(total - targetEighths)} over` : total < targetEighths ? `${formatPages(targetEighths - total)} under` : "on it";
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/** The written scenes measured under the page they were read as (the handover's call 7, four rounds): a sketch is counted as measured and named. */
|
|
1077
|
+
function sketches(state) {
|
|
1078
|
+
return state.notes.filter((note) => isMeasured(note) && noteEighths(note) < (note.lengthEighths ?? DEFAULT_NOTE_EIGHTHS));
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/** The runtime's second number while a sketch stands: what the cards would come to if each sketch ran to the page it was read as. */
|
|
1082
|
+
function sketchLine(state) {
|
|
1083
|
+
const found = sketches(state);
|
|
1084
|
+
if (!found.length) return "";
|
|
1085
|
+
const ifRan = boardEighths(state) + found.reduce((sum, note) => sum + ((note.lengthEighths ?? DEFAULT_NOTE_EIGHTHS) - noteEighths(note)), 0);
|
|
1086
|
+
return `; ${found.length} written scene${found.length === 1 ? " is a sketch" : "s are sketches"}, measured under the page ${found.length === 1 ? "it was" : "they were"} read as: about ${formatPages(ifRan)} pages if ${found.length === 1 ? "it" : "they"} ran to that`;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1041
1089
|
/** What kinds of number a runtime folds together: measured from text, set by the writer, or the default page (round fifteen, entry 39). */
|
|
1042
1090
|
function runtimeKinds(state) {
|
|
1043
1091
|
const measured = state.notes.filter((note) => isMeasured(note));
|
|
@@ -1072,7 +1120,8 @@ function summarize(state) {
|
|
|
1072
1120
|
const count = formatPages(noteEighths(note));
|
|
1073
1121
|
// A written card's estimate is kept underneath for when the text goes; say it, or it is invisible (round sixteen, entry 44).
|
|
1074
1122
|
const underneath = isMeasured(note) && note.lengthEighths !== null ? `; the writer's estimate underneath: ${formatPages(note.lengthEighths)}` : "";
|
|
1075
|
-
const
|
|
1123
|
+
const sketch = isMeasured(note) && noteEighths(note) < (note.lengthEighths ?? DEFAULT_NOTE_EIGHTHS) ? " (a sketch: under the page it was read as)" : "";
|
|
1124
|
+
const pages = isMeasured(note) ? `${count} ${count === "1" ? "page" : "pages"}, written${sketch}${underneath}` : note.lengthEighths === null ? "about a page, unsized" : `${count} ${count === "1" ? "page" : "pages"}`;
|
|
1076
1125
|
return ` - ${note.id} [${note.rank ?? "scene"}, ${pages}${who}${place}${when}${openWord}${plant}${pays}${revised}] — "${note.headline}" (${note.color}) at ${Math.round(note.x)},${Math.round(note.y)}`;
|
|
1077
1126
|
})
|
|
1078
1127
|
.join("\n");
|
|
@@ -1142,12 +1191,17 @@ function summarize(state) {
|
|
|
1142
1191
|
...production,
|
|
1143
1192
|
`left, for now: ${leftCount ? `${leftCount} question(s) the writer left; read_wall lists them` : "none"}`,
|
|
1144
1193
|
`beats: ${beats}, scenes: ${scenes}`,
|
|
1145
|
-
state.
|
|
1146
|
-
? `runtime: about ${formatPages(runtime)} pages (an estimate from the cards; a page runs about a minute);
|
|
1147
|
-
:
|
|
1194
|
+
state.targetOpen
|
|
1195
|
+
? `runtime: about ${formatPages(runtime)} pages (an estimate from the cards; a page runs about a minute); target open, by the writer's word — "${state.targetOpen}" (against 30 it would be ${againstWord(state, 30 * EIGHTHS_PER_PAGE)}; against 120, ${againstWord(state, 120 * EIGHTHS_PER_PAGE)}; set_target decides it)${runtimeKinds(state)}${sketchLine(state)}`
|
|
1196
|
+
: state.targetEighths === DEFAULT_TARGET_EIGHTHS
|
|
1197
|
+
? `runtime: about ${formatPages(runtime)} pages (an estimate from the cards; a page runs about a minute); no target set — set_target for a pilot (60) or a half-hour (30); against the feature default of 120 it would be ${formatPages(-over)} under${runtimeKinds(state)}${sketchLine(state)}`
|
|
1198
|
+
: `runtime: about ${formatPages(runtime)} pages of the ${formatPages(state.targetEighths)}-page target the writer set (set_target changes it) — ${over > 0 ? `${formatPages(over)} over` : over < 0 ? `${formatPages(-over)} under` : "on it"} (an estimate from the cards; a page runs about a minute; page_count is the script so far)${runtimeKinds(state)}${sketchLine(state)}`,
|
|
1148
1199
|
`notes: ${state.notes.length}, groups: ${state.groups.length}, arrows: ${state.arrows.length}, cast: ${state.characters.length}`,
|
|
1149
1200
|
"cards (in story order — the follows arrows over the rows; each with its id):",
|
|
1150
1201
|
notes || " (no cards)",
|
|
1202
|
+
...(state.notes.some((note) => note.alternativeOf)
|
|
1203
|
+
? ["versions, not chosen (behind their front cards; out of the order, the count and the pages; choose_version decides):", ...state.notes.filter((note) => note.alternativeOf).map((note) => ` - ${note.id} — "${note.headline}", a version of "${state.notes.find((item) => item.id === note.alternativeOf)?.headline ?? note.alternativeOf}"`)]
|
|
1204
|
+
: []),
|
|
1151
1205
|
"rows on the wall (top to bottom, left to right; ★ a beat):",
|
|
1152
1206
|
...(rowLines.length ? rowLines : [" (no cards)"]),
|
|
1153
1207
|
"cast (the project's; every board of it casts from here; read_character reads a person's whole page, update_character writes it):",
|
|
@@ -1316,15 +1370,20 @@ server.registerTool(
|
|
|
1316
1370
|
{
|
|
1317
1371
|
title: "Set target length",
|
|
1318
1372
|
description:
|
|
1319
|
-
"Set the board's target script length, in pages or in minutes (a page runs about a minute): 120 for a feature, 30 for a half-hour, 60 for an hour drama. This is what the runtime estimate is measured against.",
|
|
1320
|
-
inputSchema: { pages: pagesSchema.optional(), minutes: z.number().positive().optional() },
|
|
1373
|
+
"Set the board's target script length, in pages or in minutes (a page runs about a minute): 120 for a feature, 30 for a half-hour, 60 for an hour drama. This is what the runtime estimate is measured against. Or leave the target open: pass open with the writer's words for why it is not decided — \"half-hour or feature\" — and the reading lists it under open, by the writer's word, and reads the runtime against both defaults while the words stand; a number decides it, open \"\" takes the words back.",
|
|
1374
|
+
inputSchema: { pages: pagesSchema.optional(), minutes: z.number().positive().optional(), open: z.string().optional() },
|
|
1321
1375
|
},
|
|
1322
1376
|
async (args) => {
|
|
1323
|
-
if (args.pages === undefined && args.minutes === undefined) return ok("Say the target in pages or in minutes.");
|
|
1324
|
-
const { state, live } = await commit({
|
|
1377
|
+
if (args.pages === undefined && args.minutes === undefined && args.open === undefined) return ok("Say the target in pages or in minutes, or open with the writer's words for why it is not decided.");
|
|
1378
|
+
const { state, changed, live } = await commit({
|
|
1325
1379
|
type: "set_target",
|
|
1326
|
-
targetEighths: toEighths(args.pages ?? args.minutes),
|
|
1380
|
+
...(args.pages !== undefined || args.minutes !== undefined ? { targetEighths: toEighths(args.pages ?? args.minutes) } : {}),
|
|
1381
|
+
...(args.open !== undefined ? { open: args.open } : {}),
|
|
1327
1382
|
});
|
|
1383
|
+
if (!changed) return ok("Target unchanged: it already read that way.");
|
|
1384
|
+
if (state.targetOpen) {
|
|
1385
|
+
return ok(`Target left open, by the writer's word: "${state.targetOpen}"${where(live)}. The reading lists it and reads the cards against a half-hour and a feature meanwhile: about ${formatPages(boardEighths(state))} pages — against 30, ${againstWord(state, 30 * EIGHTHS_PER_PAGE)}; against 120, ${againstWord(state, 120 * EIGHTHS_PER_PAGE)}. set_target with pages or minutes decides it.`, { targetEighths: state.targetEighths, targetOpen: state.targetOpen });
|
|
1386
|
+
}
|
|
1328
1387
|
return ok(
|
|
1329
1388
|
`Target is ${formatPages(state.targetEighths)} pages${where(live)}. The cards add up to about ${formatPages(boardEighths(state))} — ${boardEighths(state) > state.targetEighths ? `${formatPages(boardEighths(state) - state.targetEighths)} over` : `${formatPages(state.targetEighths - boardEighths(state))} under`}.`,
|
|
1330
1389
|
{ targetEighths: state.targetEighths },
|
|
@@ -1567,6 +1626,7 @@ server.registerTool(
|
|
|
1567
1626
|
const reading = readWall(state, { elsewhere: Object.keys(elsewhereForRead), laterBoards: laterBoards(projectForRead, boardsNow), paidBy: landingsOn(projectForRead, boardsNow, readId).paid });
|
|
1568
1627
|
lastReading = { findings: reading.findings };
|
|
1569
1628
|
sinceRead.length = 0;
|
|
1629
|
+
readOnce = true;
|
|
1570
1630
|
const runs = describeRuns(reading, state).map((line, index) => {
|
|
1571
1631
|
const ids = reading.runs[index]?.ids ?? [];
|
|
1572
1632
|
return ids.length ? `${line} — ${ids.map((id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`).join(", ")}` : line;
|
|
@@ -1582,6 +1642,7 @@ server.registerTool(
|
|
|
1582
1642
|
// Fields left open by the writer's word (R61): the logline and the whens are the reading's; the premise and the board's name are the project's.
|
|
1583
1643
|
const openFieldLines = [
|
|
1584
1644
|
...(projectForRead.nameOpen ? [` - the project's name — ${projectForRead.nameOpen}`] : []),
|
|
1645
|
+
...(state.targetOpen ? [` - the target — ${state.targetOpen}`] : []),
|
|
1585
1646
|
...reading.openFields.filter((field) => field.field === "logline").map((field) => ` - the logline — ${field.words}`),
|
|
1586
1647
|
...(projectForRead.premiseOpen ? [` - the premise — ${projectForRead.premiseOpen}`] : []),
|
|
1587
1648
|
...(readBoardMeta?.nameOpen ? [` - this board's name — ${readBoardMeta.nameOpen}`] : []),
|
|
@@ -1610,9 +1671,11 @@ server.registerTool(
|
|
|
1610
1671
|
...(projectForRead.premiseOpen ? [`premise: open, by the writer's word — "${projectForRead.premiseOpen}"`] : []),
|
|
1611
1672
|
`logline: ${state.loglineOpen ? `open, by the writer's word — "${state.loglineOpen}"` : state.logline ? `"${state.logline}"` : "(none yet)"}`,
|
|
1612
1673
|
"the cast and the places are list_board's, not the reading's",
|
|
1613
|
-
state.
|
|
1614
|
-
? `runtime: about ${formatPages(boardEighths(state))} pages (${whose || "no cards"});
|
|
1615
|
-
:
|
|
1674
|
+
state.targetOpen
|
|
1675
|
+
? `runtime: about ${formatPages(boardEighths(state))} pages (${whose || "no cards"}); target open, by the writer's word — "${state.targetOpen}" (against 30 it would be ${againstWord(state, 30 * EIGHTHS_PER_PAGE)}; against 120, ${againstWord(state, 120 * EIGHTHS_PER_PAGE)})${sketchLine(state)}`
|
|
1676
|
+
: state.targetEighths === DEFAULT_TARGET_EIGHTHS
|
|
1677
|
+
? `runtime: about ${formatPages(boardEighths(state))} pages (${whose || "no cards"}); no target set (set_target)${sketchLine(state)}`
|
|
1678
|
+
: `runtime: about ${formatPages(boardEighths(state))} pages of the ${formatPages(state.targetEighths)}-page target the writer set (set_target changes it) — ${boardEighths(state) > state.targetEighths ? `${formatPages(boardEighths(state) - state.targetEighths)} over` : boardEighths(state) < state.targetEighths ? `${formatPages(state.targetEighths - boardEighths(state))} under` : "on it"} (${whose || "no cards"}; page_count is the script so far)${sketchLine(state)}`,
|
|
1616
1679
|
`groups: ${
|
|
1617
1680
|
state.groups.length
|
|
1618
1681
|
? state.groups
|
|
@@ -1643,6 +1706,9 @@ server.registerTool(
|
|
|
1643
1706
|
...(reading.open.length || openFieldLines.length
|
|
1644
1707
|
? ["open, by the writer's word (listed, not asked about while the words stand; set_open with \"\" closes a card, the field's own tool with open \"\" a field):", ...openFieldLines, ...reading.open.map((item) => ` - "${state.notes.find((note) => note.id === item.id)?.headline ?? item.id}" — ${item.words}${item.hides.length ? ` (closed, it would be asked ${item.hides.map((kind) => ASK_WORDS[kind] ?? CHECK_WORDS[kind] ?? kind).join("; ")})` : ""}`)]
|
|
1645
1708
|
: []),
|
|
1709
|
+
...(reading.versions.length
|
|
1710
|
+
? ["two versions, not chosen (the front card is in the story; choose_version decides):", ...reading.versions.map((pair) => ` - "${state.notes.find((note) => note.id === pair.id)?.headline ?? pair.id}" or ${pair.alternatives.map((id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`).join(" or ")}`)]
|
|
1711
|
+
: []),
|
|
1646
1712
|
...(reading.threads.length
|
|
1647
1713
|
? ["threads (the writer's strings through the story; a loose end is asked about below):", ...reading.threads.map((thread) => ` - "${thread.name}": ${thread.ids.length ? thread.ids.map((id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`).join(" → ") : "no card yet"}${thread.startOpen ? " — starts nowhere yet" : ""}${thread.endOpen ? " — ends nowhere yet" : ""}${!thread.startOpen && !thread.endOpen && thread.ids.length >= 2 ? ` — both ends tied, about ${formatPages(thread.apart)} pages apart` : ""}`)]
|
|
1648
1714
|
: []),
|
|
@@ -2223,7 +2289,7 @@ server.registerTool(
|
|
|
2223
2289
|
}
|
|
2224
2290
|
const printed = sceneLineCount(args.text);
|
|
2225
2291
|
return ok(
|
|
2226
|
-
`Wrote "${result.headline}": ${printed} line(s) as they print (headings, blank lines and wrapped dialogue counted), measured at ${formatPages(noteEighths(result))} of a 55-line page, rounded to the nearest eighth and never below one eighth${where(live)}.${revisionMark(state, result.id)}${once("heading-from-place", " The heading comes from the card's place and when, so the text starts with the action.")} While the text stands the wall reads the measure, not the estimate${(() => {
|
|
2292
|
+
`Wrote "${result.headline}": ${printed} line(s) as they print (headings, blank lines and wrapped dialogue counted), measured at ${formatPages(noteEighths(result))} of a 55-line page, rounded to the nearest eighth and never below one eighth${noteEighths(result) < (result.lengthEighths ?? DEFAULT_NOTE_EIGHTHS) ? " — a sketch: shorter than the page it was read as; the wall counts the measure and says so" : ""}${(() => { const found = cameraLines(result.text); return found.length ? ` — ${found.length} line${found.length === 1 ? "" : "s"} the camera cannot see (${cameraVerbs(found).join(", ")}): the reminder "Write for the camera" is the house's; show it or cut it, on the writer's word` : ""; })()}${where(live)}.${revisionMark(state, result.id)}${once("heading-from-place", " The heading comes from the card's place and when, so the text starts with the action.")} While the text stands the wall reads the measure, not the estimate${(() => {
|
|
2227
2293
|
// How far the measure sits from what the card was read as before (round eighteen, entry 43): the writer's estimate, or the page an unsized card is read as.
|
|
2228
2294
|
const before = result.lengthEighths !== null ? result.lengthEighths : 8;
|
|
2229
2295
|
const label = result.lengthEighths !== null ? `the writer's ${formatPages(result.lengthEighths)} pages` : "the page an unsized card is read as";
|
|
@@ -2290,6 +2356,7 @@ server.registerTool(
|
|
|
2290
2356
|
let index = 0;
|
|
2291
2357
|
// The changed lines of the scene being printed, starred in the right margin as plain text stars them (round sixteen, entry 32).
|
|
2292
2358
|
let changedTexts = new Set();
|
|
2359
|
+
let cameraByText = new Map();
|
|
2293
2360
|
for (const line of text.split("\n")) {
|
|
2294
2361
|
if (/^\.(?!\.)/.test(line) && index < ids.length) {
|
|
2295
2362
|
const note = state.notes.find((item) => item.id === ids[index]);
|
|
@@ -2300,9 +2367,14 @@ server.registerTool(
|
|
|
2300
2367
|
const sourceLines = (note?.text ?? "").split("\n");
|
|
2301
2368
|
changedTexts = new Set([...(mark?.lines ?? [])].map((at) => sourceLines[at]).filter((item) => item && item.trim()));
|
|
2302
2369
|
const revised = mark?.revised ? ` · changed in the ${state.revision.color} revision${changedTexts.size ? ` (${changedTexts.size} line${changedTexts.size === 1 ? "" : "s"} starred below)` : ""}` : "";
|
|
2303
|
-
|
|
2370
|
+
// The lines the camera cannot see (the handover's call 6): marked on the page, never asked on the wall.
|
|
2371
|
+
const camera = note ? cameraLines(note.text ?? "") : [];
|
|
2372
|
+
cameraByText = new Map(camera.map((item) => [item.line, item.verbs]));
|
|
2373
|
+
const cameraNote = camera.length ? ` · camera: ${camera.length} line${camera.length === 1 ? "" : "s"} it cannot see (${cameraVerbs(camera).join(", ")})` : "";
|
|
2374
|
+
lines.push(`${line} [[id: ${note?.id ?? "?"} · ${note && isMeasured(note) ? "measured" : "estimated"} ${formatPages(note ? noteEighths(note) : 0)}pp${standIn}${numbered}${revised}${cameraNote}]]`);
|
|
2304
2375
|
} else {
|
|
2305
|
-
|
|
2376
|
+
const verbs = cameraByText.get(line.trim());
|
|
2377
|
+
lines.push(`${line}${changedTexts.has(line) ? " *" : ""}${verbs ? ` ◂ ${verbs.join(", ")}` : ""}`);
|
|
2306
2378
|
}
|
|
2307
2379
|
}
|
|
2308
2380
|
return ok(lines.join("\n"));
|
|
@@ -2785,6 +2857,56 @@ server.registerTool(
|
|
|
2785
2857
|
// The open card (R59): the writer's word that a card is not decided. The
|
|
2786
2858
|
// per-card twin of leave_question — that one leaves a question, this one
|
|
2787
2859
|
// leaves a card.
|
|
2860
|
+
/**
|
|
2861
|
+
* Two versions of one scene (R65, the handover's call 5 on Robert's word):
|
|
2862
|
+
* a card set behind another as its other version leaves the story — the
|
|
2863
|
+
* order, the count, the pages, every export — and waits there; the reading
|
|
2864
|
+
* lists the pair and asks nothing of it until the writer chooses.
|
|
2865
|
+
*/
|
|
2866
|
+
server.registerTool(
|
|
2867
|
+
"set_alternative",
|
|
2868
|
+
{
|
|
2869
|
+
title: "Another version of a scene",
|
|
2870
|
+
description:
|
|
2871
|
+
"Set a card behind another as its other version — two endings, two ways a scene could go — by id or headline. The version leaves the story: out of the order, the count, the pages and every export; its follows arrows are dropped (setup arrows stay). The wall draws it tucked behind its sibling; read_wall lists the pair under \"two versions, not chosen\" and asks nothing of it; choose_version decides. of: \"\" takes a card out from behind and it stands as a plain card again. Only on the writer's word: two versions the notes hold, never two the agent could not choose between.",
|
|
2872
|
+
inputSchema: { id: z.string(), of: z.string() },
|
|
2873
|
+
},
|
|
2874
|
+
async (args) => {
|
|
2875
|
+
const { state: current } = await readBoard();
|
|
2876
|
+
const byRef = (ref) => current.notes.find((note) => note.id === ref) ?? current.notes.find((note) => note.headline.trim().toLowerCase() === ref.trim().toLowerCase()) ?? null;
|
|
2877
|
+
const card = byRef(args.id);
|
|
2878
|
+
if (!card) return ok(`No card with id or headline "${args.id}". Call list_board.`);
|
|
2879
|
+
const front = args.of.trim() ? byRef(args.of) : null;
|
|
2880
|
+
if (args.of.trim() && !front) return ok(`No card with id or headline "${args.of}". Call list_board.`);
|
|
2881
|
+
if (front?.alternativeOf) return ok(`"${front.headline}" is itself a version of another card; set the version behind the front card, "${current.notes.find((note) => note.id === front.alternativeOf)?.headline ?? front.alternativeOf}".`);
|
|
2882
|
+
if (front && current.notes.some((note) => note.alternativeOf === card.id)) return ok(`"${card.headline}" has a version behind it already; choose_version there first.`);
|
|
2883
|
+
const { state, changed, result, live } = await commit({ type: "set_alternative", id: card.id, of: front ? front.id : null });
|
|
2884
|
+
if (!changed) return ok(front ? `"${card.headline}" already stands behind "${front.headline}".` : `"${card.headline}" is not a version of another card.`);
|
|
2885
|
+
if (!front) return ok(`"${card.headline}" stands as a plain card again, unwired${where(live)}: create_arrow or move_scene puts it in the order.`, result);
|
|
2886
|
+
return ok(`"${card.headline}" is now the other version of "${front.headline}"${where(live)}: out of the order, the count and the pages${result.arrowsDropped ? `, ${result.arrowsDropped} follows arrow${result.arrowsDropped === 1 ? "" : "s"} dropped` : ""}. The reading lists the pair as two versions, not chosen, and asks nothing of it; choose_version decides. The wall draws it tucked behind its sibling.`, result);
|
|
2887
|
+
},
|
|
2888
|
+
);
|
|
2889
|
+
|
|
2890
|
+
server.registerTool(
|
|
2891
|
+
"choose_version",
|
|
2892
|
+
{
|
|
2893
|
+
title: "Choose a version",
|
|
2894
|
+
description:
|
|
2895
|
+
"Choose one of two versions of a scene, by id or headline: the chosen card is the scene, in the front card's place — its arrows, its rank, its group; the other goes, or with keep true stands beside it as a plain unwired card. Only on the writer's word.",
|
|
2896
|
+
inputSchema: { id: z.string(), keep: z.boolean().optional() },
|
|
2897
|
+
},
|
|
2898
|
+
async (args) => {
|
|
2899
|
+
const { state: current } = await readBoard();
|
|
2900
|
+
const card = current.notes.find((note) => note.id === args.id) ?? current.notes.find((note) => note.headline.trim().toLowerCase() === args.id.trim().toLowerCase());
|
|
2901
|
+
if (!card) return ok(`No card with id or headline "${args.id}". Call list_board.`);
|
|
2902
|
+
const other = card.alternativeOf ? current.notes.find((note) => note.id === card.alternativeOf) : current.notes.find((note) => note.alternativeOf === card.id);
|
|
2903
|
+
if (!other) return ok(`"${card.headline}" has no other version; nothing to choose.`);
|
|
2904
|
+
const { changed, result, live } = await commit({ type: "choose_version", id: card.id, keep: args.keep === true });
|
|
2905
|
+
if (!changed) return ok("Nothing chosen.");
|
|
2906
|
+
return ok(`Chose "${card.headline}"${result.steppedForward ? ` — it steps forward into "${other.headline}"'s place, with its arrows, rank and group` : ""}${where(live)}. "${other.headline}" ${result.kept ? "stands beside it as a plain card, unwired" : "is gone"}.`, result);
|
|
2907
|
+
},
|
|
2908
|
+
);
|
|
2909
|
+
|
|
2788
2910
|
server.registerTool(
|
|
2789
2911
|
"set_open",
|
|
2790
2912
|
{
|
|
@@ -3735,7 +3857,7 @@ server.registerTool(
|
|
|
3735
3857
|
return ok(
|
|
3736
3858
|
[
|
|
3737
3859
|
`projects: ${projects.length} (as ${account.email})${projects.length === 0 ? ` — ${noProjectYet()}` : ""}`,
|
|
3738
|
-
...projects.map((row) => ` - ${row.id} — "${row.record.name}"${row.id === account.projectId ? " (working)" : ""}: ${row.record.boards.length} board(s) · ${(row.people ?? []).join(", ")}`),
|
|
3860
|
+
...projects.map((row) => ` - ${row.id} — "${row.record.name}"${row.id === account.projectId ? " (working)" : ""}: ${row.record.boards.length} board(s) · ${(row.people ?? []).join(", ")}${row.id === account.projectId ? (() => { const people = presentPeople(); return people.length ? ` · open now on ${people.length} screen${people.length === 1 ? "" : "s"}: ${people.join(", ")}` : " · no wall open right now"; })() : ""}`),
|
|
3739
3861
|
].join("\n"),
|
|
3740
3862
|
projects.map((row) => ({ id: row.id, name: row.record.name, boards: row.record.boards.length, people: row.people })),
|
|
3741
3863
|
);
|
|
@@ -3813,7 +3935,7 @@ server.registerTool(
|
|
|
3813
3935
|
title: "Start a project",
|
|
3814
3936
|
description:
|
|
3815
3937
|
"Through the account door: start a new project of the writer's with this name — one empty board, nothing on it — and work it from now on. The writer sees it under Projects on every device. A title not decided: open with the writer's words (\"The Allotments, or Plot 14\") instead of a name, and the project starts as Untitled project with those words beside it. board names the first board; boardOpen leaves its name open in the writer's words instead (\"the pilot, or the film\"), so a board born from a maybe is not silently \"Board 1\".",
|
|
3816
|
-
inputSchema: { name: z.string().min(1).optional(), open: z.string().optional(), board: z.string().optional(), boardOpen: z.string().optional(), pages: pagesSchema.optional(), minutes: z.number().positive().optional() },
|
|
3938
|
+
inputSchema: { name: z.string().min(1).optional(), open: z.string().optional(), board: z.string().optional(), boardOpen: z.string().optional(), pages: pagesSchema.optional(), minutes: z.number().positive().optional(), targetOpen: z.string().optional().describe("The writer's words for why the length is not decided — \"half-hour or feature\" — so the target is born open instead of the feature default standing unsaid.") },
|
|
3817
3939
|
},
|
|
3818
3940
|
async (args) => {
|
|
3819
3941
|
const account = await findAccount();
|
|
@@ -3829,12 +3951,12 @@ server.registerTool(
|
|
|
3829
3951
|
const inserted = await account.client.from("projects").insert({ id: record.id, record, reminders: null, rev: 1 });
|
|
3830
3952
|
if (inserted.error) return ok(`Could not start the project: ${inserted.error.message}`);
|
|
3831
3953
|
const target = args.pages ?? args.minutes;
|
|
3832
|
-
const state = target === undefined ?
|
|
3954
|
+
const state = { ...emptyState(), ...(target === undefined ? {} : { targetEighths: toEighths(target) }), ...(args.targetOpen?.trim() && target === undefined ? { targetOpen: args.targetOpen.trim().replace(/\s+/g, " ") } : {}) };
|
|
3833
3955
|
const board = await account.client.from("boards").insert({ id: record.activeBoardId, project_id: record.id, state, rev: 1, updated_by: null });
|
|
3834
3956
|
if (board.error) return ok(`Started "${record.name}" but could not make its first board: ${board.error.message}`);
|
|
3835
3957
|
workingProject(record.id, record.name, (account.projectCount ?? 0) + 1);
|
|
3836
3958
|
joinPresence(record.id);
|
|
3837
|
-
const targetLine = target === undefined ? ` Its target is ${formatPages(state.targetEighths)} pages, the default for a feature; set_target for a pilot or a half-hour, or pass pages or minutes here.` : ` Its target is ${formatPages(state.targetEighths)} pages.`;
|
|
3959
|
+
const targetLine = state.targetOpen ? ` Its target is left open, by the writer's word: "${state.targetOpen}"; the reading reads the cards against a half-hour and a feature until set_target decides it.` : target === undefined ? ` Its target is ${formatPages(state.targetEighths)} pages, the default for a feature; set_target for a pilot or a half-hour, or pass pages or minutes here.` : ` Its target is ${formatPages(state.targetEighths)} pages.`;
|
|
3838
3960
|
const first = record.boards[0];
|
|
3839
3961
|
const nameOpenLine = `${record.nameOpen ? ` The project's name is left open, by the writer's word: "${record.nameOpen}"; rename_project decides it.` : ""}${first.nameOpen ? ` The board's name is left open, by the writer's word: "${first.nameOpen}"; rename_board decides it.` : ""}`;
|
|
3840
3962
|
return ok(`Started "${record.name}" (${record.id}) with its first board "${first.name}" (${first.id}), and working it now, as ${account.email}.${targetLine}${nameOpenLine}${oneCallHint(record)}`, { id: record.id, name: record.name, boardId: first.id, boardName: first.name, boardNameOpen: first.nameOpen ?? "", targetEighths: state.targetEighths });
|
package/src/board/agents.js
CHANGED
|
@@ -23,7 +23,7 @@ export const AGENTS = {
|
|
|
23
23
|
{
|
|
24
24
|
id: "hosted",
|
|
25
25
|
name: "The hosted door, with nothing installed",
|
|
26
|
-
text: "Where someone runs PlotCoder's server for you — npx -y plotcoder-board@latest serve puts it on a port, and the repo has a Dockerfile — an MCP client connects over HTTP with the writer's sign-in on the request: claude mcp add plotcoder --transport http https://<that host>/mcp --header \"Authorization: Basic <base64 of email:password>\", then start the session again. The same server, the same tools, the account as the wall, no disk. PlotCoder
|
|
26
|
+
text: "Where someone runs PlotCoder's server for you — npx -y plotcoder-board@latest serve puts it on a port, and the repo has a Dockerfile — an MCP client connects over HTTP with the writer's sign-in on the request: claude mcp add plotcoder --transport http https://<that host>/mcp --header \"Authorization: Basic <base64 of email:password>\", then start the session again. The same server, the same tools, the account as the wall, no disk. PlotCoder runs one, as a Supabase Edge Function: https://kmpahjsggbleygsnuwug.supabase.co/functions/v1/mcp — claude mcp add plotcoder --transport http https://kmpahjsggbleygsnuwug.supabase.co/functions/v1/mcp --header \"Authorization: Basic <base64 of email:password>\", or in the Claude desktop app, Settings › Connectors › add a custom connector with that address and header: the app keeps a connector in its own settings, where a stdio block in a file can vanish. The password rides in the header, base64 over https, as the env block carries it; share a machine and know it.",
|
|
27
27
|
},
|
|
28
28
|
{
|
|
29
29
|
id: "account",
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Type surface for camera.js — the lines the camera cannot see.
|
|
2
|
+
export type CameraLine = { at: number; line: string; verbs: string[] };
|
|
3
|
+
export declare function cameraLines(text: string): CameraLine[];
|
|
4
|
+
export declare function cameraVerbs(found: CameraLine[]): string[];
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// What the camera cannot see (the handover's call 6, round eighteen entry
|
|
2
|
+
// 44): a line of action that says what someone knows, feels, thinks or
|
|
3
|
+
// wants is a fact the page states and the screen cannot show. The house
|
|
4
|
+
// principle "write for the camera and the cut" is a reminder every project
|
|
5
|
+
// starts with; this marks the lines it is about, on the page and in the
|
|
6
|
+
// write's reply, and never asks a question on the wall — a sentence on a
|
|
7
|
+
// page is the writer's. Only action is read: a character cue and the
|
|
8
|
+
// dialogue under it are theirs to say.
|
|
9
|
+
|
|
10
|
+
/** Interior verbs, as they appear in present- and past-tense action. */
|
|
11
|
+
const INTERIOR = [
|
|
12
|
+
"knows", "knew", "feels", "felt", "thinks", "thought", "remembers", "remembered",
|
|
13
|
+
"realises", "realizes", "realised", "realized", "wants", "wanted", "decides", "decided",
|
|
14
|
+
"understands", "understood", "wonders", "wondered", "hopes", "hoped", "believes", "believed",
|
|
15
|
+
"fears", "feared", "regrets", "regretted", "loves", "loved", "hates", "hated",
|
|
16
|
+
];
|
|
17
|
+
const INTERIOR_RE = new RegExp(`\\b(${INTERIOR.join("|")})\\b`, "gi");
|
|
18
|
+
|
|
19
|
+
/** A character cue: capitals and little else, short, no ending full stop; dialogue follows it until a blank line. */
|
|
20
|
+
function isCue(line) {
|
|
21
|
+
const trimmed = line.trim();
|
|
22
|
+
if (!trimmed || trimmed.length > 40) return false;
|
|
23
|
+
if (/[.!?]$/.test(trimmed) && !/\(V\.O\.\)|\(O\.S\.\)|\(CONT'D\)$/i.test(trimmed)) return false;
|
|
24
|
+
const letters = trimmed.replace(/[^A-Za-z]/g, "");
|
|
25
|
+
return letters.length >= 2 && letters === letters.toUpperCase();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The action lines the camera cannot see, with the verbs that say so:
|
|
30
|
+
* `{ at, line, verbs }` per line, in order. Headings (a leading dot),
|
|
31
|
+
* cues, dialogue and parentheticals are skipped.
|
|
32
|
+
*/
|
|
33
|
+
export function cameraLines(text) {
|
|
34
|
+
const lines = (text ?? "").split("\n");
|
|
35
|
+
const found = [];
|
|
36
|
+
let inDialogue = false;
|
|
37
|
+
for (const [at, line] of lines.entries()) {
|
|
38
|
+
const trimmed = line.trim();
|
|
39
|
+
if (!trimmed) { inDialogue = false; continue; }
|
|
40
|
+
if (trimmed.startsWith(".") || trimmed.startsWith("(")) continue;
|
|
41
|
+
if (isCue(trimmed)) { inDialogue = true; continue; }
|
|
42
|
+
if (inDialogue) continue;
|
|
43
|
+
const verbs = [...new Set([...trimmed.matchAll(INTERIOR_RE)].map((match) => match[1].toLowerCase()))];
|
|
44
|
+
if (verbs.length) found.push({ at, line: trimmed, verbs });
|
|
45
|
+
}
|
|
46
|
+
return found;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The verbs across every marked line, once each, in order of first use. */
|
|
50
|
+
export function cameraVerbs(found) {
|
|
51
|
+
return [...new Set(found.flatMap((item) => item.verbs))];
|
|
52
|
+
}
|
package/src/board/readWall.d.ts
CHANGED
|
@@ -73,6 +73,8 @@ export type WallReading = {
|
|
|
73
73
|
open: Array<{ id: string; words: string; hides: FindingKind[] }>;
|
|
74
74
|
/** Fields left open by the writer's word (R61): the board's logline, and each card's when, in story order. Listed, not asked. */
|
|
75
75
|
openFields: Array<{ field: "logline"; words: string } | { field: "location"; id: string; words: string } | { field: "when"; id: string; words: string }>;
|
|
76
|
+
/** Two versions of one scene (R65): each front card with the versions behind it, in story order; listed, never asked. */
|
|
77
|
+
versions: Array<{ id: string; alternatives: string[] }>;
|
|
76
78
|
/** Threads (R60): each named string with its cards in story order and which ends are open. */
|
|
77
79
|
threads: Array<{ id: string; name: string; ids: string[]; startOpen: boolean; endOpen: boolean; /** Eighths from the first card's start to the last card's start; 0 with fewer than two cards. */ apart: number }>;
|
|
78
80
|
/** Cards here that pay off a fold of another board (R58), composed by the door from the project. */
|
package/src/board/readWall.js
CHANGED
|
@@ -516,6 +516,8 @@ export function readWall(state, options = {}) {
|
|
|
516
516
|
paidBy: paidHere,
|
|
517
517
|
open: describeOpen(state, options, order, openIds),
|
|
518
518
|
openFields: describeOpenFields(state, order),
|
|
519
|
+
// Two versions of one scene (R65): the front card and the one behind it, in story order; listed, never asked.
|
|
520
|
+
versions: order.filter((note) => state.notes.some((item) => item.alternativeOf === note.id)).map((note) => ({ id: note.id, alternatives: state.notes.filter((item) => item.alternativeOf === note.id).map((item) => item.id) })),
|
|
519
521
|
threads,
|
|
520
522
|
findings: asked,
|
|
521
523
|
left,
|
package/src/board/reducer.d.ts
CHANGED
|
@@ -81,6 +81,8 @@ export type BoardNote = {
|
|
|
81
81
|
plants: boolean;
|
|
82
82
|
/** What the fold plants, in the writer's words (R62), or empty; never set on an unfolded card. */
|
|
83
83
|
plantsWhat: string;
|
|
84
|
+
/** Another version of another card (R65): the id of the card this one stands behind, or null. A version is out of the order, the count and the pages until chosen. */
|
|
85
|
+
alternativeOf: string | null;
|
|
84
86
|
/** When folded: the id of another board of the project where it pays off (R50), or null. */
|
|
85
87
|
payoffBoardId: string | null;
|
|
86
88
|
/** The scene on that board that pays it off (R58), or null while the board is only a promise. */
|
|
@@ -120,6 +122,8 @@ export type BoardState = {
|
|
|
120
122
|
logline: string;
|
|
121
123
|
/** The writer's words for why there is no logline yet (R61), or empty. */
|
|
122
124
|
loglineOpen: string;
|
|
125
|
+
/** The writer's words for why the target is not decided, or empty; the number stands as the default meanwhile. */
|
|
126
|
+
targetOpen: string;
|
|
123
127
|
/** Target script length in eighths of a page; 120 pages for a feature (R25). */
|
|
124
128
|
targetEighths: number;
|
|
125
129
|
/** The roster: every person in the story, whether or not they are on a card yet (R29). */
|
|
@@ -169,7 +173,7 @@ export type Command =
|
|
|
169
173
|
| { type: "set_logline"; logline?: string; open?: string }
|
|
170
174
|
| { type: "set_rank"; ids: string[]; rank: NoteRank }
|
|
171
175
|
| { type: "set_length"; ids: string[]; lengthEighths: number | null }
|
|
172
|
-
| { type: "set_target"; targetEighths
|
|
176
|
+
| { type: "set_target"; targetEighths?: number; open?: string }
|
|
173
177
|
| {
|
|
174
178
|
type: "create_note";
|
|
175
179
|
id?: string;
|
|
@@ -218,6 +222,8 @@ export type Command =
|
|
|
218
222
|
| { type: "set_cast"; ids: string[]; characterIds: string[] }
|
|
219
223
|
| { type: "set_plant"; ids: string[]; plants?: boolean; what?: string }
|
|
220
224
|
| { type: "set_open"; ids: string[]; open: string }
|
|
225
|
+
| { type: "set_alternative"; id: string; of: string | null }
|
|
226
|
+
| { type: "choose_version"; id: string; keep?: boolean }
|
|
221
227
|
| { type: "set_payoff_board"; ids: string[]; boardId: string | null; noteId?: string | null }
|
|
222
228
|
| { type: "set_location"; ids: string[]; location?: string; open?: string }
|
|
223
229
|
| { type: "set_when"; ids: string[]; when?: string; open?: string }
|
|
@@ -250,3 +256,8 @@ export declare function applyCommand(
|
|
|
250
256
|
command: Command,
|
|
251
257
|
now?: string,
|
|
252
258
|
): CommandResult;
|
|
259
|
+
|
|
260
|
+
/** Rows top to bottom, cards left to right within a row (moved here for R62). */
|
|
261
|
+
export declare function readingOrder(notes: BoardNote[]): BoardNote[];
|
|
262
|
+
/** The follows arrows where they exist, reading order where they do not; versions of other cards left out (R56, R65). */
|
|
263
|
+
export declare function storyOrder(state: BoardState, ids?: string[]): BoardNote[];
|
package/src/board/reducer.js
CHANGED
|
@@ -167,7 +167,7 @@ export function isMeasured(note) {
|
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
export function boardEighths(state) {
|
|
170
|
-
return state.notes.reduce((total, note) => total + noteEighths(note), 0);
|
|
170
|
+
return state.notes.reduce((total, note) => total + (note.alternativeOf ? 0 : noteEighths(note)), 0);
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
/**
|
|
@@ -220,6 +220,7 @@ export function emptyState() {
|
|
|
220
220
|
return {
|
|
221
221
|
logline: "",
|
|
222
222
|
targetEighths: DEFAULT_TARGET_EIGHTHS,
|
|
223
|
+
targetOpen: "",
|
|
223
224
|
loglineOpen: "",
|
|
224
225
|
characters: [],
|
|
225
226
|
notes: [],
|
|
@@ -256,6 +257,8 @@ export function seedState(now = nowIso()) {
|
|
|
256
257
|
plants: false,
|
|
257
258
|
// What the fold plants, in the writer's words (R62), or nothing.
|
|
258
259
|
plantsWhat: "",
|
|
260
|
+
// Another version of another card (R65): the card this one stands behind, or null.
|
|
261
|
+
alternativeOf: null,
|
|
259
262
|
// A fold that pays off on another board — a later episode — names it here;
|
|
260
263
|
// null claims nothing (R50). The scene there that pays it off, once one
|
|
261
264
|
// does (R58); null while the board is a promise.
|
|
@@ -274,6 +277,7 @@ export function seedState(now = nowIso()) {
|
|
|
274
277
|
// new writer finds out the logline is there at all.
|
|
275
278
|
logline: "",
|
|
276
279
|
targetEighths: DEFAULT_TARGET_EIGHTHS,
|
|
280
|
+
targetOpen: "",
|
|
277
281
|
loglineOpen: "",
|
|
278
282
|
// Two people, cast on the cards, so a new writer sees what the roster is for.
|
|
279
283
|
characters: [
|
|
@@ -349,7 +353,8 @@ export function normalizeState(value) {
|
|
|
349
353
|
// Cards written before R25 have no length; a scene is about a page. Cards
|
|
350
354
|
// written before R29 have no cast; nobody is in the scene until someone is.
|
|
351
355
|
let patched = false;
|
|
352
|
-
|
|
356
|
+
|
|
357
|
+
const rawIds = new Set((value.notes ?? []).map((item) => item?.id).filter((id) => typeof id === "string")); const notes = value.notes.map((note) => {
|
|
353
358
|
const rank = note && NOTE_RANKS.includes(note.rank) ? note.rank : "scene";
|
|
354
359
|
// Unsized stays unsized: null (or no field, before R25) claims nothing and
|
|
355
360
|
// reads as about a page. A number is the writer's estimate, kept in range.
|
|
@@ -368,6 +373,8 @@ export function normalizeState(value) {
|
|
|
368
373
|
const payoffNoteId = payoffBoardId && typeof note?.payoffNoteId === "string" && note.payoffNoteId ? note.payoffNoteId : null;
|
|
369
374
|
// Cards written before R59 are not open; a card claims to be decided until the writer says otherwise.
|
|
370
375
|
const open = typeof note?.open === "string" ? note.open : "";
|
|
376
|
+
// Cards written before R65 are versions of nothing; a version whose sibling is gone, or of itself, stands as a plain card.
|
|
377
|
+
const alternativeOf = typeof note?.alternativeOf === "string" && note.alternativeOf !== note?.id && rawIds.has(note.alternativeOf) ? note.alternativeOf : null;
|
|
371
378
|
// Cards written before R37 have no place; a scene is nowhere until it is.
|
|
372
379
|
const location = typeof note?.location === "string" ? note.location : "";
|
|
373
380
|
// Cards written before R61's edge have no open place; a place is decided or blank until the writer says otherwise.
|
|
@@ -386,6 +393,7 @@ export function normalizeState(value) {
|
|
|
386
393
|
sameIds(note.characterIds, characterIds) &&
|
|
387
394
|
note.plants === plants &&
|
|
388
395
|
note.plantsWhat === plantsWhat &&
|
|
396
|
+
note.alternativeOf === alternativeOf &&
|
|
389
397
|
note.payoffBoardId === payoffBoardId &&
|
|
390
398
|
note.payoffNoteId === payoffNoteId &&
|
|
391
399
|
note.open === open &&
|
|
@@ -398,8 +406,17 @@ export function normalizeState(value) {
|
|
|
398
406
|
return note;
|
|
399
407
|
}
|
|
400
408
|
patched = true;
|
|
401
|
-
return { ...note, rank, lengthEighths, characterIds, plants, plantsWhat, payoffBoardId, payoffNoteId, open, location, locationOpen, when, whenOpen, text };
|
|
409
|
+
return { ...note, rank, lengthEighths, characterIds, plants, plantsWhat, alternativeOf, payoffBoardId, payoffNoteId, open, location, locationOpen, when, whenOpen, text };
|
|
402
410
|
});
|
|
411
|
+
// A version of a version is a version of the front card, so the pair stays a pair.
|
|
412
|
+
for (const [index, note] of notes.entries()) {
|
|
413
|
+
if (!note.alternativeOf) continue;
|
|
414
|
+
const front = notes.find((item) => item.id === note.alternativeOf);
|
|
415
|
+
if (front?.alternativeOf) {
|
|
416
|
+
patched = true;
|
|
417
|
+
notes[index] = { ...note, alternativeOf: front.alternativeOf === note.id ? null : front.alternativeOf };
|
|
418
|
+
}
|
|
419
|
+
}
|
|
403
420
|
|
|
404
421
|
// Boards written before the production half (Roadmap 2, item 8) have no
|
|
405
422
|
// lock and no revision; both are null until a draft goes out.
|
|
@@ -430,10 +447,13 @@ export function normalizeState(value) {
|
|
|
430
447
|
.filter(Boolean);
|
|
431
448
|
// Boards written before R61 have no open logline; a logline is decided or blank until the writer says otherwise.
|
|
432
449
|
const loglineOpen = typeof value.loglineOpen === "string" ? value.loglineOpen : "";
|
|
450
|
+
// A target left open in the writer's words (the handover's calls, 2026-09-19): the number stands as the default meanwhile.
|
|
451
|
+
const targetOpen = typeof value.targetOpen === "string" ? value.targetOpen : "";
|
|
433
452
|
if (
|
|
434
453
|
value.logline === logline &&
|
|
435
454
|
value.loglineOpen === loglineOpen &&
|
|
436
455
|
value.targetEighths === targetEighths &&
|
|
456
|
+
value.targetOpen === targetOpen &&
|
|
437
457
|
!rosterPatched &&
|
|
438
458
|
!arrowsPatched &&
|
|
439
459
|
!patched &&
|
|
@@ -449,6 +469,7 @@ export function normalizeState(value) {
|
|
|
449
469
|
logline,
|
|
450
470
|
loglineOpen,
|
|
451
471
|
targetEighths,
|
|
472
|
+
targetOpen,
|
|
452
473
|
characters,
|
|
453
474
|
notes: patched ? notes : value.notes,
|
|
454
475
|
arrows: arrowsPatched ? arrows : value.arrows,
|
|
@@ -491,6 +512,11 @@ function cleanOpen(value) {
|
|
|
491
512
|
/** Cards within half a card's height of each other sit on one row. */
|
|
492
513
|
const ROW_TOLERANCE = NOTE_HEIGHT / 2;
|
|
493
514
|
|
|
515
|
+
/** A card that is another version of a card is not in the story (R65): not in the order, the count or the pages, until chosen. */
|
|
516
|
+
export function inStory(note) {
|
|
517
|
+
return !note.alternativeOf;
|
|
518
|
+
}
|
|
519
|
+
|
|
494
520
|
export function readingOrder(notes) {
|
|
495
521
|
const byTop = [...notes].sort((a, b) => a.y - b.y || a.x - b.x);
|
|
496
522
|
const rows = [];
|
|
@@ -518,7 +544,7 @@ export function readingOrder(notes) {
|
|
|
518
544
|
*/
|
|
519
545
|
export function storyOrder(state, ids) {
|
|
520
546
|
const scope = ids ? new Set(ids) : null;
|
|
521
|
-
const notes = state.notes.filter((note) => !scope || scope.has(note.id));
|
|
547
|
+
const notes = state.notes.filter((note) => inStory(note) && (!scope || scope.has(note.id)));
|
|
522
548
|
const reading = readingOrder(notes);
|
|
523
549
|
const byId = new Map(reading.map((note) => [note.id, note]));
|
|
524
550
|
const rank = new Map(reading.map((note, index) => [note.id, index]));
|
|
@@ -635,6 +661,7 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
635
661
|
? null
|
|
636
662
|
: clampEighths(command.lengthEighths, DEFAULT_NOTE_EIGHTHS, MAX_NOTE_EIGHTHS),
|
|
637
663
|
characterIds: knownCast(command.characterIds, state.characters ?? []),
|
|
664
|
+
alternativeOf: null,
|
|
638
665
|
plants: command.plants === true || Boolean(cleanOpen(command.plantsWhat)),
|
|
639
666
|
// What it plants, in the writer's words (R62): naming a plant folds the card.
|
|
640
667
|
plantsWhat: command.plants === false ? "" : cleanOpen(command.plantsWhat),
|
|
@@ -716,19 +743,14 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
716
743
|
}
|
|
717
744
|
|
|
718
745
|
case "set_target": {
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
);
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
}
|
|
727
|
-
return {
|
|
728
|
-
state: { ...state, targetEighths },
|
|
729
|
-
changed: true,
|
|
730
|
-
result: { targetEighths },
|
|
731
|
-
};
|
|
746
|
+
// A number decides the target and clears the open words; open words
|
|
747
|
+
// leave the number where it is (the default, or the last one set) and
|
|
748
|
+
// say the writer has not decided; open "" takes the words back.
|
|
749
|
+
const hasOpen = typeof command.open === "string";
|
|
750
|
+
const targetOpen = hasOpen ? cleanOpen(command.open) : typeof command.targetEighths === "number" ? "" : (state.targetOpen ?? "");
|
|
751
|
+
const targetEighths = typeof command.targetEighths === "number" ? clampEighths(command.targetEighths, DEFAULT_TARGET_EIGHTHS, MAX_TARGET_EIGHTHS) : state.targetEighths;
|
|
752
|
+
if (targetEighths === state.targetEighths && targetOpen === (state.targetOpen ?? "")) return { state, changed: false };
|
|
753
|
+
return { state: { ...state, targetEighths, targetOpen }, changed: true, result: { targetEighths, targetOpen } };
|
|
732
754
|
}
|
|
733
755
|
|
|
734
756
|
case "set_rank": {
|
|
@@ -780,7 +802,7 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
780
802
|
const gone = state.notes.find((note) => note.id === command.id);
|
|
781
803
|
if (!gone) return { state, changed: false };
|
|
782
804
|
const headlineOf = (id) => state.notes.find((note) => note.id === id)?.headline ?? id;
|
|
783
|
-
|
|
805
|
+
let notes = state.notes.filter((note) => note.id !== command.id);
|
|
784
806
|
const taken = state.arrows.filter((arrow) => arrow.from === command.id || arrow.to === command.id);
|
|
785
807
|
let arrows = state.arrows.filter((arrow) => !taken.includes(arrow));
|
|
786
808
|
// A card wired into a chain — one follows in, one follows out — leaves
|
|
@@ -804,6 +826,8 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
804
826
|
);
|
|
805
827
|
// A thread through the card keeps its name and loses the card (R60);
|
|
806
828
|
// the thread stays, with one card fewer, so the writer can retie it.
|
|
829
|
+
// A version whose front card goes stands as a plain card (R65).
|
|
830
|
+
notes = notes.map((note) => (note.alternativeOf === command.id ? { ...note, alternativeOf: null } : note));
|
|
807
831
|
const threadsLeft = [];
|
|
808
832
|
const threads = (state.threads ?? []).map((thread) => {
|
|
809
833
|
if (!thread.noteIds.includes(command.id)) return thread;
|
|
@@ -880,6 +904,60 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
880
904
|
return { state: { ...state, groups }, changed: true };
|
|
881
905
|
}
|
|
882
906
|
|
|
907
|
+
// Two versions of one scene (R65): a card set behind another as its
|
|
908
|
+
// alternative leaves the story — the order, the count, the pages — and
|
|
909
|
+
// waits there until the writer chooses one. The reading lists the pair.
|
|
910
|
+
case "set_alternative": {
|
|
911
|
+
const note = state.notes.find((item) => item.id === command.id);
|
|
912
|
+
if (!note) return { state, changed: false };
|
|
913
|
+
const of = typeof command.of === "string" && command.of ? command.of : null;
|
|
914
|
+
if (of === null) {
|
|
915
|
+
if (!note.alternativeOf) return { state, changed: false };
|
|
916
|
+
const notes = state.notes.map((item) => (item.id === command.id ? bump(item, { alternativeOf: null }, now) : item));
|
|
917
|
+
return { state: { ...state, notes }, changed: true, result: { id: command.id, of: null, arrowsDropped: 0 } };
|
|
918
|
+
}
|
|
919
|
+
const front = state.notes.find((item) => item.id === of);
|
|
920
|
+
if (!front || of === command.id || front.alternativeOf || state.notes.some((item) => item.alternativeOf === command.id)) return { state, changed: false };
|
|
921
|
+
if (note.alternativeOf === of) return { state, changed: false };
|
|
922
|
+
// Out of the story: its follows arrows go with it; setup arrows are claims and stay.
|
|
923
|
+
const arrows = state.arrows.filter((arrow) => arrow.kind === "setup" || (arrow.from !== command.id && arrow.to !== command.id));
|
|
924
|
+
const notes = state.notes.map((item) => (item.id === command.id ? bump(item, { alternativeOf: of }, now) : item));
|
|
925
|
+
return { state: { ...state, notes, arrows }, changed: true, result: { id: command.id, of, arrowsDropped: state.arrows.length - arrows.length } };
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Choose one of two versions (R65): the chosen card is the scene, in the
|
|
929
|
+
// front card's place; the other goes, or stands beside it as a plain
|
|
930
|
+
// card when the writer says keep.
|
|
931
|
+
case "choose_version": {
|
|
932
|
+
const chosen = state.notes.find((item) => item.id === command.id);
|
|
933
|
+
if (!chosen) return { state, changed: false };
|
|
934
|
+
const other = chosen.alternativeOf ? state.notes.find((item) => item.id === chosen.alternativeOf) : state.notes.find((item) => item.alternativeOf === chosen.id);
|
|
935
|
+
if (!other) return { state, changed: false };
|
|
936
|
+
const frontId = chosen.alternativeOf ? other.id : chosen.id;
|
|
937
|
+
let notes = state.notes;
|
|
938
|
+
let arrows = state.arrows;
|
|
939
|
+
let groups = state.groups;
|
|
940
|
+
if (chosen.alternativeOf) {
|
|
941
|
+
// The alternative steps forward: the front's arrows, place in the order, rank and group are its now.
|
|
942
|
+
arrows = arrows.map((arrow) => ({ ...arrow, from: arrow.from === frontId ? chosen.id : arrow.from, to: arrow.to === frontId ? chosen.id : arrow.to })).filter((arrow) => arrow.from !== arrow.to);
|
|
943
|
+
groups = groups.map((group) => (group.noteIds.includes(frontId) ? { ...group, noteIds: group.noteIds.map((id) => (id === frontId ? chosen.id : id)) } : group));
|
|
944
|
+
notes = notes.map((item) => (item.id === chosen.id ? bump(item, { alternativeOf: null, x: other.x, y: other.y, rank: other.rank, z: other.z }, now) : item));
|
|
945
|
+
}
|
|
946
|
+
const keep = command.keep === true;
|
|
947
|
+
if (keep) {
|
|
948
|
+
notes = notes.map((item) => (item.id === other.id ? bump(item, { alternativeOf: null, x: item.x + 40, y: item.y + 40 }, now) : item));
|
|
949
|
+
if (chosen.alternativeOf) {
|
|
950
|
+
arrows = arrows.filter((arrow) => arrow.kind === "setup" || (arrow.from !== other.id && arrow.to !== other.id));
|
|
951
|
+
groups = groups.map((group) => (group.noteIds.includes(other.id) ? { ...group, noteIds: group.noteIds.filter((id) => id !== other.id) } : group));
|
|
952
|
+
}
|
|
953
|
+
} else {
|
|
954
|
+
notes = notes.filter((item) => item.id !== other.id);
|
|
955
|
+
arrows = arrows.filter((arrow) => arrow.from !== other.id && arrow.to !== other.id);
|
|
956
|
+
groups = pruneGroups(groups.map((group) => ({ ...group, noteIds: group.noteIds.filter((id) => id !== other.id) })));
|
|
957
|
+
}
|
|
958
|
+
return { state: { ...state, notes, arrows, groups }, changed: true, result: { chosen: chosen.id, other: other.id, kept: keep, steppedForward: Boolean(chosen.alternativeOf) } };
|
|
959
|
+
}
|
|
960
|
+
|
|
883
961
|
// A thread (R60): a named string through cards, either end open until the
|
|
884
962
|
// writer ties it. A record of its own beside the fold and the setup arrow;
|
|
885
963
|
// the reading asks about each loose end from that end.
|
|
@@ -1167,6 +1245,7 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
1167
1245
|
characterIds: [],
|
|
1168
1246
|
plants: false,
|
|
1169
1247
|
plantsWhat: "",
|
|
1248
|
+
alternativeOf: null,
|
|
1170
1249
|
payoffBoardId: null,
|
|
1171
1250
|
payoffNoteId: null,
|
|
1172
1251
|
open: "",
|