intentdna 1.8.6 → 1.8.7

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 (60) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +1 -0
  4. package/dist/cli/commands/run-lifecycle.d.ts +73 -0
  5. package/dist/cli/commands/run-lifecycle.js +240 -0
  6. package/dist/cli/commands/run.d.ts +22 -40
  7. package/dist/cli/commands/run.js +674 -392
  8. package/dist/cli/index.js +87 -2
  9. package/dist/compiler/workflow.js +3 -2
  10. package/dist/hooks/cli.d.ts +1 -2
  11. package/dist/hooks/cli.js +119 -80
  12. package/dist/hooks/enforce.d.ts +2 -0
  13. package/dist/hooks/enforce.js +56 -27
  14. package/dist/hooks/enforcement-boundary.d.ts +13 -0
  15. package/dist/hooks/enforcement-boundary.js +33 -0
  16. package/dist/hooks/index.d.ts +3 -2
  17. package/dist/hooks/index.js +3 -2
  18. package/dist/hooks/protocol.d.ts +12 -4
  19. package/dist/hooks/protocol.js +20 -14
  20. package/dist/hooks/schema.d.ts +2 -1
  21. package/dist/hooks/schema.js +6 -2
  22. package/dist/hooks/state-manager.d.ts +5 -5
  23. package/dist/hooks/state-manager.js +26 -24
  24. package/dist/hooks/state.d.ts +19 -3
  25. package/dist/hooks/state.js +327 -80
  26. package/dist/mcp/index.js +0 -0
  27. package/dist/runtime/diagnosis-contract-verifier.d.ts +11 -0
  28. package/dist/runtime/diagnosis-contract-verifier.js +417 -0
  29. package/dist/runtime/execution-provider.d.ts +40 -0
  30. package/dist/runtime/execution-provider.js +138 -0
  31. package/dist/runtime/handoff-resolver.d.ts +61 -0
  32. package/dist/runtime/handoff-resolver.js +167 -0
  33. package/dist/runtime/index.d.ts +24 -0
  34. package/dist/runtime/index.js +13 -0
  35. package/dist/runtime/process-tree.d.ts +47 -0
  36. package/dist/runtime/process-tree.js +402 -0
  37. package/dist/runtime/providers/claude.d.ts +9 -0
  38. package/dist/runtime/providers/claude.js +64 -0
  39. package/dist/runtime/providers/codex.d.ts +8 -0
  40. package/dist/runtime/providers/codex.js +72 -0
  41. package/dist/runtime/result-store.d.ts +32 -0
  42. package/dist/runtime/result-store.js +130 -0
  43. package/dist/runtime/run-contracts.d.ts +290 -0
  44. package/dist/runtime/run-contracts.js +58 -0
  45. package/dist/runtime/run-controller.d.ts +149 -0
  46. package/dist/runtime/run-controller.js +1108 -0
  47. package/dist/runtime/run-store.d.ts +96 -0
  48. package/dist/runtime/run-store.js +725 -0
  49. package/dist/runtime/worker-executor.d.ts +19 -0
  50. package/dist/runtime/worker-executor.js +194 -0
  51. package/dist/runtime/workflow-plan-adapter.d.ts +26 -0
  52. package/dist/runtime/workflow-plan-adapter.js +416 -0
  53. package/dist/runtime/workflow-runner.d.ts +15 -3
  54. package/dist/runtime/workflow-runner.js +13 -1
  55. package/dist/runtime/workspace-isolation.d.ts +103 -0
  56. package/dist/runtime/workspace-isolation.js +373 -0
  57. package/dist/schema/types.d.ts +1 -0
  58. package/dist/schema/validate.js +64 -6
  59. package/dist/schema/yaml-parser.js +7 -2
  60. package/package.json +1 -1
@@ -1,481 +1,763 @@
1
1
  /**
2
- * Intent DNA dna run command
2
+ * Intent DNA - canonical dna run command.
3
3
  *
4
- * TypeScript runtime execution of workflows.
5
- * Loads DNA compiles auto-syncs agents/hooks executes steps
6
- * via `claude -p --agent` child processes.
4
+ * The CLI compiles one workflow into Controller definitions, persists one
5
+ * durable run ledger, and launches one disposable provider session per Step
6
+ * attempt. Cross-Step inputs come only from committed results.
7
7
  */
8
- import { spawn } from "node:child_process";
9
- import { mkdir, writeFile } from "node:fs/promises";
10
- import { join, resolve } from "node:path";
11
- import { randomUUID } from "node:crypto";
12
- import { loadDNA, compileFromFiles, expandDNAInputFiles } from "../../compiler/index.js";
8
+ import { createHash, randomUUID } from "node:crypto";
9
+ import { isAbsolute, join, relative, resolve, sep, } from "node:path";
13
10
  import { cascadeDNA } from "../../compiler/cascade.js";
11
+ import { compileFromFiles, expandDNAInputFiles, loadDNA, } from "../../compiler/index.js";
14
12
  import { compileWorkflow } from "../../compiler/workflow.js";
15
- import { compileAllRolesToAgentMD, writeAgentMDFiles, toKebabCase, } from "../../runtime/agent-md.js";
16
- import { createCompiledIR, writeCompiledIR } from "../../runtime/plugin-adapter.js";
17
13
  import { trustedEvidenceCaptureAttribution } from "../../governance/index.js";
18
14
  import { DNAStateManager } from "../../hooks/state-manager.js";
19
15
  import { appendEvidenceCaptureEvent, EVIDENCE_CAPTURE_SCHEMA_VERSION, } from "../../hooks/state.js";
20
- import { runCompletionVerifier, runCheckpointVerifier, } from "../../runtime/verifier.js";
21
- // ── Template & Condition Helpers ───────────────────────────
22
- /**
23
- * Replace {{var}} placeholders in a template string.
24
- * Unmatched placeholders are preserved as-is.
25
- */
16
+ import { compileAllRolesToAgentMD, toKebabCase, writeAgentMDFiles, } from "../../runtime/agent-md.js";
17
+ import { HandoffResolver } from "../../runtime/handoff-resolver.js";
18
+ import { createCompiledIR, writeCompiledIR } from "../../runtime/plugin-adapter.js";
19
+ import { ImmutableResultStore } from "../../runtime/result-store.js";
20
+ import { RunController, } from "../../runtime/run-controller.js";
21
+ import { DurableRunStore, RunStoreError, } from "../../runtime/run-store.js";
22
+ import { createClaudeExecutionProvider } from "../../runtime/providers/claude.js";
23
+ import { createCodexExecutionProvider } from "../../runtime/providers/codex.js";
24
+ import { runCheckpointVerifier, runCompletionVerifier, } from "../../runtime/verifier.js";
25
+ import { adaptWorkflowPlan, } from "../../runtime/workflow-plan-adapter.js";
26
+ import { executeWorkerAttempt, } from "../../runtime/worker-executor.js";
27
+ import { allocateAttemptWorkspace, applyWorkspaceLifecycleDecision, planAttemptWorkspace, } from "../../runtime/workspace-isolation.js";
28
+ import { runLifecycleCancel, runLifecycleInspect, runLifecycleResume, runLifecycleStart, runLifecycleStatus, } from "./run-lifecycle.js";
29
+ class RunUsageError extends Error {
30
+ constructor(message) {
31
+ super(message);
32
+ this.name = "RunUsageError";
33
+ }
34
+ }
35
+ // ── Compatibility-only pure helpers ───────────────────────
26
36
  export function substituteTemplate(template, vars) {
27
- return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
28
- return key in vars ? vars[key] : match;
29
- });
37
+ return template.replace(/\{\{(\w+)\}\}/g, (match, key) => (key in vars ? vars[key] : match));
30
38
  }
