plotcoder-board 0.1.36 → 0.1.38

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
@@ -53,7 +53,7 @@ An agent should call the tools, never fake mouse drags. The skill in `.cursor/sk
53
53
  A **blind run** is a fresh agent given the on-ramp and a treatment and nothing
54
54
  else, asked to build a wall and to keep a log of everything that made the job
55
55
  harder than it should have been. The friction log is the product; the wall is
56
- just what produces it. Twenty rounds have been run, the first three through a
56
+ just what produces it. Twenty-one rounds have been run, the first three through a
57
57
  repo checkout and the rest through the account door; every finding from the
58
58
  first seventeen is fixed or decided. Round seventeen, the first to start from
59
59
  a page of notes instead of a treatment, found the wall has no way to hold a
@@ -107,4 +107,4 @@ next round into a test of `claim_account` instead of the door it meant to test.
107
107
 
108
108
  ## Status
109
109
 
110
- Version 0.1.36. 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.
110
+ Version 0.1.38. 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.36",
3
+ "version": "0.1.38",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite",
@@ -835,7 +835,7 @@ function changeNote() {
835
835
  const parts = [];
836
836
  if (change.gone.length || change.came.length) {
837
837
  parts.push(
838
- `the wall now asks ${change.asks} question${change.asks === 1 ? "" : "s"}${change.gone.length ? ` (gone: ${change.gone.map((finding) => `[${finding.kind}] ${finding.text}`).join(" ")})` : ""}${change.came.length ? ` (new: ${change.came.map((finding) => `[${finding.kind}] ${finding.text}`).join(" ")})` : ""}`,
838
+ `the wall now asks ${change.asks} question${change.asks === 1 ? "" : "s"}${change.gone.length ? ` (gone: ${change.gone.map((finding) => `[${finding.kind}] ${finding.text.replace(/\.$/, "")}`).join(" ")})` : ""}${change.came.length ? ` (new: ${change.came.map((finding) => `[${finding.kind}] ${finding.text.replace(/\.$/, "")}`).join(" ")})` : ""}`,
839
839
  );
840
840
  }
841
841
  if (change.leftAfter !== change.leftBefore) parts.push(`left, for now: ${change.leftAfter} (was ${change.leftBefore})`);
@@ -982,7 +982,7 @@ const CHECK_WORDS = {
982
982
  sag: "no run out of proportion",
983
983
  empty: "no beats back to back",
984
984
  unwritten: "no card without a headline or change line",
985
- unlinked: "no card without an arrow",
985
+ unlinked: "no card without a follows arrow (a setup arrow is a claim, not a place in the story)",
986
986
  duplicate: "no two headlines alike",
987
987
  sequence: "no group too long for one sequence (act groups are not asked)",
988
988
  uncast: "nobody in the cast on no card of the project",
@@ -1345,11 +1345,13 @@ server.registerTool(
1345
1345
  rank: rankSchema.optional(),
1346
1346
  pages: pagesSchema.optional(),
1347
1347
  plants: z.boolean().optional(),
1348
- plantsWhat: z.string().optional(),
1348
+ plantsWhat: z.string().optional().describe("What the folded corner plants, in the writer's words; naming it folds the card."),
1349
1349
  after: z.string().optional().describe("Wire the new scene into the story after this card (id or headline): one call, one number under a lock. On a wall with no follows arrows yet this draws the first, so a wall can be built in order from its second card."),
1350
1350
  before: z.string().optional().describe("Or before this card (id or headline)."),
1351
1351
  location: z.string().optional(),
1352
1352
  when: z.string().optional().describe('When the scene happens, as the writer says it — "night", "day four, dawn" — printed after the place on the scene heading.'),
1353
+ locationOpen: z.string().optional().describe("The writer's words for why the place is not decided (R61's edge): the card is born with its place open, listed and not asked where, while its other questions stand."),
1354
+ whenOpen: z.string().optional().describe("The writer's words for why the when is not decided: the card is born with its when open, listed and not asked."),
1353
1355
  open: z.string().optional().describe("The writer's words for what is not decided about this card — \"whether Tom knows\" — so the card is born open: the reading lists it and asks nothing else of it until the words are cleared."),
1354
1356
  characters: z.array(z.string().min(1)).optional(),
1355
1357
  x: z.number().optional(),
@@ -1389,6 +1391,8 @@ server.registerTool(
1389
1391
  plants: args.plants,
1390
1392
  plantsWhat: args.plantsWhat,
1391
1393
  location: args.location,
1394
+ locationOpen: args.locationOpen,
1395
+ whenOpen: args.whenOpen,
1392
1396
  when: args.when,
1393
1397
  open: args.open,
1394
1398
  x: landing.x,
@@ -1433,8 +1437,8 @@ server.registerTool(
1433
1437
  result?.lengthEighths === null ? "about a page (unsized: the writer's guess until set_length)" : `${formatPages(noteEighths(result))} ${formatPages(noteEighths(result)) === "1" ? "page" : "pages"}`,
1434
1438
  result?.color ? `${result.color} paper${args.color ? "" : once("paper", " (pass color to choose)")}` : null,
1435
1439
  result?.plants ? `corner folded${result.plantsWhat ? ` — plants ${result.plantsWhat}` : ""}` : null,
1436
- result?.location ? `at ${result.location}` : `no place yet${once("place", " (location here, or set_location)")}`,
1437
- result?.when ? `when: ${result.when}` : null,
1440
+ result?.location ? `at ${result.location}` : result?.locationOpen ? `place open: "${result.locationOpen}" (listed, not asked where)` : `no place yet${once("place", " (location here, or set_location)")}`,
1441
+ result?.when ? `when: ${result.when}` : result?.whenOpen ? `when open: "${result.whenOpen}" (listed, not asked)` : null,
1438
1442
  result?.open ? `open: "${result.open}" (listed, not asked about)` : null,
1439
1443
  ].filter(Boolean).join(", ");
1440
1444
  // Where it landed matters only until the tidy, so the reply says the rule once and never the coordinates (round fourteen, entry 11).
@@ -1669,7 +1673,10 @@ server.registerTool(
1669
1673
  if (kind === "sequence" && state.groups.length === 0) return "no group too long for one sequence (not asked: no groups)";
1670
1674
  // A kind clean only because an open card is not asked says so (round eighteen, entry 18).
1671
1675
  const hiddenBy = reading.open.filter((item) => item.hides.includes(kind)).length;
1672
- if (hiddenBy) return `${CHECK_WORDS[kind]} (except ${hiddenBy} open card${hiddenBy === 1 ? "" : "s"}, not asked)`;
1676
+ // A place left open is not an open card (round twenty-one, entry 26): say which.
1677
+ const placesOpen = kind === "unplaced" ? reading.openFields.filter((field) => field.field === "location").length : 0;
1678
+ const except = [hiddenBy ? `${hiddenBy} open card${hiddenBy === 1 ? "" : "s"}` : "", placesOpen ? `${placesOpen} with ${placesOpen === 1 ? "its" : "their"} place open` : ""].filter(Boolean).join(" and ");
1679
+ if (except) return `${CHECK_WORDS[kind]} (except ${except}, not asked)`;
1673
1680
  return CHECK_WORDS[kind];
1674
1681
  }).join("; ") || "(nothing — every check found something)"}`,
