slidemind 0.3.20 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +592 -16
- package/LICENSE +4 -0
- package/README.md +203 -8
- package/THIRD-PARTY-NOTICES.md +201 -0
- package/bin/slidemind.mjs +50 -2
- package/package.json +5 -3
- package/skills/create-slides/SKILL.md +484 -886
- package/skills/search/SKILL.md +46 -23
- package/src/bridge/build-and-push.mjs +548 -73
- package/src/bridge/bundle.mjs +100 -31
- package/src/bridge/capture.mjs +14 -17
- package/src/bridge/client/brands.mjs +157 -18
- package/src/bridge/client/engine-routing.mjs +48 -13
- package/src/bridge/client/engine-search.mjs +24 -1
- package/src/bridge/client/render.mjs +27 -1
- package/src/bridge/client/transport.mjs +499 -23
- package/src/bridge/client.mjs +10 -0
- package/src/bridge/config.mjs +67 -6
- package/src/bridge/engine-download.mjs +0 -10
- package/src/bridge/graph-auth.mjs +55 -3
- package/src/bridge/image-intent-envelope.mjs +239 -0
- package/src/bridge/package-preview.mjs +67 -0
- package/src/bridge/sharepoint.mjs +914 -0
- package/src/builders/asset-fetch.mjs +203 -0
- package/src/builders/block-dividers.mjs +83 -0
- package/src/builders/build.mjs +171 -87
- package/src/builders/card-height.mjs +294 -0
- package/src/builders/chart-hole.mjs +183 -0
- package/src/builders/chart-jsx.mjs +410 -0
- package/src/builders/chart-labels.mjs +326 -0
- package/src/builders/concept-geometry.mjs +370 -0
- package/src/builders/concept-intent.mjs +200 -0
- package/src/builders/concept-layout.mjs +79 -0
- package/src/builders/concept-lines.mjs +509 -0
- package/src/builders/concept-lists.mjs +270 -0
- package/src/builders/concept-marks.mjs +84 -0
- package/src/builders/concept-relationships.mjs +12 -0
- package/src/builders/concept-spec.mjs +645 -0
- package/src/builders/concept-text.mjs +140 -0
- package/src/builders/concept-waves.mjs +57 -0
- package/src/builders/copy-fit.mjs +139 -0
- package/src/builders/design-tokens.mjs +347 -0
- package/src/builders/draft-check.mjs +552 -340
- package/src/builders/draft-lines.mjs +369 -0
- package/src/builders/edit-route.mjs +29 -0
- package/src/builders/figure-sketch.mjs +375 -0
- package/src/builders/fold-sets.mjs +639 -0
- package/src/builders/gate-server.mjs +9 -4
- package/src/builders/gate-session.js +273 -12
- package/src/builders/gate-shell.js +1494 -0
- package/src/builders/image-intent-check.mjs +308 -0
- package/src/builders/image-use-reconcile.mjs +107 -0
- package/src/builders/kit-helpers.mjs +121 -0
- package/src/builders/layout-names.mjs +32 -0
- package/src/builders/legacy-reference-uses.mjs +179 -0
- package/src/builders/line-fit.mjs +224 -0
- package/src/builders/line-native.mjs +895 -0
- package/src/builders/line-relations.mjs +230 -0
- package/src/builders/lint-fix.mjs +117 -9
- package/src/builders/lint.mjs +25 -20
- package/src/builders/list-jsx.mjs +88 -0
- package/src/builders/placement.mjs +133 -0
- package/src/builders/plan-checks.mjs +135 -0
- package/src/builders/plan-comments.mjs +19 -0
- package/src/builders/plan-handlers.mjs +95 -0
- package/src/builders/plan-markdown-edit.mjs +3 -3
- package/src/builders/plan-overlay.js +1530 -1608
- package/src/builders/plan.mjs +689 -186
- package/src/builders/preset-definitions.json +1 -0
- package/src/builders/preset-eval.mjs +244 -0
- package/src/builders/preview.html +51 -120
- package/src/builders/red-thread-backbone.mjs +135 -36
- package/src/builders/repair.mjs +205 -0
- package/src/builders/repeat-groups.mjs +540 -0
- package/src/builders/resolve-skill.mjs +5 -4
- package/src/builders/review-overlay.js +296 -293
- package/src/builders/review.mjs +305 -84
- package/src/builders/rule-shift.mjs +96 -0
- package/src/builders/seat.mjs +354 -0
- package/src/builders/settle-driver.mjs +236 -0
- package/src/builders/settle.mjs +1448 -0
- package/src/builders/slide-prep.mjs +67 -0
- package/src/builders/spec-assets.mjs +236 -0
- package/src/builders/spec-match.mjs +56 -0
- package/src/builders/stack.mjs +413 -0
- package/src/builders/storyboard-markdown.mjs +70 -27
- package/src/builders/storyboard-schema.mjs +54 -114
- package/src/builders/text-blocks.mjs +212 -0
- package/src/builders/text-card-fit.mjs +552 -0
- package/src/builders/text-stack.mjs +276 -0
- package/src/builders/transcribe.mjs +1069 -0
- package/src/cli/assets.mjs +164 -0
- package/src/cli/common.mjs +173 -12
- package/src/cli/concept-spec-support.mjs +76 -0
- package/src/cli/concepts.mjs +1002 -103
- package/src/cli/deck.mjs +401 -65
- package/src/cli/diagnose.mjs +4 -2
- package/src/cli/engine-helpers.mjs +17 -10
- package/src/cli/engine-results.mjs +76 -3
- package/src/cli/help.mjs +232 -28
- package/src/cli/install.mjs +9 -0
- package/src/cli/maintenance.mjs +104 -0
- package/src/cli/read.mjs +2 -1
- package/src/cli/ready.mjs +6 -3
- package/src/cli/record.mjs +522 -0
- package/src/cli/remote.mjs +818 -0
- package/src/cli/revision.mjs +686 -0
- package/src/cli/search.mjs +30 -11
- package/src/cli/session.mjs +56 -13
- package/src/cli/setup.mjs +46 -2
- package/src/cli/status.mjs +142 -33
- package/src/cli/tasks.mjs +246 -0
- package/src/cli/transcribe.mjs +854 -0
- package/src/cli/workspace-ui.mjs +260 -0
- package/src/cli/workspace.mjs +212 -34
- package/src/install-channel.mjs +64 -7
- package/src/lib/adopt.mjs +346 -25
- package/src/lib/concept-spec-binding.mjs +96 -0
- package/src/lib/conversions.mjs +70 -6
- package/src/lib/convert-folded.mjs +107 -0
- package/src/lib/deck-paths.mjs +418 -0
- package/src/lib/durable-ids.mjs +47 -0
- package/src/lib/edit-operations.mjs +316 -0
- package/src/lib/edit-state.mjs +1244 -0
- package/src/lib/edit-transitions.mjs +120 -0
- package/src/lib/extensions.mjs +501 -0
- package/src/lib/image-assets.mjs +465 -0
- package/src/lib/image-derivatives.mjs +234 -0
- package/src/lib/image-intent-fingerprint.mjs +70 -0
- package/src/lib/image-intent-tags.mjs +176 -0
- package/src/lib/image-intent.mjs +802 -0
- package/src/lib/image-use-overrides.mjs +279 -0
- package/src/lib/inline-components.mjs +613 -0
- package/src/lib/lint-converted.mjs +68 -4
- package/src/lib/merge-json.mjs +163 -0
- package/src/lib/overwrite-guard.mjs +3 -3
- package/src/lib/presentation-context.mjs +531 -0
- package/src/lib/preservation-tags.mjs +157 -0
- package/src/lib/preservation-verdict.mjs +1342 -0
- package/src/lib/publish.mjs +1557 -0
- package/src/lib/rebase.mjs +945 -0
- package/src/lib/reconcile-ack.mjs +4 -6
- package/src/lib/reconcile.mjs +62 -24
- package/src/lib/record-client.mjs +175 -0
- package/src/lib/record-state.mjs +151 -0
- package/src/lib/remote.mjs +661 -0
- package/src/lib/revision.mjs +1210 -0
- package/src/lib/slide-guids.mjs +4 -6
- package/src/lib/slide-preservation.mjs +423 -0
- package/src/lib/slide-selection.mjs +9 -0
- package/src/lib/workspace-transaction.mjs +117 -0
- package/src/pptx/content-digest.mjs +334 -0
- package/src/pptx/deck-assemble.mjs +1278 -0
- package/src/pptx/fill/engine.mjs +2 -3
- package/src/pptx/fill/freeform.mjs +6 -3
- package/src/pptx/ooxml.mjs +20 -0
- package/src/pptx/package-evidence.mjs +383 -0
- package/src/pptx/tags.mjs +163 -11
- package/src/update-check.mjs +22 -4
- package/src/workspace/adapters.mjs +222 -0
- package/src/workspace/auth.mjs +256 -0
- package/src/workspace/autostart.mjs +204 -0
- package/src/workspace/daemon.mjs +360 -0
- package/src/workspace/discovery.mjs +180 -0
- package/src/workspace/edit.mjs +167 -0
- package/src/workspace/events.mjs +52 -0
- package/src/workspace/gate-client.mjs +290 -0
- package/src/workspace/handback.mjs +73 -0
- package/src/workspace/lifecycle.mjs +635 -0
- package/src/workspace/operations.mjs +200 -0
- package/src/workspace/pick-folder.mjs +152 -0
- package/src/workspace/registry.mjs +359 -0
- package/src/workspace/remote-links.mjs +397 -0
- package/src/workspace/remote-status.mjs +359 -0
- package/src/workspace/routes.mjs +1331 -0
- package/src/workspace/server.mjs +159 -0
- package/src/workspace/sync.mjs +1119 -0
- package/src/workspace/task-actions.mjs +215 -0
- package/src/workspace/tasks.mjs +507 -0
- package/src/workspace/ui/app.js +1886 -0
- package/src/workspace/ui/index.html +141 -0
- package/src/workspace/ui/plan-bridge.js +120 -0
- package/src/workspace/ui/sharepoint.svg +59 -0
- package/src/workspace/ui/styles.css +423 -0
- package/src/builders/visual-families.mjs +0 -46
package/src/builders/plan.mjs
CHANGED
|
@@ -52,11 +52,15 @@ import path from "node:path";
|
|
|
52
52
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
53
53
|
|
|
54
54
|
import { openInBrowser } from "./build.mjs";
|
|
55
|
-
import { loadReviewState } from "./review.mjs";
|
|
55
|
+
import { loadReviewState, writeStateAtomic } from "./review.mjs";
|
|
56
56
|
import { renderPlanMarkdown } from "./plan-md.mjs";
|
|
57
57
|
import { splitPlanFrontmatter } from "./resolve-skill.mjs";
|
|
58
58
|
import { layoutJobs, isFrameJob, plannedJob } from "./layout-jobs.mjs";
|
|
59
59
|
import { splitPlanMarkdownIntoSegments } from "./storyboard-markdown.mjs";
|
|
60
|
+
import { serializeStoryboardCanvas } from "./storyboard-schema.mjs";
|
|
61
|
+
import { isEditRouteDeck } from "./edit-route.mjs";
|
|
62
|
+
import { planCommentBullet } from "./plan-comments.mjs";
|
|
63
|
+
import { CONTENT_DRAFT, PLAN_NOT_PRESENTED, bodySlidesWithoutContent, parseDeliverable, planCheckErrors as checkPlan } from "./plan-checks.mjs";
|
|
60
64
|
import {
|
|
61
65
|
FAVICON_LINK,
|
|
62
66
|
MIME,
|
|
@@ -76,7 +80,25 @@ import {
|
|
|
76
80
|
POLL_MS,
|
|
77
81
|
POLL_TTL_MS,
|
|
78
82
|
} from "./gate-server.mjs";
|
|
79
|
-
import {
|
|
83
|
+
import {
|
|
84
|
+
listCachedBundles,
|
|
85
|
+
readDeckBundleRef,
|
|
86
|
+
resolveBrandAsset,
|
|
87
|
+
resolveBundleDirForDeck,
|
|
88
|
+
writeDeckBundleRef,
|
|
89
|
+
} from "../bridge/bundle.mjs";
|
|
90
|
+
import { readDesignName, readThemeColors } from "../cli/designs.mjs";
|
|
91
|
+
import { readImageAssetManifest } from "../lib/image-assets.mjs";
|
|
92
|
+
import { reconcileImageUseOverrides } from "./image-use-reconcile.mjs";
|
|
93
|
+
import {
|
|
94
|
+
PLAN_PAGE_MARKER,
|
|
95
|
+
deckSlug,
|
|
96
|
+
planPageOwner,
|
|
97
|
+
planPagePath,
|
|
98
|
+
readPlanOwner,
|
|
99
|
+
statePath,
|
|
100
|
+
writePlanOwner,
|
|
101
|
+
} from "../lib/deck-paths.mjs";
|
|
80
102
|
|
|
81
103
|
const __filename = fileURLToPath(import.meta.url);
|
|
82
104
|
const __dirname = path.dirname(__filename);
|
|
@@ -85,12 +107,22 @@ const PLAN_MD_PATH = path.join(__dirname, "plan-md.mjs");
|
|
|
85
107
|
// The storyboard/red-thread plan-review modules, bundled into the browser page
|
|
86
108
|
// alongside plan-md.mjs (see bundlePlanModules). Order matters: each file's
|
|
87
109
|
// inter-module imports are stripped, so a module must be concatenated AFTER the
|
|
88
|
-
// module whose exports it references (
|
|
110
|
+
// module whose exports it references (image-intent ← schema ←
|
|
111
|
+
// storyboard-markdown ← backbone). image-intent.mjs lives under src/lib/ and
|
|
112
|
+
// was written browser-safe (no Node builtins, `ii`/`II_`-prefixed module
|
|
113
|
+
// scope) precisely so it can ride along here — the schema parses `assetUses`
|
|
114
|
+
// through it.
|
|
115
|
+
const IMAGE_INTENT_PATH = path.join(__dirname, "..", "lib", "image-intent.mjs");
|
|
89
116
|
const STORYBOARD_SCHEMA_PATH = path.join(__dirname, "storyboard-schema.mjs");
|
|
90
117
|
const STORYBOARD_MARKDOWN_PATH = path.join(__dirname, "storyboard-markdown.mjs");
|
|
91
118
|
const RED_THREAD_BACKBONE_PATH = path.join(__dirname, "red-thread-backbone.mjs");
|
|
92
119
|
const PLAN_MARKDOWN_EDIT_PATH = path.join(__dirname, "plan-markdown-edit.mjs");
|
|
93
120
|
const PLAN_STATE_FILE = "plan-review.json";
|
|
121
|
+
// What a body slide's content draft holds — one wording (plan-checks.mjs) for
|
|
122
|
+
// the approval output, the deck stage, the concepts refusal and the add-in.
|
|
123
|
+
export { CONTENT_DRAFT, parseDeliverable };
|
|
124
|
+
const NEXT_AFTER_APPROVAL =
|
|
125
|
+
`write ${CONTENT_DRAFT} into each storyboard's \`content\` in plan.md (no new sign-off needed), then run \`slidemind concepts\`.`;
|
|
94
126
|
// No decision timeout: the gate returns only on the user's decision (or when
|
|
95
127
|
// no browser could be opened at all — see openFailedGraceMs). An agent cannot
|
|
96
128
|
// proceed past a plan a human has not approved, so the process waits as long as
|
|
@@ -135,7 +167,7 @@ export function normalizePlanComments(raw) {
|
|
|
135
167
|
// drives the tab-reuse grace window in runPlanReview (a waiting tab can only
|
|
136
168
|
// exist after a prior comments round).
|
|
137
169
|
export function loadPlanState(deckDir) {
|
|
138
|
-
const f =
|
|
170
|
+
const f = statePath(deckDir, PLAN_STATE_FILE);
|
|
139
171
|
if (!fs.existsSync(f)) return { ...EMPTY_STATE };
|
|
140
172
|
try {
|
|
141
173
|
const data = JSON.parse(fs.readFileSync(f, "utf8"));
|
|
@@ -146,7 +178,14 @@ export function loadPlanState(deckDir) {
|
|
|
146
178
|
comments: normalizePlanComments(data.comments),
|
|
147
179
|
edited: data.edited ?? false,
|
|
148
180
|
planHash: data.planHash ?? null,
|
|
181
|
+
storyHash: data.storyHash ?? null,
|
|
149
182
|
at: data.at ?? null,
|
|
183
|
+
// Workspace provenance (respondPlan): which operation wrote this round and
|
|
184
|
+
// who answered. Absent on rounds the standalone gate recorded.
|
|
185
|
+
operationId: typeof data.operationId === "string" ? data.operationId : null,
|
|
186
|
+
actor: data.actor ?? null,
|
|
187
|
+
// The design the user switched the deck to with this answer (switchPlanDesign).
|
|
188
|
+
design: data.design && typeof data.design.to === "string" ? data.design : null,
|
|
150
189
|
};
|
|
151
190
|
} catch {
|
|
152
191
|
return { ...EMPTY_STATE };
|
|
@@ -154,10 +193,10 @@ export function loadPlanState(deckDir) {
|
|
|
154
193
|
}
|
|
155
194
|
|
|
156
195
|
export function savePlanState(deckDir, state) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
);
|
|
196
|
+
writeStateAtomic(deckDir, PLAN_STATE_FILE, JSON.stringify(state, null, 2) + "\n");
|
|
197
|
+
// Every writer of the record re-renders the page in the same command —
|
|
198
|
+
// a plan.html nobody refreshed is worse than none at all.
|
|
199
|
+
writePlanPage(deckDir);
|
|
161
200
|
}
|
|
162
201
|
|
|
163
202
|
// The human checkmark every post-plan command (concepts, build, view-draft,
|
|
@@ -169,14 +208,32 @@ export function savePlanState(deckDir, state) {
|
|
|
169
208
|
// The gate binds only until the deck has passed a review: from then on the plan
|
|
170
209
|
// has been executed and plan.md is a record, not a contract — the add-in's
|
|
171
210
|
// manager likewise hands an edit delegation `approvedPlan: null` and never
|
|
172
|
-
// re-gates the old plan.
|
|
173
|
-
//
|
|
174
|
-
//
|
|
211
|
+
// re-gates the old plan. The preview review is one-time too, so neither gate
|
|
212
|
+
// re-arms on a later change; the plan rule for slides added to a reviewed deck
|
|
213
|
+
// stays the skill's, as it is in-app.
|
|
214
|
+
|
|
215
|
+
// Both gates ask it; it lives in edit-route.mjs so review.mjs can import it too.
|
|
216
|
+
export { isEditRouteDeck };
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The plan gate's own question: was THIS plan signed off at the plan gate?
|
|
220
|
+
* Stricter than requirePlanApproved, which also lets later stages through once
|
|
221
|
+
* the preview was approved — a gate asked again about a changed plan must ask.
|
|
222
|
+
*/
|
|
223
|
+
export function planApprovedExactly(deckDir) {
|
|
224
|
+
const planPath = path.join(deckDir, "plan.md");
|
|
225
|
+
if (!fs.existsSync(planPath)) return false;
|
|
226
|
+
const state = loadPlanState(deckDir);
|
|
227
|
+
if (state.decision !== "approve") return false;
|
|
228
|
+
return state.planHash === hashPlan(planPath) || state.storyHash === planStoryHash(deckDir);
|
|
229
|
+
}
|
|
230
|
+
|
|
175
231
|
export function requirePlanApproved(deckDir) {
|
|
176
232
|
const planPath = path.join(deckDir, "plan.md");
|
|
177
233
|
if (!fs.existsSync(planPath)) return null;
|
|
178
|
-
if (
|
|
179
|
-
|
|
234
|
+
if (isEditRouteDeck(deckDir)) return null;
|
|
235
|
+
if (loadReviewState(deckDir).approval) return null;
|
|
236
|
+
const base = deckSlug(deckDir);
|
|
180
237
|
const state = loadPlanState(deckDir);
|
|
181
238
|
if (state.decision !== "approve") {
|
|
182
239
|
return (
|
|
@@ -184,7 +241,9 @@ export function requirePlanApproved(deckDir) {
|
|
|
184
241
|
`run \`slidemind plan ${base}\` and wait for DECISION: approved.`
|
|
185
242
|
);
|
|
186
243
|
}
|
|
187
|
-
|
|
244
|
+
// A content expansion after sign-off is allowed (planStoryHash); anything that
|
|
245
|
+
// moves the story is not.
|
|
246
|
+
if (state.planHash !== hashPlan(planPath) && state.storyHash !== planStoryHash(deckDir)) {
|
|
188
247
|
return (
|
|
189
248
|
`${base}/plan.md changed since the user approved it — ` +
|
|
190
249
|
`re-run \`slidemind plan ${base}\` for a fresh sign-off.`
|
|
@@ -197,16 +256,25 @@ export function requirePlanApproved(deckDir) {
|
|
|
197
256
|
// harness output
|
|
198
257
|
// ---------------------------------------------------------------------------
|
|
199
258
|
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
259
|
+
// The design the user switched the deck to at the gate, said once in either
|
|
260
|
+
// decision: the agent planned against the old design.md and must not author
|
|
261
|
+
// from it. Concept mockups made before the switch show the old design, and
|
|
262
|
+
// `concepts` keeps a mockup it already has — so they are named for `--force`.
|
|
263
|
+
function designSwitchLine(deckDir, design) {
|
|
264
|
+
if (!design?.to) return null;
|
|
265
|
+
const dir = resolveBundleDirForDeck(deckDir);
|
|
266
|
+
const read = dir ? `Re-read ${path.join(dir, "design.md")}` : `Run \`slidemind ready ${deckSlug(deckDir)}\``;
|
|
267
|
+
let line =
|
|
268
|
+
`The user switched this deck's corporate design to ${design.name ?? design.to} (${design.to}) at the gate. ` +
|
|
269
|
+
`${read} before you go on — every later step builds against it.`;
|
|
270
|
+
try {
|
|
271
|
+
if (fs.readdirSync(path.join(deckDir, "concepts")).some((f) => /^concept-slide-\d+\.png$/.test(f))) {
|
|
272
|
+
line += ` The existing concept mockups show the previous design: regenerate them with \`slidemind concepts ${deckSlug(deckDir)} --force\`.`;
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
/* no concepts yet */
|
|
276
|
+
}
|
|
277
|
+
return line;
|
|
210
278
|
}
|
|
211
279
|
|
|
212
280
|
// Render the comments round for the harness — the plan analog of review.mjs's
|
|
@@ -216,11 +284,13 @@ function commentBullet(c) {
|
|
|
216
284
|
// next `slidemind plan` run — nothing reaches them until it runs. An agent that
|
|
217
285
|
// ends its turn here strands them, so the re-run instruction says "same turn"
|
|
218
286
|
// and says why more comment rounds may follow.
|
|
219
|
-
export function formatPlanCommentsForHarness(deckDir, comments, { edited = false } = {}) {
|
|
287
|
+
export function formatPlanCommentsForHarness(deckDir, comments, { edited = false, design = null } = {}) {
|
|
220
288
|
const rows = normalizePlanComments(comments);
|
|
221
289
|
const out = [];
|
|
222
290
|
out.push("DECISION: comments");
|
|
223
291
|
out.push("The user requested changes to the plan. Nothing was authored.");
|
|
292
|
+
const switched = designSwitchLine(deckDir, design);
|
|
293
|
+
if (switched) out.push(switched);
|
|
224
294
|
if (edited) {
|
|
225
295
|
out.push(
|
|
226
296
|
"The user also edited plan.md directly — re-read it before revising; the " +
|
|
@@ -229,14 +299,14 @@ export function formatPlanCommentsForHarness(deckDir, comments, { edited = false
|
|
|
229
299
|
}
|
|
230
300
|
out.push("");
|
|
231
301
|
if (rows.length) {
|
|
232
|
-
for (const c of rows) out.push(
|
|
302
|
+
for (const c of rows) out.push(planCommentBullet(c));
|
|
233
303
|
} else if (edited) {
|
|
234
304
|
out.push("(No written comments — their direct edits to plan.md are the change.)");
|
|
235
305
|
}
|
|
236
306
|
out.push("");
|
|
237
307
|
out.push("Revise plan.md to address ALL comments — change only what the feedback");
|
|
238
308
|
out.push("requires; keep unaffected slides verbatim.");
|
|
239
|
-
out.push(`Then run \`slidemind plan ${
|
|
309
|
+
out.push(`Then run \`slidemind plan ${deckSlug(deckDir)}\` again in the same turn.`);
|
|
240
310
|
out.push("The reviewer then reviews the revised plan — several comment rounds are");
|
|
241
311
|
out.push("normal, not an error. Do NOT end your turn and do NOT send the user back");
|
|
242
312
|
out.push("to the browser before you re-run the gate.");
|
|
@@ -245,17 +315,49 @@ export function formatPlanCommentsForHarness(deckDir, comments, { edited = false
|
|
|
245
315
|
// the round is persisted before this prints, so a lost capture is recoverable.
|
|
246
316
|
out.push(
|
|
247
317
|
`This round (decision + comments) is also saved verbatim in ` +
|
|
248
|
-
`${
|
|
318
|
+
`${statePath(deckDir, PLAN_STATE_FILE)} — if this output looks truncated, read ` +
|
|
249
319
|
`that file instead of guessing.`,
|
|
250
320
|
);
|
|
251
321
|
return out.join("\n") + "\n";
|
|
252
322
|
}
|
|
253
323
|
|
|
324
|
+
// The approval as the harness reads it — one wording for the standalone gate
|
|
325
|
+
// and the workspace gate (an edit made at the gate is the approved plan).
|
|
326
|
+
export function planApprovedText(deckDir, { edited = false, note = null, design = null } = {}) {
|
|
327
|
+
const switched = designSwitchLine(deckDir, design);
|
|
328
|
+
return (
|
|
329
|
+
"DECISION: approved\n" +
|
|
330
|
+
(note ? `${note}\n` : "") +
|
|
331
|
+
(switched ? `${switched}\n` : "") +
|
|
332
|
+
(edited
|
|
333
|
+
? "The user approved the plan — and edited plan.md directly before approving. " +
|
|
334
|
+
`Re-read ${deckDir}/plan.md: the edited text IS the approved plan. Author from ` +
|
|
335
|
+
`it, not from your earlier draft. Next: ${NEXT_AFTER_APPROVAL}\n`
|
|
336
|
+
: `The user approved the plan. Next: ${NEXT_AFTER_APPROVAL}\n`)
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* A workspace task's answer, printed exactly as the standalone gate prints its
|
|
342
|
+
* own: the comments come from the gate record the answer wrote (plan-review.json,
|
|
343
|
+
* the same operation), else from the task itself. "Edited" is the record's flag
|
|
344
|
+
* or the task having followed an edit the human made in the page.
|
|
345
|
+
*/
|
|
346
|
+
export function formatPlanTaskDecision(deckDir, task, { note = null, editedInPage = false } = {}) {
|
|
347
|
+
const state = loadPlanState(deckDir);
|
|
348
|
+
const mine = Boolean(state.operationId) && task?.response?.operationId === state.operationId;
|
|
349
|
+
const edited = Boolean(editedInPage || (mine && state.edited) || (task?.subject?.requestedHash && task.subject.requestedHash !== task.subject.hash));
|
|
350
|
+
const design = mine ? state.design : null;
|
|
351
|
+
if (task?.response?.action === "approve") return planApprovedText(deckDir, { edited, note, design });
|
|
352
|
+
const comments = mine ? state.comments : task?.response?.comment ? [{ text: task.response.comment, scope: "story" }] : [];
|
|
353
|
+
return (note ? `NOTE: ${note}\n` : "") + formatPlanCommentsForHarness(deckDir, comments, { edited, design });
|
|
354
|
+
}
|
|
355
|
+
|
|
254
356
|
// Render the no-answer outcome for the harness. A timed-out gate is an EXPECTED
|
|
255
357
|
// state (the user stepped away), not a failure — the agent must neither treat
|
|
256
358
|
// it as approval nor abort the task; it wraps up with clear resume instructions.
|
|
257
359
|
export function formatPlanPendingForHarness(deckDir, { timeoutMs, pageLoaded, url } = {}) {
|
|
258
|
-
const base =
|
|
360
|
+
const base = deckSlug(deckDir);
|
|
259
361
|
const waited = timeoutMs
|
|
260
362
|
? `the gate waited ${Math.max(1, Math.round(timeoutMs / 60000))} minutes and the user hasn't answered.`
|
|
261
363
|
: "the gate closed without an answer from the user.";
|
|
@@ -288,7 +390,7 @@ const PLAN_NAMESPACE_EXPORTS = [
|
|
|
288
390
|
"splitPlanMarkdownIntoSegments",
|
|
289
391
|
"replaceStoryboardSegment",
|
|
290
392
|
"setStoryboardLayoutForAll",
|
|
291
|
-
"
|
|
393
|
+
"remapStoryboardLayouts",
|
|
292
394
|
"extractRedThreadMessages",
|
|
293
395
|
"findRedThreadSection",
|
|
294
396
|
"parseStoryboardCanvas",
|
|
@@ -297,14 +399,6 @@ const PLAN_NAMESPACE_EXPORTS = [
|
|
|
297
399
|
"parsePlanBackbone",
|
|
298
400
|
"applyPlanBackbone",
|
|
299
401
|
"stageStoryIteration",
|
|
300
|
-
"STORYBOARD_WIDTH",
|
|
301
|
-
"STORYBOARD_HEIGHT",
|
|
302
|
-
"BODY_SAFE_AREA",
|
|
303
|
-
"MIN_BLOCK_WIDTH",
|
|
304
|
-
"MIN_BLOCK_HEIGHT",
|
|
305
|
-
"MAX_BLOCKS",
|
|
306
|
-
"MAX_DESIGN_ELEMENT_LENGTH",
|
|
307
|
-
"MAX_CONTENT_LENGTH",
|
|
308
402
|
"MAX_IMAGE_PROMPT_LENGTH",
|
|
309
403
|
];
|
|
310
404
|
|
|
@@ -313,14 +407,15 @@ const PLAN_NAMESPACE_EXPORTS = [
|
|
|
313
407
|
// caller and the module tests reach for it on this path.
|
|
314
408
|
export { stripModuleSyntax };
|
|
315
409
|
|
|
316
|
-
// Bundle the
|
|
317
|
-
// dependency order (schema → storyboard-markdown → backbone →
|
|
410
|
+
// Bundle the plan-review modules into one IIFE for the browser page, in
|
|
411
|
+
// dependency order (image-intent → schema → storyboard-markdown → backbone →
|
|
412
|
+
// edit → plan-md).
|
|
318
413
|
// The IIFE exposes window.__SLIDEMIND_RENDER_MD__ (back-compat with the old
|
|
319
414
|
// preview) and window.__SLIDEMIND_PLAN__ (the full namespace the overlay reads).
|
|
320
415
|
// Exported so a test can evaluate the output against a stub window and catch a
|
|
321
416
|
// strip-regex miss or a top-level name collision for real.
|
|
322
|
-
export function bundlePlanModules({ schema, storyboardMd, backbone, edit, planMd } = {}) {
|
|
323
|
-
const body = [schema, storyboardMd, backbone, edit, planMd]
|
|
417
|
+
export function bundlePlanModules({ intent, schema, storyboardMd, backbone, edit, planMd } = {}) {
|
|
418
|
+
const body = [intent, schema, storyboardMd, backbone, edit, planMd]
|
|
324
419
|
.map(stripModuleSyntax)
|
|
325
420
|
.join("\n");
|
|
326
421
|
return (
|
|
@@ -333,10 +428,11 @@ export function bundlePlanModules({ schema, storyboardMd, backbone, edit, planMd
|
|
|
333
428
|
);
|
|
334
429
|
}
|
|
335
430
|
|
|
336
|
-
// Read the
|
|
431
|
+
// Read the module sources off disk and bundle them. Split from
|
|
337
432
|
// bundlePlanModules so the pure string→string step stays unit-testable.
|
|
338
433
|
export function bundlePlanModulesFromDisk() {
|
|
339
434
|
return bundlePlanModules({
|
|
435
|
+
intent: fs.readFileSync(IMAGE_INTENT_PATH, "utf8"),
|
|
340
436
|
schema: fs.readFileSync(STORYBOARD_SCHEMA_PATH, "utf8"),
|
|
341
437
|
storyboardMd: fs.readFileSync(STORYBOARD_MARKDOWN_PATH, "utf8"),
|
|
342
438
|
backbone: fs.readFileSync(RED_THREAD_BACKBONE_PATH, "utf8"),
|
|
@@ -471,96 +567,55 @@ export function parsePlaceholderOutlines(source, layoutsCss) {
|
|
|
471
567
|
// `/brand/chrome/<file>`. No bundle → `{ layouts: [], chromeDir: null }` and
|
|
472
568
|
// the picker degrades to read-only (the host-free contract holds).
|
|
473
569
|
// ---------------------------------------------------------------------------
|
|
474
|
-
// The plan
|
|
475
|
-
//
|
|
476
|
-
// is a contract the agent writes and the user edits by hand.
|
|
570
|
+
// The plan checks (plan-checks.mjs, shared with the add-in), read against this
|
|
571
|
+
// deck's bundle: what job each layout does.
|
|
477
572
|
// ---------------------------------------------------------------------------
|
|
478
573
|
|
|
479
|
-
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
const get = (key) => {
|
|
483
|
-
const r = new RegExp(`^\\s*-?\\s*${key}\\s*:\\s*(.+)$`, "im").exec(m[1]);
|
|
484
|
-
return r ? r[1].trim() : null;
|
|
485
|
-
};
|
|
486
|
-
const shape = (get("shape") ?? "").toLowerCase().replace(/[^a-z]/g, "");
|
|
487
|
-
const body = Number.parseInt(get("body slides") ?? get("body") ?? "", 10);
|
|
488
|
-
const frameRaw = (get("frame") ?? "").toLowerCase();
|
|
489
|
-
const frame = /^(none|-|—|no)\b/.test(frameRaw)
|
|
490
|
-
? []
|
|
491
|
-
: frameRaw.split(/[,;/]+/).map((x) => x.trim().replace(/s$/, "")).filter(Boolean);
|
|
492
|
-
return {
|
|
493
|
-
shape: shape === "deck" || shape === "slides" ? shape : null,
|
|
494
|
-
bodySlides: Number.isFinite(body) ? body : null,
|
|
495
|
-
frame,
|
|
496
|
-
target: get("target"),
|
|
497
|
-
};
|
|
574
|
+
function deckJobs(deckDir) {
|
|
575
|
+
const jobs = layoutJobs(resolveBundleDirForDeck(deckDir));
|
|
576
|
+
return { jobs, isFrameSlide: (data) => isFrameJob(plannedJob(data, jobs)) };
|
|
498
577
|
}
|
|
499
578
|
|
|
500
579
|
/**
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
* gate still opens — the user decides in it — but the agent reads these
|
|
504
|
-
* before the user does.
|
|
580
|
+
* The body slides still owing their full content draft — the step between
|
|
581
|
+
* sign-off and `concepts`, read by the deck stage and the concepts refusal.
|
|
505
582
|
*/
|
|
506
|
-
export function
|
|
507
|
-
|
|
508
|
-
const dir = resolveBundleDirForDeck(deckDir);
|
|
509
|
-
const jobs = layoutJobs(dir);
|
|
510
|
-
const beats = [];
|
|
511
|
-
for (const segment of splitPlanMarkdownIntoSegments(markdown ?? "")) {
|
|
512
|
-
if (segment.type !== "storyboard" || !segment.data) continue;
|
|
513
|
-
const layout = segment.data.layout ?? null;
|
|
514
|
-
if (!segment.data.role) out.push(`[plan:warn] slide ${segment.data.slide} has no role; legacy layout inference is being used. Write role: body | frame before the next approval.`);
|
|
515
|
-
if (segment.data.role === "frame" && !segment.data.frameKind) out.push(`[plan:warn] frame slide ${segment.data.slide} needs a frameKind.`);
|
|
516
|
-
beats.push({ slide: segment.data.slide, layout, job: plannedJob(segment.data, jobs) });
|
|
517
|
-
}
|
|
518
|
-
if (beats.length === 0) return out;
|
|
519
|
-
const frameBeats = beats.filter((b) => isFrameJob(b.job));
|
|
520
|
-
const bodyBeats = beats.filter((b) => !isFrameJob(b.job));
|
|
521
|
-
const d = parseDeliverable(markdown);
|
|
522
|
-
if (!d) {
|
|
523
|
-
out.push(
|
|
524
|
-
`[plan:warn] plan.md has no \`## Deliverable\` block (shape: deck | slides, body slides: N, frame: <jobs>, target). ` +
|
|
525
|
-
`Without it the frame (cover/agenda/dividers/closing) is unspecified — write the block before the gate.`,
|
|
526
|
-
);
|
|
527
|
-
return out;
|
|
528
|
-
}
|
|
529
|
-
if (d.shape === "deck" && frameBeats.length === 0) {
|
|
530
|
-
const offered = [...jobs.values()].filter((j) => isFrameJob(j.job)).map((j) => j.job);
|
|
531
|
-
out.push(
|
|
532
|
-
`[plan:warn] shape is deck but no storyboard sits on a frame layout — this design offers ${offered.length ? [...new Set(offered)].join(", ") : "no frame layouts"}; a deck ships its cover${offered.includes("closing") ? " and closing" : ""}.`,
|
|
533
|
-
);
|
|
534
|
-
}
|
|
535
|
-
if (d.shape === "slides" && frameBeats.length > 0 && d.frame.length === 0) {
|
|
536
|
-
out.push(
|
|
537
|
-
`[plan:warn] shape is slides but slide${frameBeats.length > 1 ? "s" : ""} ${frameBeats.map((b) => b.slide).join(", ")} sit on frame layouts (${[...new Set(frameBeats.map((b) => b.job))].join(", ")}) — slides extend a deck that has its own frame; drop them or set shape: deck.`,
|
|
538
|
-
);
|
|
539
|
-
}
|
|
540
|
-
if (d.bodySlides != null && d.bodySlides !== bodyBeats.length) {
|
|
541
|
-
out.push(
|
|
542
|
-
`[plan:warn] Deliverable says body slides: ${d.bodySlides}, the storyboards carry ${bodyBeats.length} body slide${bodyBeats.length === 1 ? "" : "s"} (frame slides excluded).`,
|
|
543
|
-
);
|
|
544
|
-
}
|
|
545
|
-
for (const want of d.frame) {
|
|
546
|
-
if (!isFrameJob(want)) continue;
|
|
547
|
-
if (!frameBeats.some((b) => b.job === want)) {
|
|
548
|
-
out.push(`[plan:warn] frame lists ${want} but no storyboard declares that frameKind.`);
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
const cover = frameBeats.find((b) => b.job === "cover");
|
|
552
|
-
if (cover && beats.some((b) => b.slide < cover.slide)) {
|
|
553
|
-
out.push(`[plan:warn] the cover is slide ${cover.slide} — a cover is slide 1.`);
|
|
554
|
-
}
|
|
555
|
-
const closing = frameBeats.find((b) => b.job === "closing");
|
|
556
|
-
if (closing && beats.some((b) => b.slide > closing.slide)) {
|
|
557
|
-
out.push(`[plan:warn] the closing is slide ${closing.slide} but slides follow it — a closing is the last slide.`);
|
|
558
|
-
}
|
|
559
|
-
return out;
|
|
583
|
+
export function slidesWithoutContent(deckDir, markdown) {
|
|
584
|
+
return bodySlidesWithoutContent(markdown, { isFrameSlide: deckJobs(deckDir).isFrameSlide });
|
|
560
585
|
}
|
|
561
586
|
|
|
562
|
-
|
|
563
|
-
|
|
587
|
+
/**
|
|
588
|
+
* `[plan:error]` lines for a plan whose deliverable block, red thread and
|
|
589
|
+
* storyboards do not agree with each other or with the bundle's layout jobs.
|
|
590
|
+
*/
|
|
591
|
+
export function planCheckErrors(deckDir, markdown) {
|
|
592
|
+
const { jobs } = deckJobs(deckDir);
|
|
593
|
+
return checkPlan(markdown, {
|
|
594
|
+
slideJob: (data) => plannedJob(data, jobs),
|
|
595
|
+
isFrameJob,
|
|
596
|
+
offeredJobs: [...jobs.values()].map((j) => j.job),
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* The refusal for opening a NEW plan round on plan.md as it stands, or null —
|
|
602
|
+
* the add-in's `present_plan_for_review` refuses the same plans. Never asked of
|
|
603
|
+
* an answer the user already gave: their edits are their decision.
|
|
604
|
+
*/
|
|
605
|
+
export function planRefusal(deckDir) {
|
|
606
|
+
const errors = planCheckErrors(deckDir, fs.readFileSync(path.join(deckDir, "plan.md"), "utf8"));
|
|
607
|
+
if (errors.length === 0) return null;
|
|
608
|
+
return (
|
|
609
|
+
`${PLAN_NOT_PRESENTED} Fix each line below in plan.md (workflow/plan.md), then run ` +
|
|
610
|
+
`\`slidemind plan ${deckSlug(deckDir)}\` again:\n` +
|
|
611
|
+
errors.map((line) => ` ${line}\n`).join("")
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// `bundleDir` reads another cached design's palette instead of the deck's own
|
|
616
|
+
// (the design picker); the deck's layout context still applies by layoutKey.
|
|
617
|
+
export function resolvePlanLayouts(deckDir, { bundleDir = null } = {}) {
|
|
618
|
+
const dir = bundleDir ?? resolveBundleDirForDeck(deckDir);
|
|
564
619
|
if (!dir) return { layouts: [], chromeDir: null };
|
|
565
620
|
const layoutsDir = path.join(dir, "layouts");
|
|
566
621
|
const assetsDir = path.join(dir, "assets");
|
|
@@ -696,36 +751,144 @@ export function resolvePlanLayouts(deckDir) {
|
|
|
696
751
|
}
|
|
697
752
|
// Target context is opt-in, written by `read --deck`; it never changes the brand bundle.
|
|
698
753
|
try {
|
|
699
|
-
const context = JSON.parse(fs.readFileSync(
|
|
754
|
+
const context = JSON.parse(fs.readFileSync(statePath(deckDir, "layout-context.json"), "utf8"));
|
|
700
755
|
for (const layout of layouts) {
|
|
701
756
|
const selected = context.selections?.[layout.componentName];
|
|
702
757
|
if (!layout.selection) continue;
|
|
703
758
|
// A modern scoped identity mismatch must never fall back to a name join.
|
|
704
759
|
if (selected?.layoutKey) {
|
|
705
760
|
if (selected.layoutKey !== layout.layoutKey) continue;
|
|
706
|
-
layout.selection = { ...layout.selection, currentCount: selected.currentCount
|
|
707
|
-
sectionCount: selected.sectionCount };
|
|
761
|
+
layout.selection = { ...layout.selection, currentCount: selected.currentCount };
|
|
708
762
|
} else if (context.selections === undefined &&
|
|
709
763
|
Object.hasOwn(context.layoutCounts ?? {}, layout.nativeLayoutName) &&
|
|
710
764
|
layouts.filter((other) => other.nativeLayoutName === layout.nativeLayoutName).length === 1) {
|
|
711
765
|
layout.selection = { ...layout.selection, currentCount: context.layoutCounts[layout.nativeLayoutName] };
|
|
712
766
|
}
|
|
713
767
|
const p = layout.selection;
|
|
714
|
-
p.source = p.
|
|
768
|
+
p.source = p.currentCount > 0 ? "presentation" : p.usage?.count > 0 ? "brand" : "suggested";
|
|
715
769
|
}
|
|
716
770
|
} catch {}
|
|
717
|
-
// Same precedence as the add-in profile: capability,
|
|
718
|
-
//
|
|
771
|
+
// Same precedence as the add-in profile (layout-selection.ts): capability,
|
|
772
|
+
// target, brand. The brand rank is the add-in's own order, so its remaining
|
|
773
|
+
// tie-breaks (writable canvas area, then name) carry over unchanged.
|
|
719
774
|
layouts.sort((a, b) => {
|
|
720
775
|
if (!a.selection || !b.selection) return (a.selection?.rank ?? Infinity) - (b.selection?.rank ?? Infinity);
|
|
721
776
|
return Number(b.selection.composition) - Number(a.selection.composition) ||
|
|
722
|
-
(b.selection.sectionCount ?? 0) - (a.selection.sectionCount ?? 0) ||
|
|
723
777
|
(b.selection.currentCount ?? 0) - (a.selection.currentCount ?? 0) ||
|
|
724
|
-
b.selection.usage.share - a.selection.usage.share ||
|
|
778
|
+
b.selection.usage.share - a.selection.usage.share ||
|
|
779
|
+
(a.selection.rank ?? Infinity) - (b.selection.rank ?? Infinity) ||
|
|
780
|
+
a.componentName.localeCompare(b.componentName);
|
|
725
781
|
});
|
|
726
782
|
return { layouts, chromeDir: chromeFiles.length ? assetsDir : null };
|
|
727
783
|
}
|
|
728
784
|
|
|
785
|
+
// ---------------------------------------------------------------------------
|
|
786
|
+
// The design picker: every design cached on this machine, offered at the gate.
|
|
787
|
+
// Choosing one is part of the answer (`design` on the decision), not a write of
|
|
788
|
+
// its own — the deck is rebound when the user sends, together with the plan
|
|
789
|
+
// whose layouts the page moved onto the new design.
|
|
790
|
+
// ---------------------------------------------------------------------------
|
|
791
|
+
|
|
792
|
+
const ACCENT_KEYS = ["accent1", "accent2", "accent3", "accent4", "accent5", "accent6"];
|
|
793
|
+
|
|
794
|
+
function cachedDesign(presFP) {
|
|
795
|
+
return (presFP && listCachedBundles().find((b) => b.presFP === presFP)) || null;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* The picker's menu: name + accent colours per cached design, sorted by name,
|
|
800
|
+
* `current` on the one the deck is bound to. Every other design carries its
|
|
801
|
+
* layout palette so the page can swap the layout picker without a round trip;
|
|
802
|
+
* its chrome thumbnails are addressed with `?design=<presFP>` (see
|
|
803
|
+
* `planChromeDir`). `mapUrl` prefixes those URLs for the workspace's routes.
|
|
804
|
+
*/
|
|
805
|
+
export function planDesignOptions(deckDir, { mapUrl = (u) => u } = {}) {
|
|
806
|
+
const bound = readDeckBundleRef(deckDir)?.presFP ?? null;
|
|
807
|
+
return listCachedBundles()
|
|
808
|
+
.map((b) => {
|
|
809
|
+
const colors = readThemeColors(b.dir) ?? {};
|
|
810
|
+
const current = b.presFP === bound;
|
|
811
|
+
const design = {
|
|
812
|
+
presFP: b.presFP,
|
|
813
|
+
name: readDesignName(b.dir) ?? b.presFP,
|
|
814
|
+
accents: ACCENT_KEYS.map((k) => colors[k]).filter(Boolean),
|
|
815
|
+
current,
|
|
816
|
+
};
|
|
817
|
+
if (!current) {
|
|
818
|
+
design.layouts = resolvePlanLayouts(deckDir, { bundleDir: b.dir }).layouts.map((l) => ({
|
|
819
|
+
...l,
|
|
820
|
+
previewUrl: l.previewUrl ? mapUrl(`${l.previewUrl}?design=${encodeURIComponent(b.presFP)}`) : null,
|
|
821
|
+
}));
|
|
822
|
+
}
|
|
823
|
+
return design;
|
|
824
|
+
})
|
|
825
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* The picker's whole menu: the cached designs above (`cached: true`) and, after
|
|
830
|
+
* them, every other design a connected backend serves (`cached: false` — a name,
|
|
831
|
+
* no palette yet), downloaded when picked (`downloadPlanDesign`). A backend that does not
|
|
832
|
+
* answer leaves the menu at what this machine already has. The page asks for it
|
|
833
|
+
* after it loads, so a slow backend never holds up the plan.
|
|
834
|
+
*/
|
|
835
|
+
export async function listPlanDesigns(deckDir, { mapUrl = (u) => u, listBrands = null } = {}) {
|
|
836
|
+
const cached = planDesignOptions(deckDir, { mapUrl }).map((d) => ({ ...d, cached: true }));
|
|
837
|
+
let listed = [];
|
|
838
|
+
try {
|
|
839
|
+
listed = await (listBrands ?? (await import("../bridge/client.mjs")).listBrands)();
|
|
840
|
+
} catch {
|
|
841
|
+
listed = [];
|
|
842
|
+
}
|
|
843
|
+
const seen = new Set(cached.map((d) => d.presFP));
|
|
844
|
+
const more = [];
|
|
845
|
+
for (const b of listed) {
|
|
846
|
+
if (!b?.presFP || seen.has(b.presFP)) continue;
|
|
847
|
+
seen.add(b.presFP);
|
|
848
|
+
more.push({ presFP: b.presFP, name: b.name || b.presFP, accents: [], current: false, cached: false });
|
|
849
|
+
}
|
|
850
|
+
// what this machine has first (each sorted by name), what would still have to
|
|
851
|
+
// be downloaded last — the page draws those rows smaller, under a divider
|
|
852
|
+
const byName = (a, b) => a.name.localeCompare(b.name);
|
|
853
|
+
return [...cached.sort(byName), ...more.sort(byName)];
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* A design picked at the gate that this machine has not downloaded yet:
|
|
858
|
+
* materialize it over the pane-free brand route, then return its picker entry
|
|
859
|
+
* (palette, layouts) like any cached one. Throws `unknown_design` when no
|
|
860
|
+
* connected backend serves it.
|
|
861
|
+
*/
|
|
862
|
+
export async function downloadPlanDesign(deckDir, presFP, { mapUrl = (u) => u, materialize = null } = {}) {
|
|
863
|
+
if (!cachedDesign(presFP)) {
|
|
864
|
+
const run = materialize ?? (async (brandId) => (await import("../bridge/bundle.mjs")).materializeBundle(null, { brandId }));
|
|
865
|
+
await run(presFP);
|
|
866
|
+
}
|
|
867
|
+
const design = planDesignOptions(deckDir, { mapUrl }).find((d) => d.presFP === presFP);
|
|
868
|
+
if (!design) throw Object.assign(new Error(`no connected backend serves design '${presFP}'`), { code: "unknown_design" });
|
|
869
|
+
return { ...design, cached: true };
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** Where `/brand/chrome/<file>` reads from: the named cached design, else the deck's own. */
|
|
873
|
+
export function planChromeDir(deckDir, design = null) {
|
|
874
|
+
const dir = design ? cachedDesign(design)?.dir : resolveBundleDirForDeck(deckDir);
|
|
875
|
+
return dir ? path.join(dir, "assets") : null;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* Rebind the deck to the cached design the user chose at the gate. Returns
|
|
880
|
+
* `{ from, to, name }` when the binding moved, null when it already was that
|
|
881
|
+
* design, or `{ error }` for a design this machine does not hold.
|
|
882
|
+
*/
|
|
883
|
+
export function switchPlanDesign(deckDir, presFP) {
|
|
884
|
+
const hit = cachedDesign(presFP);
|
|
885
|
+
if (!hit) return { error: `design '${presFP}' is not cached on this machine` };
|
|
886
|
+
const from = readDeckBundleRef(deckDir)?.presFP ?? null;
|
|
887
|
+
if (from === presFP) return null;
|
|
888
|
+
writeDeckBundleRef(deckDir, { presFP, etag: hit.meta?.etag ?? null });
|
|
889
|
+
return { from, to: presFP, name: readDesignName(hit.dir) ?? presFP };
|
|
890
|
+
}
|
|
891
|
+
|
|
729
892
|
// Build the standalone plan-review page: the rendered markdown in #plan-root
|
|
730
893
|
// (the no-JS fallback and the preview's initial state), the injected overlay
|
|
731
894
|
// config, the bundled plan-review modules, then the overlay script itself.
|
|
@@ -744,16 +907,34 @@ export function buildPlanPage({
|
|
|
744
907
|
deck,
|
|
745
908
|
ledgerSeed,
|
|
746
909
|
layouts,
|
|
910
|
+
designs,
|
|
911
|
+
imageAssets,
|
|
912
|
+
// The plan.md record this page was rendered from, verbatim. It rides in the
|
|
913
|
+
// page so the file at the deck root IS the plan — recoverable, diffable, and
|
|
914
|
+
// enough for `extract` to be unnecessary.
|
|
915
|
+
planRecord = null,
|
|
916
|
+
// The command that serves this page for editing. Set on the copy written to
|
|
917
|
+
// disk (a browser cannot write back to a file), null on the served copy —
|
|
918
|
+
// the overlay stays dormant while it is set, so the file reads as a document.
|
|
919
|
+
readOnly = null,
|
|
747
920
|
}) {
|
|
748
921
|
const cfg = safeInlineJson({
|
|
749
922
|
endpoint: "/plan-review",
|
|
750
923
|
heartbeat: "/__plan/heartbeat",
|
|
924
|
+
readOnly,
|
|
925
|
+
planRecord,
|
|
751
926
|
round: round ?? null,
|
|
752
927
|
pollMs: POLL_MS,
|
|
753
928
|
pollTtlMs: POLL_TTL_MS,
|
|
754
929
|
logoMark: logoMark ?? null,
|
|
755
930
|
// Brand layouts for the per-slide layout picker (empty → picker read-only).
|
|
756
931
|
layouts: Array.isArray(layouts) ? layouts : [],
|
|
932
|
+
// The cached designs for the design picker (planDesignOptions; empty → none).
|
|
933
|
+
designs: Array.isArray(designs) ? designs : [],
|
|
934
|
+
// The deck's image registry, for the per-slide image-use summary: name +
|
|
935
|
+
// preview for each `assetUses[].assetId`. Names and previews only — the
|
|
936
|
+
// obligation itself lives in the plan record the page already carries.
|
|
937
|
+
imageAssets: Array.isArray(imageAssets) ? imageAssets : [],
|
|
757
938
|
planBody: planBody ?? "",
|
|
758
939
|
bodyStartLine: bodyStartLine ?? 1,
|
|
759
940
|
// deck: the sessionStorage namespace for this deck's comment ledger.
|
|
@@ -770,11 +951,19 @@ export function buildPlanPage({
|
|
|
770
951
|
const rendererBlock = modulesSrc ? "<script>\n" + modulesSrc + "\n</script>\n" : "";
|
|
771
952
|
// The shared gate session goes in before the overlay, which reads it at boot.
|
|
772
953
|
const gateBlock = "<script>\n" + bundleGateSession().replace(/<\/script>/g, "<\\/script>") + "\n</script>\n";
|
|
954
|
+
const banner = readOnly
|
|
955
|
+
? `<div id="plan-readonly-note" style="max-width:720px;margin:0 auto;` +
|
|
956
|
+
`padding:14px 28px 0;font:500 13.5px/1.5 ui-sans-serif,system-ui,sans-serif;` +
|
|
957
|
+
`color:#a8a8ad">This is the saved plan, read-only. To edit it, run ` +
|
|
958
|
+
`<code style="background:#1c1c20;padding:.12em .42em;border-radius:5px">` +
|
|
959
|
+
`${escapeHtml(readOnly)}</code>.</div>\n`
|
|
960
|
+
: "";
|
|
773
961
|
return (
|
|
774
962
|
"<!doctype html>\n" +
|
|
775
963
|
'<html lang="en">\n' +
|
|
776
964
|
"<head>\n" +
|
|
777
965
|
'<meta charset="utf-8">\n' +
|
|
966
|
+
`${PLAN_PAGE_MARKER}\n` +
|
|
778
967
|
'<meta name="viewport" content="width=device-width, initial-scale=1">\n' +
|
|
779
968
|
"<title>Plan review — slidemind</title>\n" +
|
|
780
969
|
FAVICON_LINK +
|
|
@@ -782,8 +971,11 @@ export function buildPlanPage({
|
|
|
782
971
|
`<style>${PAGE_CSS}</style>\n` +
|
|
783
972
|
"</head>\n" +
|
|
784
973
|
"<body>\n" +
|
|
974
|
+
banner +
|
|
785
975
|
`<div id="plan-root">${bodyHtml}</div>\n` +
|
|
786
976
|
`<script>window.__SLIDEMIND_PLAN_REVIEW__ = ${cfg};</script>\n` +
|
|
977
|
+
LIVE_PATCH_SLOT +
|
|
978
|
+
"\n" +
|
|
787
979
|
gateBlock +
|
|
788
980
|
rendererBlock +
|
|
789
981
|
`<script>\n${overlay}\n</script>\n` +
|
|
@@ -792,6 +984,125 @@ export function buildPlanPage({
|
|
|
792
984
|
);
|
|
793
985
|
}
|
|
794
986
|
|
|
987
|
+
// The served page IS the file at the deck root: the plan gate reads plan.html
|
|
988
|
+
// back and swaps this slot for the run's write channel (endpoint, round,
|
|
989
|
+
// layouts), which also clears `readOnly` and drops the banner. One rendering
|
|
990
|
+
// path, so the page the user edits and the page they reopen cannot diverge.
|
|
991
|
+
const LIVE_PATCH_SLOT = "<!--__PLAN_LIVE__-->";
|
|
992
|
+
|
|
993
|
+
export function withPlanWriteChannel(html, live) {
|
|
994
|
+
const patch =
|
|
995
|
+
"<script>\n" +
|
|
996
|
+
`Object.assign(window.__SLIDEMIND_PLAN_REVIEW__, ${safeInlineJson({ ...live, readOnly: null })});\n` +
|
|
997
|
+
'document.addEventListener("DOMContentLoaded", function () {\n' +
|
|
998
|
+
' var n = document.getElementById("plan-readonly-note");\n' +
|
|
999
|
+
" if (n) n.remove();\n" +
|
|
1000
|
+
"});\n" +
|
|
1001
|
+
"</script>";
|
|
1002
|
+
// Function replacers: `patch` embeds JSON.stringify'd reviewer comments and
|
|
1003
|
+
// user file names (imageAssets[].displayName), so a `$'`, `$&` or `` $` `` in
|
|
1004
|
+
// any of them would be expanded by String.replace and corrupt the script.
|
|
1005
|
+
return html.includes(LIVE_PATCH_SLOT)
|
|
1006
|
+
? html.replace(LIVE_PATCH_SLOT, () => patch)
|
|
1007
|
+
: html.replace("</body>", () => `${patch}\n</body>`);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function escapeHtml(text) {
|
|
1011
|
+
return String(text).replace(/[&<>"]/g, (c) =>
|
|
1012
|
+
({ "&": "&", "<": "<", ">": ">", '"': """ })[c],
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// A deck root has one plan page and one writer of it (see planPageOwner). When
|
|
1017
|
+
// that is an extension — a pack deck, whose page renders a record this CLI
|
|
1018
|
+
// cannot read — the generic gates leave the page alone and name the command that
|
|
1019
|
+
// opens it. Reported once per workspace, on stderr so --json payloads stay clean.
|
|
1020
|
+
const ownerNoted = new Set();
|
|
1021
|
+
function foreignPlanPageOwner(deckDir) {
|
|
1022
|
+
const owner = planPageOwner(deckDir);
|
|
1023
|
+
if (owner.owner === "core") return null;
|
|
1024
|
+
if (!ownerNoted.has(deckDir)) {
|
|
1025
|
+
ownerNoted.add(deckDir);
|
|
1026
|
+
process.stderr.write(
|
|
1027
|
+
`slidemind: plan.html belongs to ${owner.owner} — left as it is. ` +
|
|
1028
|
+
`${owner.reviewCommand ? `Open it with \`${owner.reviewCommand}\`.` : `Its owner is not installed here.`}\n`,
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
return owner;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// Render `plan.html` at the DECK ROOT from the workspace's plan.md. Called
|
|
1035
|
+
// after every write to the record, so the file the user opens is never stale.
|
|
1036
|
+
// Returns the path written, or null when the deck has no plan record yet or the
|
|
1037
|
+
// page belongs to an extension.
|
|
1038
|
+
export function writePlanPage(deckDir) {
|
|
1039
|
+
const planPath = path.join(deckDir, "plan.md");
|
|
1040
|
+
let raw;
|
|
1041
|
+
try {
|
|
1042
|
+
raw = fs.readFileSync(planPath, "utf8");
|
|
1043
|
+
} catch {
|
|
1044
|
+
return null;
|
|
1045
|
+
}
|
|
1046
|
+
if (foreignPlanPageOwner(deckDir)) return null;
|
|
1047
|
+
const { body, bodyStartLine } = splitPlanFrontmatter(raw);
|
|
1048
|
+
const slug = deckSlug(deckDir);
|
|
1049
|
+
const html = buildPlanPage({
|
|
1050
|
+
bodyHtml: renderPlanMarkdown(body, { lineOffset: bodyStartLine - 1 }),
|
|
1051
|
+
overlayJs: fs.readFileSync(OVERLAY_PATH, "utf8"),
|
|
1052
|
+
planModulesJs: bundlePlanModulesFromDisk(),
|
|
1053
|
+
logoMark: null,
|
|
1054
|
+
planBody: body,
|
|
1055
|
+
bodyStartLine,
|
|
1056
|
+
round: null,
|
|
1057
|
+
deck: slug,
|
|
1058
|
+
ledgerSeed: loadPlanState(deckDir).comments || [],
|
|
1059
|
+
layouts: [],
|
|
1060
|
+
planRecord: raw,
|
|
1061
|
+
readOnly: `slidemind plan ${slug}`,
|
|
1062
|
+
});
|
|
1063
|
+
const out = planPagePath(deckDir);
|
|
1064
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
1065
|
+
fs.writeFileSync(out, html);
|
|
1066
|
+
// Claim the page the first time core renders it, so a later extension sees an
|
|
1067
|
+
// owner rather than having to guess from the page it finds.
|
|
1068
|
+
if (!readPlanOwner(deckDir)) {
|
|
1069
|
+
writePlanOwner(deckDir, {
|
|
1070
|
+
owner: "core",
|
|
1071
|
+
reviewCommand: `slidemind plan ${slug}`,
|
|
1072
|
+
recordPath: "plan.md",
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
return out;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* The deck's image registry as the plan page needs it: enough to NAME and SHOW
|
|
1080
|
+
* each asset a slide's `assetUses` points at, and nothing else.
|
|
1081
|
+
*
|
|
1082
|
+
* `previewUrl` is the manifest's own portable workspace path served off the
|
|
1083
|
+
* deck root — `serveStaticFile({roots:[deckRoot]})` already answers it, so
|
|
1084
|
+
* there is no second image route and no data URI inflating the page. An
|
|
1085
|
+
* unreadable manifest yields an empty list: the plan gate is how a user fixes
|
|
1086
|
+
* a deck, and it must open even when the registry is broken (the fence's own
|
|
1087
|
+
* typed issue still reports it).
|
|
1088
|
+
*/
|
|
1089
|
+
export function planImageAssets(deckDir) {
|
|
1090
|
+
let manifest;
|
|
1091
|
+
try {
|
|
1092
|
+
manifest = readImageAssetManifest(deckDir);
|
|
1093
|
+
} catch {
|
|
1094
|
+
return [];
|
|
1095
|
+
}
|
|
1096
|
+
return manifest.assets.map((a) => ({
|
|
1097
|
+
assetId: a.assetId,
|
|
1098
|
+
displayName: a.displayName,
|
|
1099
|
+
mimeType: a.mimeType,
|
|
1100
|
+
width: a.width,
|
|
1101
|
+
height: a.height,
|
|
1102
|
+
previewUrl: `/${a.workspacePath}`,
|
|
1103
|
+
}));
|
|
1104
|
+
}
|
|
1105
|
+
|
|
795
1106
|
// ---------------------------------------------------------------------------
|
|
796
1107
|
// server
|
|
797
1108
|
// ---------------------------------------------------------------------------
|
|
@@ -810,7 +1121,7 @@ export async function createPlanReviewServer({
|
|
|
810
1121
|
brandAssetPath = null,
|
|
811
1122
|
ledgerSeed = [],
|
|
812
1123
|
layouts = [],
|
|
813
|
-
|
|
1124
|
+
designs = [],
|
|
814
1125
|
port = 0,
|
|
815
1126
|
}) {
|
|
816
1127
|
const gate = createDecisionGate({
|
|
@@ -819,20 +1130,31 @@ export async function createPlanReviewServer({
|
|
|
819
1130
|
// Own service marker so a tab left waiting on this port only reloads into the
|
|
820
1131
|
// NEXT plan round — never into the preview gate or a foreign app on the port.
|
|
821
1132
|
const hb = createHeartbeat({ service: "slidemind-plan" });
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
const
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
1133
|
+
// What the gate serves IS the plan.html at the deck root: re-rendered from
|
|
1134
|
+
// the current record, then handed this run's write channel. So the page the
|
|
1135
|
+
// user edits here and the page they reopen from disk cannot drift apart.
|
|
1136
|
+
const pageFile = writePlanPage(deckDir);
|
|
1137
|
+
const diskHtml = pageFile
|
|
1138
|
+
? fs.readFileSync(pageFile, "utf8")
|
|
1139
|
+
: buildPlanPage({
|
|
1140
|
+
bodyHtml: renderPlanMarkdown(body, { lineOffset: bodyStartLine - 1 }),
|
|
1141
|
+
overlayJs: overlayJs ?? fs.readFileSync(OVERLAY_PATH, "utf8"),
|
|
1142
|
+
planModulesJs: planModulesJs ?? bundlePlanModulesFromDisk(),
|
|
1143
|
+
logoMark: null,
|
|
1144
|
+
planBody: body,
|
|
1145
|
+
bodyStartLine,
|
|
1146
|
+
round: null,
|
|
1147
|
+
deck: deckSlug(deckDir),
|
|
1148
|
+
ledgerSeed: normalizePlanComments(ledgerSeed),
|
|
1149
|
+
layouts: [],
|
|
1150
|
+
});
|
|
1151
|
+
const indexHtml = withPlanWriteChannel(diskHtml, {
|
|
832
1152
|
round: hb.round,
|
|
833
|
-
|
|
834
|
-
ledgerSeed: normalizePlanComments(ledgerSeed),
|
|
1153
|
+
logoMark,
|
|
835
1154
|
layouts,
|
|
1155
|
+
designs,
|
|
1156
|
+
imageAssets: planImageAssets(deckDir),
|
|
1157
|
+
ledgerSeed: normalizePlanComments(ledgerSeed),
|
|
836
1158
|
});
|
|
837
1159
|
const deckRoot = path.resolve(deckDir);
|
|
838
1160
|
|
|
@@ -854,6 +1176,7 @@ export async function createPlanReviewServer({
|
|
|
854
1176
|
decision,
|
|
855
1177
|
comments,
|
|
856
1178
|
editedPlan: typeof payload.editedPlan === "string" ? payload.editedPlan : null,
|
|
1179
|
+
design: typeof payload.design === "string" ? payload.design : null,
|
|
857
1180
|
});
|
|
858
1181
|
},
|
|
859
1182
|
});
|
|
@@ -912,9 +1235,11 @@ export async function createPlanReviewServer({
|
|
|
912
1235
|
}
|
|
913
1236
|
|
|
914
1237
|
// Layout chrome thumbnails for the picker — served from the materialized
|
|
915
|
-
// bundle's assets dir
|
|
1238
|
+
// bundle's assets dir (another cached design's with `?design=`, for the
|
|
1239
|
+
// design picker), basename-guarded to the chrome-<Name>.<ext> shape.
|
|
916
1240
|
if (pathname.startsWith("/brand/chrome/")) {
|
|
917
1241
|
const name = path.basename(pathname);
|
|
1242
|
+
const chromeDir = planChromeDir(deckDir, u.searchParams.get("design"));
|
|
918
1243
|
if (!chromeDir || !CHROME_ASSET_RE.test(name)) {
|
|
919
1244
|
res.writeHead(404);
|
|
920
1245
|
res.end("not found");
|
|
@@ -981,21 +1306,178 @@ export function applyPlanEdit({ planPath, frontmatterRaw, body, editedPlan }) {
|
|
|
981
1306
|
return { edited: true };
|
|
982
1307
|
}
|
|
983
1308
|
|
|
1309
|
+
// Fold the targeted-edit image-use overrides into the plan being approved and
|
|
1310
|
+
// say so. Reported on stderr, never fatal: a sidecar this CLI cannot read must
|
|
1311
|
+
// not refuse a human's sign-off, and the obligation stays on file either way.
|
|
1312
|
+
function noteImageUseReconcile(deckDir) {
|
|
1313
|
+
const result = reconcileImageUseOverrides(deckDir);
|
|
1314
|
+
if (result.error) {
|
|
1315
|
+
process.stderr.write(`slidemind: image use overrides could not be read (${result.error}) — left as they are.\n`);
|
|
1316
|
+
return result;
|
|
1317
|
+
}
|
|
1318
|
+
if (result.applied.length) {
|
|
1319
|
+
process.stderr.write(
|
|
1320
|
+
`slidemind: folded image uses from ${result.applied.length} targeted edit${result.applied.length === 1 ? "" : "s"} ` +
|
|
1321
|
+
`into the approved plan (slide${result.applied.length === 1 ? "" : "s"} ${result.applied.map((a) => a.slide).join(", ")}).\n`,
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1324
|
+
for (const entry of result.kept) {
|
|
1325
|
+
process.stderr.write(`slidemind: image use override for ${entry.slideId} kept — ${entry.why}.\n`);
|
|
1326
|
+
}
|
|
1327
|
+
return result;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
984
1330
|
// Persisted with the decision as this round's plan fingerprint (the round
|
|
985
1331
|
// TRANSITION a reloaded tab detects is the server's heartbeat nonce, not this).
|
|
986
1332
|
function hashPlan(planPath) {
|
|
987
1333
|
return crypto.createHash("sha256").update(fs.readFileSync(planPath)).digest("hex");
|
|
988
1334
|
}
|
|
989
1335
|
|
|
1336
|
+
/**
|
|
1337
|
+
* The plan's STORY fingerprint: the same markdown with every record's
|
|
1338
|
+
* `content` stripped before hashing.
|
|
1339
|
+
*
|
|
1340
|
+
* What a human signs off at the gate is the story, the layouts, the structure
|
|
1341
|
+
* and each slide's 3-5 key points (`contentNotes`). The agent then writes each
|
|
1342
|
+
* slide's full content draft into `content` — an elaboration of what was
|
|
1343
|
+
* already approved, not a new plan. Hashing the bytes would re-arm the gate on
|
|
1344
|
+
* that draft and stall every deck at the step after sign-off.
|
|
1345
|
+
*
|
|
1346
|
+
* Narrow on purpose, and safe by construction: a reworded beat or key point, a
|
|
1347
|
+
* swapped layout, a renamed heading or a reordered red thread all move this
|
|
1348
|
+
* hash and re-arm the gate. Only `content` is exempt.
|
|
1349
|
+
*/
|
|
1350
|
+
export function planStoryHash(deckDir) {
|
|
1351
|
+
const markdown = fs.readFileSync(path.join(deckDir, "plan.md"), "utf8");
|
|
1352
|
+
const story = splitPlanMarkdownIntoSegments(markdown)
|
|
1353
|
+
.map((segment) => {
|
|
1354
|
+
if (segment.type !== "storyboard") return segment.text ?? "";
|
|
1355
|
+
if (!segment.data) return segment.raw ?? "";
|
|
1356
|
+
const { content: _content, ...rest } = segment.data;
|
|
1357
|
+
return serializeStoryboardCanvas(rest);
|
|
1358
|
+
})
|
|
1359
|
+
.join("\n");
|
|
1360
|
+
return crypto.createHash("sha256").update(story).digest("hex");
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/** The plan subject as the workspace names it: `sha256:<hex of plan.md bytes>`. */
|
|
1364
|
+
export function planSubjectHash(deckDir) {
|
|
1365
|
+
return "sha256:" + hashPlan(path.join(deckDir, "plan.md"));
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function bareHash(h) {
|
|
1369
|
+
return typeof h === "string" ? h.replace(/^sha256:/, "") : null;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function refusal(status, code, message, details) {
|
|
1373
|
+
return { status, body: { ok: false, error: { code, message, retryable: false, ...(details ? { details } : {}) } } };
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// The ONE write path of a plan decision — the standalone gate's tail and the
|
|
1377
|
+
// workspace task response both land here, so plan.md, plan-review.json and
|
|
1378
|
+
// plan.html come out the same whichever page answered. `action` is approve,
|
|
1379
|
+
// comments (also `request-changes`, the task-contract name) or `done` (a
|
|
1380
|
+
// plan-edit task: the record is re-rendered, no decision is recorded).
|
|
1381
|
+
// `expectedHash` is the plan the reviewer saw (`planSubjectHash` or its bare
|
|
1382
|
+
// hex) — a plan.md rewritten since is refused, never approved by accident.
|
|
1383
|
+
// `operationId` makes a repeat idempotent; a different decision under the same
|
|
1384
|
+
// id is refused. `write` is the seam a crash test injects; the record is
|
|
1385
|
+
// written last and atomically, so a throw here leaves no approval behind.
|
|
1386
|
+
export function respondPlan(
|
|
1387
|
+
deckDir,
|
|
1388
|
+
{
|
|
1389
|
+
action,
|
|
1390
|
+
expectedHash = null,
|
|
1391
|
+
payload = {},
|
|
1392
|
+
actor = null,
|
|
1393
|
+
operationId = null,
|
|
1394
|
+
at = new Date().toISOString(),
|
|
1395
|
+
write = savePlanState,
|
|
1396
|
+
} = {},
|
|
1397
|
+
) {
|
|
1398
|
+
const decision =
|
|
1399
|
+
action === "approve"
|
|
1400
|
+
? "approve"
|
|
1401
|
+
: action === "comments" || action === "comment" || action === "request-changes"
|
|
1402
|
+
? "comments"
|
|
1403
|
+
: action === "done"
|
|
1404
|
+
? "done"
|
|
1405
|
+
: null;
|
|
1406
|
+
if (!decision) return refusal(422, "validation_failed", `unknown plan action '${action}'`);
|
|
1407
|
+
const planPath = path.join(deckDir, "plan.md");
|
|
1408
|
+
if (!fs.existsSync(planPath)) return refusal(404, "plan_missing", `no plan.md in ${deckSlug(deckDir)}`);
|
|
1409
|
+
const prev = loadPlanState(deckDir);
|
|
1410
|
+
if (operationId && prev.operationId === operationId) {
|
|
1411
|
+
if (prev.decision !== decision || (expectedHash != null && bareHash(expectedHash) !== prev.planHash)) {
|
|
1412
|
+
return refusal(409, "operation_mismatch", "this operation id already recorded a different response");
|
|
1413
|
+
}
|
|
1414
|
+
return {
|
|
1415
|
+
status: 200,
|
|
1416
|
+
body: { ok: true, decision: prev.decision, comments: prev.comments, edited: prev.edited, design: prev.design, planHash: prev.planHash, hash: "sha256:" + prev.planHash, at: prev.at, replayed: true },
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
if (expectedHash != null && bareHash(expectedHash) !== hashPlan(planPath)) {
|
|
1420
|
+
return refusal(409, "stale_subject", "plan.md changed since this plan was reviewed", {
|
|
1421
|
+
expected: expectedHash,
|
|
1422
|
+
current: planSubjectHash(deckDir),
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
if (decision === "done") {
|
|
1426
|
+
writePlanPage(deckDir);
|
|
1427
|
+
return { status: 200, body: { ok: true, decision, hash: planSubjectHash(deckDir), at } };
|
|
1428
|
+
}
|
|
1429
|
+
// A design picked at the gate rides with the answer; checked before anything
|
|
1430
|
+
// is written, so an unknown one refuses the whole answer, not half of it.
|
|
1431
|
+
const wantDesign = typeof payload?.design === "string" && payload.design ? payload.design : null;
|
|
1432
|
+
if (wantDesign && !cachedDesign(wantDesign)) {
|
|
1433
|
+
return refusal(422, "unknown_design", `design '${wantDesign}' is not cached on this machine`);
|
|
1434
|
+
}
|
|
1435
|
+
const { frontmatterRaw, body } = splitPlanFrontmatter(fs.readFileSync(planPath, "utf8"));
|
|
1436
|
+
const applied = applyPlanEdit({
|
|
1437
|
+
planPath,
|
|
1438
|
+
frontmatterRaw,
|
|
1439
|
+
body,
|
|
1440
|
+
editedPlan: typeof payload?.editedPlan === "string" ? payload.editedPlan : null,
|
|
1441
|
+
});
|
|
1442
|
+
// `payload.edited`: the workspace committed the human's edit just before this
|
|
1443
|
+
// answer (so the answer is about the edited plan); the record still says so.
|
|
1444
|
+
const edited = applied.edited || payload?.edited === true;
|
|
1445
|
+
const comments = decision === "comments" ? normalizePlanComments(payload?.comments) : [];
|
|
1446
|
+
// An approved revision makes the plan the source of approved intent again:
|
|
1447
|
+
// every image use a targeted edit recorded out-of-band is folded into its
|
|
1448
|
+
// slide's storyboard and cleared. BEFORE the hash, so the planHash recorded
|
|
1449
|
+
// with the approval is the hash of the plan that was folded into — otherwise
|
|
1450
|
+
// the very next command would read the plan as changed since sign-off.
|
|
1451
|
+
if (decision === "approve") noteImageUseReconcile(deckDir);
|
|
1452
|
+
const switched = wantDesign ? switchPlanDesign(deckDir, wantDesign) : null;
|
|
1453
|
+
const design = switched && !switched.error ? switched : null;
|
|
1454
|
+
const planHash = hashPlan(planPath);
|
|
1455
|
+
write(deckDir, {
|
|
1456
|
+
version: 3,
|
|
1457
|
+
decision,
|
|
1458
|
+
comments,
|
|
1459
|
+
edited,
|
|
1460
|
+
planHash,
|
|
1461
|
+
storyHash: planStoryHash(deckDir),
|
|
1462
|
+
at,
|
|
1463
|
+
...(design ? { design } : {}),
|
|
1464
|
+
...(operationId ? { operationId } : {}),
|
|
1465
|
+
...(actor ? { actor } : {}),
|
|
1466
|
+
});
|
|
1467
|
+
return { status: 200, body: { ok: true, decision, comments, edited, design, planHash, hash: "sha256:" + planHash, at } };
|
|
1468
|
+
}
|
|
1469
|
+
|
|
990
1470
|
export async function runPlanReview({
|
|
991
1471
|
deckDir,
|
|
992
1472
|
port = null,
|
|
993
1473
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
1474
|
+
// null = decide (gateUsesWorkspace); tests force either path
|
|
1475
|
+
useWorkspace = null,
|
|
994
1476
|
// quiet: serveAndOpen's BROWSER: line owns all open/failed messaging.
|
|
995
1477
|
open = (url) => openInBrowser(url, { quiet: true }),
|
|
996
1478
|
openFailedGraceMs = OPEN_FAILED_GRACE_MS,
|
|
997
1479
|
} = {}) {
|
|
998
|
-
const base =
|
|
1480
|
+
const base = deckSlug(deckDir);
|
|
999
1481
|
const planPath = path.join(deckDir, "plan.md");
|
|
1000
1482
|
if (!fs.existsSync(planPath)) {
|
|
1001
1483
|
process.stderr.write(
|
|
@@ -1005,31 +1487,67 @@ export async function runPlanReview({
|
|
|
1005
1487
|
return;
|
|
1006
1488
|
}
|
|
1007
1489
|
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1490
|
+
// A plan that fails the plan checks opens no round (refuse). Only a NEW round
|
|
1491
|
+
// is checked: handing over an answer the user already gave never is.
|
|
1492
|
+
const refuse = () => {
|
|
1493
|
+
const why = planRefusal(deckDir);
|
|
1494
|
+
if (why) {
|
|
1495
|
+
process.stdout.write(why);
|
|
1496
|
+
process.exitCode = 1;
|
|
1497
|
+
}
|
|
1498
|
+
return why;
|
|
1499
|
+
};
|
|
1011
1500
|
|
|
1012
1501
|
// Headless simulation runs (scripts/codex-sim.mjs): approve without a browser.
|
|
1013
1502
|
if (process.env.SLIDEMIND_GATE_AUTOAPPROVE === "1") {
|
|
1503
|
+
if (refuse()) return null;
|
|
1504
|
+
noteImageUseReconcile(deckDir);
|
|
1014
1505
|
savePlanState(deckDir, {
|
|
1015
1506
|
version: 3,
|
|
1016
1507
|
decision: "approve",
|
|
1017
1508
|
comments: [],
|
|
1018
1509
|
edited: false,
|
|
1019
1510
|
planHash: hashPlan(planPath),
|
|
1511
|
+
storyHash: planStoryHash(deckDir),
|
|
1020
1512
|
at: new Date().toISOString(),
|
|
1021
1513
|
});
|
|
1022
1514
|
process.stdout.write(
|
|
1023
1515
|
"DECISION: approved\n" +
|
|
1024
1516
|
"Auto-approved via SLIDEMIND_GATE_AUTOAPPROVE — no human reviewed this plan. " +
|
|
1025
|
-
|
|
1517
|
+
`Next: ${NEXT_AFTER_APPROVAL}\n`,
|
|
1026
1518
|
);
|
|
1027
1519
|
return { decision: "approve", comments: [] };
|
|
1028
1520
|
}
|
|
1029
1521
|
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1522
|
+
// The human answers in the workspace: ask there and return at once (ASK + SAY;
|
|
1523
|
+
// the agent ends its turn). The user's pasted message names this command again,
|
|
1524
|
+
// which then hands their answer over. A workspace that cannot be used is a
|
|
1525
|
+
// blocker — never a second, standalone page (doc 21). Only headless runs and
|
|
1526
|
+
// SLIDEMIND_WORKSPACE=0 reach the standalone gate below.
|
|
1527
|
+
const gate = await import("../workspace/gate-client.mjs");
|
|
1528
|
+
if (useWorkspace ?? gate.gateUsesWorkspace(deckDir, "plan-approval")) {
|
|
1529
|
+
const r = await gate.runWorkspaceGate({
|
|
1530
|
+
deckDir,
|
|
1531
|
+
kind: "plan-approval",
|
|
1532
|
+
approvedNow: () => planApprovedExactly(deckDir),
|
|
1533
|
+
refuseOpen: () => planRefusal(deckDir),
|
|
1534
|
+
// the plan as it stands is the one the user last saved in the workspace's editor: they edited it
|
|
1535
|
+
format: (task, { note }) => process.stdout.write(formatPlanTaskDecision(deckDir, task, { note, editedInPage: gate.planLastSavedInPage(deckDir) })),
|
|
1536
|
+
});
|
|
1537
|
+
if (r.refused) {
|
|
1538
|
+
process.exitCode = 1;
|
|
1539
|
+
return null;
|
|
1540
|
+
}
|
|
1541
|
+
if (r.blocked) {
|
|
1542
|
+
process.stdout.write(`BLOCKED: ${r.blocked.code}\n${r.blocked.message}\n`);
|
|
1543
|
+
process.exitCode = 2;
|
|
1544
|
+
return null;
|
|
1545
|
+
}
|
|
1546
|
+
return r.decision === "pending" ? null : { decision: r.decision };
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
if (refuse()) return null;
|
|
1550
|
+
const { body, bodyStartLine } = splitPlanFrontmatter(fs.readFileSync(planPath, "utf8"));
|
|
1033
1551
|
|
|
1034
1552
|
// The plan window's logo is the add-in's genuine per-brand orb when a bundle
|
|
1035
1553
|
// is materialized (dark variant preferred, matching the dark UI). Absent a
|
|
@@ -1037,7 +1555,7 @@ export async function runPlanReview({
|
|
|
1037
1555
|
const mark = resolveBrandAsset(deckDir, ["logoMarkDark", "logoMark", "logoMarkLight"]);
|
|
1038
1556
|
const logoMark = mark ? "/brand/logo" : null;
|
|
1039
1557
|
const brandAssetPath = mark?.path ?? null;
|
|
1040
|
-
const { layouts
|
|
1558
|
+
const { layouts } = resolvePlanLayouts(deckDir);
|
|
1041
1559
|
|
|
1042
1560
|
const prev = loadPlanState(deckDir);
|
|
1043
1561
|
|
|
@@ -1052,7 +1570,7 @@ export async function runPlanReview({
|
|
|
1052
1570
|
// (the plan gate's analogue of review.json's ghost pins).
|
|
1053
1571
|
ledgerSeed: prev.comments || [],
|
|
1054
1572
|
layouts,
|
|
1055
|
-
|
|
1573
|
+
designs: planDesignOptions(deckDir),
|
|
1056
1574
|
port: wantPort,
|
|
1057
1575
|
});
|
|
1058
1576
|
// A waiting tab can only exist when the previous round ended in comments — so
|
|
@@ -1103,40 +1621,18 @@ export async function runPlanReview({
|
|
|
1103
1621
|
server.close();
|
|
1104
1622
|
}
|
|
1105
1623
|
|
|
1106
|
-
const
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
body,
|
|
1110
|
-
editedPlan: decision.editedPlan,
|
|
1111
|
-
});
|
|
1112
|
-
|
|
1113
|
-
const comments = normalizePlanComments(decision.comments);
|
|
1114
|
-
savePlanState(deckDir, {
|
|
1115
|
-
version: 3,
|
|
1116
|
-
decision: decision.decision,
|
|
1117
|
-
comments,
|
|
1118
|
-
edited,
|
|
1119
|
-
planHash: hashPlan(planPath),
|
|
1120
|
-
at: new Date().toISOString(),
|
|
1624
|
+
const answered = respondPlan(deckDir, {
|
|
1625
|
+
action: decision.decision,
|
|
1626
|
+
payload: { comments: decision.comments, editedPlan: decision.editedPlan, design: decision.design },
|
|
1121
1627
|
});
|
|
1628
|
+
if (answered.status !== 200) throw new Error(answered.body.error.message);
|
|
1629
|
+
const { edited, comments, design } = answered.body;
|
|
1122
1630
|
|
|
1123
1631
|
if (decision.decision === "approve") {
|
|
1124
|
-
|
|
1125
|
-
process.stdout.write(
|
|
1126
|
-
"DECISION: approved\n" +
|
|
1127
|
-
"The user approved the plan — and edited plan.md directly before approving. " +
|
|
1128
|
-
`Re-read ${deckDir}/plan.md: the edited text IS the approved plan. Author from ` +
|
|
1129
|
-
"it, not from your earlier draft.\n",
|
|
1130
|
-
);
|
|
1131
|
-
} else {
|
|
1132
|
-
process.stdout.write(
|
|
1133
|
-
"DECISION: approved\n" +
|
|
1134
|
-
"The user approved the plan. Proceed to author the slides — the plan is agreed.\n",
|
|
1135
|
-
);
|
|
1136
|
-
}
|
|
1632
|
+
process.stdout.write(planApprovedText(deckDir, { edited, design }));
|
|
1137
1633
|
} else {
|
|
1138
1634
|
process.stdout.write(
|
|
1139
|
-
formatPlanCommentsForHarness(deckDir, comments, { edited }),
|
|
1635
|
+
formatPlanCommentsForHarness(deckDir, comments, { edited, design }),
|
|
1140
1636
|
);
|
|
1141
1637
|
}
|
|
1142
1638
|
return decision;
|
|
@@ -1153,6 +1649,13 @@ function parseArgs(argv) {
|
|
|
1153
1649
|
args.deck = argv[++i];
|
|
1154
1650
|
} else if (a === "--port" && i + 1 < argv.length) {
|
|
1155
1651
|
args.port = parseInt(argv[++i], 10) || 0;
|
|
1652
|
+
} else if (a === "--wait" && i + 1 < argv.length) {
|
|
1653
|
+
// retired with the bounded wait (a question returns at once); accepted so a
|
|
1654
|
+
// stale skill or script that still passes it does not fail
|
|
1655
|
+
i++;
|
|
1656
|
+
} else if (a === "--json") {
|
|
1657
|
+
// Accepted like every deck command's --json (SKILL.md); the gate's
|
|
1658
|
+
// output already is its machine contract — the one DECISION: line.
|
|
1156
1659
|
} else if (a === "-h" || a === "--help") {
|
|
1157
1660
|
process.stdout.write(usage);
|
|
1158
1661
|
process.exit(0);
|