@bastani/atomic 0.5.20 → 0.5.21-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 (45) hide show
  1. package/.agents/skills/workflow-creator/SKILL.md +78 -8
  2. package/.agents/skills/workflow-creator/references/discovery-and-verification.md +75 -0
  3. package/dist/sdk/components/orchestrator-panel.d.ts +23 -1
  4. package/dist/sdk/components/orchestrator-panel.d.ts.map +1 -1
  5. package/dist/sdk/components/workflow-picker-panel.d.ts.map +1 -1
  6. package/dist/sdk/define-workflow.d.ts.map +1 -1
  7. package/dist/sdk/errors.d.ts +12 -0
  8. package/dist/sdk/errors.d.ts.map +1 -1
  9. package/dist/sdk/runtime/discovery.d.ts +55 -12
  10. package/dist/sdk/runtime/discovery.d.ts.map +1 -1
  11. package/dist/sdk/runtime/executor.d.ts.map +1 -1
  12. package/dist/sdk/runtime/loader.d.ts.map +1 -1
  13. package/dist/sdk/runtime/status-writer.d.ts +101 -0
  14. package/dist/sdk/runtime/status-writer.d.ts.map +1 -0
  15. package/dist/sdk/runtime/version-compat.d.ts +28 -0
  16. package/dist/sdk/runtime/version-compat.d.ts.map +1 -0
  17. package/dist/sdk/types.d.ts +21 -0
  18. package/dist/sdk/types.d.ts.map +1 -1
  19. package/dist/sdk/workflows/index.d.ts +1 -1
  20. package/dist/sdk/workflows/index.d.ts.map +1 -1
  21. package/dist/version.d.ts +2 -0
  22. package/dist/version.d.ts.map +1 -0
  23. package/package.json +1 -1
  24. package/src/cli.ts +57 -3
  25. package/src/commands/cli/session.test.ts +43 -0
  26. package/src/commands/cli/session.ts +18 -8
  27. package/src/commands/cli/workflow-command.test.ts +10 -4
  28. package/src/commands/cli/workflow-inputs.test.ts +322 -0
  29. package/src/commands/cli/workflow-inputs.ts +219 -0
  30. package/src/commands/cli/workflow-status.test.ts +451 -0
  31. package/src/commands/cli/workflow-status.ts +330 -0
  32. package/src/commands/cli/workflow.test.ts +10 -3
  33. package/src/commands/cli/workflow.ts +57 -18
  34. package/src/sdk/components/orchestrator-panel.tsx +36 -1
  35. package/src/sdk/components/workflow-picker-panel.tsx +167 -18
  36. package/src/sdk/define-workflow.ts +1 -0
  37. package/src/sdk/errors.ts +20 -0
  38. package/src/sdk/runtime/discovery.ts +94 -20
  39. package/src/sdk/runtime/executor.ts +37 -0
  40. package/src/sdk/runtime/loader.ts +29 -2
  41. package/src/sdk/runtime/status-writer.test.ts +245 -0
  42. package/src/sdk/runtime/status-writer.ts +201 -0
  43. package/src/sdk/runtime/version-compat.ts +68 -0
  44. package/src/sdk/types.ts +21 -0
  45. package/src/sdk/workflows/index.ts +1 -0
