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.
- package/CLAUDE.md +10 -7
- package/README.md +20 -7
- package/bin/slidemind.mjs +393 -39
- package/package.json +2 -1
- package/skills/create-slides/SKILL.md +68 -16
- package/skills/search/SKILL.md +15 -2
- package/src/bridge/build-and-push.mjs +11 -0
- package/src/bridge/bundle.mjs +15 -4
- package/src/bridge/capture.mjs +20 -2
- package/src/bridge/client.mjs +99 -0
- package/src/builders/build.mjs +83 -11
- package/src/builders/gate-server.mjs +364 -0
- package/src/builders/plan-markdown-edit.mjs +120 -0
- package/src/builders/plan-md.mjs +216 -0
- package/src/builders/plan-overlay.js +1778 -0
- package/src/builders/plan.mjs +696 -0
- package/src/builders/red-thread-backbone.mjs +194 -0
- package/src/builders/resolve-skill.mjs +40 -5
- package/src/builders/review-overlay.js +191 -13
- package/src/builders/review.mjs +196 -68
- package/src/builders/storyboard-markdown.mjs +118 -0
- package/src/builders/storyboard-schema.mjs +180 -0
- package/src/builders/visual-families.mjs +32 -0
- package/src/pptx/app-props.mjs +222 -0
- package/src/pptx/hidden-ole.mjs +125 -0
- package/src/pptx/image-extensions.mjs +190 -0
- package/src/pptx/ooxml.mjs +169 -0
- package/src/pptx/read-local.mjs +206 -0
- package/src/pptx/slide-swap.mjs +429 -0
- package/src/pptx/zip.mjs +30 -0
|
@@ -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
|
+
}
|