plotcoder-board 0.1.6 → 0.1.8

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.6",
3
+ "version": "0.1.8",
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 };
@@ -1142,7 +1161,7 @@ server.registerTool(
1142
1161
  {
1143
1162
  title: "Read the wall",
1144
1163
  description:
1145
- "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.",
1164
+ "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.",
1146
1165
  inputSchema: {},
1147
1166
  },
1148
1167
  async () => {
@@ -1157,7 +1176,21 @@ server.registerTool(
1157
1176
  const lines = [
1158
1177
  `PlotCoder wall (${door(live, base)})`,
1159
1178
  `logline: ${state.logline ? `"${state.logline}"` : "(none yet)"}`,
1160
- `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`}`,
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, so its length is not questioned" : ", read as a sequence"}`;
1189
+ })
1190
+ .join("; ")
1191
+ : "(none)"
1192
+ }`,
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${written <= 5 ? ` (${state.notes.filter((note) => isMeasured(note)).map((note) => `"${note.headline}"`).join(", ")})` : ""}, the rest are guesses`}`,
1161
1194
  `beats in wall order: ${
1162
1195
  reading.beats.length
1163
1196
  ? reading.beats.map((beat) => `"${beat.headline}"`).join(", ")
@@ -1180,12 +1213,81 @@ server.registerTool(
1180
1213
  },
1181
1214
  );
1182
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
+
1183
1285
  server.registerTool(
1184
1286
  "organize",
1185
1287
  {
1186
1288
  title: "Organize the wall",
1187
1289
  description:
1188
- "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.",
1189
1291
  inputSchema: { noteIds: z.array(z.string()).min(2).optional() },
1190
1292
  },
1191
1293
  async (args) => {
@@ -1204,14 +1306,16 @@ server.registerTool(
1204
1306
  for (const pose of poses) byRow.set(pose.y, [...(byRow.get(pose.y) ?? []), pose]);
1205
1307
  const wrappedUnder = [];
1206
1308
  let currentBeat = null;
1309
+ let opening = 0;
1207
1310
  for (const y of [...byRow.keys()].sort((a, b) => a - b)) {
1208
- const first = byRow.get(y).sort((a, b) => a.x - b.x)[0];
1209
- 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);
1210
1313
  if (note?.rank === "beat") currentBeat = note;
1211
- 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 });
1212
1316
  }
1213
1317
  const shape = beats
1214
- ? `${beats} row(s), one per beat${wrappedUnder.length ? `; ${wrappedUnder.map((item) => `the row of "${item.beat.headline}" wraps under from "${item.first.headline}"`).join(", ")}` : ""}`
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(", ")}` : ""}`
1215
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`;
1216
1320
  return ok(`Organized ${poses.length} card(s) along the arrows into ${shape}${where(live)}.`, poses);
1217
1321
  },
@@ -1346,8 +1450,9 @@ server.registerTool(
1346
1450
  if (!result) return ok(`No card with id ${args.id}. Call list_board.`);
1347
1451
  return ok(`Nothing changed: "${result.headline}" already reads that way.`);
1348
1452
  }
1453
+ const lines = args.text.split("\n").filter((line) => line.trim()).length;
1349
1454
  return ok(
1350
- `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.`,
1351
1456
  result,
1352
1457
  );
1353
1458
  },
@@ -1527,7 +1632,7 @@ server.registerTool(
1527
1632
  );
1528
1633
  }
1529
1634
  const note = unwritten
1530
- ? [`${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.`]
1531
1636
  : [];
1532
1637
  return ok([`pages: ${result.pageCount} of ${Math.round(state.targetEighths / 8)}`, ...note, ...lines].join("\n"), result.scenes);
1533
1638
  },
@@ -1747,7 +1852,7 @@ server.registerTool(
1747
1852
  const last = trail[trail.length - 1];
1748
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.");
1749
1854
  const { state, rev, base } = await readBoard();
1750
- if (JSON.stringify(state) !== last.after) {
1855
+ if (canon(state) !== last.after) {
1751
1856
  return ok(
1752
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.`,
1753
1858
  );
@@ -1775,11 +1880,11 @@ server.registerTool(
1775
1880
  const last = undone[undone.length - 1];
1776
1881
  if (!last) return ok("Nothing of mine to redo.");
1777
1882
  const { state, rev, base, boardId } = await readBoard();
1778
- if (JSON.stringify(state) !== JSON.stringify(last.before)) {
1883
+ if (canon(state) !== canon(last.before)) {
1779
1884
  return ok(`Not redone: the board has changed since I undid my ${last.what}. Redoing now would trample that.`);
1780
1885
  }
1781
1886
  undone.pop();
1782
- const after = JSON.parse(last.after);
1887
+ const after = normalizeState(JSON.parse(last.after));
1783
1888
  const live = await writeBoard(after, rev, base, boardId);
1784
1889
  trail.push(last);
1785
1890
  return ok(`Redid ${last.what}${where(live)}. ${undone.length} more can be redone.`, after);
@@ -1855,7 +1960,8 @@ server.registerTool(
1855
1960
  ? ok("Not renamed: that is already the name.")
1856
1961
  : ok(`No character with id ${args.id}. Call list_board for the cast.`);
1857
1962
  }
1858
- 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);
1859
1965
  },
1860
1966
  );
