plotcoder-board 0.1.12 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plotcoder-board",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite",
@@ -807,10 +807,12 @@ function summarize(state) {
807
807
  const cast = note.characterIds.map((id) => nameOf.get(id) ?? id);
808
808
  const who = cast.length ? `, cast: ${cast.join(", ")}` : "";
809
809
  const plant = note.plants ? (note.payoffBoardId ? ", plants → pays off later" : ", plants") : "";
810
+ const snap = state.revision?.snapshot?.[note.id];
811
+ const revised = snap && (snap.headline !== note.headline || snap.change !== note.change || (snap.text ?? "") !== (note.text ?? "") || (snap.location ?? "") !== (note.location ?? "")) ? `, changed in ${state.revision.color}` : "";
810
812
  const place = note.location ? `, at: ${note.location}` : "";
811
813
  const count = formatPages(noteEighths(note));
812
- const pages = `${count} ${count === "1" ? "page" : "pages"}${isMeasured(note) ? ", written" : note.lengthEighths === null ? ", unsized" : ""}`;
813
- return ` - ${note.id} [${note.rank ?? "scene"}, ${pages}${who}${place}${plant}] — "${note.headline}" (${note.color}) at ${Math.round(note.x)},${Math.round(note.y)}`;
814
+ const pages = isMeasured(note) ? `${count} ${count === "1" ? "page" : "pages"}, written` : note.lengthEighths === null ? "about a page, unsized" : `${count} ${count === "1" ? "page" : "pages"}`;
815
+ return ` - ${note.id} [${note.rank ?? "scene"}, ${pages}${who}${place}${plant}${revised}] — "${note.headline}" (${note.color}) at ${Math.round(note.x)},${Math.round(note.y)}`;
814
816
  })
815
817
  .join("\n");
816
818
  const cast = state.characters
@@ -936,7 +938,7 @@ server.registerTool(
936
938
  : "board";
937
939
  return ok(
938
940
  `PlotCoder ${which} (${door(live, base)})\n${summarize(state)}`,
939
- { ...state, notes: state.notes.map((note) => ({ ...note, eighths: noteEighths(note), measured: isMeasured(note) })) },
941
+ { ...state, revision: state.revision ? { name: state.revision.name, color: state.revision.color, since: state.revision.since } : null, notes: state.notes.map((note) => ({ ...note, eighths: noteEighths(note), measured: isMeasured(note) })) },
940
942
  );
941
943
  },
942
944
  );
@@ -1117,7 +1119,7 @@ server.registerTool(
1117
1119
  },
1118
1120
  },
