plotcoder-board 0.1.24 → 0.1.25

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
@@ -51,10 +51,11 @@ An agent should call the tools, never fake mouse drags. The skill in `.cursor/sk
51
51
  A **blind run** is a fresh agent given the on-ramp and a treatment and nothing
52
52
  else, asked to build a wall and to keep a log of everything that made the job
53
53
  harder than it should have been. The friction log is the product; the wall is
54
- just what produces it. Sixteen rounds have been run, the first three through a
54
+ just what produces it. Seventeen rounds have been run, the first three through a
55
55
  repo checkout and the rest through the account door; every finding from the
56
- first fifteen is fixed or decided, and forty-one of round sixteen's
57
- forty-eight were fixed the same night, while it ran. [`blind-runs/`](blind-runs/) holds the rules that keep a round honest,
56
+ first sixteen is fixed or decided, and round seventeen, the first to start from
57
+ a page of notes instead of a treatment, found the wall has no way to hold a
58
+ maybe — the open card, R59, proposed and mocked from it. [`blind-runs/`](blind-runs/) holds the rules that keep a round honest,
58
59
  the table of rounds, and the next round's prompt with the test account filled in.
59
60
 
60
61
  A round works a **test account** — a throwaway marked on its writer row, and the
@@ -99,4 +100,4 @@ next round into a test of `claim_account` instead of the door it meant to test.
99
100
 
100
101
  ## Status
101
102
 
102
- Version 0.1.24. A project of boards; sign in with your email and a password from the PlotCoder mark and your projects follow you to every device, share one with another writer by email and write it together live, or stay signed out and work on this device as before. Pages sit beside the wall: a scene's text lives on its card, measures it, paginates to the industry's rules, prints, goes out and comes in as Fountain or Final Draft, and goes out as Markdown or plain text for a collaborator in Google Docs. It installs as a progressive web app and opens offline; plotcoder.com serves over HTTPS. The wall, beats, card length, groups, arrows, pan and zoom, save and open, and the agent surface are in use.
103
+ Version 0.1.25. A project of boards; sign in with your email and a password from the PlotCoder mark and your projects follow you to every device, share one with another writer by email and write it together live, or stay signed out and work on this device as before. Pages sit beside the wall: a scene's text lives on its card, measures it, paginates to the industry's rules, prints, goes out and comes in as Fountain or Final Draft, and goes out as Markdown or plain text for a collaborator in Google Docs. It installs as a progressive web app and opens offline; plotcoder.com serves over HTTPS. The wall, beats, card length, groups, arrows, pan and zoom, save and open, and the agent surface are in use.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plotcoder-board",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite",
@@ -805,7 +805,8 @@ let lastReading = null;
805
805
  const sinceRead = [];
806
806
  /** What the last write did to the wall's questions and runtime, said once on that write's tail (round fourteen, entries 18, 19, 42). */
807
807
  let lastChange = null;
808
- const findingKey = (finding) => `${finding.kind}|${finding.ids.join(",")}|${finding.text}`;
808
+ // The same question in the same words is the same question, whatever order its names come in (round seventeen, entry 21).
809
+ const findingKey = (finding) => `${finding.kind}|${[...finding.ids].sort().join(",")}`;
809
810
  function noteChange(before, after, boardId = null) {
810
811
  // The same reading read_wall gives: a person cast on another board is not
811
812
  // asked about, so a write's tail never names a question the reading does not.
@@ -967,9 +968,10 @@ function isSampleWall(state) {
967
968
  return state.notes.map((note) => note.headline).sort().join("\n") === sample;
968
969
  }
969
970
  /** Every check read_wall runs, so silence can be named. */
970
- const CHECKS = ["sag", "empty", "unwritten", "unlinked", "duplicate", "sequence", "uncast", "absent", "backwards", "unpaid", "unplaced"];
971
+ const CHECKS = ["unmarked", "sag", "empty", "unwritten", "unlinked", "duplicate", "sequence", "uncast", "nobody", "absent", "backwards", "unpaid", "unplanted", "unplaced"];
971
972
  /** What each check looks for, in words, so "clean" says what was checked rather than a kind's name. */
972
973
  const CHECK_WORDS = {
974
+ unmarked: "a beat is marked",
973
975
  sag: "no run out of proportion",
974
976
  empty: "no beats back to back",
975
977
  unwritten: "no card without a headline or change line",
@@ -977,9 +979,11 @@ const CHECK_WORDS = {
977
979
  duplicate: "no two headlines alike",
978
980
  sequence: "no group too long for one sequence (act groups are not asked)",
979
981
  uncast: "nobody in the cast on no card of the project",
982
+ nobody: "no card with nobody in it",
980
983
  absent: "nobody gone for a third of the story",
981
984
  backwards: "no payoff before its setup",
982
985
  unpaid: "no fold without a payoff",
986
+ unplanted: "no payoff without its fold",
983
987
  unplaced: "no card without a place",
984
988
  };
985
989
  const SAMPLE_NOTE = "sample: this is the wall PlotCoder starts with (Maya, Tom, the letter); nothing here is the writer's. Replace it, or new_board.";
@@ -1491,7 +1495,7 @@ server.registerTool(
1491
1495
  {
1492
1496
  title: "Read the wall",
1493
1497
  description:
1494
- "Read the board back: the beats in wall order (rows top to bottom, cards left to right), the pages of scenes between consecutive beats with the cards in each, every setup with the distance to its payoff, and the questions the wall raises — no beat marked yet; a run out of proportion with the others; beats back to back with nothing between them (a chain of them is one question); a card with a placeholder headline or no change line; a card no arrow touches; two headlines that read like the same scene; a group too long to be one sequence; a person in the cast on no card; a person gone for more than a third of the story and ten pages; a payoff before its setup on the wall; a folded card no setup arrow pays off; cards that say no place once any card has one. These are questions, not fixes: put them to the writer and do not act on them unasked. A question the writer answers with \"leave it\" is left with leave_question and listed under \"left, for now\" instead, until it would read differently. It says nothing about how many beats there should be, and neither should you. The prose carries every id; PLOTCODER_JSON=1 in the server's environment adds the same reading as JSON after it, for a program.",
1498
+ "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; a setup arrow leaving a card that is not folded; a card with nobody in it once the wall has a cast; cards that say no place once any card has one. These are questions, not fixes: put them to the writer and do not act on them unasked. A question the writer answers with \"leave it\" is left with leave_question and listed under \"left, for now\" instead, until it would read differently. It says nothing about how many beats there should be, and neither should you. The prose carries every id; PLOTCODER_JSON=1 in the server's environment adds the same reading as JSON after it, for a program.",
1495
1499
  inputSchema: {},
1496
1500
  },
1497
1501
  async () => {
@@ -1519,7 +1523,7 @@ server.registerTool(
1519
1523
  "the cast and the places are list_board's, not the reading's",
1520
1524
  state.targetEighths === DEFAULT_TARGET_EIGHTHS
1521
1525
  ? `runtime: about ${formatPages(boardEighths(state))} pages; no target set (set_target)`
1522
- : `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"}`,
1526
+ : `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"} (the number to use until the scenes are written; page_count is the script so far)`,
1523
1527
  `groups: ${
1524
1528
  state.groups.length
1525
1529
  ? state.groups
@@ -1532,6 +1536,8 @@ server.registerTool(
1532
1536
  : "(none)"
1533
1537
  }`,
