plotcoder-board 0.1.13 → 0.1.15
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 +5 -5
- package/package.json +1 -1
- package/scripts/plotcoder-mcp-server.mjs +368 -118
- package/src/board/compareStructure.d.ts +32 -0
- package/src/board/compareStructure.js +85 -0
- package/src/board/markdown.d.ts +15 -0
- package/src/board/markdown.js +177 -0
- package/src/board/project.d.ts +19 -1
- package/src/board/project.js +134 -2
- package/src/board/readWall.d.ts +10 -1
- package/src/board/readWall.js +26 -2
- package/src/board/reducer.d.ts +18 -1
- package/src/board/reducer.js +50 -4
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
import { TEMPLATES } from "../src/board/templates.js";
|
|
42
42
|
import { wordSentence, wordsAsText } from "../src/board/words.js";
|
|
43
43
|
import { fromFountain, mergeFountain, toFountain } from "../src/board/fountain.js";
|
|
44
|
+
import { toMarkdown, toPlainText } from "../src/board/markdown.js";
|
|
44
45
|
import { fromProjectFile, toProjectFile } from "../src/board/projectFile.js";
|
|
45
46
|
import { describeSetAside, fromFdx, toFdx } from "../src/board/fdx.js";
|
|
46
47
|
import { paginate } from "../src/board/paginate.js";
|
|
@@ -51,6 +52,7 @@ import { segmentBrief, WORKFLOWS } from "../src/board/workflows.js";
|
|
|
51
52
|
import { DEFAULT_REMINDERS, titleFromBody } from "../src/board/reminders.js";
|
|
52
53
|
import crypto from "node:crypto";
|
|
53
54
|
import { describeRuns, describeSetups, readWall } from "../src/board/readWall.js";
|
|
55
|
+
import { compareStructure, describeComparison, MATCH_PAGES } from "../src/board/compareStructure.js";
|
|
54
56
|
import { GAP, ROW_WIDTH, organizePoses } from "../src/board/organize.js";
|
|
55
57
|
import { sceneLineCount } from "../src/board/paginate.js";
|
|
56
58
|
import {
|
|
@@ -69,6 +71,11 @@ import {
|
|
|
69
71
|
structureBeats,
|
|
70
72
|
reidentifyProject,
|
|
71
73
|
renameProject,
|
|
74
|
+
castElsewhere,
|
|
75
|
+
liftCast,
|
|
76
|
+
mergeRoster,
|
|
77
|
+
sameRoster,
|
|
78
|
+
withRoster,
|
|
72
79
|
} from "../src/board/project.js";
|
|
73
80
|
|
|
74
81
|
/**
|
|
@@ -626,7 +633,23 @@ async function openBoardEverywhere(project, boards, projectRev, base, boardId) {
|
|
|
626
633
|
return { project: opened, state, live };
|
|
627
634
|
}
|
|
628
635
|
|
|
636
|
+
/**
|
|
637
|
+
* The open board with the project's cast in it (R51). A project written
|
|
638
|
+
* before the cast moved to the record is lifted once, here, and written back.
|
|
639
|
+
*/
|
|
629
640
|
async function readBoard() {
|
|
641
|
+
const raw = await readBoardRaw();
|
|
642
|
+
const held = await readProject();
|
|
643
|
+
if (!Array.isArray(held.project.characters)) {
|
|
644
|
+
const boardId = raw.boardId ?? held.project.activeBoardId;
|
|
645
|
+
const lifted = liftCast(held.project, { ...held.boards, [boardId]: raw.state });
|
|
646
|
+
await writeProject(lifted.project, lifted.boards, held.rev, held.base);
|
|
647
|
+
return { ...raw, state: lifted.boards[boardId] ?? withRoster(raw.state, lifted.project) };
|
|
648
|
+
}
|
|
649
|
+
return { ...raw, state: withRoster(raw.state, held.project) };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function readBoardRaw() {
|
|
630
653
|
const viaAccount = await throughAccount(accountReadBoard);
|
|
631
654
|
if (viaAccount) return viaAccount;
|
|
632
655
|
const base = await findBridge();
|
|
@@ -653,7 +676,47 @@ async function readBoard() {
|
|
|
653
676
|
return { ...file, base: null, live: false };
|
|
654
677
|
}
|
|
655
678
|
|
|
656
|
-
|
|
679
|
+
/**
|
|
680
|
+
* Write the board, keeping the project's cast (R51) with it. A kernel
|
|
681
|
+
* command's result ("exact": commit, undo, redo) is the roster as the writer
|
|
682
|
+
* now wants it, removals included, and it is lifted onto the record. Any
|
|
683
|
+
* other state — a board opened, a file imported — joins the cast without
|
|
684
|
+
* shrinking it ("merge"), and is written composed with the record.
|
|
685
|
+
*/
|
|
686
|
+
async function writeBoard(next, rev, base, boardId = null, roster = "merge") {
|
|
687
|
+
const held = await readProject();
|
|
688
|
+
let toWrite = next;
|
|
689
|
+
if (Array.isArray(held.project.characters)) {
|
|
690
|
+
let project = held.project;
|
|
691
|
+
if (roster === "exact") {
|
|
692
|
+
if (!sameRoster(project.characters, next.characters)) project = { ...project, characters: next.characters, updatedAt: new Date().toISOString() };
|
|
693
|
+
} else {
|
|
694
|
+
const merged = mergeRoster(project, next);
|
|
695
|
+
project = merged.project;
|
|
696
|
+
toWrite = merged.state;
|
|
697
|
+
}
|
|
698
|
+
if (project !== held.project) {
|
|
699
|
+
const boards = {};
|
|
700
|
+
for (const [id, state] of Object.entries(held.boards)) boards[id] = withRoster(state, project);
|
|
701
|
+
boards[boardId ?? project.activeBoardId] = withRoster(toWrite, project);
|
|
702
|
+
// The order matters on the bridge: the board frame carries the exact roster
|
|
703
|
+
// and lands first, so the wall records one undo step holding the card, the
|
|
704
|
+
// cast and the person together (R33, R51); the project frame that follows
|
|
705
|
+
// then changes nothing. The account door keeps the project first, because
|
|
706
|
+
// its live path composes a board's cast against the project row it holds.
|
|
707
|
+
if (base === ACCOUNT) {
|
|
708
|
+
await writeProject(project, boards, held.rev, held.base);
|
|
709
|
+
return writeBoardRaw(toWrite, rev, base, boardId);
|
|
710
|
+
}
|
|
711
|
+
const live = await writeBoardRaw(toWrite, rev, base, boardId);
|
|
712
|
+
await writeProject(project, boards, held.rev, held.base);
|
|
713
|
+
return live;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
return writeBoardRaw(toWrite, rev, base, boardId);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
async function writeBoardRaw(next, rev, base, boardId = null) {
|
|
657
720
|
if (base === ACCOUNT) return accountWriteBoard(next, rev, boardId);
|
|
658
721
|
if (base) {
|
|
659
722
|
try {
|
|
@@ -734,13 +797,42 @@ async function commit(command) {
|
|
|
734
797
|
// command teaches the agent the board is in a state it is not.
|
|
735
798
|
if (!changed) return { state: next, changed, result, live: base !== null };
|
|
736
799
|
|
|
737
|
-
const live = await writeBoard(next, rev, base, boardId);
|
|
800
|
+
const live = await writeBoard(next, rev, base, boardId, "exact");
|
|
738
801
|
trail.push({ before: state, after: canon(next), what: describeCommand(command) });
|
|
739
802
|
if (trail.length > TRAIL_CAP) trail.shift();
|
|
740
803
|
undone.length = 0;
|
|
741
804
|
return { state: next, changed, result, live };
|
|
742
805
|
}
|
|
743
806
|
|
|
807
|
+
/**
|
|
808
|
+
* Several kernel commands as one change. Read once; `build(step, current)` applies
|
|
809
|
+
* each command through the kernel against the running state; write once. One tool
|
|
810
|
+
* call is then one frame on the bridge — one ⌘Z on the wall — and one entry on this
|
|
811
|
+
* server's own trail, however many commands it took: create_note with its cast,
|
|
812
|
+
* set_plant with later, move_scene, an import. Before this, ⌘Z on the wall took the
|
|
813
|
+
* cast off an agent's new card and left the card (R33).
|
|
814
|
+
*/
|
|
815
|
+
async function commitAll(what, build) {
|
|
816
|
+
const { state, rev, base, boardId } = await readBoard();
|
|
817
|
+
let current = state;
|
|
818
|
+
let changed = false;
|
|
819
|
+
const step = (command) => {
|
|
820
|
+
const out = applyCommand(current, command);
|
|
821
|
+
if (out.changed) {
|
|
822
|
+
current = out.state;
|
|
823
|
+
changed = true;
|
|
824
|
+
}
|
|
825
|
+
return out;
|
|
826
|
+
};
|
|
827
|
+
const value = await build(step, () => current);
|
|
828
|
+
if (!changed) return { state: current, changed: false, value, live: base !== null };
|
|
829
|
+
const live = await writeBoard(current, rev, base, boardId, "exact");
|
|
830
|
+
trail.push({ before: state, after: canon(current), what });
|
|
831
|
+
if (trail.length > TRAIL_CAP) trail.shift();
|
|
832
|
+
undone.length = 0;
|
|
833
|
+
return { state: current, changed: true, value, live };
|
|
834
|
+
}
|
|
835
|
+
|
|
744
836
|
/** Said once per session: that cards stack until organize (round seven, finding 11). */
|
|
745
837
|
/** Where a new card lands when the agent gives no position: after the last card in reading order, wrapping five wide, so cards never stack (round eleven, finding 14). */
|
|
746
838
|
function nextPlace(state) {
|
|
@@ -873,7 +965,7 @@ function summarize(state) {
|
|
|
873
965
|
? `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`
|
|
874
966
|
: `runtime: about ${formatPages(runtime)} pages of a ${formatPages(state.targetEighths)}-page target — ${over > 0 ? `${formatPages(over)} over` : over < 0 ? `${formatPages(-over)} under` : "on it"} (an estimate from the cards; a page runs about a minute)`,
|
|
875
967
|
`notes: ${state.notes.length}, groups: ${state.groups.length}, arrows: ${state.arrows.length}, cast: ${state.characters.length}`,
|
|
876
|
-
"cast:",
|
|
968
|
+
"cast (the project's; every board of it casts from here):",
|
|
877
969
|
cast || " (no one yet — add_character to start the roster)",
|
|
878
970
|
"places (each phrase is its own place, and the app relates none of them — if two are one place, set_location them the same):",
|
|
879
971
|
places || " (no card says where it happens yet)",
|
|
@@ -1055,45 +1147,47 @@ server.registerTool(
|
|
|
1055
1147
|
},
|
|
1056
1148
|
async (args) => {
|
|
1057
1149
|
const landing = args.x === undefined && args.y === undefined ? nextPlace((await readBoard()).state) : { x: args.x, y: args.y };
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
const
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
person
|
|
1085
|
-
|
|
1150
|
+
const names = (args.characters ?? []).map((name) => name.trim()).filter(Boolean);
|
|
1151
|
+
const added = [];
|
|
1152
|
+
// The card, anyone new in its cast, and the casting land as one change, so
|
|
1153
|
+
// one ⌘Z on the wall takes back the whole call and not just the cast.
|
|
1154
|
+
const { value: result, live } = await commitAll(`create_note "${args.headline}"`, (step, current) => {
|
|
1155
|
+
let made = step({
|
|
1156
|
+
type: "create_note",
|
|
1157
|
+
headline: args.headline,
|
|
1158
|
+
change: args.change,
|
|
1159
|
+
// One colour unless the agent chooses: a wall an agent builds in one go
|
|
1160
|
+
// would otherwise stripe through the cycle, and a writer reads a pattern
|
|
1161
|
+
// into it (round four, finding 17). The wall's own new-card button keeps
|
|
1162
|
+
// cycling for a person adding cards by hand.
|
|
1163
|
+
color: args.color ?? "yellow",
|
|
1164
|
+
rank: args.rank,
|
|
1165
|
+
lengthEighths: args.pages === undefined ? undefined : toEighths(args.pages),
|
|
1166
|
+
plants: args.plants,
|
|
1167
|
+
location: args.location,
|
|
1168
|
+
x: landing.x,
|
|
1169
|
+
y: landing.y,
|
|
1170
|
+
}).result;
|
|
1171
|
+
if (names.length && made?.id) {
|
|
1172
|
+
const ids = [];
|
|
1173
|
+
for (const name of names) {
|
|
1174
|
+
const wanted = name.toLowerCase();
|
|
1175
|
+
let person = current().characters.find((item) => item.id === name) ?? current().characters.find((item) => item.name.trim().toLowerCase() === wanted);
|
|
1176
|
+
if (!person) {
|
|
1177
|
+
person = step({ type: "add_character", name }).result;
|
|
1178
|
+
if (person) added.push(`${person.name} (${person.id})`);
|
|
1179
|
+
}
|
|
1180
|
+
if (person && !ids.includes(person.id)) ids.push(person.id);
|
|
1181
|
+
}
|
|
1182
|
+
if (ids.length) {
|
|
1183
|
+
const cast = step({ type: "set_cast", ids: [made.id], characterIds: ids });
|
|
1184
|
+
// The card as it is now, cast and all, so the reply's JSON agrees with its prose.
|
|
1185
|
+
made = cast.state.notes.find((note) => note.id === made.id) ?? made;
|
|
1086
1186
|
}
|
|
1087
|
-
if (person) ids.push(person.id);
|
|
1088
|
-
}
|
|
1089
|
-
if (ids.length) {
|
|
1090
|
-
const cast = await commit({ type: "set_cast", ids: [result.id], characterIds: ids });
|
|
1091
|
-
// The card as it is now, cast and all, so the reply's JSON agrees with its prose.
|
|
1092
|
-
const after = cast.state.notes.find((note) => note.id === result.id);
|
|
1093
|
-
if (after) result = after;
|
|
1094
1187
|
}
|
|
1095
|
-
|
|
1096
|
-
}
|
|
1188
|
+
return made;
|
|
1189
|
+
});
|
|
1190
|
+
const castLine = names.length && result?.id ? ` Cast: ${names.join(", ")}${added.length ? ` (added to the roster: ${added.join(", ")})` : ""}.` : "";
|
|
1097
1191
|
const landed = [
|
|
1098
1192
|
result?.rank === "beat" ? "a beat" : "a scene",
|
|
1099
1193
|
result?.lengthEighths === null ? "about a page (unsized: the writer's guess until set_length)" : `${formatPages(noteEighths(result))} ${formatPages(noteEighths(result)) === "1" ? "page" : "pages"}`,
|
|
@@ -1188,14 +1282,16 @@ server.registerTool(
|
|
|
1188
1282
|
{
|
|
1189
1283
|
title: "Read the wall",
|
|
1190
1284
|
description:
|
|
1191
|
-
"Read the board back: the beats in wall order (rows top to bottom, cards left to right), the pages of scenes between consecutive beats with the cards in each, every setup with the distance to its payoff, and the questions the wall raises — no beat marked yet; a run out of proportion with the others; beats back to back with nothing between them (a chain of them is one question); a card with a placeholder headline or no change line; a card no arrow touches; two headlines that read like the same scene; a group too long to be one sequence; a person in the cast on no card; a person gone for more than a third of the story and ten pages; a payoff before its setup on the wall; a folded card no setup arrow pays off; cards that say no place once any card has one. These are questions, not fixes: put them to the writer and do not act on them unasked. It says nothing about how many beats there should be, and neither should you. The prose carries every id; the JSON after it is the same reading for a program, and PLOTCODER_JSON=0 in the server's environment drops it.",
|
|
1285
|
+
"Read the board back: the beats in wall order (rows top to bottom, cards left to right), the pages of scenes between consecutive beats with the cards in each, every setup with the distance to its payoff, and the questions the wall raises — no beat marked yet; a run out of proportion with the others; beats back to back with nothing between them (a chain of them is one question); a card with a placeholder headline or no change line; a card no arrow touches; two headlines that read like the same scene; a group too long to be one sequence; a person in the cast on no card; a person gone for more than a third of the story and ten pages; a payoff before its setup on the wall; a folded card no setup arrow pays off; cards that say no place once any card has one. These are questions, not fixes: put them to the writer and do not act on them unasked. A question the writer answers with \"leave it\" is left with leave_question and listed under \"left, for now\" instead, until it would read differently. It says nothing about how many beats there should be, and neither should you. The prose carries every id; the JSON after it is the same reading for a program, and PLOTCODER_JSON=0 in the server's environment drops it.",
|
|
1192
1286
|
inputSchema: {},
|
|
1193
1287
|
},
|
|
1194
1288
|
async () => {
|
|
1195
1289
|
const { state, live, base, boardId: readBoardId } = await readBoard();
|
|
1196
1290
|
const { project: projectForRead } = await readProject();
|
|
1197
1291
|
const readBoardMeta = boardById(projectForRead, readBoardId ?? projectForRead.activeBoardId);
|
|
1198
|
-
const
|
|
1292
|
+
const { boards: boardsForRead } = await readProject();
|
|
1293
|
+
const elsewhereForRead = castElsewhere(projectForRead, boardsForRead, readBoardId ?? projectForRead.activeBoardId);
|
|
1294
|
+
const reading = readWall(state, { elsewhere: Object.keys(elsewhereForRead) });
|
|
1199
1295
|
const runs = describeRuns(reading, state).map((line, index) => {
|
|
1200
1296
|
const ids = reading.runs[index]?.ids ?? [];
|
|
1201
1297
|
return ids.length ? `${line} — ${ids.map((id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`).join(", ")}` : line;
|
|
@@ -1238,13 +1334,20 @@ server.registerTool(
|
|
|
1238
1334
|
"questions the wall raises:",
|
|
1239
1335
|
...(reading.findings.length
|
|
1240
1336
|
? reading.findings.map((finding) => ` - [${finding.kind}] ${finding.text}${finding.ids.length ? ` (ids: ${finding.ids.join(", ")})` : ""}`)
|
|
1241
|
-
: [" (none that this reading can see)"]),
|
|
1337
|
+
: [reading.left.length ? " (none the writer has not left)" : " (none that this reading can see)"]),
|
|
1338
|
+
...(reading.left.length
|
|
1339
|
+
? [
|
|
1340
|
+
"left, for now (the writer's word; kept until the question would read differently, and ask_again brings one back):",
|
|
1341
|
+
...reading.left.map((finding) => ` - [${finding.kind}] ${finding.text} (left ${String(finding.since).slice(0, 10)}${finding.ids.length ? `; ids: ${finding.ids.join(", ")}` : ""})`),
|
|
1342
|
+
]
|
|
1343
|
+
: []),
|
|
1242
1344
|
`checks: ${CHECKS.length} run — ${(() => {
|
|
1243
1345
|
const asked = reading.findings;
|
|
1244
|
-
|
|
1346
|
+
const held = reading.left.length ? `, ${reading.left.length} left by the writer` : "";
|
|
1347
|
+
if (asked.length === 0) return `asking nothing${held}`;
|
|
1245
1348
|
const counts = new Map();
|
|
1246
1349
|
for (const finding of asked) counts.set(finding.kind, (counts.get(finding.kind) ?? 0) + 1);
|
|
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(", ")}`;
|
|
1350
|
+
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}`;
|
|
1248
1351
|
})()}; checked and clean: ${CHECKS.filter((kind) => !reading.findings.some((finding) => finding.kind === kind)).map((kind) => {
|
|
1249
1352
|
if (kind === "unlinked" && state.arrows.length === 0) return "no card without an arrow (not asked: no arrows yet)";
|
|
1250
1353
|
if (kind === "unplaced" && !state.notes.some((note) => (note.location ?? "").trim())) return "no card without a place (not asked: no card placed yet)";
|
|
@@ -1257,6 +1360,60 @@ server.registerTool(
|
|
|
1257
1360
|
},
|
|
1258
1361
|
);
|
|
1259
1362
|
|
|
1363
|
+
// --- Leaving a question (R53) ----------------------------------------
|
|
1364
|
+
|
|
1365
|
+
const sameList = (a, b) => a.length === b.length && a.every((id, index) => id === b[index]);
|
|
1366
|
+
|
|
1367
|
+
server.registerTool(
|
|
1368
|
+
"leave_question",
|
|
1369
|
+
{
|
|
1370
|
+
title: "Leave a question, for now",
|
|
1371
|
+
description:
|
|
1372
|
+
"Write the writer's word on a question the wall asks — \"leave it\" — so the reading stops asking it. Pass the question's kind as read_wall names it (sag, empty, unpaid, …) and, when that kind is asked more than once, its ids as read_wall lists them. The wall keeps the question and asks it again on its own the moment it would read differently — a card in it changes, a page moves, the median shifts — so a left question is never a dismissal; ask_again brings one back now. Only on the writer's word: never leave a question unasked.",
|
|
1373
|
+
inputSchema: { kind: z.string().min(1), ids: z.array(z.string()).optional() },
|
|
1374
|
+
},
|
|
1375
|
+
async (args) => {
|
|
1376
|
+
const { state } = await readBoard();
|
|
1377
|
+
const reading = readWall(state);
|
|
1378
|
+
const already = reading.left.filter((finding) => finding.kind === args.kind && (!args.ids || sameList(finding.ids, args.ids)));
|
|
1379
|
+
const matches = reading.findings.filter((finding) => finding.kind === args.kind && (!args.ids || sameList(finding.ids, args.ids)));
|
|
1380
|
+
if (matches.length === 0) {
|
|
1381
|
+
if (already.length) return ok(`Already left: [${args.kind}] ${already[0].text} It stays left until the question would read differently; ask_again brings it back.`);
|
|
1382
|
+
return ok(`The wall is not asking a question of kind "${args.kind}"${args.ids ? ` about ids ${args.ids.join(", ")}` : ""}. read_wall lists the questions it asks now, each with its kind and ids.`);
|
|
1383
|
+
}
|
|
1384
|
+
if (matches.length > 1) {
|
|
1385
|
+
return ok(`The wall asks ${matches.length} questions of kind "${args.kind}"; pass ids to say which:\n${matches.map((finding) => ` - ${finding.text} (ids: ${finding.ids.join(", ")})`).join("\n")}`);
|
|
1386
|
+
}
|
|
1387
|
+
const finding = matches[0];
|
|
1388
|
+
const { result, live } = await commit({ type: "leave_question", kind: finding.kind, ids: finding.ids, text: finding.text });
|
|
1389
|
+
return ok(
|
|
1390
|
+
`Left, for now: [${finding.kind}] ${finding.text}${where(live)} The wall stops asking it and keeps the writer's word; it asks again on its own when the question would read differently, and ask_again brings it back now. read_wall lists it under "left, for now".`,
|
|
1391
|
+
result,
|
|
1392
|
+
);
|
|
1393
|
+
},
|
|
1394
|
+
);
|
|
1395
|
+
|
|
1396
|
+
server.registerTool(
|
|
1397
|
+
"ask_again",
|
|
1398
|
+
{
|
|
1399
|
+
title: "Ask a left question again",
|
|
1400
|
+
description: "Take back a left question by its kind (and ids, when that kind was left more than once), so the wall asks it again now. Without ids, every left question of that kind comes back.",
|
|
1401
|
+
inputSchema: { kind: z.string().min(1), ids: z.array(z.string()).optional() },
|
|
1402
|
+
},
|
|
1403
|
+
async (args) => {
|
|
1404
|
+
const { state } = await readBoard();
|
|
1405
|
+
const held = (state.left ?? []).filter((item) => item.kind === args.kind && (!args.ids || sameList(item.ids, args.ids)));
|
|
1406
|
+
if (held.length === 0) return ok(`Nothing of kind "${args.kind}"${args.ids ? ` about ids ${args.ids.join(", ")}` : ""} is left. read_wall lists what is, under "left, for now".`);
|
|
1407
|
+
const { result, live } = await commit({ type: "ask_again", kind: args.kind, ids: args.ids });
|
|
1408
|
+
const reading = readWall((await readBoard()).state);
|
|
1409
|
+
const back = reading.findings.filter((finding) => held.some((item) => item.kind === finding.kind && sameList(item.ids, finding.ids)));
|
|
1410
|
+
return ok(
|
|
1411
|
+
`Asked again${where(live)}: ${held.length} question${held.length === 1 ? "" : "s"} of kind "${args.kind}" ${held.length === 1 ? "is" : "are"} no longer left${back.length ? ` — the wall asks ${back.length === 1 ? "it" : `${back.length} of them`} now: ${back.map((finding) => finding.text).join(" ")}` : " — and the wall no longer asks it; the question had already changed"}.`,
|
|
1412
|
+
result,
|
|
1413
|
+
);
|
|
1414
|
+
},
|
|
1415
|
+
);
|
|
1416
|
+
|
|
1260
1417
|
server.registerTool(
|
|
1261
1418
|
"move_scene",
|
|
1262
1419
|
{
|
|
@@ -1278,46 +1435,41 @@ server.registerTool(
|
|
|
1278
1435
|
if (!state.arrows.some(isFollows)) {
|
|
1279
1436
|
return ok("The wall has no follows arrows, so there is no story order to move within: create_arrow the sequence first, or move_note the card by position.");
|
|
1280
1437
|
}
|
|
1281
|
-
const trailBefore = trail.length;
|
|
1282
1438
|
let removed = 0;
|
|
1283
1439
|
let drawn = 0;
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
if (
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1440
|
+
// The whole move — its dozen arrows and the tidy — as one change: one frame on
|
|
1441
|
+
// the bridge, one ⌘Z on the wall, one step for undo here.
|
|
1442
|
+
const { state: final, live } = await commitAll(`move_scene "${card.headline}"`, (step, current) => {
|
|
1443
|
+
const run = (command) => {
|
|
1444
|
+
const done = step(command);
|
|
1445
|
+
if (done.changed) {
|
|
1446
|
+
if (command.type === "delete_arrow") removed += 1;
|
|
1447
|
+
if (command.type === "create_arrow") drawn += 1;
|
|
1448
|
+
}
|
|
1449
|
+
return done;
|
|
1450
|
+
};
|
|
1451
|
+
// Leave: what pointed at the card points at what the card pointed at.
|
|
1452
|
+
const ins = state.arrows.filter((arrow) => isFollows(arrow) && arrow.to === card.id);
|
|
1453
|
+
const outs = state.arrows.filter((arrow) => isFollows(arrow) && arrow.from === card.id);
|
|
1454
|
+
for (const arrow of [...ins, ...outs]) run({ type: "delete_arrow", id: arrow.id });
|
|
1455
|
+
for (const before of ins) for (const after of outs) if (before.from !== after.to) run({ type: "create_arrow", from: before.from, to: after.to, kind: "follows" });
|
|
1456
|
+
// Land: between the target and what followed it (or what led to it).
|
|
1457
|
+
const mid = current();
|
|
1458
|
+
if (args.after) {
|
|
1459
|
+
for (const arrow of mid.arrows.filter((item) => isFollows(item) && item.from === target.id && item.to !== card.id)) {
|
|
1460
|
+
run({ type: "delete_arrow", id: arrow.id });
|
|
1461
|
+
run({ type: "create_arrow", from: card.id, to: arrow.to, kind: "follows" });
|
|
1462
|
+
}
|
|
1463
|
+
run({ type: "create_arrow", from: target.id, to: card.id, kind: "follows" });
|
|
1464
|
+
} else {
|
|
1465
|
+
for (const arrow of mid.arrows.filter((item) => isFollows(item) && item.to === target.id && item.from !== card.id)) {
|
|
1466
|
+
run({ type: "delete_arrow", id: arrow.id });
|
|
1467
|
+
run({ type: "create_arrow", from: arrow.from, to: card.id, kind: "follows" });
|
|
1468
|
+
}
|
|
1469
|
+
run({ type: "create_arrow", from: card.id, to: target.id, kind: "follows" });
|
|
1311
1470
|
}
|
|
1312
|
-
|
|
1313
|
-
}
|
|
1314
|
-
const { state: linked } = await readBoard();
|
|
1315
|
-
const tidied = await step({ type: "apply_poses", poses: organizePoses(linked, {}) });
|
|
1316
|
-
const final = tidied.state;
|
|
1317
|
-
// One step for undo: the whole move, not its dozen arrows.
|
|
1318
|
-
trail.splice(trailBefore);
|
|
1319
|
-
trail.push({ before: state, after: canon(final), what: `move_scene "${card.headline}"` });
|
|
1320
|
-
undone.length = 0;
|
|
1471
|
+
run({ type: "apply_poses", poses: organizePoses(current(), {}) });
|
|
1472
|
+
});
|
|
1321
1473
|
const order = readingOrder(final.notes);
|
|
1322
1474
|
const group = final.groups.find((item) => item.noteIds.includes(card.id));
|
|
1323
1475
|
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.` : "";
|
|
@@ -1403,7 +1555,7 @@ server.registerTool(
|
|
|
1403
1555
|
"list_structures",
|
|
1404
1556
|
{
|
|
1405
1557
|
title: "List the structures",
|
|
1406
|
-
description: "The structures apply_template can lay on a wall: the built-in ones, and the writer's own saved from their walls (save_structure), each with its beats.",
|
|
1558
|
+
description: "The structures apply_template can lay on a wall: the built-in ones, and the writer's own saved from their walls (save_structure), each with its beats. compare_structure sets one beside this wall's beats without laying anything.",
|
|
1407
1559
|
inputSchema: {},
|
|
1408
1560
|
},
|
|
1409
1561
|
async () => {
|
|
@@ -1413,11 +1565,43 @@ server.registerTool(
|
|
|
1413
1565
|
`the writer's own: ${own.length}`,
|
|
1414
1566
|
...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
1567
|
`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`,
|
|
1568
|
+
"compare_structure sets one of these beside this wall's beats, page by page, and lays nothing",
|
|
1416
1569
|
];
|
|
1417
1570
|
return ok(lines.join("\n"), { builtIn: TEMPLATES.map((template) => ({ id: template.id, name: template.name, beats: template.beats })), own });
|
|
1418
1571
|
},
|
|
1419
1572
|
);
|
|
1420
1573
|
|
|
1574
|
+
server.registerTool(
|
|
1575
|
+
"compare_structure",
|
|
1576
|
+
{
|
|
1577
|
+
title: "A structure beside the wall",
|
|
1578
|
+
description:
|
|
1579
|
+
"Set a structure beside this wall's beats without laying anything: each of the structure's beats with the page it falls near on this board's target, and the nearest of the wall's own beats within six pages — one to one, in order — with how far off it is (here, near, N pp early or late). A reading, like read_wall: nothing moves and no card is made. Takes a built-in structure by id (turns, three-acts, eight-sequences, fifteen-beats, story-circle) or one of the writer's own by name or id; turns is the default.",
|
|
1580
|
+
inputSchema: { structure: z.string().optional() },
|
|
1581
|
+
},
|
|
1582
|
+
async (args) => {
|
|
1583
|
+
const { state } = await readBoard();
|
|
1584
|
+
const { project } = await readProject();
|
|
1585
|
+
const wanted = (args.structure ?? "turns").trim().toLowerCase();
|
|
1586
|
+
const own = project.structures ?? [];
|
|
1587
|
+
const chosen =
|
|
1588
|
+
TEMPLATES.find((template) => template.id === wanted || template.name.toLowerCase() === wanted) ??
|
|
1589
|
+
own.find((structure) => structure.id === wanted || structure.name.toLowerCase() === wanted);
|
|
1590
|
+
if (!chosen) return ok(`No structure called "${args.structure}". list_structures names the built-in five and the writer's own.`);
|
|
1591
|
+
const comparison = compareStructure(state, chosen.beats);
|
|
1592
|
+
const beats = state.notes.filter((note) => note.rank === "beat").length;
|
|
1593
|
+
const lines = [
|
|
1594
|
+
`"${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}); a match is the nearest of the wall's beats within ${MATCH_PAGES} pages, one to one and in order:`,
|
|
1595
|
+
...describeComparison(comparison).map((line) => ` - ${line}`),
|
|
1596
|
+
comparison.unmatched.length
|
|
1597
|
+
? `beats of the wall no beat of the structure answers: ${comparison.unmatched.map((beat) => `"${beat.headline}" (p. ${beat.page})`).join(", ")}`
|
|
1598
|
+
: "every beat of the wall answers one of the structure's",
|
|
1599
|
+
beats === 0 ? "No card on this board is marked as a beat (set_rank), so there is nothing to compare; apply_template lays the structure's beats to fill." : "Nothing moved and nothing was made: this is a reading. apply_template lays the beats as cards when the writer wants them.",
|
|
1600
|
+
];
|
|
1601
|
+
return ok(lines.join("\n"), { structure: { id: chosen.id, name: chosen.name }, ...comparison });
|
|
1602
|
+
},
|
|
1603
|
+
);
|
|
1604
|
+
|
|
1421
1605
|
server.registerTool(
|
|
1422
1606
|
"save_structure",
|
|
1423
1607
|
{
|
|
@@ -1481,6 +1665,57 @@ server.registerTool(
|
|
|
1481
1665
|
},
|
|
1482
1666
|
);
|
|
1483
1667
|
|
|
1668
|
+
server.registerTool(
|
|
1669
|
+
"export_markdown",
|
|
1670
|
+
{
|
|
1671
|
+
title: "Export the wall as Markdown",
|
|
1672
|
+
description:
|
|
1673
|
+
"The open board as Markdown, for a collaborator who lives in Google Docs or the like: the board as the title (the project's name before it when the project has several boards), the premise and the logline under it, beats as second-level headings, a third-level heading per scene from its place with its scene number, the headline as a synopsis line, then the scene's text — a speech as its cue in bold with the lines under it — or, unwritten, its change line. Pass a path to write a .md file; otherwise the text comes back.",
|
|
1674
|
+
inputSchema: { path: z.string().optional() },
|
|
1675
|
+
},
|
|
1676
|
+
async (args) => {
|
|
1677
|
+
const { state } = await readBoard();
|
|
1678
|
+
const { project } = await readProject();
|
|
1679
|
+
const board = project.boards.find((item) => item.id === project.activeBoardId);
|
|
1680
|
+
const text = toMarkdown(state, {
|
|
1681
|
+
title: board?.name,
|
|
1682
|
+
project: project.boards.length > 1 && project.name !== "Untitled project" ? project.name : undefined,
|
|
1683
|
+
premise: project.premise || undefined,
|
|
1684
|
+
});
|
|
1685
|
+
if (args.path) {
|
|
1686
|
+
fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
|
|
1687
|
+
fs.writeFileSync(args.path, text);
|
|
1688
|
+
return ok(`Wrote ${text.split("\n").length} lines of Markdown to ${path.resolve(args.path)}.`);
|
|
1689
|
+
}
|
|
1690
|
+
return ok(text);
|
|
1691
|
+
},
|
|
1692
|
+
);
|
|
1693
|
+
|
|
1694
|
+
server.registerTool(
|
|
1695
|
+
"export_text",
|
|
1696
|
+
{
|
|
1697
|
+
title: "Export the script as plain text",
|
|
1698
|
+
description:
|
|
1699
|
+
"The open board's script as plain text, set as it prints: the paginator's lines at Courier's columns kept with spaces, scene numbers in both margins (the wall's order, or as locked), no page numbers. Pastes into anything and reads as a script wherever the font is monospaced. Pass a path to write a .txt file; otherwise the text comes back.",
|
|
1700
|
+
inputSchema: { path: z.string().optional() },
|
|
1701
|
+
},
|
|
1702
|
+
async (args) => {
|
|
1703
|
+
const { state } = await readBoard();
|
|
1704
|
+
const { project } = await readProject();
|
|
1705
|
+
const board = project.boards.find((item) => item.id === project.activeBoardId);
|
|
1706
|
+
const text = toPlainText(state, {
|
|
1707
|
+
title: board?.name,
|
|
1708
|
+
project: project.boards.length > 1 && project.name !== "Untitled project" ? project.name : undefined,
|
|
1709
|
+
});
|
|
1710
|
+
if (args.path) {
|
|
1711
|
+
fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
|
|
1712
|
+
fs.writeFileSync(args.path, text);
|
|
1713
|
+
return ok(`Wrote ${text.split("\n").length} lines of plain text to ${path.resolve(args.path)}.`);
|
|
1714
|
+
}
|
|
1715
|
+
return ok(text);
|
|
1716
|
+
},
|
|
1717
|
+
);
|
|
1718
|
+
|
|
1484
1719
|
server.registerTool(
|
|
1485
1720
|
"write_scene",
|
|
1486
1721
|
{
|
|
@@ -1550,8 +1785,10 @@ server.registerTool(
|
|
|
1550
1785
|
const { state } = await readBoard();
|
|
1551
1786
|
const parsed = fromFountain(source);
|
|
1552
1787
|
const { commands, matched } = mergeFountain(state, parsed);
|
|
1553
|
-
|
|
1554
|
-
|
|
1788
|
+
// The whole import as one change, so one undo takes every scene back.
|
|
1789
|
+
const { live } = await commitAll(`import_fountain (${parsed.scenes.length} scene(s))`, (step) => {
|
|
1790
|
+
for (const command of commands) step(command);
|
|
1791
|
+
});
|
|
1555
1792
|
const written = commands.filter((command) => command.type === "set_text").length;
|
|
1556
1793
|
const created = matched.filter((item) => item.created).length;
|
|
1557
1794
|
return ok(
|
|
@@ -1647,8 +1884,10 @@ server.registerTool(
|
|
|
1647
1884
|
const { state } = await readBoard();
|
|
1648
1885
|
const parsed = fromFdx(source);
|
|
1649
1886
|
const { commands, matched } = mergeFountain(state, parsed);
|
|
1650
|
-
|
|
1651
|
-
|
|
1887
|
+
// The whole import as one change, so one undo takes every scene back.
|
|
1888
|
+
const { live } = await commitAll(`import_fdx (${parsed.scenes.length} scene(s))`, (step) => {
|
|
1889
|
+
for (const command of commands) step(command);
|
|
1890
|
+
});
|
|
1652
1891
|
const written = commands.filter((command) => command.type === "set_text").length;
|
|
1653
1892
|
const created = matched.filter((item) => item.created).length;
|
|
1654
1893
|
const receipt = describeSetAside(parsed.setAside);
|
|
@@ -1916,7 +2155,7 @@ server.registerTool(
|
|
|
1916
2155
|
trail.pop();
|
|
1917
2156
|
undone.push(last);
|
|
1918
2157
|
const { boardId } = await readBoard();
|
|
1919
|
-
const live = await writeBoard(last.before, rev, base, boardId);
|
|
2158
|
+
const live = await writeBoard(last.before, rev, base, boardId, "exact");
|
|
1920
2159
|
const orderLine = /^(move_scene|organize)/.test(last.what) ? ` Story order now: ${readingOrder(last.before.notes).map((note, index) => `${index + 1}. ${note.headline}`).join(", ")}.` : "";
|
|
1921
2160
|
const cardsDiff = last.before.notes.length - state.notes.length;
|
|
1922
2161
|
const countLine = cardsDiff > 0 ? ` ${cardsDiff} card(s) back.` : cardsDiff < 0 ? ` ${-cardsDiff} card(s) gone.` : "";
|
|
@@ -1942,7 +2181,7 @@ server.registerTool(
|
|
|
1942
2181
|
}
|
|
1943
2182
|
undone.pop();
|
|
1944
2183
|
const after = normalizeState(JSON.parse(last.after));
|
|
1945
|
-
const live = await writeBoard(after, rev, base, boardId);
|
|
2184
|
+
const live = await writeBoard(after, rev, base, boardId, "exact");
|
|
1946
2185
|
trail.push(last);
|
|
1947
2186
|
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 });
|
|
1948
2187
|
},
|
|
@@ -1961,39 +2200,40 @@ server.registerTool(
|
|
|
1961
2200
|
},
|
|
1962
2201
|
},
|
|
1963
2202
|
async (args) => {
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
});
|
|
1969
|
-
let laterLine = "";
|
|
2203
|
+
// A series plant (R50): the fold pays off on another board of the project.
|
|
2204
|
+
// The kernel cannot check the board exists; this door can, before anything lands.
|
|
2205
|
+
let target = null;
|
|
2206
|
+
let forgetting = false;
|
|
1970
2207
|
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
2208
|
const { project } = await readProject();
|
|
1974
|
-
const {
|
|
1975
|
-
const here = now.notes.filter((note) => args.ids.includes(note.id));
|
|
2209
|
+
const { boardId: current } = await readBoard();
|
|
1976
2210
|
if (args.later.trim() === "") {
|
|
1977
|
-
|
|
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
|
-
}
|
|
2211
|
+
forgetting = true;
|
|
1984
2212
|
} else {
|
|
1985
|
-
|
|
2213
|
+
target = findBoard(project, args.later);
|
|
1986
2214
|
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
2215
|
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
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
// The fold and the board it pays off on land as one change: one ⌘Z on the wall.
|
|
2219
|
+
const { value, live, changed } = await commitAll("set_plant", (step, current) => {
|
|
2220
|
+
let { result } = step({ type: "set_plant", ids: args.ids, plants: args.plants });
|
|
2221
|
+
let laterLine = "";
|
|
2222
|
+
if (forgetting) {
|
|
2223
|
+
const cleared = step({ type: "set_payoff_board", ids: args.ids, boardId: null });
|
|
2224
|
+
if (cleared.changed) {
|
|
2225
|
+
result = cleared.result;
|
|
2226
|
+
laterLine = " The board it paid off on is forgotten; read_wall asks again until a setup arrow or a board pays it off.";
|
|
1993
2227
|
}
|
|
2228
|
+
} else if (target) {
|
|
2229
|
+
const named = step({ type: "set_payoff_board", ids: args.ids, boardId: target.id });
|
|
2230
|
+
if (named.changed) result = named.result;
|
|
2231
|
+
const here = current().notes.filter((note) => args.ids.includes(note.id));
|
|
1994
2232
|
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
2233
|
}
|
|
1996
|
-
|
|
2234
|
+
return { result, laterLine };
|
|
2235
|
+
});
|
|
2236
|
+
const { result, laterLine } = value;
|
|
1997
2237
|
const count = result?.length ?? 0;
|
|
1998
2238
|
if (!changed || count === 0) return ok("No change: those cards were already that way, or the ids are not on the board.");
|
|
1999
2239
|
return ok(
|
|
@@ -2012,7 +2252,7 @@ server.registerTool(
|
|
|
2012
2252
|
{
|
|
2013
2253
|
title: "Add character",
|
|
2014
2254
|
description:
|
|
2015
|
-
"Add a person to the
|
|
2255
|
+
"Add a person to the project's cast — one roster every board of the project casts from, so a person is one record across the pilot and the episodes after it. The same name twice is refused and the existing record returned. Add someone here before casting them on a card.",
|
|
2016
2256
|
inputSchema: { name: z.string().min(1) },
|
|
2017
2257
|
},
|
|
2018
2258
|
async (args) => {
|
|
@@ -2022,7 +2262,7 @@ server.registerTool(
|
|
|
2022
2262
|
? ok(`Already in the cast as "${result.name}" (${result.id}). Use that id.`, result)
|
|
2023
2263
|
: ok("No character added: the name was empty.");
|
|
2024
2264
|
}
|
|
2025
|
-
return ok(`Added "${result.name}" to the cast${where(live)}.`, result);
|
|
2265
|
+
return ok(`Added "${result.name}" to the project's cast${where(live)}; every board of the project casts from it.`, result);
|
|
2026
2266
|
},
|
|
2027
2267
|
);
|
|
2028
2268
|
|
|
@@ -2161,13 +2401,23 @@ server.registerTool(
|
|
|
2161
2401
|
{
|
|
2162
2402
|
title: "Remove character",
|
|
2163
2403
|
description:
|
|
2164
|
-
"Remove a person from the cast by id. They leave every card they were on
|
|
2404
|
+
"Remove a person from the project's cast by id. They leave every card they were on here; the cards themselves stay. Refused while another board of the project has them on a card: take them off there first.",
|
|
2165
2405
|
inputSchema: { id: z.string() },
|
|
2166
2406
|
},
|
|
2167
2407
|
async (args) => {
|
|
2408
|
+
const { state, boardId } = await readBoard();
|
|
2409
|
+
const person = state.characters.find((character) => character.id === args.id);
|
|
2410
|
+
if (!person) return ok(`No character with id ${args.id}. Call list_board for the cast.`);
|
|
2411
|
+
const { project, boards } = await readProject();
|
|
2412
|
+
const elsewhere = castElsewhere(project, boards, boardId ?? project.activeBoardId)[args.id] ?? [];
|
|
2413
|
+
if (elsewhere.length) {
|
|
2414
|
+
return ok(
|
|
2415
|
+
`"${person.name}" stays: the cast is the project's, and a person leaves it only when no board has them on a card — they are on ${elsewhere.map((item) => `${item.cards} card${item.cards === 1 ? "" : "s"} of "${item.board}"`).join(" and ")}. open_board there and cast them off those cards first, or leave them.`,
|
|
2416
|
+
);
|
|
2417
|
+
}
|
|
2168
2418
|
const { changed, live } = await commit({ type: "remove_character", id: args.id });
|
|
2169
2419
|
if (!changed) return ok(`No character with id ${args.id}. Call list_board for the cast.`);
|
|
2170
|
-
return ok(`Removed from the cast and from every card${where(live)}.`);
|
|
2420
|
+
return ok(`Removed "${person.name}" from the project's cast and from every card${where(live)}.`);
|
|
2171
2421
|
},
|
|
2172
2422
|
);
|
|
2173
2423
|
|
|
@@ -2838,7 +3088,7 @@ server.registerTool(
|
|
|
2838
3088
|
const fresh = { ...emptyState(), ...(target ? { targetEighths: target } : {}) };
|
|
2839
3089
|
const { live } = await openBoardEverywhere(next, { ...boards, [board.id]: fresh }, rev, base, board.id);
|
|
2840
3090
|
return ok(
|
|
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." : ""}`,
|
|
3091
|
+
`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, 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." : ""}`,
|
|
2842
3092
|
board,
|
|
2843
3093
|
);
|
|
2844
3094
|
},
|