1861
1967
 
@@ -2522,7 +2628,7 @@ server.registerTool(
2522
2628
  {
2523
2629
  title: "Save the project as a file",
2524
2630
  description:
2525
- "The project the server is working, as the file Save project writes and Open project takes: the record, every board with its cards, the reminders and the writer's structures. Pass path to write it (a .json); without a path, the reply's JSON is the file. Pictures and takes on the account are not in the file. Works through every door.",
2631
+ "The project the server is working, as the file Save project writes and Open project takes: the record, every board with its cards, the reminders and the writer's structures. Pass path to write it (a .json) — an absolute path, since a relative one resolves from the server's own folder, not yours; without a path, the reply's JSON is the file. Pictures and takes on the account are not in the file. Works through every door.",
2526
2632
  inputSchema: { path: z.string().optional() },
2527
2633
  },
2528
2634
  async (args) => {
@@ -2534,7 +2640,7 @@ server.registerTool(
2534
2640
  if (args.path) {
2535
2641
  fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
2536
2642
  fs.writeFileSync(args.path, JSON.stringify(file, null, 2));
2537
- return ok(`Saved ${what}. Written to ${args.path}: Open project in the app takes it, import_project brings it onto an account.`, { path: args.path, boards: project.boards.length, cards });
2643
+ return ok(`Saved ${what}. Written to ${path.resolve(args.path)}: Open project in the app takes it, import_project brings it onto an account.`, { path: path.resolve(args.path), boards: project.boards.length, cards });
2538
2644
  }
2539
2645
  return ok(`The project as a file — ${what}. The JSON below is the file; write it to a .json for Open project or import_project.`, file);
2540
2646
  },
@@ -2688,9 +2794,12 @@ server.registerTool(
2688
2794
  inputSchema: { id: z.string() },
2689
2795
  },
2690
2796
  async (args) => {
2797
+ const { state: before } = await readBoard();
2798
+ const arrow = before.arrows.find((item) => item.id === args.id);
2691
2799
  const { changed, live } = await commit({ type: "delete_arrow", id: args.id });
2692
2800
  if (!changed) return ok(`No arrow with id ${args.id}. Call list_board for the real ids.`);
2693
- 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.`);
2694
2803
  },
2695
2804
  );
2696
2805
 
@@ -7,7 +7,7 @@
7
7
 
8
8
  export const AGENTS = {
9
9
  lead:
10
- "A storyline wall. Cards are scenes, beats are the big turns, arrows say what follows or pays off what. An agent driven by a person has every tool a person here has; the person directs, the agent operates. Call the tools; never fake a mouse.",
10
+ "A storyline wall. Cards are scenes, beats are the big turns, arrows say what follows or pays off what. An agent driven by a person has every tool a person here has; the person directs, the agent operates. Call the tools; never fake a mouse. Already have the plotcoder-board tools in front of you? Skip the doors and go to Call these first.",
11
11
  doors: [
12
12
  {
13
13
  id: "mcp",
@@ -42,7 +42,7 @@ export const AGENTS = {
42
42
  text: "Skip this when the account is the wall. Without an account, a wall is a folder: any folder, empty is fine — choose one that will outlive your session, never a scratch one. The app run from that folder shows the wall, and the server writes it there (PLOTCODER_ROOT, or the folder it is run from). A fresh folder holds the sample; new_board for the writer's wall, then rename_project. No app running? export_fountain is the wall in order, as text.",
43
43
  },
44
44
  ],
45
- firstNote: "These four are about the wall you will work, so after open_project or open_board, read_wall again. On an account with no project yet, read_wall has nothing to read and says so, and list_reminders gives the house principles every project starts with; new_project, then the four again. No server in front of you, and no shell to take the shell door? Nothing gets you in from inside the session: say so, and ask the person to wire the server and start a new session.",
45
+ firstNote: "These four are about the wall you will work, so after open_project, open_board, new_project or empty_account, read_wall again. On an account with no project yet, read_wall has nothing to read and says so, and list_reminders gives the house principles every project starts with; new_project, then the four again. No server in front of you, and no shell to take the shell door? Nothing gets you in from inside the session: say so, and ask the person to wire the server and start a new session.",
46
46
  first: [
47
47
  { tool: "list_words", why: "the room's words, the app's meaning." },
48
48
  { tool: "read_wall", why: "what is here, and what it asks. A fresh folder holds a sample wall (Maya, Tom, the letter) and says so; it is not the writer's." },
@@ -64,7 +64,7 @@ export const AGENTS = {
64
64
 
65
65
  /** The on-ramp as one text: the file at /llms.txt, and what an agent reads. */
66
66
  export function agentsAsText() {
67
- const lines = ["# PlotCoder — for agents", "", AGENTS.lead, "", `The guide, read once before anything: ${AGENTS.guide}.`, "", "## Doors"];
67
+ const lines = ["# PlotCoder — for agents", "", AGENTS.lead, "", `The guide, read once before anything: ${AGENTS.guide}. The guide is the whole and this page is its first page; where the two differ, the guide wins.`, "", "## Doors"];
68
68
  for (const door of AGENTS.doors) {
69
69
  lines.push(`- ${door.name}: ${door.text}`);
70
70
  if (door.code) lines.push("", "```", door.code, "```", "");
@@ -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
  }