slidemind 0.3.20 → 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 (185) 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/render.mjs +27 -1
  16. package/src/bridge/client/transport.mjs +499 -23
  17. package/src/bridge/client.mjs +10 -0
  18. package/src/bridge/config.mjs +67 -6
  19. package/src/bridge/engine-download.mjs +0 -10
  20. package/src/bridge/graph-auth.mjs +55 -3
  21. package/src/bridge/image-intent-envelope.mjs +239 -0
  22. package/src/bridge/package-preview.mjs +67 -0
  23. package/src/bridge/sharepoint.mjs +914 -0
  24. package/src/builders/asset-fetch.mjs +203 -0
  25. package/src/builders/block-dividers.mjs +83 -0
  26. package/src/builders/build.mjs +171 -87
  27. package/src/builders/card-height.mjs +294 -0
  28. package/src/builders/chart-hole.mjs +183 -0
  29. package/src/builders/chart-jsx.mjs +410 -0
  30. package/src/builders/chart-labels.mjs +326 -0
  31. package/src/builders/concept-geometry.mjs +370 -0
  32. package/src/builders/concept-intent.mjs +200 -0
  33. package/src/builders/concept-layout.mjs +79 -0
  34. package/src/builders/concept-lines.mjs +509 -0
  35. package/src/builders/concept-lists.mjs +270 -0
  36. package/src/builders/concept-marks.mjs +84 -0
  37. package/src/builders/concept-relationships.mjs +12 -0
  38. package/src/builders/concept-spec.mjs +645 -0
  39. package/src/builders/concept-text.mjs +140 -0
  40. package/src/builders/concept-waves.mjs +57 -0
  41. package/src/builders/copy-fit.mjs +139 -0
  42. package/src/builders/design-tokens.mjs +347 -0
  43. package/src/builders/draft-check.mjs +552 -340
  44. package/src/builders/draft-lines.mjs +369 -0
  45. package/src/builders/edit-route.mjs +29 -0
  46. package/src/builders/figure-sketch.mjs +375 -0
  47. package/src/builders/fold-sets.mjs +639 -0
  48. package/src/builders/gate-server.mjs +9 -4
  49. package/src/builders/gate-session.js +273 -12
  50. package/src/builders/gate-shell.js +1494 -0
  51. package/src/builders/image-intent-check.mjs +308 -0
  52. package/src/builders/image-use-reconcile.mjs +107 -0
  53. package/src/builders/kit-helpers.mjs +121 -0
  54. package/src/builders/layout-names.mjs +32 -0
  55. package/src/builders/legacy-reference-uses.mjs +179 -0
  56. package/src/builders/line-fit.mjs +224 -0
  57. package/src/builders/line-native.mjs +895 -0
  58. package/src/builders/line-relations.mjs +230 -0
  59. package/src/builders/lint-fix.mjs +117 -9
  60. package/src/builders/lint.mjs +25 -20
  61. package/src/builders/list-jsx.mjs +88 -0
  62. package/src/builders/placement.mjs +133 -0
  63. package/src/builders/plan-checks.mjs +135 -0
  64. package/src/builders/plan-comments.mjs +19 -0
  65. package/src/builders/plan-handlers.mjs +95 -0
  66. package/src/builders/plan-markdown-edit.mjs +3 -3
  67. package/src/builders/plan-overlay.js +1530 -1608
  68. package/src/builders/plan.mjs +689 -186
  69. package/src/builders/preset-definitions.json +1 -0
  70. package/src/builders/preset-eval.mjs +244 -0
  71. package/src/builders/preview.html +51 -120
  72. package/src/builders/red-thread-backbone.mjs +135 -36
  73. package/src/builders/repair.mjs +205 -0
  74. package/src/builders/repeat-groups.mjs +540 -0
  75. package/src/builders/resolve-skill.mjs +5 -4
  76. package/src/builders/review-overlay.js +296 -293
  77. package/src/builders/review.mjs +305 -84
  78. package/src/builders/rule-shift.mjs +96 -0
  79. package/src/builders/seat.mjs +354 -0
  80. package/src/builders/settle-driver.mjs +236 -0
  81. package/src/builders/settle.mjs +1448 -0
  82. package/src/builders/slide-prep.mjs +67 -0
  83. package/src/builders/spec-assets.mjs +236 -0
  84. package/src/builders/spec-match.mjs +56 -0
  85. package/src/builders/stack.mjs +413 -0
  86. package/src/builders/storyboard-markdown.mjs +70 -27
  87. package/src/builders/storyboard-schema.mjs +54 -114
  88. package/src/builders/text-blocks.mjs +212 -0
  89. package/src/builders/text-card-fit.mjs +552 -0
  90. package/src/builders/text-stack.mjs +276 -0
  91. package/src/builders/transcribe.mjs +1069 -0
  92. package/src/cli/assets.mjs +164 -0
  93. package/src/cli/common.mjs +173 -12
  94. package/src/cli/concept-spec-support.mjs +76 -0
  95. package/src/cli/concepts.mjs +1002 -103
  96. package/src/cli/deck.mjs +401 -65
  97. package/src/cli/diagnose.mjs +4 -2
  98. package/src/cli/engine-helpers.mjs +17 -10
  99. package/src/cli/engine-results.mjs +76 -3
  100. package/src/cli/help.mjs +232 -28
  101. package/src/cli/install.mjs +9 -0
  102. package/src/cli/maintenance.mjs +104 -0
  103. package/src/cli/read.mjs +2 -1
  104. package/src/cli/ready.mjs +6 -3
  105. package/src/cli/record.mjs +522 -0
  106. package/src/cli/remote.mjs +818 -0
  107. package/src/cli/revision.mjs +686 -0
  108. package/src/cli/search.mjs +30 -11
  109. package/src/cli/session.mjs +56 -13
  110. package/src/cli/setup.mjs +46 -2
  111. package/src/cli/status.mjs +142 -33
  112. package/src/cli/tasks.mjs +246 -0
  113. package/src/cli/transcribe.mjs +854 -0
  114. package/src/cli/workspace-ui.mjs +260 -0
  115. package/src/cli/workspace.mjs +212 -34
  116. package/src/install-channel.mjs +64 -7
  117. package/src/lib/adopt.mjs +346 -25
  118. package/src/lib/concept-spec-binding.mjs +96 -0
  119. package/src/lib/conversions.mjs +70 -6
  120. package/src/lib/convert-folded.mjs +107 -0
  121. package/src/lib/deck-paths.mjs +418 -0
  122. package/src/lib/durable-ids.mjs +47 -0
  123. package/src/lib/edit-operations.mjs +316 -0
  124. package/src/lib/edit-state.mjs +1244 -0
  125. package/src/lib/edit-transitions.mjs +120 -0
  126. package/src/lib/extensions.mjs +501 -0
  127. package/src/lib/image-assets.mjs +465 -0
  128. package/src/lib/image-derivatives.mjs +234 -0
  129. package/src/lib/image-intent-fingerprint.mjs +70 -0
  130. package/src/lib/image-intent-tags.mjs +176 -0
  131. package/src/lib/image-intent.mjs +802 -0
  132. package/src/lib/image-use-overrides.mjs +279 -0
  133. package/src/lib/inline-components.mjs +613 -0
  134. package/src/lib/lint-converted.mjs +68 -4
  135. package/src/lib/merge-json.mjs +163 -0
  136. package/src/lib/overwrite-guard.mjs +3 -3
  137. package/src/lib/presentation-context.mjs +531 -0
  138. package/src/lib/preservation-tags.mjs +157 -0
  139. package/src/lib/preservation-verdict.mjs +1342 -0
  140. package/src/lib/publish.mjs +1557 -0
  141. package/src/lib/rebase.mjs +945 -0
  142. package/src/lib/reconcile-ack.mjs +4 -6
  143. package/src/lib/reconcile.mjs +62 -24
  144. package/src/lib/record-client.mjs +175 -0
  145. package/src/lib/record-state.mjs +151 -0
  146. package/src/lib/remote.mjs +661 -0
  147. package/src/lib/revision.mjs +1210 -0
  148. package/src/lib/slide-guids.mjs +4 -6
  149. package/src/lib/slide-preservation.mjs +423 -0
  150. package/src/lib/slide-selection.mjs +9 -0
  151. package/src/lib/workspace-transaction.mjs +117 -0
  152. package/src/pptx/content-digest.mjs +334 -0
  153. package/src/pptx/deck-assemble.mjs +1278 -0
  154. package/src/pptx/fill/engine.mjs +2 -3
  155. package/src/pptx/fill/freeform.mjs +6 -3
  156. package/src/pptx/ooxml.mjs +20 -0
  157. package/src/pptx/package-evidence.mjs +383 -0
  158. package/src/pptx/tags.mjs +163 -11
  159. package/src/update-check.mjs +22 -4
  160. package/src/workspace/adapters.mjs +222 -0
  161. package/src/workspace/auth.mjs +256 -0
  162. package/src/workspace/autostart.mjs +204 -0
  163. package/src/workspace/daemon.mjs +360 -0
  164. package/src/workspace/discovery.mjs +180 -0
  165. package/src/workspace/edit.mjs +167 -0
  166. package/src/workspace/events.mjs +52 -0
  167. package/src/workspace/gate-client.mjs +290 -0
  168. package/src/workspace/handback.mjs +73 -0
  169. package/src/workspace/lifecycle.mjs +635 -0
  170. package/src/workspace/operations.mjs +200 -0
  171. package/src/workspace/pick-folder.mjs +152 -0
  172. package/src/workspace/registry.mjs +359 -0
  173. package/src/workspace/remote-links.mjs +397 -0
  174. package/src/workspace/remote-status.mjs +359 -0
  175. package/src/workspace/routes.mjs +1331 -0
  176. package/src/workspace/server.mjs +159 -0
  177. package/src/workspace/sync.mjs +1119 -0
  178. package/src/workspace/task-actions.mjs +215 -0
  179. package/src/workspace/tasks.mjs +507 -0
  180. package/src/workspace/ui/app.js +1886 -0
  181. package/src/workspace/ui/index.html +141 -0
  182. package/src/workspace/ui/plan-bridge.js +120 -0
  183. package/src/workspace/ui/sharepoint.svg +59 -0
  184. package/src/workspace/ui/styles.css +423 -0
  185. package/src/builders/visual-families.mjs +0 -46
