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 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-one 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`, `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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plotcoder-board",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite",
@@ -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: JSON.stringify(next), what: describeCommand(command) });
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
- `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)${state.targetEighths === DEFAULT_TARGET_EIGHTHS ? " — the target is the feature default, nobody's choice yet; set_target for a pilot or a half-hour" : ""}`,
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
- const { result, live } = await commit({
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) await commit({ type: "set_cast", ids: [result.id], characterIds: ids });
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. Groups stay together. Pass noteIds to tidy only those cards, from their own top-left. Undoable from the wall.",
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 first = byRow.get(y).sort((a, b) => a.x - b.x)[0];
1191
- const note = state.notes.find((item) => item.id === first.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 && note && !wrappedUnder.some((item) => item.beat === currentBeat)) wrappedUnder.push({ beat: currentBeat, first: note });
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(s) measured${where(live)}.`,
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; for the estimate from the cards' lengths, see list_board's runtime line.`]
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 (JSON.stringify(state) !== last.after) {
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 (JSON.stringify(state) !== JSON.stringify(last.before)) {
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
- return ok(`Renamed to "${result.name}"${where(live)}.`, result);
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
- return ok(`Deleted that arrow${where(live)}. Any arrow the other way is untouched.`);
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
 
@@ -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
- payoffs: Record<string, string | null>;
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
 
@@ -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 middle 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?`,
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
- payoffs[note.id] = heads[0] ?? null;
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)) {