plotcoder-board 0.1.27 → 0.1.29
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 +4 -4
- package/package.json +1 -1
- package/scripts/plotcoder-mcp-server.mjs +188 -23
- package/src/board/fountain.js +3 -0
- package/src/board/readWall.d.ts +7 -3
- package/src/board/readWall.js +51 -5
- package/src/board/reducer.d.ts +16 -0
- package/src/board/reducer.js +84 -2
- package/src/board/words.js +5 -0
- package/src/board/workflows.js +2 -2
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. Eighty-
|
|
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. Eighty-five tools: reading (`list_board`, `read_wall`, `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` and `at`) and `set_payoff`, `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.
|
|
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.
|
|
54
|
+
just what produces it. Eighteen rounds have been run, the first three through a
|
|
55
55
|
repo checkout and the rest through the account door; every finding from the
|
|
56
|
-
first
|
|
56
|
+
first seventeen is fixed or decided, and round seventeen, the first to start from
|
|
57
57
|
a page of notes instead of a treatment, found the wall has no way to hold a
|
|
58
58
|
maybe — the open card, R59, proposed and mocked from it. [`blind-runs/`](blind-runs/) holds the rules that keep a round honest,
|
|
59
59
|
the table of rounds, and the next round's prompt with the test account filled in.
|
|
@@ -100,4 +100,4 @@ next round into a test of `claim_account` instead of the door it meant to test.
|
|
|
100
100
|
|
|
101
101
|
## Status
|
|
102
102
|
|
|
103
|
-
Version 0.1.
|
|
103
|
+
Version 0.1.29. 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
|
@@ -856,6 +856,10 @@ function describeCommand(command) {
|
|
|
856
856
|
return "recolor_note";
|
|
857
857
|
case "apply_poses":
|
|
858
858
|
return "organize";
|
|
859
|
+
case "create_thread":
|
|
860
|
+
return `create_thread "${command.name ?? ""}"`;
|
|
861
|
+
case "update_thread":
|
|
862
|
+
return typeof command.startOpen === "boolean" || typeof command.endOpen === "boolean" ? "update_thread (an end tied or opened)" : "update_thread";
|
|
859
863
|
default:
|
|
860
864
|
return command.type;
|
|
861
865
|
}
|
|
@@ -968,7 +972,7 @@ function isSampleWall(state) {
|
|
|
968
972
|
return state.notes.map((note) => note.headline).sort().join("\n") === sample;
|
|
969
973
|
}
|
|
970
974
|
/** Every check read_wall runs, so silence can be named. */
|
|
971
|
-
const CHECKS = ["unmarked", "sag", "empty", "unwritten", "unlinked", "duplicate", "sequence", "uncast", "nobody", "absent", "backwards", "unpaid", "unplanted", "unplaced"];
|
|
975
|
+
const CHECKS = ["unmarked", "sag", "empty", "unwritten", "unlinked", "duplicate", "sequence", "uncast", "nobody", "absent", "backwards", "unpaid", "unplanted", "unplaced", "loose"];
|
|
972
976
|
/** What each check looks for, in words, so "clean" says what was checked rather than a kind's name. */
|
|
973
977
|
const CHECK_WORDS = {
|
|
974
978
|
unmarked: "a beat is marked",
|
|
@@ -985,6 +989,7 @@ const CHECK_WORDS = {
|
|
|
985
989
|
unpaid: "no fold without a payoff",
|
|
986
990
|
unplanted: "no payoff without its fold",
|
|
987
991
|
unplaced: "no card without a place",
|
|
992
|
+
loose: "no thread with a loose end",
|
|
988
993
|
};
|
|
989
994
|
const SAMPLE_NOTE = "sample: this is the wall PlotCoder starts with (Maya, Tom, the letter); nothing here is the writer's. Replace it, or new_board.";
|
|
990
995
|
|
|
@@ -1010,6 +1015,13 @@ function cueReport(state, text) {
|
|
|
1010
1015
|
return ` Cues: ${parts.join("; ")}.`;
|
|
1011
1016
|
}
|
|
1012
1017
|
|
|
1018
|
+
/** A write on an open card does not close it: the words stay until the writer clears them, and the reply says so (round eighteen, entry 31). */
|
|
1019
|
+
function stillOpen(notes) {
|
|
1020
|
+
const open = (notes ?? []).filter((note) => (note.open ?? "").trim());
|
|
1021
|
+
if (!open.length) return "";
|
|
1022
|
+
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.`;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1013
1025
|
/** What kinds of number a runtime folds together: measured from text, set by the writer, or the default page (round fifteen, entry 39). */
|
|
1014
1026
|
function runtimeKinds(state) {
|
|
1015
1027
|
const measured = state.notes.filter((note) => isMeasured(note));
|
|
@@ -1127,6 +1139,8 @@ function summarize(state) {
|
|
|
1127
1139
|
places || " (no card says where it happens yet)",
|
|
1128
1140
|
"groups:",
|
|
1129
1141
|
groups || " (no groups)",
|
|
1142
|
+
"threads (R60; a named string through cards, in story order; an open end is asked about by the reading):",
|
|
1143
|
+
...((state.threads ?? []).length ? state.threads.map((thread) => ` - ${threadLine(state, thread)}`) : [" (no threads — create_thread names one)"]),
|
|
1130
1144
|
"arrows:",
|
|
1131
1145
|
arrows || " (no arrows)",
|
|
1132
1146
|
].join("\n");
|
|
@@ -1303,7 +1317,7 @@ server.registerTool(
|
|
|
1303
1317
|
"Add a card (post-it) to the board. A card is one scene: a headline plus the change it causes. Provide both headline and change. Optionally set color, x/y position, rank ('beat' for one of the major turns — a beat is a whole card, the scene where the turn happens), pages (how long it runs; leave it out and the card is taken to be about a page), plants (true if this scene sets something up that must pay off later), location (where it happens, as the writer would say it — 'the piano shop', not 'INT. PIANO SHOP'), and characters (who is in the scene, by name; a name not in the cast yet is added to it — name an unnamed person by their role, 'Dana's mother', rather than leaving them off). The reply names the card's id.",
|
|
1304
1318
|
inputSchema: {
|
|
1305
1319
|
headline: z.string().min(1),
|
|
1306
|
-
change: z.string().
|
|
1320
|
+
change: z.string().optional().describe("What is different when the scene ends. Required, unless the card is born open — then it may wait, and the reading will not ask for it while the words stand."),
|
|
1307
1321
|
color: colorSchema.optional(),
|
|
1308
1322
|
rank: rankSchema.optional(),
|
|
1309
1323
|
pages: pagesSchema.optional(),
|
|
@@ -1312,7 +1326,7 @@ server.registerTool(
|
|
|
1312
1326
|
before: z.string().optional().describe("Or before this card (id or headline)."),
|
|
1313
1327
|
location: z.string().optional(),
|
|
1314
1328
|
when: z.string().optional().describe('When the scene happens, as the writer says it — "night", "day four, dawn" — printed after the place on the scene heading.'),
|
|
1315
|
-
open: z.string().optional().describe("The writer's words for what is not decided about this card
|
|
1329
|
+
open: z.string().optional().describe("The writer's words for what is not decided about this card — \"whether Tom knows\" — so the card is born open: the reading lists it and asks nothing else of it until the words are cleared."),
|
|
1316
1330
|
characters: z.array(z.string().min(1)).optional(),
|
|
1317
1331
|
x: z.number().optional(),
|
|
1318
1332
|
y: z.number().optional(),
|
|
@@ -1326,17 +1340,21 @@ server.registerTool(
|
|
|
1326
1340
|
// The card, anyone new in its cast, and the casting land as one change, so
|
|
1327
1341
|
// one ⌘Z on the wall takes back the whole call and not just the cast.
|
|
1328
1342
|
if (args.after && args.before) return ok("Say where: after one card, or before one, not both.");
|
|
1343
|
+
// A card needs its change line — unless it is born open (round eighteen, entry 13): the writer had no consequence yet.
|
|
1344
|
+
if (!(args.change ?? "").trim() && !(args.open ?? "").trim()) return ok("A card needs its change line: what is different when the scene ends. If the writer has not decided it, pass open with their words and the change line waits.");
|
|
1329
1345
|
// The card beside which the new scene goes (round sixteen, entry 36): by id or headline, on this board.
|
|
1330
1346
|
const besideKey = (args.after ?? args.before ?? "").trim();
|
|
1331
1347
|
const beside = besideKey ? state.notes.find((note) => note.id === besideKey) ?? state.notes.find((note) => note.headline.trim().toLowerCase() === besideKey.toLowerCase()) ?? null : null;
|
|
1332
1348
|
if (besideKey && !beside) return ok(`No card with id or headline "${besideKey}" on this board. Call list_board.`);
|
|
1333
1349
|
const wallHasFollows = state.arrows.some((arrow) => arrow.kind !== "setup");
|
|
1334
1350
|
let joinedGroup = null;
|
|
1351
|
+
let removedArrows = 0;
|
|
1352
|
+
let drawnArrows = 0;
|
|
1335
1353
|
const { value: result, live, state: after } = await commitAll(`create_note "${args.headline}"`, (step, current) => {
|
|
1336
1354
|
let made = step({
|
|
1337
1355
|
type: "create_note",
|
|
1338
1356
|
headline: args.headline,
|
|
1339
|
-
change: args.change,
|
|
1357
|
+
change: (args.change ?? "").trim() || undefined,
|
|
1340
1358
|
// One colour unless the agent chooses: a wall an agent builds in one go
|
|
1341
1359
|
// would otherwise stripe through the cycle, and a writer reads a pattern
|
|
1342
1360
|
// into it (round four, finding 17). The wall's own new-card button keeps
|
|
@@ -1370,7 +1388,14 @@ server.registerTool(
|
|
|
1370
1388
|
}
|
|
1371
1389
|
// Wired into the story where the writer said, in the same frame.
|
|
1372
1390
|
if (beside && made?.id && wallHasFollows) {
|
|
1373
|
-
|
|
1391
|
+
// Count the rewiring, so the reply can say it as move_scene does (round eighteen, entry 36).
|
|
1392
|
+
const run = (command) => {
|
|
1393
|
+
const done = step(command);
|
|
1394
|
+
if (done.changed && command.type === "delete_arrow") removedArrows += 1;
|
|
1395
|
+
if (done.changed && command.type === "create_arrow") drawnArrows += 1;
|
|
1396
|
+
return done;
|
|
1397
|
+
};
|
|
1398
|
+
joinedGroup = landBeside(run, current, made.id, beside, Boolean(args.after));
|
|
1374
1399
|
step({ type: "apply_poses", poses: organizePoses(current(), {}) });
|
|
1375
1400
|
made = current().notes.find((note) => note.id === made.id) ?? made;
|
|
1376
1401
|
}
|
|
@@ -1389,7 +1414,7 @@ server.registerTool(
|
|
|
1389
1414
|
// Where it landed matters only until the tidy, so the reply says the rule once and never the coordinates (round fourteen, entry 11).
|
|
1390
1415
|
const placed = beside
|
|
1391
1416
|
? wallHasFollows
|
|
1392
|
-
? ` Wired ${args.after ? "after" : "before"} "${beside.headline}" in the story${joinedGroup ? `, in "${joinedGroup}"` : ""}, and the wall tidied.`
|
|
1417
|
+
? ` Wired ${args.after ? "after" : "before"} "${beside.headline}" in the story (${removedArrows} follows arrow${removedArrows === 1 ? "" : "s"} removed, ${drawnArrows} drawn)${joinedGroup ? `, in "${joinedGroup}"` : ""}, and the wall tidied.`
|
|
1393
1418
|
: ` The wall has no follows arrows, so "${args.after ? "after" : "before"}" has no story to land in: it sits after the last card; create_arrow the sequence, then move_scene.`
|
|
1394
1419
|
: args.x === undefined && args.y === undefined ? ` Placed after the last card in story order.${once("placed", " organize lays the wall out along the arrows.")}` : "";
|
|
1395
1420
|
// Under a lock a new scene has a letter, not a number: say it, since the board is the only other place to learn it (round fourteen, entry 44).
|
|
@@ -1519,6 +1544,12 @@ server.registerTool(
|
|
|
1519
1544
|
});
|
|
1520
1545
|
// No blank lines: ok() splits prose from payload on the first one.
|
|
1521
1546
|
const written = state.notes.filter((note) => isMeasured(note)).length;
|
|
1547
|
+
// Whose number the runtime is (round eighteen, entries 45, 47, 50): once a
|
|
1548
|
+
// scene is written the total is part measure, part guess, part default,
|
|
1549
|
+
// and the reading says which, the same way list_board does.
|
|
1550
|
+
const whose = runtimeKinds(state).replace(/^; /, "");
|
|
1551
|
+
// A beat's own pages are in no run (entry 48); say how many pages that is.
|
|
1552
|
+
const beatEighths = reading.beats.reduce((sum, beat) => sum + noteEighths(state.notes.find((note) => note.id === beat.id) ?? {}), 0);
|
|
1522
1553
|
const lines = [
|
|
1523
1554
|
`PlotCoder wall (${door(live, base)})`,
|
|
1524
1555
|
...(state.lock ? [`numbers: locked since ${String(state.lock.at).slice(0, 10)}; read_pages shows each scene's number`] : []),
|
|
@@ -1526,8 +1557,8 @@ server.registerTool(
|
|
|
1526
1557
|
`logline: ${state.logline ? `"${state.logline}"` : "(none yet)"}`,
|
|
1527
1558
|
"the cast and the places are list_board's, not the reading's",
|
|
1528
1559
|
state.targetEighths === DEFAULT_TARGET_EIGHTHS
|
|
1529
|
-
? `runtime: about ${formatPages(boardEighths(state))} pages; no target set (set_target)`
|
|
1530
|
-
: `runtime: about ${formatPages(boardEighths(state))} pages of a ${formatPages(state.targetEighths)}-page target — ${boardEighths(state) > state.targetEighths ? `${formatPages(boardEighths(state) - state.targetEighths)} over` : boardEighths(state) < state.targetEighths ? `${formatPages(state.targetEighths - boardEighths(state))} under` : "on it"}
|
|
1560
|
+
? `runtime: about ${formatPages(boardEighths(state))} pages (${whose || "no cards"}); no target set (set_target)`
|
|
1561
|
+
: `runtime: about ${formatPages(boardEighths(state))} pages of a ${formatPages(state.targetEighths)}-page target — ${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)`,
|
|
1531
1562
|
`groups: ${
|
|
1532
1563
|
state.groups.length
|
|
1533
1564
|
? state.groups
|
|
@@ -1547,7 +1578,7 @@ server.registerTool(
|
|
|
1547
1578
|
? reading.beats.map((beat) => `"${beat.headline}"`).join(", ")
|
|
1548
1579
|
: "(none marked)"
|
|
1549
1580
|
}`,
|
|
1550
|
-
`runs between beats (the scenes between two turns; a beat's own pages are in no run${written < state.notes.length ? "; pages are estimates" : ""}):`,
|
|
1581
|
+
`runs between beats (the scenes between two turns; a beat's own pages are in no run${reading.beats.length ? ` — the ${reading.beats.length} beat${reading.beats.length === 1 ? "" : "s"} hold${reading.beats.length === 1 ? "s" : ""} about ${formatPages(beatEighths)} pages between them` : ""}${written < state.notes.length ? "; pages are estimates" : ""}):`,
|
|
1551
1582
|
...(runs.length ? runs.map((line) => ` - ${line}`) : [" (none)"]),
|
|
1552
1583
|
`setups and payoffs${written < state.notes.length ? " (distances in estimated pages)" : ""}:`,
|
|
1553
1584
|
...(reading.setups.length
|
|
@@ -1556,7 +1587,10 @@ server.registerTool(
|
|
|
1556
1587
|
...reading.later.map((item) => ` - "${state.notes.find((note) => note.id === item.id)?.headline ?? item.id}" is folded and pays off later, on "${boardById(projectForRead, item.boardId)?.name ?? item.boardId}"${item.noteId ? `, at ${episodeLabel(projectForRead, boardsNow, item.boardId, item.noteId)} "${boardsNow[item.boardId]?.notes?.find((note) => note.id === item.noteId)?.headline ?? item.noteId}"` : " — no scene there claims it yet"}`),
|
|
1557
1588
|
...reading.paidBy.map((item) => ` - "${state.notes.find((note) => note.id === item.id)?.headline ?? item.id}" pays off "${item.fromHeadline}" from "${item.fromBoardName}" (${episodeLabel(projectForRead, boardsNow, item.fromBoardId, item.fromNoteId)}), one board earlier`),
|
|
1558
1589
|
...(reading.open.length
|
|
1559
|
-
? ["open, by the writer's word (listed, not asked about while the words stand; set_open with \"\" closes):", ...reading.open.map((item) => ` - "${state.notes.find((note) => note.id === item.id)?.headline ?? item.id}" — ${item.words}`)]
|
|
1590
|
+
? ["open, by the writer's word (listed, not asked about while the words stand; set_open with \"\" closes):", ...reading.open.map((item) => ` - "${state.notes.find((note) => note.id === item.id)?.headline ?? item.id}" — ${item.words}${item.hides.length ? ` (would be asked, closed: ${item.hides.map((kind) => CHECK_WORDS[kind] ?? kind).join("; ")})` : ""}`)]
|
|
1591
|
+
: []),
|
|
1592
|
+
...(reading.threads.length
|
|
1593
|
+
? ["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" : ""}`)]
|
|
1560
1594
|
: []),
|
|
1561
1595
|
"questions the wall raises:",
|
|
1562
1596
|
...(reading.findings.length
|
|
@@ -1583,6 +1617,9 @@ server.registerTool(
|
|
|
1583
1617
|
}
|
|
1584
1618
|
if (kind === "unplaced" && !state.notes.some((note) => (note.location ?? "").trim())) return "no card without a place (not asked: no card placed yet)";
|
|
1585
1619
|
if (kind === "sequence" && state.groups.length === 0) return "no group too long for one sequence (not asked: no groups)";
|
|
1620
|
+
// A kind clean only because an open card is not asked says so (round eighteen, entry 18).
|
|
1621
|
+
const hiddenBy = reading.open.filter((item) => item.hides.includes(kind)).length;
|
|
1622
|
+
if (hiddenBy) return `${CHECK_WORDS[kind]} (except ${hiddenBy} open card${hiddenBy === 1 ? "" : "s"}, not asked)`;
|
|
1586
1623
|
return CHECK_WORDS[kind];
|
|
1587
1624
|
}).join("; ") || "(nothing — every check found something)"}`,
|
|
1588
1625
|
];
|
|
@@ -1869,7 +1906,7 @@ server.registerTool(
|
|
|
1869
1906
|
? ` It joined "${joinedGroup}", the group it landed in, so the tidy keeps it with the act.`
|
|
1870
1907
|
: group ? ` It is still in "${group.title || "an untitled group"}"; a frame does not follow a move, so say if the act or sequence should change.` : "";
|
|
1871
1908
|
return ok(
|
|
1872
|
-
`Moved "${card.headline}" to ${args.after ? "after" : "before"} "${target.headline}": ${removed} follows arrow(s) removed, ${drawn} drawn, setup arrows untouched, the wall tidied along them${where(live)}. Story order now: ${order.map((note, index) => `${index + 1}. ${note.headline}`).join(", ")}.${groupLine}${lockedNow} One undo takes the whole move back.`,
|
|
1909
|
+
`Moved "${card.headline}" to ${args.after ? "after" : "before"} "${target.headline}": ${removed} follows arrow(s) removed, ${drawn} drawn${final.arrows.some((arrow) => arrow.kind === "setup") ? ", setup arrows untouched" : ""}, the wall tidied along them${where(live)}. Story order now: ${order.map((note, index) => `${index + 1}. ${note.headline}`).join(", ")}.${groupLine}${lockedNow} One undo takes the whole move back.`,
|
|
1873
1910
|
{ order: order.map((note) => note.id) },
|
|
1874
1911
|
);
|
|
1875
1912
|
},
|
|
@@ -1990,11 +2027,12 @@ server.registerTool(
|
|
|
1990
2027
|
const short = state.targetEighths > 0 && boardEighths(state) * 2 < state.targetEighths;
|
|
1991
2028
|
const lines = [
|
|
1992
2029
|
`"${chosen.name}" beside this wall's ${beats} beat${beats === 1 ? "" : "s"}, of ${formatPages(state.targetEighths)} pages (the story so far runs to p. ${comparison.soFar}${allMeasured ? ", measured" : ", an estimate: unsized cards read as a page each"}); a match is the nearest of the wall's beats within ${MATCH_PAGES} pages, one to one and in order:`,
|
|
2030
|
+
// On a wall under half its target the pairing is arithmetic; say so before the rows, not after them (round eighteen, entry 52).
|
|
2031
|
+
...(short ? [`the wall runs to less than half its target, so its beats sit early and the ${MATCH_PAGES}-page window pairs them with the structure's first beats by arithmetic; the pairing says more once the cards are sized or written, and whether a turn is missing is the writer's call, not this reading's`] : []),
|
|
1993
2032
|
...describeComparison(comparison).map((line) => ` - ${line}`),
|
|
1994
2033
|
comparison.unmatched.length
|
|
1995
2034
|
? `beats of the wall no beat of the structure answers: ${comparison.unmatched.map((beat) => `"${beat.headline}" (p. ${beat.page})`).join(", ")}`
|
|
1996
2035
|
: "every beat of the wall answers one of the structure's",
|
|
1997
|
-
...(short ? [`the wall runs to less than half its target, so its beats sit early and the ${MATCH_PAGES}-page window pairs them with the structure's first beats by arithmetic; the pairing says more once the cards are sized or written, and whether a turn is missing is the writer's call, not this reading's`] : []),
|
|
1998
2036
|
beats === 0 ? "No card on this board is marked as a beat (set_rank), so there is nothing to compare; apply_template lays the structure's beats to fill." : "Nothing moved and nothing was made: this is a reading. apply_template lays the beats as cards when the writer wants them.",
|
|
1999
2037
|
];
|
|
2000
2038
|
return ok(lines.join("\n"), { structure: { id: chosen.id, name: chosen.name }, ...comparison });
|
|
@@ -2122,7 +2160,13 @@ server.registerTool(
|
|
|
2122
2160
|
}
|
|
2123
2161
|
const printed = sceneLineCount(args.text);
|
|
2124
2162
|
return ok(
|
|
2125
|
-
`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${
|
|
2163
|
+
`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${(() => {
|
|
2164
|
+
// 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.
|
|
2165
|
+
const before = result.lengthEighths !== null ? result.lengthEighths : 8;
|
|
2166
|
+
const label = result.lengthEighths !== null ? `the writer's ${formatPages(result.lengthEighths)} pages` : "the page an unsized card is read as";
|
|
2167
|
+
const moved = noteEighths(result) - before;
|
|
2168
|
+
return ` (${label}${moved ? `, so the runtime moved ${formatPages(Math.abs(moved))} ${moved < 0 ? "down" : "up"}` : ""})`;
|
|
2169
|
+
})()}; the estimate is kept for when the text goes, and set_length changes it.${cueReport(state, result.text)}`,
|
|
2126
2170
|
{ ...result, eighths: noteEighths(result), measured: true, printedLines: printed },
|
|
2127
2171
|
);
|
|
2128
2172
|
},
|
|
@@ -2187,7 +2231,7 @@ server.registerTool(
|
|
|
2187
2231
|
if (/^\.(?!\.)/.test(line) && index < ids.length) {
|
|
2188
2232
|
const note = state.notes.find((item) => item.id === ids[index]);
|
|
2189
2233
|
index += 1;
|
|
2190
|
-
const standIn = note && !(note.location ?? "").trim() ? " · no place: the headline stands in for the heading" : "";
|
|
2234
|
+
const standIn = note && !(note.location ?? "").trim() ? `${note.open ? " · open card" : ""} · no place: the headline stands in for the heading${note.open ? ", not a place" : ""}` : "";
|
|
2191
2235
|
const numbered = note && pageNumbers?.get(note.id) ? ` · locked no. ${pageNumbers.get(note.id)}` : "";
|
|
2192
2236
|
const mark = note ? marks.get(note.id) : null;
|
|
2193
2237
|
const sourceLines = (note?.text ?? "").split("\n");
|
|
@@ -2356,7 +2400,7 @@ server.registerTool(
|
|
|
2356
2400
|
const note = order.length > 0 && unwritten === order.length
|
|
2357
2401
|
? [`None of the ${order.length} scenes is written: every scene sets its change line as action, marked [Unwritten], a few lines each — so this is the wall as pages, not a script; the runtime estimate from the cards is about ${formatPages(boardEighths(state))} of ${formatPages(state.targetEighths)} pages.`]
|
|
2358
2402
|
: unwritten
|
|
2359
|
-
? [`${unwritten} of ${order.length} scenes are unwritten and set their change line as action, marked [Unwritten], a few lines each — so this is the script so far, not the runtime: the
|
|
2403
|
+
? [`${unwritten} of ${order.length} scenes are unwritten and set their change line as action, marked [Unwritten], a few lines each — so this is the script so far, not the runtime: the runtime from the cards is about ${formatPages(boardEighths(state))} pages, ${order.length - unwritten} of ${order.length} measured and the rest estimated.`]
|
|
2360
2404
|
: [];
|
|
2361
2405
|
return ok([...note, `pages: ${result.pageCount} of ${Math.round(state.targetEighths / 8)}`, `scene numbers here are ${state.lock ? "the locked numbers" : "story order (not locked)"}`, ...lines].join("\n"), result.scenes);
|
|
2362
2406
|
},
|
|
@@ -2663,8 +2707,8 @@ server.registerTool(
|
|
|
2663
2707
|
const when = result[0]?.when ?? "";
|
|
2664
2708
|
return ok(
|
|
2665
2709
|
when
|
|
2666
|
-
? `${result.length} card(s) now happen ${/^(at|on|in|by|the)\b/i.test(when) ? "" : "at "}"${when}"${where(live)}. The heading prints as ${sceneHeading(result[0]).slice(1)}
|
|
2667
|
-
: `${result.length} card(s) no longer say when they happen${where(live)}
|
|
2710
|
+
? `${result.length} card(s) now happen ${/^(at|on|in|by|the)\b/i.test(when) ? "" : "at "}"${when}"${where(live)}. The heading prints as ${sceneHeading(result[0]).slice(1)}.${stillOpen(result)}`
|
|
2711
|
+
: `${result.length} card(s) no longer say when they happen${where(live)}.${stillOpen(result)}`,
|
|
2668
2712
|
result,
|
|
2669
2713
|
);
|
|
2670
2714
|
},
|
|
@@ -2678,7 +2722,7 @@ server.registerTool(
|
|
|
2678
2722
|
{
|
|
2679
2723
|
title: "Leave a card open",
|
|
2680
2724
|
description:
|
|
2681
|
-
"Mark one or more cards open, with the writer's words for what is not decided — \"
|
|
2725
|
+
"Mark one or more cards open, with the writer's words for what is not decided — \"whether Tom knows\", \"who sent the letter\". An open card is listed by read_wall under its own head, with what it would be asked if closed, and asked nothing of itself while the words stand: not its place, its change line, its arrows, its cast or its fold; a question about the run it sits in — beats back to back, a sag — is still asked, because that is about the story around it. It is still counted, in the order, and a page. open \"\" closes the card and its questions come back on their own. Only on the writer's word — a card is open because they said so, never because you could not decide; where the notes have two versions, ask, and if they say leave it, this is how.",
|
|
2682
2726
|
inputSchema: { ids: z.array(z.string()).min(1), open: z.string() },
|
|
2683
2727
|
},
|
|
2684
2728
|
async (args) => {
|
|
@@ -2906,7 +2950,8 @@ server.registerTool(
|
|
|
2906
2950
|
return { meta, on };
|
|
2907
2951
|
});
|
|
2908
2952
|
const total = parts.reduce((sum, part) => sum + part.on.length, 0);
|
|
2909
|
-
|
|
2953
|
+
// An open card reads as open on a person's page too (round eighteen, entry 53).
|
|
2954
|
+
const where_ = (note) => [note.location ? `at ${note.location}` : "", note.when ? note.when : "", note.rank === "beat" ? "beat" : "", note.open ? `open: ${note.open}` : ""].filter(Boolean).join(" · ");
|
|
2910
2955
|
// A read opens with the door it came through, like every reading (round sixteen, entry 28); one scene a line (29).
|
|
2911
2956
|
const lines = [
|
|
2912
2957
|
`PlotCoder cast (${door(live, base)})`,
|
|
@@ -2993,7 +3038,7 @@ server.registerTool(
|
|
|
2993
3038
|
: [];
|
|
2994
3039
|
const warn = near.length ? ` The wall also has ${near.map((other) => `"${other}"`).join(", ")} — the same place spelled twice, or two places? Each distinct phrase counts as one place.` : "";
|
|
2995
3040
|
return ok(
|
|
2996
|
-
`${result.length} card(s) now ${place ? `at ${place}` : "nowhere"}${where(live)}.${warn}`,
|
|
3041
|
+
`${result.length} card(s) now ${place ? `at ${place}` : "nowhere"}${where(live)}.${warn}${stillOpen(result)}`,
|
|
2997
3042
|
result,
|
|
2998
3043
|
);
|
|
2999
3044
|
},
|
|
@@ -3065,12 +3110,132 @@ server.registerTool(
|
|
|
3065
3110
|
(id) => state.characters.find((character) => character.id === id)?.name ?? id,
|
|
3066
3111
|
);
|
|
3067
3112
|
return ok(
|
|
3068
|
-
`${result.length} card(s) now cast ${names.length ? names.join(", ") : "nobody"}: ${result.map((note) => `"${note.headline}"`).join(", ")}${added.length ? ` (added to the cast: ${added.join(", ")})` : ""}${where(live)}
|
|
3113
|
+
`${result.length} card(s) now cast ${names.length ? names.join(", ") : "nobody"}: ${result.map((note) => `"${note.headline}"`).join(", ")}${added.length ? ` (added to the cast: ${added.join(", ")})` : ""}${where(live)}.${stillOpen(result)}`,
|
|
3069
3114
|
result,
|
|
3070
3115
|
);
|
|
3071
3116
|
},
|
|
3072
3117
|
);
|
|
3073
3118
|
|
|
3119
|
+
// --- Threads (R60) ----------------------------------------------------------
|
|
3120
|
+
//
|
|
3121
|
+
// A thread is a named string through cards, either end open until the writer
|
|
3122
|
+
// ties it: the record for a thing the writer knows the far end of — the
|
|
3123
|
+
// bucket in the last scene, the key that changes hands — and not where it is
|
|
3124
|
+
// first seen. A fold and a setup arrow are still how a plant and its payoff
|
|
3125
|
+
// are drawn; a thread is beside them, in the writer's words, and the reading
|
|
3126
|
+
// asks about each loose end from that end.
|
|
3127
|
+
|
|
3128
|
+
function threadByRef(state, ref) {
|
|
3129
|
+
const wanted = (ref ?? "").trim();
|
|
3130
|
+
if (!wanted) return null;
|
|
3131
|
+
return (state.threads ?? []).find((thread) => thread.id === wanted) ?? (state.threads ?? []).find((thread) => thread.name.trim().toLowerCase() === wanted.toLowerCase()) ?? null;
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
function cardsByRef(state, refs) {
|
|
3135
|
+
const found = [];
|
|
3136
|
+
const missing = [];
|
|
3137
|
+
for (const ref of refs ?? []) {
|
|
3138
|
+
const wanted = (ref ?? "").trim();
|
|
3139
|
+
const note = state.notes.find((item) => item.id === wanted) ?? state.notes.find((item) => item.headline.trim().toLowerCase() === wanted.toLowerCase()) ?? null;
|
|
3140
|
+
if (note) found.push(note.id);
|
|
3141
|
+
else missing.push(ref);
|
|
3142
|
+
}
|
|
3143
|
+
return { found, missing };
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3146
|
+
function threadLine(state, thread) {
|
|
3147
|
+
const order = storyOrder(state).filter((note) => thread.noteIds.includes(note.id));
|
|
3148
|
+
const cards = order.length ? order.map((note) => `"${note.headline}"`).join(" → ") : "no card yet";
|
|
3149
|
+
const ends = [thread.startOpen ? "starts nowhere yet" : null, thread.endOpen ? "ends nowhere yet" : null].filter(Boolean);
|
|
3150
|
+
return `"${thread.name}" (${thread.id}): ${cards}${ends.length ? ` — ${ends.join(", ")}` : order.length ? " — both ends tied" : ""}`;
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
server.registerTool(
|
|
3154
|
+
"create_thread",
|
|
3155
|
+
{
|
|
3156
|
+
title: "Name a thread",
|
|
3157
|
+
description:
|
|
3158
|
+
"Name a thread — a thing that runs through the story and is first seen somewhere and comes out somewhere: \"the letter\", \"the shop's lease\", a subplot — and string it through the cards it touches, by id or headline, in story order. Say which end is not decided: startOpen when the writer knows where it comes out and not where it is first seen; endOpen when they know where it starts and not where it comes out. The reading asks about each open end from that end — \"where is it first seen?\" — until update_thread ties it, and lists every thread with its cards. A thread is beside the fold and the setup arrow, not instead of them: fold the card that plants and draw the setup arrow when both scenes exist; a thread is for the writer's word before they do, and for a strand a fold cannot hold. Only on the writer's word: a thread is theirs to name.",
|
|
3159
|
+
inputSchema: {
|
|
3160
|
+
name: z.string().min(1),
|
|
3161
|
+
cards: z.array(z.string()).optional(),
|
|
3162
|
+
startOpen: z.boolean().optional(),
|
|
3163
|
+
endOpen: z.boolean().optional(),
|
|
3164
|
+
},
|
|
3165
|
+
},
|
|
3166
|
+
async (args) => {
|
|
3167
|
+
const current = await readBoard();
|
|
3168
|
+
const { found, missing } = cardsByRef(current.state, args.cards);
|
|
3169
|
+
if (missing.length) return ok(`No thread made: not on the board — ${missing.map((ref) => `"${ref}"`).join(", ")}. Call list_board for the ids or the exact headlines.`);
|
|
3170
|
+
const { state, changed, result, live } = await commit({ type: "create_thread", name: args.name, noteIds: found, startOpen: args.startOpen === true, endOpen: args.endOpen === true });
|
|
3171
|
+
if (!changed) return ok("No thread made: a thread needs a name.");
|
|
3172
|
+
const asks = [result.startOpen ? "where it is first seen" : null, result.endOpen ? "where it comes out" : null].filter(Boolean);
|
|
3173
|
+
return ok(
|
|
3174
|
+
`Named the thread ${threadLine(state, result)}${where(live)}.${asks.length ? ` The reading asks ${asks.join(" and ")} until update_thread ties ${asks.length === 1 ? "that end" : "them"}.` : result.noteIds.length ? " Both ends are tied; the reading lists it and asks nothing." : " No card yet: the reading asks where it is first seen and where it comes out."} The wall draws it as a string through its cards, a loose end where one is open.`,
|
|
3175
|
+
result,
|
|
3176
|
+
);
|
|
3177
|
+
},
|
|
3178
|
+
);
|
|
3179
|
+
|
|
3180
|
+
server.registerTool(
|
|
3181
|
+
"update_thread",
|
|
3182
|
+
{
|
|
3183
|
+
title: "Tie or change a thread",
|
|
3184
|
+
description:
|
|
3185
|
+
"Change a thread by id or name: rename it, add cards (by id or headline) or remove them, or tie an end — startOpen false once the writer says where it is first seen, endOpen false once they say where it comes out; true reopens an end. Adding the card where a thing is first seen and tying the start is one call: add plus startOpen false. The reading stops asking about an end the moment it is tied.",
|
|
3186
|
+
inputSchema: {
|
|
3187
|
+
thread: z.string(),
|
|
3188
|
+
name: z.string().optional(),
|
|
3189
|
+
add: z.array(z.string()).optional(),
|
|
3190
|
+
remove: z.array(z.string()).optional(),
|
|
3191
|
+
startOpen: z.boolean().optional(),
|
|
3192
|
+
endOpen: z.boolean().optional(),
|
|
3193
|
+
},
|
|
3194
|
+
},
|
|
3195
|
+
async (args) => {
|
|
3196
|
+
const current = await readBoard();
|
|
3197
|
+
const thread = threadByRef(current.state, args.thread);
|
|
3198
|
+
if (!thread) return ok(`No thread called "${args.thread}". list_board names the threads on this board; create_thread names a new one.`);
|
|
3199
|
+
const add = cardsByRef(current.state, args.add);
|
|
3200
|
+
const remove = cardsByRef(current.state, args.remove);
|
|
3201
|
+
const missing = [...add.missing, ...remove.missing];
|
|
3202
|
+
if (missing.length) return ok(`Nothing changed: not on the board — ${missing.map((ref) => `"${ref}"`).join(", ")}. Call list_board for the ids or the exact headlines.`);
|
|
3203
|
+
const { state, changed, result, live } = await commit({
|
|
3204
|
+
type: "update_thread",
|
|
3205
|
+
id: thread.id,
|
|
3206
|
+
...(args.name !== undefined ? { name: args.name } : {}),
|
|
3207
|
+
...(add.found.length ? { add: add.found } : {}),
|
|
3208
|
+
...(remove.found.length ? { remove: remove.found } : {}),
|
|
3209
|
+
...(typeof args.startOpen === "boolean" ? { startOpen: args.startOpen } : {}),
|
|
3210
|
+
...(typeof args.endOpen === "boolean" ? { endOpen: args.endOpen } : {}),
|
|
3211
|
+
});
|
|
3212
|
+
if (!changed) return ok(`Nothing changed: "${thread.name}" already reads that way.`);
|
|
3213
|
+
const tied = [result.before.startOpen && !result.thread.startOpen ? "its start" : null, result.before.endOpen && !result.thread.endOpen ? "its end" : null].filter(Boolean);
|
|
3214
|
+
const reopened = [!result.before.startOpen && result.thread.startOpen ? "its start" : null, !result.before.endOpen && result.thread.endOpen ? "its end" : null].filter(Boolean);
|
|
3215
|
+
return ok(
|
|
3216
|
+
`Now ${threadLine(state, result.thread)}${where(live)}.${tied.length ? ` Tied ${tied.join(" and ")}; the reading stops asking about ${tied.length === 1 ? "it" : "them"}.` : ""}${reopened.length ? ` Opened ${reopened.join(" and ")}; the reading asks about ${reopened.length === 1 ? "it" : "them"} again.` : ""}`,
|
|
3217
|
+
result.thread,
|
|
3218
|
+
);
|
|
3219
|
+
},
|
|
3220
|
+
);
|
|
3221
|
+
|
|
3222
|
+
server.registerTool(
|
|
3223
|
+
"delete_thread",
|
|
3224
|
+
{
|
|
3225
|
+
title: "Cut a thread",
|
|
3226
|
+
description: "Remove a thread by id or name. The cards stay; only the string and its name go. Only on the writer's word.",
|
|
3227
|
+
inputSchema: { thread: z.string() },
|
|
3228
|
+
},
|
|
3229
|
+
async (args) => {
|
|
3230
|
+
const current = await readBoard();
|
|
3231
|
+
const thread = threadByRef(current.state, args.thread);
|
|
3232
|
+
if (!thread) return ok(`No thread called "${args.thread}". list_board names the threads on this board.`);
|
|
3233
|
+
const { changed, result, live } = await commit({ type: "delete_thread", id: thread.id });
|
|
3234
|
+
if (!changed) return ok(`No thread called "${args.thread}".`);
|
|
3235
|
+
return ok(`Cut the thread "${result.name}"${where(live)}; its ${result.noteIds.length} card${result.noteIds.length === 1 ? "" : "s"} stay where they are.`, result);
|
|
3236
|
+
},
|
|
3237
|
+
);
|
|
3238
|
+
|
|
3074
3239
|
// --- Groups -----------------------------------------------------------
|
|
3075
3240
|
|
|
3076
3241
|
server.registerTool(
|
|
@@ -3291,7 +3456,7 @@ server.registerTool(
|
|
|
3291
3456
|
{
|
|
3292
3457
|
title: "Set the project's premise",
|
|
3293
3458
|
description:
|
|
3294
|
-
"Set the project's premise: the line above every board's logline, held by the project whatever its board count — what a series is about, or what is true before a film starts ('the
|
|
3459
|
+
"Set the project's premise: the line above every board's logline, held by the project whatever its board count — what a series is about, or what is true before a film starts ('the winter the shop closes'). An empty string clears it. Boards keep their own loglines.",
|
|
3295
3460
|
inputSchema: { premise: z.string() },
|
|
3296
3461
|
},
|
|
3297
3462
|
async (args) => {
|
|
@@ -3615,7 +3780,7 @@ server.registerTool(
|
|
|
3615
3780
|
{
|
|
3616
3781
|
title: "Save the project as a file",
|
|
3617
3782
|
description:
|
|
3618
|
-
"The project the server is working, as the file Save project writes and Open project takes: the record, every board with its cards, the reminders and the writer's structures. Pass path to write it (a .json) — an absolute path, since a relative one resolves from the server
|
|
3783
|
+
"The project the server is working, as the file Save project writes and Open project takes: the record, every board with its cards, the reminders and the writer's structures. Pass path to write it (a .json) — an absolute path, since a relative one resolves from the folder the server was started in, not yours; without a path, the reply's JSON is the file. Pictures and takes on the account are not in the file. Works through every door.",
|
|
3619
3784
|
inputSchema: { path: z.string().optional() },
|
|
3620
3785
|
},
|
|
3621
3786
|
async (args) => {
|
package/src/board/fountain.js
CHANGED
|
@@ -123,6 +123,9 @@ export function toFountain(state, options = {}) {
|
|
|
123
123
|
const cast = (note.characterIds ?? []).map((id) => nameOf.get(id)).filter(Boolean);
|
|
124
124
|
if (cast.length) marks.push(`with ${cast.join(", ")}`);
|
|
125
125
|
if (note.plants) marks.push("plants something to pay off later");
|
|
126
|
+
if (note.open) marks.push(`open: ${note.open}`);
|
|
127
|
+
const onThreads = (state.threads ?? []).filter((thread) => thread.noteIds.includes(note.id)).map((thread) => thread.name);
|
|
128
|
+
if (onThreads.length) marks.push(`thread: ${onThreads.join(", ")}`);
|
|
126
129
|
if (revisionMarksFor(marks, note)) marks.push(`changed in the ${state.revision.color} revision`);
|
|
127
130
|
if (marks.length) {
|
|
128
131
|
body.push(`[[${marks.join(" · ")}]]`);
|
package/src/board/readWall.d.ts
CHANGED
|
@@ -33,11 +33,13 @@ export type FindingKind =
|
|
|
33
33
|
/** A setup arrow whose payoff comes before its setup on the wall. */
|
|
34
34
|
| "backwards"
|
|
35
35
|
/** A card with a folded corner and no setup arrow leaving it. */
|
|
36
|
-
| "unpaid"
|
|
36
|
+
| "unpaid"
|
|
37
|
+
/** A thread (R60) with an end the writer has not tied: where it is first seen, or where it comes out. */
|
|
38
|
+
| "loose";
|
|
37
39
|
|
|
38
40
|
export type Finding = {
|
|
39
41
|
kind: FindingKind;
|
|
40
|
-
/** Card ids; a group id for "sequence"; a character id (then card ids) for "uncast" and "absent"; an arrow id (then card ids) for "backwards". Empty for "unmarked". */
|
|
42
|
+
/** Card ids; a group id for "sequence"; a character id (then card ids) for "uncast" and "absent"; an arrow id (then card ids) for "backwards"; a thread id (then card ids) for "loose". Empty for "unmarked". */
|
|
41
43
|
ids: string[];
|
|
42
44
|
/** The question, written for a writer. */
|
|
43
45
|
text: string;
|
|
@@ -68,7 +70,9 @@ export type WallReading = {
|
|
|
68
70
|
/** Folded cards that pay off on another board of the project (R50): the card, the board, and the scene there that claims it (R58) or null while the board is a promise. */
|
|
69
71
|
later: { id: string; boardId: string; noteId: string | null }[];
|
|
70
72
|
/** Open cards (R59): the writer's words for what is not decided, in story order; not asked about while they stand. */
|
|
71
|
-
open: Array<{ id: string; words: string }>;
|
|
73
|
+
open: Array<{ id: string; words: string; hides: FindingKind[] }>;
|
|
74
|
+
/** Threads (R60): each named string with its cards in story order and which ends are open. */
|
|
75
|
+
threads: Array<{ id: string; name: string; ids: string[]; startOpen: boolean; endOpen: boolean }>;
|
|
72
76
|
/** Cards here that pay off a fold of another board (R58), composed by the door from the project. */
|
|
73
77
|
paidBy: Array<{ id: string; fromBoardId: string; fromBoardName: string; fromNoteId: string; fromHeadline: string; fromColor: string }>;
|
|
74
78
|
/** The questions the wall asks now. A left one (R53) is not here while its words hold. */
|
package/src/board/readWall.js
CHANGED
|
@@ -262,6 +262,10 @@ export function readWall(state, options = {}) {
|
|
|
262
262
|
// whose turns come thick at the end would otherwise ask the same sentence
|
|
263
263
|
// six times (round four, finding 19).
|
|
264
264
|
let chain = [];
|
|
265
|
+
// On a wall where most runs hold nothing, the turns are back to back by
|
|
266
|
+
// construction, and the question stands until scenes go in; say so rather
|
|
267
|
+
// than ask it as if it were a choice (round eighteen, entry 29).
|
|
268
|
+
const thin = between.length >= 2 && between.filter((run) => run.cards === 0).length * 2 > between.length ? " Most runs hold nothing yet, so this stands until scenes go in." : "";
|
|
265
269
|
const askChain = () => {
|
|
266
270
|
if (chain.length === 0) return;
|
|
267
271
|
const ids = [chain[0].from, ...chain.map((run) => run.to)];
|
|
@@ -271,8 +275,8 @@ export function readWall(state, options = {}) {
|
|
|
271
275
|
ids,
|
|
272
276
|
text:
|
|
273
277
|
ids.length === 2
|
|
274
|
-
? `Nothing runs between ${names[0]} and ${names[1]}: two turns back to back. Are they one beat, or is a scene missing
|
|
275
|
-
: `Nothing runs between ${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}: ${countWord(ids.length)} turns back to back. Are some of them one beat, or are scenes missing between them
|
|
278
|
+
? `Nothing runs between ${names[0]} and ${names[1]}: two turns back to back. Are they one beat, or is a scene missing?${thin}`
|
|
279
|
+
: `Nothing runs between ${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}: ${countWord(ids.length)} turns back to back. Are some of them one beat, or are scenes missing between them?${thin}`,
|
|
276
280
|
});
|
|
277
281
|
chain = [];
|
|
278
282
|
};
|
|
@@ -330,7 +334,7 @@ export function readWall(state, options = {}) {
|
|
|
330
334
|
findings.push({
|
|
331
335
|
kind: "unlinked",
|
|
332
336
|
ids: loose.map((note) => note.id),
|
|
333
|
-
text: `${loose.length === 1 ? "One card has" : `${loose.length} cards have`} no arrow in or out: ${list(loose)}. What
|
|
337
|
+
text: `${loose.length === 1 ? "One card has" : `${loose.length} cards have`} no arrow in or out: ${list(loose)}. What comes before ${loose.length === 1 ? "it" : "them"} in the story, and what after?`,
|
|
334
338
|
});
|
|
335
339
|
}
|
|
336
340
|
}
|
|
@@ -498,6 +502,30 @@ export function readWall(state, options = {}) {
|
|
|
498
502
|
}
|
|
499
503
|
}
|
|
500
504
|
|
|
505
|
+
// Threads (R60): a named string through cards, in story order, either end
|
|
506
|
+
// open until the writer ties it. The reading asks about each loose end from
|
|
507
|
+
// that end — the question a fold cannot ask, where a thing is first seen.
|
|
508
|
+
const threads = (state.threads ?? []).map((thread) => ({
|
|
509
|
+
id: thread.id,
|
|
510
|
+
name: thread.name,
|
|
511
|
+
ids: order.filter((note) => thread.noteIds.includes(note.id)).map((note) => note.id),
|
|
512
|
+
startOpen: thread.startOpen === true,
|
|
513
|
+
endOpen: thread.endOpen === true,
|
|
514
|
+
}));
|
|
515
|
+
for (const thread of threads) {
|
|
516
|
+
const first = thread.ids.length ? byId.get(thread.ids[0]) : null;
|
|
517
|
+
const last = thread.ids.length ? byId.get(thread.ids[thread.ids.length - 1]) : null;
|
|
518
|
+
if (!thread.ids.length) {
|
|
519
|
+
findings.push({ kind: "loose", ids: [thread.id], text: `"${thread.name}" runs through no card yet. Where is it first seen, and where does it come out?` });
|
|
520
|
+
} else if (thread.startOpen && thread.endOpen) {
|
|
521
|
+
findings.push({ kind: "loose", ids: [thread.id, ...thread.ids], text: `"${thread.name}" runs through ${list(thread.ids.map((id) => byId.get(id)))} and neither end is tied. Where is it first seen, and where does it come out?` });
|
|
522
|
+
} else if (thread.startOpen) {
|
|
523
|
+
findings.push({ kind: "loose", ids: [thread.id, first.id], text: `"${thread.name}" starts nowhere yet: it runs ${thread.ids.length === 1 ? "to" : "through"} ${list(thread.ids.map((id) => byId.get(id)))}. Where is it first seen?` });
|
|
524
|
+
} else if (thread.endOpen) {
|
|
525
|
+
findings.push({ kind: "loose", ids: [thread.id, last.id], text: `"${thread.name}" ends nowhere yet: it runs ${thread.ids.length === 1 ? "from" : "through"} ${list(thread.ids.map((id) => byId.get(id)))}. Where does it come out?` });
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
501
529
|
// A question the writer has left (R53) is held back while it is still the
|
|
502
530
|
// same question — same kind, same cards, same words. The moment it would
|
|
503
531
|
// read differently (a page moved, a headline changed, the median shifted)
|
|
@@ -505,7 +533,8 @@ export function readWall(state, options = {}) {
|
|
|
505
533
|
// reading does, on every read.
|
|
506
534
|
const left = [];
|
|
507
535
|
// A question whose every card is open is not asked (R59): the writer's word covers it.
|
|
508
|
-
|
|
536
|
+
// A thread's loose end (R60) is the writer's own claim, asked whether or not its cards are open.
|
|
537
|
+
const openCards = openIds.size ? findings.filter((finding) => finding.kind !== "loose" && finding.ids.some((id) => byId.has(id)) && finding.ids.filter((id) => byId.has(id)).every((id) => openIds.has(id))) : [];
|
|
509
538
|
const asked = findings.filter((finding) => {
|
|
510
539
|
if (openCards.includes(finding)) return false;
|
|
511
540
|
const entry = (state.left ?? []).find(
|
|
@@ -524,12 +553,29 @@ export function readWall(state, options = {}) {
|
|
|
524
553
|
payoffs,
|
|
525
554
|
later,
|
|
526
555
|
paidBy: paidHere,
|
|
527
|
-
open:
|
|
556
|
+
open: describeOpen(state, options, order, openIds),
|
|
557
|
+
threads,
|
|
528
558
|
findings: asked,
|
|
529
559
|
left,
|
|
530
560
|
};
|
|
531
561
|
}
|
|
532
562
|
|
|
563
|
+
/**
|
|
564
|
+
* The open cards, each with the kinds of question it would be asked if it were
|
|
565
|
+
* closed (round eighteen, entry 27): the reading of the same wall with the
|
|
566
|
+
* words cleared, read once more, so the writer can see what the words hide.
|
|
567
|
+
*/
|
|
568
|
+
function describeOpen(state, options, order, openIds) {
|
|
569
|
+
if (!openIds.size) return [];
|
|
570
|
+
const hides = new Map();
|
|
571
|
+
if (!options.closedReading) {
|
|
572
|
+
const closed = { ...state, notes: state.notes.map((note) => (openIds.has(note.id) ? { ...note, open: "" } : note)) };
|
|
573
|
+
const again = readWall(closed, { ...options, closedReading: true });
|
|
574
|
+
for (const finding of again.findings) for (const id of finding.ids) if (openIds.has(id)) (hides.get(id) ?? hides.set(id, new Set()).get(id)).add(finding.kind);
|
|
575
|
+
}
|
|
576
|
+
return order.filter((note) => openIds.has(note.id)).map((note) => ({ id: note.id, words: note.open.trim(), hides: [...(hides.get(note.id) ?? [])] }));
|
|
577
|
+
}
|
|
578
|
+
|
|
533
579
|
function sameList(a, b) {
|
|
534
580
|
return a.length === b.length && a.every((id, index) => id === b[index]);
|
|
535
581
|
}
|
package/src/board/reducer.d.ts
CHANGED
|
@@ -118,6 +118,8 @@ export type BoardState = {
|
|
|
118
118
|
characters: BoardCharacter[];
|
|
119
119
|
notes: BoardNote[];
|
|
120
120
|
groups: BoardGroup[];
|
|
121
|
+
/** Threads (R60): named strings through cards, either end open until tied. */
|
|
122
|
+
threads: BoardThread[];
|
|
121
123
|
arrows: BoardArrow[];
|
|
122
124
|
/** Locked scene numbers, once a draft has gone out (Roadmap 2, item 8); null until then. */
|
|
123
125
|
lock: import("./numbering").Lock | null;
|
|
@@ -127,6 +129,17 @@ export type BoardState = {
|
|
|
127
129
|
left: LeftQuestion[];
|
|
128
130
|
};
|
|
129
131
|
|
|
132
|
+
/** A thread (R60): a name, the cards it runs through, and whether either end is still open. */
|
|
133
|
+
export type BoardThread = {
|
|
134
|
+
id: string;
|
|
135
|
+
name: string;
|
|
136
|
+
noteIds: string[];
|
|
137
|
+
/** Where it is first seen is not decided. */
|
|
138
|
+
startOpen: boolean;
|
|
139
|
+
/** Where it comes out is not decided. */
|
|
140
|
+
endOpen: boolean;
|
|
141
|
+
};
|
|
142
|
+
|
|
130
143
|
/** A question the wall asked and the writer left (R53). */
|
|
131
144
|
export type LeftQuestion = {
|
|
132
145
|
kind: string;
|
|
@@ -179,6 +192,9 @@ export type Command =
|
|
|
179
192
|
| { type: "ungroup"; id: string }
|
|
180
193
|
| { type: "add_to_group"; id: string; noteIds: string[] }
|
|
181
194
|
| { type: "rename_group"; id: string; title: string }
|
|
195
|
+
| { type: "create_thread"; name: string; noteIds?: string[]; startOpen?: boolean; endOpen?: boolean; id?: string }
|
|
196
|
+
| { type: "update_thread"; id: string; name?: string; noteIds?: string[]; add?: string[]; remove?: string[]; startOpen?: boolean; endOpen?: boolean }
|
|
197
|
+
| { type: "delete_thread"; id: string }
|
|
182
198
|
| { type: "create_arrow"; from: string; to: string; kind?: ArrowKind }
|
|
183
199
|
| { type: "delete_arrow"; id: string }
|
|
184
200
|
| { type: "set_arrow_kind"; id: string; kind: ArrowKind }
|
package/src/board/reducer.js
CHANGED
|
@@ -223,6 +223,7 @@ export function emptyState() {
|
|
|
223
223
|
characters: [],
|
|
224
224
|
notes: [],
|
|
225
225
|
groups: [],
|
|
226
|
+
threads: [],
|
|
226
227
|
arrows: [],
|
|
227
228
|
lock: null,
|
|
228
229
|
revision: null,
|
|
@@ -277,6 +278,7 @@ export function seedState(now = nowIso()) {
|
|
|
277
278
|
mk("letter-aloud", "The letter is read aloud", "The plan dies in the room.", "blue", 196, 340, 0.8, 3, ["maya", "tom"]),
|
|
278
279
|
],
|
|
279
280
|
groups: [],
|
|
281
|
+
threads: [],
|
|
280
282
|
arrows: [],
|
|
281
283
|
lock: null,
|
|
282
284
|
revision: null,
|
|
@@ -390,6 +392,25 @@ export function normalizeState(value) {
|
|
|
390
392
|
// the writer leaves it.
|
|
391
393
|
const left = Array.isArray(value.left) ? value.left.filter(isLeftQuestion) : [];
|
|
392
394
|
const leftPatched = !Array.isArray(value.left) || left.length !== value.left.length;
|
|
395
|
+
// Boards written before R60 have no threads: a wall has none until the
|
|
396
|
+
// writer names one. A thread that names a card the board no longer holds
|
|
397
|
+
// drops that card; a thread with no name is not a thread.
|
|
398
|
+
const noteIdSet = new Set(notes.map((note) => note.id));
|
|
399
|
+
let threadsPatched = !Array.isArray(value.threads);
|
|
400
|
+
const threads = (Array.isArray(value.threads) ? value.threads : [])
|
|
401
|
+
.map((thread) => {
|
|
402
|
+
if (!thread || typeof thread !== "object" || typeof thread.id !== "string" || typeof thread.name !== "string" || !thread.name.trim()) {
|
|
403
|
+
threadsPatched = true;
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
const noteIds = Array.isArray(thread.noteIds) ? thread.noteIds.filter((id, index) => typeof id === "string" && noteIdSet.has(id) && thread.noteIds.indexOf(id) === index) : [];
|
|
407
|
+
const startOpen = thread.startOpen === true;
|
|
408
|
+
const endOpen = thread.endOpen === true;
|
|
409
|
+
if (Array.isArray(thread.noteIds) && noteIds.length === thread.noteIds.length && thread.startOpen === startOpen && thread.endOpen === endOpen) return thread;
|
|
410
|
+
threadsPatched = true;
|
|
411
|
+
return { ...thread, noteIds, startOpen, endOpen };
|
|
412
|
+
})
|
|
413
|
+
.filter(Boolean);
|
|
393
414
|
if (
|
|
394
415
|
value.logline === logline &&
|
|
395
416
|
value.targetEighths === targetEighths &&
|
|
@@ -398,7 +419,8 @@ export function normalizeState(value) {
|
|
|
398
419
|
!patched &&
|
|
399
420
|
value.lock === lock &&
|
|
400
421
|
value.revision === revision &&
|
|
401
|
-
!leftPatched
|
|
422
|
+
!leftPatched &&
|
|
423
|
+
!threadsPatched
|
|
402
424
|
) {
|
|
403
425
|
return value;
|
|
404
426
|
}
|
|
@@ -412,6 +434,7 @@ export function normalizeState(value) {
|
|
|
412
434
|
lock,
|
|
413
435
|
revision,
|
|
414
436
|
left,
|
|
437
|
+
threads,
|
|
415
438
|
};
|
|
416
439
|
}
|
|
417
440
|
|
|
@@ -435,6 +458,11 @@ function cleanOpen(value) {
|
|
|
435
458
|
return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
|
|
436
459
|
}
|
|
437
460
|
|
|
461
|
+
/** A thread's name as the writer typed it, one line, spaces collapsed (R60). */
|
|
462
|
+
function cleanThreadName(value) {
|
|
463
|
+
return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
|
|
464
|
+
}
|
|
465
|
+
|
|
438
466
|
/** A when as the writer typed it, one line, spaces collapsed. */
|
|
439
467
|
function cleanWhen(value) {
|
|
440
468
|
return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
|
|
@@ -631,10 +659,19 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
631
659
|
return { ...group, noteIds };
|
|
632
660
|
}),
|
|
633
661
|
);
|
|
662
|
+
// A thread through the card keeps its name and loses the card (R60);
|
|
663
|
+
// the thread stays, with one card fewer, so the writer can retie it.
|
|
664
|
+
const threadsLeft = [];
|
|
665
|
+
const threads = (state.threads ?? []).map((thread) => {
|
|
666
|
+
if (!thread.noteIds.includes(command.id)) return thread;
|
|
667
|
+
const noteIds = thread.noteIds.filter((id) => id !== command.id);
|
|
668
|
+
threadsLeft.push({ id: thread.id, name: thread.name, remaining: noteIds.length });
|
|
669
|
+
return { ...thread, noteIds };
|
|
670
|
+
});
|
|
634
671
|
// The result says what went with the card, so a door can say it too
|
|
635
672
|
// (round thirteen, entry 17: "Deleted card." and nothing of the arrows).
|
|
636
673
|
return {
|
|
637
|
-
state: { ...state, notes, arrows, groups },
|
|
674
|
+
state: { ...state, notes, arrows, groups, threads },
|
|
638
675
|
changed: true,
|
|
639
676
|
result: {
|
|
640
677
|
id: command.id,
|
|
@@ -647,6 +684,7 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
647
684
|
arrows: taken.map((arrow) => ({ ...arrow, fromHeadline: headlineOf(arrow.from), toHeadline: headlineOf(arrow.to) })),
|
|
648
685
|
joined: joined ? { ...joined, fromHeadline: headlineOf(joined.from), toHeadline: headlineOf(joined.to) } : null,
|
|
649
686
|
groups: left,
|
|
687
|
+
threads: threadsLeft,
|
|
650
688
|
},
|
|
651
689
|
};
|
|
652
690
|
}
|
|
@@ -699,6 +737,50 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
699
737
|
return { state: { ...state, groups }, changed: true };
|
|
700
738
|
}
|
|
701
739
|
|
|
740
|
+
// A thread (R60): a named string through cards, either end open until the
|
|
741
|
+
// writer ties it. A record of its own beside the fold and the setup arrow;
|
|
742
|
+
// the reading asks about each loose end from that end.
|
|
743
|
+
case "create_thread": {
|
|
744
|
+
const name = cleanThreadName(command.name);
|
|
745
|
+
if (!name) return { state, changed: false };
|
|
746
|
+
const noteIds = (command.noteIds ?? []).filter((id, index, all) => all.indexOf(id) === index && state.notes.some((note) => note.id === id));
|
|
747
|
+
const thread = {
|
|
748
|
+
id: typeof command.id === "string" && command.id && !(state.threads ?? []).some((item) => item.id === command.id) ? command.id : newId(),
|
|
749
|
+
name,
|
|
750
|
+
noteIds,
|
|
751
|
+
startOpen: command.startOpen === true,
|
|
752
|
+
endOpen: command.endOpen === true,
|
|
753
|
+
};
|
|
754
|
+
return { state: { ...state, threads: [...(state.threads ?? []), thread] }, changed: true, result: thread };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
case "update_thread": {
|
|
758
|
+
const current = (state.threads ?? []).find((thread) => thread.id === command.id);
|
|
759
|
+
if (!current) return { state, changed: false };
|
|
760
|
+
const exists = (id) => state.notes.some((note) => note.id === id);
|
|
761
|
+
let noteIds = Array.isArray(command.noteIds) ? command.noteIds.filter((id, index, all) => all.indexOf(id) === index && exists(id)) : [...current.noteIds];
|
|
762
|
+
if (Array.isArray(command.add)) for (const id of command.add) if (exists(id) && !noteIds.includes(id)) noteIds.push(id);
|
|
763
|
+
if (Array.isArray(command.remove)) noteIds = noteIds.filter((id) => !command.remove.includes(id));
|
|
764
|
+
const name = command.name === undefined ? current.name : cleanThreadName(command.name) || current.name;
|
|
765
|
+
const startOpen = typeof command.startOpen === "boolean" ? command.startOpen : current.startOpen;
|
|
766
|
+
const endOpen = typeof command.endOpen === "boolean" ? command.endOpen : current.endOpen;
|
|
767
|
+
if (name === current.name && startOpen === current.startOpen && endOpen === current.endOpen && sameIds(noteIds, current.noteIds) && noteIds.length === current.noteIds.length) {
|
|
768
|
+
return { state, changed: false };
|
|
769
|
+
}
|
|
770
|
+
const next = { ...current, name, noteIds, startOpen, endOpen };
|
|
771
|
+
return {
|
|
772
|
+
state: { ...state, threads: state.threads.map((thread) => (thread.id === command.id ? next : thread)) },
|
|
773
|
+
changed: true,
|
|
774
|
+
result: { thread: next, before: current },
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
case "delete_thread": {
|
|
779
|
+
const gone = (state.threads ?? []).find((thread) => thread.id === command.id);
|
|
780
|
+
if (!gone) return { state, changed: false };
|
|
781
|
+
return { state: { ...state, threads: state.threads.filter((thread) => thread.id !== command.id) }, changed: true, result: gone };
|
|
782
|
+
}
|
|
783
|
+
|
|
702
784
|
case "create_group": {
|
|
703
785
|
const noteIds = command.noteIds.filter((id) =>
|
|
704
786
|
state.notes.some((note) => note.id === id),
|
package/src/board/words.js
CHANGED
|
@@ -68,6 +68,11 @@ export const WORD_GROUPS = [
|
|
|
68
68
|
name: "Open",
|
|
69
69
|
sentence: "A card the writer has not decided, in their words on its edge: the reading lists it and asks nothing else of it until the words are cleared.",
|
|
70
70
|
},
|
|
71
|
+
{
|
|
72
|
+
id: "thread",
|
|
73
|
+
name: "A thread",
|
|
74
|
+
sentence: "A named string through the cards a thing runs through — the letter, the key, a subplot — with either end open until the writer ties it. Drawn on the wall as a dashed string, a ring where an end is loose; the reading asks where a loose thread is first seen, or where it comes out.",
|
|
75
|
+
},
|
|
71
76
|
{
|
|
72
77
|
id: "corner",
|
|
73
78
|
name: "The folded corner",
|
package/src/board/workflows.js
CHANGED
|
@@ -15,7 +15,7 @@ export const WORKFLOWS = [
|
|
|
15
15
|
id: "break-a-treatment",
|
|
16
16
|
name: "Break a treatment into a wall",
|
|
17
17
|
ask: "Here is a treatment. Break it into a wall: one card per scene with a headline and what changes, the cast on each card, the places, and the major turns marked as beats.",
|
|
18
|
-
tools: ["list_words", "read_wall", "list_reminders", "list_board", "new_project", "new_board", "rename_project", "rename_board", "set_target", "set_logline", "set_premise", "create_note", "add_character", "cast", "update_character", "set_location", "set_when", "set_rank", "set_length", "set_plant", "create_arrow", "create_group", "organize"],
|
|
18
|
+
tools: ["list_words", "read_wall", "list_reminders", "list_board", "new_project", "new_board", "rename_project", "rename_board", "set_target", "set_logline", "set_premise", "create_note", "add_character", "cast", "update_character", "set_location", "set_when", "set_rank", "set_length", "set_plant", "create_thread", "create_arrow", "create_group", "organize"],
|
|
19
19
|
then: "Start where the wall will live: on the account, new_project names it; in a folder, new_board for the writer's wall, then rename_project. Read the wall (read_wall) and say what it asks. A treatment is cards, one create_note each, with characters, location, rank and plants on the call; import_fountain is the door for pages, not a treatment — a scene's text measures its card.",
|
|
20
20
|
// What a treatment should say (R49): eleven blind runs asked the writer
|
|
21
21
|
// the same questions at the end of every build. Each is a fact the wall
|
|
@@ -30,7 +30,7 @@ export const WORKFLOWS = [
|
|
|
30
30
|
{ question: "Where does each scene happen?", hint: "In your own words. A scene that moves through one location is still one place.", tool: "set_location" },
|
|
31
31
|
{ question: "When does a scene happen, where that matters?", hint: "That night; the fourth of October. It goes beside the place, never in the headline.", tool: "set_when" },
|
|
32
32
|
{ question: "Who is in each scene, and what do we call them?", hint: "A full name, or a role for someone unnamed — the man in 42. And who is only spoken of, never in a scene? They go in someone's notes, not the cast.", tool: "add_character, cast, update_character" },
|
|
33
|
-
{ question: "What is planted, and where does it pay off?", hint: "Name the episode when it pays off outside this one, so the fold is deliberate and the wall knows where to look.", tool: "set_plant with later, create_arrow" },
|
|
33
|
+
{ question: "What is planted, and where does it pay off?", hint: "Name the episode when it pays off outside this one, so the fold is deliberate and the wall knows where to look. A thing whose far end you know and not its first sighting — the key, the bucket — is a thread with an open start.", tool: "set_plant with later, create_arrow; create_thread" },
|
|
34
34
|
{ question: "Which scenes do you already know run long or short?", hint: "A day in the story is not a page count; leave the rest unsized.", tool: "set_length" },
|
|
35
35
|
{ question: "What are the project and the board called?", hint: "The series, and this episode.", tool: "rename_project, rename_board" },
|
|
36
36
|
{ question: "What must not be invented?", hint: "Looks and voices are yours until you say; so is anything the treatment does not state.", tool: "update_character, later" },
|