slidemind 0.1.7 → 0.1.9

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.
@@ -0,0 +1,696 @@
1
+ #!/usr/bin/env node
2
+ // slidemind plan <deck> — the plan sign-off gate, CLI-owned.
3
+ //
4
+ // Serves <deck>/plan.md as a branded editor page (the plan-overlay.js chrome
5
+ // injected): a plain markdown editor on the left, a live rendered preview on
6
+ // the right (collapsible), and the decision in ONE always-visible chat-style
7
+ // composer bar at the bottom (feedback input + send + Approve, whose sub-label
8
+ // says the agent builds the slides next) — no per-block commenting. The live
9
+ // preview runs the same dependency-free renderer the server-side render used
10
+ // (plan-md.mjs), shipped inline to the browser as window.__SLIDEMIND_RENDER_MD__.
11
+ // Opens the browser and BLOCKS until the user approves the plan or requests
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:
16
+ // - approved -> "DECISION: approved" (the agent then authors the slides;
17
+ // an edit-and-approve rewrites plan.md first and tells the
18
+ // 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).
21
+ //
22
+ // Host-free: the plan gate runs BEFORE any slide exists, so it needs no bundle
23
+ // and no open host. A per-brand orb PNG is used when a bundle happens to be
24
+ // 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.
27
+ //
28
+ // Port + tab-reuse contract (shared with the preview gate via gate-server.mjs):
29
+ // each run binds a deterministic per-deck plan port (a hash of the deck path,
30
+ // salted "plan", folded into 40000..40999) so a tab left waiting from the
31
+ // previous round reconnects to the same URL. A taken port never fails — it
32
+ // falls back to an OS-assigned port with a stderr notice. On a comments round
33
+ // the browser tab does NOT close: it polls /__plan/heartbeat and reloads itself
34
+ // once the next run binds the same port (matched by the "slidemind-plan"
35
+ // service marker + a fresh round nonce), so a fresh tab isn't opened per round.
36
+ //
37
+ // This mirrors review.mjs's structure and shares its low-level primitives via
38
+ // gate-server.mjs (MIME, serveStaticFile, createDecisionGate, createHeartbeat,
39
+ // listenWithFallback, preferredGatePort, serveAndOpen).
40
+
41
+ import crypto from "node:crypto";
42
+ import fs from "node:fs";
43
+ import http from "node:http";
44
+ import path from "node:path";
45
+ import { fileURLToPath, pathToFileURL } from "node:url";
46
+
47
+ import { openInBrowser } from "./build.mjs";
48
+ import { renderPlanMarkdown } from "./plan-md.mjs";
49
+ import { splitPlanFrontmatter } from "./resolve-skill.mjs";
50
+ import {
51
+ MIME,
52
+ serveStaticFile,
53
+ createDecisionGate,
54
+ createHeartbeat,
55
+ listenWithFallback,
56
+ preferredGatePort,
57
+ serveAndOpen,
58
+ pendingBrowserLines,
59
+ GRACE_MS,
60
+ OPEN_FAILED_GRACE_MS,
61
+ POLL_MS,
62
+ POLL_TTL_MS,
63
+ } from "./gate-server.mjs";
64
+ import { resolveBrandAsset } from "../bridge/bundle.mjs";
65
+
66
+ const __filename = fileURLToPath(import.meta.url);
67
+ const __dirname = path.dirname(__filename);
68
+ const OVERLAY_PATH = path.join(__dirname, "plan-overlay.js");
69
+ const PLAN_MD_PATH = path.join(__dirname, "plan-md.mjs");
70
+ // The storyboard/red-thread plan-review modules, bundled into the browser page
71
+ // alongside plan-md.mjs (see bundlePlanModules). Order matters: each file's
72
+ // inter-module imports are stripped, so a module must be concatenated AFTER the
73
+ // module whose exports it references (schema ← storyboard-markdown ← backbone).
74
+ const STORYBOARD_SCHEMA_PATH = path.join(__dirname, "storyboard-schema.mjs");
75
+ const STORYBOARD_MARKDOWN_PATH = path.join(__dirname, "storyboard-markdown.mjs");
76
+ const RED_THREAD_BACKBONE_PATH = path.join(__dirname, "red-thread-backbone.mjs");
77
+ const PLAN_MARKDOWN_EDIT_PATH = path.join(__dirname, "plan-markdown-edit.mjs");
78
+ const PLAN_STATE_FILE = "plan-review.json";
79
+ const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
80
+
81
+ // Escape a </script> sequence so an inlined JSON/JS blob can't break out of the
82
+ // surrounding <script> tag (copied from review.mjs — that module is off-limits
83
+ // and doesn't export it).
84
+ function safeInlineJson(data) {
85
+ return JSON.stringify(data).replace(/<\/script>/g, "<\\/script>");
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // plan-review.json round-trip (previous-round feedback source)
90
+ // ---------------------------------------------------------------------------
91
+
92
+ export function loadPlanState(deckDir) {
93
+ 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 };
98
+ try {
99
+ 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
+ }
110
+ return {
111
+ version: 2,
112
+ decision: data.decision ?? null,
113
+ comment,
114
+ edited: data.edited ?? false,
115
+ planHash: data.planHash ?? null,
116
+ at: data.at ?? null,
117
+ };
118
+ } catch {
119
+ return { version: 2, decision: null, comment: null };
120
+ }
121
+ }
122
+
123
+ export function savePlanState(deckDir, state) {
124
+ fs.writeFileSync(
125
+ path.join(deckDir, PLAN_STATE_FILE),
126
+ JSON.stringify(state, null, 2) + "\n",
127
+ );
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // harness output
132
+ // ---------------------------------------------------------------------------
133
+
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.
136
+ //
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 } = {}) {
144
+ const out = [];
145
+ out.push("DECISION: comments");
146
+ out.push("The user requested changes to the plan. Nothing was authored.");
147
+ if (edited) {
148
+ out.push(
149
+ "The user also edited plan.md directly — re-read it before revising; the " +
150
+ "feedback below is on top of their edits.",
151
+ );
152
+ }
153
+ out.push("");
154
+ if (comment) {
155
+ out.push("Feedback:");
156
+ for (const line of String(comment).split("\n")) out.push(` ${line}`);
157
+ out.push("");
158
+ } else if (edited) {
159
+ out.push("(No written feedback — their direct edits to plan.md are the change.)");
160
+ out.push("");
161
+ }
162
+ // Recovery pointer in the tail (truncated captures usually lose the TOP):
163
+ // the round is persisted before this prints, so a lost capture is recoverable.
164
+ out.push(
165
+ `This round (decision + feedback) is also saved verbatim in ` +
166
+ `${path.join(deckDir, PLAN_STATE_FILE)} — if this output looks truncated, read ` +
167
+ `that file instead of guessing.`,
168
+ );
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
+ return out.join("\n") + "\n";
181
+ }
182
+
183
+ // Render the no-answer outcome for the harness. A timed-out gate is an EXPECTED
184
+ // state (the user stepped away), not a failure — the agent must neither treat
185
+ // it as approval nor abort the task; it wraps up with clear resume instructions.
186
+ export function formatPlanPendingForHarness(deckDir, { timeoutMs, pageLoaded, url } = {}) {
187
+ const mins = Math.max(1, Math.round((timeoutMs ?? DEFAULT_TIMEOUT_MS) / 60000));
188
+ const base = path.basename(deckDir);
189
+ return [
190
+ "DECISION: pending",
191
+ `No decision yet — the gate waited ${mins} minutes and the user hasn't answered.`,
192
+ ...pendingBrowserLines({ pageLoaded, url }),
193
+ "This is NOT an approval and NOT a rejection. Do not author anything.",
194
+ "Close out your turn gracefully:",
195
+ " 1. Tell the user their plan is still waiting for sign-off in the browser.",
196
+ ` 2. Tell them that when they're ready, you'll re-open the gate — then, on`,
197
+ ` their go-ahead, re-run: slidemind plan ${base}`,
198
+ "A still-open tab reconnects to the re-run automatically; a closed one just",
199
+ "gets a fresh window. Nothing is lost by the wait.",
200
+ ].join("\n") + "\n";
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // browser bundle
205
+ // ---------------------------------------------------------------------------
206
+
207
+ // The exact set of names the bundled IIFE hangs off window.__SLIDEMIND_PLAN__ —
208
+ // this is the contract the overlay (plan-overlay.js) reads. Every name here must
209
+ // resolve to a top-level binding in one of the five concatenated modules.
210
+ const PLAN_NAMESPACE_EXPORTS = [
211
+ "renderPlanMarkdown",
212
+ "addEditableAttributes",
213
+ "isEditableKind",
214
+ "replaceRenderedMarkdownBlock",
215
+ "splitPlanMarkdownIntoSegments",
216
+ "replaceStoryboardSegment",
217
+ "extractRedThreadMessages",
218
+ "findRedThreadSection",
219
+ "parseStoryboardCanvas",
220
+ "serializeStoryboardCanvas",
221
+ "backboneFromMessages",
222
+ "parsePlanBackbone",
223
+ "applyPlanBackbone",
224
+ "stageStoryIteration",
225
+ "STORYBOARD_WIDTH",
226
+ "STORYBOARD_HEIGHT",
227
+ "BODY_SAFE_AREA",
228
+ "MIN_BLOCK_WIDTH",
229
+ "MIN_BLOCK_HEIGHT",
230
+ "MAX_BLOCKS",
231
+ "MAX_DESIGN_ELEMENT_LENGTH",
232
+ "MAX_CONTENT_LENGTH",
233
+ ];
234
+
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
+ }
246
+
247
+ // Bundle the five plan-review modules into one IIFE for the browser page, in
248
+ // dependency order (schema → storyboard-markdown → backbone → edit → plan-md).
249
+ // The IIFE exposes window.__SLIDEMIND_RENDER_MD__ (back-compat with the old
250
+ // preview) and window.__SLIDEMIND_PLAN__ (the full namespace the overlay reads).
251
+ // Exported so a test can evaluate the output against a stub window and catch a
252
+ // strip-regex miss or a top-level name collision for real.
253
+ export function bundlePlanModules({ schema, storyboardMd, backbone, edit, planMd } = {}) {
254
+ const body = [schema, storyboardMd, backbone, edit, planMd]
255
+ .map(stripModuleSyntax)
256
+ .join("\n");
257
+ return (
258
+ "(function () {\n" +
259
+ body +
260
+ "\nwindow.__SLIDEMIND_RENDER_MD__ = renderPlanMarkdown;\n" +
261
+ "window.__SLIDEMIND_PLAN__ = { " +
262
+ PLAN_NAMESPACE_EXPORTS.join(", ") +
263
+ " };\n})();\n"
264
+ );
265
+ }
266
+
267
+ // Read the five module sources off disk and bundle them. Split from
268
+ // bundlePlanModules so the pure string→string step stays unit-testable.
269
+ export function bundlePlanModulesFromDisk() {
270
+ return bundlePlanModules({
271
+ schema: fs.readFileSync(STORYBOARD_SCHEMA_PATH, "utf8"),
272
+ storyboardMd: fs.readFileSync(STORYBOARD_MARKDOWN_PATH, "utf8"),
273
+ backbone: fs.readFileSync(RED_THREAD_BACKBONE_PATH, "utf8"),
274
+ edit: fs.readFileSync(PLAN_MARKDOWN_EDIT_PATH, "utf8"),
275
+ planMd: fs.readFileSync(PLAN_MD_PATH, "utf8"),
276
+ });
277
+ }
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // page
281
+ // ---------------------------------------------------------------------------
282
+
283
+ // The rendered-markdown chrome for #plan-root (the live preview's content, and
284
+ // the whole page when JS is off). The overlay injects the header / editor /
285
+ // dock CSS itself; this only styles the plan text so it reads as a compact dark
286
+ // document. Hex colours are fine here — src/ is not parity-linted and this
287
+ // palette mirrors the overlay's neutral dark product tones.
288
+ const PAGE_CSS = `
289
+ :root{color-scheme:dark;}
290
+ html,body{margin:0;background:#111113;color:#ededed;}
291
+ body{font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
292
+ -webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;}
293
+ #plan-root{max-width:720px;margin:0 auto;padding:40px 28px 140px;
294
+ line-height:1.65;font-size:15.5px;color:#d6d6d9;}
295
+ #plan-root h1,#plan-root h2,#plan-root h3,#plan-root h4,#plan-root h5,#plan-root h6{
296
+ color:#f4f4f5;font-weight:600;line-height:1.28;margin:1.6em 0 .5em;}
297
+ #plan-root h1{font-size:1.95em;margin-top:.2em;}
298
+ #plan-root h2{font-size:1.5em;padding-bottom:.24em;border-bottom:1px solid #26262b;}
299
+ #plan-root h3{font-size:1.22em;}
300
+ #plan-root h4{font-size:1.05em;}
301
+ #plan-root h5,#plan-root h6{font-size:.95em;color:#c4c4c9;}
302
+ #plan-root p{margin:.7em 0;}
303
+ #plan-root ul,#plan-root ol{margin:.6em 0;padding-left:1.5em;}
304
+ #plan-root li{margin:.3em 0;}
305
+ #plan-root blockquote{margin:.95em 0;padding:.45em 1em;border-left:3px solid #3a3a40;
306
+ color:#a8a8ad;background:rgba(255,255,255,.02);border-radius:0 6px 6px 0;}
307
+ #plan-root code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
308
+ font-size:.88em;background:#1c1c20;padding:.12em .42em;border-radius:5px;}
309
+ #plan-root pre{background:#0c0c0e;border:1px solid #26262b;border-radius:10px;
310
+ padding:14px 16px;overflow-x:auto;line-height:1.5;}
311
+ #plan-root pre code{background:none;padding:0;font-size:.86em;}
312
+ #plan-root table{border-collapse:collapse;width:100%;margin:1em 0;font-size:.92em;
313
+ display:block;overflow-x:auto;}
314
+ #plan-root th,#plan-root td{border:1px solid #2b2b30;padding:7px 11px;text-align:left;}
315
+ #plan-root th{background:#18181b;font-weight:600;color:#ededed;}
316
+ #plan-root strong{color:#f4f4f5;}
317
+ #plan-root a{color:#a9c2ff;}
318
+ `;
319
+
320
+ // Build the standalone plan-review page: the rendered markdown in #plan-root
321
+ // (the no-JS fallback and the preview's initial state), the injected overlay
322
+ // config, the bundled plan-review modules, then the overlay script itself.
323
+ // `planModulesJs` is the prebundled IIFE from bundlePlanModules — it already
324
+ // exposes window.__SLIDEMIND_RENDER_MD__ and window.__SLIDEMIND_PLAN__, so this
325
+ // only inlines it verbatim (with the </script> break-out guard). Kept a pure
326
+ // function of its inputs so it's unit-testable without a server.
327
+ export function buildPlanPage({
328
+ bodyHtml,
329
+ previousComment,
330
+ overlayJs,
331
+ planModulesJs,
332
+ logoMark,
333
+ planBody,
334
+ bodyStartLine,
335
+ round,
336
+ }) {
337
+ const cfg = safeInlineJson({
338
+ endpoint: "/plan-review",
339
+ heartbeat: "/__plan/heartbeat",
340
+ round: round ?? null,
341
+ pollMs: POLL_MS,
342
+ pollTtlMs: POLL_TTL_MS,
343
+ previousComment: previousComment ?? null,
344
+ logoMark: logoMark ?? null,
345
+ planBody: planBody ?? "",
346
+ bodyStartLine: bodyStartLine ?? 1,
347
+ });
348
+ const overlay = String(overlayJs || "").replace(/<\/script>/g, "<\\/script>");
349
+ const modulesSrc = String(planModulesJs || "").replace(/<\/script>/g, "<\\/script>");
350
+ const rendererBlock = modulesSrc ? "<script>\n" + modulesSrc + "\n</script>\n" : "";
351
+ return (
352
+ "<!doctype html>\n" +
353
+ '<html lang="en">\n' +
354
+ "<head>\n" +
355
+ '<meta charset="utf-8">\n' +
356
+ '<meta name="viewport" content="width=device-width, initial-scale=1">\n' +
357
+ "<title>Plan review</title>\n" +
358
+ `<style>${PAGE_CSS}</style>\n` +
359
+ "</head>\n" +
360
+ "<body>\n" +
361
+ `<div id="plan-root">${bodyHtml}</div>\n` +
362
+ `<script>window.__SLIDEMIND_PLAN_REVIEW__ = ${cfg};</script>\n` +
363
+ rendererBlock +
364
+ `<script>\n${overlay}\n</script>\n` +
365
+ "</body>\n" +
366
+ "</html>\n"
367
+ );
368
+ }
369
+
370
+ // ---------------------------------------------------------------------------
371
+ // server
372
+ // ---------------------------------------------------------------------------
373
+
374
+ // Start the local plan-review server. Returns { url, port, waitForDecision,
375
+ // close }. Serves the rendered plan.md at "/", the per-brand orb at
376
+ // "/brand/logo" (when a bundle asset resolved), and any other deck-local file
377
+ // (traversal-guarded) for completeness.
378
+ export async function createPlanReviewServer({
379
+ deckDir,
380
+ body,
381
+ bodyStartLine,
382
+ previousComment = null,
383
+ overlayJs,
384
+ planModulesJs,
385
+ logoMark = null,
386
+ brandAssetPath = null,
387
+ port = 0,
388
+ }) {
389
+ const gate = createDecisionGate({
390
+ timeoutMessage: "plan review timed out — no decision received",
391
+ });
392
+ // Own service marker so a tab left waiting on this port only reloads into the
393
+ // NEXT plan round — never into the preview gate or a foreign app on the port.
394
+ const hb = createHeartbeat({ service: "slidemind-plan" });
395
+ const overlay = overlayJs ?? fs.readFileSync(OVERLAY_PATH, "utf8");
396
+ const planModules = planModulesJs ?? bundlePlanModulesFromDisk();
397
+ const bodyHtml = renderPlanMarkdown(body, { lineOffset: bodyStartLine - 1 });
398
+ const indexHtml = buildPlanPage({
399
+ bodyHtml,
400
+ previousComment,
401
+ overlayJs: overlay,
402
+ planModulesJs: planModules,
403
+ logoMark,
404
+ planBody: body,
405
+ bodyStartLine,
406
+ round: hb.round,
407
+ });
408
+ const deckRoot = path.resolve(deckDir);
409
+
410
+ const server = http.createServer((req, res) => {
411
+ const u = new URL(req.url, "http://127.0.0.1");
412
+ const pathname = decodeURIComponent(u.pathname);
413
+
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();
419
+ });
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
+ });
435
+ });
436
+ return;
437
+ }
438
+
439
+ if (req.method !== "GET") {
440
+ res.writeHead(405);
441
+ res.end();
442
+ return;
443
+ }
444
+
445
+ if (pathname === "/__plan/heartbeat") {
446
+ // `visible`/`round` from the poll drive the reuse-vs-fresh-tab decision:
447
+ // only a VISIBLE waiting tab may claim reuse (see gate-server.mjs).
448
+ hb.respond(res, {
449
+ visible: u.searchParams.get("visible") === "1",
450
+ tabRound: u.searchParams.get("round"),
451
+ });
452
+ return;
453
+ }
454
+
455
+ if (pathname === "/" || pathname === "/index.html") {
456
+ // A browser fetched the gate page — the end-to-end proof the plan review
457
+ // actually opened in front of the user (see gate-server's page-load
458
+ // tracker; serveAndOpen and the pending contract report from it).
459
+ hb.notePageLoad();
460
+ res.writeHead(200, { "Content-Type": MIME[".html"] });
461
+ res.end(indexHtml);
462
+ return;
463
+ }
464
+
465
+ if (pathname === "/brand/logo") {
466
+ if (!brandAssetPath) {
467
+ res.writeHead(404);
468
+ res.end("not found");
469
+ return;
470
+ }
471
+ fs.readFile(brandAssetPath, (err, data) => {
472
+ if (err) {
473
+ res.writeHead(404);
474
+ res.end("not found");
475
+ return;
476
+ }
477
+ const ext = path.extname(brandAssetPath).toLowerCase();
478
+ res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" });
479
+ res.end(data);
480
+ });
481
+ return;
482
+ }
483
+
484
+ if (serveStaticFile({ roots: [deckRoot], pathname, res })) return;
485
+ res.writeHead(404);
486
+ res.end("not found");
487
+ });
488
+
489
+ await listenWithFallback(server, port, "127.0.0.1", (wanted, err) => {
490
+ process.stderr.write(
491
+ `plan: port ${wanted} unavailable (${err.code}); using an OS-assigned port instead.\n`,
492
+ );
493
+ });
494
+ const addr = server.address();
495
+ const url = `http://127.0.0.1:${addr.port}/`;
496
+
497
+ return {
498
+ url,
499
+ port: addr.port,
500
+ waitForHeartbeat: (ms) => hb.waitForHeartbeat(ms),
501
+ markOpenedFresh: () => hb.markFresh(),
502
+ waitForPageLoad: (ms) => hb.waitForPageLoad(ms),
503
+ pageLoaded: () => hb.pageLoaded(),
504
+ waitForDecision: (opts) => gate.waitForDecision(opts),
505
+ close() {
506
+ try {
507
+ server.close();
508
+ } catch {
509
+ /* already closing */
510
+ }
511
+ },
512
+ };
513
+ }
514
+
515
+ // ---------------------------------------------------------------------------
516
+ // run
517
+ // ---------------------------------------------------------------------------
518
+
519
+ // Write the user's direct edit back to plan.md, preserving the frontmatter
520
+ // fence byte-for-byte. A no-op (returns { edited:false } without touching the
521
+ // file) when the user never edited or their text matches the current body.
522
+ // Extracted so the write path is unit-testable without a browser/server.
523
+ export function applyPlanEdit({ planPath, frontmatterRaw, body, editedPlan }) {
524
+ if (editedPlan == null || editedPlan === body) return { edited: false };
525
+ fs.writeFileSync(planPath, frontmatterRaw + editedPlan);
526
+ return { edited: true };
527
+ }
528
+
529
+ export async function runPlanReview({
530
+ deckDir,
531
+ port = null,
532
+ timeoutMs = DEFAULT_TIMEOUT_MS,
533
+ // quiet: serveAndOpen's BROWSER: line owns all open/failed messaging.
534
+ open = (url) => openInBrowser(url, { quiet: true }),
535
+ openFailedGraceMs = OPEN_FAILED_GRACE_MS,
536
+ } = {}) {
537
+ const base = path.basename(deckDir);
538
+ const planPath = path.join(deckDir, "plan.md");
539
+ if (!fs.existsSync(planPath)) {
540
+ process.stderr.write(
541
+ `no plan.md in ${base} — write the plan first, then re-run: slidemind plan ${base}\n`,
542
+ );
543
+ process.exitCode = 1;
544
+ return;
545
+ }
546
+
547
+ const { frontmatterRaw, body, bodyStartLine } = splitPlanFrontmatter(
548
+ fs.readFileSync(planPath, "utf8"),
549
+ );
550
+
551
+ // The plan window's logo is the add-in's genuine per-brand orb when a bundle
552
+ // is materialized (dark variant preferred, matching the dark UI). Absent a
553
+ // bundle, logoMark is null and the overlay draws its inline slide glyph.
554
+ const mark = resolveBrandAsset(deckDir, ["logoMarkDark", "logoMark", "logoMarkLight"]);
555
+ const logoMark = mark ? "/brand/logo" : null;
556
+ const brandAssetPath = mark?.path ?? null;
557
+
558
+ const prev = loadPlanState(deckDir);
559
+
560
+ const wantPort = port == null ? preferredGatePort(deckDir, "plan") : port;
561
+ const server = await createPlanReviewServer({
562
+ deckDir,
563
+ body,
564
+ bodyStartLine,
565
+ previousComment: prev.comment ?? null,
566
+ logoMark,
567
+ brandAssetPath,
568
+ port: wantPort,
569
+ });
570
+ // A waiting tab can only exist when the previous round ended in comments — so
571
+ // first runs / post-approve runs open the browser immediately (graceMs 0).
572
+ const browser = await serveAndOpen(server, {
573
+ open,
574
+ graceMs: prev.decision === "comments" ? GRACE_MS : 0,
575
+ readyLabel: "Plan review",
576
+ reuseNoun: "plan tab",
577
+ gateNoun: "plan review",
578
+ waitLine:
579
+ `Waiting for the user's decision — this command BLOCKS (up to ${Math.round(
580
+ timeoutMs / 60000,
581
+ )} min) while they review. A long quiet wait is normal; do not cancel it.`,
582
+ });
583
+
584
+ let decision;
585
+ try {
586
+ if (browser.launched === false) {
587
+ // The opener chain failed outright: the user can only reach the gate via
588
+ // the printed URL. Give a manually-opened tab a short window, then bail
589
+ // out with the pending contract (carrying the failed-open status + link)
590
+ // instead of blocking the agent for the full timeout on an invisible gate.
591
+ const loaded = await server.waitForPageLoad(openFailedGraceMs);
592
+ if (!loaded) {
593
+ const err = new Error("browser never opened — gate unseen");
594
+ err.code = "GATE_TIMEOUT";
595
+ throw err;
596
+ }
597
+ }
598
+ decision = await server.waitForDecision({ timeoutMs });
599
+ } catch (err) {
600
+ if (err && err.code === "GATE_TIMEOUT") {
601
+ // Expected outcome, not an error: exit 0 with the pending contract so the
602
+ // agent wraps up cleanly instead of reading a scary non-zero failure.
603
+ // pageLoaded is re-checked NOW (not serveAndOpen's early snapshot) so a
604
+ // tab that opened late still counts as "the user saw the gate".
605
+ process.stdout.write(
606
+ formatPlanPendingForHarness(deckDir, {
607
+ timeoutMs,
608
+ pageLoaded: server.pageLoaded(),
609
+ url: server.url,
610
+ }),
611
+ );
612
+ return null;
613
+ }
614
+ throw err;
615
+ } finally {
616
+ server.close();
617
+ }
618
+
619
+ const { edited } = applyPlanEdit({
620
+ planPath,
621
+ frontmatterRaw,
622
+ body,
623
+ editedPlan: decision.editedPlan,
624
+ });
625
+
626
+ const planHash = crypto
627
+ .createHash("sha256")
628
+ .update(fs.readFileSync(planPath))
629
+ .digest("hex");
630
+ savePlanState(deckDir, {
631
+ version: 2,
632
+ decision: decision.decision,
633
+ comment: decision.comment ?? null,
634
+ edited,
635
+ planHash,
636
+ at: new Date().toISOString(),
637
+ });
638
+
639
+ if (decision.decision === "approve") {
640
+ if (edited) {
641
+ process.stdout.write(
642
+ "DECISION: approved\n" +
643
+ "The user approved the plan — and edited plan.md directly before approving. " +
644
+ `Re-read ${deckDir}/plan.md: the edited text IS the approved plan. Author from ` +
645
+ "it, not from your earlier draft.\n",
646
+ );
647
+ } else {
648
+ process.stdout.write(
649
+ "DECISION: approved\n" +
650
+ "The user approved the plan. Proceed to author the slides — the plan is agreed.\n",
651
+ );
652
+ }
653
+ } else {
654
+ process.stdout.write(formatPlanFeedbackForHarness(deckDir, decision.comment, { edited }));
655
+ }
656
+ return decision;
657
+ }
658
+
659
+ function parseArgs(argv) {
660
+ // port null ⇒ derive the deterministic per-deck plan port; an explicit --port
661
+ // (incl. --port 0 for OS-random) overrides it.
662
+ const args = { deck: null, port: null };
663
+ const usage = "usage: node plan.mjs --deck <dir> [--port <n>]\n";
664
+ for (let i = 0; i < argv.length; i++) {
665
+ const a = argv[i];
666
+ if (a === "--deck" && i + 1 < argv.length) {
667
+ args.deck = argv[++i];
668
+ } else if (a === "--port" && i + 1 < argv.length) {
669
+ args.port = parseInt(argv[++i], 10) || 0;
670
+ } else if (a === "-h" || a === "--help") {
671
+ process.stdout.write(usage);
672
+ process.exit(0);
673
+ } else {
674
+ process.stderr.write(`plan: unexpected argument '${a}'\n${usage}`);
675
+ process.exit(2);
676
+ }
677
+ }
678
+ if (!args.deck) {
679
+ process.stderr.write(`plan: --deck is required\n${usage}`);
680
+ process.exit(2);
681
+ }
682
+ return args;
683
+ }
684
+
685
+ async function main() {
686
+ const args = parseArgs(process.argv.slice(2));
687
+ const deckDir = path.resolve(args.deck);
688
+ await runPlanReview({ deckDir, port: args.port });
689
+ }
690
+
691
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
692
+ main().catch((err) => {
693
+ process.stderr.write(`plan: ${err.message || err}\n`);
694
+ process.exit(1);
695
+ });
696
+ }