plotcoder-board 0.1.29 → 0.1.31
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 +8 -4
- package/package.json +1 -1
- package/scripts/plotcoder-mcp-server.mjs +105 -54
- package/src/board/agents.js +2 -2
- package/src/board/fountain.js +1 -0
- package/src/board/project.d.ts +10 -1
- package/src/board/project.js +45 -8
- package/src/board/readWall.d.ts +2 -0
- package/src/board/readWall.js +26 -3
- package/src/board/reducer.d.ts +7 -2
- package/src/board/reducer.js +36 -8
- package/src/board/words.js +1 -1
- package/src/board/workflows.js +4 -4
package/README.md
CHANGED
|
@@ -51,11 +51,15 @@ 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. Nineteen 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 seventeen is fixed or decided
|
|
56
|
+
first seventeen is fixed or decided. 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
|
-
maybe — the open card, R59,
|
|
58
|
+
maybe — the open card, R59, built from it; round eighteen confirmed that a
|
|
59
|
+
thread is not a fold — the thread, R60, built from it, a named string through
|
|
60
|
+
cards with either end open until the writer ties it; round nineteen measured
|
|
61
|
+
the thread and found the fold beside it cannot say which of two things a
|
|
62
|
+
scene plants. [`blind-runs/`](blind-runs/) holds the rules that keep a round honest,
|
|
59
63
|
the table of rounds, and the next round's prompt with the test account filled in.
|
|
60
64
|
|
|
61
65
|
A round works a **test account** — a throwaway marked on its writer row, and the
|
|
@@ -100,4 +104,4 @@ next round into a test of `claim_account` instead of the door it meant to test.
|
|
|
100
104
|
|
|
101
105
|
## Status
|
|
102
106
|
|
|
103
|
-
Version 0.1.
|
|
107
|
+
Version 0.1.31. 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
|
@@ -69,6 +69,8 @@ import {
|
|
|
69
69
|
removeStructure,
|
|
70
70
|
setActiveBoard,
|
|
71
71
|
setPremise,
|
|
72
|
+
setPremiseOpen,
|
|
73
|
+
setBoardNameOpen,
|
|
72
74
|
structureBeats,
|
|
73
75
|
reidentifyProject,
|
|
74
76
|
renameProject,
|
|
@@ -991,6 +993,19 @@ const CHECK_WORDS = {
|
|
|
991
993
|
unplaced: "no card without a place",
|
|
992
994
|
loose: "no thread with a loose end",
|
|
993
995
|
};
|
|
996
|
+
/** What an open card would be asked once closed, as the question and not the check's clean form (round nineteen, entry 31). */
|
|
997
|
+
const ASK_WORDS = {
|
|
998
|
+
unwritten: "its change line",
|
|
999
|
+
unlinked: "what comes before and after it",
|
|
1000
|
+
duplicate: "which of two alike headlines it is",
|
|
1001
|
+
sequence: "whether its group is one sequence",
|
|
1002
|
+
nobody: "who is in it",
|
|
1003
|
+
absent: "who has been gone too long",
|
|
1004
|
+
backwards: "why its payoff comes before its setup",
|
|
1005
|
+
unpaid: "where its fold pays off",
|
|
1006
|
+
unplanted: "what plants its payoff",
|
|
1007
|
+
unplaced: "where it happens",
|
|
1008
|
+
};
|
|
994
1009
|
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.";
|
|
995
1010
|
|
|
996
1011
|
// --- Reporting -------------------------------------------------------------
|
|
@@ -1050,8 +1065,8 @@ function summarize(state) {
|
|
|
1050
1065
|
const snap = state.revision?.snapshot?.[note.id];
|
|
1051
1066
|
const revised = snap && (snap.headline !== note.headline || snap.change !== note.change || (snap.text ?? "") !== (note.text ?? "") || (snap.location ?? "") !== (note.location ?? "")) ? `, changed in ${state.revision.color}` : "";
|
|
1052
1067
|
const place = note.location ? `, at: ${note.location}` : "";
|
|
1053
|
-
const when = note.when ? `, when: ${note.when}` : "";
|
|
1054
|
-
const openWord = note.open ? `, open: ${note.open}` : "";
|
|
1068
|
+
const when = note.when ? `, when: ${note.when}` : note.whenOpen ? `, when: open, by the writer's word — "${note.whenOpen}"` : "";
|
|
1069
|
+
const openWord = note.open ? `, open (the writer's words): "${note.open}"` : "";
|
|
1055
1070
|
const count = formatPages(noteEighths(note));
|
|
1056
1071
|
// A written card's estimate is kept underneath for when the text goes; say it, or it is invisible (round sixteen, entry 44).
|
|
1057
1072
|
const underneath = isMeasured(note) && note.lengthEighths !== null ? `; the writer's estimate underneath: ${formatPages(note.lengthEighths)}` : "";
|
|
@@ -1121,25 +1136,25 @@ function summarize(state) {
|
|
|
1121
1136
|
const leftCount = (state.left ?? []).length;
|
|
1122
1137
|
return [
|
|
1123
1138
|
...(isSampleWall(state) ? [SAMPLE_NOTE] : []),
|
|
1124
|
-
`logline: ${state.logline ? `"${state.logline}"` : "(not set)"}`,
|
|
1139
|
+
`logline: ${state.loglineOpen ? `open, by the writer's word — "${state.loglineOpen}"` : state.logline ? `"${state.logline}"` : "(not set)"}`,
|
|
1125
1140
|
...production,
|
|
1126
1141
|
`left, for now: ${leftCount ? `${leftCount} question(s) the writer left; read_wall lists them` : "none"}`,
|
|
1127
1142
|
`beats: ${beats}, scenes: ${scenes}`,
|
|
1128
1143
|
state.targetEighths === DEFAULT_TARGET_EIGHTHS
|
|
1129
1144
|
? `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)}`
|
|
1130
|
-
: `runtime: about ${formatPages(runtime)} pages of
|
|
1145
|
+
: `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)${runtimeKinds(state)}`,
|
|
1131
1146
|
`notes: ${state.notes.length}, groups: ${state.groups.length}, arrows: ${state.arrows.length}, cast: ${state.characters.length}`,
|
|
1132
1147
|
"cards (in story order — the follows arrows over the rows; each with its id):",
|
|
1133
1148
|
notes || " (no cards)",
|
|
1134
1149
|
"rows on the wall (top to bottom, left to right; ★ a beat):",
|
|
1135
1150
|
...(rowLines.length ? rowLines : [" (no cards)"]),
|
|
1136
|
-
"cast (the project's; every board of it casts from here):",
|
|
1151
|
+
"cast (the project's; every board of it casts from here; read_character reads a person's whole page, update_character writes it):",
|
|
1137
1152
|
cast || " (no one yet — add_character to start the roster)",
|
|
1138
1153
|
"places (each phrase is its own place, and the app relates none of them — if two are one place, set_location them the same):",
|
|
1139
1154
|
places || " (no card says where it happens yet)",
|
|
1140
1155
|
"groups:",
|
|
1141
1156
|
groups || " (no groups)",
|
|
1142
|
-
"threads (
|
|
1157
|
+
"threads (a named string through cards, in story order; an open end is asked about by the reading):",
|
|
1143
1158
|
...((state.threads ?? []).length ? state.threads.map((thread) => ` - ${threadLine(state, thread)}`) : [" (no threads — create_thread names one)"]),
|
|
1144
1159
|
"arrows:",
|
|
1145
1160
|
arrows || " (no arrows)",
|
|
@@ -1208,18 +1223,24 @@ server.registerTool(
|
|
|
1208
1223
|
{
|
|
1209
1224
|
title: "Set logline",
|
|
1210
1225
|
description:
|
|
1211
|
-
"Set the board's logline — the central question, what this story is arguing. One sentence. Every card on the wall should be checkable against it. Pass an empty string to clear it.",
|
|
1226
|
+
"Set the board's logline — the central question, what this story is arguing. One sentence. Every card on the wall should be checkable against it. Pass an empty string to clear it. Or leave it open: pass open with the writer's words for why there is no logline yet — \"two candidates, not chosen\" — and the reading lists it under open, by the writer's word, and asks nothing; text decides it and clears the words; open \"\" takes the words back and leaves the field blank. Only on the writer's word: an open field is theirs, never a guess of yours.",
|
|
1212
1227
|
inputSchema: {
|
|
1213
|
-
logline: z.string(),
|
|
1228
|
+
logline: z.string().optional(),
|
|
1229
|
+
open: z.string().optional(),
|
|
1214
1230
|
},
|
|
1215
1231
|
},
|
|
1216
1232
|
async (args) => {
|
|
1217
|
-
|
|
1233
|
+
if (args.logline === undefined && args.open === undefined) return ok("Say which: logline (the sentence, or \"\" to clear it), or open (the writer's words for why there is none yet).");
|
|
1234
|
+
const { state, changed, live } = await commit({ type: "set_logline", ...(args.logline !== undefined ? { logline: args.logline } : {}), ...(args.open !== undefined ? { open: args.open } : {}) });
|
|
1235
|
+
if (!changed) return ok(args.logline === "" && !state.loglineOpen ? "Logline cleared." : "Logline unchanged: it already read that way.");
|
|
1236
|
+
if (state.loglineOpen) {
|
|
1237
|
+
return ok(`Logline left open, by the writer's word: "${state.loglineOpen}"${where(live)}. The reading lists it and asks nothing; set_logline with text decides it, open "" leaves it blank.`, { logline: state.logline, loglineOpen: state.loglineOpen });
|
|
1238
|
+
}
|
|
1218
1239
|
return ok(
|
|
1219
1240
|
state.logline
|
|
1220
1241
|
? `Logline set: "${state.logline}"${where(live)}.`
|
|
1221
|
-
:
|
|
1222
|
-
{ logline: state.logline },
|
|
1242
|
+
: `Logline cleared${where(live)}.`,
|
|
1243
|
+
{ logline: state.logline, loglineOpen: state.loglineOpen },
|
|
1223
1244
|
);
|
|
1224
1245
|
},
|
|
1225
1246
|
);
|
|
@@ -1387,7 +1408,8 @@ server.registerTool(
|
|
|
1387
1408
|
}
|
|
1388
1409
|
}
|
|
1389
1410
|
// Wired into the story where the writer said, in the same frame.
|
|
1390
|
-
|
|
1411
|
+
// On a wall with no follows arrows yet, after or before draws the first one (round nineteen, entry 14).
|
|
1412
|
+
if (beside && made?.id) {
|
|
1391
1413
|
// Count the rewiring, so the reply can say it as move_scene does (round eighteen, entry 36).
|
|
1392
1414
|
const run = (command) => {
|
|
1393
1415
|
const done = step(command);
|
|
@@ -1413,9 +1435,7 @@ server.registerTool(
|
|
|
1413
1435
|
].filter(Boolean).join(", ");
|
|
1414
1436
|
// Where it landed matters only until the tidy, so the reply says the rule once and never the coordinates (round fourteen, entry 11).
|
|
1415
1437
|
const placed = beside
|
|
1416
|
-
? wallHasFollows
|
|
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.`
|
|
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.`
|
|
1438
|
+
? ` Wired ${args.after ? "after" : "before"} "${beside.headline}" in the story (${removedArrows} follows arrow${removedArrows === 1 ? "" : "s"} removed, ${drawnArrows} drawn${wallHasFollows ? "" : "; the wall's first, so the story order starts here"})${joinedGroup ? `, in "${joinedGroup}"` : ""}, and the wall tidied.`
|
|
1419
1439
|
: args.x === undefined && args.y === undefined ? ` Placed after the last card in story order.${once("placed", " organize lays the wall out along the arrows.")}` : "";
|
|
1420
1440
|
// 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).
|
|
1421
1441
|
const numbered = after?.lock && result?.id ? ` Numbered ${sceneNumbers(storyOrder(after), after.lock).get(result.id)} (the numbers are locked; a new scene's letter is its place between locked ones now, worked out again from where it sits if it moves; the locked numbers never move).` : "";
|
|
@@ -1452,7 +1472,8 @@ server.registerTool(
|
|
|
1452
1472
|
.map(([field, word]) => `${word}: "${prior?.[field] ?? ""}" → "${result[field] ?? ""}"`);
|
|
1453
1473
|
if (!changed || fields.length === 0) return ok(`Nothing changed on "${result.headline}": the card already read that way.`);
|
|
1454
1474
|
const mark = state.revision && state.revision.snapshot?.[result.id] && (state.revision.snapshot[result.id].headline !== result.headline || state.revision.snapshot[result.id].change !== result.change) ? ` Marked changed in the ${state.revision.color} revision "${state.revision.name}".` : "";
|
|
1455
|
-
|
|
1475
|
+
const placeNote = args.location !== undefined && (prior?.location ?? "") !== (result.location ?? "") ? nearPlaces(state, result.location) : "";
|
|
1476
|
+
return ok(`Updated "${result.headline}" — ${fields.join("; ")}${where(live)}.${mark}${placeNote}`, result);
|
|
1456
1477
|
},
|
|
1457
1478
|
);
|
|
1458
1479
|
|
|
@@ -1550,15 +1571,23 @@ server.registerTool(
|
|
|
1550
1571
|
const whose = runtimeKinds(state).replace(/^; /, "");
|
|
1551
1572
|
// A beat's own pages are in no run (entry 48); say how many pages that is.
|
|
1552
1573
|
const beatEighths = reading.beats.reduce((sum, beat) => sum + noteEighths(state.notes.find((note) => note.id === beat.id) ?? {}), 0);
|
|
1574
|
+
// 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.
|
|
1575
|
+
const openFieldLines = [
|
|
1576
|
+
...reading.openFields.filter((field) => field.field === "logline").map((field) => ` - the logline — ${field.words}`),
|
|
1577
|
+
...(projectForRead.premiseOpen ? [` - the premise — ${projectForRead.premiseOpen}`] : []),
|
|
1578
|
+
...(readBoardMeta?.nameOpen ? [` - this board's name — ${readBoardMeta.nameOpen}`] : []),
|
|
1579
|
+
...reading.openFields.filter((field) => field.field === "when").map((field) => ` - "${state.notes.find((note) => note.id === field.id)?.headline ?? field.id}" — when: ${field.words}`),
|
|
1580
|
+
];
|
|
1553
1581
|
const lines = [
|
|
1554
1582
|
`PlotCoder wall (${door(live, base)})`,
|
|
1555
1583
|
...(state.lock ? [`numbers: locked since ${String(state.lock.at).slice(0, 10)}; read_pages shows each scene's number`] : []),
|
|
1556
|
-
`board: "${readBoardMeta?.name ?? "?"}"${projectForRead.boards.length > 1 ? ` — board ${projectForRead.boards.findIndex((meta) => meta.id === readBoardMeta?.id) + 1} of ${projectForRead.boards.length} in the project "${projectForRead.name}"; open_board reads another` : ""}`,
|
|
1557
|
-
`
|
|
1584
|
+
`board: "${readBoardMeta?.name ?? "?"}"${readBoardMeta?.nameOpen ? ` — its name is open, by the writer's word: "${readBoardMeta.nameOpen}"` : ""}${projectForRead.boards.length > 1 ? ` — board ${projectForRead.boards.findIndex((meta) => meta.id === readBoardMeta?.id) + 1} of ${projectForRead.boards.length} in the project "${projectForRead.name}"; open_board reads another` : ""}`,
|
|
1585
|
+
...(projectForRead.premiseOpen ? [`premise: open, by the writer's word — "${projectForRead.premiseOpen}"`] : []),
|
|
1586
|
+
`logline: ${state.loglineOpen ? `open, by the writer's word — "${state.loglineOpen}"` : state.logline ? `"${state.logline}"` : "(none yet)"}`,
|
|
1558
1587
|
"the cast and the places are list_board's, not the reading's",
|
|
1559
1588
|
state.targetEighths === DEFAULT_TARGET_EIGHTHS
|
|
1560
1589
|
? `runtime: about ${formatPages(boardEighths(state))} pages (${whose || "no cards"}); no target set (set_target)`
|
|
1561
|
-
: `runtime: about ${formatPages(boardEighths(state))} pages of
|
|
1590
|
+
: `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)`,
|
|
1562
1591
|
`groups: ${
|
|
1563
1592
|
state.groups.length
|
|
1564
1593
|
? state.groups
|
|
@@ -1586,13 +1615,13 @@ server.registerTool(
|
|
|
1586
1615
|
: [reading.paidBy.length ? " (no setup arrow on this board; what pays off a fold of another board is listed below)" : " (no arrow is marked as a setup)"]),
|
|
1587
1616
|
...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"}`),
|
|
1588
1617
|
...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`),
|
|
1589
|
-
...(reading.open.length
|
|
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
|
|
1618
|
+
...(reading.open.length || openFieldLines.length
|
|
1619
|
+
? ["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("; ")})` : ""}`)]
|
|
1591
1620
|
: []),
|
|
1592
1621
|
...(reading.threads.length
|
|
1593
1622
|
? ["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" : ""}`)]
|
|
1594
1623
|
: []),
|
|
1595
|
-
"questions the wall raises:",
|
|
1624
|
+
"questions the wall raises (each stands on every reading until the wall changes to answer it, or the writer leaves it — leave_question, with their reason):",
|
|
1596
1625
|
...(reading.findings.length
|
|
1597
1626
|
? reading.findings.map((finding) => ` - [${finding.kind}] ${finding.text}${finding.ids.length ? ` (ids: ${finding.ids.join(", ")})` : ""}`)
|
|
1598
1627
|
: [reading.left.length ? " (none the writer has not left)" : " (none that this reading can see)"]),
|
|
@@ -1609,7 +1638,7 @@ server.registerTool(
|
|
|
1609
1638
|
const counts = new Map();
|
|
1610
1639
|
for (const finding of asked) counts.set(finding.kind, (counts.get(finding.kind) ?? 0) + 1);
|
|
1611
1640
|
return `asking ${asked.length} question${asked.length === 1 ? "" : "s"} of ${counts.size} kind${counts.size === 1 ? "" : "s"}: ${[...counts.entries()].map(([kind, n]) => (n > 1 ? `${kind} ×${n}` : kind)).join(", ")}${held}`;
|
|
1612
|
-
})()}${reading.left.length ? `; left by the writer, so not clean: ${[...new Set(reading.left.map((finding) => finding.kind))].map((kind) => `[${kind}]`).join(" ")}` : ""}${reading.open.length ? `; ${reading.open.length} card${reading.open.length === 1 ? "" : "s"} open by the writer's word, not asked` : ""}; checked and clean: ${CHECKS.filter((kind) => !reading.findings.some((finding) => finding.kind === kind) && !reading.left.some((finding) => finding.kind === kind)).map((kind) => {
|
|
1641
|
+
})()}${reading.left.length ? `; left by the writer, so not clean: ${[...new Set(reading.left.map((finding) => finding.kind))].map((kind) => `[${kind}]`).join(" ")}` : ""}${reading.open.length || openFieldLines.length ? `; ${[reading.open.length ? `${reading.open.length} card${reading.open.length === 1 ? "" : "s"}` : "", openFieldLines.length ? `${openFieldLines.length} field${openFieldLines.length === 1 ? "" : "s"}` : ""].filter(Boolean).join(" and ")} open by the writer's word, not asked` : ""}; checked and clean: ${CHECKS.filter((kind) => !reading.findings.some((finding) => finding.kind === kind) && !reading.left.some((finding) => finding.kind === kind)).map((kind) => {
|
|
1613
1642
|
if (kind === "unlinked" && state.arrows.length === 0) return "no card without an arrow (not asked until half the cards are wired: no arrows yet)";
|
|
1614
1643
|
if (kind === "unlinked") {
|
|
1615
1644
|
const linked = new Set(state.arrows.flatMap((arrow) => [arrow.from, arrow.to]));
|
|
@@ -2026,7 +2055,7 @@ server.registerTool(
|
|
|
2026
2055
|
const allMeasured = state.notes.length > 0 && state.notes.every((note) => isMeasured(note));
|
|
2027
2056
|
const short = state.targetEighths > 0 && boardEighths(state) * 2 < state.targetEighths;
|
|
2028
2057
|
const lines = [
|
|
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:`,
|
|
2058
|
+
`"${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}, the page its last card ends on — read_wall counts the same cards as about ${formatPages(boardEighths(state))} pages${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
2059
|
// On a wall under half its target the pairing is arithmetic; say so before the rows, not after them (round eighteen, entry 52).
|
|
2031
2060
|
...(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`] : []),
|
|
2032
2061
|
...describeComparison(comparison).map((line) => ` - ${line}`),
|
|
@@ -2695,16 +2724,21 @@ server.registerTool(
|
|
|
2695
2724
|
{
|
|
2696
2725
|
title: "Set when scenes happen",
|
|
2697
2726
|
description:
|
|
2698
|
-
"When one or more scenes happen, as the writer would say it — \"night\", \"day four, dawn\", \"the next morning\" — on the card beside its place, and printed after the place on every scene heading: THE PIER AT FENIT - NIGHT. Free text, the writer's phrase; an empty string clears it. This is where a scene's day and time live, not the headline, so the duplicate check never reads a day as a scene's words. create_note and update_note take when too; list_board shows it as when: …",
|
|
2699
|
-
inputSchema: { ids: z.array(z.string()).min(1), when: z.string() },
|
|
2727
|
+
"When one or more scenes happen, as the writer would say it — \"night\", \"day four, dawn\", \"the next morning\" — on the card beside its place, and printed after the place on every scene heading: THE PIER AT FENIT - NIGHT. Free text, the writer's phrase; an empty string clears it. Or leave the when open: pass open with the writer's words for why it is not decided — \"after the break-in; which day\" — and the reading lists it under open, by the writer's word, while the card's other questions still stand; a when decides it, open \"\" leaves it blank. This is where a scene's day and time live, not the headline, so the duplicate check never reads a day as a scene's words. create_note and update_note take when too; list_board shows it as when: …",
|
|
2728
|
+
inputSchema: { ids: z.array(z.string()).min(1), when: z.string().optional(), open: z.string().optional() },
|
|
2700
2729
|
},
|
|
2701
2730
|
async (args) => {
|
|
2702
|
-
|
|
2731
|
+
if (args.when === undefined && args.open === undefined) return ok("Say which: when (the writer's phrase, or \"\" to clear it), or open (their words for why the when is not decided).");
|
|
2732
|
+
const { state, changed, result, live } = await commit({ type: "set_when", ids: args.ids, ...(args.when !== undefined ? { when: args.when } : {}), ...(args.open !== undefined ? { open: args.open } : {}) });
|
|
2703
2733
|
if (!changed) {
|
|
2704
2734
|
const missing = args.ids.filter((id) => !state.notes.some((note) => note.id === id));
|
|
2705
|
-
return ok(missing.length ? `No card with id ${missing.join(", ")}. Call list_board for the real ids.` : `Nothing changed: ${args.ids.length === 1 ? "the card already says" : "those cards already say"} "${args.when.trim()}".`);
|
|
2735
|
+
return ok(missing.length ? `No card with id ${missing.join(", ")}. Call list_board for the real ids.` : `Nothing changed: ${args.ids.length === 1 ? "the card already says" : "those cards already say"} "${(args.when ?? args.open ?? "").trim()}".`);
|
|
2706
2736
|
}
|
|
2707
2737
|
const when = result[0]?.when ?? "";
|
|
2738
|
+
const whenOpen = result[0]?.whenOpen ?? "";
|
|
2739
|
+
if (whenOpen) {
|
|
2740
|
+
return ok(`${result.length} card(s) have their when left open, by the writer's word: "${whenOpen}"${where(live)}. The reading lists it and asks nothing; the heading prints no time; set_when with a when decides it, open "" leaves it blank. The card is still asked about everything else.${stillOpen(result)}`, result);
|
|
2741
|
+
}
|
|
2708
2742
|
return ok(
|
|
2709
2743
|
when
|
|
2710
2744
|
? `${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)}`
|
|
@@ -2951,7 +2985,7 @@ server.registerTool(
|
|
|
2951
2985
|
});
|
|
2952
2986
|
const total = parts.reduce((sum, part) => sum + part.on.length, 0);
|
|
2953
2987
|
// 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(" · ");
|
|
2988
|
+
const where_ = (note) => [note.location ? `at ${note.location}` : "", note.when ? note.when : note.whenOpen ? `when open: "${note.whenOpen}"` : "", note.rank === "beat" ? "beat" : "", note.open ? `open: "${note.open}"` : ""].filter(Boolean).join(" · ");
|
|
2955
2989
|
// A read opens with the door it came through, like every reading (round sixteen, entry 28); one scene a line (29).
|
|
2956
2990
|
const lines = [
|
|
2957
2991
|
`PlotCoder cast (${door(live, base)})`,
|
|
@@ -3009,6 +3043,17 @@ server.registerTool(
|
|
|
3009
3043
|
},
|
|
3010
3044
|
);
|
|
3011
3045
|
|
|
3046
|
+
/** A near match on the wall is probably the same place spelled twice (round ten; update_note too, round nineteen entry 36). */
|
|
3047
|
+
function nearPlaces(state, place) {
|
|
3048
|
+
const wanted = (place ?? "").trim().toLowerCase();
|
|
3049
|
+
const near = wanted
|
|
3050
|
+
? [...new Set(state.notes.map((note) => (note.location ?? "").trim()).filter(Boolean))].filter(
|
|
3051
|
+
(other) => other.toLowerCase() !== wanted && (other.toLowerCase().includes(wanted) || wanted.includes(other.toLowerCase())),
|
|
3052
|
+
)
|
|
3053
|
+
: [];
|
|
3054
|
+
return 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.` : "";
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3012
3057
|
server.registerTool(
|
|
3013
3058
|
"set_location",
|
|
3014
3059
|
{
|
|
@@ -3029,16 +3074,8 @@ server.registerTool(
|
|
|
3029
3074
|
return ok("No place changed: those cards already read that way.");
|
|
3030
3075
|
}
|
|
3031
3076
|
const place = result[0].location;
|
|
3032
|
-
// A near match on the wall is probably the same place spelled twice.
|
|
3033
|
-
const wanted = (place ?? "").trim().toLowerCase();
|
|
3034
|
-
const near = wanted
|
|
3035
|
-
? [...new Set(state.notes.map((note) => (note.location ?? "").trim()).filter(Boolean))].filter(
|
|
3036
|
-
(other) => other.toLowerCase() !== wanted && (other.toLowerCase().includes(wanted) || wanted.includes(other.toLowerCase())),
|
|
3037
|
-
)
|
|
3038
|
-
: [];
|
|
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.` : "";
|
|
3040
3077
|
return ok(
|
|
3041
|
-
`${result.length} card(s) now ${place ? `at ${place}` : "nowhere"}${where(live)}.${
|
|
3078
|
+
`${result.length} card(s) now ${place ? `at ${place}` : "nowhere"}${where(live)}.${nearPlaces(state, place)}${stillOpen(result)}`,
|
|
3042
3079
|
result,
|
|
3043
3080
|
);
|
|
3044
3081
|
},
|
|
@@ -3414,7 +3451,8 @@ function describeBoards(project, boards, changedAt = null) {
|
|
|
3414
3451
|
? `${state.notes.length} cards, about ${formatPages(boardEighths(normalizeState(state)))} of ${formatPages(normalizeState(state).targetEighths)} pages`
|
|
3415
3452
|
: "no cards";
|
|
3416
3453
|
const changed = changedAt?.[board.id] ? `, last changed ${changedAt[board.id]}` : "";
|
|
3417
|
-
|
|
3454
|
+
const nameOpen = board.nameOpen ? ` (name open, by the writer's word: "${board.nameOpen}")` : "";
|
|
3455
|
+
return ` ${index + 1}. ${board.id} — "${board.name}"${nameOpen}${open}: ${shape}${changed}`;
|
|
3418
3456
|
})
|
|
3419
3457
|
.join("\n");
|
|
3420
3458
|
}
|
|
@@ -3432,7 +3470,7 @@ server.registerTool(
|
|
|
3432
3470
|
return ok(
|
|
3433
3471
|
[
|
|
3434
3472
|
`Project "${project.name}" (${door(live, base)})`,
|
|
3435
|
-
`premise: ${project.premise ? `"${project.premise}"` : "(not set)"}`,
|
|
3473
|
+
`premise: ${project.premiseOpen ? `open, by the writer's word — "${project.premiseOpen}"` : project.premise ? `"${project.premise}"` : "(not set)"}`,
|
|
3436
3474
|
`boards: ${project.boards.length}`,
|
|
3437
3475
|
describeBoards(project, boards, changedAt),
|
|
3438
3476
|
// The project's length as one line, so a series is not arithmetic by hand (round fifteen, entry 38).
|
|
@@ -3456,14 +3494,16 @@ server.registerTool(
|
|
|
3456
3494
|
{
|
|
3457
3495
|
title: "Set the project's premise",
|
|
3458
3496
|
description:
|
|
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.",
|
|
3460
|
-
inputSchema: { premise: z.string() },
|
|
3497
|
+
"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. Or leave it open: pass open with the writer's words for why there is no premise yet — \"the buyer: housing, or a supermarket\" — and the reading lists it under open, by the writer's word; a premise decides it, open \"\" leaves it blank. Boards keep their own loglines.",
|
|
3498
|
+
inputSchema: { premise: z.string().optional(), open: z.string().optional() },
|
|
3461
3499
|
},
|
|
3462
3500
|
async (args) => {
|
|
3501
|
+
if (args.premise === undefined && args.open === undefined) return ok("Say which: premise (the line, or \"\" to clear it), or open (the writer's words for why there is none yet).");
|
|
3463
3502
|
const { project, boards, rev, base, live } = await readProject();
|
|
3464
|
-
const next = setPremise(project, args.premise);
|
|
3503
|
+
const next = args.open !== undefined ? setPremiseOpen(project, args.open) : setPremise(project, args.premise);
|
|
3465
3504
|
if (next === project) return ok("Premise unchanged.");
|
|
3466
3505
|
await writeProject(next, boards, rev, base);
|
|
3506
|
+
if (next.premiseOpen) return ok(`Premise left open, by the writer's word: "${next.premiseOpen}"${where(live)}. The reading lists it and asks nothing; set_premise with a line decides it, open "" leaves it blank.`, next);
|
|
3467
3507
|
return ok(`Premise ${next.premise ? `set to "${next.premise}"` : "cleared"}${where(live)}.`, next);
|
|
3468
3508
|
},
|
|
3469
3509
|
);
|
|
@@ -3662,14 +3702,16 @@ server.registerTool(
|
|
|
3662
3702
|
{
|
|
3663
3703
|
title: "Start a project",
|
|
3664
3704
|
description:
|
|
3665
|
-
"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.",
|
|
3666
|
-
inputSchema: { name: z.string().min(1), board: z.string().optional(), pages: pagesSchema.optional(), minutes: z.number().positive().optional() },
|
|
3705
|
+
"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. board names the first board; boardOpen leaves its name open in the writer's words instead (\"the title, or Feature\"), so a board born from a maybe is not silently \"Board 1\".",
|
|
3706
|
+
inputSchema: { name: z.string().min(1), board: z.string().optional(), boardOpen: z.string().optional(), pages: pagesSchema.optional(), minutes: z.number().positive().optional() },
|
|
3667
3707
|
},
|
|
3668
3708
|
async (args) => {
|
|
3669
3709
|
const account = await findAccount();
|
|
3670
3710
|
if (!account) return shut("No account door: there is one project here, the open one. Set PLOTCODER_EMAIL and PLOTCODER_PASSWORD to start another on the writer's account.");
|
|
3671
3711
|
let record = renameProject(emptyProject(), args.name.trim());
|
|
3672
3712
|
if (args.board?.trim()) record = renameBoard(record, record.activeBoardId, args.board.trim());
|
|
3713
|
+
// A board born from a maybe is born open on its name (R61): boardOpen holds the writer's words.
|
|
3714
|
+
if (args.boardOpen?.trim()) record = setBoardNameOpen(record, record.activeBoardId, args.boardOpen);
|
|
3673
3715
|
const inserted = await account.client.from("projects").insert({ id: record.id, record, reminders: null, rev: 1 });
|
|
3674
3716
|
if (inserted.error) return ok(`Could not start the project: ${inserted.error.message}`);
|
|
3675
3717
|
const target = args.pages ?? args.minutes;
|
|
@@ -3680,7 +3722,8 @@ server.registerTool(
|
|
|
3680
3722
|
joinPresence(record.id);
|
|
3681
3723
|
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.`;
|
|
3682
3724
|
const first = record.boards[0];
|
|
3683
|
-
|
|
3725
|
+
const nameOpenLine = first.nameOpen ? ` The board's name is left open, by the writer's word: "${first.nameOpen}"; rename_board decides it.` : "";
|
|
3726
|
+
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 });
|
|
3684
3727
|
},
|
|
3685
3728
|
);
|
|
3686
3729
|
|
|
@@ -3780,7 +3823,7 @@ server.registerTool(
|
|
|
3780
3823
|
{
|
|
3781
3824
|
title: "Save the project as a file",
|
|
3782
3825
|
description:
|
|
3783
|
-
|
|
3826
|
+
`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, which is ${process.cwd()}, 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.`,
|
|
3784
3827
|
inputSchema: { path: z.string().optional() },
|
|
3785
3828
|
},
|
|
3786
3829
|
async (args) => {
|
|
@@ -3878,19 +3921,19 @@ server.registerTool(
|
|
|
3878
3921
|
{
|
|
3879
3922
|
title: "New board",
|
|
3880
3923
|
description:
|
|
3881
|
-
"Add a board to the project and open it: an empty wall with the logline placeholder, under the same premise, with the same target length as the board that was open. Nothing else is touched — the other boards stay as they are. Name it for what it is: an episode, a draft, a story.",
|
|
3882
|
-
inputSchema: { name: z.string().optional() },
|
|
3924
|
+
"Add a board to the project and open it: an empty wall with the logline placeholder, under the same premise, with the same target length as the board that was open. Nothing else is touched — the other boards stay as they are. Name it for what it is: an episode, a draft, a story; or pass open with the writer's words for why the name is not decided, and it is born open on its name.",
|
|
3925
|
+
inputSchema: { name: z.string().optional(), open: z.string().optional() },
|
|
3883
3926
|
},
|
|
3884
3927
|
async (args) => {
|
|
3885
3928
|
const { project, boards, rev, base } = await readProject();
|
|
3886
3929
|
const previous = boards[project.activeBoardId];
|
|
3887
3930
|
const target =
|
|
3888
3931
|
previous && isBoardState(previous) ? normalizeState(previous).targetEighths : undefined;
|
|
3889
|
-
const { project: next, board } = addBoard(project, args.name ?? "");
|
|
3932
|
+
const { project: next, board } = addBoard(project, args.name ?? "", undefined, args.open ?? "");
|
|
3890
3933
|
const fresh = { ...emptyState(), ...(target ? { targetEighths: target } : {}) };
|
|
3891
3934
|
const { live } = await openBoardEverywhere(next, { ...boards, [board.id]: fresh }, rev, base, board.id);
|
|
3892
3935
|
return ok(
|
|
3893
|
-
`Added "${board.name}" (${board.id}) and opened it${where(live)}: every card call lands there now, and the writer's open wall switched with it; open_board ${project.boards.findIndex((item) => item.id === project.activeBoardId) + 1} returns to "${project.boards.find((item) => item.id === project.activeBoardId)?.name ?? "the one before"}". It is empty, and the project's cast is already there to cast from. The logline is the story's question when the writer has one — leave it empty rather than invent it — and the cards come next.${next.name === "Untitled project" ? " The project is still \"Untitled project\": rename_project names it." : ""}${next.boards.length === 2 && isSampleWall(isBoardState(boards[next.boards[0].id]) ? normalizeState(boards[next.boards[0].id]) : emptyState()) ? " The sample stays as Board 1; delete_board drops it." : ""}`,
|
|
3936
|
+
`Added "${board.name}" (${board.id})${board.nameOpen ? ` — its name left open, by the writer's word: "${board.nameOpen}"` : ""} and opened it${where(live)}: every card call lands there now, and the writer's open wall switched with it; open_board ${project.boards.findIndex((item) => item.id === project.activeBoardId) + 1} returns to "${project.boards.find((item) => item.id === project.activeBoardId)?.name ?? "the one before"}". It is empty, and the project's cast is already there to cast from. The logline is the story's question when the writer has one — leave it empty rather than invent it — and the cards come next.${next.name === "Untitled project" ? " The project is still \"Untitled project\": rename_project names it." : ""}${next.boards.length === 2 && isSampleWall(isBoardState(boards[next.boards[0].id]) ? normalizeState(boards[next.boards[0].id]) : emptyState()) ? " The sample stays as Board 1; delete_board drops it." : ""}`,
|
|
3894
3937
|
board,
|
|
3895
3938
|
);
|
|
3896
3939
|
},
|
|
@@ -3900,17 +3943,25 @@ server.registerTool(
|
|
|
3900
3943
|
"rename_board",
|
|
3901
3944
|
{
|
|
3902
3945
|
title: "Rename board",
|
|
3903
|
-
description: "Rename a board of the project by id, name, or number.",
|
|
3904
|
-
inputSchema: { board: z.union([z.string().min(1), z.number()]), name: z.string().min(1) },
|
|
3946
|
+
description: "Rename a board of the project by id, name, or number. Or leave its name open: pass open with the writer's words for why it is not decided — \"the title, or Feature\" — and the name stands as it is (every reply still calls it that) while the reading lists the words; a name decides it, open \"\" takes the words back.",
|
|
3947
|
+
inputSchema: { board: z.union([z.string().min(1), z.number()]), name: z.string().min(1).optional(), open: z.string().optional() },
|
|
3905
3948
|
},
|
|
3906
3949
|
async (args) => {
|
|
3950
|
+
if (args.name === undefined && args.open === undefined) return ok("Say which: name, or open (the writer's words for why the name is not decided).");
|
|
3907
3951
|
const { project, boards, rev, base } = await readProject();
|
|
3908
3952
|
const target = findBoard(project, String(args.board));
|
|
3909
3953
|
if (!target) return ok(`No board matches "${args.board}". Call list_boards for the real ones.`);
|
|
3954
|
+
if (args.name === undefined) {
|
|
3955
|
+
const next = setBoardNameOpen(project, target.id, args.open);
|
|
3956
|
+
if (next === project) return ok(`"${target.name}" already reads that way.`);
|
|
3957
|
+
const live = await writeProject(next, boards, rev, base);
|
|
3958
|
+
const words = boardById(next, target.id)?.nameOpen ?? "";
|
|
3959
|
+
return ok(words ? `Board "${target.name}" (${target.id}) keeps its name and its name is left open, by the writer's word: "${words}"${where(live)}. The reading lists it; rename_board with a name decides it.` : `Board "${target.name}" (${target.id}) is no longer open on its name${where(live)}.`, { id: target.id, name: target.name, nameOpen: words });
|
|
3960
|
+
}
|
|
3910
3961
|
const next = renameBoard(project, target.id, args.name);
|
|
3911
3962
|
if (next === project) return ok(`"${target.name}" already has that name.`);
|
|
3912
3963
|
const live = await writeProject(next, boards, rev, base);
|
|
3913
|
-
return ok(`Renamed board "${target.name}" (${target.id}) to "${args.name.trim()}"${where(live)}
|
|
3964
|
+
return ok(`Renamed board "${target.name}" (${target.id}) to "${args.name.trim()}"${where(live)}.${target.nameOpen ? " Its name is decided; the open words are gone." : ""}`, { id: target.id, name: args.name.trim() });
|
|
3914
3965
|
},
|
|
3915
3966
|
);
|
|
3916
3967
|
|
package/src/board/agents.js
CHANGED
|
@@ -55,7 +55,7 @@ export const AGENTS = {
|
|
|
55
55
|
"No opinions about how many beats there should be. Marking the turns a treatment plainly makes is reading it, not an opinion: mark them, say which, and let the writer strike or add.",
|
|
56
56
|
"Page counts are estimates.",
|
|
57
57
|
"Ask before delete_board, delete_project, empty_account, delete_account, unlock_numbers, remove_file, an import_project that replaces, or claim_account — the writer gives the email and the password; never invent one. export_project first, when something might be wanted back.",
|
|
58
|
-
"Do not invent people or a logline. What the treatment states — an age, a job, a bad knee — is not invented: it goes in the person's notes. An unnamed person is named by their role — Dana's mother, the dispatcher — which is a name until the writer gives one. A scene is one place and one stretch of time; a new place or time is a new card. A beat is a whole card; a setup arrow lands on the scene's card, so a payoff never needs a card of its own. Acts are groups titled Act one, Act two, when the treatment has them; the wall never asks whether an act is a sequence. Paper colour means nothing to the app. Under target is a fact to report plainly, like over; neither is a verdict.",
|
|
58
|
+
"Do not invent people or a logline. What the treatment states — an age, a job, a bad knee — is not invented: it goes in the person's notes. An unnamed person is named by their role — Dana's mother, the dispatcher — which is a name until the writer gives one. A scene is one place and one stretch of time; a new place or time is a new card. A beat is a whole card; a setup arrow lands on the scene's card, so a payoff never needs a card of its own. Acts are groups titled Act one, Act two, when the treatment has them; the wall never asks whether an act is a sequence. Paper colour means nothing to the app. Under target is a fact to report plainly, like over; neither is a verdict. A thing the writer has not decided is an open card in their words (set_open), or an open field — the logline, the premise, a when, a board's name take open beside the value — never a guess to fill the field; a thing whose far end the writer knows and not where it is first seen — the letter, the ring — is a thread (create_thread) with an open start, and the wall asks from that end.",
|
|
59
59
|
],
|
|
60
60
|
person:
|
|
61
61
|
"Give your agent the account door only on a machine you trust; it signs in as you and shows under People as “an agent, as you” while it runs. Wire the server before you start the agent's session, with the two sign-in lines beside it, and the agent has every tool from its first message; wired from inside a session, the server connects only on the next one. Your agent can also make your account: give it your email and a password of your choosing.",
|
|
@@ -68,7 +68,7 @@ export function agentsAsText() {
|
|
|
68
68
|
// The calls and the rules first, the doors after: an agent with the tools in
|
|
69
69
|
// front of it reads two pages of wiring it was told to skip before it reached
|
|
70
70
|
// the first call (round fifteen, entry 1).
|
|
71
|
-
const lines = ["# PlotCoder — for agents", "", AGENTS.lead, "", `The guide: ${AGENTS.guide}. Read it once, before your first call if you can; it is the whole and this page is its first page, and where the two differ, the guide wins. Then Call these first, below
|
|
71
|
+
const lines = ["# PlotCoder — for agents", "", AGENTS.lead, "", `The guide: ${AGENTS.guide}. Read it once, before your first call if you can; it is the whole and this page is its first page, and where the two differ, the guide wins. Then Call these first, below.`, "", "## Call these first"];
|
|
72
72
|
AGENTS.first.forEach((item, index) => lines.push(`${index + 1}. ${item.tool} — ${item.why}`));
|
|
73
73
|
lines.push("", AGENTS.firstNote);
|
|
74
74
|
lines.push("", "## Rules");
|
package/src/board/fountain.js
CHANGED
|
@@ -124,6 +124,7 @@ export function toFountain(state, options = {}) {
|
|
|
124
124
|
if (cast.length) marks.push(`with ${cast.join(", ")}`);
|
|
125
125
|
if (note.plants) marks.push("plants something to pay off later");
|
|
126
126
|
if (note.open) marks.push(`open: ${note.open}`);
|
|
127
|
+
if (note.whenOpen) marks.push(`when open: ${note.whenOpen}`);
|
|
127
128
|
const onThreads = (state.threads ?? []).filter((thread) => thread.noteIds.includes(note.id)).map((thread) => thread.name);
|
|
128
129
|
if (onThreads.length) marks.push(`thread: ${onThreads.join(", ")}`);
|
|
129
130
|
if (revisionMarksFor(marks, note)) marks.push(`changed in the ${state.revision.color} revision`);
|
package/src/board/project.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export declare const DEFAULT_PROJECT_NAME: string;
|
|
|
8
8
|
export type BoardMeta = {
|
|
9
9
|
id: string;
|
|
10
10
|
name: string;
|
|
11
|
+
/** The writer's words for why the name is not decided (R61), or empty; the name stands meanwhile. */
|
|
12
|
+
nameOpen: string;
|
|
11
13
|
createdAt: string;
|
|
12
14
|
updatedAt: string;
|
|
13
15
|
};
|
|
@@ -18,6 +20,8 @@ export type ProjectRecord = {
|
|
|
18
20
|
id: string;
|
|
19
21
|
name: string;
|
|
20
22
|
premise: string;
|
|
23
|
+
/** The writer's words for why there is no premise yet (R61), or empty. */
|
|
24
|
+
premiseOpen: string;
|
|
21
25
|
boards: BoardMeta[];
|
|
22
26
|
activeBoardId: string;
|
|
23
27
|
/** A writer's own structures, saved from a wall's beats (Roadmap 2, item 7). */
|
|
@@ -41,7 +45,7 @@ export declare function addStructure(
|
|
|
41
45
|
): { project: ProjectRecord; structure: OwnStructure };
|
|
42
46
|
export declare function removeStructure(project: ProjectRecord, id: string, now?: string): ProjectRecord;
|
|
43
47
|
|
|
44
|
-
export declare function newBoardMeta(name: string, now?: string): BoardMeta;
|
|
48
|
+
export declare function newBoardMeta(name: string, now?: string, nameOpen?: string): BoardMeta;
|
|
45
49
|
export declare function emptyProject(now?: string): ProjectRecord;
|
|
46
50
|
export declare function isProjectRecord(value: unknown): value is ProjectRecord;
|
|
47
51
|
export declare function normalizeProject(value: unknown, now?: string): ProjectRecord;
|
|
@@ -49,6 +53,7 @@ export declare function addBoard(
|
|
|
49
53
|
project: ProjectRecord,
|
|
50
54
|
name: string,
|
|
51
55
|
now?: string,
|
|
56
|
+
nameOpen?: string,
|
|
52
57
|
): { project: ProjectRecord; board: BoardMeta };
|
|
53
58
|
export declare function renameBoard(
|
|
54
59
|
project: ProjectRecord,
|
|
@@ -66,6 +71,10 @@ export declare function moveBoard(
|
|
|
66
71
|
export declare function setActiveBoard(project: ProjectRecord, id: string, now?: string): ProjectRecord;
|
|
67
72
|
export declare function renameProject(project: ProjectRecord, name: string, now?: string): ProjectRecord;
|
|
68
73
|
export declare function setPremise(project: ProjectRecord, premise: string, now?: string): ProjectRecord;
|
|
74
|
+
/** The writer's words for why there is no premise yet (R61); words clear the premise, "" takes them back. */
|
|
75
|
+
export declare function setPremiseOpen(project: ProjectRecord, words: string, now?: string): ProjectRecord;
|
|
76
|
+
/** The writer's words for why a board's name is not decided (R61); the name stands meanwhile. */
|
|
77
|
+
export declare function setBoardNameOpen(project: ProjectRecord, id: string, words: string, now?: string): ProjectRecord;
|
|
69
78
|
export declare function boardById(project: ProjectRecord, id: string): BoardMeta | null;
|
|
70
79
|
/** What a script going out is called: a named project is the title, its board beside it only when the project has several. */
|
|
71
80
|
export declare function scriptTitles(project: ProjectRecord, board: BoardMeta | null | undefined): { title: string; episode?: string };
|
package/src/board/project.js
CHANGED
|
@@ -18,8 +18,13 @@ function trimmed(value, fallback) {
|
|
|
18
18
|
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export function newBoardMeta(name, now = nowIso()) {
|
|
22
|
-
return { id: newId(), name: trimmed(name, "Board"), createdAt: now, updatedAt: now };
|
|
21
|
+
export function newBoardMeta(name, now = nowIso(), nameOpen = "") {
|
|
22
|
+
return { id: newId(), name: trimmed(name, "Board"), nameOpen: openWords(nameOpen), createdAt: now, updatedAt: now };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The writer's words for why a field is not decided (R61), one line, spaces collapsed; empty is decided or blank. */
|
|
26
|
+
function openWords(value) {
|
|
27
|
+
return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
|
|
23
28
|
}
|
|
24
29
|
|
|
25
30
|
export function emptyProject(now = nowIso()) {
|
|
@@ -29,6 +34,7 @@ export function emptyProject(now = nowIso()) {
|
|
|
29
34
|
id: newId(),
|
|
30
35
|
name: DEFAULT_PROJECT_NAME,
|
|
31
36
|
premise: "",
|
|
37
|
+
premiseOpen: "",
|
|
32
38
|
boards: [board],
|
|
33
39
|
activeBoardId: board.id,
|
|
34
40
|
createdAt: now,
|
|
@@ -60,6 +66,8 @@ export function normalizeProject(value, now = nowIso()) {
|
|
|
60
66
|
const boards = value.boards.filter(isBoardMeta).map((board) => ({
|
|
61
67
|
...board,
|
|
62
68
|
name: trimmed(board.name, "Board"),
|
|
69
|
+
// A board named before R61 has no open name; the name stands until the writer says it is not decided.
|
|
70
|
+
nameOpen: openWords(board.nameOpen),
|
|
63
71
|
createdAt: typeof board.createdAt === "string" ? board.createdAt : now,
|
|
64
72
|
updatedAt: typeof board.updatedAt === "string" ? board.updatedAt : now,
|
|
65
73
|
}));
|
|
@@ -80,6 +88,8 @@ export function normalizeProject(value, now = nowIso()) {
|
|
|
80
88
|
version: PROJECT_VERSION,
|
|
81
89
|
name: trimmed(value.name, DEFAULT_PROJECT_NAME),
|
|
82
90
|
premise: typeof value.premise === "string" ? value.premise.trim() : "",
|
|
91
|
+
// A project written before R61 has no open premise (R61).
|
|
92
|
+
premiseOpen: openWords(value.premiseOpen),
|
|
83
93
|
boards,
|
|
84
94
|
activeBoardId,
|
|
85
95
|
structures,
|
|
@@ -261,9 +271,9 @@ function touch(project, patch, now) {
|
|
|
261
271
|
}
|
|
262
272
|
|
|
263
273
|
/** Add a board after the others and open it. Returns the project and the new board. */
|
|
264
|
-
export function addBoard(project, name, now = nowIso()) {
|
|
274
|
+
export function addBoard(project, name, now = nowIso(), nameOpen = "") {
|
|
265
275
|
const fallback = `Board ${project.boards.length + 1}`;
|
|
266
|
-
const board = newBoardMeta(trimmed(name, fallback), now);
|
|
276
|
+
const board = newBoardMeta(trimmed(name, fallback), now, nameOpen);
|
|
267
277
|
return {
|
|
268
278
|
project: touch(project, { boards: [...project.boards, board], activeBoardId: board.id }, now),
|
|
269
279
|
board,
|
|
@@ -275,9 +285,26 @@ export function renameBoard(project, id, name, now = nowIso()) {
|
|
|
275
285
|
if (!next) return project;
|
|
276
286
|
let changed = false;
|
|
277
287
|
const boards = project.boards.map((board) => {
|
|
278
|
-
if (board.id !== id || board.name === next) return board;
|
|
288
|
+
if (board.id !== id || (board.name === next && !(board.nameOpen ?? ""))) return board;
|
|
279
289
|
changed = true;
|
|
280
|
-
|
|
290
|
+
// A name decides the field: the open words go (R61).
|
|
291
|
+
return { ...board, name: next, nameOpen: "", updatedAt: now };
|
|
292
|
+
});
|
|
293
|
+
return changed ? touch(project, { boards }, now) : project;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The writer's words for why a board's name is not decided (R61), or "" to
|
|
298
|
+
* take them back. The name stands as it is — "Board 1" is still what every
|
|
299
|
+
* reply calls it — but the reading lists the words and the crumb draws them.
|
|
300
|
+
*/
|
|
301
|
+
export function setBoardNameOpen(project, id, words, now = nowIso()) {
|
|
302
|
+
const next = openWords(words);
|
|
303
|
+
let changed = false;
|
|
304
|
+
const boards = project.boards.map((board) => {
|
|
305
|
+
if (board.id !== id || (board.nameOpen ?? "") === next) return board;
|
|
306
|
+
changed = true;
|
|
307
|
+
return { ...board, nameOpen: next, updatedAt: now };
|
|
281
308
|
});
|
|
282
309
|
return changed ? touch(project, { boards }, now) : project;
|
|
283
310
|
}
|
|
@@ -321,8 +348,18 @@ export function renameProject(project, name, now = nowIso()) {
|
|
|
321
348
|
|
|
322
349
|
export function setPremise(project, premise, now = nowIso()) {
|
|
323
350
|
const next = typeof premise === "string" ? premise.trim() : "";
|
|
324
|
-
|
|
325
|
-
|
|
351
|
+
// A premise decides the field: the open words go (R61); clearing it leaves them.
|
|
352
|
+
const premiseOpen = next ? "" : (project.premiseOpen ?? "");
|
|
353
|
+
if (next === project.premise && premiseOpen === (project.premiseOpen ?? "")) return project;
|
|
354
|
+
return touch(project, { premise: next, premiseOpen }, now);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** The writer's words for why there is no premise yet (R61), or "" to take them back; words clear the premise. */
|
|
358
|
+
export function setPremiseOpen(project, words, now = nowIso()) {
|
|
359
|
+
const next = openWords(words);
|
|
360
|
+
const premise = next ? "" : project.premise;
|
|
361
|
+
if (next === (project.premiseOpen ?? "") && premise === project.premise) return project;
|
|
362
|
+
return touch(project, { premise, premiseOpen: next }, now);
|
|
326
363
|
}
|
|
327
364
|
|
|
328
365
|
/**
|
package/src/board/readWall.d.ts
CHANGED
|
@@ -71,6 +71,8 @@ export type WallReading = {
|
|
|
71
71
|
later: { id: string; boardId: string; noteId: string | null }[];
|
|
72
72
|
/** Open cards (R59): the writer's words for what is not decided, in story order; not asked about while they stand. */
|
|
73
73
|
open: Array<{ id: string; words: string; hides: FindingKind[] }>;
|
|
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
|
+
openFields: Array<{ field: "logline"; words: string } | { field: "when"; id: string; words: string }>;
|
|
74
76
|
/** Threads (R60): each named string with its cards in story order and which ends are open. */
|
|
75
77
|
threads: Array<{ id: string; name: string; ids: string[]; startOpen: boolean; endOpen: boolean }>;
|
|
76
78
|
/** Cards here that pay off a fold of another board (R58), composed by the door from the project. */
|
package/src/board/readWall.js
CHANGED
|
@@ -533,8 +533,11 @@ export function readWall(state, options = {}) {
|
|
|
533
533
|
// reading does, on every read.
|
|
534
534
|
const left = [];
|
|
535
535
|
// A question whose every card is open is not asked (R59): the writer's word covers it.
|
|
536
|
-
//
|
|
537
|
-
|
|
536
|
+
// Not so for a question about the story around the card: a thread's loose end
|
|
537
|
+
// (R60) is the writer's own claim, and the run's questions — beats back to
|
|
538
|
+
// back, a sag — are about the cards between, as the guide promises (round
|
|
539
|
+
// nineteen, entry 28). Those are asked whether or not the cards are open.
|
|
540
|
+
const openCards = openIds.size ? findings.filter((finding) => !ASKED_OF_OPEN_CARDS.has(finding.kind) && finding.ids.some((id) => byId.has(id)) && finding.ids.filter((id) => byId.has(id)).every((id) => openIds.has(id))) : [];
|
|
538
541
|
const asked = findings.filter((finding) => {
|
|
539
542
|
if (openCards.includes(finding)) return false;
|
|
540
543
|
const entry = (state.left ?? []).find(
|
|
@@ -554,6 +557,7 @@ export function readWall(state, options = {}) {
|
|
|
554
557
|
later,
|
|
555
558
|
paidBy: paidHere,
|
|
556
559
|
open: describeOpen(state, options, order, openIds),
|
|
560
|
+
openFields: describeOpenFields(state, order),
|
|
557
561
|
threads,
|
|
558
562
|
findings: asked,
|
|
559
563
|
left,
|
|
@@ -565,13 +569,32 @@ export function readWall(state, options = {}) {
|
|
|
565
569
|
* closed (round eighteen, entry 27): the reading of the same wall with the
|
|
566
570
|
* words cleared, read once more, so the writer can see what the words hide.
|
|
567
571
|
*/
|
|
572
|
+
/**
|
|
573
|
+
* The fields the writer has left open (R61), in the writer's words: the
|
|
574
|
+
* board's logline, and a card's when, in story order. The premise and the
|
|
575
|
+
* board's name live on the project, and the door adds them. Listed, never
|
|
576
|
+
* asked about: no check asks about a missing logline or when.
|
|
577
|
+
*/
|
|
578
|
+
function describeOpenFields(state, order) {
|
|
579
|
+
const fields = [];
|
|
580
|
+
if ((state.loglineOpen ?? "").trim()) fields.push({ field: "logline", words: state.loglineOpen.trim() });
|
|
581
|
+
for (const note of order) if ((note.whenOpen ?? "").trim()) fields.push({ field: "when", id: note.id, words: note.whenOpen.trim() });
|
|
582
|
+
return fields;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** The question kinds an open card does not silence: about the story around it, not the card (R59, R60). */
|
|
586
|
+
const ASKED_OF_OPEN_CARDS = new Set(["loose", "empty", "sag"]);
|
|
587
|
+
|
|
568
588
|
function describeOpen(state, options, order, openIds) {
|
|
569
589
|
if (!openIds.size) return [];
|
|
570
590
|
const hides = new Map();
|
|
571
591
|
if (!options.closedReading) {
|
|
572
592
|
const closed = { ...state, notes: state.notes.map((note) => (openIds.has(note.id) ? { ...note, open: "" } : note)) };
|
|
573
593
|
const again = readWall(closed, { ...options, closedReading: true });
|
|
574
|
-
|
|
594
|
+
// A question about the story around the card is asked whether or not the
|
|
595
|
+
// card is open, so it is never something the open words hide (round
|
|
596
|
+
// nineteen, entries 23 and 28).
|
|
597
|
+
for (const finding of again.findings) if (!ASKED_OF_OPEN_CARDS.has(finding.kind)) for (const id of finding.ids) if (openIds.has(id)) (hides.get(id) ?? hides.set(id, new Set()).get(id)).add(finding.kind);
|
|
575
598
|
}
|
|
576
599
|
return order.filter((note) => openIds.has(note.id)).map((note) => ({ id: note.id, words: note.open.trim(), hides: [...(hides.get(note.id) ?? [])] }));
|
|
577
600
|
}
|
package/src/board/reducer.d.ts
CHANGED
|
@@ -89,6 +89,8 @@ export type BoardNote = {
|
|
|
89
89
|
location: string;
|
|
90
90
|
/** When the scene happens, as the writer says it — "night", "day four, dawn" — printed after the place on the heading (R55). Empty when unsaid. */
|
|
91
91
|
when: string;
|
|
92
|
+
/** The writer's words for why the when is not decided (R61), or empty; while they stand the when is blank and the reading lists them. */
|
|
93
|
+
whenOpen: string;
|
|
92
94
|
/** The scene's text in Fountain (R23 b): action, cues, dialogue; empty until written. */
|
|
93
95
|
text: string;
|
|
94
96
|
createdAt: string;
|
|
@@ -112,6 +114,8 @@ export type BoardArrow = {
|
|
|
112
114
|
export type BoardState = {
|
|
113
115
|
/** The board's central question — what this story is arguing (R19). */
|
|
114
116
|
logline: string;
|
|
117
|
+
/** The writer's words for why there is no logline yet (R61), or empty. */
|
|
118
|
+
loglineOpen: string;
|
|
115
119
|
/** Target script length in eighths of a page; 120 pages for a feature (R25). */
|
|
116
120
|
targetEighths: number;
|
|
117
121
|
/** The roster: every person in the story, whether or not they are on a card yet (R29). */
|
|
@@ -158,7 +162,7 @@ export declare function fillCharacter(character: { id: string; name: string } &
|
|
|
158
162
|
export declare function sameName(a: string, b: string): boolean;
|
|
159
163
|
|
|
160
164
|
export type Command =
|
|
161
|
-
| { type: "set_logline"; logline
|
|
165
|
+
| { type: "set_logline"; logline?: string; open?: string }
|
|
162
166
|
| { type: "set_rank"; ids: string[]; rank: NoteRank }
|
|
163
167
|
| { type: "set_length"; ids: string[]; lengthEighths: number | null }
|
|
164
168
|
| { type: "set_target"; targetEighths: number }
|
|
@@ -177,6 +181,7 @@ export type Command =
|
|
|
177
181
|
plants?: boolean;
|
|
178
182
|
location?: string;
|
|
179
183
|
when?: string;
|
|
184
|
+
whenOpen?: string;
|
|
180
185
|
open?: string;
|
|
181
186
|
text?: string;
|
|
182
187
|
}
|
|
@@ -208,7 +213,7 @@ export type Command =
|
|
|
208
213
|
| { type: "set_open"; ids: string[]; open: string }
|
|
209
214
|
| { type: "set_payoff_board"; ids: string[]; boardId: string | null; noteId?: string | null }
|
|
210
215
|
| { type: "set_location"; ids: string[]; location: string }
|
|
211
|
-
| { type: "set_when"; ids: string[]; when
|
|
216
|
+
| { type: "set_when"; ids: string[]; when?: string; open?: string }
|
|
212
217
|
| { type: "apply_template"; template: string; beats?: Array<{ name: string; prompt: string; at: number }> }
|
|
213
218
|
| { type: "set_text"; id: string; text: string }
|
|
214
219
|
| { type: "lock_numbers"; order?: string[] }
|
package/src/board/reducer.js
CHANGED
|
@@ -220,6 +220,7 @@ export function emptyState() {
|
|
|
220
220
|
return {
|
|
221
221
|
logline: "",
|
|
222
222
|
targetEighths: DEFAULT_TARGET_EIGHTHS,
|
|
223
|
+
loglineOpen: "",
|
|
223
224
|
characters: [],
|
|
224
225
|
notes: [],
|
|
225
226
|
groups: [],
|
|
@@ -247,6 +248,8 @@ export function seedState(now = nowIso()) {
|
|
|
247
248
|
characterIds,
|
|
248
249
|
location: "",
|
|
249
250
|
when: "",
|
|
251
|
+
// The writer's words for why the when is not decided (R61), or nothing.
|
|
252
|
+
whenOpen: "",
|
|
250
253
|
text: "",
|
|
251
254
|
plants: false,
|
|
252
255
|
// A fold that pays off on another board — a later episode — names it here;
|
|
@@ -267,6 +270,7 @@ export function seedState(now = nowIso()) {
|
|
|
267
270
|
// new writer finds out the logline is there at all.
|
|
268
271
|
logline: "",
|
|
269
272
|
targetEighths: DEFAULT_TARGET_EIGHTHS,
|
|
273
|
+
loglineOpen: "",
|
|
270
274
|
// Two people, cast on the cards, so a new writer sees what the roster is for.
|
|
271
275
|
characters: [
|
|
272
276
|
fillCharacter({ id: "maya", name: "Maya", createdAt: now, updatedAt: now }),
|
|
@@ -362,6 +366,8 @@ export function normalizeState(value) {
|
|
|
362
366
|
const location = typeof note?.location === "string" ? note.location : "";
|
|
363
367
|
// Cards written before R55 have no when; a scene is at no time until it is.
|
|
364
368
|
const when = typeof note?.when === "string" ? note.when : "";
|
|
369
|
+
// Cards written before R61 have no open when; a when is decided or blank until the writer says otherwise.
|
|
370
|
+
const whenOpen = typeof note?.whenOpen === "string" ? note.whenOpen : "";
|
|
365
371
|
// Cards written before pages (R23 b) have no text; a scene is unwritten until it is.
|
|
366
372
|
const text = typeof note?.text === "string" ? note.text : "";
|
|
367
373
|
if (
|
|
@@ -376,12 +382,13 @@ export function normalizeState(value) {
|
|
|
376
382
|
note.open === open &&
|
|
377
383
|
note.location === location &&
|
|
378
384
|
note.when === when &&
|
|
385
|
+
note.whenOpen === whenOpen &&
|
|
379
386
|
note.text === text
|
|
380
387
|
) {
|
|
381
388
|
return note;
|
|
382
389
|
}
|
|
383
390
|
patched = true;
|
|
384
|
-
return { ...note, rank, lengthEighths, characterIds, plants, payoffBoardId, payoffNoteId, open, location, when, text };
|
|
391
|
+
return { ...note, rank, lengthEighths, characterIds, plants, payoffBoardId, payoffNoteId, open, location, when, whenOpen, text };
|
|
385
392
|
});
|
|
386
393
|
|
|
387
394
|
// Boards written before the production half (Roadmap 2, item 8) have no
|
|
@@ -411,8 +418,11 @@ export function normalizeState(value) {
|
|
|
411
418
|
return { ...thread, noteIds, startOpen, endOpen };
|
|
412
419
|
})
|
|
413
420
|
.filter(Boolean);
|
|
421
|
+
// Boards written before R61 have no open logline; a logline is decided or blank until the writer says otherwise.
|
|
422
|
+
const loglineOpen = typeof value.loglineOpen === "string" ? value.loglineOpen : "";
|
|
414
423
|
if (
|
|
415
424
|
value.logline === logline &&
|
|
425
|
+
value.loglineOpen === loglineOpen &&
|
|
416
426
|
value.targetEighths === targetEighths &&
|
|
417
427
|
!rosterPatched &&
|
|
418
428
|
!arrowsPatched &&
|
|
@@ -427,6 +437,7 @@ export function normalizeState(value) {
|
|
|
427
437
|
return {
|
|
428
438
|
...value,
|
|
429
439
|
logline,
|
|
440
|
+
loglineOpen,
|
|
430
441
|
targetEighths,
|
|
431
442
|
characters,
|
|
432
443
|
notes: patched ? notes : value.notes,
|
|
@@ -474,10 +485,15 @@ function pruneGroups(groups) {
|
|
|
474
485
|
|
|
475
486
|
export function applyCommand(state, command, now = nowIso()) {
|
|
476
487
|
switch (command.type) {
|
|
488
|
+
// The logline, or the writer's words for why there is none yet (R61): a
|
|
489
|
+
// value clears the open words, open words clear the value, and open ""
|
|
490
|
+
// leaves the field blank.
|
|
477
491
|
case "set_logline": {
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
492
|
+
const hasOpen = typeof command.open === "string";
|
|
493
|
+
const logline = hasOpen && command.open.trim() ? "" : typeof command.logline === "string" ? command.logline.trim() : (state.logline ?? "");
|
|
494
|
+
const loglineOpen = hasOpen ? cleanOpen(command.open) : logline ? "" : (state.loglineOpen ?? "");
|
|
495
|
+
if (logline === (state.logline ?? "") && loglineOpen === (state.loglineOpen ?? "")) return { state, changed: false };
|
|
496
|
+
return { state: { ...state, logline, loglineOpen }, changed: true, result: { logline, loglineOpen } };
|
|
481
497
|
}
|
|
482
498
|
|
|
483
499
|
case "create_note": {
|
|
@@ -501,7 +517,8 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
501
517
|
payoffNoteId: null,
|
|
502
518
|
open: cleanOpen(command.open),
|
|
503
519
|
location: cleanPlace(command.location),
|
|
504
|
-
when: cleanWhen(command.when),
|
|
520
|
+
when: cleanOpen(command.whenOpen) ? "" : cleanWhen(command.when),
|
|
521
|
+
whenOpen: cleanOpen(command.whenOpen),
|
|
505
522
|
text: typeof command.text === "string" ? command.text : "",
|
|
506
523
|
z: maxZ(state.notes) + 1,
|
|
507
524
|
createdAt: now,
|
|
@@ -1015,6 +1032,7 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
1015
1032
|
open: "",
|
|
1016
1033
|
location: "",
|
|
1017
1034
|
when: "",
|
|
1035
|
+
whenOpen: "",
|
|
1018
1036
|
text: "",
|
|
1019
1037
|
createdAt: now,
|
|
1020
1038
|
updatedAt: now,
|
|
@@ -1121,11 +1139,21 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
1121
1139
|
case "set_when": {
|
|
1122
1140
|
const ids = new Set(command.ids);
|
|
1123
1141
|
if (ids.size === 0) return { state, changed: false };
|
|
1124
|
-
|
|
1142
|
+
// The when, or the writer's words for why there is none yet (R61): a
|
|
1143
|
+
// value clears the open words, open words clear the value, open "" leaves
|
|
1144
|
+
// the when blank.
|
|
1145
|
+
const hasOpen = typeof command.open === "string";
|
|
1146
|
+
const whenOpen = hasOpen ? cleanOpen(command.open) : null;
|
|
1147
|
+
const when = hasOpen ? (whenOpen ? "" : typeof command.when === "string" ? cleanWhen(command.when) : null) : cleanWhen(command.when);
|
|
1125
1148
|
const touched = [];
|
|
1126
1149
|
const notes = state.notes.map((note) => {
|
|
1127
|
-
if (!ids.has(note.id)
|
|
1128
|
-
const
|
|
1150
|
+
if (!ids.has(note.id)) return note;
|
|
1151
|
+
const patch = {
|
|
1152
|
+
when: when === null ? (note.when ?? "") : when,
|
|
1153
|
+
whenOpen: whenOpen === null ? (when ? "" : (note.whenOpen ?? "")) : whenOpen,
|
|
1154
|
+
};
|
|
1155
|
+
if (patch.when === (note.when ?? "") && patch.whenOpen === (note.whenOpen ?? "")) return note;
|
|
1156
|
+
const next = bump(note, patch, now);
|
|
1129
1157
|
touched.push(next);
|
|
1130
1158
|
return next;
|
|
1131
1159
|
});
|
package/src/board/words.js
CHANGED
|
@@ -66,7 +66,7 @@ export const WORD_GROUPS = [
|
|
|
66
66
|
{
|
|
67
67
|
id: "open",
|
|
68
68
|
name: "Open",
|
|
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.",
|
|
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. A field can be open the same way — the logline, the premise, a card's when, a board's name — the words where the value would be, listed and not asked.",
|
|
70
70
|
},
|
|
71
71
|
{
|
|
72
72
|
id: "thread",
|
package/src/board/workflows.js
CHANGED
|
@@ -24,15 +24,15 @@ export const WORKFLOWS = [
|
|
|
24
24
|
// an agent asks the ones the treatment leaves open, and invents none.
|
|
25
25
|
needs: [
|
|
26
26
|
{ question: "How long is it?", hint: "An hour, a half-hour, a feature — or a page count, if you have one.", tool: "set_target" },
|
|
27
|
-
{ question: "What is the central question, in one sentence?", hint: "And if this is one episode of something, what is the series about?", tool: "set_logline, set_premise" },
|
|
28
|
-
{ question: "Which scenes are the turns?", hint: "Name them,
|
|
27
|
+
{ question: "What is the central question, in one sentence?", hint: "And if this is one episode of something, what is the series about? Not decided: either takes open with the writer's words, and the reading lists it.", tool: "set_logline, set_premise" },
|
|
28
|
+
{ question: "Which scenes are the turns?", hint: "Name them, say \"propose them and I will strike\", or say \"mark none yet\" — every card stays a scene and the reading asks once for a beat until you do.", tool: "set_rank" },
|
|
29
29
|
{ question: "Does it have acts?", hint: "If so, where does each break fall?", tool: "create_group" },
|
|
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
|
-
{ 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" },
|
|
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. Not decided: set_when with open and the writer's words, and the card is still asked about the rest.", 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
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
|
-
{ question: "What are the project and the board called?", hint: "The series, and this episode.", tool: "rename_project, rename_board" },
|
|
35
|
+
{ question: "What are the project and the board called?", hint: "The series, and this episode. A board's name not decided: rename_board, new_project and new_board take open with the writer's words.", 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" },
|
|
37
37
|
],
|
|
38
38
|
},
|