slidemind 0.2.4 → 0.3.1

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.
@@ -10,20 +10,32 @@
10
10
  // (plan-md.mjs), shipped inline to the browser as window.__SLIDEMIND_RENDER_MD__.
11
11
  // Opens the browser and BLOCKS until the user approves the plan or requests
12
12
  // changes. The browser POSTs the decision to /plan-review, which unblocks this
13
- // command — no copy-paste round-trip. On a comments round the tab stays open
14
- // and readable (spinner in the composer) and reloads itself when the next
15
- // round binds the same port. The verdict is printed to stdout for the harness:
13
+ // command — no copy-paste round-trip. The verdict is printed to stdout for the
14
+ // harness:
16
15
  // - approved -> "DECISION: approved" (the agent then authors the slides;
17
16
  // an edit-and-approve rewrites plan.md first and tells the
18
17
  // agent to re-read it — the user's words are the plan).
19
- // - comments -> the user's overall feedback (the agent revises plan.md to
20
- // address it and re-runs).
18
+ // - comments -> the user's slide-anchored comments (the agent revises
19
+ // plan.md to address them and re-runs).
20
+ //
21
+ // ASYNC REVIEW (the drain loop). A comments round no longer freezes the tab:
22
+ // once the decision is sent the page enters WORKING mode — still readable and
23
+ // navigable, composer still live — and further comments queue in the tab's
24
+ // sessionStorage. When the agent re-runs this command the waiting tab reloads
25
+ // (heartbeat claim) and its boot script POSTs that backlog to
26
+ // /plan-review/backlog. A non-empty backlog resolves the gate AT ONCE with
27
+ // those comments (a "drain round": the command prints DECISION: comments and
28
+ // exits without ever parking), so several consecutive comment rounds are the
29
+ // normal shape of a review. An empty backlog never POSTs and the run parks and
30
+ // blocks, exactly as before. Approve is refused once a backlog arrived in this
31
+ // run — that round is already superseded.
21
32
  //
22
33
  // Host-free: the plan gate runs BEFORE any slide exists, so it needs no bundle
23
34
  // and no open host. A per-brand orb PNG is used when a bundle happens to be
24
35
  // materialized (dark variant preferred); otherwise the overlay falls back to
25
- // its inline embossed slide glyph. Each round's feedback persists to
26
- // <deck>/plan-review.json so the next run shows it as a read-only note.
36
+ // its inline embossed slide glyph. Each round persists to
37
+ // <deck>/plan-review.json (v3, one row per anchored comment) as the harness's
38
+ // recovery copy of the output.
27
39
  //
28
40
  // Port + tab-reuse contract (shared with the preview gate via gate-server.mjs):
29
41
  // each run binds a deterministic per-deck plan port (a hash of the deck path,
@@ -48,10 +60,16 @@ import { openInBrowser } from "./build.mjs";
48
60
  import { renderPlanMarkdown } from "./plan-md.mjs";
49
61
  import { splitPlanFrontmatter } from "./resolve-skill.mjs";