@@ -42,7 +42,10 @@ import { useState, useEffect, useMemo, useRef, useCallback, useContext, createCo
42
42
  import { useLatest } from "./hooks.ts";
43
43
  import { resolveTheme, type TerminalTheme } from "../runtime/theme.ts";
44
44
  import type { AgentType, WorkflowInput } from "../types.ts";
45
- import type { WorkflowWithMetadata } from "../runtime/discovery.ts";
45
+ import type {
46
+ WorkflowWithMetadata,
47
+ WorkflowMetadataStatus,
48
+ } from "../runtime/discovery.ts";
46
49
  import { ErrorBoundary } from "./error-boundary.tsx";
47
50
 
48
51
  // ─── Theme ──────────────────────────────────────
@@ -258,6 +261,38 @@ export function buildRows(entries: ListEntry[], query: string): ListRow[] {
258
261
  return rows;
259
262
  }
260
263
 
264
+ // ─── Status helpers ─────────────────────────────
265
+ // Non-ok entries stay visible in the picker so the user sees that a
266
+ // workflow from an older SDK release (or one with a load error) still
267
+ // exists on disk — the previous behaviour silently dropped them, which
268
+ // made user/global workflows appear to vanish after an atomic upgrade.
269
+
270
+ /** Unicode glyph that prefixes a non-ok entry and heads its preview. */
271
+ const STATUS_ICON: Record<WorkflowMetadataStatus["kind"], string> = {
272
+ ok: " ",
273
+ incompatible: "⚠",
274
+ error: "✗",
275
+ };
276
+
277
+ /** Compact single-word label used in list rows and the bottom hint. */
278
+ const STATUS_LABEL: Record<WorkflowMetadataStatus["kind"], string> = {
279
+ ok: "",
280
+ incompatible: "update",
281
+ error: "broken",
282
+ };
283
+
284
+ /** Map each status kind to its semantic palette slot. */
285
+ const STATUS_COLOR: Record<WorkflowMetadataStatus["kind"], keyof PickerTheme> = {
286
+ ok: "success",
287
+ incompatible: "warning",
288
+ error: "error",
289
+ };
290
+
291
+ /** Non-ok rows are inert — Enter / run commands must not transition the picker. */
292
+ function isRunnable(wf: WorkflowWithMetadata): boolean {
293
+ return wf.status.kind === "ok";
294
+ }
295
+
261
296
  // ─── Validation ─────────────────────────────────
262
297
 
263
298
  export function isFieldValid(field: WorkflowInput, value: string): boolean {
@@ -385,6 +420,24 @@ const WorkflowList = memo(function WorkflowList({
385
420
  const entryIdx = entryIndexByRow.get(i) ?? -1;
386
421
  const isFocused = entryIdx === focusedEntryIdx;
387
422
  const wf = row.entry.workflow;
423
+ const statusKind = wf.status.kind;
424
+ const runnable = statusKind === "ok";
425
+ // Status indicator sits between the focus marker and the name so
426
+ // every row shares a 4-character gutter — ok rows render a blank
427
+ // gutter, so the list stays visually flush while non-ok rows
428
+ // always occupy a fixed slot (no layout jitter when filtering).
429
+ const statusIcon = STATUS_ICON[statusKind];
430
+ const statusCol = theme[STATUS_COLOR[statusKind]];
431
+ // Non-ok rows fade the name so the "diagnostic, not selectable"
432
+ // read is immediate even when the user hasn't reached the row
433
+ // yet — the eye catches the warning glyph + dim text together.
434
+ const nameCol = runnable
435
+ ? isFocused
436
+ ? theme.text
437
+ : theme.textMuted
438
+ : isFocused
439
+ ? theme.textMuted
440
+ : theme.textDim;
388
441
 
389
442
  return (
390
443
  <box
@@ -399,7 +452,10 @@ const WorkflowList = memo(function WorkflowList({
399
452
  <span fg={isFocused ? theme.primary : theme.textDim}>
400
453
  {isFocused ? "▸ " : " "}
401
454
  </span>
402
- <span fg={isFocused ? theme.text : theme.textMuted}>
455
+ <span fg={statusCol}>
456
+ {statusIcon + " "}
457
+ </span>
458
+ <span fg={nameCol}>
403
459
  {wf.name}
404
460
  </span>
405
461
  </text>
@@ -459,6 +515,59 @@ const ArgumentRow = memo(function ArgumentRow({
459
515
  );
460
516
  });
461
517
 
518
+ /**
519
+ * Diagnostic block for non-ok workflows. Replaces the description +
520
+ * arguments sections in the preview pane so the user sees *why* a row
521
+ * is inert and what to do about it, instead of an empty-looking panel.
522
+ */
523
+ const StatusDiagnostic = memo(function StatusDiagnostic({
524
+ status,
525
+ }: {
526
+ status: Exclude<WorkflowMetadataStatus, { kind: "ok" }>;
527
+ }) {
528
+ const theme = usePickerTheme();
529
+ const color = theme[STATUS_COLOR[status.kind]];
530
+ const icon = STATUS_ICON[status.kind];
531
+
532
+ // Headline + sub-copy split: the headline is terse (three words) so
533
+ // it reads at a glance even on a narrow right pane; the sub-copy
534
+ // explains *why* and the third paragraph tells the user what to do.
535
+ const headline =
536
+ status.kind === "incompatible"
537
+ ? "update required"
538
+ : "failed to load";
539
+ const detail =
540
+ status.kind === "incompatible"
541
+ ? `Needs Atomic v${status.requiredVersion}. Installed: v${status.currentVersion}.`
542
+ : status.message;
543
+ const remediation =
544
+ status.kind === "incompatible"
545
+ ? "Update Atomic, or re-save the workflow against the current SDK."
546
+ : "Open the workflow file and fix the error above.";
547
+
548
+ return (
549
+ <box flexDirection="column">
550
+ <text>
551
+ <span fg={color}>
552
+ <strong>{icon + " " + headline}</strong>
553
+ </span>
554
+ </text>
555
+
556
+ <box height={1} />
557
+
558
+ <text>
559
+ <span fg={theme.textMuted}>{detail}</span>
560
+ </text>
561
+
562
+ <box height={1} />
563
+
564
+ <text>
565
+ <span fg={theme.textDim}>{remediation}</span>
566
+ </text>
567
+ </box>
568
+ );
569
+ });
570
+
462
571
  const Preview = memo(function Preview({
463
572
  wf,
464
573
  }: {
@@ -466,6 +575,7 @@ const Preview = memo(function Preview({
466
575
  }) {
467
576
  const theme = usePickerTheme();
468
577
  const args = wf.inputs;
578
+ const status = wf.status;
469
579
 
470
580
  return (
471
581
  <box
@@ -493,21 +603,27 @@ const Preview = memo(function Preview({
493
603
 
494
604
  <box height={2} />
495
605
 
496
- <text>
497
- <span fg={theme.textMuted}>
498
- {wf.description || "(no description)"}
499
- </span>
500
- </text>
501
-
502
- {args.length > 0 && (
606
+ {status.kind === "ok" ? (
503
607
  <>
504
- <box height={2} />
505
- <SectionLabel label="ARGUMENTS" />
506
- <box height={1} />
507
- {args.map((f) => (
508
- <ArgumentRow key={f.name} field={f} />
509
- ))}
608
+ <text>
609
+ <span fg={theme.textMuted}>
610
+ {wf.description || "(no description)"}
611
+ </span>
612
+ </text>
613
+
614
+ {args.length > 0 && (
615
+ <>
616
+ <box height={2} />
617
+ <SectionLabel label="ARGUMENTS" />
618
+ <box height={1} />
619
+ {args.map((f) => (
620
+ <ArgumentRow key={f.name} field={f} />
621
+ ))}
622
+ </>
623
+ )}
510
624
  </>
625
+ ) : (
626
+ <StatusDiagnostic status={status} />
511
627
  )}
512
628
  </box>
513
629
  );
@@ -1022,6 +1138,14 @@ const PICK_HINTS: Hint[] = [
1022
1138
  { key: "↵", label: "select" },
1023
1139
  { key: "esc", label: "quit" },
1024
1140
  ];
1141
+ // Shown instead of PICK_HINTS when the focused row is non-ok — the dim
1142
+ // `↵ unavailable` reads immediately as "this row is navigable but not
1143
+ // runnable", which matches the muted row colour and preview diagnostic.
1144
+ const PICK_HINTS_UNAVAILABLE: Hint[] = [
1145
+ { key: "↑↓", label: "navigate" },
1146
+ { key: "↵", label: "unavailable", dim: true },
1147
+ { key: "esc", label: "quit" },
1148
+ ];
1025
1149
  const CONFIRM_HINTS: Hint[] = [
1026
1150
  { key: "y", label: "submit" },
1027
1151
  { key: "n", label: "cancel" },
@@ -1134,7 +1258,26 @@ const Statusline = memo(function Statusline({
1134
1258
  {focusedWf ? (
1135
1259
  <box paddingLeft={1} paddingRight={1} alignItems="center">
1136
1260
  <text>
1137
- <span fg={theme.text}>{focusedWf.name}</span>
1261
+ {focusedWf.status.kind !== "ok" ? (
1262
+ <span fg={theme[STATUS_COLOR[focusedWf.status.kind]]}>
1263
+ {STATUS_ICON[focusedWf.status.kind] + " "}
1264
+ </span>
1265
+ ) : null}
1266
+ <span
1267
+ fg={
1268
+ focusedWf.status.kind === "ok" ? theme.text : theme.textMuted
1269
+ }
1270
+ >
1271
+ {focusedWf.name}
1272
+ </span>
1273
+ {focusedWf.status.kind !== "ok" ? (
1274
+ <>
1275
+ <span fg={theme.textDim}>{" · "}</span>
1276
+ <span fg={theme[STATUS_COLOR[focusedWf.status.kind]]}>
1277
+ {STATUS_LABEL[focusedWf.status.kind]}
1278
+ </span>
1279
+ </>
1280
+ ) : null}
1138
1281
  </text>
1139
1282
  </box>
1140
1283
  ) : null}
@@ -1247,7 +1390,10 @@ function usePickerKeyboard(state: PickerKeyboardState): void {
1247
1390
  if (key.name === "return") {
1248
1391
  key.stopPropagation();
1249
1392
  const wf = focusedWfRef.current;
1250
- if (wf) {
1393
+ // Silently swallow Enter on incompatible / broken entries — the
1394
+ // preview pane already explains the failure; advancing into the
1395
+ // prompt phase would be misleading since the workflow can't run.
1396
+ if (wf && isRunnable(wf)) {
1251
1397
  const initial: Record<string, string> = {};
1252
1398
  for (const f of wf.inputs) {
1253
1399
  initial[f.name] =
@@ -1408,10 +1554,13 @@ export function WorkflowPicker({
1408
1554
  setConfirmOpen,
1409
1555
  });
1410
1556
 
1557
+ const focusedIsRunnable = focusedWf ? isRunnable(focusedWf) : true;
1411
1558
  const hints = confirmOpen
1412
1559
  ? CONFIRM_HINTS
1413
1560
  : phase === "pick"
1414
- ? PICK_HINTS
1561
+ ? focusedIsRunnable
1562
+ ? PICK_HINTS
1563
+ : PICK_HINTS_UNAVAILABLE
1415
1564
  : isFormValid
1416
1565
  ? PROMPT_HINTS_VALID
1417
1566
  : PROMPT_HINTS_INVALID;
@@ -152,6 +152,7 @@ export class WorkflowBuilder<A extends AgentType = AgentType, N extends string =
152
152
  name: this.options.name,
153
153
  description: this.options.description ?? "",
154
154
  inputs,
155
+ minSDKVersion: this.options.minSDKVersion ?? null,
155
156
  run: runFn,
156
157
  };
157
158
  }
package/src/sdk/errors.ts CHANGED
@@ -38,6 +38,26 @@ export class InvalidWorkflowError extends Error {
38
38
  }
39
39
  }
40
40
 
41
+ /**
42
+ * Thrown when a workflow declares a `minSDKVersion` newer than the
43
+ * bundled CLI. Carries both versions so the CLI can render an
44
+ * actionable "update atomic or re-save the workflow" hint rather than
45
+ * a generic load error.
46
+ */
47
+ export class IncompatibleSDKError extends Error {
48
+ constructor(
49
+ public readonly path: string,
50
+ public readonly requiredVersion: string,
51
+ public readonly currentVersion: string,
52
+ ) {
53
+ super(
54
+ `${path} requires Atomic SDK v${requiredVersion}, but v${currentVersion} is installed.\n` +
55
+ ` Update Atomic, or re-save the workflow against the current SDK.`,
56
+ );
57
+ this.name = "IncompatibleSDKError";
58
+ }
59
+ }
60
+
41
61
  /** Extract a human-readable message from an unknown thrown value. */
42
62
  export function errorMessage(error: unknown): string {
43
63
  return error instanceof Error ? error.message : String(error);
@@ -17,6 +17,7 @@ import { homedir } from "node:os";
17
17
  import ignore from "ignore";
18
18
  import type { AgentType, WorkflowInput } from "../types.ts";
19
19
  import { WorkflowLoader } from "./loader.ts";
20
+ import { IncompatibleSDKError } from "../errors.ts";
20
21
 
21
22
  export interface DiscoveredWorkflow {
22
23
  name: string;
@@ -248,47 +249,120 @@ export async function findWorkflow(
248
249
  return all.find((w) => w.name === name) ?? null;
249
250
  }
250
251
 
252
+ /**
253
+ * Load status for a {@link WorkflowWithMetadata} entry.
254
+ *
255
+ * - `ok` — the workflow compiled cleanly and is ready to run.
256
+ * - `incompatible` — the workflow declared a `minSDKVersion` newer
257
+ * than the bundled CLI. The CLI renders it with an
258
+ * "update required" badge so users see the mismatch
259
+ * rather than a silent disappearance.
260
+ * - `error` — any other load failure (syntax error, missing
261
+ * `.compile()`, invalid default export, etc.).
262
+ * Rendered with a "failed to load" badge plus the
263
+ * underlying message.
264
+ */
265
+ export type WorkflowMetadataStatus =
266
+ | { kind: "ok" }
267
+ | {
268
+ kind: "incompatible";
269
+ requiredVersion: string;
270
+ currentVersion: string;
271
+ message: string;
272
+ }
273
+ | {
274
+ kind: "error";
275
+ stage: "resolve" | "validate" | "load";
276
+ message: string;
277
+ };
278
+
251
279
  /**
252
280
  * A discovered workflow enriched with the metadata the picker needs to
253
- * render it: the human description and the declared input schema.
281
+ * render it: the human description, the declared input schema, and the
282
+ * load status.
254
283
  *
255
284
  * Populated by {@link loadWorkflowsMetadata}, which runs each discovered
256
- * workflow through {@link WorkflowLoader.loadWorkflow} and extracts just
257
- * the display-relevant fields — the full compiled definition is
258
- * discarded after extraction so re-imports during execution are cheap.
285
+ * workflow through {@link WorkflowLoader.loadWorkflow} and extracts the
286
+ * display-relevant fields — the full compiled definition is discarded
287
+ * after extraction so re-imports during execution are cheap.
288
+ *
289
+ * Broken entries still materialise with empty `description` / `inputs`
290
+ * and a non-`ok` {@link status}, so the picker can render them as
291
+ * visible "update required" / "failed to load" rows instead of
292
+ * silently omitting them (the previous behaviour, which made user and
293
+ * global workflows vanish whenever the base SDK shape drifted between
294
+ * releases).
259
295
  */
260
296
  export interface WorkflowWithMetadata extends DiscoveredWorkflow {
261
- /** Workflow description, empty string when none was declared. */
297
+ /** Workflow description, empty string when none was declared or the workflow failed to load. */
262
298
  description: string;
263
- /** Picker-ready input schema; free-form workflows materialize a prompt field. */
299
+ /** Picker-ready input schema; empty for free-form or failed-to-load workflows. */
264
300
  inputs: readonly WorkflowInput[];
301
+ /** Load outcome — non-`ok` entries are rendered as visible diagnostics in the picker/list. */
302
+ status: WorkflowMetadataStatus;
265
303
  }
266
304
 
267
305
  /**
268
- * Load metadata (description + picker-ready inputs) for a batch of discovered workflows.
306
+ * Load metadata (description + picker-ready inputs + status) for a batch
307
+ * of discovered workflows.
269
308
  *
270
- * Workflows that fail to import are **skipped silently** so one broken
271
- * entry can never prevent the picker from rendering. Callers that need
272
- * to surface load errors (e.g. `atomic workflow -n broken`) should use
273
- * {@link WorkflowLoader.loadWorkflow} directly that path produces
274
- * structured error reports.
309
+ * **Failed workflows are kept in the returned list**, not dropped. Each
310
+ * broken entry carries a {@link WorkflowMetadataStatus} explaining the
311
+ * failure so the picker and `atomic workflow -l` can surface it as an
312
+ * actionable diagnostic. This is the only way end users discover that a
313
+ * workflow from an older SDK release has gone incompatible after an
314
+ * `atomic` upgrade — silent filtering would leave them with a missing
315
+ * entry and no breadcrumb.
316
+ *
317
+ * Callers that want to execute a workflow should still route through
318
+ * {@link WorkflowLoader.loadWorkflow} — this function throws away the
319
+ * compiled definition so re-running the loader on a confirmed pick is
320
+ * unavoidable.
275
321
  */
276
322
  export async function loadWorkflowsMetadata(
277
323
  discovered: DiscoveredWorkflow[],
278
324
  ): Promise<WorkflowWithMetadata[]> {
279
- const results = await Promise.all(
280
- discovered.map(async (wf): Promise<WorkflowWithMetadata | null> => {
325
+ return Promise.all(
326
+ discovered.map(async (wf): Promise<WorkflowWithMetadata> => {
281
327
  const loaded = await WorkflowLoader.loadWorkflow(wf);
282
- if (!loaded.ok) return null;
328
+ if (loaded.ok) {
329
+ return {
330
+ ...wf,
331
+ description: loaded.value.definition.description,
332
+ inputs: loaded.value.definition.inputs,
333
+ status: { kind: "ok" },
334
+ };
335
+ }
336
+
337
+ // Incompatible SDK version is a first-class status so the UI can
338
+ // show a dedicated "update required" hint. Every other failure
339
+ // maps to a generic `error` variant — the picker renders the
340
+ // message but doesn't try to interpret it further.
341
+ if (loaded.error instanceof IncompatibleSDKError) {
342
+ return {
343
+ ...wf,
344
+ description: "",
345
+ inputs: [],
346
+ status: {
347
+ kind: "incompatible",
348
+ requiredVersion: loaded.error.requiredVersion,
349
+ currentVersion: loaded.error.currentVersion,
350
+ message: loaded.message,
351
+ },
352
+ };
353
+ }
354
+
283
355
  return {
284
356
  ...wf,
285
- description: loaded.value.definition.description,
286
- inputs: loaded.value.definition.inputs,
357
+ description: "",
358
+ inputs: [],
359
+ status: {
360
+ kind: "error",
361
+ stage: loaded.stage,
362
+ message: loaded.message,
363
+ },
287
364
  };
288
365
  }),
289
366
  );
290
- return results.filter(
291
- (r): r is WorkflowWithMetadata => r !== null,
292
- );
293
367
  }
294
368
 
@@ -55,6 +55,7 @@ import {
55
55
  } from "../providers/claude.ts";
56
56
  import { OrchestratorPanel } from "./panel.tsx";
57
57
  import { GraphFrontierTracker } from "./graph-inference.ts";
58
+ import { buildSnapshot, writeSnapshot } from "./status-writer.ts";
58
59
  import { errorMessage } from "../errors.ts";
59
60
  import { createPainter } from "../../theme/colors.ts";
60
61
 
@@ -1533,11 +1534,47 @@ export async function runOrchestrator(): Promise<void> {
1533
1534
  tmuxSession: tmuxSessionName,
1534
1535
  });
1535
1536
 
1537
+ // Mirror panel-store mutations to <sessionDir>/status.json so
1538
+ // out-of-process consumers (e.g. `atomic workflow status`) can read
1539
+ // the live workflow state without IPC into the orchestrator.
1540
+ // Writes are debounced via a "pending" flag so a burst of mutations
1541
+ // collapses into a single file write.
1542
+ let snapshotPending = false;
1543
+ const persistSnapshot = (): void => {
1544
+ if (snapshotPending) return;
1545
+ snapshotPending = true;
1546
+ queueMicrotask(() => {
1547
+ snapshotPending = false;
1548
+ const snap = panel.getSnapshot();
1549
+ void writeSnapshot(
1550
+ sessionsBaseDir,
1551
+ buildSnapshot({
1552
+ workflowRunId,
1553
+ tmuxSession: tmuxSessionName,
1554
+ ...snap,
1555
+ }),
1556
+ );
1557
+ });
1558
+ };
1559
+ const unsubscribePanel = panel.subscribe(persistSnapshot);
1560
+ // Seed an initial snapshot so the file exists before any session starts.
1561
+ persistSnapshot();
1562
+
1536
1563
  // Idempotent shutdown guard
1537
1564
  let shutdownCalled = false;
1538
1565
  const shutdown = (exitCode = 0) => {
1539
1566
  if (shutdownCalled) return;
1540
1567
  shutdownCalled = true;
1568
+ unsubscribePanel();
1569
+ // Final snapshot reflecting terminal state (completed/error/aborted).
1570
+ void writeSnapshot(
1571
+ sessionsBaseDir,
1572
+ buildSnapshot({
1573
+ workflowRunId,
1574
+ tmuxSession: tmuxSessionName,
1575
+ ...panel.getSnapshot(),
1576
+ }),
1577
+ );
1541
1578
  panel.destroy();
1542
1579
  try {
1543
1580
  tmux.killSession(tmuxSessionName);
@@ -12,10 +12,17 @@
12
12
 
13
13
  import type { WorkflowDefinition, AgentType } from "../types.ts";
14
14
  import type { DiscoveredWorkflow } from "./discovery.ts";
15
- import { errorMessage, WorkflowNotCompiledError, InvalidWorkflowError } from "../errors.ts";
15
+ import {
16
+ errorMessage,
17
+ WorkflowNotCompiledError,
18
+ InvalidWorkflowError,
19
+ IncompatibleSDKError,
20
+ } from "../errors.ts";
16
21
  import { validateCopilotWorkflow } from "../providers/copilot.ts";
17
22
  import { validateOpenCodeWorkflow } from "../providers/opencode.ts";
18
23
  import { validateClaudeWorkflow } from "../providers/claude.ts";
24
+ import { satisfiesMinVersion } from "./version-compat.ts";
25
+ import { VERSION } from "../../version.ts";
19
26
 
20
27
  export namespace WorkflowLoader {
21
28
  // ---------------------------------------------------------------------------
@@ -180,9 +187,29 @@ export namespace WorkflowLoader {
180
187
  };
181
188
  }
182
189
 
190
+ const def = definition as WorkflowDefinition;
191
+
192
+ // Refuse workflows whose declared minSDKVersion is newer than the
193
+ // bundled CLI — the workflow author opted in to a version gate
194
+ // exactly so the loader could surface a clear upgrade hint
195
+ // instead of letting a shape-drift error bubble up at run time.
196
+ if (!satisfiesMinVersion(VERSION, def.minSDKVersion)) {
197
+ const err = new IncompatibleSDKError(
198
+ validated.path,
199
+ def.minSDKVersion ?? "",
200
+ VERSION,
201
+ );
202
+ return {
203
+ ok: false,
204
+ stage: "load",
205
+ error: err,
206
+ message: err.message,
207
+ };
208
+ }
209
+
183
210
  return {
184
211
  ok: true,
185
- value: { ...validated, definition: definition as WorkflowDefinition },
212
+ value: { ...validated, definition: def },
186
213
  };
187
214
  } catch (error) {
188
215
  return {