31
- /**
32
- * Evaluate a run_if condition against the current round.
33
- * Returns true if the step should execute.
34
- */
35
39
  export function evaluateRunIf(condition, round) {
36
40
  if (condition === null)
37
41
  return true;
38
- // "round == N"
39
42
  const eqMatch = condition.match(/^round\s*==\s*(\d+)$/);
40
43
  if (eqMatch)
41
44
  return round === parseInt(eqMatch[1], 10);
42
- // "round > N"
43
45
  const gtMatch = condition.match(/^round\s*>\s*(\d+)$/);
44
46
  if (gtMatch)
45
47
  return round > parseInt(gtMatch[1], 10);
46
- // "round >= N"
47
48
  const geMatch = condition.match(/^round\s*>=\s*(\d+)$/);
48
49
  if (geMatch)
49
50
  return round >= parseInt(geMatch[1], 10);
50
- // "retry" = round > 1
51
51
  if (condition === "retry")
52
52
  return round > 1;
53
- // "always" or unknown → run
54
53
  return true;
55
54
  }
56
- /**
57
- * Run checkpoint assertions for a completed step.
58
- * Returns true if all checkpoints pass, false if any fail.
59
- */
60
- async function runCompletions(step, vars, opts) {
61
- if (!step.completion)
62
- return true;
63
- for (const completion of step.completion) {
64
- const result = await runCompletionVerifier(completion, opts.ir, {
65
- projectDir: opts.projectDir,
66
- variables: vars,
67
- });
68
- if (!result.passed) {
69
- logVerifierFailure("Completion failed", step.id, result);
70
- return false;
55
+ export function checkRetryNeeded(stepResults, transitions) {
56
+ for (const transition of transitions) {
57
+ if (transition.condition === "fail"
58
+ && stepResults.get(transition.from)?.status === "fail") {
59
+ return true;
71
60
  }
72
61
  }
73
- return true;
62
+ return false;
74
63
  }
75
- async function runCheckpoints(step, vars, opts) {
76
- if (!step.checkpoints)
77
- return true;
78
- for (const cp of step.checkpoints) {
79
- const action = cp.action ?? "block";
80
- const result = await runCheckpointVerifier(cp, opts.ir, {
81
- projectDir: opts.projectDir,
82
- variables: vars,
64
+ // ── Validation and durable metadata ───────────────────────
65
+ function requireText(value, label) {
66
+ const trimmed = value?.trim();
67
+ if (!trimmed)
68
+ throw new RunUsageError(`${label} is required`);
69
+ return trimmed;
70
+ }
71
+ function requirePositiveNumber(value, label) {
72
+ if (!Number.isFinite(value) || value <= 0) {
73
+ throw new RunUsageError(`${label} must be a positive number`);
74
+ }
75
+ return value;
76
+ }
77
+ function requirePositiveInteger(value, label) {
78
+ if (!Number.isSafeInteger(value) || value <= 0) {
79
+ throw new RunUsageError(`${label} must be a positive integer`);
80
+ }
81
+ return value;
82
+ }
83
+ function requireNonNegativeInteger(value, label) {
84
+ if (!Number.isSafeInteger(value) || value < 0) {
85
+ throw new RunUsageError(`${label} must be a non-negative integer`);
86
+ }
87
+ return value;
88
+ }
89
+ function asRunId(value) {
90
+ return requireText(value, "run_id");
91
+ }
92
+ function isRecord(value) {
93
+ return typeof value === "object" && value !== null && !Array.isArray(value);
94
+ }
95
+ function readStringRecord(value, label) {
96
+ if (!isRecord(value))
97
+ throw new Error(`${label} must be an object`);
98
+ const result = {};
99
+ for (const [key, entry] of Object.entries(value)) {
100
+ if (typeof entry !== "string") {
101
+ throw new Error(`${label}.${key} must be a string`);
102
+ }
103
+ result[key] = entry;
104
+ }
105
+ return result;
106
+ }
107
+ function readCanonicalMetadata(value) {
108
+ if (!isRecord(value) || value.schema_version !== "intentdna.canonical_run.v1") {
109
+ throw new Error("run metadata is not intentdna.canonical_run.v1");
110
+ }
111
+ const dnaFiles = value.dna_files;
112
+ const context = value.context;
113
+ const provider = value.provider;
114
+ const timeoutMs = value.timeout_ms;
115
+ const workflowAsset = value.workflow_asset;
116
+ if (typeof value.project_directory !== "string"
117
+ || !Array.isArray(dnaFiles)
118
+ || !dnaFiles.every((entry) => typeof entry === "string")
119
+ || typeof value.workflow_name !== "string"
120
+ || typeof value.task_id !== "string"
121
+ || (context !== null && typeof context !== "string")
122
+ || typeof value.agents_directory !== "string"
123
+ || !isRecord(provider)
124
+ || !["claude", "codex"].includes(String(provider.kind))
125
+ || (provider.executable !== null && typeof provider.executable !== "string")
126
+ || typeof provider.max_budget_usd !== "number"
127
+ || typeof provider.permission_mode !== "string"
128
+ || typeof value.max_concurrency !== "number"
129
+ || (timeoutMs !== null && typeof timeoutMs !== "number")
130
+ || typeof value.cancellation_grace_ms !== "number"
131
+ || (workflowAsset !== null && typeof workflowAsset !== "string")) {
132
+ throw new Error("run metadata is malformed");
133
+ }
134
+ const metadata = {
135
+ schema_version: "intentdna.canonical_run.v1",
136
+ project_directory: value.project_directory,
137
+ dna_files: dnaFiles,
138
+ workflow_name: value.workflow_name,
139
+ task_id: value.task_id,
140
+ context,
141
+ variables: readStringRecord(value.variables, "run metadata variables"),
142
+ agents_directory: value.agents_directory,
143
+ provider: {
144
+ kind: provider.kind,
145
+ executable: provider.executable === null
146
+ ? null
147
+ : requireText(provider.executable, "run metadata provider.executable"),
148
+ max_budget_usd: requirePositiveNumber(provider.max_budget_usd, "run metadata provider.max_budget_usd"),
149
+ permission_mode: requireText(provider.permission_mode, "run metadata provider.permission_mode"),
150
+ },
151
+ max_concurrency: requirePositiveInteger(value.max_concurrency, "run metadata max_concurrency"),
152
+ timeout_ms: timeoutMs === null
153
+ ? null
154
+ : requireNonNegativeInteger(timeoutMs, "run metadata timeout_ms"),
155
+ cancellation_grace_ms: requireNonNegativeInteger(value.cancellation_grace_ms, "run metadata cancellation_grace_ms"),
156
+ workflow_asset: workflowAsset,
157
+ };
158
+ requireText(metadata.project_directory, "run metadata project_directory");
159
+ requireText(metadata.workflow_name, "run metadata workflow_name");
160
+ requireText(metadata.task_id, "run metadata task_id");
161
+ requireText(metadata.agents_directory, "run metadata agents_directory");
162
+ if (metadata.dna_files.length === 0) {
163
+ throw new Error("run metadata dna_files must not be empty");
164
+ }
165
+ return metadata;
166
+ }
167
+ function stableJson(value) {
168
+ if (Array.isArray(value)) {
169
+ return `[${value.map(stableJson).join(",")}]`;
170
+ }
171
+ if (value !== null && typeof value === "object") {
172
+ const entries = Object.entries(value)
173
+ .filter(([, entry]) => entry !== undefined)
174
+ .sort(([left], [right]) => left.localeCompare(right));
175
+ return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`).join(",")}}`;
176
+ }
177
+ return JSON.stringify(value) ?? "null";
178
+ }
179
+ function planDigest(plan, roles) {
180
+ const stablePlan = { ...plan, compiled_at: null };
181
+ return createHash("sha256")
182
+ .update(stableJson({ plan: stablePlan, roles }), "utf-8")
183
+ .digest("hex");
184
+ }
185
+ function runtimeRoot(projectDirectory) {
186
+ return join(projectDirectory, ".dna", "runtime");
187
+ }
188
+ // ── Compilation and provider construction ─────────────────
189
+ async function compileRunAssets(metadata) {
190
+ const dnas = [];
191
+ for (const file of metadata.dna_files)
192
+ dnas.push(await loadDNA(file));
193
+ const cascaded = cascadeDNA(dnas);
194
+ const ir = await compileFromFiles([...metadata.dna_files], {
195
+ context: metadata.context ?? undefined,
196
+ });
197
+ const definition = cascaded.workflows[metadata.workflow_name];
198
+ if (!definition) {
199
+ throw new Error(`workflow '${metadata.workflow_name}' not found; available: `
200
+ + `${Object.keys(cascaded.workflows).join(", ") || "(none)"}`);
201
+ }
202
+ const result = compileWorkflow(definition, {
203
+ workflow_key: metadata.workflow_name,
204
+ });
205
+ if (!result.ok || !result.plan) {
206
+ throw new Error("workflow compilation failed:\n"
207
+ + result.errors.map((error) => ` ${error.path}: ${error.message}`).join("\n"));
208
+ }
209
+ const plan = result.plan;
210
+ const attribution = trustedEvidenceCaptureAttribution(ir, {
211
+ workflowId: metadata.workflow_name,
212
+ });
213
+ const workflowAsset = attribution.workflowAsset === "unknown"
214
+ ? null
215
+ : attribution.workflowAsset;
216
+ if (metadata.workflow_asset !== null
217
+ && workflowAsset !== metadata.workflow_asset) {
218
+ throw new Error(`workflow asset changed from '${metadata.workflow_asset}' to '${workflowAsset ?? "unknown"}'`);
219
+ }
220
+ return {
221
+ plan,
222
+ roles: cascaded.roles,
223
+ ir,
224
+ plan_digest: planDigest(plan, cascaded.roles),
225
+ workflow_asset: workflowAsset,
226
+ };
227
+ }
228
+ async function compileCanonicalRun(metadata) {
229
+ const assets = await compileRunAssets(metadata);
230
+ const adapterOptions = {
231
+ roles: assets.roles,
232
+ variables: metadata.variables,
233
+ working_directory: metadata.project_directory,
234
+ timeout_ms: metadata.timeout_ms,
235
+ cancellation_grace_ms: metadata.cancellation_grace_ms,
236
+ };
237
+ return {
238
+ ...assets,
239
+ definitions: adaptWorkflowPlan(assets.plan, adapterOptions),
240
+ };
241
+ }
242
+ async function syncRuntimeArtifacts(compiled, metadata) {
243
+ const agents = compileAllRolesToAgentMD(compiled.roles, compiled.ir);
244
+ await writeAgentMDFiles(agents, metadata.agents_directory);
245
+ await writeCompiledIR(metadata.project_directory, createCompiledIR(compiled.ir, compiled.roles));
246
+ }
247
+ function projectPathInWorkspace(metadata, workspaceDirectory, projectPath) {
248
+ const projectRelative = relative(metadata.project_directory, projectPath);
249
+ const outsideProject = (projectRelative === ".."
250
+ || projectRelative.startsWith(`..${sep}`)
251
+ || isAbsolute(projectRelative));
252
+ return outsideProject
253
+ ? projectPath
254
+ : resolve(workspaceDirectory, projectRelative);
255
+ }
256
+ async function syncAttemptRuntimeArtifacts(compiled, metadata, workspace) {
257
+ if (workspace.isolation !== "worktree")
258
+ return;
259
+ const agentsDirectory = projectPathInWorkspace(metadata, workspace.working_directory, metadata.agents_directory);
260
+ if (agentsDirectory !== metadata.agents_directory) {
261
+ const agents = compileAllRolesToAgentMD(compiled.roles, compiled.ir);
262
+ await writeAgentMDFiles(agents, agentsDirectory);
263
+ }
264
+ await writeCompiledIR(workspace.working_directory, createCompiledIR(compiled.ir, compiled.roles));
265
+ }
266
+ function createProvider(metadata) {
267
+ if (metadata.provider.kind === "codex") {
268
+ return createCodexExecutionProvider({
269
+ executable: metadata.provider.executable ?? undefined,
83
270
  });
84
- if (!result.passed) {
85
- logVerifierFailure(`Checkpoint failed: ${cp.assert}`, step.id, result);
86
- if (action === "block")
87
- return false;
271
+ }
272
+ return createClaudeExecutionProvider({
273
+ executable: metadata.provider.executable ?? undefined,
274
+ agentName: (packet) => `dna-${toKebabCase(packet.role)}`,
275
+ extraArgs: [
276
+ "--max-budget-usd",
277
+ String(metadata.provider.max_budget_usd),
278
+ "--permission-mode",
279
+ metadata.provider.permission_mode,
280
+ ],
281
+ });
282
+ }
283
+ function preservationReason(packet) {
284
+ return packet.workspace.isolation === "worktree"
285
+ ? "integration_pending"
286
+ : "controller_policy";
287
+ }
288
+ function preserveWorkspace(workspace, reason, detail) {
289
+ const result = applyWorkspaceLifecycleDecision(workspace, {
290
+ action: "preserve",
291
+ reason,
292
+ detail,
293
+ });
294
+ if (result.status === "preserved") {
295
+ log(`Preserved worktree ${result.working_directory}: `
296
+ + `${result.reason}${result.detail ? ` (${result.detail})` : ""}`);
297
+ }
298
+ }
299
+ async function recordAttemptProcess(runStore, packet, processId) {
300
+ if (!Number.isSafeInteger(processId) || processId <= 0) {
301
+ throw new Error(`provider returned invalid process id ${processId}`);
302
+ }
303
+ while (true) {
304
+ const snapshot = await runStore.requireRun(packet.run_id);
305
+ const attempt = snapshot.attempts.find((entry) => entry.attempt_id === packet.attempt_id);
306
+ if (!attempt
307
+ || attempt.step_id !== packet.step_id
308
+ || attempt.worker_session_id !== packet.worker_session_id) {
309
+ throw new Error(`attempt ${packet.attempt_id} disappeared before process registration`);
310
+ }
311
+ const recordedProcessId = attempt.process_id ?? null;
312
+ if (recordedProcessId === processId || attempt.phase === "terminal")
313
+ return;
314
+ if (recordedProcessId !== null) {
315
+ throw new Error(`attempt ${packet.attempt_id} already owns process ${recordedProcessId}`);
316
+ }
317
+ try {
318
+ await runStore.updateRun({
319
+ run_id: packet.run_id,
320
+ expected_record_version: snapshot.record_version,
321
+ attempts: [{ ...attempt, process_id: processId }],
322
+ });
323
+ return;
324
+ }
325
+ catch (error) {
326
+ if (error instanceof RunStoreError && error.code === "version_conflict") {
327
+ continue;
328
+ }
329
+ throw error;
88
330
  }
89
331
  }
90
- return true;
91
332
  }
92
- function logVerifierFailure(prefix, stepId, result) {
93
- const exitCode = result.exit_code === undefined ? "" : ` exit_code=${result.exit_code}`;
333
+ const reconcileLocalProcess = async (attempt) => {
334
+ const processId = attempt.process_id ?? null;
335
+ if (processId === null
336
+ || !Number.isSafeInteger(processId)
337
+ || processId <= 0) {
338
+ return "unknown";
339
+ }
340
+ try {
341
+ process.kill(processId, 0);
342
+ return "live";
343
+ }
344
+ catch (error) {
345
+ const code = error instanceof Error
346
+ ? error.code
347
+ : undefined;
348
+ if (code === "EPERM")
349
+ return "live";
350
+ if (code === "ESRCH")
351
+ return "dead";
352
+ return "unknown";
353
+ }
354
+ };
355
+ function verifierFailureMessage(prefix, stepId, result) {
356
+ const exitCode = result.exit_code === undefined
357
+ ? ""
358
+ : ` exit_code=${result.exit_code}`;
94
359
  const target = result.target ? ` target=${result.target}` : "";
95
- const message = result.message ? ` ${result.message}` : "";
96
- log(`${prefix}${target}${exitCode}${message} (step: ${stepId})`);
360
+ const message = result.message ? ` - ${result.message}` : "";
361
+ const detail = `${prefix}${target}${exitCode}${message}`;
362
+ log(`${detail} (step: ${stepId})`);
363
+ return detail;
364
+ }
365
+ async function applyStepValidation(metadata, compiled, packet, execution) {
366
+ if (execution.result.outcome.kind !== "success")
367
+ return execution;
368
+ const step = compiled.plan.steps.find((entry) => entry.id === packet.step_id);
369
+ if (!step) {
370
+ throw new Error(`compiled Step ${packet.step_id} disappeared before validation`);
371
+ }
372
+ const variables = {
373
+ ...metadata.variables,
374
+ round: String(packet.attempt_number),
375
+ };
376
+ let failure = null;
377
+ for (const completion of step.completion ?? []) {
378
+ const result = await runCompletionVerifier(completion, compiled.ir, {
379
+ projectDir: packet.workspace.working_directory,
380
+ variables,
381
+ });
382
+ if (!result.passed) {
383
+ failure = verifierFailureMessage("Completion failed", step.id, result);
384
+ break;
385
+ }
386
+ }
387
+ if (!failure) {
388
+ for (const checkpoint of step.checkpoints ?? []) {
389
+ const result = await runCheckpointVerifier(checkpoint, compiled.ir, {
390
+ projectDir: packet.workspace.working_directory,
391
+ variables,
392
+ });
393
+ if (result.passed)
394
+ continue;
395
+ const detail = verifierFailureMessage(`Checkpoint failed: ${checkpoint.assert}`, step.id, result);
396
+ if ((checkpoint.action ?? "block") === "block") {
397
+ failure = detail;
398
+ break;
399
+ }
400
+ }
401
+ }
402
+ if (!failure)
403
+ return execution;
404
+ return {
405
+ ...execution,
406
+ result: {
407
+ ...execution.result,
408
+ outputs: [],
409
+ outcome: {
410
+ kind: "malformed_result",
411
+ exit_code: 0,
412
+ signal: null,
413
+ error: failure,
414
+ },
415
+ },
416
+ };
97
417
  }
98
- // ── Step Execution ─────────────────────────────────────────
99
- async function recordStepResultEvidence(step, opts, sessionId, status, durationMs) {
100
- const result = status === "pass" ? "success" : "failure";
101
- await appendEvidenceCaptureEvent(opts.projectDir, {
418
+ async function recordStepResultEvidence(metadata, packet, execution) {
419
+ const outcome = execution.result.outcome;
420
+ const succeeded = outcome.kind === "success";
421
+ const startedAt = Date.parse(execution.result.started_at);
422
+ const completedAt = Date.parse(execution.result.completed_at);
423
+ await appendEvidenceCaptureEvent(metadata.project_directory, {
102
424
  schema_version: EVIDENCE_CAPTURE_SCHEMA_VERSION,
103
425
  event_id: `c5:${randomUUID()}`,
104
- captured_at: new Date().toISOString(),
105
- project_root: opts.projectDir,
426
+ captured_at: execution.result.completed_at,
427
+ project_root: metadata.project_directory,
106
428
  source: "dna_cli",
107
- workflow_asset: opts.workflowAsset ?? "unknown",
108
- workflow_id: opts.workflowName,
109
- run_id: sessionId,
110
- step_id: step.id,
429
+ workflow_asset: metadata.workflow_asset ?? "unknown",
430
+ workflow_id: metadata.workflow_name,
431
+ run_id: packet.run_id,
432
+ step_id: packet.step_id,
111
433
  fact: "result",
112
- summary: `dna run recorded ${result} for workflow step ${step.id}`,
113
- result,
114
- duration_ms: durationMs,
434
+ summary: `dna run recorded ${succeeded ? "success" : "failure"} for workflow step ${packet.step_id}`,
435
+ result: succeeded ? "success" : "failure",
436
+ exit_code: "exit_code" in outcome
437
+ ? outcome.exit_code ?? undefined
438
+ : undefined,
439
+ duration_ms: Number.isFinite(startedAt) && Number.isFinite(completedAt)
440
+ ? Math.max(0, completedAt - startedAt)
441
+ : undefined,
115
442
  });
116
443
  }
117
- /**
118
- * Execute a single workflow step via `claude -p --agent`.
119
- * Returns StepResult with pass/fail status.
120
- */
121
- export async function executeStep(step, vars, opts, iteration = 1) {
122
- const start = Date.now();
123
- const prompt = substituteTemplate(step.prompt ?? step.description, vars);
124
- const agentName = `${opts.agentPrefix}${toKebabCase(step.role)}`;
125
- const sessionId = randomUUID();
126
- const resultFile = join(opts.logDir, `${step.id}-result.json`);
127
- const stateManager = new DNAStateManager(opts.projectDir, sessionId);
128
- let stateWritten = false;
129
- const args = [
130
- "-p",
131
- "--agent",
132
- agentName,
133
- "--output-format",
134
- "json",
135
- "--max-budget-usd",
136
- String(opts.maxBudget),
137
- "--permission-mode",
138
- opts.permissionMode,
139
- "--session-id",
140
- sessionId,
141
- "--no-session-persistence",
142
- ];
143
- log(`Running step: ${step.id} (agent: ${agentName})`);
144
- try {
444
+ function createWorkspaceAwareExecutor(metadata, compiled, runStore) {
445
+ return async (packet, provider, options) => {
446
+ const workspace = allocateAttemptWorkspace(planAttemptWorkspace({
447
+ isolation: packet.workspace.isolation,
448
+ repository_directory: metadata.project_directory,
449
+ run_id: packet.run_id,
450
+ step_id: packet.step_id,
451
+ attempt_id: packet.attempt_id,
452
+ }));
453
+ const workerPacket = {
454
+ ...packet,
455
+ workspace: {
456
+ isolation: workspace.isolation,
457
+ working_directory: workspace.working_directory,
458
+ },
459
+ };
460
+ const state = new DNAStateManager(workspace.working_directory, packet.worker_session_id);
461
+ let lifecycleRecorded = false;
145
462
  try {
146
- await stateManager.writeWorkflowState({
147
- active: true,
148
- workflow_asset: opts.workflowAsset,
149
- workflow: opts.workflowName,
150
- current_step: step.id,
151
- current_role: step.role,
152
- iteration,
153
- session_id: sessionId,
154
- started_at: new Date(start).toISOString(),
155
- inputs: opts.inputs,
156
- resolved_variables: vars,
463
+ await syncAttemptRuntimeArtifacts(compiled, metadata, workspace);
464
+ try {
465
+ await state.writeWorkflowState({
466
+ active: true,
467
+ workflow_asset: metadata.workflow_asset ?? undefined,
468
+ workflow: metadata.workflow_name,
469
+ current_step: packet.step_id,
470
+ current_role: packet.role,
471
+ iteration: packet.attempt_number,
472
+ session_id: packet.worker_session_id,
473
+ worker_session_id: packet.worker_session_id,
474
+ run_id: packet.run_id,
475
+ step_id: packet.step_id,
476
+ attempt_id: packet.attempt_id,
477
+ started_at: packet.created_at,
478
+ inputs: { ...metadata.variables },
479
+ resolved_variables: {
480
+ ...metadata.variables,
481
+ round: String(packet.attempt_number),
482
+ },
483
+ });
484
+ }
485
+ catch (error) {
486
+ log(`Workflow evidence state unavailable for ${packet.step_id}; `
487
+ + `continuing fail-open: ${error instanceof Error ? error.message : String(error)}`);
488
+ }
489
+ const inheritedProcessObserver = options?.onProcessStart;
490
+ let execution = await executeWorkerAttempt(workerPacket, provider, {
491
+ ...options,
492
+ onProcessStart: async (processId) => {
493
+ await recordAttemptProcess(runStore, workerPacket, processId);
494
+ await inheritedProcessObserver?.(processId);
495
+ },
157
496
  });
158
- stateWritten = true;
497
+ execution = await applyStepValidation(metadata, compiled, workerPacket, execution);
498
+ await recordStepResultEvidence(metadata, workerPacket, execution);
499
+ const reason = execution.result.outcome.kind === "cancelled"
500
+ ? "attempt_cancelled"
501
+ : execution.result.outcome.kind === "success"
502
+ ? preservationReason(workerPacket)
503
+ : "attempt_failed";
504
+ preserveWorkspace(workspace, reason, `attempt outcome: ${execution.result.outcome.kind}`);
505
+ lifecycleRecorded = true;
506
+ return execution;
159
507
  }
160
508
  catch (error) {
161
- log(`Workflow evidence state unavailable for ${step.id}; continuing fail-open: ${error instanceof Error ? error.message : String(error)}`);
162
- }
163
- const output = await spawnAsync("claude", args, prompt, opts.projectDir);
164
- await writeFile(resultFile, output, "utf-8");
165
- // Check for error in JSON output
166
- let isError = false;
167
- try {
168
- const parsed = JSON.parse(output);
169
- isError = parsed.is_error === true;
170
- }
171
- catch {
172
- // Non-JSON output → treat as success
173
- }
174
- const status = isError ? "fail" : "pass";
175
- if (status === "pass" && step.completion && step.completion.length > 0) {
176
- const completionsPassed = await runCompletions(step, vars, opts);
177
- if (!completionsPassed) {
178
- const durationMs = Date.now() - start;
179
- await recordStepResultEvidence(step, opts, sessionId, "fail", durationMs);
180
- log(`Step ${step.id} COMPLETION_FAIL (${durationMs}ms)`);
181
- return { stepId: step.id, status: "fail", durationMs, resultFile };
182
- }
183
- }
184
- if (status === "pass" && step.checkpoints && step.checkpoints.length > 0) {
185
- const checkpointsPassed = await runCheckpoints(step, vars, opts);
186
- if (!checkpointsPassed) {
187
- const durationMs = Date.now() - start;
188
- await recordStepResultEvidence(step, opts, sessionId, "fail", durationMs);
189
- log(`Step ${step.id} CHECKPOINT_FAIL (${durationMs}ms)`);
190
- return { stepId: step.id, status: "fail", durationMs, resultFile };
509
+ if (!lifecycleRecorded) {
510
+ preserveWorkspace(workspace, "controller_policy", `attempt launch failed: ${error instanceof Error ? error.message : String(error)}`);
191
511
  }
512
+ throw error;
192
513
  }
193
- const durationMs = Date.now() - start;
194
- await recordStepResultEvidence(step, opts, sessionId, status, durationMs);
195
- log(`Step ${step.id} ${status.toUpperCase()} (${durationMs}ms)`);
196
- return { stepId: step.id, status, durationMs, resultFile };
197
- }
198
- catch {
199
- const durationMs = Date.now() - start;
200
- await recordStepResultEvidence(step, opts, sessionId, "fail", durationMs);
201
- log(`Step ${step.id} FAIL (process error, ${durationMs}ms)`);
202
- return { stepId: step.id, status: "fail", durationMs };
203
- }
204
- finally {
205
- if (stateWritten) {
514
+ finally {
206
515
  try {
207
- await stateManager.clearWorkflowState();
516
+ await state.cleanup();
208
517
  }
209
518
  catch (error) {
210
- log(`Workflow evidence state cleanup failed for ${step.id}; continuing fail-open: ${error instanceof Error ? error.message : String(error)}`);
211
- }
212
- }
213
- }
214
- }
215
- /**
216
- * Execute a parallel group of steps.
217
- */
218
- export async function executeGroup(group, allSteps, round, vars, opts) {
219
- const steps = group.step_ids
220
- .map((id) => allSteps.find((s) => s.id === id))
221
- .filter(Boolean);
222
- const results = [];
223
- for (const step of steps) {
224
- // Evaluate run_if condition
225
- if (!evaluateRunIf(step.run_if, round)) {
226
- log(`Skipping step: ${step.id} (run_if: ${step.run_if}, round: ${round})`);
227
- results.push({ stepId: step.id, status: "skipped", durationMs: 0 });
228
- continue;
229
- }
230
- // Collect for execution
231
- results.push(step); // placeholder, replaced below
232
- }
233
- // Filter executable steps
234
- const executable = steps.filter((s) => evaluateRunIf(s.run_if, round));
235
- if (executable.length === 0)
236
- return results.filter((r) => r.status === "skipped");
237
- // Single step: sequential
238
- if (executable.length === 1) {
239
- const result = await executeStep(executable[0], vars, opts, round);
240
- const skipped = results.filter((r) => r.status === "skipped");
241
- return [...skipped, result];
242
- }
243
- // Multiple steps: parallel
244
- log(`Running ${executable.length} steps in parallel`);
245
- const parallelResults = await Promise.all(executable.map((step) => executeStep(step, vars, opts, round)));
246
- const skipped = results.filter((r) => r.status === "skipped");
247
- return [...skipped, ...parallelResults];
248
- }
249
- /**
250
- * Check if any transition requires retry.
251
- */
252
- export function checkRetryNeeded(stepResults, transitions) {
253
- for (const t of transitions) {
254
- if (t.condition === "fail") {
255
- const result = stepResults.get(t.from);
256
- if (result && result.status === "fail") {
257
- return true;
519
+ log(`Worker Hook state cleanup failed for ${packet.worker_session_id}: `
520
+ + `${error instanceof Error ? error.message : String(error)}`);
258
521
  }
259
522
  }
260
- }
261
- return false;
523
+ };
262
524
  }
263
- // ── Spawn Helper ───────────────────────────────────────────
264
- function spawnAsync(command, args, stdinData, cwd) {
265
- return new Promise((resolve, reject) => {
266
- const child = spawn(command, args, {
267
- stdio: ["pipe", "pipe", "pipe"],
268
- cwd,
269
- });
270
- let stdout = "";
271
- let stderr = "";
272
- child.stdout.on("data", (data) => {
273
- stdout += data.toString();
274
- });
275
- child.stderr.on("data", (data) => {
276
- stderr += data.toString();
277
- // Stream stderr in real-time
278
- process.stderr.write(data);
279
- });
280
- child.on("error", reject);
281
- child.on("close", (code) => {
282
- if (code !== 0 && stdout === "") {
283
- reject(new Error(`claude exited with code ${code}: ${stderr}`));
284
- }
285
- else {
286
- resolve(stdout);
287
- }
288
- });
289
- child.stdin.write(stdinData);
290
- child.stdin.end();
525
+ function buildRuntime(metadata, compiled) {
526
+ const runStore = new DurableRunStore({
527
+ root_directory: runtimeRoot(metadata.project_directory),
528
+ });
529
+ const resultStore = new ImmutableResultStore(runStore);
530
+ const resolver = new HandoffResolver(resultStore);
531
+ const provider = createProvider(metadata);
532
+ const controller = new RunController({
533
+ run_store: runStore,
534
+ handoff_resolver: resolver,
535
+ steps: compiled.definitions,
536
+ provider_for_step: () => provider,
537
+ reconcile_attempt: reconcileLocalProcess,
538
+ execute_attempt: createWorkspaceAwareExecutor(metadata, compiled, runStore),
539
+ max_concurrency: metadata.max_concurrency,
540
+ lease_duration_ms: 15_000,
541
+ lease_heartbeat_ms: 3_000,
542
+ scheduler_poll_ms: 50,
291
543
  });
544
+ return { run_store: runStore, controller };
292
545
  }
293
- // ── Logging ────────────────────────────────────────────────
294
- function log(msg) {
295
- const ts = new Date().toLocaleTimeString("en-US", { hour12: false });
296
- process.stderr.write(`[${ts}] ${msg}\n`);
546
+ function buildCancellationController(runStore) {
547
+ return new RunController({
548
+ run_store: runStore,
549
+ steps: [],
550
+ provider_for_step: () => {
551
+ throw new Error("cancellation-only Controller cannot launch a Step");
552
+ },
553
+ reconcile_attempt: reconcileLocalProcess,
554
+ max_concurrency: 1,
555
+ lease_duration_ms: 15_000,
556
+ lease_heartbeat_ms: 3_000,
557
+ scheduler_poll_ms: 50,
558
+ });
297
559
  }
298
- // ── Main Entry ─────────────────────────────────────────────
299
- export async function runRun(opts) {
300
- // Validate required inputs
560
+ async function startMetadata(opts, projectDirectory) {
301
561
  if (opts.dnaFiles.length === 0) {
302
- process.stderr.write("Error: --dna requires at least one DNA file\n");
303
- return 2;
562
+ throw new RunUsageError("--dna requires at least one DNA file");
304
563
  }
305
- if (!opts.workflowName) {
306
- process.stderr.write("Error: --workflow is required\n");
307
- return 2;
564
+ const workflowName = requireText(opts.workflowName, "--workflow");
565
+ const taskId = requireText(opts.taskId, "--task");
566
+ const expanded = await expandDNAInputFiles(opts.dnaFiles, {
567
+ cwd: projectDirectory,
568
+ });
569
+ if (expanded.length === 0) {
570
+ throw new RunUsageError("--dna did not resolve any DNA files");
571
+ }
572
+ const variables = {
573
+ ...(opts.vars ?? {}),
574
+ task_id: taskId,
575
+ ARGUMENTS: taskId,
576
+ workflow: workflowName,
577
+ };
578
+ const maxBudget = requirePositiveNumber(opts.maxBudget ?? 5, "--max-budget");
579
+ const maxConcurrency = requirePositiveInteger(opts.maxConcurrency ?? 1, "--max-concurrency");
580
+ const timeoutMs = opts.timeoutMs === null || opts.timeoutMs === undefined
581
+ ? null
582
+ : requireNonNegativeInteger(opts.timeoutMs, "--timeout-ms");
583
+ const cancellationGraceMs = requireNonNegativeInteger(opts.cancellationGraceMs ?? 5_000, "--cancellation-grace-ms");
584
+ const permissionMode = requireText(opts.permissionMode ?? "default", "--permission-mode");
585
+ return {
586
+ schema_version: "intentdna.canonical_run.v1",
587
+ project_directory: projectDirectory,
588
+ dna_files: expanded.map((file) => resolve(projectDirectory, file)),
589
+ workflow_name: workflowName,
590
+ task_id: taskId,
591
+ context: opts.context ?? null,
592
+ variables,
593
+ agents_directory: opts.agentsDir
594
+ ? resolve(projectDirectory, opts.agentsDir)
595
+ : join(projectDirectory, ".claude", "agents"),
596
+ provider: {
597
+ kind: opts.provider ?? "claude",
598
+ executable: opts.providerExecutable === undefined
599
+ ? null
600
+ : requireText(opts.providerExecutable, "--provider-executable"),
601
+ max_budget_usd: maxBudget,
602
+ permission_mode: permissionMode,
603
+ },
604
+ max_concurrency: maxConcurrency,
605
+ timeout_ms: timeoutMs,
606
+ cancellation_grace_ms: cancellationGraceMs,
607
+ workflow_asset: null,
608
+ };
609
+ }
610
+ async function runtimeForExistingRun(runStore, runId, projectDirectory) {
611
+ const snapshot = await runStore.requireRun(runId);
612
+ const metadata = readCanonicalMetadata(snapshot.run.metadata);
613
+ if (resolve(metadata.project_directory) !== projectDirectory) {
614
+ throw new Error(`run ${runId} belongs to project ${metadata.project_directory}, not ${projectDirectory}`);
308
615
  }
309
- if (!opts.taskId) {
310
- process.stderr.write("Error: --task is required\n");
311
- return 2;
616
+ const compiled = await compileCanonicalRun(metadata);
617
+ if (compiled.plan_digest !== snapshot.run.plan_digest) {
618
+ throw new Error(`run ${runId} plan digest changed; resume requires the original DNA and role definitions`);
312
619
  }
313
- const maxBudget = opts.maxBudget ?? 5;
314
- const permissionMode = opts.permissionMode ?? "default";
315
- const projectDir = resolve(opts.projectDir ?? process.cwd());
316
- const agentsDir = opts.agentsDir ? resolve(projectDir, opts.agentsDir) : join(projectDir, ".claude", "agents");
620
+ await syncRuntimeArtifacts(compiled, metadata);
621
+ return buildRuntime(metadata, compiled);
622
+ }
623
+ // ── CLI entry ──────────────────────────────────────────────
624
+ export async function runRun(opts) {
625
+ const action = opts.action ?? "start";
626
+ const projectDirectory = resolve(opts.projectDir ?? process.cwd());
627
+ const runStore = new DurableRunStore({
628
+ root_directory: runtimeRoot(projectDirectory),
629
+ });
317
630
  try {
318
- // ── Step 1: Load & compile DNA ───────────────────────
319
- log("Loading DNA files...");
320
- const expandedDNAFiles = await expandDNAInputFiles(opts.dnaFiles, { cwd: projectDir });
321
- const dnas = await Promise.all(expandedDNAFiles.map(loadDNA));
322
- const cascaded = cascadeDNA(dnas);
323
- const ir = await compileFromFiles(expandedDNAFiles, {
324
- context: opts.context,
325
- });
326
- // ── Step 2: Find & compile workflow ──────────────────
327
- const wfDef = cascaded.workflows[opts.workflowName];
328
- if (!wfDef) {
329
- process.stderr.write(`Error: Workflow '${opts.workflowName}' not found. Available: ${Object.keys(cascaded.workflows).join(", ") || "(none)"}\n`);
330
- return 1;
631
+ if (action !== "start"
632
+ && action !== "status"
633
+ && action !== "inspect"
634
+ && action !== "resume"
635
+ && action !== "cancel") {
636
+ throw new RunUsageError(`unsupported run action '${String(action)}'`);
331
637
  }
332
- const wfResult = compileWorkflow(wfDef, { workflow_key: opts.workflowName });
333
- if (!wfResult.ok) {
334
- process.stderr.write(`Error: Workflow compilation failed:\n`);
335
- for (const err of wfResult.errors) {
336
- process.stderr.write(` ${err.path}: ${err.message}\n`);
337
- }
338
- return 1;
638
+ if (action === "status") {
639
+ return runLifecycleStatus({
640
+ run_store: runStore,
641
+ run_id: asRunId(requireText(opts.runId, "run_id")),
642
+ json: opts.json,
643
+ });
339
644
  }
340
- const plan = wfResult.plan;
341
- log(`Compiled workflow: ${plan.name} (${plan.steps.length} steps, ${plan.parallel_groups.length} groups)`);
342
- // ── Step 3: Build template variables ─────────────────
343
- const vars = {
344
- ...(opts.vars ?? {}),
345
- task_id: opts.taskId,
346
- ARGUMENTS: opts.taskId,
347
- workflow: opts.workflowName,
348
- };
349
- // ── Dry-run mode ─────────────────────────────────────
645
+ if (action === "inspect") {
646
+ return runLifecycleInspect({
647
+ run_store: runStore,
648
+ run_id: asRunId(requireText(opts.runId, "run_id")),
649
+ json: opts.json,
650
+ });
651
+ }
652
+ if (action === "resume") {
653
+ const runId = asRunId(requireText(opts.runId, "run_id"));
654
+ const runtime = await runtimeForExistingRun(runStore, runId, projectDirectory);
655
+ return runLifecycleResume({
656
+ controller: runtime.controller,
657
+ run_id: runId,
658
+ json: opts.json,
659
+ });
660
+ }
661
+ if (action === "cancel") {
662
+ const runId = asRunId(requireText(opts.runId, "run_id"));
663
+ return runLifecycleCancel({
664
+ controller: buildCancellationController(runStore),
665
+ run_id: runId,
666
+ reason: opts.reason,
667
+ json: opts.json,
668
+ });
669
+ }
670
+ const initialMetadata = await startMetadata(opts, projectDirectory);
350
671
  if (opts.dryRun) {
351
- return printDryRun(plan, vars);
672
+ const firstPreview = await compileRunAssets(initialMetadata);
673
+ const previewMetadata = {
674
+ ...initialMetadata,
675
+ workflow_asset: firstPreview.workflow_asset,
676
+ };
677
+ const preview = previewMetadata.workflow_asset
678
+ === initialMetadata.workflow_asset
679
+ ? firstPreview
680
+ : await compileRunAssets(previewMetadata);
681
+ return printDryRun(preview.plan, previewMetadata, preview.plan_digest);
352
682
  }
353
- // ── Step 4: Auto-sync agents & hooks ─────────────────
354
- log("Syncing agents and hooks...");
355
- const agentResults = compileAllRolesToAgentMD(cascaded.roles, ir);
356
- await writeAgentMDFiles(agentResults, agentsDir);
357
- log(`Generated ${agentResults.length} agent file(s) in ${agentsDir}`);
358
- const hookScripts = createCompiledIR(ir, cascaded.roles);
359
- await writeCompiledIR(projectDir, hookScripts);
360
- log("Synced compiled IR for dna-hook enforcement");
361
- // ── Step 5: Prepare log directory ────────────────────
362
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
363
- const logDir = opts.logDir
364
- ? resolve(projectDir, opts.logDir)
365
- : join(projectDir, ".intentdna", "runs", `${opts.workflowName}-${timestamp}`);
366
- await mkdir(logDir, { recursive: true });
367
- const attribution = trustedEvidenceCaptureAttribution(ir, { workflowId: opts.workflowName });
368
- const execOpts = {
369
- agentPrefix: "dna-",
370
- maxBudget,
371
- permissionMode,
372
- logDir,
373
- projectDir,
374
- workflowName: opts.workflowName,
375
- workflowAsset: attribution.workflowAsset === "unknown" ? undefined : attribution.workflowAsset,
376
- inputs: {
377
- ...(opts.vars ?? {}),
378
- task_id: opts.taskId,
379
- ARGUMENTS: opts.taskId,
380
- workflow: opts.workflowName,
381
- },
382
- ir,
683
+ const firstCompilation = await compileCanonicalRun(initialMetadata);
684
+ const metadata = {
685
+ ...initialMetadata,
686
+ workflow_asset: firstCompilation.workflow_asset,
383
687
  };
384
- // ── Step 6: Execute workflow ──────────────────────────
385
- const maxRetries = plan.retry.max_retries;
386
- const allTransitions = [...plan.transitions, ...plan.default_transitions];
387
- const stepResults = new Map();
388
- let round = 1;
389
- while (round <= maxRetries + 1) {
390
- log(`\n=== Round ${round}/${maxRetries + 1} ===`);
391
- // Update round variable for templates
392
- const roundVars = { ...vars, round: String(round) };
393
- for (const group of plan.parallel_groups) {
394
- const groupResults = await executeGroup(group, plan.steps, round, roundVars, execOpts);
395
- // Store results
396
- for (const result of groupResults) {
397
- stepResults.set(result.stepId, result);
398
- }
399
- // Check for non-optional step failure (abort group execution)
400
- for (const result of groupResults) {
401
- if (result.status === "fail") {
402
- const step = plan.steps.find((s) => s.id === result.stepId);
403
- if (step && !step.optional) {
404
- // Check if there's a fail transition for this step
405
- const hasFailTransition = allTransitions.some((t) => t.from === result.stepId && t.condition === "fail");
406
- if (!hasFailTransition) {
407
- log(`Non-optional step '${result.stepId}' failed with no fail transition — aborting`);
408
- return printSummary(stepResults, round, logDir, 1);
409
- }
410
- }
411
- }
412
- }
413
- }
414
- // Check if retry needed
415
- if (checkRetryNeeded(stepResults, allTransitions)) {
416
- if (round >= maxRetries + 1) {
417
- log(`Max retries (${maxRetries}) exhausted`);
418
- return printSummary(stepResults, round, logDir, 1);
419
- }
420
- log("Retry triggered by fail transition");
421
- round++;
422
- continue;
423
- }
424
- // All passed
425
- break;
426
- }
427
- return printSummary(stepResults, round, logDir, 0);
688
+ const compiled = metadata.workflow_asset === initialMetadata.workflow_asset
689
+ ? firstCompilation
690
+ : await compileCanonicalRun(metadata);
691
+ await syncRuntimeArtifacts(compiled, metadata);
692
+ const runtime = buildRuntime(metadata, compiled);
693
+ const now = new Date().toISOString();
694
+ const runId = opts.runId?.trim()
695
+ ? asRunId(opts.runId)
696
+ : `run_${randomUUID()}`;
697
+ const run = {
698
+ run_id: runId,
699
+ workflow_id: metadata.workflow_name,
700
+ workflow_version: null,
701
+ plan_digest: compiled.plan_digest,
702
+ status: "active",
703
+ cancellation: null,
704
+ terminal: null,
705
+ metadata: metadata,
706
+ created_at: now,
707
+ updated_at: now,
708
+ };
709
+ return runLifecycleStart({
710
+ controller: runtime.controller,
711
+ run,
712
+ json: opts.json,
713
+ });
428
714
  }
429
- catch (err) {
430
- process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}\n`);
431
- return 1;
715
+ catch (error) {
716
+ const usage = error instanceof RunUsageError;
717
+ process.stderr.write(`${usage ? "Usage error" : "Error"}: `
718
+ + `${error instanceof Error ? error.message : String(error)}\n`);
719
+ return usage ? 2 : 1;
432
720
  }
433
721
  }
434
- // ── Output Helpers ─────────────────────────────────────────
435
- function printDryRun(plan, vars) {
436
- process.stderr.write("\n=== DRY RUN — Execution Plan ===\n\n");
722
+ function printDryRun(plan, metadata, digest) {
723
+ process.stderr.write("\n=== DRY RUN - Canonical Controller Plan ===\n\n");
437
724
  process.stderr.write(`Workflow: ${plan.name}\n`);
438
- process.stderr.write(`Steps: ${plan.steps.length}\n`);
725
+ process.stderr.write(`Plan digest: ${digest}\n`);
726
+ process.stderr.write(`Provider: ${metadata.provider.kind}\n`);
727
+ process.stderr.write(`Max concurrency: ${metadata.max_concurrency}\n`);
728
+ process.stderr.write(`Durable root: ${runtimeRoot(metadata.project_directory)}\n`);
729
+ process.stderr.write(`Steps: ${plan.steps.length}\n\n`);
439
730
  process.stderr.write(`Groups: ${plan.parallel_groups.length}\n`);
440
731
  process.stderr.write(`Max retries: ${plan.retry.max_retries}\n`);
441
732
  if (plan.retry.retry_from) {
442
733
  process.stderr.write(`Retry from: ${plan.retry.retry_from}\n`);
443
734
  }
444
- process.stderr.write(`Variables: ${JSON.stringify(vars)}\n`);
445
- process.stderr.write("\nExecution order:\n");
735
+ process.stderr.write(`Variables: ${JSON.stringify(metadata.variables)}\n\n`);
446
736
  for (const group of plan.parallel_groups) {
447
- const parallel = group.step_ids.length > 1 ? " (parallel)" : "";
448
- process.stderr.write(`\n Group ${group.group_index + 1}${parallel}:\n`);
737
+ process.stderr.write(`Group ${group.group_index + 1} (${group.isolation}):\n`);
449
738
  for (const stepId of group.step_ids) {
450
- const step = plan.steps.find((s) => s.id === stepId);
451
- const prompt = substituteTemplate(step.prompt ?? step.description, vars);
739
+ const step = plan.steps.find((entry) => entry.id === stepId);
740
+ if (!step)
741
+ continue;
742
+ const prompt = substituteTemplate(step.prompt ?? step.description, metadata.variables);
452
743
  const runIf = step.run_if ? ` [run_if: ${step.run_if}]` : "";
453
744
  const optional = step.optional ? " (optional)" : "";
454
- process.stderr.write(` - ${step.id} agent: dna-${toKebabCase(step.role)}${runIf}${optional}\n`);
455
- process.stderr.write(` prompt: ${prompt.slice(0, 120)}${prompt.length > 120 ? "..." : ""}\n`);
745
+ process.stderr.write(` - ${step.id} -> agent: dna-${toKebabCase(step.role)}`
746
+ + `${runIf}${optional}; independent session\n`);
747
+ process.stderr.write(` prompt: ${prompt.slice(0, 120)}${prompt.length > 120 ? "..." : ""}\n`);
456
748
  }
457
749
  }
458
750
  if (plan.transitions.length > 0) {
459
751
  process.stderr.write("\nTransitions:\n");
460
- for (const t of plan.transitions) {
461
- process.stderr.write(` ${t.from} ${t.to} (on ${t.condition})\n`);
752
+ for (const transition of plan.transitions) {
753
+ process.stderr.write(` ${transition.from} -> ${transition.to} `
754
+ + `(on ${transition.condition})\n`);
462
755
  }
463
756
  }
464
757
  process.stderr.write("\n");
465
758
  return 0;
466
759
  }
467
- function printSummary(results, rounds, logDir, exitCode) {
468
- process.stderr.write("\n=== Workflow Summary ===\n");
469
- process.stderr.write(`Rounds executed: ${rounds}\n`);
470
- process.stderr.write(`Log directory: ${logDir}\n\n`);
471
- for (const [stepId, result] of results) {
472
- const icon = result.status === "pass" ? "PASS" : result.status === "fail" ? "FAIL" : "SKIP";
473
- const duration = result.durationMs > 0 ? ` (${(result.durationMs / 1000).toFixed(1)}s)` : "";
474
- process.stderr.write(` [${icon}] ${stepId}${duration}\n`);
475
- }
476
- const passed = [...results.values()].filter((r) => r.status === "pass").length;
477
- const failed = [...results.values()].filter((r) => r.status === "fail").length;
478
- const skipped = [...results.values()].filter((r) => r.status === "skipped").length;
479
- process.stderr.write(`\nTotal: ${passed} passed, ${failed} failed, ${skipped} skipped\n`);
480
- return exitCode;
760
+ function log(message) {
761
+ const timestamp = new Date().toLocaleTimeString("en-US", { hour12: false });
762
+ process.stderr.write(`[${timestamp}] ${message}\n`);
481
763
  }