@@ -5,6 +5,24 @@ import { fail, resolveDeckArg, SRC_DIR } from "./common.mjs";
5
5
  import { readDesignName, readThemeColors, designRows } from "./designs.mjs";
6
6
  import { searchFailureHint, writeEmptySearchDiagnostic, writeSearchGrid, resolveEngineRouting, deckBrandId, failOnEngineBlocker, paneRelayForLoginRequired } from "./engine-helpers.mjs";
7
7
 
8
+ // The live engine catalog, or null when the caller falls back to the bundle:
9
+ // this add-in has no catalog route (a 404). A catalog that wants a Microsoft
10
+ // 365 sign-in while a pane is open falls back too, naming that pane — it
11
+ // refuses a token-less CLI even then, and the search itself relays through the
12
+ // pane (paneRelayForLoginRequired). Anything else — a 429, a timeout, an
13
+ // outage — is not "no engines": it surfaces as itself (x71/x72: a rate-limited
14
+ // read printed "no image engines" and the agent fabricated icons).
15
+ async function liveCatalog(client, opts, to) {
16
+ try {
17
+ return await client.listEngines(opts);
18
+ } catch (err) {
19
+ if (err?.status === 404) return null;
20
+ const relay = await paneRelayForLoginRequired(client, err, { to });
21
+ if (relay) return { engines: [], pane: relay.entry };
22
+ return failOnEngineBlocker(client, err, { to });
23
+ }
24
+ }
25
+
8
26
  export async function cmdSearch(rest) {
9
27
  let asJson = false;
10
28
  let to = null;
@@ -111,7 +129,7 @@ export async function cmdSearch(rest) {
111
129
  !bundle.isSearchScopeId(engineId) &&
112
130
  !/^[0-9a-f-]{8,}$/i.test(engineId)
113
131
  ) {
114
- const live = await client.listEngines({ backendURL }).catch(() => null);
132
+ const live = await liveCatalog(client, { backendURL }, to);
115
133
  const wanted = engineId.toLowerCase();
116
134
  const ids = new Set(
117
135
  (live?.engines ?? [])
@@ -140,26 +158,27 @@ export async function cmdSearch(rest) {
140
158
  // Imagery: resolve a real image engine (a scope is not a brand image
141
159
  // source). Ask the LIVE catalog first — the same one the add-in's own
142
160
  // design agent reads — and fall back to the materialized bundle only when
143
- // the host can't answer. Bundle-first would be stale: engine ids can be
144
- // retired server-side, and a dead id surfaces as "Engine not found".
145
- const live = await client
146
- .listEngines({ subtype: "image", backendURL })
147
- .catch(() => ({ engines: [] }));
161
+ // the add-in has no catalog route. Bundle-first would be stale: engine
162
+ // ids can be retired server-side, and a dead id surfaces as "Engine not
163
+ // found".
164
+ const live = (await liveCatalog(client, { subtype: "image", backendURL }, to)) ?? { engines: [] };
148
165
  let pick = bundle.pickImageEngine(live.engines);
149
166
  if (!pick) {
150
167
  // Best-effort: a bundle that can't be materialized (e.g. an incomplete
151
168
  // one) must not replace the actionable "wire an image engine" message
152
169
  // below with a bundle error — the live catalog is the real answer here.
153
- // Pane path: fetch by the window's presId. Direct path: there is no
154
- // window, so only a bound deck (--deck) can name the design; without one
155
- // there is nothing to fall back to and the live catalog's answer stands.
170
+ // Pane path: fetch by the window's presId. Direct path: a bound deck
171
+ // (--deck) names the design, else the open pane a sign-in-less search
172
+ // relays through; without either there is nothing to fall back to and
173
+ // the live catalog's answer stands.
156
174
  const brandId = presId
157
175
  ? null
158
176
  : await deckBrandId(deck ? resolveDeckArg(deck) : null);
177
+ const windowId = presId ?? (brandId ? null : (live.pane?.presId ?? null));
159
178
  const mat =
160
- presId || brandId
179
+ windowId || brandId
161
180
  ? await bundle
162
- .materializeBundle(presId, { backendURL, brandId })
181
+ .materializeBundle(windowId, { backendURL, brandId })
163
182
  .catch(() => null)
164
183
  : null;
165
184
  if (mat) pick = bundle.pickImageEngine(bundle.readEnginesFromDir(mat.dir));
@@ -65,7 +65,11 @@ export async function cmdLogin(rest) {
65
65
  `Signed in to Microsoft 365 as ${res.username ?? "your account"}${envPhrase(target.label, "for")}.\n` +
66
66
  "SharePoint-backed slides and images now download with no PowerPoint window open.\n",
67
67
  );
68
- const others = sessions.filter((s) => s.backendURL !== target.backendURL);
68
+ // Only the environments that actually still lack a Graph session — listing a
69
+ // backend the user signed in to earlier reads as "your sign-in was lost".
70
+ const others = sessions.filter(
71
+ (s) => s.backendURL !== target.backendURL && !graph.hasGraphSession(s.backendURL),
72
+ );
69
73
  if (others.length > 0) {
70
74
  process.stdout.write(
71
75
  `Still signed out: ${others.map((s) => s.label).join(", ")} ` +
@@ -74,19 +78,29 @@ export async function cmdLogin(rest) {
74
78
  }
75
79
  }
76
80
 
77
- export async function cmdWhoami() {
81
+ export async function cmdWhoami(rest = []) {
82
+ const asJson = rest.includes("--json");
83
+ const stray = rest.find((a) => a.startsWith("-") && a !== "--json");
84
+ if (stray) fail(`unexpected argument: ${stray}\nusage: slidemind whoami [--json]`);
78
85
  const cfg = await loadBridgeModule("config");
79
86
  const sessions = cfg.readSessions();
80
87
  if (sessions.length === 0) {
88
+ // An agent asked a question and must get an answer it can parse — "no
89
+ // environments" is one, a bare stderr line is not.
90
+ if (asJson) {
91
+ process.stdout.write(`${JSON.stringify({ environments: [] }, null, 2)}\n`);
92
+ return;
93
+ }
81
94
  process.stderr.write(`${NOT_CONNECTED_LINE}\n`);
82
95
  return;
83
96
  }
84
97
  const { httpJson } = await loadBridgeModule("http-json");
85
98
  const graph = await loadBridgeModule("graph-auth");
86
99
  const def = cfg.getDefaultBackend();
87
- const lines = await Promise.all(
100
+ const envs = await Promise.all(
88
101
  sessions.map(async (s) => {
89
- const marker = s.backendURL === def ? "*" : " ";
102
+ let oid = null;
103
+ let tid = null;
90
104
  let detail;
91
105
  try {
92
106
  const res = await httpJson(
@@ -94,21 +108,50 @@ export async function cmdWhoami() {
94
108
  `${s.backendURL}/api/cli/whoami`,
95
109
  { token: s.sessionToken },
96
110
  );
97
- detail =
98
- res.status === 200 && res.json
99
- ? `user ${res.json.oid} · tenant ${res.json.tid ?? "(n/a)"}`
100
- : `session invalid (${res.status}) — authorize again`;
111
+ if (res.status === 200 && res.json) {
112
+ oid = res.json.oid ?? null;
113
+ tid = res.json.tid ?? null;
114
+ detail = `user ${oid} · tenant ${tid ?? "(n/a)"}`;
115
+ } else {
116
+ detail = `session invalid (${res.status}) — authorize again`;
117
+ }
101
118
  } catch {
102
119
  detail = "unreachable — check your connection or authorize again";
103
120
  }
104
121
  // Storage-only — no probe. The Graph sign-in is a SECOND identity, so it
105
- // is reported next to the session rather than folded into it.
106
- const graphLine = graph.hasGraphSession(s.backendURL)
107
- ? "Microsoft 365: signed in"
108
- : "Microsoft 365: not signed in — needed only for window-free SharePoint downloads (`slidemind login`)";
109
- return `${marker} ${s.label}\n ${detail}\n ${graphLine}`;
122
+ // is reported next to the session rather than folded into it, and the
123
+ // account name is whatever `login` wrote down at sign-in (records written
124
+ // before that field existed are signed in with no name to show).
125
+ return {
126
+ label: s.label,
127
+ default: s.backendURL === def,
128
+ oid,
129
+ tid,
130
+ microsoft365: {
131
+ signedIn: graph.hasGraphSession(s.backendURL),
132
+ username: s.msGraphUsername ?? null,
133
+ },
134
+ detail,
135
+ };
110
136
  }),
111
137
  );
138
+ if (asJson) {
139
+ process.stdout.write(
140
+ `${JSON.stringify(
141
+ { environments: envs.map(({ detail, ...env }) => env) },
142
+ null,
143
+ 2,
144
+ )}\n`,
145
+ );
146
+ return;
147
+ }
148
+ const lines = envs.map((env) => {
149
+ const marker = env.default ? "*" : " ";
150
+ const graphLine = env.microsoft365.signedIn
151
+ ? `Microsoft 365: signed in${env.microsoft365.username ? ` as ${env.microsoft365.username}` : ""}`
152
+ : "Microsoft 365: not signed in — needed only for window-free SharePoint downloads (`slidemind login`)";
153
+ return `${marker} ${env.label}\n ${env.detail}\n ${graphLine}`;
154
+ });
112
155
  process.stdout.write(
113
156
  `Connected environments (${sessions.length}):\n${lines.join("\n")}\n`,
114
157
  );
package/src/cli/setup.mjs CHANGED
@@ -174,7 +174,16 @@ export async function cmdSetup(rest) {
174
174
  bundleMod.clearBundleCache();
175
175
  }
176
176
 
177
- // --- 4. Microsoft 365 sign-in (best-effort) -----------------------------
177
+ // --- 4. the workspace: running now, and at every login -------------------
178
+ // Where the user answers the agent's questions (doc 21). It must be running
179
+ // BEFORE an agent asks: inside Codex's sandbox an agent can neither start it
180
+ // nor keep a detached process, so this unsandboxed moment — the user pasting
181
+ // the setup line — is when it starts, and the login item keeps it there.
182
+ // Best-effort: a failure here never fails setup (any unsandboxed CLI run
183
+ // starts it again). SLIDEMIND_NO_AUTOSTART=1 leaves it alone (tests, CI).
184
+ const workspace = await setUpWorkspace({ ui, globalScript: installed });
185
+
186
+ // --- 5. Microsoft 365 sign-in (best-effort) -----------------------------
178
187
  // The pasted-claim moment is the one time the user is reliably at the
179
188
  // keyboard in consent headspace, so offer the browser sign-in NOW and no
180
189
  // later command has to interrupt a task for it. Strictly best-effort and
@@ -238,6 +247,7 @@ export async function cmdSetup(rest) {
238
247
  // Present-but-plugin-served harnesses, so an agent can tell "nothing
239
248
  // deployed because nothing is installed" from "nothing needed".
240
249
  skippedPlugins,
250
+ workspace,
241
251
  nextAction: "slidemind ready",
242
252
  // Only on the unhappy path: the session IS saved, so `ok` stays true,
243
253
  // but an agent needs to know the binary did not land.
@@ -271,7 +281,7 @@ export async function cmdSetup(rest) {
271
281
  return;
272
282
  }
273
283
 
274
- // --- 5. finish the session the user actually came for -------------------
284
+ // --- 6. finish the session the user actually came for -------------------
275
285
  // `ready` exits non-zero for every legitimate not-yet blocker (PowerPoint
276
286
  // closed, no design activated). Setup already succeeded at that point, so its
277
287
  // blocker is information, not a failure — run it for the message and the
@@ -280,3 +290,37 @@ export async function cmdSetup(rest) {
280
290
  await spawnStatus(process.execPath, [BIN_PATH, "ready"]);
281
291
  }
282
292
  }
293
+
294
+ /**
295
+ * Start the workspace and register its login item. Resolves `{ running, url,
296
+ * atLogin }` for the --json payload; never throws.
297
+ */
298
+ async function setUpWorkspace({ ui, globalScript }) {
299
+ if (process.env.SLIDEMIND_NO_AUTOSTART === "1") return { running: null, url: null, atLogin: "skipped" };
300
+ await ui.section("workspace", "where you answer your agent");
301
+ const { ensureRunning } = await import("../workspace/lifecycle.mjs");
302
+ const auto = await import("../workspace/autostart.mjs");
303
+ const out = { running: false, url: null, atLogin: "unsupported" };
304
+ // the login item needs paths that outlive npx's cache and a node upgrade
305
+ const scriptPath = auto.stableScriptPath({ running: globalScript ? null : BIN_PATH });
306
+ if (auto.autostartPath()) {
307
+ if (!scriptPath) {
308
+ out.atLogin = "skipped";
309
+ ui.step({ status: "warn", label: "at login", value: "skipped", note: "no permanent slidemind install to start it from" });
310
+ } else {
311
+ const env = { PATH: process.env.PATH, SLIDEMIND_NO_UPDATE_CHECK: "1", ...(process.env.XDG_CONFIG_HOME ? { XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME } : {}) };
312
+ const r = auto.installAutostart({ nodePath: auto.stableNodePath(), scriptPath, env });
313
+ out.atLogin = r.installed ? "registered" : "failed";
314
+ ui.step(r.installed ? { label: "at login", value: "starts by itself" } : { status: "warn", label: "at login", value: "not registered", note: r.reason ?? "" });
315
+ }
316
+ }
317
+ try {
318
+ const run = await ensureRunning({ note: () => {} });
319
+ out.running = true;
320
+ out.url = run.url;
321
+ ui.step({ label: "workspace", value: "running" });
322
+ } catch (err) {
323
+ ui.step({ status: "warn", label: "workspace", value: "not started", note: err?.message ?? String(err) });
324
+ }
325
+ return out;
326
+ }
@@ -3,21 +3,51 @@
3
3
  // stand and what is the next command. `ready <deck>` prints it (the skill
4
4
  // already names `ready` as the first command after any continuation), so the
5
5
  // stage → re-read table that used to live in the skill's prose is a line of
6
- // output instead. Pure disk: no bridge call, no bundle read.
6
+ // output instead. Pure disk: no bridge call (the materialized bundle's layout
7
+ // jobs are read only to tell frame slides from body slides).
7
8
  import fs from "node:fs";
8
9
  import path from "node:path";
9
10
  import { pathToFileURL } from "node:url";
10
11
  import { BUILDERS_DIR } from "./common.mjs";
12
+ import { deckRoot, deckSlug } from "../lib/deck-paths.mjs";
13
+ import { revisionStatus } from "../lib/revision.mjs";
14
+ import { remoteHumanLine, remoteReadiness, savedDestination } from "../lib/remote.mjs";
15
+ import { listTasks } from "../workspace/tasks.mjs";
16
+ import { receiveCommand } from "../workspace/handback.mjs";
17
+
18
+ const GATE_COMMAND = { "plan-approval": "plan", "preview-review": "review" };
19
+
20
+ /**
21
+ * The workspace's side of the two gates, read from the deck's task store: an
22
+ * answer the human gave that no agent has received yet (it wins — the gate
23
+ * command hands it over), else a question still waiting for the human. Null
24
+ * when the deck has neither, or no readable store.
25
+ */
26
+ export function workspaceGate(deckDir) {
27
+ let tasks;
28
+ try {
29
+ tasks = listTasks(deckRoot(deckDir));
30
+ } catch {
31
+ return null;
32
+ }
33
+ const gates = tasks.filter((t) => GATE_COMMAND[t.kind]);
34
+ const unreceived = gates
35
+ .filter((t) => t.status === "completed" && !t.pickedUpAt)
36
+ .sort((a, b) => String(b.response?.at ?? "").localeCompare(String(a.response?.at ?? "")))[0];
37
+ if (unreceived) return { kind: unreceived.kind, state: "answered", action: unreceived.response?.action ?? null, at: unreceived.response?.at ?? null, taskId: unreceived.id, adapterId: unreceived.adapterId ?? null };
38
+ const pending = gates.find((t) => t.status === "pending");
39
+ return pending ? { kind: pending.kind, state: "asked", action: null, at: pending.createdAt, taskId: pending.id, adapterId: pending.adapterId ?? null } : null;
40
+ }
11
41
 
12
42
  const load = (name) => import(pathToFileURL(path.join(BUILDERS_DIR, name)).href);
13
43
 
14
44
  export async function deckStage(deckDir) {
15
- const slug = path.basename(deckDir);
16
- const [plan, review, check, families] = await Promise.all([
45
+ const slug = deckSlug(deckDir);
46
+ const [plan, review, check, conceptWaves] = await Promise.all([
17
47
  load("plan.mjs"),
18
48
  load("review.mjs"),
19
49
  load("draft-check.mjs"),
20
- load("visual-families.mjs"),
50
+ load("concept-waves.mjs"),
21
51
  ]);
22
52
  const slidesDir = path.join(deckDir, "slides");
23
53
  const files = fs.existsSync(slidesDir) ? fs.readdirSync(slidesDir).sort() : [];
@@ -29,7 +59,7 @@ export async function deckStage(deckDir) {
29
59
  const planPath = path.join(deckDir, "plan.md");
30
60
  const planExists = fs.existsSync(planPath);
31
61
  const planMarkdown = planExists ? fs.readFileSync(planPath, "utf8") : "";
32
- const planned = planExists ? [...families.plannedSlideNumbers(planMarkdown)].sort((a, b) => a - b) : [];
62
+ const planned = planExists ? [...conceptWaves.plannedSlideNumbers(planMarkdown)].sort((a, b) => a - b) : [];
33
63
  const planState = plan.loadPlanState(deckDir);
34
64
  const planApproved = planExists && plan.requirePlanApproved(deckDir) === null;
35
65
 
@@ -37,82 +67,161 @@ export async function deckStage(deckDir) {
37
67
  const concepts = fs.existsSync(conceptsDir)
38
68
  ? fs.readdirSync(conceptsDir).filter((f) => /^concept-slide-\d+\.png$/.test(f)).length
39
69
  : 0;
40
- const assets = check.assetPlanSummary(deckDir);
41
70
 
42
71
  const record = check.loadDraftCheck(deckDir);
43
72
  const draftFresh =
44
73
  Boolean(record?.slides) &&
45
74
  jsx.every((f) => record.slides[f] && record.slides[f].hash === check.hashFile(path.join(slidesDir, f)));
46
75
  const standing = check.draftCheckBlocker(deckDir) ? Object.values(record?.slides ?? {}).reduce((n, e) => n + (e.defects?.length ?? 0), 0) : 0;
47
- const selfCheck = check.verdictBlocker(deckDir) ? "incomplete" : "ok";
48
76
 
49
77
  const reviewState = review.loadReviewState(deckDir);
78
+ // Passed once is passed: a deck edited after its approval goes to deliver,
79
+ // never back to the review gate.
50
80
  const reviewCurrent = review.requireReviewApproved(deckDir) === null;
51
81
 
52
- const missing = planned.filter((n) => !authored.has(n));
82
+ // An ADOPTED deck already exists. It has no storyboard to approve, no preview
83
+ // to approve, nothing to concept and nothing missing to author — its route is
84
+ // edit → draft check → deliver, and its own state is the accepted base, not plan.md.
85
+ const revision = revisionStatus(deckDir);
86
+ const remote = remoteReadiness(deckDir, revisionStatus);
87
+ // A folder the user connected in the workspace before any publish: the next
88
+ // build creates the file there (build-pptx publishes a connected deck itself).
89
+ const folder = remote ? null : savedDestination(deckDir);
90
+ const intoFolder = folder ? `creates ${folder.name} in the SharePoint folder the user connected` : null;
91
+ const editRoute = plan.isEditRouteDeck(deckDir);
92
+
93
+ const missing = editRoute ? [] : planned.filter((n) => !authored.has(n));
94
+ // Body slides owing their content draft, before any concept or slide exists.
95
+ const owed = !editRoute && planApproved && concepts === 0 && authored.size === 0 ? plan.slidesWithoutContent(deckDir, planMarkdown) : [];
53
96
  let name;
54
97
  let next;
55
98
  // No plan.md and no slides: nothing exists yet. No plan.md but slides: an
56
99
  // adopted or hand-made deck on the edit route — no plan, no concepts.
57
- if (authored.size === 0 && (!planExists || planned.length === 0)) {
100
+ if (editRoute && revision.conflicts > 0) {
101
+ name = "reconcile";
102
+ next = `\`slidemind rebase ${slug} --dry-run\` to see the ${revision.conflicts} open conflict(s), then resolve them`;
103
+ } else if (editRoute && (!draftFresh || standing > 0)) {
104
+ name = "draft check";
105
+ next = revision.localDirty
106
+ ? `\`slidemind view-draft ${slug}\` (${revision.dirtySlides.length} slide(s) changed since the accepted base)`
107
+ : `\`slidemind view-draft ${slug}\``;
108
+ } else if (editRoute) {
109
+ name = "deliver";
110
+ // On a remote-bound deck `revision accept` REFUSES (only a confirmed publish
111
+ // receipt may move that base), so naming it here would send the agent into a
112
+ // guaranteed error. Publishing is the acceptance door there, and a build
113
+ // walks through it itself; a candidate still standing is one whose publish
114
+ // did not save (or a `--no-publish` build).
115
+ next = remote
116
+ ? revision.candidate && !revision.candidate.stale
117
+ ? `\`slidemind publish ${slug}\` — it reconciles first and replaces ${remote.name ?? "the bound file"} only if nobody saved meanwhile`
118
+ : `\`slidemind build-pptx ${slug}\` — it publishes to ${remote.name ?? "the bound file"} too`
119
+ : folder
120
+ ? revision.candidate && !revision.candidate.stale
121
+ ? `\`slidemind publish ${slug}\` — it ${intoFolder}`
122
+ : `\`slidemind build-pptx ${slug}\` — it publishes too: ${intoFolder}`
123
+ : revision.candidate && !revision.candidate.stale
124
+ ? `\`slidemind revision accept ${slug} --candidate ${revision.candidate.candidateId}\` to make that build the deck's base, or \`slidemind push ${slug}\``
125
+ : `\`slidemind build-pptx ${slug}\` (writes a candidate; accepting it is a separate, deliberate step)`;
126
+ } else if (authored.size === 0 && (!planExists || planned.length === 0)) {
58
127
  name = "intake";
59
128
  next = `write ${slug}/plan.md (## Red thread + ## Per-slide storyboards), then \`slidemind plan ${slug}\``;
60
129
  } else if (planExists && !planApproved) {
61
130
  name = "plan gate";
62
131
  next = planState.decision === "comments"
63
- ? `apply the comments in ${slug}/plan-review.json to plan.md, then \`slidemind plan ${slug}\``
132
+ ? `apply the comments in ${slug}/.slidemind/state/plan-review.json to plan.md, then \`slidemind plan ${slug}\``
64
133
  : `\`slidemind plan ${slug}\` (wait for DECISION: approved)`;
134
+ } else if (owed.length > 0) {
135
+ // A deck continued from an approved plan never saw the approval's "Next:"
136
+ // line; without this stage it was sent straight into the concepts refusal.
137
+ name = "content draft";
138
+ next = `write ${plan.CONTENT_DRAFT} in plan.md (slide${owed.length === 1 ? "" : "s"} ${owed.join(", ")}; no new sign-off needed), then \`slidemind concepts ${slug}\``;
65
139
  } else if (planExists && concepts === 0 && authored.size === 0) {
66
140
  name = "concepts";
67
- next = `\`slidemind concepts ${slug}\`, then Read every concepts/concept-slide-NN.png and fill asset-plan.md`;
68
- } else if (assets && (assets.unfilled > 0 || assets.iconsResolved < assets.icons) && missing.length > 0) {
69
- name = "sourcing";
70
- next = assets.unfilled > 0
71
- ? `fill the regions/organisms/connectors/icons rows in ${slug}/asset-plan.md from the concept PNGs (${assets.unfilled} slide section${assets.unfilled === 1 ? "" : "s"} still empty)`
72
- : `search + download the ${assets.icons - assets.iconsResolved} unresolved icon row${assets.icons - assets.iconsResolved === 1 ? "" : "s"} in ${slug}/asset-plan.md (\`slidemind search\` → \`download-result\`), append each file to its row`;
141
+ next = `\`slidemind concepts ${slug}\`, then \`slidemind transcribe ${slug}\` and compare concept/draft pairs`;
73
142
  } else if (missing.length > 0) {
74
143
  name = "authoring";
75
- next = `Read concepts/concept-slide-${String(missing[0]).padStart(2, "0")}.png (batches of ≤3), then write slides/${missing.map((n) => `${String(n).padStart(2, "0")}-*.jsx`).join(", ")}; then \`slidemind view-draft ${slug}\``;
76
- } else if (!draftFresh || standing > 0 || selfCheck !== "ok") {
144
+ next = `\`slidemind transcribe ${slug}\`, complete frame or missing-spec slides from the plan, then \`slidemind view-draft ${slug}\` and compare concept/draft pairs`;
145
+ } else if (!draftFresh || standing > 0) {
77
146
  name = "draft check";
78
147
  next = !draftFresh
79
148
  ? `\`slidemind view-draft ${slug}\` (slides changed since the last check${record ? "" : ", or never checked"})`
80
- : standing > 0
81
- ? `fix the ${standing} measured defect${standing === 1 ? "" : "s"} in the JSX, then \`slidemind view-draft ${slug}\``
82
- : `tick every organism under \`draft-check:\` in ${slug}/asset-plan.md (concept vs .draft-preview pair), then \`slidemind review ${slug}\``;
149
+ : `fix the ${standing} measured defect${standing === 1 ? "" : "s"} in the JSX, then \`slidemind view-draft ${slug}\``;
83
150
  } else if (!reviewCurrent) {
84
151
  name = "review";
85
152
  next = reviewState.decision === "comments"
86
- ? `edit the files cited in ${slug}/review.json, \`slidemind view-draft ${slug}\`, then \`slidemind review ${slug}\``
153
+ ? `edit the files cited in ${slug}/.slidemind/state/review.json, \`slidemind view-draft ${slug}\`, then \`slidemind review ${slug}\``
87
154
  : `\`slidemind review ${slug}\` (wait for DECISION: approved)`;
88
155
  } else {
89
156
  name = "deliver";
90
- next = `\`slidemind push ${slug}\` (into PowerPoint) or \`slidemind build-pptx ${slug}\` (to a file) — whichever target was agreed at intake; then stop`;
157
+ // A deck bound to a SharePoint item has ONE delivery: the file it came from.
158
+ // Naming push/build-pptx there would send the agent to produce a local .pptx
159
+ // nobody is waiting for while the colleague's copy stays untouched.
160
+ next = remote
161
+ ? `\`slidemind build-pptx ${slug}\` — it publishes too: pulls, reconciles and replaces ${remote.name ?? "the bound file"} only if nobody saved meanwhile; then stop`
162
+ : folder
163
+ ? `\`slidemind build-pptx ${slug}\` — it publishes too: ${intoFolder}; then stop`
164
+ : `\`slidemind push ${slug}\` (into PowerPoint) or \`slidemind build-pptx ${slug}\` (to a file) — whichever target was agreed at intake; then stop`;
165
+ }
166
+
167
+ // The human answered in the workspace: whatever the stage, the next step is the
168
+ // command that receives their answer (the one their pasted message names). A
169
+ // question still open keeps its stage: the agent's turn ends with its SAY line.
170
+ const gate = workspaceGate(deckDir);
171
+ if (gate?.state === "answered") {
172
+ const what = gate.action === "approve" ? "approved" : "sent comments";
173
+ const receive = receiveCommand({ kind: gate.kind, adapterId: gate.adapterId, deckPath: deckRoot(deckDir), taskId: gate.taskId });
174
+ next = `\`${receive}\` — the user ${what} on the ${gate.kind === "plan-approval" ? "plan" : "preview"} in the workspace; it prints their answer`;
175
+ } else if (gate?.state === "asked" && ((gate.kind === "plan-approval" && name === "plan gate") || (gate.kind === "preview-review" && name === "review"))) {
176
+ next = `your question is waiting for the user in the workspace — \`slidemind ${GATE_COMMAND[gate.kind]} ${slug}\` prints its link and SAY line again; end your turn with that line, do not wait`;
91
177
  }
92
178
 
93
179
  return {
94
180
  name,
95
181
  next,
182
+ ...(gate ? { gate } : {}),
96
183
  plan: { exists: planExists, approved: planApproved, decision: planState.decision ?? null, planned: planned.length },
97
184
  concepts,
98
- assets,
99
185
  slides: { jsx: jsx.length, native: native.length, missing },
100
- draftCheck: { ran: Boolean(record), fresh: draftFresh, standingDefects: standing, selfCheck },
101
- review: { decision: reviewState.decision ?? null, current: reviewCurrent },
186
+ draftCheck: { ran: Boolean(record), fresh: draftFresh, standingDefects: standing },
187
+ // current: delivery may proceed; approved: a human actually approved (an
188
+ // adopted deck is current without ever being approved — it needs no review)
189
+ review: { decision: reviewState.decision ?? null, current: reviewCurrent, approved: Boolean(reviewState.approval) },
190
+ route: editRoute ? "edit" : "author",
191
+ // The accepted base, what has changed against it, the candidate a build
192
+ // proposed and anything staged or conflicted — the whole local revision
193
+ // picture, in the one payload an agent already reads after a summarization.
194
+ revision,
195
+ // The remote half, read from disk. Null for a deck that publishes nowhere.
196
+ // No network call: `ready` must not become a SharePoint round trip, so this
197
+ // reports the last SUCCESSFUL observation and when it happened rather than
198
+ // claiming the deck is current.
199
+ ...(remote ? { remote } : {}),
102
200
  };
103
201
  }
104
202
 
105
203
  export function stageHumanLines(stage) {
106
- const a = stage.assets;
107
204
  const parts = [
108
205
  `plan: ${!stage.plan.exists ? "none" : stage.plan.approved ? "approved" : stage.plan.decision ?? "not gated"}`,
109
206
  `concepts: ${stage.concepts}/${stage.plan.planned}`,
110
- a
111
- ? `asset plan: ${a.unfilled > 0 ? `${a.unfilled} section${a.unfilled === 1 ? "" : "s"} empty` : `${a.iconsResolved}/${a.icons} icon rows resolved`}`
112
- : "asset plan: none",
113
207
  `slides: ${stage.slides.jsx + stage.slides.native}/${stage.plan.planned}${stage.slides.native ? ` (${stage.slides.native} native)` : ""}`,
114
- `draft check: ${!stage.draftCheck.ran ? "not run" : !stage.draftCheck.fresh ? "stale" : stage.draftCheck.standingDefects > 0 ? `${stage.draftCheck.standingDefects} defect${stage.draftCheck.standingDefects === 1 ? "" : "s"} standing` : stage.draftCheck.selfCheck === "ok" ? "clean" : "self-check incomplete"}`,
115
- `review: ${stage.review.decision ? `${stage.review.decision}${stage.review.current ? "" : " (stale)"}` : "none"}`,
208
+ `draft check: ${!stage.draftCheck.ran ? "not run" : !stage.draftCheck.fresh ? "stale" : stage.draftCheck.standingDefects > 0 ? `${stage.draftCheck.standingDefects} defect${stage.draftCheck.standingDefects === 1 ? "" : "s"} standing` : "clean"}`,
209
+ `review: ${stage.review.approved ? "approved" : stage.review.current ? "not needed" : stage.review.decision ?? "none"}`,
116
210
  ];
117
- return ` stage: ${stage.name} — ${parts.join(" · ")}\n next: ${stage.next}\n`;
211
+ // An adopted deck's plan/concepts lines say nothing — it has none.
212
+ // What matters there is what changed since the file it was adopted from.
213
+ const r = stage.revision;
214
+ const lines = stage.route === "edit"
215
+ ? [
216
+ `base: ${r.base ? `${r.base.sha256.slice(0, 8)} (${r.base.slideCount} slides, gen ${r.generation})` : "none"}`,
217
+ `local: ${r.localDirty ? `${r.dirtySlides.length} slide(s) changed` : "clean"}${r.designMoved ? " · design moved (build-pptx --refresh-design)" : ""}`,
218
+ `slides: ${stage.slides.jsx} jsx${stage.slides.native ? ` + ${stage.slides.native} native` : ""}`,
219
+ `candidate: ${r.candidate ? `${r.candidate.candidateId}${r.candidate.stale ? " (stale)" : ""}` : "none"}`,
220
+ `review: ${stage.review.approved ? "approved" : stage.review.current ? "not needed" : stage.review.decision ?? "none"}`,
221
+ ...(r.staged.length ? [`staged: ${r.staged.length}`] : []),
222
+ ...(r.conflicts ? [`conflicts: ${r.conflicts}`] : []),
223
+ ]
224
+ : parts;
225
+ const remote = stage.remote ? ` ${remoteHumanLine(stage.remote).trimStart()}` : "";
226
+ return ` stage: ${stage.name} — ${lines.join(" · ")}\n${remote} next: ${stage.next}\n`;
118
227
  }