50
62
  import {
63
+ FAVICON_LINK,
51
64
  MIME,
52
65
  serveStaticFile,
53
66
  createDecisionGate,
54
67
  createHeartbeat,
68
+ bundleGateSession,
69
+ normalizeGateComments,
70
+ serveBacklogPost,
71
+ serveDecisionPost,
72
+ stripModuleSyntax,
55
73
  listenWithFallback,
56
74
  preferredGatePort,
57
75
  serveAndOpen,
@@ -86,37 +104,50 @@ function safeInlineJson(data) {
86
104
  }
87
105
 
88
106
  // ---------------------------------------------------------------------------
89
- // plan-review.json round-trip (previous-round feedback source)
107
+ // plan-review.json round-trip (the harness's recovery copy of a round)
90
108
  // ---------------------------------------------------------------------------
91
109
 
110
+ const EMPTY_STATE = { version: 3, decision: null, comments: [] };
111
+
112
+ // Normalize the browser's comment array into the persisted/printed row shape.
113
+ // Every row is anchored (scope "slide" carries the slide number + the beat
114
+ // heading it was written against); the shared core adds identity (`id` dedupes
115
+ // a client retry of the same batch) and drops text-less rows — an empty row
116
+ // would print as a bullet the agent cannot act on.
117
+ export function normalizePlanComments(raw) {
118
+ return normalizeGateComments(raw, (c) => {
119
+ const scope = c.scope === "slide" ? "slide" : "story";
120
+ return {
121
+ scope,
122
+ slideIndex: scope === "slide" && Number.isFinite(c.slideIndex) ? Number(c.slideIndex) : null,
123
+ slideHeading:
124
+ scope === "slide" && typeof c.slideHeading === "string" && c.slideHeading
125
+ ? c.slideHeading
126
+ : null,
127
+ };
128
+ });
129
+ }
130
+
131
+ // v3 only — the file is ephemeral per-deck round state, so older shapes are not
132
+ // read (clean break, no compat shim). `decision` must always be explicit: it
133
+ // drives the tab-reuse grace window in runPlanReview (a waiting tab can only
134
+ // exist after a prior comments round).
92
135
  export function loadPlanState(deckDir) {
93
136
  const f = path.join(deckDir, PLAN_STATE_FILE);
94
- // decision drives the grace-window logic in runPlanReview (a waiting tab can
95
- // only exist after a prior comments round), so a fresh/garbage state must
96
- // still carry an explicit decision: null — same shape review.mjs uses.
97
- if (!fs.existsSync(f)) return { version: 2, decision: null, comment: null };
137
+ if (!fs.existsSync(f)) return { ...EMPTY_STATE };
98
138
  try {
99
139
  const data = JSON.parse(fs.readFileSync(f, "utf8"));
100
- // v1 files carried a per-block comments array; fold it into one feedback
101
- // text so an old round still shows up as the previous-round note.
102
- let comment = typeof data.comment === "string" ? data.comment : null;
103
- if (comment == null && Array.isArray(data.comments)) {
104
- const joined = data.comments
105
- .map((c) => c && c.comment)
106
- .filter(Boolean)
107
- .join("\n");
108
- comment = joined || null;
109
- }
140
+ if (!data || data.version !== 3) return { ...EMPTY_STATE };
110
141
  return {
111
- version: 2,
142
+ version: 3,
112
143
  decision: data.decision ?? null,
113
- comment,
144
+ comments: normalizePlanComments(data.comments),
114
145
  edited: data.edited ?? false,
115
146
  planHash: data.planHash ?? null,
116
147
  at: data.at ?? null,
117
148
  };
118
149
  } catch {
119
- return { version: 2, decision: null, comment: null };
150
+ return { ...EMPTY_STATE };
120
151
  }
121
152
  }
122
153
 
@@ -131,52 +162,69 @@ export function savePlanState(deckDir, state) {
131
162
  // harness output
132
163
  // ---------------------------------------------------------------------------
133
164
 
134
- // Render the feedback round for the harness — the plan analog of review.mjs's
135
- // formatCommentsForHarness, on ONE overall comment instead of per-block pins.
165
+ // One anchored bullet per comment: `- [Slide 3: "Align the next outcome"] text`
166
+ // or `- [Whole story] text`. Continuation lines are indented under the bullet so
167
+ // a multi-line note can't be mistaken for a second comment.
168
+ function commentBullet(c) {
169
+ const anchor =
170
+ c.scope === "slide"
171
+ ? `Slide ${c.slideIndex ?? "?"}${c.slideHeading ? `: "${c.slideHeading}"` : ""}`
172
+ : "Whole story";
173
+ const [first, ...rest] = String(c.text).split("\n");
174
+ return [`- [${anchor}] ${first}`, ...rest.map((l) => ` ${l}`)].join("\n");
175
+ }
176
+
177
+ // Render the comments round for the harness — the plan analog of review.mjs's
178
+ // formatCommentsForHarness, on slide-anchored comments instead of spatial pins.
136
179
  //
137
- // The tail is deliberately imperative and stateful: after a comments round the
138
- // user's browser tab sits on a waiting spinner that ONLY the next `slidemind
139
- // plan` run releases, so an agent that ends its turn here (or redirects the
140
- // user "back to the browser") deadlocks the session — the tab waits for the
141
- // agent while the agent waits for the tab. Spell the next actions out as
142
- // short, own-line steps so no instruction hides at the end of a long path.
143
- export function formatPlanFeedbackForHarness(deckDir, comment, { edited = false } = {}) {
180
+ // The tail is deliberately imperative: the reviewer's tab is LIVE while the
181
+ // agent revises (they can keep reading and keep queueing comments), and those
182
+ // queued comments are only delivered by the next `slidemind plan` run. An agent
183
+ // that ends its turn here strands them, so the re-run instruction says "same
184
+ // turn" and says why more comments may follow.
185
+ export function formatPlanCommentsForHarness(
186
+ deckDir,
187
+ comments,
188
+ { edited = false, drained = false } = {},
189
+ ) {
190
+ const rows = normalizePlanComments(comments);
144
191
  const out = [];
145
192
  out.push("DECISION: comments");
146
193
  out.push("The user requested changes to the plan. Nothing was authored.");
194
+ if (drained) {
195
+ out.push(
196
+ "(These comments were queued while you were revising and arrived with this " +
197
+ "run — that is why the gate returned immediately instead of waiting.)",
198
+ );
199
+ }
147
200
  if (edited) {
148
201
  out.push(
149
202
  "The user also edited plan.md directly — re-read it before revising; the " +
150
- "feedback below is on top of their edits.",
203
+ "comments below are on top of their edits.",
151
204
  );
152
205
  }
153
206
  out.push("");
154
- if (comment) {
155
- out.push("Feedback:");
156
- for (const line of String(comment).split("\n")) out.push(` ${line}`);
157
- out.push("");
207
+ if (rows.length) {
208
+ for (const c of rows) out.push(commentBullet(c));
158
209
  } else if (edited) {
159
- out.push("(No written feedback — their direct edits to plan.md are the change.)");
160
- out.push("");
210
+ out.push("(No written comments — their direct edits to plan.md are the change.)");
161
211
  }
212
+ out.push("");
213
+ out.push("Revise plan.md to address ALL comments — change only what the feedback");
214
+ out.push("requires; keep unaffected slides verbatim.");
215
+ out.push(`Then run \`slidemind plan ${path.basename(deckDir)}\` again in the same turn.`);
216
+ out.push("The reviewer may queue further comments while you revise; they are");
217
+ out.push("delivered on that next run (it can return immediately with them — that is");
218
+ out.push("normal, not an error). Do NOT end your turn and do NOT send the user back");
219
+ out.push("to the browser before you re-run the gate.");
220
+ out.push("");
162
221
  // Recovery pointer in the tail (truncated captures usually lose the TOP):
163
222
  // the round is persisted before this prints, so a lost capture is recoverable.
164
223
  out.push(
165
- `This round (decision + feedback) is also saved verbatim in ` +
224
+ `This round (decision + comments) is also saved verbatim in ` +
166
225
  `${path.join(deckDir, PLAN_STATE_FILE)} — if this output looks truncated, read ` +
167
226
  `that file instead of guessing.`,
168
227
  );
169
- out.push("");
170
- out.push(
171
- "The user's browser tab is still open, on a waiting spinner that says you are",
172
- );
173
- out.push("revising the plan. They are waiting on YOU now — do both steps in this");
174
- out.push("same turn:");
175
- out.push(` 1. Edit ${deckDir}/plan.md to address the feedback.`);
176
- out.push(` 2. Re-run: slidemind plan ${path.basename(deckDir)}`);
177
- out.push("The re-run reopens their same tab with the revised plan. Do NOT end your");
178
- out.push("turn and do NOT send the user back to the browser before step 2 — nothing");
179
- out.push("happens until the gate runs again.");
180
228
  return out.join("\n") + "\n";
181
229
  }
182
230
 
@@ -232,17 +280,10 @@ const PLAN_NAMESPACE_EXPORTS = [
232
280
  "MAX_CONTENT_LENGTH",
233
281
  ];
234
282
 
235
- // Turn a dependency-free ESM module's source into plain browser JS that runs
236
- // inside a shared IIFE scope: drop single-line `import … from "…";` statements
237
- // (all inter-module imports across the plan-review modules are single-line) and
238
- // strip the leading `export ` keyword from declarations. The imported names are
239
- // provided by an earlier module in the concatenation, so removing the import
240
- // line just leaves the reference resolving to that earlier top-level binding.
241
- export function stripModuleSyntax(src) {
242
- return String(src || "")
243
- .replace(/^\s*import\s+[^;]*?from\s*["'][^"']+["'];?\s*$/gm, "")
244
- .replace(/^export\s+/gm, "");
245
- }
283
+ // stripModuleSyntax now lives in gate-server.mjs (both gates bundle browser
284
+ // modules through it); re-exported here because the plan bundle is its oldest
285
+ // caller and the module tests reach for it on this path.
286
+ export { stripModuleSyntax };
246
287
 
247
288
  // Bundle the five plan-review modules into one IIFE for the browser page, in
248
289
  // dependency order (schema → storyboard-markdown → backbone → edit → plan-md).
@@ -326,40 +367,54 @@ display:block;overflow-x:auto;}
326
367
  // function of its inputs so it's unit-testable without a server.
327
368
  export function buildPlanPage({
328
369
  bodyHtml,
329
- previousComment,
330
370
  overlayJs,
331
371
  planModulesJs,
332
372
  logoMark,
333
373
  planBody,
334
374
  bodyStartLine,
335
375
  round,
376
+ deck,
377
+ ledgerSeed,
336
378
  }) {
337
379
  const cfg = safeInlineJson({
338
380
  endpoint: "/plan-review",
381
+ backlogEndpoint: "/plan-review/backlog",
339
382
  heartbeat: "/__plan/heartbeat",
340
383
  round: round ?? null,
341
384
  pollMs: POLL_MS,
342
385
  pollTtlMs: POLL_TTL_MS,
343
- previousComment: previousComment ?? null,
344
386
  logoMark: logoMark ?? null,
345
387
  planBody: planBody ?? "",
346
388
  bodyStartLine: bodyStartLine ?? 1,
389
+ // deck: the sessionStorage namespace for this deck's comment ledger.
390
+ // ledgerSeed: the previous round's persisted comments — a FRESH tab (no
391
+ // session of its own) renders its ledger rail from these instead of coming
392
+ // up blank. The ROUND identity is the heartbeat nonce in `round`, not a
393
+ // content hash: the page's cached plan body is only used for the cosmetic
394
+ // "updated" badges.
395
+ deck: deck ?? "",
396
+ ledgerSeed: ledgerSeed ?? [],
347
397
  });
348
398
  const overlay = String(overlayJs || "").replace(/<\/script>/g, "<\\/script>");
349
399
  const modulesSrc = String(planModulesJs || "").replace(/<\/script>/g, "<\\/script>");
350
400
  const rendererBlock = modulesSrc ? "<script>\n" + modulesSrc + "\n</script>\n" : "";
401
+ // The shared gate session goes in before the overlay, which reads it at boot.
402
+ const gateBlock = "<script>\n" + bundleGateSession().replace(/<\/script>/g, "<\\/script>") + "\n</script>\n";
351
403
  return (
352
404
  "<!doctype html>\n" +
353
405
  '<html lang="en">\n' +
354
406
  "<head>\n" +
355
407
  '<meta charset="utf-8">\n' +
356
408
  '<meta name="viewport" content="width=device-width, initial-scale=1">\n' +
357
- "<title>Plan review</title>\n" +
409
+ "<title>Plan review — slidemind</title>\n" +
410
+ FAVICON_LINK +
411
+ "\n" +
358
412
  `<style>${PAGE_CSS}</style>\n` +
359
413
  "</head>\n" +
360
414
  "<body>\n" +
361
415
  `<div id="plan-root">${bodyHtml}</div>\n` +
362
416
  `<script>window.__SLIDEMIND_PLAN_REVIEW__ = ${cfg};</script>\n` +
417
+ gateBlock +
363
418
  rendererBlock +
364
419
  `<script>\n${overlay}\n</script>\n` +
365
420
  "</body>\n" +
@@ -379,11 +434,11 @@ export async function createPlanReviewServer({
379
434
  deckDir,
380
435
  body,
381
436
  bodyStartLine,
382
- previousComment = null,
383
437
  overlayJs,
384
438
  planModulesJs,
385
439
  logoMark = null,
386
440
  brandAssetPath = null,
441
+ ledgerSeed = [],
387
442
  port = 0,
388
443
  }) {
389
444
  const gate = createDecisionGate({
@@ -397,13 +452,14 @@ export async function createPlanReviewServer({
397
452
  const bodyHtml = renderPlanMarkdown(body, { lineOffset: bodyStartLine - 1 });
398
453
  const indexHtml = buildPlanPage({
399
454
  bodyHtml,
400
- previousComment,
401
455
  overlayJs: overlay,
402
456
  planModulesJs: planModules,
403
457
  logoMark,
404
458
  planBody: body,
405
459
  bodyStartLine,
406
460
  round: hb.round,
461
+ deck: path.basename(path.resolve(deckDir)),
462
+ ledgerSeed: normalizePlanComments(ledgerSeed),
407
463
  });
408
464
  const deckRoot = path.resolve(deckDir);
409
465
 
@@ -411,27 +467,35 @@ export async function createPlanReviewServer({
411
467
  const u = new URL(req.url, "http://127.0.0.1");
412
468
  const pathname = decodeURIComponent(u.pathname);
413
469
 
414
- if (req.method === "POST" && pathname === "/plan-review") {
415
- let raw = "";
416
- req.on("data", (chunk) => {
417
- raw += chunk;
418
- if (raw.length > 5e6) req.destroy();
470
+ // Both POST routes are gate-server's shared handlers: the backlog drain and
471
+ // the decision + 409 guard behave identically in both gates. `gate.settled()`
472
+ // guards BOTH: once one tab's drain or decision resolved this run, a late
473
+ // POST from another waiting tab is refused instead of being acked and
474
+ // silently dropped (every waiter reloads into a new round, so two tabs
475
+ // posting into one run is the normal multi-window case, not an anomaly).
476
+ if (req.method === "POST" && pathname === "/plan-review/backlog") {
477
+ serveBacklogPost(req, res, {
478
+ normalize: normalizePlanComments,
479
+ isSuperseded: () => gate.settled(),
480
+ onDrained: (comments) => {
481
+ gate.resolve({ decision: "comments", comments, editedPlan: null, drained: true });
482
+ },
419
483
  });
420
- req.on("end", () => {
421
- let payload;
422
- try {
423
- payload = JSON.parse(raw || "{}");
424
- } catch {
425
- payload = {};
426
- }
427
- res.writeHead(200, { "Content-Type": MIME[".json"] });
428
- res.end(JSON.stringify({ ok: true }));
429
- const commentRaw = typeof payload.comment === "string" ? payload.comment.trim() : "";
430
- gate.resolve({
431
- decision: payload.decision === "comments" ? "comments" : "approve",
432
- comment: commentRaw || null,
433
- editedPlan: typeof payload.editedPlan === "string" ? payload.editedPlan : null,
434
- });
484
+ return;
485
+ }
486
+
487
+ if (req.method === "POST" && pathname === "/plan-review") {
488
+ serveDecisionPost(req, res, {
489
+ normalize: normalizePlanComments,
490
+ isSuperseded: () => gate.settled(),
491
+ onDecision: (decision, payload, comments) => {
492
+ gate.resolve({
493
+ decision,
494
+ comments,
495
+ editedPlan: typeof payload.editedPlan === "string" ? payload.editedPlan : null,
496
+ drained: false,
497
+ });
498
+ },
435
499
  });
436
500
  return;
437
501
  }
@@ -526,6 +590,12 @@ export function applyPlanEdit({ planPath, frontmatterRaw, body, editedPlan }) {
526
590
  return { edited: true };
527
591
  }
528
592
 
593
+ // Persisted with the decision as this round's plan fingerprint (the round
594
+ // TRANSITION a reloaded tab detects is the server's heartbeat nonce, not this).
595
+ function hashPlan(planPath) {
596
+ return crypto.createHash("sha256").update(fs.readFileSync(planPath)).digest("hex");
597
+ }
598
+
529
599
  export async function runPlanReview({
530
600
  deckDir,
531
601
  port = null,
@@ -562,9 +632,11 @@ export async function runPlanReview({
562
632
  deckDir,
563
633
  body,
564
634
  bodyStartLine,
565
- previousComment: prev.comment ?? null,
566
635
  logoMark,
567
636
  brandAssetPath,
637
+ // A fresh tab renders the previous round's comments from the persisted file
638
+ // (the plan gate's analogue of review.json's ghost pins).
639
+ ledgerSeed: prev.comments || [],
568
640
  port: wantPort,
569
641
  });
570
642
  // A waiting tab can only exist when the previous round ended in comments — so
@@ -623,16 +695,13 @@ export async function runPlanReview({
623
695
  editedPlan: decision.editedPlan,
624
696
  });
625
697
 
626
- const planHash = crypto
627
- .createHash("sha256")
628
- .update(fs.readFileSync(planPath))
629
- .digest("hex");
698
+ const comments = normalizePlanComments(decision.comments);
630
699
  savePlanState(deckDir, {
631
- version: 2,
700
+ version: 3,
632
701
  decision: decision.decision,
633
- comment: decision.comment ?? null,
702
+ comments,
634
703
  edited,
635
- planHash,
704
+ planHash: hashPlan(planPath),
636
705
  at: new Date().toISOString(),
637
706
  });
638
707
 
@@ -651,7 +720,9 @@ export async function runPlanReview({
651
720
  );
652
721
  }
653
722
  } else {
654
- process.stdout.write(formatPlanFeedbackForHarness(deckDir, decision.comment, { edited }));
723
+ process.stdout.write(
724
+ formatPlanCommentsForHarness(deckDir, comments, { edited, drained: !!decision.drained }),
725
+ );
655
726
  }
656
727
  return decision;
657
728
  }