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/review.mjs
CHANGED
|
@@ -46,9 +46,10 @@ import path from "node:path";
|
|
|
46
46
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
47
47
|
|
|
48
48
|
import { buildDeck, openInBrowser } from "./build.mjs";
|
|
49
|
-
import { draftCheckBlocker
|
|
49
|
+
import { draftCheckBlocker } from "./draft-check.mjs";
|
|
50
|
+
import { isEditRouteDeck } from "./edit-route.mjs";
|
|
50
51
|
import { resolveSkillForDeck } from "./resolve-skill.mjs";
|
|
51
|
-
import { resolveBrandAsset } from "../bridge/bundle.mjs";
|
|
52
|
+
import { resolveBrandAsset, resolveBundleDirForDeck } from "../bridge/bundle.mjs";
|
|
52
53
|
import {
|
|
53
54
|
MIME,
|
|
54
55
|
serveStaticFile,
|
|
@@ -66,10 +67,16 @@ import {
|
|
|
66
67
|
POLL_MS,
|
|
67
68
|
POLL_TTL_MS,
|
|
68
69
|
} from "./gate-server.mjs";
|
|
70
|
+
import {
|
|
71
|
+
bundleOwnedFiles,
|
|
72
|
+
deckSlug,
|
|
73
|
+
previewPath,
|
|
74
|
+
statePath,
|
|
75
|
+
} from "../lib/deck-paths.mjs";
|
|
69
76
|
|
|
70
77
|
const __filename = fileURLToPath(import.meta.url);
|
|
71
78
|
const __dirname = path.dirname(__filename);
|
|
72
|
-
const OVERLAY_PATH = path.join(__dirname, "review-overlay.js");
|
|
79
|
+
export const OVERLAY_PATH = path.join(__dirname, "review-overlay.js");
|
|
73
80
|
const REVIEW_STATE_FILE = "review.json";
|
|
74
81
|
// No decision timeout: the gate returns only on the user's decision (or when
|
|
75
82
|
// no browser could be opened at all — see openFailedGraceMs). An agent cannot
|
|
@@ -78,6 +85,36 @@ const REVIEW_STATE_FILE = "review.json";
|
|
|
78
85
|
// lands. Pass timeoutMs explicitly to cap the wait (tests).
|
|
79
86
|
const DEFAULT_TIMEOUT_MS = null;
|
|
80
87
|
|
|
88
|
+
// The bundle's assets dir, or null when no design is materialized for the deck
|
|
89
|
+
// (the preview then carries no brand chrome and the page has nothing to rewrite).
|
|
90
|
+
export function brandAssetsDirForDeck(deckDir) {
|
|
91
|
+
try {
|
|
92
|
+
const dir = resolveSkillForDeck(deckDir)?.brand?.assetsDir;
|
|
93
|
+
return dir && fs.existsSync(dir) ? dir : null;
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// A browser on an http page may not load file: subresources, so the absolute
|
|
100
|
+
// bundle URLs buildDeck wrote for the disk view (chrome-*, logos, decor) become
|
|
101
|
+
// `route`, which the serving page answers from that same directory. Both the
|
|
102
|
+
// standalone review server and the workspace serve the page through this.
|
|
103
|
+
export function routeBrandAssets(pageHtml, brandAssetsDir, route) {
|
|
104
|
+
if (!brandAssetsDir) return pageHtml;
|
|
105
|
+
const brandUrl = pathToFileURL(brandAssetsDir).href.replace(/\/$/, "");
|
|
106
|
+
return pageHtml.split(brandUrl).join(route);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The review window's logo is the add-in's genuine per-brand orb: a PNG
|
|
110
|
+
// shipped on the bundle as a role-keyed asset, served from the bundle cache
|
|
111
|
+
// (never copied into the deck). The UI is dark, so prefer the dark variant. When absent
|
|
112
|
+
// (brand predates the feature / bundle not re-materialized), the overlay shows
|
|
113
|
+
// a plain slide glyph — the CLI no longer re-derives an orb.
|
|
114
|
+
export function reviewLogoFile(deckDir) {
|
|
115
|
+
return resolveBrandAsset(deckDir, ["logoMarkDark", "logoMark", "logoMarkLight"])?.filename ?? null;
|
|
116
|
+
}
|
|
117
|
+
|
|
81
118
|
function clamp01(n) {
|
|
82
119
|
return n < 0 ? 0 : n > 1 ? 1 : n;
|
|
83
120
|
}
|
|
@@ -196,7 +233,7 @@ export function formatCommentsForHarness(deckDir, comments) {
|
|
|
196
233
|
// persisted round is what survives.
|
|
197
234
|
out.push(
|
|
198
235
|
`This round (all ${n} comment${n === 1 ? "" : "s"}) is also saved verbatim in ` +
|
|
199
|
-
`${
|
|
236
|
+
`${statePath(deckDir, REVIEW_STATE_FILE)} — if this output looks truncated or ` +
|
|
200
237
|
`you can't see every numbered comment above, read that file for the full list ` +
|
|
201
238
|
`instead of guessing or asking the user to re-type anything.`,
|
|
202
239
|
);
|
|
@@ -204,22 +241,58 @@ export function formatCommentsForHarness(deckDir, comments) {
|
|
|
204
241
|
// Imperative, stateful tail (mirrors plan.mjs's formatPlanCommentsForHarness):
|
|
205
242
|
// the reviewer's tab is waiting on the next `slidemind review` run — nothing
|
|
206
243
|
// reaches them until it runs. An agent that ends its turn here strands them.
|
|
244
|
+
// The draft check sits between edit and re-run because `review` refuses a
|
|
245
|
+
// slide edited since its last draft check (`[draft:stale]`, draft-check.mjs):
|
|
246
|
+
// telling the agent to re-run `review` straight away costs a refused call.
|
|
247
|
+
const slug = deckSlug(deckDir);
|
|
248
|
+
const cited = [
|
|
249
|
+
...new Set(
|
|
250
|
+
rows
|
|
251
|
+
.map((c) => c.jsxPath)
|
|
252
|
+
.filter((p) => p.startsWith("slides/") && p.endsWith(".jsx"))
|
|
253
|
+
.map((p) => path.basename(p)),
|
|
254
|
+
),
|
|
255
|
+
];
|
|
207
256
|
out.push("Fix exactly what the comments ask for at the cited lines — read the file");
|
|
208
257
|
out.push("before you edit. Then re-run the gate in the same turn:");
|
|
209
258
|
out.push(" 1. Edit the cited slides/*.jsx file(s) at the given lines.");
|
|
210
|
-
out.push(
|
|
259
|
+
out.push(
|
|
260
|
+
` 2. Draft-check every slide you edited: slidemind view-draft ${slug}` +
|
|
261
|
+
(cited.length ? ` --only ${cited.join(",")}` : "") +
|
|
262
|
+
" — and fix what it measures (review refuses a slide edited since its last draft check).",
|
|
263
|
+
);
|
|
264
|
+
out.push(` 3. Re-run: slidemind review ${slug}`);
|
|
211
265
|
out.push("The re-run rebuilds the preview and reopens their same tab (addressed pins");
|
|
212
266
|
out.push("show as ghost pins), and the user reviews again — several rounds are normal.");
|
|
213
267
|
out.push("Do NOT end your turn, do NOT export anything, and do NOT send the user back");
|
|
214
|
-
out.push("to the browser before step
|
|
268
|
+
out.push("to the browser before step 3.");
|
|
215
269
|
return out.join("\n") + "\n";
|
|
216
270
|
}
|
|
217
271
|
|
|
272
|
+
const REVIEW_APPROVED_TEXT =
|
|
273
|
+
"DECISION: approved\n" +
|
|
274
|
+
"The user approved the preview. Proceed to produce the output they asked " +
|
|
275
|
+
"for (slidemind push / slidemind build-pptx). One approval covers the task.\n";
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* A workspace task's answer, printed exactly as the standalone gate prints its
|
|
279
|
+
* own: the pins come from the gate record the answer wrote (review.json, the
|
|
280
|
+
* same operation), else the task's own comment text.
|
|
281
|
+
*/
|
|
282
|
+
export function formatReviewTaskDecision(deckDir, task, { note = null } = {}) {
|
|
283
|
+
const head = note ? `NOTE: ${note}\n` : "";
|
|
284
|
+
if (task?.response?.action === "approve") return head + REVIEW_APPROVED_TEXT;
|
|
285
|
+
const state = loadReviewState(deckDir);
|
|
286
|
+
const mine = Boolean(state.operationId) && task?.response?.operationId === state.operationId;
|
|
287
|
+
const comments = mine ? state.comments : task?.response?.comment ? [{ text: task.response.comment }] : [];
|
|
288
|
+
return head + formatCommentsForHarness(deckDir, comments);
|
|
289
|
+
}
|
|
290
|
+
|
|
218
291
|
// Render the no-answer outcome for the harness — same contract as plan.mjs's
|
|
219
292
|
// formatPlanPendingForHarness: an expected state with resume instructions, not
|
|
220
293
|
// a failure and never an implicit approval.
|
|
221
294
|
export function formatReviewPendingForHarness(deckDir, { timeoutMs, pageLoaded, url } = {}) {
|
|
222
|
-
const base =
|
|
295
|
+
const base = deckSlug(deckDir);
|
|
223
296
|
const waited = timeoutMs
|
|
224
297
|
? `the gate waited ${Math.max(1, Math.round(timeoutMs / 60000))} minutes and the user hasn't answered.`
|
|
225
298
|
: "the gate closed without an answer from the user.";
|
|
@@ -237,7 +310,7 @@ export function formatReviewPendingForHarness(deckDir, { timeoutMs, pageLoaded,
|
|
|
237
310
|
].join("\n") + "\n";
|
|
238
311
|
}
|
|
239
312
|
|
|
240
|
-
function injectReview(html, cfgExtra, overlayJs, logoMark = null, round = null) {
|
|
313
|
+
export function injectReview(html, cfgExtra, overlayJs, logoMark = null, round = null) {
|
|
241
314
|
const cfg = safeInlineJson({
|
|
242
315
|
endpoint: "/review",
|
|
243
316
|
heartbeat: "/__review/heartbeat",
|
|
@@ -264,7 +337,7 @@ const EMPTY_STATE = { version: 2, decision: null, comments: [] };
|
|
|
264
337
|
// drives the tab-reuse grace window in runReview (a waiting tab can only exist
|
|
265
338
|
// after a prior comments round).
|
|
266
339
|
export function loadReviewState(deckDir) {
|
|
267
|
-
const f =
|
|
340
|
+
const f = statePath(deckDir, REVIEW_STATE_FILE);
|
|
268
341
|
if (!fs.existsSync(f)) return { ...EMPTY_STATE };
|
|
269
342
|
try {
|
|
270
343
|
const data = JSON.parse(fs.readFileSync(f, "utf8"));
|
|
@@ -275,19 +348,41 @@ export function loadReviewState(deckDir) {
|
|
|
275
348
|
comments: normalizeReviewComments(data.comments),
|
|
276
349
|
deckHash: data.deckHash ?? null,
|
|
277
350
|
at: data.at ?? null,
|
|
351
|
+
// Workspace provenance (respondPreview): which operation wrote this round
|
|
352
|
+
// and who answered. Absent on rounds the standalone gate recorded.
|
|
353
|
+
operationId: typeof data.operationId === "string" ? data.operationId : null,
|
|
354
|
+
actor: data.actor ?? null,
|
|
355
|
+
approval: readApproval(data),
|
|
278
356
|
};
|
|
279
357
|
} catch {
|
|
280
358
|
return { ...EMPTY_STATE };
|
|
281
359
|
}
|
|
282
360
|
}
|
|
283
361
|
|
|
362
|
+
// The deck's standing sign-off: the round that approved, carried through any
|
|
363
|
+
// later comments round (respondPreview writes it forward). An approving round
|
|
364
|
+
// is its own record, so only a later round stores the field.
|
|
365
|
+
function readApproval(data) {
|
|
366
|
+
const a = data.approval;
|
|
367
|
+
if (a && typeof a === "object" && typeof a.deckHash === "string") {
|
|
368
|
+
return { at: a.at ?? null, deckHash: a.deckHash, actor: a.actor ?? null };
|
|
369
|
+
}
|
|
370
|
+
if (data.decision === "approve") return { at: data.at ?? null, deckHash: data.deckHash ?? null, actor: data.actor ?? null };
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
|
|
284
374
|
// What the user reviewed: every file under slides/ (jsx + reused native .pptx)
|
|
285
|
-
// and assets/ (downloaded imagery), hashed by path + bytes.
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
375
|
+
// and DECK-OWNED assets/ (downloaded imagery), hashed by path + bytes. Copies of
|
|
376
|
+
// the bundle a pre-workspace deck still carries are skipped — verified copies, by
|
|
377
|
+
// bundle manifest and byte identity, so re-materializing the design with the same
|
|
378
|
+
// content cannot invalidate an approval while a user's own `master-data.png` still
|
|
379
|
+
// counts. Recorded with the decision, so a round binds to exactly the content the
|
|
380
|
+
// user saw: an answer on since-edited slides is refused as stale, and a gate
|
|
381
|
+
// asked again on unchanged slides answers at once. It does not gate delivery
|
|
382
|
+
// after the approval — see requireReviewApproved.
|
|
289
383
|
export function hashDeckContent(deckDir) {
|
|
290
384
|
const h = crypto.createHash("sha256");
|
|
385
|
+
const bundleOwned = bundleOwnedFiles(deckDir, resolveBundleDirForDeck(deckDir));
|
|
291
386
|
const walk = (dir) => {
|
|
292
387
|
let entries = [];
|
|
293
388
|
try {
|
|
@@ -299,7 +394,9 @@ export function hashDeckContent(deckDir) {
|
|
|
299
394
|
const p = path.join(dir, e.name);
|
|
300
395
|
if (e.isDirectory()) walk(p);
|
|
301
396
|
else if (e.isFile()) {
|
|
302
|
-
|
|
397
|
+
const rel = path.relative(deckDir, p).split(path.sep).join("/");
|
|
398
|
+
if (bundleOwned.has(rel)) continue;
|
|
399
|
+
h.update(rel);
|
|
303
400
|
h.update("\0");
|
|
304
401
|
h.update(fs.readFileSync(p));
|
|
305
402
|
h.update("\0");
|
|
@@ -311,31 +408,115 @@ export function hashDeckContent(deckDir) {
|
|
|
311
408
|
}
|
|
312
409
|
|
|
313
410
|
// The human checkmark push/build-pptx require — enforced by the CLI, not by the
|
|
314
|
-
// skill's prose.
|
|
411
|
+
// skill's prose. ONE per deck: the approval builds the deck, and every change
|
|
412
|
+
// after it (an edit, a comment on the delivered deck, a reconcile fold, a
|
|
413
|
+
// repair) goes straight into the .pptx, where the user sees it, without a second
|
|
414
|
+
// review. So this asks whether the deck ever passed the review, never whether
|
|
415
|
+
// the slides still match it. A deck adopted from the user's own .pptx (the edit
|
|
416
|
+
// route) never asks at all: it was a deck before any agent touched it, so its
|
|
417
|
+
// edits are already "afterwards". null = passed; otherwise the agent-facing
|
|
315
418
|
// reason plus the command that clears it.
|
|
316
419
|
export function requireReviewApproved(deckDir) {
|
|
317
|
-
|
|
420
|
+
if (loadReviewState(deckDir).approval) return null;
|
|
421
|
+
if (isEditRouteDeck(deckDir)) return null;
|
|
422
|
+
const base = deckSlug(deckDir);
|
|
423
|
+
return (
|
|
424
|
+
`${base} has not passed the preview review — a human must approve it first: ` +
|
|
425
|
+
`run \`slidemind review ${base}\` and wait for DECISION: approved.`
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// The latest round approved exactly these slides: a gate asked again on
|
|
430
|
+
// unchanged content answers at once instead of asking the user twice.
|
|
431
|
+
export function previewApprovedAsIs(deckDir) {
|
|
318
432
|
const state = loadReviewState(deckDir);
|
|
319
|
-
|
|
320
|
-
return (
|
|
321
|
-
`${base} has not passed the preview review — a human must approve it first: ` +
|
|
322
|
-
`run \`slidemind review ${base}\` and wait for DECISION: approved.`
|
|
323
|
-
);
|
|
324
|
-
}
|
|
325
|
-
if (state.deckHash !== hashDeckContent(deckDir)) {
|
|
326
|
-
return (
|
|
327
|
-
`${base}'s slides changed since the user approved the preview — ` +
|
|
328
|
-
`re-run \`slidemind review ${base}\` for a fresh sign-off.`
|
|
329
|
-
);
|
|
330
|
-
}
|
|
331
|
-
return null;
|
|
433
|
+
return state.decision === "approve" && state.deckHash === hashDeckContent(deckDir);
|
|
332
434
|
}
|
|
333
435
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
);
|
|
436
|
+
// Sidecar write as temp + rename: a gate record is either the whole round or
|
|
437
|
+
// absent — a crash mid-write must not leave a half-parsed approval behind.
|
|
438
|
+
export function writeStateAtomic(deckDir, name, contents) {
|
|
439
|
+
const file = statePath(deckDir, name);
|
|
440
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
441
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
|
442
|
+
fs.writeFileSync(tmp, contents);
|
|
443
|
+
fs.renameSync(tmp, file);
|
|
444
|
+
return file;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function saveReviewState(deckDir, state) {
|
|
448
|
+
writeStateAtomic(deckDir, REVIEW_STATE_FILE, JSON.stringify(state, null, 2) + "\n");
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** The preview subject as the workspace names it: `sha256:<hashDeckContent>`. */
|
|
452
|
+
export function previewHash(deckDir) {
|
|
453
|
+
return "sha256:" + hashDeckContent(deckDir);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function bareHash(h) {
|
|
457
|
+
return typeof h === "string" ? h.replace(/^sha256:/, "") : null;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function refusal(status, code, message, details) {
|
|
461
|
+
return { status, body: { ok: false, error: { code, message, retryable: false, ...(details ? { details } : {}) } } };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// The ONE write path of a preview decision — the standalone gate's tail and the
|
|
465
|
+
// workspace task response both land here, so review.json carries the same
|
|
466
|
+
// evidence whichever page answered. `expectedHash` is the subject the reviewer
|
|
467
|
+
// saw (`previewHash` or its bare hex); a deck edited since is refused, never
|
|
468
|
+
// approved by accident. `operationId` makes a repeat idempotent: the recorded
|
|
469
|
+
// round comes back unchanged, and a different decision under the same id is
|
|
470
|
+
// refused. `write` is the seam a crash test injects; the record is written last
|
|
471
|
+
// and atomically, so a throw here leaves no approval behind.
|
|
472
|
+
export function respondPreview(
|
|
473
|
+
deckDir,
|
|
474
|
+
{
|
|
475
|
+
action,
|
|
476
|
+
expectedHash = null,
|
|
477
|
+
payload = {},
|
|
478
|
+
actor = null,
|
|
479
|
+
operationId = null,
|
|
480
|
+
at = new Date().toISOString(),
|
|
481
|
+
write = saveReviewState,
|
|
482
|
+
} = {},
|
|
483
|
+
) {
|
|
484
|
+
const decision =
|
|
485
|
+
action === "approve" ? "approve" : action === "comment" || action === "comments" ? "comments" : null;
|
|
486
|
+
if (!decision) return refusal(422, "validation_failed", `unknown preview action '${action}'`);
|
|
487
|
+
const prev = loadReviewState(deckDir);
|
|
488
|
+
if (operationId && prev.operationId === operationId) {
|
|
489
|
+
if (prev.decision !== decision || (expectedHash != null && bareHash(expectedHash) !== prev.deckHash)) {
|
|
490
|
+
return refusal(409, "operation_mismatch", "this operation id already recorded a different response");
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
status: 200,
|
|
494
|
+
body: { ok: true, decision: prev.decision, comments: prev.comments, deckHash: prev.deckHash, hash: "sha256:" + prev.deckHash, at: prev.at, replayed: true },
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
const deckHash = hashDeckContent(deckDir);
|
|
498
|
+
if (expectedHash != null && bareHash(expectedHash) !== deckHash) {
|
|
499
|
+
return refusal(409, "stale_subject", "the slides changed since this preview was reviewed", {
|
|
500
|
+
expected: expectedHash,
|
|
501
|
+
current: "sha256:" + deckHash,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
const comments = decision === "comments" ? normalizeReviewComments(payload?.comments) : [];
|
|
505
|
+
// A comments round after the approval keeps it: the review is one-time per
|
|
506
|
+
// deck, and asking for a change is not taking the sign-off back.
|
|
507
|
+
const kept = decision === "comments" ? prev.approval : null;
|
|
508
|
+
const state = {
|
|
509
|
+
version: 2,
|
|
510
|
+
decision,
|
|
511
|
+
comments,
|
|
512
|
+
deckHash,
|
|
513
|
+
at,
|
|
514
|
+
...(kept ? { approval: { at: kept.at, deckHash: kept.deckHash, ...(kept.actor ? { actor: kept.actor } : {}) } } : {}),
|
|
515
|
+
...(operationId ? { operationId } : {}),
|
|
516
|
+
...(actor ? { actor } : {}),
|
|
517
|
+
};
|
|
518
|
+
write(deckDir, state);
|
|
519
|
+
return { status: 200, body: { ok: true, decision, comments, deckHash, hash: "sha256:" + deckHash, at } };
|
|
339
520
|
}
|
|
340
521
|
|
|
341
522
|
// Deterministic per-deck port so a tab left waiting from the previous round can
|
|
@@ -347,12 +528,13 @@ export function preferredPortForDeck(deckDir) {
|
|
|
347
528
|
}
|
|
348
529
|
|
|
349
530
|
// Start the local review server. Returns { url, port, waitForDecision, waitForHeartbeat, close }.
|
|
350
|
-
// presentation.html must already exist in
|
|
531
|
+
// preview/presentation.html must already exist in the workspace (build it first).
|
|
351
532
|
export async function createReviewServer({
|
|
352
533
|
deckDir,
|
|
353
534
|
ledgerSeed = [],
|
|
354
535
|
overlayJs,
|
|
355
536
|
logoMark = null,
|
|
537
|
+
brandAssetsDir = null,
|
|
356
538
|
port = 0,
|
|
357
539
|
}) {
|
|
358
540
|
const gate = createDecisionGate();
|
|
@@ -362,8 +544,11 @@ export async function createReviewServer({
|
|
|
362
544
|
const hb = createHeartbeat({ service: "slidemind-review" });
|
|
363
545
|
const overlay =
|
|
364
546
|
overlayJs ?? fs.readFileSync(OVERLAY_PATH, "utf8");
|
|
547
|
+
// The bundle's file: URLs become /brand/assets, served below from the same
|
|
548
|
+
// directory. The deck itself still carries no copy.
|
|
549
|
+
const pageHtml = fs.readFileSync(previewPath(deckDir, "presentation.html"), "utf8");
|
|
365
550
|
const indexHtml = injectReview(
|
|
366
|
-
|
|
551
|
+
routeBrandAssets(pageHtml, brandAssetsDir, "/brand/assets"),
|
|
367
552
|
{
|
|
368
553
|
// deck: the sessionStorage namespace for this deck's pin ledger.
|
|
369
554
|
// ledgerSeed: the previous round's pins — a FRESH tab (no session) renders
|
|
@@ -372,14 +557,14 @@ export async function createReviewServer({
|
|
|
372
557
|
// a content hash: a fix round that edited nothing hashes identically, and
|
|
373
558
|
// an unreadable slides/ dir hashed to "" — both made the transition
|
|
374
559
|
// undetectable, which stranded pins in "sent" forever.
|
|
375
|
-
deck:
|
|
560
|
+
deck: deckSlug(deckDir),
|
|
376
561
|
ledgerSeed: normalizeReviewComments(ledgerSeed),
|
|
377
562
|
},
|
|
378
563
|
overlay,
|
|
379
564
|
logoMark,
|
|
380
565
|
hb.round,
|
|
381
566
|
);
|
|
382
|
-
const
|
|
567
|
+
const workspaceRoot = path.resolve(deckDir);
|
|
383
568
|
|
|
384
569
|
const server = http.createServer((req, res) => {
|
|
385
570
|
const u = new URL(req.url, "http://127.0.0.1");
|
|
@@ -437,10 +622,27 @@ export async function createReviewServer({
|
|
|
437
622
|
return;
|
|
438
623
|
}
|
|
439
624
|
|
|
440
|
-
// Traversal-guarded static serving from the
|
|
625
|
+
// Traversal-guarded static serving from the workspace root; a miss or an escape
|
|
441
626
|
// attempt falls through to the caller's 404 (serveStaticFile never writes on
|
|
442
627
|
// a non-match).
|
|
443
|
-
|
|
628
|
+
// Brand files come from the bundle cache; everything else (slides/, the
|
|
629
|
+
// deck's own assets/, preview/fonts/) from the workspace.
|
|
630
|
+
if (pathname.startsWith("/brand/assets/")) {
|
|
631
|
+
if (
|
|
632
|
+
brandAssetsDir &&
|
|
633
|
+
serveStaticFile({
|
|
634
|
+
roots: [brandAssetsDir],
|
|
635
|
+
pathname: pathname.slice("/brand/assets".length),
|
|
636
|
+
res,
|
|
637
|
+
})
|
|
638
|
+
) {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
res.writeHead(404);
|
|
642
|
+
res.end("not found");
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
if (serveStaticFile({ roots: [workspaceRoot], pathname, res })) return;
|
|
444
646
|
res.writeHead(404);
|
|
445
647
|
res.end("not found");
|
|
446
648
|
});
|
|
@@ -503,23 +705,23 @@ export async function runReview({
|
|
|
503
705
|
// quiet: serveAndOpen's BROWSER: line owns all open/failed messaging.
|
|
504
706
|
open = (url) => openInBrowser(url, { quiet: true }),
|
|
505
707
|
openFailedGraceMs = OPEN_FAILED_GRACE_MS,
|
|
506
|
-
|
|
708
|
+
// null = decide (gateUsesWorkspace); tests force either path
|
|
709
|
+
useWorkspace = null,
|
|
507
710
|
} = {}) {
|
|
508
|
-
await
|
|
509
|
-
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
}
|
|
711
|
+
const gate = await import("../workspace/gate-client.mjs");
|
|
712
|
+
const viaWorkspace = useWorkspace ?? gate.gateUsesWorkspace(deckDir, "preview-review");
|
|
713
|
+
// Asked again about this exact, still-current build (the build and its checks
|
|
714
|
+
// passed when it was asked — the user said "continue" without answering yet),
|
|
715
|
+
// or receiving an answer the user already gave: nothing to rebuild first.
|
|
716
|
+
const resuming = viaWorkspace && ((await gate.previewAnswerWaiting(deckDir)) || (await gate.openPreviewQuestionIsCurrent(deckDir)));
|
|
717
|
+
if (!resuming) await buildDeck(deckDir);
|
|
718
|
+
|
|
719
|
+
// The user reviews only slides the draft check passed: every slide checked
|
|
720
|
+
// at its current bytes, no defect of the shared table (draft-lines.mjs)
|
|
721
|
+
// standing. There is no override — the same rule holds delivery.
|
|
722
|
+
if (!resuming) {
|
|
723
|
+
const standing = draftCheckBlocker(deckDir);
|
|
724
|
+
if (standing) throw new Error(`review not opened — ${standing}`);
|
|
523
725
|
}
|
|
524
726
|
|
|
525
727
|
// Headless simulation runs (scripts/codex-sim.mjs): approve without a browser.
|
|
@@ -540,23 +742,38 @@ export async function runReview({
|
|
|
540
742
|
return { decision: "approve", comments: [] };
|
|
541
743
|
}
|
|
542
744
|
|
|
745
|
+
// The human answers in the workspace: the preview built above is what the page
|
|
746
|
+
// shows; ask there and return at once (ASK + SAY; the agent ends its turn). The
|
|
747
|
+
// user's pasted message names this command again, which hands their answer
|
|
748
|
+
// over. A workspace that cannot be used is a blocker — never a second,
|
|
749
|
+
// standalone page (doc 21).
|
|
750
|
+
if (viaWorkspace) {
|
|
751
|
+
const r = await gate.runWorkspaceGate({
|
|
752
|
+
deckDir,
|
|
753
|
+
kind: "preview-review",
|
|
754
|
+
approvedNow: () => previewApprovedAsIs(deckDir),
|
|
755
|
+
format: (task, { note }) => process.stdout.write(formatReviewTaskDecision(deckDir, task, { note })),
|
|
756
|
+
});
|
|
757
|
+
if (r.blocked) {
|
|
758
|
+
process.stdout.write(`BLOCKED: ${r.blocked.code}\n${r.blocked.message}\n`);
|
|
759
|
+
process.exitCode = 2;
|
|
760
|
+
return null;
|
|
761
|
+
}
|
|
762
|
+
return r.decision === "pending" ? null : { decision: r.decision };
|
|
763
|
+
}
|
|
764
|
+
|
|
543
765
|
const prev = loadReviewState(deckDir);
|
|
544
|
-
// Hashed
|
|
545
|
-
//
|
|
546
|
-
// content the decision refers to.
|
|
766
|
+
// Hashed before the gate opens: the agent is blocked while the user reviews,
|
|
767
|
+
// so this IS the content the decision refers to.
|
|
547
768
|
const deckHash = hashDeckContent(deckDir);
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
// buildDeck). The review UI is dark, so prefer the dark variant. When absent
|
|
551
|
-
// (brand predates the feature / bundle not re-materialized), the overlay shows
|
|
552
|
-
// a plain slide glyph — the CLI no longer re-derives an orb.
|
|
553
|
-
const mark = resolveBrandAsset(deckDir, ["logoMarkDark", "logoMark", "logoMarkLight"]);
|
|
554
|
-
const logoMark = mark ? "/assets/" + mark.filename : null;
|
|
769
|
+
const mark = reviewLogoFile(deckDir);
|
|
770
|
+
const logoMark = mark ? "/brand/assets/" + mark : null;
|
|
555
771
|
const wantPort = port == null ? preferredPortForDeck(deckDir) : port;
|
|
556
772
|
const server = await createReviewServer({
|
|
557
773
|
deckDir,
|
|
558
774
|
ledgerSeed: prev.comments || [],
|
|
559
775
|
logoMark,
|
|
776
|
+
brandAssetsDir: brandAssetsDirForDeck(deckDir),
|
|
560
777
|
port: wantPort,
|
|
561
778
|
});
|
|
562
779
|
// A waiting tab can only exist when the previous round ended in comments — so
|
|
@@ -604,21 +821,20 @@ export async function runReview({
|
|
|
604
821
|
server.close();
|
|
605
822
|
}
|
|
606
823
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
824
|
+
// No `expectedHash` here, exactly as `runPlanReview` does it. The hash binds the
|
|
825
|
+
// gate's ROUND (the page the user answered); binding the write too would make
|
|
826
|
+
// any write into slides/ or assets/ during the review refuse with stale_subject
|
|
827
|
+
// and throw away the comments the user just typed. The agent is blocked while
|
|
828
|
+
// the user reviews, so the hash this call records is the content they answered.
|
|
829
|
+
const answered = respondPreview(deckDir, {
|
|
830
|
+
action: decision.decision,
|
|
831
|
+
payload: { comments: decision.comments },
|
|
614
832
|
});
|
|
833
|
+
if (answered.status !== 200) throw new Error(answered.body.error.message);
|
|
834
|
+
const comments = answered.body.comments;
|
|
615
835
|
|
|
616
836
|
if (decision.decision === "approve") {
|
|
617
|
-
process.stdout.write(
|
|
618
|
-
"DECISION: approved\n" +
|
|
619
|
-
"The user approved the preview. Proceed to produce the output they asked " +
|
|
620
|
-
"for (slidemind push / slidemind build-pptx). One approval covers the task.\n",
|
|
621
|
-
);
|
|
837
|
+
process.stdout.write(REVIEW_APPROVED_TEXT);
|
|
622
838
|
} else {
|
|
623
839
|
process.stdout.write(
|
|
624
840
|
formatCommentsForHarness(deckDir, comments),
|
|
@@ -630,19 +846,24 @@ export async function runReview({
|
|
|
630
846
|
function parseArgs(argv) {
|
|
631
847
|
// port null ⇒ derive the deterministic per-deck port; an explicit --port
|
|
632
848
|
// (incl. --port 0 for OS-random) overrides it.
|
|
633
|
-
const args = { deck: null, port: null,
|
|
849
|
+
const args = { deck: null, port: null, wait: null };
|
|
634
850
|
for (let i = 0; i < argv.length; i++) {
|
|
635
851
|
const a = argv[i];
|
|
636
852
|
if (a === "--deck" && i + 1 < argv.length) {
|
|
637
853
|
args.deck = argv[++i];
|
|
638
854
|
} else if (a === "--port" && i + 1 < argv.length) {
|
|
639
855
|
args.port = parseInt(argv[++i], 10) || 0;
|
|
640
|
-
} else if (a === "--
|
|
641
|
-
|
|
856
|
+
} else if (a === "--wait" && i + 1 < argv.length) {
|
|
857
|
+
// retired with the bounded wait (a question returns at once); accepted so a
|
|
858
|
+
// stale skill or script that still passes it does not fail
|
|
859
|
+
i++;
|
|
860
|
+
} else if (a === "--json") {
|
|
861
|
+
// Accepted like every deck command's --json (SKILL.md); the gate's
|
|
862
|
+
// output already is its machine contract — the one DECISION: line.
|
|
642
863
|
} else if (a === "-h" || a === "--help") {
|
|
643
864
|
process.stdout.write(
|
|
644
|
-
"Usage: slidemind review <deck> [--port <n>]
|
|
645
|
-
" node src/builders/review.mjs --deck <deck> [--port <n>]
|
|
865
|
+
"Usage: slidemind review <deck> [--port <n>]\n" +
|
|
866
|
+
" node src/builders/review.mjs --deck <deck> [--port <n>]\n",
|
|
646
867
|
);
|
|
647
868
|
process.exit(0);
|
|
648
869
|
} else {
|
|
@@ -660,7 +881,7 @@ function parseArgs(argv) {
|
|
|
660
881
|
async function main() {
|
|
661
882
|
const args = parseArgs(process.argv.slice(2));
|
|
662
883
|
const deckDir = path.resolve(args.deck);
|
|
663
|
-
await runReview({ deckDir, port: args.port
|
|
884
|
+
await runReview({ deckDir, port: args.port });
|
|
664
885
|
}
|
|
665
886
|
|
|
666
887
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|