slidemind 0.3.19 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +591 -15
  4. package/README.md +202 -7
  5. package/bin/slidemind.mjs +50 -2
  6. package/package.json +2 -2
  7. package/skills/create-slides/SKILL.md +484 -886
  8. package/skills/search/SKILL.md +46 -23
  9. package/src/bridge/build-and-push.mjs +548 -73
  10. package/src/bridge/bundle.mjs +100 -31
  11. package/src/bridge/capture.mjs +14 -17
  12. package/src/bridge/client/brands.mjs +157 -18
  13. package/src/bridge/client/engine-routing.mjs +48 -13
  14. package/src/bridge/client/engine-search.mjs +24 -1
  15. package/src/bridge/client/engine-slides.mjs +1 -1
  16. package/src/bridge/client/render.mjs +27 -1
  17. package/src/bridge/client/transport.mjs +499 -23
  18. package/src/bridge/client.mjs +10 -0
  19. package/src/bridge/config.mjs +67 -6
  20. package/src/bridge/engine-download.mjs +0 -10
  21. package/src/bridge/graph-auth.mjs +55 -3
  22. package/src/bridge/image-intent-envelope.mjs +239 -0
  23. package/src/bridge/package-preview.mjs +67 -0
  24. package/src/bridge/sharepoint.mjs +914 -0
  25. package/src/builders/asset-fetch.mjs +203 -0
  26. package/src/builders/block-dividers.mjs +83 -0
  27. package/src/builders/build.mjs +171 -87
  28. package/src/builders/card-height.mjs +294 -0
  29. package/src/builders/chart-hole.mjs +183 -0
  30. package/src/builders/chart-jsx.mjs +410 -0
  31. package/src/builders/chart-labels.mjs +326 -0
  32. package/src/builders/concept-geometry.mjs +370 -0
  33. package/src/builders/concept-intent.mjs +200 -0
  34. package/src/builders/concept-layout.mjs +79 -0
  35. package/src/builders/concept-lines.mjs +509 -0
  36. package/src/builders/concept-lists.mjs +270 -0
  37. package/src/builders/concept-marks.mjs +84 -0
  38. package/src/builders/concept-relationships.mjs +12 -0
  39. package/src/builders/concept-spec.mjs +645 -0
  40. package/src/builders/concept-text.mjs +140 -0
  41. package/src/builders/concept-waves.mjs +57 -0
  42. package/src/builders/copy-fit.mjs +139 -0
  43. package/src/builders/design-tokens.mjs +347 -0
  44. package/src/builders/draft-check.mjs +552 -340
  45. package/src/builders/draft-lines.mjs +369 -0
  46. package/src/builders/edit-route.mjs +29 -0
  47. package/src/builders/figure-sketch.mjs +375 -0
  48. package/src/builders/fold-sets.mjs +639 -0
  49. package/src/builders/gate-server.mjs +9 -4
  50. package/src/builders/gate-session.js +273 -12
  51. package/src/builders/gate-shell.js +1494 -0
  52. package/src/builders/image-intent-check.mjs +308 -0
  53. package/src/builders/image-use-reconcile.mjs +107 -0
  54. package/src/builders/kit-helpers.mjs +121 -0
  55. package/src/builders/layout-names.mjs +32 -0
  56. package/src/builders/legacy-reference-uses.mjs +179 -0
  57. package/src/builders/line-fit.mjs +224 -0
  58. package/src/builders/line-native.mjs +895 -0
  59. package/src/builders/line-relations.mjs +230 -0
  60. package/src/builders/lint-fix.mjs +117 -9
  61. package/src/builders/lint.mjs +25 -20
  62. package/src/builders/list-jsx.mjs +88 -0
  63. package/src/builders/placement.mjs +133 -0
  64. package/src/builders/plan-checks.mjs +135 -0
  65. package/src/builders/plan-comments.mjs +19 -0
  66. package/src/builders/plan-handlers.mjs +95 -0
  67. package/src/builders/plan-markdown-edit.mjs +3 -3
  68. package/src/builders/plan-overlay.js +1530 -1608
  69. package/src/builders/plan.mjs +689 -186
  70. package/src/builders/preset-definitions.json +1 -0
  71. package/src/builders/preset-eval.mjs +244 -0
  72. package/src/builders/preview.html +51 -120
  73. package/src/builders/red-thread-backbone.mjs +135 -36
  74. package/src/builders/repair.mjs +205 -0
  75. package/src/builders/repeat-groups.mjs +540 -0
  76. package/src/builders/resolve-skill.mjs +5 -4
  77. package/src/builders/review-overlay.js +296 -293
  78. package/src/builders/review.mjs +305 -84
  79. package/src/builders/rule-shift.mjs +96 -0
  80. package/src/builders/seat.mjs +354 -0
  81. package/src/builders/settle-driver.mjs +236 -0
  82. package/src/builders/settle.mjs +1448 -0
  83. package/src/builders/slide-prep.mjs +67 -0
  84. package/src/builders/spec-assets.mjs +236 -0
  85. package/src/builders/spec-match.mjs +56 -0
  86. package/src/builders/stack.mjs +413 -0
  87. package/src/builders/storyboard-markdown.mjs +70 -27
  88. package/src/builders/storyboard-schema.mjs +54 -114
  89. package/src/builders/text-blocks.mjs +212 -0
  90. package/src/builders/text-card-fit.mjs +552 -0
  91. package/src/builders/text-stack.mjs +276 -0
  92. package/src/builders/transcribe.mjs +1069 -0
  93. package/src/cli/assets.mjs +164 -0
  94. package/src/cli/common.mjs +173 -12
  95. package/src/cli/concept-spec-support.mjs +76 -0
  96. package/src/cli/concepts.mjs +1002 -103
  97. package/src/cli/deck.mjs +401 -65
  98. package/src/cli/diagnose.mjs +4 -2
  99. package/src/cli/engine-helpers.mjs +17 -10
  100. package/src/cli/engine-results.mjs +76 -3
  101. package/src/cli/help.mjs +232 -28
  102. package/src/cli/install.mjs +9 -0
  103. package/src/cli/maintenance.mjs +104 -0
  104. package/src/cli/read.mjs +2 -1
  105. package/src/cli/ready.mjs +6 -3
  106. package/src/cli/record.mjs +522 -0
  107. package/src/cli/remote.mjs +818 -0
  108. package/src/cli/revision.mjs +686 -0
  109. package/src/cli/search.mjs +30 -11
  110. package/src/cli/session.mjs +56 -13
  111. package/src/cli/setup.mjs +46 -2
  112. package/src/cli/status.mjs +142 -33
  113. package/src/cli/tasks.mjs +246 -0
  114. package/src/cli/transcribe.mjs +854 -0
  115. package/src/cli/workspace-ui.mjs +260 -0
  116. package/src/cli/workspace.mjs +212 -34
  117. package/src/install-channel.mjs +64 -7
  118. package/src/lib/adopt.mjs +346 -25
  119. package/src/lib/concept-spec-binding.mjs +96 -0
  120. package/src/lib/conversions.mjs +70 -6
  121. package/src/lib/convert-folded.mjs +107 -0
  122. package/src/lib/deck-paths.mjs +418 -0
  123. package/src/lib/durable-ids.mjs +47 -0
  124. package/src/lib/edit-operations.mjs +316 -0
  125. package/src/lib/edit-state.mjs +1244 -0
  126. package/src/lib/edit-transitions.mjs +120 -0
  127. package/src/lib/extensions.mjs +501 -0
  128. package/src/lib/image-assets.mjs +465 -0
  129. package/src/lib/image-derivatives.mjs +234 -0
  130. package/src/lib/image-intent-fingerprint.mjs +70 -0
  131. package/src/lib/image-intent-tags.mjs +176 -0
  132. package/src/lib/image-intent.mjs +802 -0
  133. package/src/lib/image-use-overrides.mjs +279 -0
  134. package/src/lib/inline-components.mjs +613 -0
  135. package/src/lib/lint-converted.mjs +68 -4
  136. package/src/lib/merge-json.mjs +163 -0
  137. package/src/lib/overwrite-guard.mjs +3 -3
  138. package/src/lib/presentation-context.mjs +531 -0
  139. package/src/lib/preservation-tags.mjs +157 -0
  140. package/src/lib/preservation-verdict.mjs +1342 -0
  141. package/src/lib/publish.mjs +1557 -0
  142. package/src/lib/rebase.mjs +945 -0
  143. package/src/lib/reconcile-ack.mjs +4 -6
  144. package/src/lib/reconcile.mjs +62 -24
  145. package/src/lib/record-client.mjs +175 -0
  146. package/src/lib/record-state.mjs +151 -0
  147. package/src/lib/remote.mjs +661 -0
  148. package/src/lib/revision.mjs +1210 -0
  149. package/src/lib/slide-guids.mjs +4 -6
  150. package/src/lib/slide-preservation.mjs +423 -0
  151. package/src/lib/slide-selection.mjs +9 -0
  152. package/src/lib/workspace-transaction.mjs +117 -0
  153. package/src/pptx/content-digest.mjs +334 -0
  154. package/src/pptx/deck-assemble.mjs +1278 -0
  155. package/src/pptx/fill/engine.mjs +2 -3
  156. package/src/pptx/fill/freeform.mjs +6 -3
  157. package/src/pptx/ooxml.mjs +20 -0
  158. package/src/pptx/package-evidence.mjs +383 -0
  159. package/src/pptx/tags.mjs +163 -11
  160. package/src/update-check.mjs +22 -4
  161. package/src/workspace/adapters.mjs +222 -0
  162. package/src/workspace/auth.mjs +256 -0
  163. package/src/workspace/autostart.mjs +204 -0
  164. package/src/workspace/daemon.mjs +360 -0
  165. package/src/workspace/discovery.mjs +180 -0
  166. package/src/workspace/edit.mjs +167 -0
  167. package/src/workspace/events.mjs +52 -0
  168. package/src/workspace/gate-client.mjs +290 -0
  169. package/src/workspace/handback.mjs +73 -0
  170. package/src/workspace/lifecycle.mjs +635 -0
  171. package/src/workspace/operations.mjs +200 -0
  172. package/src/workspace/pick-folder.mjs +152 -0
  173. package/src/workspace/registry.mjs +359 -0
  174. package/src/workspace/remote-links.mjs +397 -0
  175. package/src/workspace/remote-status.mjs +359 -0
  176. package/src/workspace/routes.mjs +1331 -0
  177. package/src/workspace/server.mjs +159 -0
  178. package/src/workspace/sync.mjs +1119 -0
  179. package/src/workspace/task-actions.mjs +215 -0
  180. package/src/workspace/tasks.mjs +507 -0
  181. package/src/workspace/ui/app.js +1886 -0
  182. package/src/workspace/ui/index.html +141 -0
  183. package/src/workspace/ui/plan-bridge.js +120 -0
  184. package/src/workspace/ui/sharepoint.svg +59 -0
  185. package/src/workspace/ui/styles.css +423 -0
  186. package/src/builders/visual-families.mjs +0 -46
