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.
Files changed (187) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +592 -16
  4. package/LICENSE +4 -0
  5. package/README.md +203 -8
  6. package/THIRD-PARTY-NOTICES.md +201 -0
  7. package/bin/slidemind.mjs +50 -2
  8. package/package.json +5 -3
  9. package/skills/create-slides/SKILL.md +484 -886
  10. package/skills/search/SKILL.md +46 -23
  11. package/src/bridge/build-and-push.mjs +548 -73
  12. package/src/bridge/bundle.mjs +100 -31
  13. package/src/bridge/capture.mjs +14 -17
  14. package/src/bridge/client/brands.mjs +157 -18
  15. package/src/bridge/client/engine-routing.mjs +48 -13
  16. package/src/bridge/client/engine-search.mjs +24 -1
  17. package/src/bridge/client/render.mjs +27 -1
  18. package/src/bridge/client/transport.mjs +499 -23
  19. package/src/bridge/client.mjs +10 -0
  20. package/src/bridge/config.mjs +67 -6
  21. package/src/bridge/engine-download.mjs +0 -10
  22. package/src/bridge/graph-auth.mjs +55 -3
  23. package/src/bridge/image-intent-envelope.mjs +239 -0
  24. package/src/bridge/package-preview.mjs +67 -0
  25. package/src/bridge/sharepoint.mjs +914 -0
  26. package/src/builders/asset-fetch.mjs +203 -0
  27. package/src/builders/block-dividers.mjs +83 -0
  28. package/src/builders/build.mjs +171 -87
  29. package/src/builders/card-height.mjs +294 -0
  30. package/src/builders/chart-hole.mjs +183 -0
  31. package/src/builders/chart-jsx.mjs +410 -0
  32. package/src/builders/chart-labels.mjs +326 -0
  33. package/src/builders/concept-geometry.mjs +370 -0
  34. package/src/builders/concept-intent.mjs +200 -0
  35. package/src/builders/concept-layout.mjs +79 -0
  36. package/src/builders/concept-lines.mjs +509 -0
  37. package/src/builders/concept-lists.mjs +270 -0
  38. package/src/builders/concept-marks.mjs +84 -0
  39. package/src/builders/concept-relationships.mjs +12 -0
  40. package/src/builders/concept-spec.mjs +645 -0
  41. package/src/builders/concept-text.mjs +140 -0
  42. package/src/builders/concept-waves.mjs +57 -0
  43. package/src/builders/copy-fit.mjs +139 -0
  44. package/src/builders/design-tokens.mjs +347 -0
  45. package/src/builders/draft-check.mjs +552 -340
  46. package/src/builders/draft-lines.mjs +369 -0
  47. package/src/builders/edit-route.mjs +29 -0
  48. package/src/builders/figure-sketch.mjs +375 -0
  49. package/src/builders/fold-sets.mjs +639 -0
  50. package/src/builders/gate-server.mjs +9 -4
  51. package/src/builders/gate-session.js +273 -12
  52. package/src/builders/gate-shell.js +1494 -0
  53. package/src/builders/image-intent-check.mjs +308 -0
  54. package/src/builders/image-use-reconcile.mjs +107 -0
  55. package/src/builders/kit-helpers.mjs +121 -0
  56. package/src/builders/layout-names.mjs +32 -0
  57. package/src/builders/legacy-reference-uses.mjs +179 -0
  58. package/src/builders/line-fit.mjs +224 -0
  59. package/src/builders/line-native.mjs +895 -0
  60. package/src/builders/line-relations.mjs +230 -0
  61. package/src/builders/lint-fix.mjs +117 -9
  62. package/src/builders/lint.mjs +25 -20
  63. package/src/builders/list-jsx.mjs +88 -0
  64. package/src/builders/placement.mjs +133 -0
  65. package/src/builders/plan-checks.mjs +135 -0
  66. package/src/builders/plan-comments.mjs +19 -0
  67. package/src/builders/plan-handlers.mjs +95 -0
  68. package/src/builders/plan-markdown-edit.mjs +3 -3
  69. package/src/builders/plan-overlay.js +1530 -1608
  70. package/src/builders/plan.mjs +689 -186
  71. package/src/builders/preset-definitions.json +1 -0
  72. package/src/builders/preset-eval.mjs +244 -0
  73. package/src/builders/preview.html +51 -120
  74. package/src/builders/red-thread-backbone.mjs +135 -36
  75. package/src/builders/repair.mjs +205 -0
  76. package/src/builders/repeat-groups.mjs +540 -0
  77. package/src/builders/resolve-skill.mjs +5 -4
  78. package/src/builders/review-overlay.js +296 -293
  79. package/src/builders/review.mjs +305 -84
  80. package/src/builders/rule-shift.mjs +96 -0
  81. package/src/builders/seat.mjs +354 -0
  82. package/src/builders/settle-driver.mjs +236 -0
  83. package/src/builders/settle.mjs +1448 -0
  84. package/src/builders/slide-prep.mjs +67 -0
  85. package/src/builders/spec-assets.mjs +236 -0
  86. package/src/builders/spec-match.mjs +56 -0
  87. package/src/builders/stack.mjs +413 -0
  88. package/src/builders/storyboard-markdown.mjs +70 -27
  89. package/src/builders/storyboard-schema.mjs +54 -114
  90. package/src/builders/text-blocks.mjs +212 -0
  91. package/src/builders/text-card-fit.mjs +552 -0
  92. package/src/builders/text-stack.mjs +276 -0
  93. package/src/builders/transcribe.mjs +1069 -0
  94. package/src/cli/assets.mjs +164 -0
  95. package/src/cli/common.mjs +173 -12
  96. package/src/cli/concept-spec-support.mjs +76 -0
  97. package/src/cli/concepts.mjs +1002 -103
  98. package/src/cli/deck.mjs +401 -65
  99. package/src/cli/diagnose.mjs +4 -2
  100. package/src/cli/engine-helpers.mjs +17 -10
  101. package/src/cli/engine-results.mjs +76 -3
  102. package/src/cli/help.mjs +232 -28
  103. package/src/cli/install.mjs +9 -0
  104. package/src/cli/maintenance.mjs +104 -0
  105. package/src/cli/read.mjs +2 -1
  106. package/src/cli/ready.mjs +6 -3
  107. package/src/cli/record.mjs +522 -0
  108. package/src/cli/remote.mjs +818 -0
  109. package/src/cli/revision.mjs +686 -0
  110. package/src/cli/search.mjs +30 -11
  111. package/src/cli/session.mjs +56 -13
  112. package/src/cli/setup.mjs +46 -2
  113. package/src/cli/status.mjs +142 -33
  114. package/src/cli/tasks.mjs +246 -0
  115. package/src/cli/transcribe.mjs +854 -0
  116. package/src/cli/workspace-ui.mjs +260 -0
  117. package/src/cli/workspace.mjs +212 -34
  118. package/src/install-channel.mjs +64 -7
  119. package/src/lib/adopt.mjs +346 -25
  120. package/src/lib/concept-spec-binding.mjs +96 -0
  121. package/src/lib/conversions.mjs +70 -6
  122. package/src/lib/convert-folded.mjs +107 -0
  123. package/src/lib/deck-paths.mjs +418 -0
  124. package/src/lib/durable-ids.mjs +47 -0
  125. package/src/lib/edit-operations.mjs +316 -0
  126. package/src/lib/edit-state.mjs +1244 -0
  127. package/src/lib/edit-transitions.mjs +120 -0
  128. package/src/lib/extensions.mjs +501 -0
  129. package/src/lib/image-assets.mjs +465 -0
  130. package/src/lib/image-derivatives.mjs +234 -0
  131. package/src/lib/image-intent-fingerprint.mjs +70 -0
  132. package/src/lib/image-intent-tags.mjs +176 -0
  133. package/src/lib/image-intent.mjs +802 -0
  134. package/src/lib/image-use-overrides.mjs +279 -0
  135. package/src/lib/inline-components.mjs +613 -0
  136. package/src/lib/lint-converted.mjs +68 -4
  137. package/src/lib/merge-json.mjs +163 -0
  138. package/src/lib/overwrite-guard.mjs +3 -3
  139. package/src/lib/presentation-context.mjs +531 -0
  140. package/src/lib/preservation-tags.mjs +157 -0
  141. package/src/lib/preservation-verdict.mjs +1342 -0
  142. package/src/lib/publish.mjs +1557 -0
  143. package/src/lib/rebase.mjs +945 -0
  144. package/src/lib/reconcile-ack.mjs +4 -6
  145. package/src/lib/reconcile.mjs +62 -24
  146. package/src/lib/record-client.mjs +175 -0
  147. package/src/lib/record-state.mjs +151 -0
  148. package/src/lib/remote.mjs +661 -0
  149. package/src/lib/revision.mjs +1210 -0
  150. package/src/lib/slide-guids.mjs +4 -6
  151. package/src/lib/slide-preservation.mjs +423 -0
  152. package/src/lib/slide-selection.mjs +9 -0
  153. package/src/lib/workspace-transaction.mjs +117 -0
  154. package/src/pptx/content-digest.mjs +334 -0
  155. package/src/pptx/deck-assemble.mjs +1278 -0
  156. package/src/pptx/fill/engine.mjs +2 -3
  157. package/src/pptx/fill/freeform.mjs +6 -3
  158. package/src/pptx/ooxml.mjs +20 -0
  159. package/src/pptx/package-evidence.mjs +383 -0
  160. package/src/pptx/tags.mjs +163 -11
  161. package/src/update-check.mjs +22 -4
  162. package/src/workspace/adapters.mjs +222 -0
  163. package/src/workspace/auth.mjs +256 -0
  164. package/src/workspace/autostart.mjs +204 -0
  165. package/src/workspace/daemon.mjs +360 -0
  166. package/src/workspace/discovery.mjs +180 -0
  167. package/src/workspace/edit.mjs +167 -0
  168. package/src/workspace/events.mjs +52 -0
  169. package/src/workspace/gate-client.mjs +290 -0
  170. package/src/workspace/handback.mjs +73 -0
  171. package/src/workspace/lifecycle.mjs +635 -0
  172. package/src/workspace/operations.mjs +200 -0
  173. package/src/workspace/pick-folder.mjs +152 -0
  174. package/src/workspace/registry.mjs +359 -0
  175. package/src/workspace/remote-links.mjs +397 -0
  176. package/src/workspace/remote-status.mjs +359 -0
  177. package/src/workspace/routes.mjs +1331 -0
  178. package/src/workspace/server.mjs +159 -0
  179. package/src/workspace/sync.mjs +1119 -0
  180. package/src/workspace/task-actions.mjs +215 -0
  181. package/src/workspace/tasks.mjs +507 -0
  182. package/src/workspace/ui/app.js +1886 -0
  183. package/src/workspace/ui/index.html +141 -0
  184. package/src/workspace/ui/plan-bridge.js +120 -0
  185. package/src/workspace/ui/sharepoint.svg +59 -0
  186. package/src/workspace/ui/styles.css +423 -0
  187. package/src/builders/visual-families.mjs +0 -46