1675
1682
  ];
@@ -1975,7 +1982,6 @@ server.registerTool(
1975
1982
  const poses = organizePoses(state, { onlyIds: args.noteIds });
1976
1983
  if (poses.length === 0) return ok("Nothing to organize: no cards in scope.");
1977
1984
  const { changed, live } = await commit({ type: "apply_poses", poses });
1978
- if (!changed) return ok("Nothing moved.");
1979
1985
  const rows = new Set(poses.map((pose) => pose.y)).size;
1980
1986
  const beats = state.notes.filter(
1981
1987
  (note) => note.rank === "beat" && poses.some((pose) => pose.id === note.id),
@@ -1997,6 +2003,8 @@ server.registerTool(
1997
2003
  const shape = beats
1998
2004
  ? `${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}", indented under the row's first scene, never under the beat`).join(", ")}` : ""}`
1999
2005
  : `${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`;
2006
+ // Nothing moved says what already stands (round fifteen, entry 20): after a rank change the agent asked whether a beat row still held.
2007
+ if (!changed) return ok(`Nothing moved: the ${poses.length} card(s) already lie along the arrows in ${shape}.`, poses);
2000
2008
  return ok(`Organized ${poses.length} card(s) along the arrows into ${shape}${where(live)}.`, poses);
2001
2009
  },
2002
2010
  );
