plotcoder-board 0.1.5 → 0.1.7
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 +1 -1
- package/package.json +1 -1
- package/scripts/plotcoder-mcp-server.mjs +155 -20
- package/src/board/readWall.d.ts +2 -1
- package/src/board/readWall.js +4 -2
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ Every board verb goes through one command kernel, `src/board/reducer.js`, and th
|
|
|
41
41
|
|
|
42
42
|
- **The wall.** Tap the words to type, drag the paper to move. Lasso to select, then Group. Drag a card's handle onto another card for an arrow. ⌘Z takes back any change, whichever door made it.
|
|
43
43
|
- **`window.plotcoder`** on the page, for a console or a CDP session.
|
|
44
|
-
- **The MCP server**, published to npm as `plotcoder-board`: `npx -y plotcoder-board@latest` is the server, `npx -y plotcoder-board@latest call <tool> '{json}'` one call from a shell, `npx -y plotcoder-board@latest serve` the hosted door on a port (a `Dockerfile` is here too). Inside the repo it is `scripts/plotcoder-mcp.mjs`, wired for Cursor in `.cursor/mcp.json` and for Claude Code in `.mcp.json` (run `npm ci` once first). A version tag (`v0.2.0`) publishes it, with `NPM_TOKEN` in the repo's secrets. Seventy-
|
|
44
|
+
- **The MCP server**, published to npm as `plotcoder-board`: `npx -y plotcoder-board@latest` is the server, `npx -y plotcoder-board@latest call <tool> '{json}'` one call from a shell, `npx -y plotcoder-board@latest serve` the hosted door on a port (a `Dockerfile` is here too). Inside the repo it is `scripts/plotcoder-mcp.mjs`, wired for Cursor in `.cursor/mcp.json` and for Claude Code in `.mcp.json` (run `npm ci` once first). A version tag (`v0.2.0`) publishes it, with `NPM_TOKEN` in the repo's secrets. Seventy-two tools: reading (`list_board`, `read_wall`, `read_pages`, `read_character`, `page_count`, `list_words`, `list_workflows`, `segment_brief`); the card, cast, place, group and arrow verbs, `set_logline`, `set_target`, `set_rank`, `set_plant`, `write_scene`, `move_scene`, `organize`, `apply_template` with `list_structures`, `save_structure`, `remove_structure`; `undo` and `redo`; the project's `list_boards`, `open_board`, `new_board`, `rename_board`, `delete_board`, `set_premise`, `rename_project` and the reminders; Fountain and Final Draft both ways; the production half (`lock_numbers`, `unlock_numbers`, `start_revision`, `end_revision`); the project as a file both ways (`export_project`, `import_project`); and, through the account door, `list_projects`, `open_project`, `new_project`, `delete_project`, `empty_account`, `delete_account`, `add_picture`, `add_take`, `list_takes`, `list_files`, `remove_file`, `build_segment`. If the dev app is open, a tool call lands on the wall within a second; if not, it edits the board file and the wall catches up on the next load.
|
|
45
45
|
- **The account door.** With `PLOTCODER_EMAIL` and `PLOTCODER_PASSWORD` in the agent's environment — the writer's own — and no dev app answering, the same server works the writer's project on the account directly, and every change lands live on every open wall. `PLOTCODER_PROJECT` picks a project by name or id. No account yet? `claim_account` makes one with the writer's email and a password they chose. The on-ramp — the doors, what to call first, the rules — is in the app behind *Are you an agent? Start here* and served at [plotcoder.com/llms.txt](https://plotcoder.com/llms.txt), both from `src/board/agents.js`.
|
|
46
46
|
|
|
47
47
|
An agent should call the tools, never fake mouse drags. The skill in `.cursor/skills/plotcoder-board/SKILL.md` says how; `.claude/skills/plotcoder-board` is a symlink to the same file.
|
package/package.json
CHANGED
|
@@ -697,11 +697,30 @@ function describeCommand(command) {
|
|
|
697
697
|
return `create_note "${command.headline ?? ""}"`;
|
|
698
698
|
case "recolor_notes":
|
|
699
699
|
return "recolor_note";
|
|
700
|
+
case "apply_poses":
|
|
701
|
+
return "organize";
|
|
700
702
|
default:
|
|
701
703
|
return command.type;
|
|
702
704
|
}
|
|
703
705
|
}
|
|
704
706
|
|
|
707
|
+
/**
|
|
708
|
+
* A board as one canonical string, so "has the board changed since my call?"
|
|
709
|
+
* compares boards and not the JSON a store happened to write: the account
|
|
710
|
+
* stores JSON in its own key order, and a byte comparison refused every undo
|
|
711
|
+
* but the first (round ten, finding 29).
|
|
712
|
+
*/
|
|
713
|
+
function canon(state) {
|
|
714
|
+
const sorted = (value) => {
|
|
715
|
+
if (Array.isArray(value)) return value.map(sorted);
|
|
716
|
+
if (value && typeof value === "object") {
|
|
717
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sorted(value[key])]));
|
|
718
|
+
}
|
|
719
|
+
return value;
|
|
720
|
+
};
|
|
721
|
+
return JSON.stringify(sorted(normalizeState(state)));
|
|
722
|
+
}
|
|
723
|
+
|
|
705
724
|
async function commit(command) {
|
|
706
725
|
const { state, rev, base, boardId } = await readBoard();
|
|
707
726
|
const { state: next, changed, result } = applyCommand(state, command);
|
|
@@ -711,7 +730,7 @@ async function commit(command) {
|
|
|
711
730
|
if (!changed) return { state: next, changed, result, live: base !== null };
|
|
712
731
|
|
|
713
732
|
const live = await writeBoard(next, rev, base, boardId);
|
|
714
|
-
trail.push({ before: state, after:
|
|
733
|
+
trail.push({ before: state, after: canon(next), what: describeCommand(command) });
|
|
715
734
|
if (trail.length > TRAIL_CAP) trail.shift();
|
|
716
735
|
undone.length = 0;
|
|
717
736
|
return { state: next, changed, result, live };
|
|
@@ -790,6 +809,15 @@ function summarize(state) {
|
|
|
790
809
|
return ` - ${character.id} — "${character.name}" on ${on} card${on === 1 ? "" : "s"}${brief}`;
|
|
791
810
|
})
|
|
792
811
|
.join("\n");
|
|
812
|
+
const placeCounts = new Map();
|
|
813
|
+
for (const note of state.notes) {
|
|
814
|
+
const phrase = (note.location ?? "").trim();
|
|
815
|
+
if (phrase) placeCounts.set(phrase, (placeCounts.get(phrase) ?? 0) + 1);
|
|
816
|
+
}
|
|
817
|
+
const places = [...placeCounts.entries()]
|
|
818
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
819
|
+
.map(([phrase, count]) => ` - "${phrase}" on ${count} card${count === 1 ? "" : "s"}`)
|
|
820
|
+
.join("\n");
|
|
793
821
|
const { beats, scenes } = countRanks(state);
|
|
794
822
|
const headline = (id) =>
|
|
795
823
|
state.notes.find((note) => note.id === id)?.headline ?? "(missing card)";
|
|
@@ -823,10 +851,14 @@ function summarize(state) {
|
|
|
823
851
|
`logline: ${state.logline ? `"${state.logline}"` : "(not set)"}`,
|
|
824
852
|
...production,
|
|
825
853
|
`beats: ${beats}, scenes: ${scenes}`,
|
|
826
|
-
|
|
854
|
+
state.targetEighths === DEFAULT_TARGET_EIGHTHS
|
|
855
|
+
? `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`
|
|
856
|
+
: `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)`,
|
|
827
857
|
`notes: ${state.notes.length}, groups: ${state.groups.length}, arrows: ${state.arrows.length}, cast: ${state.characters.length}`,
|
|
828
858
|
"cast:",
|
|
829
859
|
cast || " (no one yet — add_character to start the roster)",
|
|
860
|
+
"places (each distinct phrase is one place; near-matches sit side by side):",
|
|
861
|
+
places || " (no card says where it happens yet)",
|
|
830
862
|
"cards:",
|
|
831
863
|
notes || " (no cards)",
|
|
832
864
|
"groups:",
|
|
@@ -996,7 +1028,7 @@ server.registerTool(
|
|
|
996
1028
|
},
|
|
997
1029
|
},
|
|
998
1030
|
async (args) => {
|
|
999
|
-
|
|
1031
|
+
let { result, live } = await commit({
|
|
1000
1032
|
type: "create_note",
|
|
1001
1033
|
headline: args.headline,
|
|
1002
1034
|
change: args.change,
|
|
@@ -1027,7 +1059,12 @@ server.registerTool(
|
|
|
1027
1059
|
}
|
|
1028
1060
|
if (person) ids.push(person.id);
|
|
1029
1061
|
}
|
|
1030
|
-
if (ids.length)
|
|
1062
|
+
if (ids.length) {
|
|
1063
|
+
const cast = await commit({ type: "set_cast", ids: [result.id], characterIds: ids });
|
|
1064
|
+
// The card as it is now, cast and all, so the reply's JSON agrees with its prose.
|
|
1065
|
+
const after = cast.state.notes.find((note) => note.id === result.id);
|
|
1066
|
+
if (after) result = after;
|
|
1067
|
+
}
|
|
1031
1068
|
castLine = ` Cast: ${args.characters.map((name) => name.trim()).join(", ")}${added.length ? ` (added to the roster: ${added.join(", ")})` : ""}.`;
|
|
1032
1069
|
}
|
|
1033
1070
|
const landed = [
|
|
@@ -1139,6 +1176,20 @@ server.registerTool(
|
|
|
1139
1176
|
const lines = [
|
|
1140
1177
|
`PlotCoder wall (${door(live, base)})`,
|
|
1141
1178
|
`logline: ${state.logline ? `"${state.logline}"` : "(none yet)"}`,
|
|
1179
|
+
state.targetEighths === DEFAULT_TARGET_EIGHTHS
|
|
1180
|
+
? `runtime: about ${formatPages(boardEighths(state))} pages; no target set (set_target)`
|
|
1181
|
+
: `runtime: about ${formatPages(boardEighths(state))} pages of a ${formatPages(state.targetEighths)}-page target — ${boardEighths(state) > state.targetEighths ? `${formatPages(boardEighths(state) - state.targetEighths)} over` : boardEighths(state) < state.targetEighths ? `${formatPages(state.targetEighths - boardEighths(state))} under` : "on it"}`,
|
|
1182
|
+
`groups: ${
|
|
1183
|
+
state.groups.length
|
|
1184
|
+
? state.groups
|
|
1185
|
+
.map((group) => {
|
|
1186
|
+
const members = state.notes.filter((note) => group.noteIds.includes(note.id));
|
|
1187
|
+
const act = /^act\b/i.test((group.title ?? "").trim());
|
|
1188
|
+
return `"${group.title || "(untitled)"}" — ${members.length} card(s), about ${formatPages(members.reduce((sum, note) => sum + noteEighths(note), 0))} pages${act ? ", read as an act (never asked whether it is one sequence)" : ", read as a sequence"}`;
|
|
1189
|
+
})
|
|
1190
|
+
.join("; ")
|
|
1191
|
+
: "(none)"
|
|
1192
|
+
}`,
|
|
1142
1193
|
`pages: ${written === 0 ? "all estimates — no scene is written yet, so every card is the writer's guess" : written === state.notes.length ? "measured — every scene is written" : `estimates — ${written} of ${state.notes.length} cards are written, the rest are guesses`}`,
|
|
1143
1194
|
`beats in wall order: ${
|
|
1144
1195
|
reading.beats.length
|
|
@@ -1162,12 +1213,81 @@ server.registerTool(
|
|
|
1162
1213
|
},
|
|
1163
1214
|
);
|
|
1164
1215
|
|
|
1216
|
+
server.registerTool(
|
|
1217
|
+
"move_scene",
|
|
1218
|
+
{
|
|
1219
|
+
title: "Move a scene in the story",
|
|
1220
|
+
description:
|
|
1221
|
+
"Move a card to another place in the story order — after one card, or before one — by rewiring its follows arrows and tidying the wall along them, as one step that undo takes back whole. The story order is the follows arrows: the card leaves its place (what pointed at it now points at what it pointed at) and lands between the target and what followed it. A person does this by dragging in the outline. Needs a wall with follows arrows; on a wall without any, create_arrow the sequence first, or move_note by position.",
|
|
1222
|
+
inputSchema: { id: z.string(), after: z.string().optional(), before: z.string().optional() },
|
|
1223
|
+
},
|
|
1224
|
+
async (args) => {
|
|
1225
|
+
if (!args.after === !args.before) return ok("Say where: after one card's id, or before one, not both.");
|
|
1226
|
+
const { state } = await readBoard();
|
|
1227
|
+
const find = (id) => state.notes.find((note) => note.id === id);
|
|
1228
|
+
const card = find(args.id);
|
|
1229
|
+
const target = find(args.after ?? args.before);
|
|
1230
|
+
if (!card) return ok(`No card with id ${args.id}. Call list_board.`);
|
|
1231
|
+
if (!target) return ok(`No card with id ${args.after ?? args.before}. Call list_board.`);
|
|
1232
|
+
if (card.id === target.id) return ok("A card cannot be moved next to itself.");
|
|
1233
|
+
const isFollows = (arrow) => arrow.kind !== "setup";
|
|
1234
|
+
if (!state.arrows.some(isFollows)) {
|
|
1235
|
+
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.");
|
|
1236
|
+
}
|
|
1237
|
+
const trailBefore = trail.length;
|
|
1238
|
+
let removed = 0;
|
|
1239
|
+
let drawn = 0;
|
|
1240
|
+
let live = false;
|
|
1241
|
+
const step = async (command) => {
|
|
1242
|
+
const done = await commit(command);
|
|
1243
|
+
if (done.changed) {
|
|
1244
|
+
live = done.live;
|
|
1245
|
+
if (command.type === "delete_arrow") removed += 1;
|
|
1246
|
+
if (command.type === "create_arrow") drawn += 1;
|
|
1247
|
+
}
|
|
1248
|
+
return done;
|
|
1249
|
+
};
|
|
1250
|
+
// Leave: what pointed at the card points at what the card pointed at.
|
|
1251
|
+
const ins = state.arrows.filter((arrow) => isFollows(arrow) && arrow.to === card.id);
|
|
1252
|
+
const outs = state.arrows.filter((arrow) => isFollows(arrow) && arrow.from === card.id);
|
|
1253
|
+
for (const arrow of [...ins, ...outs]) await step({ type: "delete_arrow", id: arrow.id });
|
|
1254
|
+
for (const before of ins) for (const after of outs) if (before.from !== after.to) await step({ type: "create_arrow", from: before.from, to: after.to, kind: "follows" });
|
|
1255
|
+
// Land: between the target and what followed it (or what led to it).
|
|
1256
|
+
const { state: mid } = await readBoard();
|
|
1257
|
+
if (args.after) {
|
|
1258
|
+
for (const arrow of mid.arrows.filter((item) => isFollows(item) && item.from === target.id && item.to !== card.id)) {
|
|
1259
|
+
await step({ type: "delete_arrow", id: arrow.id });
|
|
1260
|
+
await step({ type: "create_arrow", from: card.id, to: arrow.to, kind: "follows" });
|
|
1261
|
+
}
|
|
1262
|
+
await step({ type: "create_arrow", from: target.id, to: card.id, kind: "follows" });
|
|
1263
|
+
} else {
|
|
1264
|
+
for (const arrow of mid.arrows.filter((item) => isFollows(item) && item.to === target.id && item.from !== card.id)) {
|
|
1265
|
+
await step({ type: "delete_arrow", id: arrow.id });
|
|
1266
|
+
await step({ type: "create_arrow", from: arrow.from, to: card.id, kind: "follows" });
|
|
1267
|
+
}
|
|
1268
|
+
await step({ type: "create_arrow", from: card.id, to: target.id, kind: "follows" });
|
|
1269
|
+
}
|
|
1270
|
+
const { state: linked } = await readBoard();
|
|
1271
|
+
const tidied = await step({ type: "apply_poses", poses: organizePoses(linked, {}) });
|
|
1272
|
+
const final = tidied.state;
|
|
1273
|
+
// One step for undo: the whole move, not its dozen arrows.
|
|
1274
|
+
trail.splice(trailBefore);
|
|
1275
|
+
trail.push({ before: state, after: canon(final), what: `move_scene "${card.headline}"` });
|
|
1276
|
+
undone.length = 0;
|
|
1277
|
+
const order = readingOrder(final.notes);
|
|
1278
|
+
return ok(
|
|
1279
|
+
`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(", ")}. One undo takes the whole move back.`,
|
|
1280
|
+
{ order: order.map((note) => note.id) },
|
|
1281
|
+
);
|
|
1282
|
+
},
|
|
1283
|
+
);
|
|
1284
|
+
|
|
1165
1285
|
server.registerTool(
|
|
1166
1286
|
"organize",
|
|
1167
1287
|
{
|
|
1168
1288
|
title: "Organize the wall",
|
|
1169
1289
|
description:
|
|
1170
|
-
"Tidy the wall along the arrows. Cards are ordered by their 'follows' arrows (a card comes after everything that points at it), then by reading order. With beats on the wall, each beat starts a row and the scenes that follow it fill the row to its right, wrapping under themselves when a run is long; with no beats yet, rows wrap five cards wide.
|
|
1290
|
+
"Tidy the wall along the arrows. Cards are ordered by their 'follows' arrows (a card comes after everything that points at it), then by reading order. With beats on the wall, each beat starts a row and the scenes that follow it fill the row to its right, wrapping under themselves when a run is long; with no beats yet, rows wrap five cards wide. A group's cards keep their rows, so a group that spans beats spans rows. Pass noteIds to tidy only those cards, from their own top-left. Undoable from the wall.",
|
|
1171
1291
|
inputSchema: { noteIds: z.array(z.string()).min(2).optional() },
|
|
1172
1292
|
},
|
|
1173
1293
|
async (args) => {
|
|
@@ -1186,15 +1306,17 @@ server.registerTool(
|
|
|
1186
1306
|
for (const pose of poses) byRow.set(pose.y, [...(byRow.get(pose.y) ?? []), pose]);
|
|
1187
1307
|
const wrappedUnder = [];
|
|
1188
1308
|
let currentBeat = null;
|
|
1309
|
+
let opening = 0;
|
|
1189
1310
|
for (const y of [...byRow.keys()].sort((a, b) => a - b)) {
|
|
1190
|
-
const
|
|
1191
|
-
const note = state.notes.find((item) => item.id ===
|
|
1311
|
+
const row = byRow.get(y).sort((a, b) => a.x - b.x);
|
|
1312
|
+
const note = state.notes.find((item) => item.id === row[0].id);
|
|
1192
1313
|
if (note?.rank === "beat") currentBeat = note;
|
|
1193
|
-
else if (currentBeat
|
|
1314
|
+
else if (!currentBeat) opening += row.length;
|
|
1315
|
+
else if (note && !wrappedUnder.some((item) => item.beat === currentBeat)) wrappedUnder.push({ beat: currentBeat, first: note });
|
|
1194
1316
|
}
|
|
1195
1317
|
const shape = beats
|
|
1196
|
-
? `${beats} row(s), one per beat${wrappedUnder.length ? `; ${wrappedUnder.map((item) => `the row of "${item.beat.headline}" wraps under from "${item.first.headline}"`).join(", ")}` : ""}`
|
|
1197
|
-
: `${rows} row(s)`;
|
|
1318
|
+
? `${opening ? `an opening row of ${opening} card(s) before the first beat, then ` : ""}${beats} row(s), one per beat${wrappedUnder.length ? `; ${wrappedUnder.map((item) => `the row of "${item.beat.headline}" wraps under from "${item.first.headline}"`).join(", ")}` : ""}`
|
|
1319
|
+
: `${rows} row(s) five cards wide — no beats yet, so nothing sets the rows; set_rank the turns and organize again for a row per beat`;
|
|
1198
1320
|
return ok(`Organized ${poses.length} card(s) along the arrows into ${shape}${where(live)}.`, poses);
|
|
1199
1321
|
},
|
|
1200
1322
|
);
|
|
@@ -1328,8 +1450,9 @@ server.registerTool(
|
|
|
1328
1450
|
if (!result) return ok(`No card with id ${args.id}. Call list_board.`);
|
|
1329
1451
|
return ok(`Nothing changed: "${result.headline}" already reads that way.`);
|
|
1330
1452
|
}
|
|
1453
|
+
const lines = args.text.split("\n").filter((line) => line.trim()).length;
|
|
1331
1454
|
return ok(
|
|
1332
|
-
`Wrote "${result.headline}": ${formatPages(noteEighths(result))} page(
|
|
1455
|
+
`Wrote "${result.headline}": ${lines} line(s), measured at ${formatPages(noteEighths(result))} of a page (a page is 55 lines of Courier 12; a fraction is rounded up to an eighth)${where(live)}. While the text stands the card is measured, not estimated; its estimate is untouched underneath.`,
|
|
1333
1456
|
result,
|
|
1334
1457
|
);
|
|
1335
1458
|
},
|
|
@@ -1509,7 +1632,7 @@ server.registerTool(
|
|
|
1509
1632
|
);
|
|
1510
1633
|
}
|
|
1511
1634
|
const note = unwritten
|
|
1512
|
-
? [`${unwritten} of ${order.length} scenes are unwritten and count as one line each here
|
|
1635
|
+
? [`${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.`]
|
|
1513
1636
|
: [];
|
|
1514
1637
|
return ok([`pages: ${result.pageCount} of ${Math.round(state.targetEighths / 8)}`, ...note, ...lines].join("\n"), result.scenes);
|
|
1515
1638
|
},
|
|
@@ -1729,7 +1852,7 @@ server.registerTool(
|
|
|
1729
1852
|
const last = trail[trail.length - 1];
|
|
1730
1853
|
if (!last) return ok(oneCall() ? "Nothing to undo here: through plotcoder-call every call is a fresh server, so undo works only from an MCP session. The writer can take any change back from the wall with ⌘Z." : "Nothing of mine to undo in this session.");
|
|
1731
1854
|
const { state, rev, base } = await readBoard();
|
|
1732
|
-
if (
|
|
1855
|
+
if (canon(state) !== last.after) {
|
|
1733
1856
|
return ok(
|
|
1734
1857
|
`Not undone: the board has changed since my ${last.what}. Undoing now would trample that. Ask the person to undo from the wall if they want it back.`,
|
|
1735
1858
|
);
|
|
@@ -1757,11 +1880,11 @@ server.registerTool(
|
|
|
1757
1880
|
const last = undone[undone.length - 1];
|
|
1758
1881
|
if (!last) return ok("Nothing of mine to redo.");
|
|
1759
1882
|
const { state, rev, base, boardId } = await readBoard();
|
|
1760
|
-
if (
|
|
1883
|
+
if (canon(state) !== canon(last.before)) {
|
|
1761
1884
|
return ok(`Not redone: the board has changed since I undid my ${last.what}. Redoing now would trample that.`);
|
|
1762
1885
|
}
|
|
1763
1886
|
undone.pop();
|
|
1764
|
-
const after = JSON.parse(last.after);
|
|
1887
|
+
const after = normalizeState(JSON.parse(last.after));
|
|
1765
1888
|
const live = await writeBoard(after, rev, base, boardId);
|
|
1766
1889
|
trail.push(last);
|
|
1767
1890
|
return ok(`Redid ${last.what}${where(live)}. ${undone.length} more can be redone.`, after);
|
|
@@ -1837,7 +1960,8 @@ server.registerTool(
|
|
|
1837
1960
|
? ok("Not renamed: that is already the name.")
|
|
1838
1961
|
: ok(`No character with id ${args.id}. Call list_board for the cast.`);
|
|
1839
1962
|
}
|
|
1840
|
-
|
|
1963
|
+
const followed = state.notes.filter((note) => note.characterIds.includes(result.id)).length;
|
|
1964
|
+
return ok(`Renamed to "${result.name}" (${result.id})${where(live)}; the name changed on ${followed} card${followed === 1 ? "" : "s"}.`, result);
|
|
1841
1965
|
},
|
|
1842
1966
|
);
|
|
1843
1967
|
|
|
@@ -1927,8 +2051,16 @@ server.registerTool(
|
|
|
1927
2051
|
return ok("No place changed: those cards already read that way.");
|
|
1928
2052
|
}
|
|
1929
2053
|
const place = result[0].location;
|
|
2054
|
+
// A near match on the wall is probably the same place spelled twice.
|
|
2055
|
+
const wanted = (place ?? "").trim().toLowerCase();
|
|
2056
|
+
const near = wanted
|
|
2057
|
+
? [...new Set(state.notes.map((note) => (note.location ?? "").trim()).filter(Boolean))].filter(
|
|
2058
|
+
(other) => other.toLowerCase() !== wanted && (other.toLowerCase().includes(wanted) || wanted.includes(other.toLowerCase())),
|
|
2059
|
+
)
|
|
2060
|
+
: [];
|
|
2061
|
+
const warn = near.length ? ` The wall also has ${near.map((other) => `"${other}"`).join(", ")} — the same place spelled twice, or two places? Each distinct phrase counts as one place.` : "";
|
|
1930
2062
|
return ok(
|
|
1931
|
-
`${result.length} card(s) now ${place ? `at ${place}` : "nowhere"}${where(live)}
|
|
2063
|
+
`${result.length} card(s) now ${place ? `at ${place}` : "nowhere"}${where(live)}.${warn}`,
|
|
1932
2064
|
result,
|
|
1933
2065
|
);
|
|
1934
2066
|
},
|
|
@@ -2074,7 +2206,7 @@ server.registerTool(
|
|
|
2074
2206
|
{
|
|
2075
2207
|
title: "Create arrow",
|
|
2076
2208
|
description:
|
|
2077
|
-
"Draw a directed arrow from one card to another. kind 'follows' (the default) says what comes after what; kind 'setup' says the first card plants something the second pays off. Arrows are one-way: A→B does not create B→A. If you want both, call this twice — that is two arrows, not one two-headed line. A card cannot point at itself, and the same direction cannot be drawn twice, whatever its kind; use set_arrow_kind to change one.",
|
|
2209
|
+
"Draw a directed arrow from one card to another. kind 'follows' (the default) says what comes after what — a straight sequence needs them too: organize lays the wall out along them, and the wall asks about a card no arrow touches; kind 'setup' says the first card plants something the second pays off. Arrows are one-way: A→B does not create B→A. If you want both, call this twice — that is two arrows, not one two-headed line. A card cannot point at itself, and the same direction cannot be drawn twice, whatever its kind; use set_arrow_kind to change one.",
|
|
2078
2210
|
inputSchema: { from: z.string(), to: z.string(), kind: arrowKindSchema.optional() },
|
|
2079
2211
|
},
|
|
2080
2212
|
async (args) => {
|
|
@@ -2624,7 +2756,7 @@ server.registerTool(
|
|
|
2624
2756
|
const next = renameBoard(project, target.id, args.name);
|
|
2625
2757
|
if (next === project) return ok(`"${target.name}" already has that name.`);
|
|
2626
2758
|
const live = await writeProject(next, boards, rev, base);
|
|
2627
|
-
return ok(`Renamed to "${args.name.trim()}"${where(live)}
|
|
2759
|
+
return ok(`Renamed board "${target.name}" (${target.id}) to "${args.name.trim()}"${where(live)}.`, { id: target.id, name: args.name.trim() });
|
|
2628
2760
|
},
|
|
2629
2761
|
);
|
|
2630
2762
|
|
|
@@ -2662,9 +2794,12 @@ server.registerTool(
|
|
|
2662
2794
|
inputSchema: { id: z.string() },
|
|
2663
2795
|
},
|
|
2664
2796
|
async (args) => {
|
|
2797
|
+
const { state: before } = await readBoard();
|
|
2798
|
+
const arrow = before.arrows.find((item) => item.id === args.id);
|
|
2665
2799
|
const { changed, live } = await commit({ type: "delete_arrow", id: args.id });
|
|
2666
2800
|
if (!changed) return ok(`No arrow with id ${args.id}. Call list_board for the real ids.`);
|
|
2667
|
-
|
|
2801
|
+
const name = (id) => `"${before.notes.find((note) => note.id === id)?.headline ?? id}"`;
|
|
2802
|
+
return ok(`Deleted the ${arrow?.kind ?? "follows"} arrow ${name(arrow?.from)} → ${name(arrow?.to)}${where(live)}. Any arrow the other way is untouched.`);
|
|
2668
2803
|
},
|
|
2669
2804
|
);
|
|
2670
2805
|
|
package/src/board/readWall.d.ts
CHANGED
|
@@ -59,7 +59,8 @@ export type WallReading = {
|
|
|
59
59
|
runs: Run[];
|
|
60
60
|
setups: Setup[];
|
|
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
|
+
payoffs: Record<string, string[]>;
|
|
63
64
|
findings: Finding[];
|
|
64
65
|
};
|
|
65
66
|
|
package/src/board/readWall.js
CHANGED
|
@@ -176,7 +176,7 @@ export function readWall(state) {
|
|
|
176
176
|
findings.push({
|
|
177
177
|
kind: "sag",
|
|
178
178
|
ids: [longest.from, longest.to],
|
|
179
|
-
text: `About ${pages(longest.eighths)} pages run between "${headline(longest.from)}" and "${headline(longest.to)}"; the
|
|
179
|
+
text: `About ${pages(longest.eighths)} pages run between "${headline(longest.from)}" and "${headline(longest.to)}"; the median run here is about ${pages(typical)} (a beat's own pages are in no run). Is something sagging there, or is it one long set piece?`,
|
|
180
180
|
});
|
|
181
181
|
}
|
|
182
182
|
}
|
|
@@ -303,7 +303,9 @@ export function readWall(state) {
|
|
|
303
303
|
.filter((arrow) => arrow.kind === "setup" && arrow.from === note.id)
|
|
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.
|
|
308
|
+
payoffs[note.id] = heads;
|
|
307
309
|
}
|
|
308
310
|
for (const note of order) {
|
|
309
311
|
if (note.plants && !paysOff.has(note.id)) {
|