@@ -0,0 +1,167 @@
1
+ // Edit generations for the persistent workspace: one local save, under the
2
+ // deck's operation, that knows what the editor started from.
3
+ //
4
+ // Every editor submits `base` — the hash and value it loaded — and what it
5
+ // wants now. Under the lock the CURRENT plan is re-read (through the installed
6
+ // adapter, or plan.md for a generic deck), so a write another tool made in the
7
+ // meantime is noticed rather than overwritten: if it differs from the last
8
+ // accepted snapshot (`state/workspace-edit.json`) the raw value is backed up
9
+ // in the operation record before anything is accepted. A stale base is merged
10
+ // three ways with the shared policy (merge-json.mjs); a same-field clash
11
+ // returns `local_conflict` with every input and writes nothing.
12
+ //
13
+ // The commit itself is the adapter's `save-local` (the pack applies its own
14
+ // amendment/validation rules) or a plain plan.md write. What the adapter
15
+ // refuses is refused here — the snapshot is not advanced and nothing is left
16
+ // for the sync queue to pick up. Network is not this module's business.
17
+
18
+ import { createHash, randomUUID } from "node:crypto";
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+
22
+ import { Merge3Error, merge3 } from "../lib/merge-json.mjs";
23
+ import { dispatchAdapter } from "./adapters.mjs";
24
+ import { runDeckOperation } from "./operations.mjs";
25
+
26
+ export const EDIT_FILE = "workspace-edit.json";
27
+ export const SCHEMA_VERSION = 1;
28
+ export const GENERIC_ADAPTER = "core";
29
+
30
+ export function editPath(workspaceDir) {
31
+ return path.join(workspaceDir, "state", EDIT_FILE);
32
+ }
33
+
34
+ /** The last accepted editor snapshot: `{ adapterId, hash, value, updatedAt }`, `{ malformed, raw }` (kept on disk), or null. */
35
+ export function readAcceptedEdit(workspaceDir) {
36
+ let raw;
37
+ try {
38
+ raw = fs.readFileSync(editPath(workspaceDir), "utf8");
39
+ } catch (err) {
40
+ if (err?.code === "ENOENT") return null;
41
+ throw err;
42
+ }
43
+ try {
44
+ const parsed = JSON.parse(raw);
45
+ if (parsed?.schemaVersion === SCHEMA_VERSION && typeof parsed.hash === "string") return parsed;
46
+ } catch {
47
+ /* fall through */
48
+ }
49
+ return { malformed: true, raw };
50
+ }
51
+
52
+ function writeAtomic(file, data) {
53
+ fs.mkdirSync(path.dirname(file), { recursive: true });
54
+ const tmp = `${file}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
55
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
56
+ fs.renameSync(tmp, file);
57
+ }
58
+
59
+ /** A generic plan is the whole plan.md string, hashed as UTF-8 bytes (contracts §7). */
60
+ export const markdownHash = (text) => `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`;
61
+
62
+ const refusal = (code, message, extra = {}) => ({ ok: false, code, message, ...extra });
63
+ const deckRootOf = (workspaceDir) => (path.basename(workspaceDir) === ".slidemind" ? path.dirname(workspaceDir) : workspaceDir);
64
+
65
+ // The two ways to read and commit a plan. `adapter` is `{ id, entry }` from
66
+ // resolveWorkspaceAdapter, or null for a generic plan.md deck.
67
+ function editor(workspaceDir, adapter, operationId) {
68
+ if (adapter?.entry) {
69
+ const deckRoot = deckRootOf(workspaceDir);
70
+ const call = (method, payload, ownerToken) => dispatchAdapter(adapter.entry, { method, deckRoot, payload, operationId, ownerToken });
71
+ return {
72
+ id: adapter.id,
73
+ async read(token) {
74
+ const answer = await call("read", {}, token);
75
+ if (answer.status !== "ok") return { refused: refusal(answer.error.code, answer.error.message, { details: answer.data ?? undefined }) };
76
+ return { value: answer.data.value, hash: answer.data.hash };
77
+ },
78
+ async commit(current, value, token) {
79
+ const answer = await call("save-local", { base: current, value }, token);
80
+ if (answer.status !== "ok") return { refused: refusal(answer.error.code, answer.error.message, { details: answer.data ?? undefined }) };
81
+ return { value: answer.data.value, hash: answer.data.hash };
82
+ },
83
+ };
84
+ }
85
+ const file = path.join(workspaceDir, "plan.md");
86
+ return {
87
+ id: GENERIC_ADAPTER,
88
+ async read() {
89
+ let text;
90
+ try {
91
+ text = fs.readFileSync(file, "utf8");
92
+ } catch (err) {
93
+ if (err?.code !== "ENOENT") throw err;
94
+ return { refused: refusal("no_plan", `no plan.md in ${workspaceDir}`) };
95
+ }
96
+ return { value: text, hash: markdownHash(text) };
97
+ },
98
+ async commit(_current, value) {
99
+ if (typeof value !== "string") return { refused: refusal("validation_failed", "a generic plan is the plan.md text") };
100
+ const tmp = `${file}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
101
+ fs.writeFileSync(tmp, value, "utf8");
102
+ fs.renameSync(tmp, file);
103
+ return { value, hash: markdownHash(value) };
104
+ },
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Save an editor's value locally.
110
+ *
111
+ * `{ ok: true, value, hash, savedLocally: true, rawBackup? }` on commit —
112
+ * `rawBackup` is the plan as found on disk when it was not the last accepted
113
+ * snapshot (an agent's direct edit), also noted in the operation record.
114
+ * `{ ok: false, code: "local_conflict", current, base, mine, merged, conflicts,
115
+ * hash }` when the stale base cannot be merged; nothing written. Any other
116
+ * `{ ok: false, code }` is the adapter's refusal (`validation_failed`, …) or a
117
+ * bad request — the snapshot is not advanced. Lock and recovery failures throw
118
+ * as runDeckOperation does (`deck_busy`, `recovery_required`).
119
+ *
120
+ * `owner` is runDeckOperation's: the daemon passes `false`.
121
+ */
122
+ export function savePlanLocal({ workspaceDir, adapter = null, operationId = randomUUID(), base, value, owner }) {
123
+ if (!base || typeof base.hash !== "string") return Promise.resolve(refusal("validation_failed", "base.hash is required: the version the editor loaded"));
124
+ const ed = editor(workspaceDir, adapter, operationId);
125
+ return runDeckOperation(workspaceDir, { operationId, kind: "save-local", expected: { base: base.hash }, owner }, async (op) => {
126
+ const current = await ed.read(op.token);
127
+ if (current.refused) return current.refused;
128
+
129
+ const accepted = readAcceptedEdit(workspaceDir);
130
+ let rawBackup;
131
+ if (accepted && !accepted.malformed && accepted.hash !== current.hash) {
132
+ rawBackup = { hash: current.hash, value: current.value };
133
+ op.note({ rawBackup });
134
+ }
135
+
136
+ let mine = value;
137
+ if (base.hash !== current.hash) {
138
+ if (base.value === undefined) return refusal("validation_failed", "base.value is required when the record moved since it was loaded");
139
+ let out;
140
+ try {
141
+ out = merge3(base.value, current.value, value);
142
+ } catch (err) {
143
+ if (err instanceof Merge3Error) return refusal("validation_failed", err.message, { path: err.path });
144
+ throw err;
145
+ }
146
+ if (out.conflicts.length) {
147
+ return { ok: false, code: "local_conflict", current: current.value, base: base.value, mine: value, merged: out.merged, conflicts: out.conflicts, hash: current.hash };
148
+ }
149
+ mine = out.merged;
150
+ }
151
+
152
+ op.note({ before: current.hash });
153
+ const committed = await ed.commit({ hash: current.hash, value: current.value }, mine, op.token);
154
+ if (committed.refused) return committed.refused;
155
+ op.note({ after: committed.hash });
156
+ writeAtomic(editPath(workspaceDir), {
157
+ schemaVersion: SCHEMA_VERSION,
158
+ adapterId: ed.id,
159
+ hash: committed.hash,
160
+ value: committed.value,
161
+ updatedAt: new Date().toISOString(),
162
+ });
163
+ // Accepted and adapter-owned: the sync queue picks it up (generic plan.md is never sent — contracts §7).
164
+ if (adapter?.entry || adapter?.read) (await import("./sync.mjs")).scheduleSync(workspaceDir, { hash: committed.hash });
165
+ return { ok: true, value: committed.value, hash: committed.hash, previous: current.hash, savedLocally: true, ...(rawBackup ? { rawBackup } : {}) };
166
+ });
167
+ }
@@ -0,0 +1,52 @@
1
+ // Advisory invalidations over SSE. `{ type, deckId?, taskId?, generation }`
2
+ // says "re-GET this"; it never carries state, and nothing is replayed on
3
+ // reconnect — a client that reconnects fetches fresh state, so a lost event
4
+ // costs one refresh, never correctness. Heartbeat comment every 25s keeps
5
+ // proxies and browsers from closing an idle stream.
6
+
7
+ export const HEARTBEAT_MS = 25_000;
8
+
9
+ export function createEvents() {
10
+ const clients = new Set();
11
+ let generation = 0;
12
+ return {
13
+ get generation() {
14
+ return generation;
15
+ },
16
+ get clientCount() {
17
+ return clients.size;
18
+ },
19
+ invalidate(event) {
20
+ generation += 1;
21
+ const line = `data: ${JSON.stringify({ ...event, generation })}\n\n`;
22
+ for (const res of clients) res.write(line);
23
+ return generation;
24
+ },
25
+ subscribe(req, res) {
26
+ res.writeHead(200, {
27
+ "Content-Type": "text/event-stream",
28
+ "Cache-Control": "no-store",
29
+ Connection: "keep-alive",
30
+ "X-Accel-Buffering": "no",
31
+ });
32
+ res.write(`: connected ${generation}\n\n`);
33
+ clients.add(res);
34
+ const beat = setInterval(() => res.write(": ping\n\n"), HEARTBEAT_MS);
35
+ beat.unref();
36
+ const drop = () => {
37
+ clearInterval(beat);
38
+ clients.delete(res);
39
+ };
40
+ req.on("close", drop);
41
+ res.on("close", drop);
42
+ },
43
+ close() {
44
+ for (const res of clients) res.end();
45
+ clients.clear();
46
+ },
47
+ };
48
+ }
49
+
50
+ /** The daemon's one bus; modules that commit something call `invalidate`. */
51
+ export const events = createEvents();
52
+ export const invalidate = (event) => events.invalidate(event);
@@ -0,0 +1,290 @@
1
+ // The agent's side of a gate when the human answers in the workspace.
2
+ //
3
+ // `slidemind plan <deck>` and `slidemind review <deck>` are the ONLY gate
4
+ // commands an agent runs. They ask through the workspace and keep the
5
+ // standalone contract's `DECISION: approved | comments | pending` stdout, from
6
+ // the same formatters. One run:
7
+ //
8
+ // 1. hands over an answer the human already gave and the agent has not
9
+ // received (`pickedUpAt` is the receipt) — this is the receive command the
10
+ // user's pasted message names;
11
+ // 2. else answers "approved" at once when the content is already approved;
12
+ // 3. else asks (a deduped task bound to the exact content), tells an open
13
+ // page, prints ASK + SAY and returns `DECISION: pending` AT ONCE. It never
14
+ // waits: nothing can reach an agent whose turn has ended, so the agent ends
15
+ // its turn with the SAY line and the user's pasted message brings it back
16
+ // (doc 21 → *Tasks and agent continuation*, handback.mjs).
17
+ //
18
+ // A workspace that cannot be used is a blocker the agent reports, never a
19
+ // standalone page on another port: that second page was the second link this
20
+ // protocol removed. Nothing here opens a browser.
21
+
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+
25
+ import { deckRoot as rootOf, deckSlug, planPageOwner, resolveWorkspace } from "../lib/deck-paths.mjs";
26
+ import { daemonRequest, ensureRunning, readDescriptor, registerDeck } from "./lifecycle.mjs";
27
+ import { markdownHash, readAcceptedEdit } from "./edit.mjs";
28
+ import { askLines, viewOf } from "./handback.mjs";
29
+ import { deriveTaskSubject } from "./task-actions.mjs";
30
+ import { getTask, listTasks, markPickedUp, requestTask } from "./tasks.mjs";
31
+
32
+ const NOUN = { "plan-approval": "plan", "preview-review": "preview" };
33
+
34
+ /**
35
+ * Does this gate ask through the workspace? Always — `slidemind setup` keeps one
36
+ * running — except where nobody is there to answer (auto-approving headless
37
+ * runs) or the user rolled back to the standalone gates (SLIDEMIND_WORKSPACE=0).
38
+ * A plan an extension owns keeps its own review flow (`tasks request`).
39
+ */
40
+ export function gateUsesWorkspace(deckDir, kind) {
41
+ if (process.env.SLIDEMIND_GATE_AUTOAPPROVE === "1") return false;
42
+ if (process.env.SLIDEMIND_WORKSPACE === "0") return false;
43
+ if (kind === "plan-approval" && planPageOwner(resolveWorkspace(deckDir)).owner !== "core") return false;
44
+ return true;
45
+ }
46
+
47
+ const SANDBOX_CODES = new Set(["EPERM", "EACCES", "EROFS"]);
48
+
49
+ /**
50
+ * Why a question could not be asked, in the words the agent passes on or acts
51
+ * on. Every one is a blocker (exit 2), never a fallback page.
52
+ */
53
+ export function askBlocker(err, deckRoot) {
54
+ if (err?.code === "workspace_sandboxed") {
55
+ return {
56
+ code: "workspace_sandboxed",
57
+ message:
58
+ "this sandboxed session cannot reach or start the slidemind workspace. " +
59
+ "Run this same command again outside the sandbox (in Codex: with escalated permissions) — it starts the workspace if needed and asks.",
60
+ };
61
+ }
62
+ if (SANDBOX_CODES.has(err?.code)) {
63
+ return {
64
+ code: "deck_not_writable",
65
+ message:
66
+ `this session may not write into ${deckRoot} (${err.code}). Decks belong in ./decks of your working folder; ` +
67
+ "if the user chose this folder on purpose, run this same command again outside the sandbox.",
68
+ };
69
+ }
70
+ return { code: err?.code ?? "workspace_unavailable", message: `the slidemind workspace could not be used: ${err?.message ?? err}` };
71
+ }
72
+
73
+ /**
74
+ * A re-run of the review gate while its question about THIS build is still
75
+ * open — the agent waiting in a loop — needs no rebuild: the preview the user
76
+ * is looking at was built from exactly these slides (the task's subject is
77
+ * their hash) and nothing under slides/ or assets/ is newer than it.
78
+ */
79
+ export async function openPreviewQuestionIsCurrent(deckDir) {
80
+ const ws = resolveWorkspace(deckDir);
81
+ let tasks;
82
+ try {
83
+ tasks = listTasks(rootOf(ws)).filter((t) => t.kind === "preview-review" && t.status === "pending");
84
+ } catch {
85
+ return false;
86
+ }
87
+ if (!tasks.length) return false;
88
+ const { previewHash } = await import("../builders/review.mjs");
89
+ const { previewPath } = await import("../lib/deck-paths.mjs");
90
+ let hash;
91
+ let builtAt;
92
+ try {
93
+ hash = previewHash(ws);
94
+ builtAt = fs.statSync(previewPath(ws, "presentation.html")).mtimeMs;
95
+ } catch {
96
+ return false;
97
+ }
98
+ if (!tasks.some((t) => t.subject?.hash === hash)) return false;
99
+ // the build's own stamp says what it was built from (renames and deletes included)
100
+ const { previewBuiltFrom } = await import("../builders/build.mjs");
101
+ const builtFrom = previewBuiltFrom(ws);
102
+ if (builtFrom) return builtFrom === hash;
103
+ const newer = (dir) => {
104
+ let entries = [];
105
+ try {
106
+ entries = fs.readdirSync(dir, { withFileTypes: true });
107
+ } catch {
108
+ return false;
109
+ }
110
+ return entries.some((e) => (e.isDirectory() ? newer(path.join(dir, e.name)) : e.isFile() && fs.statSync(path.join(dir, e.name)).mtimeMs > builtAt));
111
+ };
112
+ return !newer(path.join(ws, "slides")) && !newer(path.join(ws, "assets"));
113
+ }
114
+
115
+ /**
116
+ * Is plan.md, as it stands, the version the user last saved in the workspace's
117
+ * editor? Then the plan the agent gets back is the user's edit, not its own
118
+ * draft — even when no question was open while they edited — and it must be
119
+ * re-read before authoring.
120
+ */
121
+ export function planLastSavedInPage(deckDir) {
122
+ const ws = resolveWorkspace(deckDir);
123
+ try {
124
+ const accepted = readAcceptedEdit(ws);
125
+ if (!accepted || accepted.malformed) return false;
126
+ return accepted.hash === markdownHash(fs.readFileSync(path.join(ws, "plan.md"), "utf8"));
127
+ } catch {
128
+ return false;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * An answer on the preview the agent has not received, which this run will hand
134
+ * over as it is (comments, or an approval of these very slides): nothing needs
135
+ * rebuilding first — a build that fails now must not keep the user's answer from
136
+ * the agent. An approval of slides that changed since is not handed over (the
137
+ * run asks about the new ones), so it still builds.
138
+ */
139
+ export async function previewAnswerWaiting(deckDir) {
140
+ const ws = resolveWorkspace(deckDir);
141
+ let answer;
142
+ try {
143
+ answer = unreceived(listTasks(rootOf(ws)).filter((t) => t.kind === "preview-review"));
144
+ } catch {
145
+ return false;
146
+ }
147
+ if (!answer) return false;
148
+ if (answer.response?.action !== "approve") return true;
149
+ try {
150
+ const { previewHash } = await import("../builders/review.mjs");
151
+ const now = previewHash(ws);
152
+ return answer.subject?.hash === now || answer.evidence?.hash === now;
153
+ } catch {
154
+ return false;
155
+ }
156
+ }
157
+
158
+ const ago = (iso) => {
159
+ const s = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000));
160
+ return s < 90 ? `${s}s` : `${Math.round(s / 60)} min`;
161
+ };
162
+
163
+ /** A workspace route through the one CLI→daemon helper; an HTTP error throws with its code and status. */
164
+ async function daemonFetch(p, opts = {}) {
165
+ const { status, body } = await daemonRequest(p, opts);
166
+ if (status < 200 || status >= 300 || !body?.ok) {
167
+ throw Object.assign(new Error(body?.error?.message ?? `workspace answered HTTP ${status}`), { code: body?.error?.code ?? "workspace_error", status });
168
+ }
169
+ return body;
170
+ }
171
+
172
+ /**
173
+ * The signed-in link to the view a question is answered in (valid every time it
174
+ * is clicked, in any browser — Codex's own pane included), or the plain page
175
+ * when the workspace does not hand out its key.
176
+ */
177
+ async function askLink(deckId, kind) {
178
+ const d = readDescriptor();
179
+ const plain = `${d.url}/deck/${deckId}/${viewOf(kind)}`;
180
+ try {
181
+ const { token } = await daemonFetch("/bootstrap", { method: "POST" });
182
+ return { link: `${plain}#bootstrap=${token}`, plain };
183
+ } catch {
184
+ return { link: plain, plain };
185
+ }
186
+ }
187
+
188
+ /** The newest answer of this kind the agent has not received yet (older unreceived ones are history). */
189
+ function unreceived(tasks) {
190
+ const answered = tasks.filter((t) => t.status === "completed");
191
+ return answered[0] && !answered[0].pickedUpAt ? answered[0] : null;
192
+ }
193
+
194
+ /**
195
+ * Run one gate through the workspace. Resolves
196
+ * { blocked: { code, message } } the question could not be asked — report it
197
+ * { refused } `refuseOpen` turned a new question down (already printed)
198
+ * { decision: "approve"|"comments"|"pending", task }
199
+ *
200
+ * `format(task, { note })` prints the decision exactly as the standalone gate
201
+ * would (the caller owns the wording: plan.mjs / review.mjs); `approvedNow()`
202
+ * says whether the current content is already approved; `refuseOpen()`, when
203
+ * given, returns a refusal for asking about the content as it stands, or null.
204
+ */
205
+ export async function runWorkspaceGate({ deckDir, kind, format, approvedNow, refuseOpen = null, out = process.stdout }) {
206
+ const ws = resolveWorkspace(deckDir);
207
+ const root = rootOf(ws);
208
+ const ofKind = () => listTasks(root).filter((t) => t.kind === kind);
209
+
210
+ // 1. The answer the user's pasted message came for (or one given while no agent ran).
211
+ const handOver = async (task, note = null) => {
212
+ // Print FIRST, then record the receipt: a run killed in between shows the
213
+ // answer again next time (harmless), never loses it.
214
+ const done = getTask(root, task.id) ?? task;
215
+ format(done, { note });
216
+ // The receipt tells the page "your agent picked it up" and keeps a seen
217
+ // comments round from coming back. Older unreceived answers are history: received too.
218
+ let failed = null;
219
+ for (const t of ofKind().filter((x) => x.status === "completed" && !x.pickedUpAt)) {
220
+ for (let attempt = 0; ; attempt++) {
221
+ try {
222
+ await markPickedUp(root, t.id);
223
+ break;
224
+ } catch (e) {
225
+ if (e?.code === "deck_busy" && attempt < 5) {
226
+ await new Promise((r) => setTimeout(r, 300));
227
+ continue;
228
+ }
229
+ failed = e;
230
+ break;
231
+ }
232
+ }
233
+ }
234
+ if (failed) {
235
+ out.write(
236
+ failed.code === "deck_busy" && !process.env.SLIDEMIND_OWNER
237
+ ? "NOTE: the deck is held by your edit session, so this answer could not be marked as received and the next run will show it again. Run slidemind commands with SLIDEMIND_OWNER exported (the token `revision begin` printed).\n"
238
+ : `NOTE: this answer could not be marked as received (${failed.message}); the next run may show it again — act on it once.\n`,
239
+ );
240
+ }
241
+ return { decision: done.response?.action === "approve" ? "approve" : "comments", task: done };
242
+ };
243
+
244
+ const { subject, adapterId } = await deriveTaskSubject({ deckRoot: root, adapter: null, kind });
245
+ const waiting = unreceived(ofKind());
246
+ if (waiting) {
247
+ const sameContent = waiting.subject?.hash === subject.hash || waiting.evidence?.hash === subject.hash;
248
+ if (sameContent || waiting.response?.action !== "approve") {
249
+ return handOver(waiting, sameContent ? null : `The user answered on an earlier version of the ${NOUN[kind]} (${ago(waiting.response.at)} ago); you changed it since.`);
250
+ }
251
+ // an approval of a version that no longer exists covers nothing: note it and ask about what is there now
252
+ try {
253
+ await markPickedUp(root, waiting.id);
254
+ } catch {
255
+ // the note below still stands
256
+ }
257
+ out.write(`NOTE: the user approved an earlier version of the ${NOUN[kind]}; you changed it since, so this asks about the current one.\n`);
258
+ }
259
+
260
+ // 2. Already approved, unchanged since: nothing to ask.
261
+ if (approvedNow()) {
262
+ format({ kind, status: "completed", response: { action: "approve" }, subject }, { note: "Already approved — this exact version was signed off earlier; nothing was asked again." });
263
+ return { decision: "approve", task: null };
264
+ }
265
+
266
+ // 3. Ask, and return. Steps 1 and 2 read the deck's own task store and need no
267
+ // workspace — an answer the user already gave reaches the agent even where the
268
+ // workspace cannot run. Asking needs the one the user answers in. Only a NEW
269
+ // question is checked (`refuseOpen`): an answer above is handed over as given.
270
+ const refused = refuseOpen?.() ?? null;
271
+ if (refused) {
272
+ out.write(refused);
273
+ return { refused };
274
+ }
275
+ let entry;
276
+ let task;
277
+ try {
278
+ await ensureRunning();
279
+ entry = await registerDeck(root);
280
+ // deduped: the same content asked twice is one question
281
+ task = (await requestTask(root, { kind, adapterId, subject })).task;
282
+ } catch (err) {
283
+ return { blocked: askBlocker(err, root) };
284
+ }
285
+ const { link } = await askLink(entry.id, kind);
286
+ // an open page shows the question at once; nothing opens a browser
287
+ await daemonFetch(`/decks/${entry.id}/attention`, { method: "POST", body: { kind, taskId: task.id } }).catch(() => {});
288
+ out.write(askLines(kind, link));
289
+ return { decision: "pending", task };
290
+ }
@@ -0,0 +1,73 @@
1
+ // The words of the hand-back (doc 21 → *Tasks and agent continuation*). Nothing
2
+ // reaches an agent whose turn has ended, so every question has one shape:
3
+ //
4
+ // the agent's command prints ASK + SAY and returns → the agent ends its turn
5
+ // with the SAY line → the user decides in the workspace, and the click copies
6
+ // the message below → the user pastes it → the agent runs the receive
7
+ // command the message names.
8
+ //
9
+ // One module owns every one of those words, so the CLI (gate-client, `tasks
10
+ // request`, `ready`) and the page (the ack the routes return) never drift.
11
+
12
+ /** The deck view a question is answered in — what its link opens. */
13
+ export const VIEW_OF_KIND = {
14
+ "plan-approval": "plan",
15
+ "plan-edit": "plan",
16
+ "sync-conflict": "plan",
17
+ "preview-review": "preview",
18
+ };
19
+ export const viewOf = (kind) => VIEW_OF_KIND[kind] ?? "plan";
20
+
21
+ const CORE_GATE = { "plan-approval": "plan", "preview-review": "review" };
22
+
23
+ /** Is this task answered through the CLI's own gate (a core deck), not a pack's task flow? */
24
+ export const isCoreGate = (task) => Boolean(CORE_GATE[task?.kind]) && (!task?.adapterId || task.adapterId === "core");
25
+
26
+ /**
27
+ * The one command that receives an answer: the gate for a core deck (it prints
28
+ * the decision and writes the receipt), `tasks get` for everything else.
29
+ */
30
+ export function receiveCommand({ kind, adapterId = null, deckPath, taskId = null }) {
31
+ if (isCoreGate({ kind, adapterId })) return `slidemind ${CORE_GATE[kind]} ${JSON.stringify(deckPath)}`;
32
+ return `slidemind tasks get ${JSON.stringify(deckPath)} ${taskId}`;
33
+ }
34
+
35
+ const SUBJECT = {
36
+ "plan-approval": "Your plan is ready for review",
37
+ "plan-edit": "Your plan is ready for your edits",
38
+ "preview-review": "Your slides are ready for review",
39
+ "sync-conflict": "Your shared plan needs a decision",
40
+ };
41
+
42
+ /** The agent's closing line, word for word: the link and what happens after the user is done. */
43
+ export function sayLine(kind, link) {
44
+ return `${SUBJECT[kind] ?? "A question is waiting for you"} in the slidemind workspace: ${link} — when you're done there, the page gives you a message to paste back here.`;
45
+ }
46
+
47
+ /** What the asking command prints after the question is recorded. */
48
+ export function askLines(kind, link) {
49
+ return (
50
+ `ASK: ${link}\n` +
51
+ `SAY: ${sayLine(kind, link)}\n` +
52
+ "DECISION: pending\n" +
53
+ "Asked. End your turn now with the SAY line, word for word. Do not wait, poll or run this again: " +
54
+ "the user answers in the workspace and pastes back a message that tells you how to receive the answer.\n"
55
+ );
56
+ }
57
+
58
+ const NOUN = { "plan-approval": "Plan", "plan-edit": "Plan", "sync-conflict": "Shared plan", "preview-review": "Slides" };
59
+
60
+ /**
61
+ * The message the page copies after the user decided — one line, complete on
62
+ * its own (what was decided + the command that receives it), so it works in the
63
+ * same chat, after a summarization and in a new chat. `action` is the task
64
+ * action the user took (approve / request-changes / comment / done / resolve).
65
+ */
66
+ export function pasteMessage({ kind, action, receive }) {
67
+ const noun = NOUN[kind] ?? "Deck";
68
+ const run = `\`${receive}\``;
69
+ if (action === "approve") return `${noun} approved. Continue: ${run}`;
70
+ if (action === "done") return `${noun} edits done. Continue: ${run}`;
71
+ if (action === "resolve") return `${noun} conflict resolved. Continue: ${run}`;
72
+ return `Changes requested on the ${noun.toLowerCase()}. Read them: ${run}`;
73
+ }