plotcoder-board 0.1.11 → 0.1.13
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/package.json +1 -1
- package/scripts/plotcoder-mcp-server.mjs +129 -56
- package/src/board/agents.js +4 -4
- package/src/board/readWall.d.ts +2 -0
- package/src/board/readWall.js +9 -2
- package/src/board/reducer.d.ts +3 -0
- package/src/board/reducer.js +35 -4
- package/src/board/workflows.d.ts +1 -1
- package/src/board/workflows.js +9 -3
package/package.json
CHANGED
|
@@ -52,6 +52,7 @@ import { DEFAULT_REMINDERS, titleFromBody } from "../src/board/reminders.js";
|
|
|
52
52
|
import crypto from "node:crypto";
|
|
53
53
|
import { describeRuns, describeSetups, readWall } from "../src/board/readWall.js";
|
|
54
54
|
import { GAP, ROW_WIDTH, organizePoses } from "../src/board/organize.js";
|
|
55
|
+
import { sceneLineCount } from "../src/board/paginate.js";
|
|
55
56
|
import {
|
|
56
57
|
addBoard,
|
|
57
58
|
addStructure,
|
|
@@ -434,14 +435,16 @@ async function accountReadProject() {
|
|
|
434
435
|
const { data, error } = await accountDoor.client.from("projects").select("id, record, reminders, rev").eq("id", accountDoor.projectId).maybeSingle();
|
|
435
436
|
if (error || !data || !isProjectRecord(data.record)) throw new Error(error?.message ?? "the project is gone from the account");
|
|
436
437
|
const project = normalizeProject(data.record);
|
|
437
|
-
const rows = await accountDoor.client.from("boards").select("id, state, rev").eq("project_id", project.id);
|
|
438
|
+
const rows = await accountDoor.client.from("boards").select("id, state, rev, updated_at").eq("project_id", project.id);
|
|
438
439
|
const boards = {};
|
|
439
440
|
const revs = {};
|
|
441
|
+
const changedAt = {};
|
|
440
442
|
for (const row of rows.data ?? []) {
|
|
441
443
|
if (isBoardState(row.state)) boards[row.id] = normalizeState(row.state);
|
|
442
444
|
revs[row.id] = row.rev;
|
|
445
|
+
if (row.updated_at) changedAt[row.id] = row.updated_at;
|
|
443
446
|
}
|
|
444
|
-
return { project, boards, revs, reminders: Array.isArray(data.reminders) ? data.reminders : null, rev: data.rev, base: ACCOUNT, live: ACCOUNT };
|
|
447
|
+
return { project, boards, revs, changedAt, reminders: Array.isArray(data.reminders) ? data.reminders : null, rev: data.rev, base: ACCOUNT, live: ACCOUNT };
|
|
445
448
|
}
|
|
446
449
|
|
|
447
450
|
async function accountWriteProject(project, boards, rev, reminders) {
|
|
@@ -786,7 +789,7 @@ const CHECK_WORDS = {
|
|
|
786
789
|
unwritten: "no card without a headline or change line",
|
|
787
790
|
unlinked: "no card without an arrow",
|
|
788
791
|
duplicate: "no two headlines alike",
|
|
789
|
-
sequence: "no group too long for one sequence",
|
|
792
|
+
sequence: "no group too long for one sequence (act groups are not asked)",
|
|
790
793
|
uncast: "nobody in the cast on no card",
|
|
791
794
|
absent: "nobody gone for a third of the story",
|
|
792
795
|
backwards: "no payoff before its setup",
|
|
@@ -803,11 +806,13 @@ function summarize(state) {
|
|
|
803
806
|
.map((note) => {
|
|
804
807
|
const cast = note.characterIds.map((id) => nameOf.get(id) ?? id);
|
|
805
808
|
const who = cast.length ? `, cast: ${cast.join(", ")}` : "";
|
|
806
|
-
const plant = note.plants ? ", plants" : "";
|
|
809
|
+
const plant = note.plants ? (note.payoffBoardId ? ", plants → pays off later" : ", plants") : "";
|
|
810
|
+
const snap = state.revision?.snapshot?.[note.id];
|
|
811
|
+
const revised = snap && (snap.headline !== note.headline || snap.change !== note.change || (snap.text ?? "") !== (note.text ?? "") || (snap.location ?? "") !== (note.location ?? "")) ? `, changed in ${state.revision.color}` : "";
|
|
807
812
|
const place = note.location ? `, at: ${note.location}` : "";
|
|
808
813
|
const count = formatPages(noteEighths(note));
|
|
809
|
-
const pages = `${count} ${count === "1" ? "page" : "pages"}
|
|
810
|
-
return ` - ${note.id} [${note.rank ?? "scene"}, ${pages}${who}${place}${plant}] — "${note.headline}" (${note.color}) at ${Math.round(note.x)},${Math.round(note.y)}`;
|
|
814
|
+
const pages = isMeasured(note) ? `${count} ${count === "1" ? "page" : "pages"}, written` : note.lengthEighths === null ? "about a page, unsized" : `${count} ${count === "1" ? "page" : "pages"}`;
|
|
815
|
+
return ` - ${note.id} [${note.rank ?? "scene"}, ${pages}${who}${place}${plant}${revised}] — "${note.headline}" (${note.color}) at ${Math.round(note.x)},${Math.round(note.y)}`;
|
|
811
816
|
})
|
|
812
817
|
.join("\n");
|
|
813
818
|
const cast = state.characters
|
|
@@ -841,7 +846,9 @@ function summarize(state) {
|
|
|
841
846
|
` - ${group.id} — "${group.title}" holds ${group.noteIds.length}: ${group.noteIds.join(", ")}`,
|
|
842
847
|
)
|
|
843
848
|
.join("\n");
|
|
844
|
-
const
|
|
849
|
+
const storyIndex = new Map(readingOrder(state.notes).map((note, index) => [note.id, index]));
|
|
850
|
+
const arrows = [...state.arrows]
|
|
851
|
+
.sort((a, b) => (storyIndex.get(a.from) ?? Infinity) - (storyIndex.get(b.from) ?? Infinity) || (a.kind === "setup") - (b.kind === "setup"))
|
|
845
852
|
.map(
|
|
846
853
|
(arrow) =>
|
|
847
854
|
` - ${arrow.id} [${arrow.kind ?? "follows"}] — ${arrow.from} → ${arrow.to} ("${headline(arrow.from)}" ${arrow.kind === "setup" ? "sets up" : "→"} "${headline(arrow.to)}")`,
|
|
@@ -931,7 +938,7 @@ server.registerTool(
|
|
|
931
938
|
: "board";
|
|
932
939
|
return ok(
|
|
933
940
|
`PlotCoder ${which} (${door(live, base)})\n${summarize(state)}`,
|
|
934
|
-
state,
|
|
941
|
+
{ ...state, revision: state.revision ? { name: state.revision.name, color: state.revision.color, since: state.revision.since } : null, notes: state.notes.map((note) => ({ ...note, eighths: noteEighths(note), measured: isMeasured(note) })) },
|
|
935
942
|
);
|
|
936
943
|
},
|
|
937
944
|
);
|
|
@@ -976,7 +983,7 @@ server.registerTool(
|
|
|
976
983
|
});
|
|
977
984
|
const { beats, scenes } = countRanks(state);
|
|
978
985
|
return ok(
|
|
979
|
-
`${result?.length ?? 0} card(s) are now ${args.rank}${where(live)}. The board holds ${beats} beats and ${scenes} scenes.`,
|
|
986
|
+
`${result?.length ?? 0} card(s) are now ${args.rank}${where(live)}. The board holds ${beats} beats and ${scenes} scenes. The rows are as they were; organize lays a row per beat.`,
|
|
980
987
|
result,
|
|
981
988
|
);
|
|
982
989
|
},
|
|
@@ -1000,7 +1007,7 @@ server.registerTool(
|
|
|
1000
1007
|
lengthEighths: toEighths(args.pages),
|
|
1001
1008
|
});
|
|
1002
1009
|
return ok(
|
|
1003
|
-
`${result?.length ?? 0} card(s) now run
|
|
1010
|
+
`${result?.length ?? 0} card(s) now run ${args.pages} page(s), the writer's estimate${where(live)}. The board runs about ${formatPages(boardEighths(state))} pages against a ${formatPages(state.targetEighths)}-page target.`,
|
|
1004
1011
|
result,
|
|
1005
1012
|
);
|
|
1006
1013
|
},
|
|
@@ -1112,7 +1119,7 @@ server.registerTool(
|
|
|
1112
1119
|
},
|
|
1113
1120
|
},
|
|
1114
1121
|
async (args) => {
|
|
1115
|
-
const { result } = await commit({
|
|
1122
|
+
const { state, result, live } = await commit({
|
|
1116
1123
|
type: "update_note",
|
|
1117
1124
|
id: args.id,
|
|
1118
1125
|
headline: args.headline,
|
|
@@ -1120,7 +1127,8 @@ server.registerTool(
|
|
|
1120
1127
|
location: args.location,
|
|
1121
1128
|
});
|
|
1122
1129
|
if (result === undefined) return ok(`No card with id ${args.id}.`);
|
|
1123
|
-
|
|
1130
|
+
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}".` : "";
|
|
1131
|
+
return ok(`Updated "${result.headline}"${args.change !== undefined ? `: change line "${result.change}"` : ""}${where(live)}.${mark}`, result);
|
|
1124
1132
|
},
|
|
1125
1133
|
);
|
|
1126
1134
|
|
|
@@ -1184,7 +1192,9 @@ server.registerTool(
|
|
|
1184
1192
|
inputSchema: {},
|
|
1185
1193
|
},
|
|
1186
1194
|
async () => {
|
|
1187
|
-
const { state, live, base } = await readBoard();
|
|
1195
|
+
const { state, live, base, boardId: readBoardId } = await readBoard();
|
|
1196
|
+
const { project: projectForRead } = await readProject();
|
|
1197
|
+
const readBoardMeta = boardById(projectForRead, readBoardId ?? projectForRead.activeBoardId);
|
|
1188
1198
|
const reading = readWall(state);
|
|
1189
1199
|
const runs = describeRuns(reading, state).map((line, index) => {
|
|
1190
1200
|
const ids = reading.runs[index]?.ids ?? [];
|
|
@@ -1194,6 +1204,8 @@ server.registerTool(
|
|
|
1194
1204
|
const written = state.notes.filter((note) => isMeasured(note)).length;
|
|
1195
1205
|
const lines = [
|
|
1196
1206
|
`PlotCoder wall (${door(live, base)})`,
|
|
1207
|
+
...(state.lock ? [`numbers: locked since ${String(state.lock.at).slice(0, 10)}; read_pages shows each scene's number`] : []),
|
|
1208
|
+
`board: "${readBoardMeta?.name ?? "?"}"${projectForRead.boards.length > 1 ? ` — ${projectForRead.boards.length} boards in "${projectForRead.name}"; open_board reads another` : ""}`,
|
|
1197
1209
|
`logline: ${state.logline ? `"${state.logline}"` : "(none yet)"}`,
|
|
1198
1210
|
"the cast and the places are list_board's, not the reading's",
|
|
1199
1211
|
state.targetEighths === DEFAULT_TARGET_EIGHTHS
|
|
@@ -1222,17 +1234,23 @@ server.registerTool(
|
|
|
1222
1234
|
...(reading.setups.length
|
|
1223
1235
|
? describeSetups(reading, state).map((line) => ` - ${line}`)
|
|
1224
1236
|
: [" (no arrow is marked as a setup)"]),
|
|
1237
|
+
...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}"`),
|
|
1225
1238
|
"questions the wall raises:",
|
|
1226
1239
|
...(reading.findings.length
|
|
1227
1240
|
? reading.findings.map((finding) => ` - [${finding.kind}] ${finding.text}${finding.ids.length ? ` (ids: ${finding.ids.join(", ")})` : ""}`)
|
|
1228
1241
|
: [" (none that this reading can see)"]),
|
|
1229
1242
|
`checks: ${CHECKS.length} run — ${(() => {
|
|
1230
|
-
const asked = reading.findings
|
|
1243
|
+
const asked = reading.findings;
|
|
1231
1244
|
if (asked.length === 0) return "asking nothing";
|
|
1232
1245
|
const counts = new Map();
|
|
1233
1246
|
for (const finding of asked) counts.set(finding.kind, (counts.get(finding.kind) ?? 0) + 1);
|
|
1234
1247
|
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(", ")}`;
|
|
1235
|
-
})()}; checked and clean: ${CHECKS.filter((kind) => !reading.findings.some((finding) => finding.kind === kind)).map((kind) =>
|
|
1248
|
+
})()}; checked and clean: ${CHECKS.filter((kind) => !reading.findings.some((finding) => finding.kind === kind)).map((kind) => {
|
|
1249
|
+
if (kind === "unlinked" && state.arrows.length === 0) return "no card without an arrow (not asked: no arrows yet)";
|
|
1250
|
+
if (kind === "unplaced" && !state.notes.some((note) => (note.location ?? "").trim())) return "no card without a place (not asked: no card placed yet)";
|
|
1251
|
+
if (kind === "sequence" && state.groups.length === 0) return "no group too long for one sequence (not asked: no groups)";
|
|
1252
|
+
return CHECK_WORDS[kind];
|
|
1253
|
+
}).join("; ") || "(nothing — every check found something)"}`,
|
|
1236
1254
|
];
|
|
1237
1255
|
if (isSampleWall(state)) lines.unshift(SAMPLE_NOTE);
|
|
1238
1256
|
return ok(lines.join("\n"), { ...reading, sample: isSampleWall(state) });
|
|
@@ -1301,8 +1319,10 @@ server.registerTool(
|
|
|
1301
1319
|
trail.push({ before: state, after: canon(final), what: `move_scene "${card.headline}"` });
|
|
1302
1320
|
undone.length = 0;
|
|
1303
1321
|
const order = readingOrder(final.notes);
|
|
1322
|
+
const group = final.groups.find((item) => item.noteIds.includes(card.id));
|
|
1323
|
+
const groupLine = group ? ` It is still in "${group.title || "an untitled group"}"; a frame does not follow a move, so say if the act or sequence should change.` : "";
|
|
1304
1324
|
return ok(
|
|
1305
|
-
`Moved "${card.headline}" to ${args.after ? "after" : "before"} "${target.headline}": ${removed} arrow(s) removed, ${drawn} drawn, the wall tidied along them${where(live)}. Story order now: ${order.map((note, index) => `${index + 1}. ${note.headline}`).join(", ")}
|
|
1325
|
+
`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} One undo takes the whole move back.`,
|
|
1306
1326
|
{ order: order.map((note) => note.id) },
|
|
1307
1327
|
);
|
|
1308
1328
|
},
|
|
@@ -1390,10 +1410,9 @@ server.registerTool(
|
|
|
1390
1410
|
const { project } = await readProject();
|
|
1391
1411
|
const own = project.structures ?? [];
|
|
1392
1412
|
const lines = [
|
|
1393
|
-
`built in: ${TEMPLATES.length}`,
|
|
1394
|
-
...TEMPLATES.map((template) => ` - ${template.id} — "${template.name}" (${template.beats.length} beats)`),
|
|
1395
1413
|
`the writer's own: ${own.length}`,
|
|
1396
|
-
...own.map((structure) => ` - ${structure.id} — "${structure.name}" (${structure.beats.length} beats: ${structure.beats.map((beat) => beat.name).join(", ")})`),
|
|
1414
|
+
...own.map((structure) => ` - ${structure.id} — "${structure.name}" (${structure.beats.length} beats: ${structure.beats.map((beat) => `${beat.name} at ${Math.round(beat.at * 100)}%`).join(", ")})`),
|
|
1415
|
+
`built in: ${TEMPLATES.length} — ${TEMPLATES.map((template) => `${template.id} "${template.name}" (${template.beats.length} beats)`).join(", ")}; each beat's name, prompt and place in the story are in the JSON`,
|
|
1397
1416
|
];
|
|
1398
1417
|
return ok(lines.join("\n"), { builtIn: TEMPLATES.map((template) => ({ id: template.id, name: template.name, beats: template.beats })), own });
|
|
1399
1418
|
},
|
|
@@ -1415,7 +1434,7 @@ server.registerTool(
|
|
|
1415
1434
|
const { project, boards, rev, base, live } = await readProject();
|
|
1416
1435
|
const { project: next, structure } = addStructure(project, args.name, beats);
|
|
1417
1436
|
await writeProject(next, boards, rev, base);
|
|
1418
|
-
return ok(`Saved "${structure.name}" with ${beats.length} beats${where(live)}: ${beats.map((beat) => beat.name).join(", ")}.`, structure);
|
|
1437
|
+
return ok(`Saved "${structure.name}" with ${beats.length} beats${where(live)}: ${beats.map((beat) => `${beat.name} at ${Math.round(beat.at * 100)}%`).join(", ")}. Each beat's prompt is its change line here; apply_template lays the same turns on another board, the next episode's, as beat cards to fill.`, structure);
|
|
1419
1438
|
},
|
|
1420
1439
|
);
|
|
1421
1440
|
|
|
@@ -1467,7 +1486,7 @@ server.registerTool(
|
|
|
1467
1486
|
{
|
|
1468
1487
|
title: "Write a scene",
|
|
1469
1488
|
description:
|
|
1470
|
-
"Write a card's scene text in Fountain — action, character cues in capitals, dialogue under them — onto the card by id. The card is then measured (its lines against a page) instead of estimated. An empty string clears it. Read read_pages first so the scene fits what is around it, and do not write scenes the writer has not asked for.",
|
|
1489
|
+
"Write a card's scene text in Fountain — action, character cues in capitals, dialogue under them — onto the card by id; the scene heading comes from the card's place, so start with the action. The card is then measured (its lines as they print against a 55-line page) instead of estimated. An empty string clears it. Read read_pages first so the scene fits what is around it, and do not write scenes the writer has not asked for.",
|
|
1471
1490
|
inputSchema: { id: z.string(), text: z.string() },
|
|
1472
1491
|
},
|
|
1473
1492
|
async (args) => {
|
|
@@ -1476,10 +1495,10 @@ server.registerTool(
|
|
|
1476
1495
|
if (!result) return ok(`No card with id ${args.id}. Call list_board.`);
|
|
1477
1496
|
return ok(`Nothing changed: "${result.headline}" already reads that way.`);
|
|
1478
1497
|
}
|
|
1479
|
-
const
|
|
1498
|
+
const printed = sceneLineCount(args.text);
|
|
1480
1499
|
return ok(
|
|
1481
|
-
`Wrote "${result.headline}": ${
|
|
1482
|
-
result,
|
|
1500
|
+
`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${where(live)}. The heading comes from the card's place, so the text starts with the action. While the text stands the card is measured, not estimated; every reading uses the measure (eighths here), and the estimate underneath (lengthEighths) is untouched.`,
|
|
1501
|
+
{ ...result, eighths: noteEighths(result), measured: true, printedLines: printed },
|
|
1483
1502
|
);
|
|
1484
1503
|
},
|
|
1485
1504
|
);
|
|
@@ -1499,13 +1518,16 @@ server.registerTool(
|
|
|
1499
1518
|
const text = toFountain(state, { title: board?.name, premise: project.premise || undefined });
|
|
1500
1519
|
const parsed = fromFountain(text);
|
|
1501
1520
|
const ids = mergeFountain(state, parsed).matched.map((item) => item.id);
|
|
1521
|
+
const pageNumbers = state.lock ? sceneNumbers(readingOrder(state.notes), state.lock) : null;
|
|
1502
1522
|
const lines = [];
|
|
1503
1523
|
let index = 0;
|
|
1504
1524
|
for (const line of text.split("\n")) {
|
|
1505
1525
|
if (/^\.(?!\.)/.test(line) && index < ids.length) {
|
|
1506
1526
|
const note = state.notes.find((item) => item.id === ids[index]);
|
|
1507
1527
|
index += 1;
|
|
1508
|
-
|
|
1528
|
+
const standIn = note && !(note.location ?? "").trim() ? " · no place: the headline stands in for the heading" : "";
|
|
1529
|
+
const numbered = note && pageNumbers?.get(note.id) ? ` · locked no. ${pageNumbers.get(note.id)}` : "";
|
|
1530
|
+
lines.push(`${line} [[id: ${note?.id ?? "?"} · ${note && isMeasured(note) ? "measured" : "estimated"} ${formatPages(note ? noteEighths(note) : 0)}pp${standIn}${numbered}]]`);
|
|
1509
1531
|
} else {
|
|
1510
1532
|
lines.push(line);
|
|
1511
1533
|
}
|
|
@@ -1583,7 +1605,7 @@ server.registerTool(
|
|
|
1583
1605
|
const { state } = await readBoard();
|
|
1584
1606
|
const { project } = await readProject();
|
|
1585
1607
|
const board = project.boards.find((item) => item.id === project.activeBoardId);
|
|
1586
|
-
const brief = segmentBrief(state, args.ids, { title: board?.name });
|
|
1608
|
+
const brief = segmentBrief(state, args.ids, { title: board?.name, boards: project.boards });
|
|
1587
1609
|
if (!brief) return ok(`No cards with ids ${args.ids.join(", ")}. Call list_board.`);
|
|
1588
1610
|
return ok(brief);
|
|
1589
1611
|
},
|
|
@@ -1662,7 +1684,7 @@ server.registerTool(
|
|
|
1662
1684
|
const note = unwritten
|
|
1663
1685
|
? [`${unwritten} of ${order.length} scenes are unwritten and count as one line each here, so this is the script so far, not the runtime: the estimate from the cards is about ${formatPages(boardEighths(state))} pages, the number to use until the scenes are written.`]
|
|
1664
1686
|
: [];
|
|
1665
|
-
return ok([`pages: ${result.pageCount} of ${Math.round(state.targetEighths / 8)}`, ...note, ...lines].join("\n"), result.scenes);
|
|
1687
|
+
return ok([`pages: ${result.pageCount} of ${Math.round(state.targetEighths / 8)}`, `scene numbers here are ${state.lock ? "the locked numbers" : "wall order (not locked)"}`, ...note, ...lines].join("\n"), result.scenes);
|
|
1666
1688
|
},
|
|
1667
1689
|
);
|
|
1668
1690
|
|
|
@@ -1687,8 +1709,10 @@ server.registerTool(
|
|
|
1687
1709
|
"unlock_numbers",
|
|
1688
1710
|
{ title: "Unlock the scene numbers", description: "Numbers follow the wall's order again.", inputSchema: {} },
|
|
1689
1711
|
async () => {
|
|
1712
|
+
const { state: before } = await readBoard();
|
|
1713
|
+
|
|
1690
1714
|
const { changed, live } = await commit({ type: "unlock_numbers" });
|
|
1691
|
-
return ok(changed ? `Unlocked${where(live)}.` : "The numbers were not locked.");
|
|
1715
|
+
return ok(changed ? `Unlocked ${Object.keys(before.lock?.numbers ?? {}).length} scene number(s)${where(live)}; scenes number by wall order again.` : "The numbers were not locked.");
|
|
1692
1716
|
},
|
|
1693
1717
|
);
|
|
1694
1718
|
|
|
@@ -1698,11 +1722,13 @@ server.registerTool(
|
|
|
1698
1722
|
title: "Start a revision",
|
|
1699
1723
|
description:
|
|
1700
1724
|
`Name a revision and give it one of the industry's colours (${REVISION_COLORS.join(", ")}). Every card is snapshotted; from then on a changed line prints in the colour with a star in the margin, and a changed card wears the colour on the wall.`,
|
|
1701
|
-
inputSchema: { name: z.string().
|
|
1725
|
+
inputSchema: { name: z.string().optional(), color: z.string().optional() },
|
|
1702
1726
|
},
|
|
1703
1727
|
async (args) => {
|
|
1704
|
-
const
|
|
1705
|
-
if (!
|
|
1728
|
+
const revisionName = (args.name ?? "").trim() || (args.color ? `${args.color.charAt(0).toUpperCase()}${args.color.slice(1)}` : "");
|
|
1729
|
+
if (!revisionName) return ok("No revision started: give it a name, or a colour to name it after.");
|
|
1730
|
+
const { changed, result, live } = await commit({ type: "start_revision", name: revisionName, color: args.color });
|
|
1731
|
+
if (!changed) return ok("No revision started: one is already in progress; end_revision first.");
|
|
1706
1732
|
return ok(`Started the ${result.color} revision "${result.name}"${where(live)}.`, { name: result.name, color: result.color, since: result.since });
|
|
1707
1733
|
},
|
|
1708
1734
|
);
|
|
@@ -1711,8 +1737,10 @@ server.registerTool(
|
|
|
1711
1737
|
"end_revision",
|
|
1712
1738
|
{ title: "End the revision", description: "The marks come off; the snapshot is dropped.", inputSchema: {} },
|
|
1713
1739
|
async () => {
|
|
1740
|
+
const { state: before } = await readBoard();
|
|
1741
|
+
|
|
1714
1742
|
const { changed, live } = await commit({ type: "end_revision" });
|
|
1715
|
-
return ok(changed ? `Revision ended${where(live)}.` : "No revision in progress.");
|
|
1743
|
+
return ok(changed ? `Revision "${before.revision?.name ?? ""}" (${before.revision?.color ?? ""}) ended${where(live)}: its marks come off and its snapshot is dropped.` : "No revision in progress.");
|
|
1716
1744
|
},
|
|
1717
1745
|
);
|
|
1718
1746
|
|
|
@@ -1730,7 +1758,7 @@ server.registerTool(
|
|
|
1730
1758
|
const { state } = await readBoard();
|
|
1731
1759
|
const { project } = await readProject();
|
|
1732
1760
|
const board = project.boards.find((item) => item.id === project.activeBoardId);
|
|
1733
|
-
const brief = segmentBrief(state, args.ids, { title: board?.name });
|
|
1761
|
+
const brief = segmentBrief(state, args.ids, { title: board?.name, boards: project.boards });
|
|
1734
1762
|
if (!brief) return ok(`No cards with ids ${args.ids.join(", ")}. Call list_board.`);
|
|
1735
1763
|
const provider = env.PLOTCODER_VIDEO_PROVIDER;
|
|
1736
1764
|
if (!provider) {
|
|
@@ -1889,10 +1917,11 @@ server.registerTool(
|
|
|
1889
1917
|
undone.push(last);
|
|
1890
1918
|
const { boardId } = await readBoard();
|
|
1891
1919
|
const live = await writeBoard(last.before, rev, base, boardId);
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1920
|
+
const orderLine = /^(move_scene|organize)/.test(last.what) ? ` Story order now: ${readingOrder(last.before.notes).map((note, index) => `${index + 1}. ${note.headline}`).join(", ")}.` : "";
|
|
1921
|
+
const cardsDiff = last.before.notes.length - state.notes.length;
|
|
1922
|
+
const countLine = cardsDiff > 0 ? ` ${cardsDiff} card(s) back.` : cardsDiff < 0 ? ` ${-cardsDiff} card(s) gone.` : "";
|
|
1923
|
+
const more = trail.length >= TRAIL_CAP ? `${trail.length} more of mine can be undone — the most I keep, so the oldest have gone` : `${trail.length} more of mine can be undone`;
|
|
1924
|
+
return ok(`Undid ${last.what}${where(live)}.${orderLine}${countLine} ${more}. list_board has the board.`, { undid: last.what, notes: last.before.notes.length, arrows: last.before.arrows.length, groups: last.before.groups.length });
|
|
1896
1925
|
},
|
|
1897
1926
|
);
|
|
1898
1927
|
|
|
@@ -1915,7 +1944,7 @@ server.registerTool(
|
|
|
1915
1944
|
const after = normalizeState(JSON.parse(last.after));
|
|
1916
1945
|
const live = await writeBoard(after, rev, base, boardId);
|
|
1917
1946
|
trail.push(last);
|
|
1918
|
-
return ok(`Redid ${last.what}${where(live)}. ${undone.length} more can be redone.`, after);
|
|
1947
|
+
return ok(`Redid ${last.what}${where(live)}. ${undone.length} more can be redone. list_board has the board.`, { redid: last.what, notes: after.notes.length, arrows: after.arrows.length, groups: after.groups.length });
|
|
1919
1948
|
},
|
|
1920
1949
|
);
|
|
1921
1950
|
|
|
@@ -1924,23 +1953,52 @@ server.registerTool(
|
|
|
1924
1953
|
{
|
|
1925
1954
|
title: "Fold the corner",
|
|
1926
1955
|
description:
|
|
1927
|
-
`Fold the corner of cards — mark them as planting something — or unfold them. ${wordSentence("corner")} The setup arrow is create_arrow with kind 'setup'. Folding never moves a card.`,
|
|
1956
|
+
`Fold the corner of cards — mark them as planting something — or unfold them. ${wordSentence("corner")} The setup arrow is create_arrow with kind 'setup'. A fold that pays off in a later episode: pass later, another board of the project by name, id or number — a board that exists; new_board makes one — and the wall stops asking where it comes back, listing the card under the reading's 'later' instead of 'payoffs'; later '' forgets it. Folding never moves a card.`,
|
|
1928
1957
|
inputSchema: {
|
|
1929
1958
|
ids: z.array(z.string()).min(1),
|
|
1930
1959
|
plants: z.boolean(),
|
|
1960
|
+
later: z.string().optional(),
|
|
1931
1961
|
},
|
|
1932
1962
|
},
|
|
1933
1963
|
async (args) => {
|
|
1934
|
-
|
|
1964
|
+
let { result, live, changed } = await commit({
|
|
1935
1965
|
type: "set_plant",
|
|
1936
1966
|
ids: args.ids,
|
|
1937
1967
|
plants: args.plants,
|
|
1938
1968
|
});
|
|
1969
|
+
let laterLine = "";
|
|
1970
|
+
if (args.plants && args.later !== undefined) {
|
|
1971
|
+
// A series plant (R50): the fold pays off on another board of the project.
|
|
1972
|
+
// The kernel cannot check the board exists; this door can.
|
|
1973
|
+
const { project } = await readProject();
|
|
1974
|
+
const { state: now, boardId: current } = await readBoard();
|
|
1975
|
+
const here = now.notes.filter((note) => args.ids.includes(note.id));
|
|
1976
|
+
if (args.later.trim() === "") {
|
|
1977
|
+
const cleared = await commit({ type: "set_payoff_board", ids: args.ids, boardId: null });
|
|
1978
|
+
if (cleared.changed) {
|
|
1979
|
+
result = cleared.result;
|
|
1980
|
+
live = cleared.live;
|
|
1981
|
+
changed = true;
|
|
1982
|
+
laterLine = " The board it paid off on is forgotten; read_wall asks again until a setup arrow or a board pays it off.";
|
|
1983
|
+
}
|
|
1984
|
+
} else {
|
|
1985
|
+
const target = findBoard(project, args.later);
|
|
1986
|
+
if (!target) return ok(`No board called "${args.later}" yet. A fold pays off later on a board of the project: new_board "${args.later}" makes it (empty), open_board back to this one, then set_plant again with later.`);
|
|
1987
|
+
if (target.id === (current ?? project.activeBoardId)) return ok(`"${target.name}" is this board. A payoff on the same board is a setup arrow: create_arrow from the fold to the scene, kind 'setup'.`);
|
|
1988
|
+
const named = await commit({ type: "set_payoff_board", ids: args.ids, boardId: target.id });
|
|
1989
|
+
if (named.changed) {
|
|
1990
|
+
result = named.result;
|
|
1991
|
+
live = named.live;
|
|
1992
|
+
changed = true;
|
|
1993
|
+
}
|
|
1994
|
+
laterLine = ` ${here.length} card(s) pay off later, on "${target.name}": read_wall stops asking where they come back, and the card says so.`;
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1939
1997
|
const count = result?.length ?? 0;
|
|
1940
|
-
if (count === 0) return ok("No change: those cards were already that way, or the ids are not on the board.");
|
|
1998
|
+
if (!changed || count === 0) return ok("No change: those cards were already that way, or the ids are not on the board.");
|
|
1941
1999
|
return ok(
|
|
1942
2000
|
args.plants
|
|
1943
|
-
? `${count} card(s) now plant something${where(live)}
|
|
2001
|
+
? `${count} card(s) now plant something${where(live)}.${laterLine || " read_wall will ask about each until a setup arrow pays it off, or later names the board it pays off on."}`
|
|
1944
2002
|
: `${count} card(s) no longer marked as planting${where(live)}.`,
|
|
1945
2003
|
result,
|
|
1946
2004
|
);
|
|
@@ -1977,6 +2035,7 @@ server.registerTool(
|
|
|
1977
2035
|
inputSchema: { id: z.string(), name: z.string().min(1) },
|
|
1978
2036
|
},
|
|
1979
2037
|
async (args) => {
|
|
2038
|
+
const { state: before } = await readBoard();
|
|
1980
2039
|
const { state, changed, result, live } = await commit({
|
|
1981
2040
|
type: "rename_character",
|
|
1982
2041
|
id: args.id,
|
|
@@ -1989,7 +2048,10 @@ server.registerTool(
|
|
|
1989
2048
|
: ok(`No character with id ${args.id}. Call list_board for the cast.`);
|
|
1990
2049
|
}
|
|
1991
2050
|
const followed = state.notes.filter((note) => note.characterIds.includes(result.id)).length;
|
|
1992
|
-
|
|
2051
|
+
const oldName = (before.characters.find((item) => item.id === result.id)?.name ?? "").trim();
|
|
2052
|
+
const stale = oldName ? CHARACTER_FIELDS.filter((field) => (result[field] ?? "").toLowerCase().includes(oldName.toLowerCase())) : [];
|
|
2053
|
+
const staleLine = stale.length ? ` The page's ${stale.join(", ")} still mention${stale.length === 1 ? "s" : ""} "${oldName}"; the page is untouched.` : "";
|
|
2054
|
+
return ok(`Renamed to "${result.name}" (${result.id})${where(live)}; the name changed on ${followed} card${followed === 1 ? "" : "s"}.${staleLine}`, result);
|
|
1993
2055
|
},
|
|
1994
2056
|
);
|
|
1995
2057
|
|
|
@@ -2238,6 +2300,7 @@ server.registerTool(
|
|
|
2238
2300
|
inputSchema: { from: z.string(), to: z.string(), kind: arrowKindSchema.optional() },
|
|
2239
2301
|
},
|
|
2240
2302
|
async (args) => {
|
|
2303
|
+
const { boardId } = await readBoard();
|
|
2241
2304
|
const { state, changed, result, live } = await commit({
|
|
2242
2305
|
type: "create_arrow",
|
|
2243
2306
|
from: args.from,
|
|
@@ -2248,13 +2311,19 @@ server.registerTool(
|
|
|
2248
2311
|
// Say which of the three reasons it was. "Something went wrong" makes an
|
|
2249
2312
|
// agent retry the same call; naming the cause makes it fix the input.
|
|
2250
2313
|
const onBoard = (id) => state.notes.some((note) => note.id === id);
|
|
2314
|
+
const { project: wholeProject, boards: allBoards } = await readProject();
|
|
2315
|
+
const elsewhere = (id) => wholeProject.boards.find((meta) => meta.id !== (boardId ?? wholeProject.activeBoardId) && isBoardState(allBoards[meta.id]) && allBoards[meta.id].notes.some((note) => note.id === id));
|
|
2316
|
+
const missing = (id) => {
|
|
2317
|
+
const other = elsewhere(id);
|
|
2318
|
+
return other ? `card ${id} is on another board, "${other.name}" — an arrow stays on one board; a fold that pays off there is set_plant with later: "${other.name}"` : `there is no card with id ${id}`;
|
|
2319
|
+
};
|
|
2251
2320
|
const why =
|
|
2252
2321
|
args.from === args.to
|
|
2253
2322
|
? "a card cannot point at itself"
|
|
2254
2323
|
: !onBoard(args.from)
|
|
2255
|
-
?
|
|
2324
|
+
? missing(args.from)
|
|
2256
2325
|
: !onBoard(args.to)
|
|
2257
|
-
?
|
|
2326
|
+
? missing(args.to)
|
|
2258
2327
|
: "that arrow already exists";
|
|
2259
2328
|
return ok(`No arrow drawn: ${why}. Call list_board to check.`);
|
|
2260
2329
|
}
|
|
@@ -2297,7 +2366,7 @@ server.registerTool(
|
|
|
2297
2366
|
|
|
2298
2367
|
// --- The project ------------------------------------------------------
|
|
2299
2368
|
|
|
2300
|
-
function describeBoards(project, boards) {
|
|
2369
|
+
function describeBoards(project, boards, changedAt = null) {
|
|
2301
2370
|
return project.boards
|
|
2302
2371
|
.map((board, index) => {
|
|
2303
2372
|
const state = boards[board.id];
|
|
@@ -2306,7 +2375,8 @@ function describeBoards(project, boards) {
|
|
|
2306
2375
|
state && isBoardState(state)
|
|
2307
2376
|
? `${state.notes.length} cards, about ${formatPages(boardEighths(normalizeState(state)))} of ${formatPages(normalizeState(state).targetEighths)} pages`
|
|
2308
2377
|
: "no cards";
|
|
2309
|
-
|
|
2378
|
+
const changed = changedAt?.[board.id] ? `, last changed ${changedAt[board.id]}` : "";
|
|
2379
|
+
return ` ${index + 1}. ${board.id} — "${board.name}"${open}: ${shape}${changed}`;
|
|
2310
2380
|
})
|
|
2311
2381
|
.join("\n");
|
|
2312
2382
|
}
|
|
@@ -2320,13 +2390,13 @@ server.registerTool(
|
|
|
2320
2390
|
inputSchema: {},
|
|
2321
2391
|
},
|
|
2322
2392
|
async () => {
|
|
2323
|
-
const { project, boards, live, base } = await readProject();
|
|
2393
|
+
const { project, boards, live, base, changedAt } = await readProject();
|
|
2324
2394
|
return ok(
|
|
2325
2395
|
[
|
|
2326
2396
|
`Project "${project.name}" (${door(live, base)})`,
|
|
2327
2397
|
`premise: ${project.premise ? `"${project.premise}"` : "(not set)"}`,
|
|
2328
2398
|
`boards: ${project.boards.length}`,
|
|
2329
|
-
describeBoards(project, boards),
|
|
2399
|
+
describeBoards(project, boards, changedAt),
|
|
2330
2400
|
].join("\n"),
|
|
2331
2401
|
project,
|
|
2332
2402
|
);
|
|
@@ -2401,8 +2471,8 @@ server.registerTool(
|
|
|
2401
2471
|
const own = list.filter((item) => !item.builtIn).length;
|
|
2402
2472
|
return ok(
|
|
2403
2473
|
[
|
|
2404
|
-
`reminders on "${project.name}" (${door(live, base)}): ${list.length} — ${list.length - own} the house principles the app starts with (built in), ${own} the writer's own${own === 0 ? "; add_reminder adds one the writer asks to keep" : ""}`,
|
|
2405
|
-
...list.map((item) => ` - ${item.id}${item.builtIn ? " (built in)" : ""} — ${item.title}: ${item.body}`),
|
|
2474
|
+
`reminders on "${project.name}" (${door(live, base)}): ${list.length} — ${list.length - own} the house principles the app starts with (built in), ${own} the writer's own${own === 0 ? "; add_reminder adds one the writer asks to keep" : ""}. Reminders live on the project and go with it`,
|
|
2475
|
+
...list.map((item) => ` - ${item.id}${item.builtIn ? " (built in)" : ""} — ${item.body.replace(/\.$/, "").startsWith(item.title.replace(/\.$/, "")) ? item.body : `${item.title}: ${item.body}`}`),
|
|
2406
2476
|
].join("\n"),
|
|
2407
2477
|
list,
|
|
2408
2478
|
);
|
|
@@ -2541,12 +2611,13 @@ server.registerTool(
|
|
|
2541
2611
|
title: "Start a project",
|
|
2542
2612
|
description:
|
|
2543
2613
|
"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.",
|
|
2544
|
-
inputSchema: { name: z.string().min(1), pages: pagesSchema.optional(), minutes: z.number().positive().optional() },
|
|
2614
|
+
inputSchema: { name: z.string().min(1), board: z.string().optional(), pages: pagesSchema.optional(), minutes: z.number().positive().optional() },
|
|
2545
2615
|
},
|
|
2546
2616
|
async (args) => {
|
|
2547
2617
|
const account = await findAccount();
|
|
2548
2618
|
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.");
|
|
2549
|
-
|
|
2619
|
+
let record = renameProject(emptyProject(), args.name.trim());
|
|
2620
|
+
if (args.board?.trim()) record = renameBoard(record, record.activeBoardId, args.board.trim());
|
|
2550
2621
|
const inserted = await account.client.from("projects").insert({ id: record.id, record, reminders: null, rev: 1 });
|
|
2551
2622
|
if (inserted.error) return ok(`Could not start the project: ${inserted.error.message}`);
|
|
2552
2623
|
const target = args.pages ?? args.minutes;
|
|
@@ -2602,7 +2673,7 @@ server.registerTool(
|
|
|
2602
2673
|
for (const row of owned) plans.push(await deletionPlan(row));
|
|
2603
2674
|
const survive = shared.length ? ` ${shared.length} project(s) shared with the writer by others stay: ${shared.map((row) => `"${row.record.name}"`).join(", ")}.` : "";
|
|
2604
2675
|
if (plans.length === 0) return ok(`The account holds nothing of the writer's own to delete.${survive}`);
|
|
2605
|
-
if (!args.confirm) return ok(`Emptying the account deletes ${plans.length} project(s) of the writer's own: ${plans.map(describePlan).join("; ")}. Cannot be undone. Ask the writer; export_project each first if they might want them back
|
|
2676
|
+
if (!args.confirm) return ok(`Emptying the account deletes ${plans.length} project(s) of the writer's own: ${plans.map(describePlan).join("; ")}. Cannot be undone. Ask the writer, or if they have already said so, pass confirm: true now; export_project each first if they might want them back.${survive}`, plans);
|
|
2606
2677
|
for (const plan of plans) await deleteProjectRows(plan);
|
|
2607
2678
|
const next = await workWhatIsLeft(plans.map((plan) => plan.id));
|
|
2608
2679
|
return ok(`Emptied the account as ${account.email}: deleted ${plans.map(describePlan).join("; ")}.${survive}${next}`, plans);
|
|
@@ -2665,7 +2736,9 @@ server.registerTool(
|
|
|
2665
2736
|
const { project, boards, reminders } = await readProject();
|
|
2666
2737
|
const file = toProjectFile({ project, boards, reminders: reminders ?? null });
|
|
2667
2738
|
const cards = countCards(boards);
|
|
2668
|
-
const
|
|
2739
|
+
const ownReminders = (reminders ?? []).filter((item) => !item.builtIn).length;
|
|
2740
|
+
const builtInReminders = (reminders ?? []).length - ownReminders;
|
|
2741
|
+
const what = `"${project.name}": ${project.boards.length} board(s) — ${project.boards.map((meta) => `"${meta.name}" (${isBoardState(boards[meta.id]) ? boards[meta.id].notes.length : 0} cards)`).join(", ")} — ${cards} card(s) in all${reminders?.length ? `, ${reminders.length} reminder(s) (${builtInReminders} built in, ${ownReminders} the writer's own)` : ", the six built-in reminders come with every project and no reminders of the writer's own (none to write)"}${project.structures?.length ? `, ${project.structures.length} structure(s)` : ", no structures of the writer's own (none to write)"}. Pictures and takes on the account are not in the file`;
|
|
2669
2742
|
if (args.path) {
|
|
2670
2743
|
fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
|
|
2671
2744
|
fs.writeFileSync(args.path, JSON.stringify(file, null, 2));
|
|
@@ -2765,7 +2838,7 @@ server.registerTool(
|
|
|
2765
2838
|
const fresh = { ...emptyState(), ...(target ? { targetEighths: target } : {}) };
|
|
2766
2839
|
const { live } = await openBoardEverywhere(next, { ...boards, [board.id]: fresh }, rev, base, board.id);
|
|
2767
2840
|
return ok(
|
|
2768
|
-
`Added "${board.name}" (${board.id}) and opened it${where(live)}. It is empty. 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." : ""}`,
|
|
2841
|
+
`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}" comes back. It is empty. 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." : ""}`,
|
|
2769
2842
|
board,
|
|
2770
2843
|
);
|
|
2771
2844
|
},
|
package/src/board/agents.js
CHANGED
|
@@ -42,12 +42,12 @@ export const AGENTS = {
|
|
|
42
42
|
text: "Skip this when the account is the wall. Without an account, a wall is a folder: any folder, empty is fine — choose one that will outlive your session, never a scratch one. The app run from that folder shows the wall, and the server writes it there (PLOTCODER_ROOT, or the folder it is run from). A fresh folder holds the sample; new_board for the writer's wall, then rename_project. No app running? export_fountain is the wall in order, as text.",
|
|
43
43
|
},
|
|
44
44
|
],
|
|
45
|
-
firstNote: "
|
|
45
|
+
firstNote: "Make these four before anything else; none depends on another, so any order is fine. They are about the wall you will work, so after open_project, open_board, new_project or empty_account, read_wall again. On an account with no project yet, read_wall has nothing to read and says so, and list_reminders gives the house principles every project starts with; new_project, then the four again. No server in front of you, and no shell to take the shell door? Nothing gets you in from inside the session: say so, and ask the person to wire the server and start a new session.",
|
|
46
46
|
first: [
|
|
47
47
|
{ tool: "list_words", why: "the room's words, the app's meaning." },
|
|
48
|
-
{ tool: "read_wall", why: "
|
|
48
|
+
{ tool: "read_wall", why: "the reading: the beats, the runs, the setups, and what the wall asks. The records — every card, the cast, the places — are list_board's. A fresh folder holds a sample wall (Maya, Tom, the letter) and says so; it is not the writer's." },
|
|
49
49
|
{ tool: "list_workflows", why: "what a writer can ask you for." },
|
|
50
|
-
{ tool: "list_reminders", why: "the house principles the app starts with, and the writer's own; read them before you change anything." },
|
|
50
|
+
{ tool: "list_reminders", why: "the house principles the app starts with, and the writer's own; read them before you change anything. Reminders live on the project and go with it." },
|
|
51
51
|
],
|
|
52
52
|
rules: [
|
|
53
53
|
"Questions, not fixes, until the writer says.",
|
|
@@ -64,7 +64,7 @@ export const AGENTS = {
|
|
|
64
64
|
|
|
65
65
|
/** The on-ramp as one text: the file at /llms.txt, and what an agent reads. */
|
|
66
66
|
export function agentsAsText() {
|
|
67
|
-
const lines = ["# PlotCoder — for agents", "", AGENTS.lead, "", `The guide
|
|
67
|
+
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. The doors between are for wiring a server in; skip them when the tools are already in front of you.`, "", "## Doors"];
|
|
68
68
|
for (const door of AGENTS.doors) {
|
|
69
69
|
lines.push(`- ${door.name}: ${door.text}`);
|
|
70
70
|
if (door.code) lines.push("", "```", door.code, "```", "");
|
package/src/board/readWall.d.ts
CHANGED
|
@@ -61,6 +61,8 @@ export type WallReading = {
|
|
|
61
61
|
/** Every planted card: the scene that pays it off (first setup arrow, by wall order), or null while unpaid. */
|
|
62
62
|
/** For each folded card, the cards its setup arrows land on, in wall order; empty when unpaid. */
|
|
63
63
|
payoffs: Record<string, string[]>;
|
|
64
|
+
/** Folded cards that pay off on another board of the project (R50): the card and the board. */
|
|
65
|
+
later: { id: string; boardId: string }[];
|
|
64
66
|
findings: Finding[];
|
|
65
67
|
};
|
|
66
68
|
|
package/src/board/readWall.js
CHANGED
|
@@ -304,11 +304,17 @@ export function readWall(state) {
|
|
|
304
304
|
.map((arrow) => arrow.to)
|
|
305
305
|
.sort((a, b) => (wallIndex.get(a) ?? Infinity) - (wallIndex.get(b) ?? Infinity));
|
|
306
306
|
// Every payoff, in wall order: a card can plant two things (the ledger
|
|
307
|
-
// pays off at the cash and again at the initials), and both count.
|
|
307
|
+
// pays off at the cash and again at the initials), and both count. A
|
|
308
|
+
// fold that pays off only on another board is not here at all — it is
|
|
309
|
+
// under `later` — so the two never disagree about one card.
|
|
310
|
+
if (!heads.length && note.payoffBoardId) continue;
|
|
308
311
|
payoffs[note.id] = heads;
|
|
309
312
|
}
|
|
313
|
+
// A fold that pays off on another board (R50) is not unpaid: it is listed
|
|
314
|
+
// under `later`, and the door that knows the project names the board.
|
|
315
|
+
const later = order.filter((note) => note.plants && note.payoffBoardId).map((note) => ({ id: note.id, boardId: note.payoffBoardId }));
|
|
310
316
|
for (const note of order) {
|
|
311
|
-
if (note.plants && !paysOff.has(note.id)) {
|
|
317
|
+
if (note.plants && !paysOff.has(note.id) && !note.payoffBoardId) {
|
|
312
318
|
findings.push({
|
|
313
319
|
kind: "unpaid",
|
|
314
320
|
ids: [note.id],
|
|
@@ -372,6 +378,7 @@ export function readWall(state) {
|
|
|
372
378
|
runs,
|
|
373
379
|
setups,
|
|
374
380
|
payoffs,
|
|
381
|
+
later,
|
|
375
382
|
findings,
|
|
376
383
|
};
|
|
377
384
|
}
|
package/src/board/reducer.d.ts
CHANGED
|
@@ -79,6 +79,8 @@ export type BoardNote = {
|
|
|
79
79
|
characterIds: string[];
|
|
80
80
|
/** The corner is folded: this card plants something that must pay off (R31). */
|
|
81
81
|
plants: boolean;
|
|
82
|
+
/** When folded: the id of another board of the project where it pays off (R50), or null. */
|
|
83
|
+
payoffBoardId: string | null;
|
|
82
84
|
/** Where the scene happens (R37): a phrase in the writer's words; empty until set. */
|
|
83
85
|
location: string;
|
|
84
86
|
/** The scene's text in Fountain (R23 b): action, cues, dialogue; empty until written. */
|
|
@@ -161,6 +163,7 @@ export type Command =
|
|
|
161
163
|
| ({ type: "update_character"; id: string } & Partial<Record<CharacterField, string>>)
|
|
162
164
|
| { type: "set_cast"; ids: string[]; characterIds: string[] }
|
|
163
165
|
| { type: "set_plant"; ids: string[]; plants: boolean }
|
|
166
|
+
| { type: "set_payoff_board"; ids: string[]; boardId: string | null }
|
|
164
167
|
| { type: "set_location"; ids: string[]; location: string }
|
|
165
168
|
| { type: "apply_template"; template: string; beats?: Array<{ name: string; prompt: string; at: number }> }
|
|
166
169
|
| { type: "set_text"; id: string; text: string }
|
package/src/board/reducer.js
CHANGED
|
@@ -233,6 +233,9 @@ export function seedState(now = nowIso()) {
|
|
|
233
233
|
location: "",
|
|
234
234
|
text: "",
|
|
235
235
|
plants: false,
|
|
236
|
+
// A fold that pays off on another board — a later episode — names it here;
|
|
237
|
+
// null claims nothing (R50).
|
|
238
|
+
payoffBoardId: null,
|
|
236
239
|
createdAt: now,
|
|
237
240
|
updatedAt: now,
|
|
238
241
|
});
|
|
@@ -325,6 +328,8 @@ export function normalizeState(value) {
|
|
|
325
328
|
const characterIds = knownCast(note?.characterIds, characters);
|
|
326
329
|
// Cards written before R31 have no fold; a plant is a claim you make.
|
|
327
330
|
const plants = note?.plants === true;
|
|
331
|
+
// Cards written before R50 pay off on their own board or not at all.
|
|
332
|
+
const payoffBoardId = plants && typeof note?.payoffBoardId === "string" && note.payoffBoardId ? note.payoffBoardId : null;
|
|
328
333
|
// Cards written before R37 have no place; a scene is nowhere until it is.
|
|
329
334
|
const location = typeof note?.location === "string" ? note.location : "";
|
|
330
335
|
// Cards written before pages (R23 b) have no text; a scene is unwritten until it is.
|
|
@@ -336,13 +341,14 @@ export function normalizeState(value) {
|
|
|
336
341
|
Array.isArray(note.characterIds) &&
|
|
337
342
|
sameIds(note.characterIds, characterIds) &&
|
|
338
343
|
note.plants === plants &&
|
|
344
|
+
note.payoffBoardId === payoffBoardId &&
|
|
339
345
|
note.location === location &&
|
|
340
346
|
note.text === text
|
|
341
347
|
) {
|
|
342
348
|
return note;
|
|
343
349
|
}
|
|
344
350
|
patched = true;
|
|
345
|
-
return { ...note, rank, lengthEighths, characterIds, plants, location, text };
|
|
351
|
+
return { ...note, rank, lengthEighths, characterIds, plants, payoffBoardId, location, text };
|
|
346
352
|
});
|
|
347
353
|
|
|
348
354
|
// Boards written before the production half (Roadmap 2, item 8) have no
|
|
@@ -416,6 +422,7 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
416
422
|
: clampEighths(command.lengthEighths, DEFAULT_NOTE_EIGHTHS, MAX_NOTE_EIGHTHS),
|
|
417
423
|
characterIds: knownCast(command.characterIds, state.characters ?? []),
|
|
418
424
|
plants: command.plants === true,
|
|
425
|
+
payoffBoardId: null,
|
|
419
426
|
location: cleanPlace(command.location),
|
|
420
427
|
text: typeof command.text === "string" ? command.text : "",
|
|
421
428
|
z: maxZ(state.notes) + 1,
|
|
@@ -802,7 +809,8 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
802
809
|
id: newId(),
|
|
803
810
|
headline: item.name,
|
|
804
811
|
change: item.prompt,
|
|
805
|
-
|
|
812
|
+
// One colour: paper means nothing to the app, and a structure is not a pattern (round eleven).
|
|
813
|
+
color: "yellow",
|
|
806
814
|
x: left + (index % 5) * (NOTE_WIDTH + 28),
|
|
807
815
|
y: top + Math.floor(index / 5) * (NOTE_HEIGHT + 40),
|
|
808
816
|
rotate: ((index % 5) - 2) * 0.8,
|
|
@@ -811,6 +819,7 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
811
819
|
lengthEighths: null,
|
|
812
820
|
characterIds: [],
|
|
813
821
|
plants: false,
|
|
822
|
+
payoffBoardId: null,
|
|
814
823
|
location: "",
|
|
815
824
|
text: "",
|
|
816
825
|
createdAt: now,
|
|
@@ -895,8 +904,30 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
895
904
|
const plants = command.plants === true;
|
|
896
905
|
const touched = [];
|
|
897
906
|
const notes = state.notes.map((note) => {
|
|
898
|
-
if (!ids.has(note.id)
|
|
899
|
-
|
|
907
|
+
if (!ids.has(note.id)) return note;
|
|
908
|
+
// Unfolding forgets where it paid off; a claim that no longer stands.
|
|
909
|
+
const payoffBoardId = plants ? note.payoffBoardId : null;
|
|
910
|
+
if (note.plants === plants && note.payoffBoardId === payoffBoardId) return note;
|
|
911
|
+
const next = bump(note, { plants, payoffBoardId }, now);
|
|
912
|
+
touched.push(next);
|
|
913
|
+
return next;
|
|
914
|
+
});
|
|
915
|
+
if (touched.length === 0) return { state, changed: false };
|
|
916
|
+
return { state: { ...state, notes }, changed: true, result: touched };
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// A fold that pays off on another board of the project (R50): the wall
|
|
920
|
+
// stops asking where it comes back, and the reading says where. The
|
|
921
|
+
// kernel cannot check the board exists; the door that knows the project
|
|
922
|
+
// does. Null takes the claim back.
|
|
923
|
+
case "set_payoff_board": {
|
|
924
|
+
const ids = new Set(command.ids);
|
|
925
|
+
if (ids.size === 0) return { state, changed: false };
|
|
926
|
+
const payoffBoardId = typeof command.boardId === "string" && command.boardId ? command.boardId : null;
|
|
927
|
+
const touched = [];
|
|
928
|
+
const notes = state.notes.map((note) => {
|
|
929
|
+
if (!ids.has(note.id) || !note.plants || note.payoffBoardId === payoffBoardId) return note;
|
|
930
|
+
const next = bump(note, { payoffBoardId }, now);
|
|
900
931
|
touched.push(next);
|
|
901
932
|
return next;
|
|
902
933
|
});
|
package/src/board/workflows.d.ts
CHANGED
|
@@ -22,5 +22,5 @@ export declare function workflowById(id: string): Workflow | null;
|
|
|
22
22
|
export declare function segmentBrief(
|
|
23
23
|
state: BoardState,
|
|
24
24
|
ids: string[],
|
|
25
|
-
options?: { title?: string },
|
|
25
|
+
options?: { title?: string; boards?: { id: string; name: string }[] },
|
|
26
26
|
): string | null;
|
package/src/board/workflows.js
CHANGED
|
@@ -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 in the headline for now.", tool: "the headline" },
|
|
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: "
|
|
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" },
|
|
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" },
|
|
@@ -91,6 +91,7 @@ function personLine(character) {
|
|
|
91
91
|
if (character.voice) lines.push(`voice: ${character.voice}`);
|
|
92
92
|
if (character.wants) lines.push(`wants: ${character.wants}`);
|
|
93
93
|
if (character.needs) lines.push(`needs: ${character.needs}`);
|
|
94
|
+
if (character.notes) lines.push(`notes: ${character.notes}`);
|
|
94
95
|
return `${character.name}${lines.length ? ` — ${lines.join("; ")}` : " — (no page yet)"}`;
|
|
95
96
|
}
|
|
96
97
|
|
|
@@ -120,7 +121,12 @@ export function segmentBrief(state, ids, options = {}) {
|
|
|
120
121
|
lines.push("");
|
|
121
122
|
lines.push(`SCENE: ${note.headline}${note.location ? ` — at ${note.location}` : ""}`);
|
|
122
123
|
lines.push(`WHAT CHANGES: ${note.change}`);
|
|
123
|
-
if (note.plants)
|
|
124
|
+
if (note.plants) {
|
|
125
|
+
const heads = state.arrows.filter((arrow) => arrow.kind === "setup" && arrow.from === note.id).map((arrow) => byId.get(arrow.to)?.headline).filter(Boolean);
|
|
126
|
+
const later = note.payoffBoardId ? (options.boards ?? []).find((board) => board.id === note.payoffBoardId)?.name ?? "a later board" : null;
|
|
127
|
+
const where = heads.length ? `pays off at ${heads.map((headline) => `"${headline}"`).join(" and ")}` : later ? `pays off later, on "${later}"` : "pays off later, nowhere yet";
|
|
128
|
+
lines.push(`PLANTS: something here ${where}; keep it visible.`);
|
|
129
|
+
}
|
|
124
130
|
if (note.text && note.text.trim()) {
|
|
125
131
|
lines.push("SCRIPT:");
|
|
126
132
|
lines.push(note.text.trim());
|
|
@@ -129,6 +135,6 @@ export function segmentBrief(state, ids, options = {}) {
|
|
|
129
135
|
}
|
|
130
136
|
}
|
|
131
137
|
lines.push("");
|
|
132
|
-
lines.push(`AFTER: ${notes.at(-1).change}`);
|
|
138
|
+
lines.push(`AFTER: ${notes.at(-1).change}${notes.length === 1 ? " (the change line, until the scene is written)" : ""}`);
|
|
133
139
|
return lines.join("\n");
|
|
134
140
|
}
|