1119
1121
  async (args) => {
1120
- const { result } = await commit({
1122
+ const { state, result, live } = await commit({
1121
1123
  type: "update_note",
1122
1124
  id: args.id,
1123
1125
  headline: args.headline,
@@ -1125,7 +1127,8 @@ server.registerTool(
1125
1127
  location: args.location,
1126
1128
  });
1127
1129
  if (result === undefined) return ok(`No card with id ${args.id}.`);
1128
- return ok("Updated card.", result);
1130
+ const mark = state.revision && state.revision.snapshot?.[result.id] && (state.revision.snapshot[result.id].headline !== result.headline || state.revision.snapshot[result.id].change !== result.change) ? ` Marked changed in the ${state.revision.color} revision "${state.revision.name}".` : "";
1131
+ return ok(`Updated "${result.headline}"${args.change !== undefined ? `: change line "${result.change}"` : ""}${where(live)}.${mark}`, result);
1129
1132
  },
1130
1133
  );
1131
1134
 
@@ -1189,8 +1192,9 @@ server.registerTool(
1189
1192
  inputSchema: {},
1190
1193
  },
1191
1194
  async () => {
1192
- const { state, live, base } = await readBoard();
1195
+ const { state, live, base, boardId: readBoardId } = await readBoard();
1193
1196
  const { project: projectForRead } = await readProject();
1197
+ const readBoardMeta = boardById(projectForRead, readBoardId ?? projectForRead.activeBoardId);
1194
1198
  const reading = readWall(state);
1195
1199
  const runs = describeRuns(reading, state).map((line, index) => {
1196
1200
  const ids = reading.runs[index]?.ids ?? [];
@@ -1200,6 +1204,8 @@ server.registerTool(
1200
1204
  const written = state.notes.filter((note) => isMeasured(note)).length;
1201
1205
  const lines = [
1202
1206
  `PlotCoder wall (${door(live, base)})`,
1207
+ ...(state.lock ? [`numbers: locked since ${String(state.lock.at).slice(0, 10)}; read_pages shows each scene's number`] : []),
1208
+ `board: "${readBoardMeta?.name ?? "?"}"${projectForRead.boards.length > 1 ? ` — ${projectForRead.boards.length} boards in "${projectForRead.name}"; open_board reads another` : ""}`,
1203
1209
  `logline: ${state.logline ? `"${state.logline}"` : "(none yet)"}`,
1204
1210
  "the cast and the places are list_board's, not the reading's",
1205
1211
  state.targetEighths === DEFAULT_TARGET_EIGHTHS
@@ -1234,12 +1240,17 @@ server.registerTool(
1234
1240
  ? reading.findings.map((finding) => ` - [${finding.kind}] ${finding.text}${finding.ids.length ? ` (ids: ${finding.ids.join(", ")})` : ""}`)
1235
1241
  : [" (none that this reading can see)"]),
1236
1242
  `checks: ${CHECKS.length} run — ${(() => {
1237
- const asked = reading.findings.filter((finding) => CHECKS.includes(finding.kind));
1243
+ const asked = reading.findings;
1238
1244
  if (asked.length === 0) return "asking nothing";
1239
1245
  const counts = new Map();
1240
1246
  for (const finding of asked) counts.set(finding.kind, (counts.get(finding.kind) ?? 0) + 1);
1241
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(", ")}`;
1242
- })()}; checked and clean: ${CHECKS.filter((kind) => !reading.findings.some((finding) => finding.kind === kind)).map((kind) => CHECK_WORDS[kind]).join("; ") || "(nothing — every check found something)"}`,
1248
+ })()}; checked and clean: ${CHECKS.filter((kind) => !reading.findings.some((finding) => finding.kind === kind)).map((kind) => {
1249
+ if (kind === "unlinked" && state.arrows.length === 0) return "no card without an arrow (not asked: no arrows yet)";
1250
+ if (kind === "unplaced" && !state.notes.some((note) => (note.location ?? "").trim())) return "no card without a place (not asked: no card placed yet)";
1251
+ if (kind === "sequence" && state.groups.length === 0) return "no group too long for one sequence (not asked: no groups)";
1252
+ return CHECK_WORDS[kind];
1253
+ }).join("; ") || "(nothing — every check found something)"}`,
1243
1254
  ];
1244
1255
  if (isSampleWall(state)) lines.unshift(SAMPLE_NOTE);
1245
1256
  return ok(lines.join("\n"), { ...reading, sample: isSampleWall(state) });
@@ -1311,7 +1322,7 @@ server.registerTool(
1311
1322
  const group = final.groups.find((item) => item.noteIds.includes(card.id));
1312
1323
  const groupLine = group ? ` It is still in "${group.title || "an untitled group"}"; a frame does not follow a move, so say if the act or sequence should change.` : "";
1313
1324
  return ok(
1314
- `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(", ")}.${groupLine} One undo takes the whole move back.`,
1325
+ `Moved "${card.headline}" to ${args.after ? "after" : "before"} "${target.headline}": ${removed} follows arrow(s) removed, ${drawn} drawn, setup arrows untouched, the wall tidied along them${where(live)}. Story order now: ${order.map((note, index) => `${index + 1}. ${note.headline}`).join(", ")}.${groupLine} One undo takes the whole move back.`,
1315
1326
  { order: order.map((note) => note.id) },
1316
1327
  );
1317
1328
  },
@@ -1399,10 +1410,9 @@ server.registerTool(
1399
1410
  const { project } = await readProject();
1400
1411
  const own = project.structures ?? [];
1401
1412
  const lines = [
1402
- `built in: ${TEMPLATES.length}`,
1403
- ...TEMPLATES.map((template) => ` - ${template.id} — "${template.name}" (${template.beats.length} beats)`),
1404
1413
  `the writer's own: ${own.length}`,
1405
- ...own.map((structure) => ` - ${structure.id} — "${structure.name}" (${structure.beats.length} beats: ${structure.beats.map((beat) => beat.name).join(", ")})`),
1414
+ ...own.map((structure) => ` - ${structure.id} — "${structure.name}" (${structure.beats.length} beats: ${structure.beats.map((beat) => `${beat.name} at ${Math.round(beat.at * 100)}%`).join(", ")})`),
1415
+ `built in: ${TEMPLATES.length} — ${TEMPLATES.map((template) => `${template.id} "${template.name}" (${template.beats.length} beats)`).join(", ")}; each beat's name, prompt and place in the story are in the JSON`,
1406
1416
  ];
1407
1417
  return ok(lines.join("\n"), { builtIn: TEMPLATES.map((template) => ({ id: template.id, name: template.name, beats: template.beats })), own });
1408
1418
  },
@@ -1424,7 +1434,7 @@ server.registerTool(
1424
1434
  const { project, boards, rev, base, live } = await readProject();
1425
1435
  const { project: next, structure } = addStructure(project, args.name, beats);
1426
1436
  await writeProject(next, boards, rev, base);
1427
- return ok(`Saved "${structure.name}" with ${beats.length} beats${where(live)}: ${beats.map((beat) => beat.name).join(", ")}.`, structure);
1437
+ return ok(`Saved "${structure.name}" with ${beats.length} beats${where(live)}: ${beats.map((beat) => `${beat.name} at ${Math.round(beat.at * 100)}%`).join(", ")}. Each beat's prompt is its change line here; apply_template lays the same turns on another board, the next episode's, as beat cards to fill.`, structure);
1428
1438
  },
1429
1439
  );
1430
1440
 
@@ -1487,7 +1497,7 @@ server.registerTool(
1487
1497
  }
1488
1498
  const printed = sceneLineCount(args.text);
1489
1499
  return ok(
1490
- `Wrote "${result.headline}": ${printed} line(s) as they print (headings, blank lines and wrapped dialogue counted), measured at ${formatPages(noteEighths(result))} of a 55-line page, rounded to the nearest eighth and never below one${where(live)}. The heading comes from the card's place, so the text starts with the action. While the text stands the card is measured, not estimated; its estimate is untouched underneath.`,
1500
+ `Wrote "${result.headline}": ${printed} line(s) as they print (headings, blank lines and wrapped dialogue counted), measured at ${formatPages(noteEighths(result))} of a 55-line page, rounded to the nearest eighth and never below one${where(live)}. The heading comes from the card's place, so the text starts with the action. While the text stands the card is measured, not estimated; every reading uses the measure (eighths here), and the estimate underneath (lengthEighths) is untouched.`,
1491
1501
  { ...result, eighths: noteEighths(result), measured: true, printedLines: printed },
1492
1502
  );
1493
1503
  },
@@ -1508,6 +1518,7 @@ server.registerTool(
1508
1518
  const text = toFountain(state, { title: board?.name, premise: project.premise || undefined });
1509
1519
  const parsed = fromFountain(text);
1510
1520
  const ids = mergeFountain(state, parsed).matched.map((item) => item.id);
1521
+ const pageNumbers = state.lock ? sceneNumbers(readingOrder(state.notes), state.lock) : null;
1511
1522
  const lines = [];
1512
1523
  let index = 0;
1513
1524
  for (const line of text.split("\n")) {
@@ -1515,7 +1526,8 @@ server.registerTool(
1515
1526
  const note = state.notes.find((item) => item.id === ids[index]);
1516
1527
  index += 1;
1517
1528
  const standIn = note && !(note.location ?? "").trim() ? " · no place: the headline stands in for the heading" : "";
1518
- lines.push(`${line} [[id: ${note?.id ?? "?"} · ${note && isMeasured(note) ? "measured" : "estimated"} ${formatPages(note ? noteEighths(note) : 0)}pp${standIn}]]`);
1529
+ const numbered = note && pageNumbers?.get(note.id) ? ` · locked no. ${pageNumbers.get(note.id)}` : "";
1530
+ lines.push(`${line} [[id: ${note?.id ?? "?"} · ${note && isMeasured(note) ? "measured" : "estimated"} ${formatPages(note ? noteEighths(note) : 0)}pp${standIn}${numbered}]]`);
1519
1531
  } else {
1520
1532
  lines.push(line);
1521
1533
  }
@@ -1593,7 +1605,7 @@ server.registerTool(
1593
1605
  const { state } = await readBoard();
1594
1606
  const { project } = await readProject();
1595
1607
  const board = project.boards.find((item) => item.id === project.activeBoardId);
1596
- const brief = segmentBrief(state, args.ids, { title: board?.name });
1608
+ const brief = segmentBrief(state, args.ids, { title: board?.name, boards: project.boards });
1597
1609
  if (!brief) return ok(`No cards with ids ${args.ids.join(", ")}. Call list_board.`);
1598
1610
  return ok(brief);
1599
1611
  },
@@ -1672,7 +1684,7 @@ server.registerTool(
1672
1684
  const note = unwritten
1673
1685
  ? [`${unwritten} of ${order.length} scenes are unwritten and count as one line each here, so this is the script so far, not the runtime: the estimate from the cards is about ${formatPages(boardEighths(state))} pages, the number to use until the scenes are written.`]
1674
1686
  : [];
1675
- return ok([`pages: ${result.pageCount} of ${Math.round(state.targetEighths / 8)}`, ...note, ...lines].join("\n"), result.scenes);
1687
+ return ok([`pages: ${result.pageCount} of ${Math.round(state.targetEighths / 8)}`, `scene numbers here are ${state.lock ? "the locked numbers" : "wall order (not locked)"}`, ...note, ...lines].join("\n"), result.scenes);
1676
1688
  },
1677
1689
  );
1678
1690
 
@@ -1697,8 +1709,10 @@ server.registerTool(
1697
1709
  "unlock_numbers",
1698
1710
  { title: "Unlock the scene numbers", description: "Numbers follow the wall's order again.", inputSchema: {} },
1699
1711
  async () => {
1712
+ const { state: before } = await readBoard();
1713
+
1700
1714
  const { changed, live } = await commit({ type: "unlock_numbers" });
1701
- return ok(changed ? `Unlocked${where(live)}.` : "The numbers were not locked.");
1715
+ return ok(changed ? `Unlocked ${Object.keys(before.lock?.numbers ?? {}).length} scene number(s)${where(live)}; scenes number by wall order again.` : "The numbers were not locked.");
1702
1716
  },
1703
1717
  );
1704
1718
 
@@ -1708,11 +1722,13 @@ server.registerTool(
1708
1722
  title: "Start a revision",
1709
1723
  description:
1710
1724
  `Name a revision and give it one of the industry's colours (${REVISION_COLORS.join(", ")}). Every card is snapshotted; from then on a changed line prints in the colour with a star in the margin, and a changed card wears the colour on the wall.`,
1711
- inputSchema: { name: z.string().min(1), color: z.string().optional() },
1725
+ inputSchema: { name: z.string().optional(), color: z.string().optional() },
1712
1726
  },
1713
1727
  async (args) => {
1714
- const { changed, result, live } = await commit({ type: "start_revision", name: args.name, color: args.color });
1715
- if (!changed) return ok("No revision started: give it a name.");
1728
+ const revisionName = (args.name ?? "").trim() || (args.color ? `${args.color.charAt(0).toUpperCase()}${args.color.slice(1)}` : "");
1729
+ if (!revisionName) return ok("No revision started: give it a name, or a colour to name it after.");
1730
+ const { changed, result, live } = await commit({ type: "start_revision", name: revisionName, color: args.color });
1731
+ if (!changed) return ok("No revision started: one is already in progress; end_revision first.");
1716
1732
  return ok(`Started the ${result.color} revision "${result.name}"${where(live)}.`, { name: result.name, color: result.color, since: result.since });
1717
1733
  },
1718
1734
  );
@@ -1721,8 +1737,10 @@ server.registerTool(
1721
1737
  "end_revision",
1722
1738
  { title: "End the revision", description: "The marks come off; the snapshot is dropped.", inputSchema: {} },
1723
1739
  async () => {
1740
+ const { state: before } = await readBoard();
1741
+
1724
1742
  const { changed, live } = await commit({ type: "end_revision" });
1725
- return ok(changed ? `Revision ended${where(live)}.` : "No revision in progress.");
1743
+ return ok(changed ? `Revision "${before.revision?.name ?? ""}" (${before.revision?.color ?? ""}) ended${where(live)}: its marks come off and its snapshot is dropped.` : "No revision in progress.");
1726
1744
  },
1727
1745
  );
1728
1746
 
@@ -1740,7 +1758,7 @@ server.registerTool(
1740
1758
  const { state } = await readBoard();
1741
1759
  const { project } = await readProject();
1742
1760
  const board = project.boards.find((item) => item.id === project.activeBoardId);
1743
- const brief = segmentBrief(state, args.ids, { title: board?.name });
1761
+ const brief = segmentBrief(state, args.ids, { title: board?.name, boards: project.boards });
1744
1762
  if (!brief) return ok(`No cards with ids ${args.ids.join(", ")}. Call list_board.`);
1745
1763
  const provider = env.PLOTCODER_VIDEO_PROVIDER;
1746
1764
  if (!provider) {
@@ -1900,8 +1918,10 @@ server.registerTool(
1900
1918
  const { boardId } = await readBoard();
1901
1919
  const live = await writeBoard(last.before, rev, base, boardId);
1902
1920
  const orderLine = /^(move_scene|organize)/.test(last.what) ? ` Story order now: ${readingOrder(last.before.notes).map((note, index) => `${index + 1}. ${note.headline}`).join(", ")}.` : "";
1921
+ const cardsDiff = last.before.notes.length - state.notes.length;
1922
+ const countLine = cardsDiff > 0 ? ` ${cardsDiff} card(s) back.` : cardsDiff < 0 ? ` ${-cardsDiff} card(s) gone.` : "";
1903
1923
  const more = trail.length >= TRAIL_CAP ? `${trail.length} more of mine can be undone — the most I keep, so the oldest have gone` : `${trail.length} more of mine can be undone`;
1904
- return ok(`Undid ${last.what}${where(live)}.${orderLine} ${more}.`, last.before);
1924
+ return ok(`Undid ${last.what}${where(live)}.${orderLine}${countLine} ${more}. list_board has the board.`, { undid: last.what, notes: last.before.notes.length, arrows: last.before.arrows.length, groups: last.before.groups.length });
1905
1925
  },
1906
1926
  );
1907
1927
 
@@ -1924,7 +1944,7 @@ server.registerTool(
1924
1944
  const after = normalizeState(JSON.parse(last.after));
1925
1945
  const live = await writeBoard(after, rev, base, boardId);
1926
1946
  trail.push(last);
1927
- return ok(`Redid ${last.what}${where(live)}. ${undone.length} more can be redone.`, after);
1947
+ return ok(`Redid ${last.what}${where(live)}. ${undone.length} more can be redone. list_board has the board.`, { redid: last.what, notes: after.notes.length, arrows: after.arrows.length, groups: after.groups.length });
1928
1948
  },
1929
1949
  );
1930
1950
 
@@ -1933,7 +1953,7 @@ server.registerTool(
1933
1953
  {
1934
1954
  title: "Fold the corner",
1935
1955
  description:
1936
- `Fold the corner of cards — mark them as planting something — or unfold them. ${wordSentence("corner")} The setup arrow is create_arrow with kind 'setup'. A fold that pays off in a later episode: pass later, another board of the project by name, id or number, and the wall stops asking where it comes back; later '' forgets it. Folding never moves a card.`,
1956
+ `Fold the corner of cards — mark them as planting something — or unfold them. ${wordSentence("corner")} The setup arrow is create_arrow with kind 'setup'. A fold that pays off in a later episode: pass later, another board of the project by name, id or number — a board that exists; new_board makes one — and the wall stops asking where it comes back, listing the card under the reading's 'later' instead of 'payoffs'; later '' forgets it. Folding never moves a card.`,
1937
1957
  inputSchema: {
1938
1958
  ids: z.array(z.string()).min(1),
1939
1959
  plants: z.boolean(),
@@ -1963,7 +1983,7 @@ server.registerTool(
1963
1983
  }
1964
1984
  } else {
1965
1985
  const target = findBoard(project, args.later);
1966
- if (!target) return ok(`No board matches "${args.later}". Call list_boards for the project's boards; a fold pays off later on one of them.`);
1986
+ if (!target) return ok(`No board called "${args.later}" yet. A fold pays off later on a board of the project: new_board "${args.later}" makes it (empty), open_board back to this one, then set_plant again with later.`);
1967
1987
  if (target.id === (current ?? project.activeBoardId)) return ok(`"${target.name}" is this board. A payoff on the same board is a setup arrow: create_arrow from the fold to the scene, kind 'setup'.`);
1968
1988
  const named = await commit({ type: "set_payoff_board", ids: args.ids, boardId: target.id });
1969
1989
  if (named.changed) {
@@ -2280,6 +2300,7 @@ server.registerTool(
2280
2300
  inputSchema: { from: z.string(), to: z.string(), kind: arrowKindSchema.optional() },
2281
2301
  },
2282
2302
  async (args) => {
2303
+ const { boardId } = await readBoard();
2283
2304
  const { state, changed, result, live } = await commit({
2284
2305
  type: "create_arrow",
2285
2306
  from: args.from,
@@ -2290,13 +2311,19 @@ server.registerTool(
2290
2311
  // Say which of the three reasons it was. "Something went wrong" makes an
2291
2312
  // agent retry the same call; naming the cause makes it fix the input.
2292
2313
  const onBoard = (id) => state.notes.some((note) => note.id === id);
2314
+ const { project: wholeProject, boards: allBoards } = await readProject();
2315
+ const elsewhere = (id) => wholeProject.boards.find((meta) => meta.id !== (boardId ?? wholeProject.activeBoardId) && isBoardState(allBoards[meta.id]) && allBoards[meta.id].notes.some((note) => note.id === id));
2316
+ const missing = (id) => {
2317
+ const other = elsewhere(id);
2318
+ return other ? `card ${id} is on another board, "${other.name}" — an arrow stays on one board; a fold that pays off there is set_plant with later: "${other.name}"` : `there is no card with id ${id}`;
2319
+ };
2293
2320
  const why =
2294
2321
  args.from === args.to
2295
2322
  ? "a card cannot point at itself"
2296
2323
  : !onBoard(args.from)
2297
- ? `there is no card with id ${args.from}`
2324
+ ? missing(args.from)
2298
2325
  : !onBoard(args.to)
2299
- ? `there is no card with id ${args.to}`
2326
+ ? missing(args.to)
2300
2327
  : "that arrow already exists";
2301
2328
  return ok(`No arrow drawn: ${why}. Call list_board to check.`);
2302
2329
  }
@@ -2444,8 +2471,8 @@ server.registerTool(
2444
2471
  const own = list.filter((item) => !item.builtIn).length;
2445
2472
  return ok(
2446
2473
  [
2447
- `reminders on "${project.name}" (${door(live, base)}): ${list.length} — ${list.length - own} the house principles the app starts with (built in), ${own} the writer's own${own === 0 ? "; add_reminder adds one the writer asks to keep" : ""}`,
2448
- ...list.map((item) => ` - ${item.id}${item.builtIn ? " (built in)" : ""} — ${item.title}: ${item.body}`),
2474
+ `reminders on "${project.name}" (${door(live, base)}): ${list.length} — ${list.length - own} the house principles the app starts with (built in), ${own} the writer's own${own === 0 ? "; add_reminder adds one the writer asks to keep" : ""}. Reminders live on the project and go with it`,
2475
+ ...list.map((item) => ` - ${item.id}${item.builtIn ? " (built in)" : ""} — ${item.body.replace(/\.$/, "").startsWith(item.title.replace(/\.$/, "")) ? item.body : `${item.title}: ${item.body}`}`),
2449
2476
  ].join("\n"),
2450
2477
  list,
2451
2478
  );
@@ -2584,12 +2611,13 @@ server.registerTool(
2584
2611
  title: "Start a project",
2585
2612
  description:
2586
2613
  "Through the account door: start a new project of the writer's with this name — one empty board, nothing on it — and work it from now on. The writer sees it under Projects on every device.",
2587
- inputSchema: { name: z.string().min(1), pages: pagesSchema.optional(), minutes: z.number().positive().optional() },
2614
+ inputSchema: { name: z.string().min(1), board: z.string().optional(), pages: pagesSchema.optional(), minutes: z.number().positive().optional() },
2588
2615
  },
2589
2616
  async (args) => {
2590
2617
  const account = await findAccount();
2591
2618
  if (!account) return shut("No account door: there is one project here, the open one. Set PLOTCODER_EMAIL and PLOTCODER_PASSWORD to start another on the writer's account.");
2592
- const record = renameProject(emptyProject(), args.name.trim());
2619
+ let record = renameProject(emptyProject(), args.name.trim());
2620
+ if (args.board?.trim()) record = renameBoard(record, record.activeBoardId, args.board.trim());
2593
2621
  const inserted = await account.client.from("projects").insert({ id: record.id, record, reminders: null, rev: 1 });
2594
2622
  if (inserted.error) return ok(`Could not start the project: ${inserted.error.message}`);
2595
2623
  const target = args.pages ?? args.minutes;
@@ -2645,7 +2673,7 @@ server.registerTool(
2645
2673
  for (const row of owned) plans.push(await deletionPlan(row));
2646
2674
  const survive = shared.length ? ` ${shared.length} project(s) shared with the writer by others stay: ${shared.map((row) => `"${row.record.name}"`).join(", ")}.` : "";
2647
2675
  if (plans.length === 0) return ok(`The account holds nothing of the writer's own to delete.${survive}`);
2648
- if (!args.confirm) return ok(`Emptying the account deletes ${plans.length} project(s) of the writer's own: ${plans.map(describePlan).join("; ")}. Cannot be undone. Ask the writer; export_project each first if they might want them back; then pass confirm: true.${survive}`, plans);
2676
+ if (!args.confirm) return ok(`Emptying the account deletes ${plans.length} project(s) of the writer's own: ${plans.map(describePlan).join("; ")}. Cannot be undone. Ask the writer, or if they have already said so, pass confirm: true now; export_project each first if they might want them back.${survive}`, plans);
2649
2677
  for (const plan of plans) await deleteProjectRows(plan);
2650
2678
  const next = await workWhatIsLeft(plans.map((plan) => plan.id));
2651
2679
  return ok(`Emptied the account as ${account.email}: deleted ${plans.map(describePlan).join("; ")}.${survive}${next}`, plans);
@@ -2708,7 +2736,9 @@ server.registerTool(
2708
2736
  const { project, boards, reminders } = await readProject();
2709
2737
  const file = toProjectFile({ project, boards, reminders: reminders ?? null });
2710
2738
  const cards = countCards(boards);
2711
- const what = `"${project.name}": ${project.boards.length} board(s), ${cards} card(s)${reminders?.length ? `, ${reminders.length} reminder(s)` : ", no reminders of the writer's own (none to write)"}${project.structures?.length ? `, ${project.structures.length} structure(s)` : ", no structures of the writer's own (none to write)"}. Pictures and takes on the account are not in the file`;
2739
+ const ownReminders = (reminders ?? []).filter((item) => !item.builtIn).length;
2740
+ const builtInReminders = (reminders ?? []).length - ownReminders;
2741
+ const what = `"${project.name}": ${project.boards.length} board(s) — ${project.boards.map((meta) => `"${meta.name}" (${isBoardState(boards[meta.id]) ? boards[meta.id].notes.length : 0} cards)`).join(", ")} — ${cards} card(s) in all${reminders?.length ? `, ${reminders.length} reminder(s) (${builtInReminders} built in, ${ownReminders} the writer's own)` : ", the six built-in reminders come with every project and no reminders of the writer's own (none to write)"}${project.structures?.length ? `, ${project.structures.length} structure(s)` : ", no structures of the writer's own (none to write)"}. Pictures and takes on the account are not in the file`;
2712
2742
  if (args.path) {
2713
2743
  fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
2714
2744
  fs.writeFileSync(args.path, JSON.stringify(file, null, 2));
@@ -2808,7 +2838,7 @@ server.registerTool(
2808
2838
  const fresh = { ...emptyState(), ...(target ? { targetEighths: target } : {}) };
2809
2839
  const { live } = await openBoardEverywhere(next, { ...boards, [board.id]: fresh }, rev, base, board.id);
2810
2840
  return ok(
2811
- `Added "${board.name}" (${board.id}) and opened it${where(live)}. It is empty. The logline is the story's question when the writer has one — leave it empty rather than invent it — and the cards come next.${next.name === "Untitled project" ? " The project is still \"Untitled project\": rename_project names it." : ""}${next.boards.length === 2 && isSampleWall(isBoardState(boards[next.boards[0].id]) ? normalizeState(boards[next.boards[0].id]) : emptyState()) ? " The sample stays as Board 1; delete_board drops it." : ""}`,
2841
+ `Added "${board.name}" (${board.id}) and opened it${where(live)}: every card call lands there now, and the writer's open wall switched with it; open_board "${project.boards.findIndex((item) => item.id === project.activeBoardId) + 1}" comes back. It is empty. The logline is the story's question when the writer has one — leave it empty rather than invent it — and the cards come next.${next.name === "Untitled project" ? " The project is still \"Untitled project\": rename_project names it." : ""}${next.boards.length === 2 && isSampleWall(isBoardState(boards[next.boards[0].id]) ? normalizeState(boards[next.boards[0].id]) : emptyState()) ? " The sample stays as Board 1; delete_board drops it." : ""}`,
2812
2842
  board,
2813
2843
  );
2814
2844
  },
@@ -42,12 +42,12 @@ export const AGENTS = {
42
42
  text: "Skip this when the account is the wall. Without an account, a wall is a folder: any folder, empty is fine — choose one that will outlive your session, never a scratch one. The app run from that folder shows the wall, and the server writes it there (PLOTCODER_ROOT, or the folder it is run from). A fresh folder holds the sample; new_board for the writer's wall, then rename_project. No app running? export_fountain is the wall in order, as text.",
43
43
  },
44
44
  ],
45
- firstNote: "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.",
45
+ firstNote: "Make these four before anything else; none depends on another, so any order is fine. They are about the wall you will work, so after open_project, open_board, new_project or empty_account, read_wall again. On an account with no project yet, read_wall has nothing to read and says so, and list_reminders gives the house principles every project starts with; new_project, then the four again. No server in front of you, and no shell to take the shell door? Nothing gets you in from inside the session: say so, and ask the person to wire the server and start a new session.",
46
46
  first: [
47
47
  { tool: "list_words", why: "the room's words, the app's meaning." },
48
- { tool: "read_wall", why: "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." },
48
+ { tool: "read_wall", why: "the reading: the beats, the runs, the setups, and what the wall asks. The records — every card, the cast, the places — are list_board's. A fresh folder holds a sample wall (Maya, Tom, the letter) and says so; it is not the writer's." },
49
49
  { tool: "list_workflows", why: "what a writer can ask you for." },
50
- { tool: "list_reminders", why: "the house principles the app starts with, and the writer's own; read them before you change anything." },
50
+ { tool: "list_reminders", why: "the house principles the app starts with, and the writer's own; read them before you change anything. Reminders live on the project and go with it." },
51
51
  ],
52
52
  rules: [
53
53
  "Questions, not fixes, until the writer says.",
@@ -64,7 +64,7 @@ export const AGENTS = {
64
64
 
65
65
  /** The on-ramp as one text: the file at /llms.txt, and what an agent reads. */
66
66
  export function agentsAsText() {
67
- const lines = ["# PlotCoder — for agents", "", AGENTS.lead, "", `The guide, 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"];
67
+ const lines = ["# PlotCoder — for agents", "", AGENTS.lead, "", `The guide: ${AGENTS.guide}. Read it once, before your first call if you can; it is the whole and this page is its first page, and where the two differ, the guide wins. Then Call these first, below. The doors between are for wiring a server in; skip them when the tools are already in front of you.`, "", "## Doors"];
68
68
  for (const door of AGENTS.doors) {
69
69
  lines.push(`- ${door.name}: ${door.text}`);
70
70
  if (door.code) lines.push("", "```", door.code, "```", "");
@@ -304,7 +304,10 @@ export function readWall(state) {
304
304
  .map((arrow) => arrow.to)
305
305
  .sort((a, b) => (wallIndex.get(a) ?? Infinity) - (wallIndex.get(b) ?? Infinity));
306
306
  // Every payoff, in wall order: a card can plant two things (the ledger
307
- // pays off at the cash and again at the initials), and both count.
307
+ // pays off at the cash and again at the initials), and both count. A
308
+ // fold that pays off only on another board is not here at all — it is
309
+ // under `later` — so the two never disagree about one card.
310
+ if (!heads.length && note.payoffBoardId) continue;
308
311
  payoffs[note.id] = heads;
309
312
  }
310
313
  // A fold that pays off on another board (R50) is not unpaid: it is listed
@@ -809,7 +809,8 @@ export function applyCommand(state, command, now = nowIso()) {
809
809
  id: newId(),
810
810
  headline: item.name,
811
811
  change: item.prompt,
812
- color: NOTE_COLORS[(state.notes.length + index) % NOTE_COLORS.length],
812
+ // One colour: paper means nothing to the app, and a structure is not a pattern (round eleven).
813
+ color: "yellow",
813
814
  x: left + (index % 5) * (NOTE_WIDTH + 28),
814
815
  y: top + Math.floor(index / 5) * (NOTE_HEIGHT + 40),
815
816
  rotate: ((index % 5) - 2) * 0.8,
@@ -22,5 +22,5 @@ export declare function workflowById(id: string): Workflow | null;
22
22
  export declare function segmentBrief(
23
23
  state: BoardState,
24
24
  ids: string[],
25
- options?: { title?: string },
25
+ options?: { title?: string; boards?: { id: string; name: string }[] },
26
26
  ): string | null;
@@ -91,6 +91,7 @@ function personLine(character) {
91
91
  if (character.voice) lines.push(`voice: ${character.voice}`);
92
92
  if (character.wants) lines.push(`wants: ${character.wants}`);
93
93
  if (character.needs) lines.push(`needs: ${character.needs}`);
94
+ if (character.notes) lines.push(`notes: ${character.notes}`);
94
95
  return `${character.name}${lines.length ? ` — ${lines.join("; ")}` : " — (no page yet)"}`;
95
96
  }
96
97
 
@@ -120,7 +121,12 @@ export function segmentBrief(state, ids, options = {}) {
120
121
  lines.push("");
121
122
  lines.push(`SCENE: ${note.headline}${note.location ? ` — at ${note.location}` : ""}`);
122
123
  lines.push(`WHAT CHANGES: ${note.change}`);
123
- if (note.plants) lines.push("PLANTS: something here pays off later; keep it visible.");
124
+ if (note.plants) {
125
+ const heads = state.arrows.filter((arrow) => arrow.kind === "setup" && arrow.from === note.id).map((arrow) => byId.get(arrow.to)?.headline).filter(Boolean);
126
+ const later = note.payoffBoardId ? (options.boards ?? []).find((board) => board.id === note.payoffBoardId)?.name ?? "a later board" : null;
127
+ const where = heads.length ? `pays off at ${heads.map((headline) => `"${headline}"`).join(" and ")}` : later ? `pays off later, on "${later}"` : "pays off later, nowhere yet";
128
+ lines.push(`PLANTS: something here ${where}; keep it visible.`);
129
+ }
124
130
  if (note.text && note.text.trim()) {
125
131
  lines.push("SCRIPT:");
126
132
  lines.push(note.text.trim());
@@ -129,6 +135,6 @@ export function segmentBrief(state, ids, options = {}) {
129
135
  }
130
136
  }
131
137
  lines.push("");
132
- lines.push(`AFTER: ${notes.at(-1).change}`);
138
+ lines.push(`AFTER: ${notes.at(-1).change}${notes.length === 1 ? " (the change line, until the scene is written)" : ""}`);
133
139
  return lines.join("\n");
134
140
  }