1534
1538
  `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`}`,
1539
+ // A wall with cards and no follows arrows has no story order yet; say so rather than read the rows as one (round seventeen, entries 10, 11).
1540
+ `story order: ${state.notes.length > 1 && !state.arrows.some((arrow) => arrow.kind !== "setup") ? "unset — no follows arrows, so the rows stand in for it; create_arrow the sequence and the reading, the numbers and every export follow the arrows" : "the follows arrows, and the rows where they say nothing"}`,
1535
1541
  `beats in wall order: ${
1536
1542
  reading.beats.length
1537
1543
  ? reading.beats.map((beat) => `"${beat.headline}"`).join(", ")
@@ -1563,7 +1569,11 @@ server.registerTool(
1563
1569
  for (const finding of asked) counts.set(finding.kind, (counts.get(finding.kind) ?? 0) + 1);
1564
1570
  return `asking ${asked.length} question${asked.length === 1 ? "" : "s"} of ${counts.size} kind${counts.size === 1 ? "" : "s"}: ${[...counts.entries()].map(([kind, n]) => (n > 1 ? `${kind} ×${n}` : kind)).join(", ")}${held}`;
1565
1571
  })()}${reading.left.length ? `; left by the writer, so not clean: ${[...new Set(reading.left.map((finding) => finding.kind))].map((kind) => `[${kind}]`).join(" ")}` : ""}; checked and clean: ${CHECKS.filter((kind) => !reading.findings.some((finding) => finding.kind === kind) && !reading.left.some((finding) => finding.kind === kind)).map((kind) => {
1566
- if (kind === "unlinked" && state.arrows.length === 0) return "no card without an arrow (not asked: no arrows yet)";
1572
+ if (kind === "unlinked" && state.arrows.length === 0) return "no card without an arrow (not asked until half the cards are wired: no arrows yet)";
1573
+ if (kind === "unlinked") {
1574
+ const linked = new Set(state.arrows.flatMap((arrow) => [arrow.from, arrow.to]));
1575
+ if (linked.size * 2 < state.notes.length) return `no card without an arrow (not asked until half the cards are wired: ${linked.size} of ${state.notes.length} are)`;
1576
+ }
1567
1577
  if (kind === "unplaced" && !state.notes.some((note) => (note.location ?? "").trim())) return "no card without a place (not asked: no card placed yet)";
1568
1578
  if (kind === "sequence" && state.groups.length === 0) return "no group too long for one sequence (not asked: no groups)";
1569
1579
  return CHECK_WORDS[kind];
@@ -2985,34 +2995,30 @@ server.registerTool(
2985
2995
  {
2986
2996
  title: "Cast a scene",
2987
2997
  description:
2988
- "Set who is in one or more cards of the open board (open_board first for another board's cards). Takes card ids and character names or ids; the list replaces the card's cast, so pass everyone who is in the scene. An empty list clears it. Names must already be in the cast add_character first and the tool says which names it did not know.",
2998
+ "Set who is in one or more cards of the open board (open_board first for another board's cards). Takes card ids and character names or ids; the list replaces the card's cast, so pass everyone who is in the scene. An empty list clears it. A name not yet in the cast is added to it, as create_note does, and the reply says so the writer named them, so it is not inventing; a role is a name.",
2989
2999
  inputSchema: {
2990
3000
  noteIds: z.array(z.string()).min(1),
2991
3001
  characters: z.array(z.string()),
2992
3002
  },
2993
3003
  },
2994
3004
  async (args) => {
2995
- const { state: before } = await readBoard();
2996
- const unknown = [];
2997
- const characterIds = [];
2998
- for (const who of args.characters) {
2999
- const match = before.characters.find(
3000
- (character) =>
3001
- character.id === who || character.name.trim().toLowerCase() === who.trim().toLowerCase(),
3002
- );
3003
- if (match) characterIds.push(match.id);
3004
- else unknown.push(who);
3005
- }
3006
- if (unknown.length > 0) {
3007
- return ok(
3008
- `No cast set: not in the cast — ${unknown.map((name) => `"${name}"`).join(", ")}. Call add_character for each, then cast again.`,
3009
- );
3010
- }
3011
- const { state, changed, result, live } = await commit({
3012
- type: "set_cast",
3013
- ids: args.noteIds,
3014
- characterIds,
3005
+ // A name the cast does not have is added to it in the same frame, as
3006
+ // create_note does — two tools, one rule (round seventeen, entry 14).
3007
+ const added = [];
3008
+ const { state, changed, value: result, live } = await commitAll(`cast ${args.noteIds.length} card(s)`, (step, current) => {
3009
+ const characterIds = [];
3010
+ for (const who of args.characters) {
3011
+ const wanted = who.trim().toLowerCase();
3012
+ let person = current().characters.find((character) => character.id === who || character.name.trim().toLowerCase() === wanted);
3013
+ if (!person && wanted) {
3014
+ person = step({ type: "add_character", name: who.trim() }).result;
3015
+ if (person) added.push(`${person.name} (${person.id})`);
3016
+ }
3017
+ if (person && !characterIds.includes(person.id)) characterIds.push(person.id);
3018
+ }
3019
+ return step({ type: "set_cast", ids: args.noteIds, characterIds }).result;
3015
3020
  });
3021
+ const characterIds = (result ?? []).length ? result[0].characterIds : [];
3016
3022
  if (!changed) {
3017
3023
  const missing = args.noteIds.filter((id) => !state.notes.some((note) => note.id === id));
3018
3024
  return ok(
@@ -3025,7 +3031,7 @@ server.registerTool(
3025
3031
  (id) => state.characters.find((character) => character.id === id)?.name ?? id,
3026
3032
  );
3027
3033
  return ok(
3028
- `${result.length} card(s) now cast ${names.length ? names.join(", ") : "nobody"}: ${result.map((note) => `"${note.headline}"`).join(", ")}${where(live)}.`,
3034
+ `${result.length} card(s) now cast ${names.length ? names.join(", ") : "nobody"}: ${result.map((note) => `"${note.headline}"`).join(", ")}${added.length ? ` (added to the cast: ${added.join(", ")})` : ""}${where(live)}.`,
3029
3035
  result,
3030
3036
  );
3031
3037
  },
@@ -3251,7 +3257,7 @@ server.registerTool(
3251
3257
  {
3252
3258
  title: "Set the project's premise",
3253
3259
  description:
3254
- "Set the project's premise: the series- or story-level line above every board's logline. An empty string clears it. Boards keep their own loglines.",
3260
+ "Set the project's premise: the line above every board's logline, held by the project whatever its board count — what a series is about, or what is true before a film starts ('the third year; the crowns can be cut for the first time'). An empty string clears it. Boards keep their own loglines.",
3255
3261
  inputSchema: { premise: z.string() },
3256
3262
  },
3257
3263
  async (args) => {
@@ -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. Already have the plotcoder-board tools in front of you? Then your way in is: read the guide, make the four calls under Call these first, and skip the doors between — they are for wiring a server in.",
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? Then your way in is: read the guide, make the five calls under Call these first, and skip the doors at the end — they are for wiring a server in.",
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: "Make these five before anything else; none depends on another, so any order is fine. list_words and list_workflows are the app's and read no project, and their first line says so; every other reading opens with the door it came through — the project it read and how many the account holds — and a write's tail says where it landed; list_projects lists the projects. read_wall, list_reminders and list_board are about the wall you will work, so after open_project or open_board make those three again, and after new_project read the wall once it holds cards. An emptied account has nothing to read: go straight to new_project (name, pages, and board for the first board's name). 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. 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 five before anything else; none depends on another, so any order is fine. list_words and list_workflows are the app's and read no project, and their first line says so; every other reading opens with the door it came through — the project it read and how many the account holds — and a write's tail says where it landed; list_projects lists the projects. read_wall, list_reminders and list_board are about the wall you will work, so after open_project or open_board make those three again, and after new_project read the wall once it holds cards. An emptied account has nothing to read: go straight to new_project: a name is enough; pages sets the target and board names the first board when the writer has them, and set_target and rename_board set them later. 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. 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: "the reading: the beats, the runs, the setups, and what the wall asks. The records — every card, the cast, the places, the rows — are list_board's. A fresh folder holds a sample wall (Maya, Tom, the letter) and the reading says so only when it is the sample; it is not the writer's." },
@@ -20,6 +20,10 @@ export type FindingKind =
20
20
  | "unlinked"
21
21
  /** Two headlines read like the same scene. */
22
22
  | "duplicate"
23
+ /** A setup arrow leaves a card whose corner is not folded: a payoff with no fold. */
24
+ | "unplanted"
25
+ /** A card with nobody in it, on a wall that has a cast. */
26
+ | "nobody"
23
27
  /** A group runs too long to be one sequence. */
24
28
  | "sequence"
25
29
  /** A character in the roster who is on no card. */
@@ -17,7 +17,7 @@
17
17
  // before any tidy — the arrows are the writer's claim about the order, and
18
18
  // where they say nothing the positions decide.
19
19
 
20
- import { boardEighths, EIGHTHS_PER_PAGE, formatPages, NOTE_HEIGHT, noteEighths } from "./reducer.js";
20
+ import { isMeasured, boardEighths, EIGHTHS_PER_PAGE, formatPages, NOTE_HEIGHT, noteEighths } from "./reducer.js";
21
21
 
22
22
  /** What create_note writes before a person has. */
23
23
  export const PLACEHOLDER_HEADLINE = "New beat";
@@ -359,6 +359,20 @@ export function readWall(state, options = {}) {
359
359
  }
360
360
  }
361
361
 
362
+ // A payoff with no fold (round seventeen, entry 12): a setup arrow leaves a
363
+ // card whose corner is not folded. The arrow says "this pays off", the
364
+ // card says nothing was planted — ask which.
365
+ for (const setup of setups) {
366
+ const tail = byId.get(setup.from);
367
+ if (tail && !tail.plants) {
368
+ findings.push({
369
+ kind: "unplanted",
370
+ ids: [setup.id, setup.from],
371
+ text: `${quote(tail)} pays off at ${quote(byId.get(setup.to))} by a setup arrow, but its corner is not folded. Fold it, or is the arrow wrong?`,
372
+ });
373
+ }
374
+ }
375
+
362
376
  // A folded corner nothing has paid off (R31). The fold says "this plants
363
377
  // something"; a setup arrow leaving the card is the payoff. Until one does,
364
378
  // the debt is open.
@@ -416,6 +430,19 @@ export function readWall(state, options = {}) {
416
430
  const hereIds = new Set(order.map((note) => note.id));
417
431
  const paidHere = paidBy.filter((item) => hereIds.has(item.id));
418
432
 
433
+ // A card with nobody in it, once the wall has a cast (round seventeen,
434
+ // entry 28): a scene nobody is in passed every check.
435
+ if ((state.characters ?? []).length > 0) {
436
+ const empty = order.filter((note) => !(note.characterIds ?? []).length);
437
+ if (empty.length) {
438
+ findings.push({
439
+ kind: "nobody",
440
+ ids: empty.map((note) => note.id),
441
+ text: `${empty.length === 1 ? `${quote(empty[0])} has nobody in it` : `${empty.length} cards have nobody in them: ${empty.map((note) => quote(note)).join(", ")}`}. Who is in the scene${empty.length === 1 ? "" : "s"}?`,
442
+ });
443
+ }
444
+ }
445
+
419
446
  // The cast (R29): someone who vanishes for a stretch, or never appears.
420
447
  const total = boardEighths(state);
421
448
  const at = new Map();
@@ -525,6 +552,10 @@ export function describeRuns(reading, state) {
525
552
  ? `After "${name(run.from)}"`
526
553
  : `"${name(run.from)}" → "${name(run.to)}"`;
527
554
  const count = run.cards === 1 ? "1 card" : `${run.cards} cards`;
528
- return `${span}: about ${pages(run.eighths)} pages, ${count}`;
555
+ // Whose number a run's pages are (round seventeen, entry 39): measured
556
+ // from written text, or the cards' guess, or some of each.
557
+ const written = (run.ids ?? []).filter((id) => isMeasured(byId.get(id) ?? {})).length;
558
+ const whose = !run.cards ? "" : written === run.cards ? ", measured" : written ? `, ${written} of ${run.cards} measured` : ", estimated";
559
+ return `${span}: about ${pages(run.eighths)} pages, ${count}${whose}`;
529
560
  });
530
561
  }
@@ -36,7 +36,7 @@ export const WORD_GROUPS = [
36
36
  {
37
37
  id: "premise",
38
38
  name: "The premise",
39
- sentence: "Above the logline when a project has several boards: the line the whole series is about.",
39
+ sentence: "The line above every board's logline, the project's whatever its board count: what a series is about, or what is true before a film starts.",
40
40
  },
41
41
  {
42
42
  id: "beat",