slidemind 0.1.7 → 0.1.8

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,575 @@
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
+ GRACE_MS,
59
+ POLL_MS,
60
+ POLL_TTL_MS,
61
+ } from "./gate-server.mjs";
62
+ import { resolveBrandAsset } from "../bridge/bundle.mjs";
63
+
64
+ const __filename = fileURLToPath(import.meta.url);
65
+ const __dirname = path.dirname(__filename);
66
+ const OVERLAY_PATH = path.join(__dirname, "plan-overlay.js");
67
+ const PLAN_MD_PATH = path.join(__dirname, "plan-md.mjs");
68
+ const PLAN_STATE_FILE = "plan-review.json";
69
+ const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
70
+
71
+ // Escape a </script> sequence so an inlined JSON/JS blob can't break out of the
72
+ // surrounding <script> tag (copied from review.mjs — that module is off-limits
73
+ // and doesn't export it).
74
+ function safeInlineJson(data) {
75
+ return JSON.stringify(data).replace(/<\/script>/g, "<\\/script>");
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // plan-review.json round-trip (previous-round feedback source)
80
+ // ---------------------------------------------------------------------------
81
+
82
+ export function loadPlanState(deckDir) {
83
+ const f = path.join(deckDir, PLAN_STATE_FILE);
84
+ // decision drives the grace-window logic in runPlanReview (a waiting tab can
85
+ // only exist after a prior comments round), so a fresh/garbage state must
86
+ // still carry an explicit decision: null — same shape review.mjs uses.
87
+ if (!fs.existsSync(f)) return { version: 2, decision: null, comment: null };
88
+ try {
89
+ const data = JSON.parse(fs.readFileSync(f, "utf8"));
90
+ // v1 files carried a per-block comments array; fold it into one feedback
91
+ // text so an old round still shows up as the previous-round note.
92
+ let comment = typeof data.comment === "string" ? data.comment : null;
93
+ if (comment == null && Array.isArray(data.comments)) {
94
+ const joined = data.comments
95
+ .map((c) => c && c.comment)
96
+ .filter(Boolean)
97
+ .join("\n");
98
+ comment = joined || null;
99
+ }
100
+ return {
101
+ version: 2,
102
+ decision: data.decision ?? null,
103
+ comment,
104
+ edited: data.edited ?? false,
105
+ planHash: data.planHash ?? null,
106
+ at: data.at ?? null,
107
+ };
108
+ } catch {
109
+ return { version: 2, decision: null, comment: null };
110
+ }
111
+ }
112
+
113
+ export function savePlanState(deckDir, state) {
114
+ fs.writeFileSync(
115
+ path.join(deckDir, PLAN_STATE_FILE),
116
+ JSON.stringify(state, null, 2) + "\n",
117
+ );
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // harness output
122
+ // ---------------------------------------------------------------------------
123
+
124
+ // Render the feedback round for the harness — the plan analog of review.mjs's
125
+ // formatCommentsForHarness, on ONE overall comment instead of per-block pins.
126
+ //
127
+ // The tail is deliberately imperative and stateful: after a comments round the
128
+ // user's browser tab sits on a waiting spinner that ONLY the next `slidemind
129
+ // plan` run releases, so an agent that ends its turn here (or redirects the
130
+ // user "back to the browser") deadlocks the session — the tab waits for the
131
+ // agent while the agent waits for the tab. Spell the next actions out as
132
+ // short, own-line steps so no instruction hides at the end of a long path.
133
+ export function formatPlanFeedbackForHarness(deckDir, comment, { edited = false } = {}) {
134
+ const out = [];
135
+ out.push("DECISION: comments");
136
+ out.push("The user requested changes to the plan. Nothing was authored.");
137
+ if (edited) {
138
+ out.push(
139
+ "The user also edited plan.md directly — re-read it before revising; the " +
140
+ "feedback below is on top of their edits.",
141
+ );
142
+ }
143
+ out.push("");
144
+ if (comment) {
145
+ out.push("Feedback:");
146
+ for (const line of String(comment).split("\n")) out.push(` ${line}`);
147
+ out.push("");
148
+ } else if (edited) {
149
+ out.push("(No written feedback — their direct edits to plan.md are the change.)");
150
+ out.push("");
151
+ }
152
+ out.push(
153
+ "The user's browser tab is still open, on a waiting spinner that says you are",
154
+ );
155
+ out.push("revising the plan. They are waiting on YOU now — do both steps in this");
156
+ out.push("same turn:");
157
+ out.push(` 1. Edit ${deckDir}/plan.md to address the feedback.`);
158
+ out.push(` 2. Re-run: slidemind plan ${path.basename(deckDir)}`);
159
+ out.push("The re-run reopens their same tab with the revised plan. Do NOT end your");
160
+ out.push("turn and do NOT send the user back to the browser before step 2 — nothing");
161
+ out.push("happens until the gate runs again.");
162
+ return out.join("\n") + "\n";
163
+ }
164
+
165
+ // Render the no-answer outcome for the harness. A timed-out gate is an EXPECTED
166
+ // state (the user stepped away), not a failure — the agent must neither treat
167
+ // it as approval nor abort the task; it wraps up with clear resume instructions.
168
+ export function formatPlanPendingForHarness(deckDir, { timeoutMs } = {}) {
169
+ const mins = Math.max(1, Math.round((timeoutMs ?? DEFAULT_TIMEOUT_MS) / 60000));
170
+ const base = path.basename(deckDir);
171
+ return [
172
+ "DECISION: pending",
173
+ `No decision yet — the gate waited ${mins} minutes and the user hasn't answered.`,
174
+ "This is NOT an approval and NOT a rejection. Do not author anything.",
175
+ "Close out your turn gracefully:",
176
+ " 1. Tell the user their plan is still waiting for sign-off in the browser.",
177
+ ` 2. Tell them that when they're ready, you'll re-open the gate — then, on`,
178
+ ` their go-ahead, re-run: slidemind plan ${base}`,
179
+ "A still-open tab reconnects to the re-run automatically; a closed one just",
180
+ "gets a fresh window. Nothing is lost by the wait.",
181
+ ].join("\n") + "\n";
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // page
186
+ // ---------------------------------------------------------------------------
187
+
188
+ // The rendered-markdown chrome for #plan-root (the live preview's content, and
189
+ // the whole page when JS is off). The overlay injects the header / editor /
190
+ // dock CSS itself; this only styles the plan text so it reads as a compact dark
191
+ // document. Hex colours are fine here — src/ is not parity-linted and this
192
+ // palette mirrors the overlay's neutral dark product tones.
193
+ const PAGE_CSS = `
194
+ :root{color-scheme:dark;}
195
+ html,body{margin:0;background:#111113;color:#ededed;}
196
+ body{font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
197
+ -webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;}
198
+ #plan-root{max-width:720px;margin:0 auto;padding:40px 28px 140px;
199
+ line-height:1.65;font-size:15.5px;color:#d6d6d9;}
200
+ #plan-root h1,#plan-root h2,#plan-root h3,#plan-root h4,#plan-root h5,#plan-root h6{
201
+ color:#f4f4f5;font-weight:600;line-height:1.28;margin:1.6em 0 .5em;}
202
+ #plan-root h1{font-size:1.95em;margin-top:.2em;}
203
+ #plan-root h2{font-size:1.5em;padding-bottom:.24em;border-bottom:1px solid #26262b;}
204
+ #plan-root h3{font-size:1.22em;}
205
+ #plan-root h4{font-size:1.05em;}
206
+ #plan-root h5,#plan-root h6{font-size:.95em;color:#c4c4c9;}
207
+ #plan-root p{margin:.7em 0;}
208
+ #plan-root ul,#plan-root ol{margin:.6em 0;padding-left:1.5em;}
209
+ #plan-root li{margin:.3em 0;}
210
+ #plan-root blockquote{margin:.95em 0;padding:.45em 1em;border-left:3px solid #3a3a40;
211
+ color:#a8a8ad;background:rgba(255,255,255,.02);border-radius:0 6px 6px 0;}
212
+ #plan-root code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
213
+ font-size:.88em;background:#1c1c20;padding:.12em .42em;border-radius:5px;}
214
+ #plan-root pre{background:#0c0c0e;border:1px solid #26262b;border-radius:10px;
215
+ padding:14px 16px;overflow-x:auto;line-height:1.5;}
216
+ #plan-root pre code{background:none;padding:0;font-size:.86em;}
217
+ #plan-root table{border-collapse:collapse;width:100%;margin:1em 0;font-size:.92em;
218
+ display:block;overflow-x:auto;}
219
+ #plan-root th,#plan-root td{border:1px solid #2b2b30;padding:7px 11px;text-align:left;}
220
+ #plan-root th{background:#18181b;font-weight:600;color:#ededed;}
221
+ #plan-root strong{color:#f4f4f5;}
222
+ #plan-root a{color:#a9c2ff;}
223
+ `;
224
+
225
+ // Build the standalone plan-review page: the rendered markdown in #plan-root
226
+ // (the no-JS fallback and the preview's initial state), the injected overlay
227
+ // config, the markdown renderer for the live preview, then the overlay script
228
+ // itself. `planMdJs` is the raw plan-md.mjs source — a dependency-free ESM
229
+ // module, so stripping its `export ` keywords makes it plain browser JS; it is
230
+ // wrapped in an IIFE that exposes window.__SLIDEMIND_RENDER_MD__ for the
231
+ // overlay's live re-render. Kept a pure function of its inputs so it's
232
+ // unit-testable without a server.
233
+ export function buildPlanPage({
234
+ bodyHtml,
235
+ previousComment,
236
+ overlayJs,
237
+ planMdJs,
238
+ logoMark,
239
+ planBody,
240
+ bodyStartLine,
241
+ round,
242
+ }) {
243
+ const cfg = safeInlineJson({
244
+ endpoint: "/plan-review",
245
+ heartbeat: "/__plan/heartbeat",
246
+ round: round ?? null,
247
+ pollMs: POLL_MS,
248
+ pollTtlMs: POLL_TTL_MS,
249
+ previousComment: previousComment ?? null,
250
+ logoMark: logoMark ?? null,
251
+ planBody: planBody ?? "",
252
+ bodyStartLine: bodyStartLine ?? 1,
253
+ });
254
+ const overlay = String(overlayJs || "").replace(/<\/script>/g, "<\\/script>");
255
+ const rendererSrc = String(planMdJs || "")
256
+ .replace(/^export\s+/gm, "")
257
+ .replace(/<\/script>/g, "<\\/script>");
258
+ const rendererBlock = rendererSrc
259
+ ? "<script>\n(function () {\n" +
260
+ rendererSrc +
261
+ "\nwindow.__SLIDEMIND_RENDER_MD__ = renderPlanMarkdown;\n})();\n</script>\n"
262
+ : "";
263
+ return (
264
+ "<!doctype html>\n" +
265
+ '<html lang="en">\n' +
266
+ "<head>\n" +
267
+ '<meta charset="utf-8">\n' +
268
+ '<meta name="viewport" content="width=device-width, initial-scale=1">\n' +
269
+ "<title>Plan review</title>\n" +
270
+ `<style>${PAGE_CSS}</style>\n` +
271
+ "</head>\n" +
272
+ "<body>\n" +
273
+ `<div id="plan-root">${bodyHtml}</div>\n` +
274
+ `<script>window.__SLIDEMIND_PLAN_REVIEW__ = ${cfg};</script>\n` +
275
+ rendererBlock +
276
+ `<script>\n${overlay}\n</script>\n` +
277
+ "</body>\n" +
278
+ "</html>\n"
279
+ );
280
+ }
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // server
284
+ // ---------------------------------------------------------------------------
285
+
286
+ // Start the local plan-review server. Returns { url, port, waitForDecision,
287
+ // close }. Serves the rendered plan.md at "/", the per-brand orb at
288
+ // "/brand/logo" (when a bundle asset resolved), and any other deck-local file
289
+ // (traversal-guarded) for completeness.
290
+ export async function createPlanReviewServer({
291
+ deckDir,
292
+ body,
293
+ bodyStartLine,
294
+ previousComment = null,
295
+ overlayJs,
296
+ planMdJs,
297
+ logoMark = null,
298
+ brandAssetPath = null,
299
+ port = 0,
300
+ }) {
301
+ const gate = createDecisionGate({
302
+ timeoutMessage: "plan review timed out — no decision received",
303
+ });
304
+ // Own service marker so a tab left waiting on this port only reloads into the
305
+ // NEXT plan round — never into the preview gate or a foreign app on the port.
306
+ const hb = createHeartbeat({ service: "slidemind-plan" });
307
+ const overlay = overlayJs ?? fs.readFileSync(OVERLAY_PATH, "utf8");
308
+ const planMd = planMdJs ?? fs.readFileSync(PLAN_MD_PATH, "utf8");
309
+ const bodyHtml = renderPlanMarkdown(body, { lineOffset: bodyStartLine - 1 });
310
+ const indexHtml = buildPlanPage({
311
+ bodyHtml,
312
+ previousComment,
313
+ overlayJs: overlay,
314
+ planMdJs: planMd,
315
+ logoMark,
316
+ planBody: body,
317
+ bodyStartLine,
318
+ round: hb.round,
319
+ });
320
+ const deckRoot = path.resolve(deckDir);
321
+
322
+ const server = http.createServer((req, res) => {
323
+ const u = new URL(req.url, "http://127.0.0.1");
324
+ const pathname = decodeURIComponent(u.pathname);
325
+
326
+ if (req.method === "POST" && pathname === "/plan-review") {
327
+ let raw = "";
328
+ req.on("data", (chunk) => {
329
+ raw += chunk;
330
+ if (raw.length > 5e6) req.destroy();
331
+ });
332
+ req.on("end", () => {
333
+ let payload;
334
+ try {
335
+ payload = JSON.parse(raw || "{}");
336
+ } catch {
337
+ payload = {};
338
+ }
339
+ res.writeHead(200, { "Content-Type": MIME[".json"] });
340
+ res.end(JSON.stringify({ ok: true }));
341
+ const commentRaw = typeof payload.comment === "string" ? payload.comment.trim() : "";
342
+ gate.resolve({
343
+ decision: payload.decision === "comments" ? "comments" : "approve",
344
+ comment: commentRaw || null,
345
+ editedPlan: typeof payload.editedPlan === "string" ? payload.editedPlan : null,
346
+ });
347
+ });
348
+ return;
349
+ }
350
+
351
+ if (req.method !== "GET") {
352
+ res.writeHead(405);
353
+ res.end();
354
+ return;
355
+ }
356
+
357
+ if (pathname === "/__plan/heartbeat") {
358
+ hb.respond(res);
359
+ return;
360
+ }
361
+
362
+ if (pathname === "/" || pathname === "/index.html") {
363
+ res.writeHead(200, { "Content-Type": MIME[".html"] });
364
+ res.end(indexHtml);
365
+ return;
366
+ }
367
+
368
+ if (pathname === "/brand/logo") {
369
+ if (!brandAssetPath) {
370
+ res.writeHead(404);
371
+ res.end("not found");
372
+ return;
373
+ }
374
+ fs.readFile(brandAssetPath, (err, data) => {
375
+ if (err) {
376
+ res.writeHead(404);
377
+ res.end("not found");
378
+ return;
379
+ }
380
+ const ext = path.extname(brandAssetPath).toLowerCase();
381
+ res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" });
382
+ res.end(data);
383
+ });
384
+ return;
385
+ }
386
+
387
+ if (serveStaticFile({ roots: [deckRoot], pathname, res })) return;
388
+ res.writeHead(404);
389
+ res.end("not found");
390
+ });
391
+
392
+ await listenWithFallback(server, port, "127.0.0.1", (wanted, err) => {
393
+ process.stderr.write(
394
+ `plan: port ${wanted} unavailable (${err.code}); using an OS-assigned port instead.\n`,
395
+ );
396
+ });
397
+ const addr = server.address();
398
+ const url = `http://127.0.0.1:${addr.port}/`;
399
+
400
+ return {
401
+ url,
402
+ port: addr.port,
403
+ waitForHeartbeat: (ms) => hb.waitForHeartbeat(ms),
404
+ waitForDecision: (opts) => gate.waitForDecision(opts),
405
+ close() {
406
+ try {
407
+ server.close();
408
+ } catch {
409
+ /* already closing */
410
+ }
411
+ },
412
+ };
413
+ }
414
+
415
+ // ---------------------------------------------------------------------------
416
+ // run
417
+ // ---------------------------------------------------------------------------
418
+
419
+ // Write the user's direct edit back to plan.md, preserving the frontmatter
420
+ // fence byte-for-byte. A no-op (returns { edited:false } without touching the
421
+ // file) when the user never edited or their text matches the current body.
422
+ // Extracted so the write path is unit-testable without a browser/server.
423
+ export function applyPlanEdit({ planPath, frontmatterRaw, body, editedPlan }) {
424
+ if (editedPlan == null || editedPlan === body) return { edited: false };
425
+ fs.writeFileSync(planPath, frontmatterRaw + editedPlan);
426
+ return { edited: true };
427
+ }
428
+
429
+ export async function runPlanReview({
430
+ deckDir,
431
+ port = null,
432
+ timeoutMs = DEFAULT_TIMEOUT_MS,
433
+ open = openInBrowser,
434
+ } = {}) {
435
+ const base = path.basename(deckDir);
436
+ const planPath = path.join(deckDir, "plan.md");
437
+ if (!fs.existsSync(planPath)) {
438
+ process.stderr.write(
439
+ `no plan.md in ${base} — write the plan first, then re-run: slidemind plan ${base}\n`,
440
+ );
441
+ process.exitCode = 1;
442
+ return;
443
+ }
444
+
445
+ const { frontmatterRaw, body, bodyStartLine } = splitPlanFrontmatter(
446
+ fs.readFileSync(planPath, "utf8"),
447
+ );
448
+
449
+ // The plan window's logo is the add-in's genuine per-brand orb when a bundle
450
+ // is materialized (dark variant preferred, matching the dark UI). Absent a
451
+ // bundle, logoMark is null and the overlay draws its inline slide glyph.
452
+ const mark = resolveBrandAsset(deckDir, ["logoMarkDark", "logoMark", "logoMarkLight"]);
453
+ const logoMark = mark ? "/brand/logo" : null;
454
+ const brandAssetPath = mark?.path ?? null;
455
+
456
+ const prev = loadPlanState(deckDir);
457
+
458
+ const wantPort = port == null ? preferredGatePort(deckDir, "plan") : port;
459
+ const server = await createPlanReviewServer({
460
+ deckDir,
461
+ body,
462
+ bodyStartLine,
463
+ previousComment: prev.comment ?? null,
464
+ logoMark,
465
+ brandAssetPath,
466
+ port: wantPort,
467
+ });
468
+ // A waiting tab can only exist when the previous round ended in comments — so
469
+ // first runs / post-approve runs open the browser immediately (graceMs 0).
470
+ await serveAndOpen(server, {
471
+ open,
472
+ graceMs: prev.decision === "comments" ? GRACE_MS : 0,
473
+ readyLabel: "Plan review",
474
+ reuseNoun: "plan tab",
475
+ openedLine:
476
+ "Opened your browser — edit the plan or tell the agent what to change; approving starts the build.",
477
+ waitLine:
478
+ `Waiting for the user's decision — this command BLOCKS (up to ${Math.round(
479
+ timeoutMs / 60000,
480
+ )} min) while they review. A long quiet wait is normal; do not cancel it.`,
481
+ });
482
+
483
+ let decision;
484
+ try {
485
+ decision = await server.waitForDecision({ timeoutMs });
486
+ } catch (err) {
487
+ if (err && err.code === "GATE_TIMEOUT") {
488
+ // Expected outcome, not an error: exit 0 with the pending contract so the
489
+ // agent wraps up cleanly instead of reading a scary non-zero failure.
490
+ process.stdout.write(formatPlanPendingForHarness(deckDir, { timeoutMs }));
491
+ return null;
492
+ }
493
+ throw err;
494
+ } finally {
495
+ server.close();
496
+ }
497
+
498
+ const { edited } = applyPlanEdit({
499
+ planPath,
500
+ frontmatterRaw,
501
+ body,
502
+ editedPlan: decision.editedPlan,
503
+ });
504
+
505
+ const planHash = crypto
506
+ .createHash("sha256")
507
+ .update(fs.readFileSync(planPath))
508
+ .digest("hex");
509
+ savePlanState(deckDir, {
510
+ version: 2,
511
+ decision: decision.decision,
512
+ comment: decision.comment ?? null,
513
+ edited,
514
+ planHash,
515
+ at: new Date().toISOString(),
516
+ });
517
+
518
+ if (decision.decision === "approve") {
519
+ if (edited) {
520
+ process.stdout.write(
521
+ "DECISION: approved\n" +
522
+ "The user approved the plan — and edited plan.md directly before approving. " +
523
+ `Re-read ${deckDir}/plan.md: the edited text IS the approved plan. Author from ` +
524
+ "it, not from your earlier draft.\n",
525
+ );
526
+ } else {
527
+ process.stdout.write(
528
+ "DECISION: approved\n" +
529
+ "The user approved the plan. Proceed to author the slides — the plan is agreed.\n",
530
+ );
531
+ }
532
+ } else {
533
+ process.stdout.write(formatPlanFeedbackForHarness(deckDir, decision.comment, { edited }));
534
+ }
535
+ return decision;
536
+ }
537
+
538
+ function parseArgs(argv) {
539
+ // port null ⇒ derive the deterministic per-deck plan port; an explicit --port
540
+ // (incl. --port 0 for OS-random) overrides it.
541
+ const args = { deck: null, port: null };
542
+ const usage = "usage: node plan.mjs --deck <dir> [--port <n>]\n";
543
+ for (let i = 0; i < argv.length; i++) {
544
+ const a = argv[i];
545
+ if (a === "--deck" && i + 1 < argv.length) {
546
+ args.deck = argv[++i];
547
+ } else if (a === "--port" && i + 1 < argv.length) {
548
+ args.port = parseInt(argv[++i], 10) || 0;
549
+ } else if (a === "-h" || a === "--help") {
550
+ process.stdout.write(usage);
551
+ process.exit(0);
552
+ } else {
553
+ process.stderr.write(`plan: unexpected argument '${a}'\n${usage}`);
554
+ process.exit(2);
555
+ }
556
+ }
557
+ if (!args.deck) {
558
+ process.stderr.write(`plan: --deck is required\n${usage}`);
559
+ process.exit(2);
560
+ }
561
+ return args;
562
+ }
563
+
564
+ async function main() {
565
+ const args = parseArgs(process.argv.slice(2));
566
+ const deckDir = path.resolve(args.deck);
567
+ await runPlanReview({ deckDir, port: args.port });
568
+ }
569
+
570
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
571
+ main().catch((err) => {
572
+ process.stderr.write(`plan: ${err.message || err}\n`);
573
+ process.exit(1);
574
+ });
575
+ }
@@ -176,17 +176,52 @@ export function resolveSkill(skillName, planMeta = {}, opts = {}) {
176
176
  // plan.md frontmatter
177
177
  // ---------------------------------------------------------------------------
178
178
 
179
+ // Split a plan.md into its YAML-ish frontmatter fence and the markdown body,
180
+ // LOSSLESSLY: `frontmatterRaw + body === text` byte-for-byte for every input.
181
+ // Same fence rule as the frontmatter parser: frontmatter exists iff the text
182
+ // starts with "---" and there is a later "\n---". `frontmatterRaw` runs through
183
+ // the END of the closing fence line INCLUDING its trailing newline (or the whole
184
+ // text, with an empty body, when the closing fence is the last line and carries
185
+ // no trailing newline). `bodyStartLine` is the 1-based line the body begins on
186
+ // (= count of "\n" in frontmatterRaw + 1). With no frontmatter the whole text is
187
+ // the body and `bodyStartLine` is 1.
188
+ export function splitPlanFrontmatter(text) {
189
+ const s = String(text);
190
+ if (!s.startsWith("---")) {
191
+ return { frontmatterRaw: "", body: s, bodyStartLine: 1 };
192
+ }
193
+ const end = s.indexOf("\n---", 3);
194
+ if (end < 0) {
195
+ return { frontmatterRaw: "", body: s, bodyStartLine: 1 };
196
+ }
197
+ // The closing fence line starts at end+1 (the first "-"); it ends at the next
198
+ // newline. frontmatterRaw includes that newline so the body starts clean.
199
+ const nlAfterFence = s.indexOf("\n", end + 1);
200
+ let frontmatterRaw;
201
+ let body;
202
+ if (nlAfterFence < 0) {
203
+ frontmatterRaw = s; // closing fence at EOF, no trailing newline
204
+ body = "";
205
+ } else {
206
+ frontmatterRaw = s.slice(0, nlAfterFence + 1);
207
+ body = s.slice(nlAfterFence + 1);
208
+ }
209
+ const bodyStartLine = (frontmatterRaw.match(/\n/g) || []).length + 1;
210
+ return { frontmatterRaw, body, bodyStartLine };
211
+ }
212
+
179
213
  function readPlanFrontmatter(deckDir) {
180
214
  if (!deckDir) return {};
181
215
  const file = path.join(deckDir, "plan.md");
182
216
  if (!fs.existsSync(file)) return {};
183
217
  const text = fs.readFileSync(file, "utf8");
184
- if (!text.startsWith("---")) return {};
185
- const end = text.indexOf("\n---", 3);
186
- if (end < 0) return {};
187
- const body = text.slice(3, end);
218
+ const { frontmatterRaw } = splitPlanFrontmatter(text);
219
+ if (!frontmatterRaw) return {}; // no frontmatter fence → {}, exactly as before
220
+ // Parse key: value fields out of the fence block. The two "---" fence lines
221
+ // never match the field regex, so scanning the whole raw block is equivalent
222
+ // to scanning strictly between the fences.
188
223
  const out = {};
189
- for (const line of body.split("\n")) {
224
+ for (const line of frontmatterRaw.split("\n")) {
190
225
  const m = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.+?)\s*$/);
191
226
  if (m) out[m[1]] = stripQuotes(m[2]);
192
227
  }