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