@@ -0,0 +1,507 @@
1
+ // Durable, content-bound tasks (contracts.md §4).
2
+ //
3
+ // A task is a request the agent leaves for the human ("approve this plan",
4
+ // "review this preview") and the human's answer, bound to the EXACT content it
5
+ // was asked about (`subject.hash`). It lives in the deck
6
+ // (`state/workspace-tasks.json`) and survives agent exit, daemon restart and a
7
+ // closed terminal; it never starts or resumes an agent.
8
+ //
9
+ // Every mutation runs inside `runDeckOperation` (operations.mjs): one deck lock,
10
+ // one in-process queue, one durable intent. The response transaction is
11
+ //
12
+ // write intent → invoke the authoritative action → write evidence
13
+ // → mark completed → clear intent
14
+ //
15
+ // so a crash in the middle is found by the next writer. Recovery completes the
16
+ // task ONLY when the caller can show evidence for that exact operation and
17
+ // subject (`verify`); otherwise it stays pending. Nobody manufactures an
18
+ // approval from a half-written transaction.
19
+
20
+ import { createHash, randomUUID } from "node:crypto";
21
+ import { EventEmitter } from "node:events";
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+
25
+ import { resolveWorkspace, statePath } from "../lib/deck-paths.mjs";
26
+ import { readOperationIntent, runDeckOperation } from "./operations.mjs";
27
+ import { coded, writeJsonAtomic } from "./registry.mjs";
28
+
29
+ export const TASKS_FILE = "workspace-tasks.json";
30
+ export const SCHEMA_VERSION = 1;
31
+ export const RESPONSE_KIND = "task-response";
32
+
33
+ /** kind → allowed actions; pinned by test/fixtures/workspace/task/cases.json. */
34
+ export const KINDS = {
35
+ "plan-edit": ["done"],
36
+ "plan-approval": ["approve", "request-changes"],
37
+ "preview-review": ["approve", "comment"],
38
+ "sync-conflict": ["resolve"],
39
+ };
40
+ export const STATUSES = ["pending", "completed", "superseded", "cancelled"];
41
+
42
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
43
+ const SHA = /^sha256:[a-f0-9]{64}$/;
44
+ const ACTION = /^[a-z][a-z0-9-]{0,31}$/;
45
+ const ID_LIKE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
46
+ const MAX_COMMENT = 16 * 1024;
47
+
48
+ const nowIso = () => new Date().toISOString();
49
+ const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
50
+ const notifier = new EventEmitter();
51
+ notifier.setMaxListeners(0);
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // subject hash
55
+ // ---------------------------------------------------------------------------
56
+
57
+ function canonical(value) {
58
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
59
+ if (isObj(value)) {
60
+ return `{${Object.keys(value)
61
+ .sort()
62
+ .map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`)
63
+ .join(",")}}`;
64
+ }
65
+ return JSON.stringify(value);
66
+ }
67
+
68
+ /** `sha256:<hex>` of a string (UTF-8 bytes) or of a JSON value (canonical, sorted keys). */
69
+ export function taskSubject(content) {
70
+ const bytes = typeof content === "string" || Buffer.isBuffer(content) ? content : canonical(content);
71
+ return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // validation (the fixtures' bad-* files are the oracle)
76
+ // ---------------------------------------------------------------------------
77
+
78
+ function invalid(msg) {
79
+ return coded("validation_failed", msg);
80
+ }
81
+
82
+ export function validateTask(task) {
83
+ if (!isObj(task)) throw invalid("task is not an object");
84
+ if (!UUID.test(task.id ?? "")) throw invalid(`task id ${JSON.stringify(task.id)} is not a UUID`);
85
+ if (!KINDS[task.kind]) throw invalid(`unknown task kind ${JSON.stringify(task.kind)}`);
86
+ if (typeof task.adapterId !== "string" || !ID_LIKE.test(task.adapterId)) throw invalid("adapterId is malformed");
87
+ if (!isObj(task.subject) || !SHA.test(task.subject.hash ?? "")) throw invalid("subject.hash is not sha256:<hex>");
88
+ if (task.subject.remoteETag !== null && typeof task.subject.remoteETag !== "string") throw invalid("subject.remoteETag must be null or a string");
89
+ if (task.subject.requestedHash !== undefined && !SHA.test(task.subject.requestedHash)) throw invalid("subject.requestedHash is not sha256:<hex>");
90
+ if (task.subject.followed !== undefined && (!Array.isArray(task.subject.followed) || !task.subject.followed.every((h) => SHA.test(h)))) throw invalid("subject.followed is not a list of sha256:<hex>");
91
+ if (!STATUSES.includes(task.status)) throw invalid(`unknown task status ${JSON.stringify(task.status)}`);
92
+ for (const k of ["createdAt", "updatedAt"]) {
93
+ if (typeof task[k] !== "string" || Number.isNaN(Date.parse(task[k]))) throw invalid(`${k} is not a date`);
94
+ }
95
+ if (!Array.isArray(task.actions) || task.actions.length === 0) throw invalid("actions is empty");
96
+ for (const a of task.actions) {
97
+ if (!KINDS[task.kind].includes(a)) throw invalid(`action ${JSON.stringify(a)} is not one of ${task.kind}'s`);
98
+ }
99
+ if (task.response !== null) {
100
+ const r = task.response;
101
+ if (!isObj(r) || !UUID.test(r.operationId ?? "") || !task.actions.includes(r.action)) throw invalid("response is malformed");
102
+ if (typeof r.at !== "string" || typeof r.actor !== "string") throw invalid("response is malformed");
103
+ }
104
+ if (task.evidence !== null && (!isObj(task.evidence) || !SHA.test(task.evidence.hash ?? ""))) {
105
+ throw invalid("evidence must be null or carry the exact content hash");
106
+ }
107
+ if (task.status === "completed" && (!task.response || !task.evidence)) throw invalid("a completed task needs response and evidence");
108
+ if (task.pickedUpAt !== undefined && (typeof task.pickedUpAt !== "string" || Number.isNaN(Date.parse(task.pickedUpAt)))) throw invalid("pickedUpAt is not a date");
109
+ return task;
110
+ }
111
+
112
+ export function validateStore(store) {
113
+ if (!isObj(store) || store.schemaVersion !== SCHEMA_VERSION || !Array.isArray(store.tasks)) {
114
+ throw coded("recovery_required", `${TASKS_FILE} is not a v${SCHEMA_VERSION} task store; inspect it before mutating this deck.`);
115
+ }
116
+ store.tasks.forEach(validateTask);
117
+ return store;
118
+ }
119
+
120
+ function validateRespondRequest(req) {
121
+ if (!isObj(req)) throw invalid("respond request is not an object");
122
+ if (!UUID.test(req.operationId ?? "")) throw invalid("operationId is not a UUID");
123
+ if (!SHA.test(req.expectedHash ?? "")) throw invalid("expectedHash is not sha256:<hex>");
124
+ if (typeof req.action !== "string" || !ACTION.test(req.action)) throw invalid("action is malformed");
125
+ if (req.payload !== undefined && req.payload !== null && !isObj(req.payload)) throw invalid("payload must be an object");
126
+ if (typeof req.actor !== "string" || !req.actor) throw invalid("actor is required");
127
+ const comment = req.payload?.comment;
128
+ if (comment !== undefined && comment !== null && (typeof comment !== "string" || comment.length > MAX_COMMENT)) {
129
+ throw invalid("comment must be a string under 16 KiB");
130
+ }
131
+ return req;
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // store
136
+ // ---------------------------------------------------------------------------
137
+
138
+ const ws = (deckRoot) => resolveWorkspace(path.resolve(deckRoot));
139
+ export const tasksPath = (deckRoot) => statePath(ws(deckRoot), TASKS_FILE);
140
+
141
+ function readStore(deckRoot) {
142
+ let raw;
143
+ try {
144
+ raw = fs.readFileSync(tasksPath(deckRoot), "utf8");
145
+ } catch (err) {
146
+ if (err?.code === "ENOENT") return { schemaVersion: SCHEMA_VERSION, receiptsSince: nowIso(), tasks: [] };
147
+ throw err;
148
+ }
149
+ let parsed;
150
+ try {
151
+ parsed = JSON.parse(raw);
152
+ } catch {
153
+ throw coded("recovery_required", `${TASKS_FILE} is unreadable; it is kept on disk for recovery.`);
154
+ }
155
+ return adoptReceipts(validateStore(parsed));
156
+ }
157
+
158
+ /**
159
+ * Receipts (`pickedUpAt`) arrived with the gates asking through the workspace.
160
+ * A store written before them holds answers an agent already acted on through
161
+ * `tasks wait` / `tasks get`: they count as received, or the first gate run
162
+ * would hand last week's comments over again. In memory on every read; the
163
+ * next write persists it with `receiptsSince`, after which a missing receipt
164
+ * means exactly "nobody has this answer yet".
165
+ */
166
+ function adoptReceipts(store) {
167
+ if (store.receiptsSince) return store;
168
+ store.receiptsSince = nowIso();
169
+ for (const t of store.tasks) if (t.status === "completed" && !t.pickedUpAt) t.pickedUpAt = t.updatedAt;
170
+ return store;
171
+ }
172
+
173
+ function writeStore(deckRoot, store) {
174
+ const file = tasksPath(deckRoot);
175
+ // a brand-new store starts with receipts on: every answer in it is tracked
176
+ store.receiptsSince ??= nowIso();
177
+ writeJsonAtomic(file, validateStore(store));
178
+ notifier.emit(file);
179
+ }
180
+
181
+ export function listTasks(deckRoot, { status = null } = {}) {
182
+ const tasks = readStore(deckRoot).tasks;
183
+ return (status ? tasks.filter((t) => t.status === status) : tasks).slice().sort((a, b) => b.createdAt.localeCompare(a.createdAt));
184
+ }
185
+
186
+ export function getTask(deckRoot, id) {
187
+ return readStore(deckRoot).tasks.find((t) => t.id === id) ?? null;
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // recovery
192
+ // ---------------------------------------------------------------------------
193
+
194
+ /**
195
+ * Settle an interrupted response transaction. Runs as `runDeckOperation`'s
196
+ * recoverer, so it is synchronous and answers truthy only when the deck is
197
+ * consistent again.
198
+ *
199
+ * `verify(task, response)` is the caller's check against the authoritative
200
+ * gate record (plan-review.json, context.json, …): it returns the evidence
201
+ * `{ record, hash, remoteETag }` when the action landed for exactly this
202
+ * subject, else null. Without evidence the task stays pending; the action is
203
+ * never repeated here.
204
+ */
205
+ export function taskRecoverer(deckRoot, { verify = null } = {}) {
206
+ return (intent) => {
207
+ if (intent?.kind !== RESPONSE_KIND) return false;
208
+ const { taskId, response } = intent.notes ?? {};
209
+ if (!taskId || !response) return true; // nothing was invoked yet; the intent is just a claim
210
+ const store = readStore(deckRoot);
211
+ const task = store.tasks.find((t) => t.id === taskId);
212
+ if (!task) return true;
213
+ if (task.status === "completed" && task.response?.operationId === response.operationId) return true;
214
+ if (task.status !== "pending") return true;
215
+ if (!verify) return false; // the action may have landed; only its gate record can say — refuse until someone checks
216
+ const evidence = verify(task, response);
217
+ if (evidence && evidence.hash === task.subject.hash) {
218
+ complete(task, response, evidence);
219
+ writeStore(deckRoot, store);
220
+ }
221
+ return true;
222
+ };
223
+ }
224
+
225
+ /** Explicit recovery pass (the daemon calls it before the first mutation after start). */
226
+ export function recoverTasks(deckRoot, { verify = null } = {}) {
227
+ const pending = readOperationIntent(ws(deckRoot));
228
+ if (!pending) return { recovered: false };
229
+ if (pending.malformed) throw coded("recovery_required", "workspace-operation.json is unreadable");
230
+ if (pending.intent.kind !== RESPONSE_KIND) throw coded("recovery_required", `operation ${pending.intent.id} (${pending.intent.kind}) is not a task transaction`, { intent: pending.intent });
231
+ // The wrapper runs the recoverer under the lock and clears the intent.
232
+ return runDeckOperation(ws(deckRoot), { kind: "task-recovery", recover: taskRecoverer(deckRoot, { verify }) }, () => ({ recovered: true }));
233
+ }
234
+
235
+ function complete(task, response, evidence) {
236
+ task.response = response;
237
+ task.evidence = { record: evidence.record ?? null, hash: evidence.hash, remoteETag: evidence.remoteETag ?? null };
238
+ task.status = "completed";
239
+ task.updatedAt = nowIso();
240
+ }
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // mutations
244
+ // ---------------------------------------------------------------------------
245
+
246
+ /**
247
+ * Ask for a task, or find the identical one. Dedup key is kind + adapterId +
248
+ * subject.hash: the same content asked twice is one task. A different
249
+ * `remoteETag` for identical content refreshes provenance on the existing task
250
+ * and is still `existing: true` — a remote version is never a new subject.
251
+ *
252
+ * A new subject SUPERSEDES the pending tasks of the same kind: their content has
253
+ * moved on, so they are questions about something that no longer exists.
254
+ */
255
+ export async function requestTask(deckRoot, { kind, subject, adapterId, actions = KINDS[kind] }, { owner, verify } = {}) {
256
+ const draft = validateTask({
257
+ id: randomUUID(),
258
+ kind,
259
+ adapterId,
260
+ subject: { hash: subject?.hash, remoteETag: subject?.remoteETag ?? null },
261
+ status: "pending",
262
+ createdAt: nowIso(),
263
+ updatedAt: nowIso(),
264
+ actions,
265
+ response: null,
266
+ evidence: null,
267
+ });
268
+ return runDeckOperation(ws(deckRoot), { kind: "task-request", owner, recover: taskRecoverer(deckRoot, { verify }) }, () => {
269
+ const store = readStore(deckRoot);
270
+ const existing = store.tasks.find(
271
+ (t) => t.status === "pending" && t.kind === kind && t.adapterId === adapterId && t.subject.hash === draft.subject.hash,
272
+ );
273
+ if (existing) {
274
+ if (existing.subject.remoteETag !== draft.subject.remoteETag) {
275
+ existing.subject.remoteETag = draft.subject.remoteETag;
276
+ existing.updatedAt = nowIso();
277
+ writeStore(deckRoot, store);
278
+ }
279
+ return { task: existing, existing: true };
280
+ }
281
+ // A newer ask about the same thing voids the older ones. An identical subject
282
+ // returned above, so every other pending task of this kind asks about content
283
+ // that has moved on — and three live "approve the plan" cards are one question
284
+ // asked three times, which no one can answer three times. The response is left
285
+ // alone (null): superseding is not an answer.
286
+ const stale = store.tasks.filter((t) => t.status === "pending" && t.kind === kind && t.adapterId === adapterId);
287
+ for (const t of stale) {
288
+ t.status = "superseded";
289
+ t.updatedAt = nowIso();
290
+ }
291
+ store.tasks.push(draft);
292
+ writeStore(deckRoot, store);
293
+ return { task: draft, existing: false, superseded: stale.map((t) => t.id) };
294
+ });
295
+ }
296
+
297
+ /**
298
+ * The response transaction. `invoke(task, response)` is the authoritative
299
+ * action (approve the plan, record the comments) and returns `{ evidence }`
300
+ * with the exact content hash; it runs under the deck lock with the durable
301
+ * intent already on disk.
302
+ *
303
+ * Refuses `stale_subject` (expectedHash is not the task's), `validation_failed`
304
+ * (unknown action, malformed body, or an operationId reused with another body).
305
+ * A repeated operationId with the same body answers the recorded result and
306
+ * does not invoke again. `invoke` may answer `{ blocked: { code, message,
307
+ * details } }` instead of evidence when it wrote nothing: that is a refusal
308
+ * with the intent cleared, not a half-done transaction.
309
+ */
310
+ /**
311
+ * What the human said, as one string on the task record: a single `comment`,
312
+ * or a gate's batch of `comments` (objects with text and an anchor, or plain
313
+ * strings) joined one per paragraph — so `tasks get` shows it without the gate's
314
+ * own state file.
315
+ */
316
+ export function commentOf(payload = {}) {
317
+ if (typeof payload?.comment === "string" && payload.comment.trim()) return payload.comment;
318
+ const list = Array.isArray(payload?.comments) ? payload.comments : [];
319
+ const lines = list
320
+ .map((c) => {
321
+ if (typeof c === "string") return c.trim();
322
+ if (!c || typeof c.text !== "string") return "";
323
+ const where = c.scope === "story" ? "Whole story" : c.slideIndex != null ? `Slide ${c.slideIndex}` : c.jsxPath ? String(c.jsxPath).replace(/^slides\//, "").replace(/\.jsx$/, "") : "";
324
+ return (where ? `[${where}] ` : "") + c.text.trim();
325
+ })
326
+ .filter(Boolean);
327
+ return lines.length ? lines.join("\n\n") : null;
328
+ }
329
+
330
+ export async function respondTask(deckRoot, id, request, { invoke, owner, verify } = {}) {
331
+ const req = validateRespondRequest(request);
332
+ if (typeof invoke !== "function") throw new Error("respondTask needs the authoritative invoke");
333
+ const response = {
334
+ operationId: req.operationId,
335
+ action: req.action,
336
+ at: nowIso(),
337
+ actor: req.actor,
338
+ comment: commentOf(req.payload),
339
+ ...(req.selfDeclared === true ? { selfDeclared: true } : {}),
340
+ };
341
+ return runDeckOperation(
342
+ ws(deckRoot),
343
+ { operationId: req.operationId, kind: RESPONSE_KIND, expected: { taskId: id, hash: req.expectedHash }, owner, recover: taskRecoverer(deckRoot, { verify }) },
344
+ async (op) => {
345
+ const store = readStore(deckRoot);
346
+ const task = store.tasks.find((t) => t.id === id);
347
+ // Refusals before the action: nothing ran, so the intent must not outlive
348
+ // this call — returned, not thrown, and rethrown outside the operation.
349
+ if (!task) return { refused: coded("stale_subject", `no task ${id} on this deck`) };
350
+ const recorded = task.response?.operationId === req.operationId ? task.response : null;
351
+ if (recorded) {
352
+ if (recorded.action !== req.action || (recorded.comment ?? null) !== commentOf(req.payload)) {
353
+ return { refused: invalid(`operation ${req.operationId} was already recorded with a different body`) };
354
+ }
355
+ return { task, result: task.evidence, replayed: true };
356
+ }
357
+ if (task.status !== "pending") return { refused: coded("stale_subject", `task ${id} is ${task.status}`) };
358
+ if (task.subject.hash !== req.expectedHash && !(task.subject.followed ?? []).includes(req.expectedHash)) {
359
+ return { refused: coded("stale_subject", "the content changed since this task was requested; reload and decide again", { current: task.subject.hash }) };
360
+ }
361
+ if (!task.actions.includes(req.action)) return { refused: invalid(`${req.action} is not an action of this task (${task.actions.join(", ")})`) };
362
+ op.note({ taskId: id, response });
363
+ const { evidence, blocked } = (await invoke(task, response, { token: op.token })) ?? {};
364
+ // Nothing ran (stale subject, invalid plan, shared approval): refused with the intent cleared.
365
+ if (isObj(blocked)) return { refused: coded(blocked.code ?? "blocked", blocked.message ?? "the action did not run", blocked.details ? { details: blocked.details } : {}) };
366
+ if (!isObj(evidence) || evidence.hash !== task.subject.hash) {
367
+ throw coded("validation_failed", "the action returned no evidence for the exact subject; the task stays pending", { evidence: evidence ?? null });
368
+ }
369
+ complete(task, response, evidence);
370
+ writeStore(deckRoot, store);
371
+ return { task, result: task.evidence, replayed: false };
372
+ },
373
+ ).then((out) => {
374
+ if (out.refused) throw out.refused;
375
+ return out;
376
+ });
377
+ }
378
+
379
+ function setStatus(deckRoot, id, status, owner) {
380
+ return runDeckOperation(ws(deckRoot), { kind: `task-${status}`, owner, recover: taskRecoverer(deckRoot) }, () => {
381
+ const store = readStore(deckRoot);
382
+ const task = store.tasks.find((t) => t.id === id);
383
+ if (!task) throw coded("stale_subject", `no task ${id} on this deck`);
384
+ if (task.status === "pending") {
385
+ task.status = status;
386
+ task.updatedAt = nowIso();
387
+ writeStore(deckRoot, store);
388
+ }
389
+ return task;
390
+ });
391
+ }
392
+
393
+ /**
394
+ * The agent has the answer: the gate command printed it. The receipt is what
395
+ * lets the page say "your agent picked it up" instead of guessing, and what
396
+ * tells a later gate run that an answer was already handed over (a comments
397
+ * round it has seen is not handed over twice). Completed tasks only; idempotent.
398
+ */
399
+ export function markPickedUp(deckRoot, id, { owner } = {}) {
400
+ return runDeckOperation(ws(deckRoot), { kind: "task-picked-up", owner, recover: taskRecoverer(deckRoot) }, () => {
401
+ const store = readStore(deckRoot);
402
+ const task = store.tasks.find((t) => t.id === id);
403
+ if (!task) throw coded("stale_subject", `no task ${id} on this deck`);
404
+ if (task.status === "completed" && !task.pickedUpAt) {
405
+ task.pickedUpAt = nowIso();
406
+ writeStore(deckRoot, store);
407
+ }
408
+ return task;
409
+ });
410
+ }
411
+
412
+ /** The content moved on; the question is void. Pending only — an answer is never un-given. */
413
+ export const supersedeTask = (deckRoot, id, { owner } = {}) => setStatus(deckRoot, id, "superseded", owner);
414
+ export const cancelTask = (deckRoot, id, { owner } = {}) => setStatus(deckRoot, id, "cancelled", owner);
415
+
416
+ /**
417
+ * The plan page saved an edit on top of exactly the version a pending plan
418
+ * task asks about: the person answering is the person editing, so the question
419
+ * follows the edit instead of going stale. Only tasks whose subject is `from`
420
+ * move; a task about any other version is left to supersede as before. The
421
+ * version the agent asked about stays on the task as `subject.requestedHash`.
422
+ */
423
+ export const PLAN_TASK_KINDS = ["plan-edit", "plan-approval"];
424
+ export function followPlanEdit(deckRoot, { from, to }, { owner } = {}) {
425
+ if (!SHA.test(String(from)) || !SHA.test(String(to)) || from === to) return Promise.resolve([]);
426
+ return runDeckOperation(ws(deckRoot), { kind: "task-follow-edit", owner, recover: taskRecoverer(deckRoot) }, () => {
427
+ const store = readStore(deckRoot);
428
+ const moved = store.tasks.filter((t) => t.status === "pending" && PLAN_TASK_KINDS.includes(t.kind) && t.subject.hash === from);
429
+ for (const t of moved) {
430
+ // what the agent asked about, kept once: an answered task whose hash is
431
+ // not its requestedHash tells the agent to re-read the plan before acting
432
+ t.subject.requestedHash ??= from;
433
+ // every version it moved through, so an answer clicked on a card drawn
434
+ // a save ago still lands (it answers for the page's own edits, and nothing else)
435
+ t.subject.followed = [...(t.subject.followed ?? []), from].slice(-50);
436
+ t.subject.hash = to;
437
+ t.updatedAt = nowIso();
438
+ }
439
+ if (moved.length) writeStore(deckRoot, store);
440
+ return moved.map((t) => t.id);
441
+ });
442
+ }
443
+
444
+ // ---------------------------------------------------------------------------
445
+ // wait
446
+ // ---------------------------------------------------------------------------
447
+
448
+ /**
449
+ * `{ task, pending }`: the saved answer first, else a bounded wait for one.
450
+ * In-process writers wake it directly; another process (the daemon while the
451
+ * agent waits, or the reverse) is seen through a watch on `state/`. The wait
452
+ * is advisory: on wake it re-reads the store, never trusts the event.
453
+ */
454
+ export async function waitTask(deckRoot, id, { timeoutMs = 45_000, signal = null } = {}) {
455
+ const check = () => {
456
+ const task = getTask(deckRoot, id);
457
+ if (!task) throw coded("stale_subject", `no task ${id} on this deck`);
458
+ return task;
459
+ };
460
+ const first = check();
461
+ if (first.status !== "pending") return { task: first, pending: false };
462
+ if (timeoutMs <= 0) return { task: first, pending: true };
463
+ const file = tasksPath(deckRoot);
464
+ return new Promise((resolve, reject) => {
465
+ let watcher = null;
466
+ const done = (fn) => {
467
+ clearTimeout(timer);
468
+ notifier.off(file, wake);
469
+ signal?.removeEventListener("abort", onAbort);
470
+ watcher?.close();
471
+ fn();
472
+ };
473
+ const wake = () => {
474
+ let task;
475
+ try {
476
+ task = check();
477
+ } catch (err) {
478
+ return done(() => reject(err));
479
+ }
480
+ if (task.status !== "pending") done(() => resolve({ task, pending: false }));
481
+ };
482
+ // `check()` throws when the deck's task store went away under us (trashed from
483
+ // the page, recovery_required). These two callbacks run OUTSIDE the executor, so
484
+ // the throw would not reject the promise — it would be an uncaughtException and
485
+ // take the daemon down. Settle through the promise like `wake` does.
486
+ const give = (fn) => {
487
+ let task;
488
+ try {
489
+ task = check();
490
+ } catch (err) {
491
+ return done(() => reject(err));
492
+ }
493
+ done(() => fn(task));
494
+ };
495
+ const onAbort = () => give((task) => resolve({ task, pending: true }));
496
+ const timer = setTimeout(() => give((task) => resolve({ task, pending: true })), timeoutMs);
497
+ notifier.on(file, wake);
498
+ signal?.addEventListener("abort", onAbort, { once: true });
499
+ try {
500
+ // ponytail: directory watch (the atomic rename replaces the file's inode); polling fallback if a platform drops events.
501
+ watcher = fs.watch(path.dirname(file), () => wake());
502
+ watcher.on("error", () => {});
503
+ } catch {
504
+ watcher = null;
505
+ }
506
+ });
507
+ }