@@ -2286,7 +2294,7 @@ server.registerTool(
2286
2294
  if (/^\.(?!\.)/.test(line) && index < ids.length) {
2287
2295
  const note = state.notes.find((item) => item.id === ids[index]);
2288
2296
  index += 1;
2289
- const standIn = note && !(note.location ?? "").trim() ? `${note.open ? " · open card" : ""} · no place: the headline stands in for the heading${note.open ? ", not a place" : ""}` : "";
2297
+ const standIn = note && !(note.location ?? "").trim() ? ((note.locationOpen ?? "").trim() ? ` · place open: the writer's words head the scene, marked, not a place` : `${note.open ? " · open card" : ""} · no place: the headline stands in for the heading${note.open ? ", not a place" : ""}`) : "";
2290
2298
  const numbered = note && pageNumbers?.get(note.id) ? ` · locked no. ${pageNumbers.get(note.id)}` : "";
2291
2299
  const mark = note ? marks.get(note.id) : null;
2292
2300
  const sourceLines = (note?.text ?? "").split("\n");
@@ -3505,6 +3513,53 @@ function describeBoards(project, boards, changedAt = null) {
3505
3513
  .join("\n");
3506
3514
  }
3507
3515
 
3516
+ /**
3517
+ * The project as a whole (round fifteen, entry 14; the handover's first
3518
+ * call): every board's questions in one reading, with what each board
3519
+ * leaves open, the folds that pay off on another board, and the project's
3520
+ * length. Each board is read as read_wall reads it — the same checks, the
3521
+ * same cross-board context — so this never says something read_wall would
3522
+ * not; it only says it for every board at once.
3523
+ */
3524
+ server.registerTool(
3525
+ "read_project",
3526
+ {
3527
+ title: "Read the project",
3528
+ description:
3529
+ "Every board's reading in one call: for each board in the writer's order, its logline, its runtime, the questions it asks and what it leaves open by the writer's word — the same checks read_wall runs, board by board — then the folds that pay off on another board and the project's length. The records — cards, ids, cast, places — are list_board's, board by board; open_board and read_wall for one board in full.",
3530
+ inputSchema: {},
3531
+ },
3532
+ async () => {
3533
+ const { project, boards, live, base } = await readProject();
3534
+ const states = new Map(project.boards.map((meta) => [meta.id, isBoardState(boards[meta.id]) ? normalizeState(boards[meta.id]) : emptyState()]));
3535
+ const boardsNow = Object.fromEntries(states);
3536
+ const lines = [
3537
+ `PlotCoder project "${project.name}"${project.nameOpen ? ` — its name is open, by the writer's word: "${project.nameOpen}"` : ""} (${door(live, base)})`,
3538
+ `premise: ${project.premiseOpen ? `open, by the writer's word — "${project.premiseOpen}"` : project.premise ? `"${project.premise}"` : "(not set)"}`,
3539
+ ];
3540
+ let asked = 0;
3541
+ for (const [index, meta] of project.boards.entries()) {
3542
+ const state = states.get(meta.id);
3543
+ const reading = readWall(state, { elsewhere: Object.keys(castElsewhere(project, boardsNow, meta.id)), laterBoards: laterBoards(project, boardsNow), paidBy: landingsOn(project, boardsNow, meta.id).paid });
3544
+ asked += reading.findings.length;
3545
+ const open = reading.open.length + reading.openFields.length + (meta.nameOpen ? 1 : 0);
3546
+ const headline = (id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`;
3547
+ lines.push(
3548
+ `${index + 1}. "${meta.name}"${meta.nameOpen ? ` (name open: "${meta.nameOpen}")` : ""}${meta.id === project.activeBoardId ? " (open)" : ""} — ${state.notes.length} card${state.notes.length === 1 ? "" : "s"}, ${reading.beats.length} beat${reading.beats.length === 1 ? "" : "s"}, about ${formatPages(boardEighths(state))} of ${formatPages(state.targetEighths)} pages; logline: ${state.loglineOpen ? `open — "${state.loglineOpen}"` : state.logline ? `"${state.logline}"` : "(none yet)"}`,
3549
+ ...(reading.findings.length ? reading.findings.map((finding) => ` - [${finding.kind}] ${finding.text}`) : [` (asks nothing${reading.left.length ? `; ${reading.left.length} left by the writer` : ""}${state.notes.length ? "" : ": no cards yet"})`]),
3550
+ ...(open ? [` open by the writer's word: ${[reading.open.length ? `${reading.open.length} card${reading.open.length === 1 ? "" : "s"}` : "", reading.openFields.length ? `${reading.openFields.length} field${reading.openFields.length === 1 ? "" : "s"}` : "", meta.nameOpen ? "the board's name" : ""].filter(Boolean).join(", ")} — read_wall there lists them`] : []),
3551
+ ...reading.threads.filter((thread) => thread.startOpen || thread.endOpen).map((thread) => ` thread "${thread.name}" — ${thread.startOpen ? "starts nowhere yet" : ""}${thread.startOpen && thread.endOpen ? ", " : ""}${thread.endOpen ? "ends nowhere yet" : ""}`),
3552
+ ...reading.later.map((item) => ` "${headline(item.id)}" is folded and pays off later, on "${boardById(project, item.boardId)?.name ?? item.boardId}"${item.noteId ? `, at "${boardsNow[item.boardId]?.notes?.find((note) => note.id === item.noteId)?.headline ?? item.noteId}"` : ", no scene there claimed yet"}`),
3553
+ );
3554
+ }
3555
+ const pages = [...states.values()].reduce((sum, state) => sum + boardEighths(state), 0);
3556
+ const target = [...states.values()].reduce((sum, state) => sum + state.targetEighths, 0);
3557
+ const cards = [...states.values()].reduce((sum, state) => sum + state.notes.length, 0);
3558
+ lines.push(`the whole project: ${cards} card${cards === 1 ? "" : "s"}, about ${formatPages(pages)} of ${formatPages(target)} pages across ${project.boards.length} board${project.boards.length === 1 ? "" : "s"}; ${asked} question${asked === 1 ? "" : "s"} in all (each board's runtime is an estimate unless every scene is written)`);
3559
+ return ok(lines.join("\n"), { project: { id: project.id, name: project.name }, boards: project.boards.map((meta) => meta.id), asked });
3560
+ },
3561
+ );
3562
+
3508
3563
  server.registerTool(
3509
3564
  "list_boards",
3510
3565
  {
@@ -45,7 +45,7 @@ export const AGENTS = {
45
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
- { 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." },
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. An account can hold a wall from earlier work that is neither the sample nor what the writer means to work now: every reading names the project it read and list_projects names who is on it; ask the writer before touching it, and new_project starts theirs beside it." },
49
49
  { tool: "list_workflows", why: "what a writer can ask you for." },
50
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
  { tool: "list_board", why: "the records — every card, the cast, the places, the rows — with their ids, which the reading does not carry and every write needs." },
@@ -24,10 +24,16 @@ function upper(text) {
24
24
  * A forced scene heading: the place, or the headline when the card has none,
25
25
  * and the card's when after a dash — THE PIER AT FENIT - NIGHT (R55).
26
26
  */
27
+ /** The heading's mark for a place the writer has left open (R61's edge): the words print, and never read back as a place. */
28
+ export const OPEN_PLACE_HEADING = "PLACE NOT DECIDED:";
29
+
27
30
  export function sceneHeading(note) {
28
31
  const place = typeof note.location === "string" ? note.location.trim() : "";
32
+ const placeOpen = typeof note.locationOpen === "string" ? note.locationOpen.trim() : "";
29
33
  const when = typeof note.when === "string" ? note.when.trim() : "";
30
- const words = place || note.headline || "UNTITLED";
34
+ // No place but the writer's words for why: the words head the scene, marked,
35
+ // so a reader never takes them for a place and the headline is not a slugline.
36
+ const words = place || (placeOpen ? `${OPEN_PLACE_HEADING} ${placeOpen}` : "") || note.headline || "UNTITLED";
31
37
  return `.${upper(words)}${when ? ` - ${upper(when)}` : ""}`;
32
38
  }
33
39
 
@@ -266,6 +272,8 @@ export function mergeFountain(state, parsed) {
266
272
  const headline = scene.synopsis || titleCase(scene.heading);
267
273
  const isPlace = scene.forced && Boolean(scene.synopsis);
268
274
  const parts = splitHeading(scene.heading);
275
+ // A heading that says the place is not decided comes back as an open place, not a place named that.
276
+ const openPlace = parts.place.toUpperCase().startsWith(OPEN_PLACE_HEADING) ? parts.place.slice(OPEN_PLACE_HEADING.length).trim().toLowerCase() : "";
269
277
  const id = `scene-${Math.random().toString(36).slice(2, 8)}`;
270
278
  // A marked body is an unwritten scene: its words are the change line, not a page.
271
279
  const body = unmark(scene.text);
@@ -274,7 +282,8 @@ export function mergeFountain(state, parsed) {
274
282
  id,
275
283
  headline,
276
284
  change: body.marked ? body.text || "What changes?" : scene.text ? firstSentence(scene.text) : "What changes?",
277
- location: isPlace ? titleCase(parts.place) : "",
285
+ location: isPlace && !openPlace ? titleCase(parts.place) : "",
286
+ locationOpen: isPlace && openPlace ? openPlace : "",
278
287
  when: isPlace ? parts.when.toLowerCase() : "",
279
288
  text: body.marked ? "" : scene.text,
280
289
  x: anchor ? anchor.x + 40 : 140,