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,17 +1,21 @@
1
1
  /**
2
2
  * Intent DNA -- Workflow Shell Script Runner
3
3
  *
4
- * Compiles a WorkflowPlan into an executable bash script.
4
+ * Compiles a WorkflowPlan into a legacy executable bash script.
5
5
  * Each step invokes `claude -p --agent <role-name>`.
6
6
  * Parallel groups use background jobs + wait.
7
7
  * Retry loops wrap the execution with round tracking.
8
8
  *
9
+ * This module is a compatibility exporter only. Canonical execution uses the
10
+ * typed RunController through `dna run`; generated scripts do not own durable
11
+ * run state, handoff resolution, resume, cancellation, or workspace lifecycle.
12
+ *
9
13
  * Output: a self-contained shell script with set -euo pipefail,
10
14
  * run_agent helper, parallel group execution, retry logic, and
11
15
  * transition-based flow control.
12
16
  */
13
17
  import type { WorkflowPlan } from "../schema/types.js";
14
- /** Options for workflow shell script generation */
18
+ /** @deprecated Compatibility exporter options; use the canonical `dna run` runtime. */
15
19
  export interface WorkflowShellOptions {
16
20
  /** Agent name prefix (default "dna-") */
17
21
  agentPrefix?: string;
@@ -26,7 +30,7 @@ export interface WorkflowShellOptions {
26
30
  /** Include pause/resume support (default false) */
27
31
  pauseSupport?: boolean;
28
32
  }
29
- /** Result of workflow shell script generation */
33
+ /** @deprecated Compatibility exporter result; use the canonical `dna run` runtime. */
30
34
  export interface WorkflowShellResult {
31
35
  /** The generated script content */
32
36
  script: string;
@@ -36,17 +40,25 @@ export interface WorkflowShellResult {
36
40
  /**
37
41
  * Compile a WorkflowPlan into an executable shell script.
38
42
  * Pure function, no side effects.
43
+ *
44
+ * @deprecated Generated shell has legacy execution semantics. Use the typed
45
+ * RunController through `dna run` for canonical execution.
39
46
  */
40
47
  export declare function compileWorkflowToShell(plan: WorkflowPlan, options?: WorkflowShellOptions): WorkflowShellResult;
41
48
  /**
42
49
  * Write workflow shell script to the file system.
43
50
  * Creates the directory if it doesn't exist.
44
51
  * Returns the path of the written file.
52
+ *
53
+ * @deprecated Writes a legacy compatibility export; it is not an execution
54
+ * entrypoint for the Foundation Runtime.
45
55
  */
46
56
  export declare function writeWorkflowScript(result: WorkflowShellResult, outputDir: string): Promise<string>;
47
57
  /**
48
58
  * Remove Intent DNA generated workflow scripts from a directory.
49
59
  * Only removes files that contain the sentinel comment.
50
60
  * Returns number of files removed.
61
+ *
62
+ * @deprecated Manages only legacy compatibility exports.
51
63
  */
52
64
  export declare function removeWorkflowScripts(outputDir: string): Promise<number>;
@@ -1,11 +1,15 @@
1
1
  /**
2
2
  * Intent DNA -- Workflow Shell Script Runner
3
3
  *
4
- * Compiles a WorkflowPlan into an executable bash script.
4
+ * Compiles a WorkflowPlan into a legacy executable bash script.
5
5
  * Each step invokes `claude -p --agent <role-name>`.
6
6
  * Parallel groups use background jobs + wait.
7
7
  * Retry loops wrap the execution with round tracking.
8
8
  *
9
+ * This module is a compatibility exporter only. Canonical execution uses the
10
+ * typed RunController through `dna run`; generated scripts do not own durable
11
+ * run state, handoff resolution, resume, cancellation, or workspace lifecycle.
12
+ *
9
13
  * Output: a self-contained shell script with set -euo pipefail,
10
14
  * run_agent helper, parallel group execution, retry logic, and
11
15
  * transition-based flow control.
@@ -460,6 +464,9 @@ function generateFailureChecks(plan) {
460
464
  /**
461
465
  * Compile a WorkflowPlan into an executable shell script.
462
466
  * Pure function, no side effects.
467
+ *
468
+ * @deprecated Generated shell has legacy execution semantics. Use the typed
469
+ * RunController through `dna run` for canonical execution.
463
470
  */
464
471
  export function compileWorkflowToShell(plan, options) {
465
472
  const opts = {
@@ -544,6 +551,9 @@ export function compileWorkflowToShell(plan, options) {
544
551
  * Write workflow shell script to the file system.
545
552
  * Creates the directory if it doesn't exist.
546
553
  * Returns the path of the written file.
554
+ *
555
+ * @deprecated Writes a legacy compatibility export; it is not an execution
556
+ * entrypoint for the Foundation Runtime.
547
557
  */
548
558
  export async function writeWorkflowScript(result, outputDir) {
549
559
  await mkdir(outputDir, { recursive: true });
@@ -555,6 +565,8 @@ export async function writeWorkflowScript(result, outputDir) {
555
565
  * Remove Intent DNA generated workflow scripts from a directory.
556
566
  * Only removes files that contain the sentinel comment.
557
567
  * Returns number of files removed.
568
+ *
569
+ * @deprecated Manages only legacy compatibility exports.
558
570
  */
559
571
  export async function removeWorkflowScripts(outputDir) {
560
572
  let entries;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Typed adaptation of oh-my-codex v0.20.3 worktree allocation.
3
+ *
4
+ * IntentDNA keys workspaces by durable attempt identity, records an exact base
5
+ * commit, and leaves preservation or cleanup to an explicit Controller decision.
6
+ * Team naming, CLI parsing, generated shell, and automatic rollback are excluded.
7
+ */
8
+ import type { AttemptId, RunId, StepId } from "./run-contracts.js";
9
+ export type WorkspaceIsolation = "none" | "worktree";
10
+ export type WorkspaceIsolationErrorCode = "invalid_request" | "not_git_repository" | "git_command_failed" | "path_conflict" | "branch_conflict" | "workspace_mismatch" | "unsafe_cleanup_target";
11
+ export declare class WorkspaceIsolationError extends Error {
12
+ readonly code: WorkspaceIsolationErrorCode;
13
+ constructor(code: WorkspaceIsolationErrorCode, message: string);
14
+ }
15
+ export interface AttemptWorkspaceRequest {
16
+ readonly isolation: WorkspaceIsolation;
17
+ readonly repository_directory: string;
18
+ readonly run_id: RunId;
19
+ readonly step_id: StepId;
20
+ readonly attempt_id: AttemptId;
21
+ /**
22
+ * Git revision to resolve once and record as the immutable worktree base.
23
+ * Defaults to HEAD. It is ignored for isolation=none.
24
+ */
25
+ readonly base_ref?: string;
26
+ /**
27
+ * Parent directory owned by this service. Defaults to
28
+ * <repository>/.dna/worktrees/runtime.
29
+ */
30
+ readonly worktree_root?: string;
31
+ }
32
+ export interface SharedWorkspacePlan {
33
+ readonly isolation: "none";
34
+ readonly run_id: RunId;
35
+ readonly step_id: StepId;
36
+ readonly attempt_id: AttemptId;
37
+ readonly working_directory: string;
38
+ }
39
+ export interface IsolatedWorkspacePlan {
40
+ readonly isolation: "worktree";
41
+ readonly run_id: RunId;
42
+ readonly step_id: StepId;
43
+ readonly attempt_id: AttemptId;
44
+ readonly repository_root: string;
45
+ readonly worktree_root: string;
46
+ readonly working_directory: string;
47
+ readonly branch_name: string;
48
+ readonly base_ref: string;
49
+ readonly base_commit: string;
50
+ readonly ownership_key: string;
51
+ }
52
+ export type AttemptWorkspacePlan = SharedWorkspacePlan | IsolatedWorkspacePlan;
53
+ export interface SharedWorkspaceAllocation extends SharedWorkspacePlan {
54
+ readonly created: false;
55
+ readonly reused: true;
56
+ }
57
+ export interface IsolatedWorkspaceAllocation extends IsolatedWorkspacePlan {
58
+ readonly created: boolean;
59
+ readonly reused: boolean;
60
+ readonly dirty_at_allocation: boolean;
61
+ readonly conflicted_at_allocation: boolean;
62
+ }
63
+ export type AttemptWorkspaceAllocation = SharedWorkspaceAllocation | IsolatedWorkspaceAllocation;
64
+ export interface WorkspaceInspection {
65
+ readonly exists: boolean;
66
+ readonly registered: boolean;
67
+ readonly owned_by_attempt: boolean;
68
+ readonly dirty: boolean;
69
+ readonly conflicted: boolean;
70
+ readonly head: string | null;
71
+ readonly branch_ref: string | null;
72
+ }
73
+ export type WorkspacePreservationReason = "attempt_failed" | "attempt_cancelled" | "integration_conflicted" | "integration_pending" | "controller_policy";
74
+ export type WorkspaceLifecycleDecision = {
75
+ readonly action: "preserve";
76
+ readonly reason: WorkspacePreservationReason;
77
+ readonly detail?: string;
78
+ } | {
79
+ readonly action: "cleanup";
80
+ readonly attempt_outcome: "succeeded";
81
+ readonly integration: "integrated";
82
+ /** Delete the attempt branch after removing the worktree. Default: true. */
83
+ readonly delete_branch?: boolean;
84
+ };
85
+ export type WorkspaceLifecycleResult = {
86
+ readonly status: "not_applicable";
87
+ readonly working_directory: string;
88
+ } | {
89
+ readonly status: "preserved";
90
+ readonly working_directory: string;
91
+ readonly reason: WorkspacePreservationReason | "dirty" | "conflicted";
92
+ readonly detail: string | null;
93
+ } | {
94
+ readonly status: "cleaned";
95
+ readonly working_directory: string;
96
+ readonly branch_deleted: boolean;
97
+ };
98
+ export declare function planAttemptWorkspace(request: AttemptWorkspaceRequest): AttemptWorkspacePlan;
99
+ export declare function allocateAttemptWorkspace(plan: AttemptWorkspacePlan): AttemptWorkspaceAllocation;
100
+ export declare function inspectAttemptWorkspace(workspace: AttemptWorkspaceAllocation): WorkspaceInspection;
101
+ export declare function applyWorkspaceLifecycleDecision(workspace: AttemptWorkspaceAllocation, decision: WorkspaceLifecycleDecision): WorkspaceLifecycleResult;
102
+ export declare function defaultWorktreeRoot(repositoryRoot: string): string;
103
+ export declare function describeWorkspace(workspace: AttemptWorkspacePlan): string;
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Typed adaptation of oh-my-codex v0.20.3 worktree allocation.
3
+ *
4
+ * IntentDNA keys workspaces by durable attempt identity, records an exact base
5
+ * commit, and leaves preservation or cleanup to an explicit Controller decision.
6
+ * Team naming, CLI parsing, generated shell, and automatic rollback are excluded.
7
+ */
8
+ import { spawnSync } from "node:child_process";
9
+ import { createHash } from "node:crypto";
10
+ import { existsSync, lstatSync, mkdirSync, realpathSync, } from "node:fs";
11
+ import { basename, dirname, isAbsolute, join, relative, resolve, } from "node:path";
12
+ export class WorkspaceIsolationError extends Error {
13
+ code;
14
+ constructor(code, message) {
15
+ super(message);
16
+ this.name = "WorkspaceIsolationError";
17
+ this.code = code;
18
+ }
19
+ }
20
+ function git(cwd, args, allowFailure = false) {
21
+ const result = spawnSync("git", [...args], {
22
+ cwd,
23
+ encoding: "utf8",
24
+ windowsHide: true,
25
+ stdio: ["ignore", "pipe", "pipe"],
26
+ });
27
+ const output = {
28
+ status: result.status,
29
+ stdout: result.stdout ?? "",
30
+ stderr: result.stderr ?? "",
31
+ };
32
+ if (!allowFailure && output.status !== 0) {
33
+ const detail = output.stderr.trim() || output.stdout.trim();
34
+ throw new WorkspaceIsolationError("git_command_failed", detail || `git ${args.join(" ")} failed`);
35
+ }
36
+ return output;
37
+ }
38
+ function requireText(value, label) {
39
+ const trimmed = value.trim();
40
+ if (!trimmed || trimmed.includes("\0")) {
41
+ throw new WorkspaceIsolationError("invalid_request", `${label} must be non-empty and contain no NUL byte`);
42
+ }
43
+ return trimmed;
44
+ }
45
+ function canonicalExistingDirectory(path, label) {
46
+ const requested = resolve(requireText(path, label));
47
+ if (!existsSync(requested)) {
48
+ throw new WorkspaceIsolationError("invalid_request", `${label} does not exist: ${requested}`);
49
+ }
50
+ const status = lstatSync(requested);
51
+ if (status.isSymbolicLink() || !status.isDirectory()) {
52
+ throw new WorkspaceIsolationError("invalid_request", `${label} must be a real directory: ${requested}`);
53
+ }
54
+ return realpathSync(requested);
55
+ }
56
+ function repositoryRoot(cwd) {
57
+ const result = git(cwd, ["rev-parse", "--show-toplevel"], true);
58
+ if (result.status !== 0 || !result.stdout.trim()) {
59
+ throw new WorkspaceIsolationError("not_git_repository", `not a Git repository: ${cwd}`);
60
+ }
61
+ return realpathSync(resolve(result.stdout.trim()));
62
+ }
63
+ function resolveCommit(repoRoot, ref) {
64
+ const value = requireText(ref, "base_ref");
65
+ const result = git(repoRoot, ["rev-parse", "--verify", `${value}^{commit}`], true);
66
+ if (result.status !== 0 || !result.stdout.trim()) {
67
+ throw new WorkspaceIsolationError("invalid_request", `base_ref does not resolve to a commit: ${value}`);
68
+ }
69
+ return result.stdout.trim();
70
+ }
71
+ function pathToken(value) {
72
+ const normalized = value
73
+ .toLowerCase()
74
+ .replace(/[^a-z0-9]+/g, "-")
75
+ .replace(/-+/g, "-")
76
+ .replace(/^-|-$/g, "")
77
+ .slice(0, 32);
78
+ return normalized || "step";
79
+ }
80
+ function ownershipKey(runId, stepId, attemptId) {
81
+ return createHash("sha256")
82
+ .update(JSON.stringify([runId, stepId, attemptId]))
83
+ .digest("hex")
84
+ .slice(0, 16);
85
+ }
86
+ function isInside(parent, child) {
87
+ const rel = relative(parent, child);
88
+ return rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel);
89
+ }
90
+ function assertSafeWorktreeRoot(repository, worktreeRoot) {
91
+ if (!isInside(repository, worktreeRoot)) {
92
+ throw new WorkspaceIsolationError("invalid_request", `worktree_root must be a child of the repository: ${worktreeRoot}`);
93
+ }
94
+ const components = relative(repository, worktreeRoot).split(/[\\/]+/);
95
+ let cursor = repository;
96
+ for (const component of components) {
97
+ cursor = join(cursor, component);
98
+ if (!existsSync(cursor))
99
+ continue;
100
+ const status = lstatSync(cursor);
101
+ if (status.isSymbolicLink() || !status.isDirectory()) {
102
+ throw new WorkspaceIsolationError("path_conflict", `worktree_root contains a non-directory or symlink component: ${cursor}`);
103
+ }
104
+ }
105
+ if (existsSync(worktreeRoot)) {
106
+ const canonical = realpathSync(worktreeRoot);
107
+ if (!isInside(repository, canonical)) {
108
+ throw new WorkspaceIsolationError("path_conflict", `worktree_root resolves outside the repository: ${canonical}`);
109
+ }
110
+ }
111
+ }
112
+ function assertOwnedPath(worktreeRoot, worktreePath, repository, requireExisting) {
113
+ const root = existsSync(worktreeRoot)
114
+ ? realpathSync(worktreeRoot)
115
+ : resolve(worktreeRoot);
116
+ if (!isInside(repository, root)) {
117
+ throw new WorkspaceIsolationError("unsafe_cleanup_target", `owned worktree root resolves outside the repository: ${root}`);
118
+ }
119
+ const target = resolve(worktreePath);
120
+ if (!isInside(root, target) || target === repository) {
121
+ throw new WorkspaceIsolationError("unsafe_cleanup_target", `workspace is outside its owned root: ${target}`);
122
+ }
123
+ if (!existsSync(target)) {
124
+ if (requireExisting) {
125
+ throw new WorkspaceIsolationError("unsafe_cleanup_target", `workspace does not exist: ${target}`);
126
+ }
127
+ return;
128
+ }
129
+ const status = lstatSync(target);
130
+ if (status.isSymbolicLink() || !status.isDirectory()) {
131
+ throw new WorkspaceIsolationError("unsafe_cleanup_target", `workspace is not a real directory: ${target}`);
132
+ }
133
+ const canonical = realpathSync(target);
134
+ if (!isInside(root, canonical) || canonical === repository) {
135
+ throw new WorkspaceIsolationError("unsafe_cleanup_target", `workspace resolves outside its owned root: ${canonical}`);
136
+ }
137
+ const dotGit = join(canonical, ".git");
138
+ if (existsSync(dotGit) && lstatSync(dotGit).isDirectory()) {
139
+ throw new WorkspaceIsolationError("unsafe_cleanup_target", `workspace points at a main repository: ${canonical}`);
140
+ }
141
+ }
142
+ function listWorktrees(repoRoot) {
143
+ const raw = git(repoRoot, ["worktree", "list", "--porcelain"]).stdout.trim();
144
+ if (!raw)
145
+ return [];
146
+ return raw.split(/\n\n+/).flatMap((block) => {
147
+ const lines = block.split(/\r?\n/).filter(Boolean);
148
+ const pathLine = lines.find((line) => line.startsWith("worktree "));
149
+ const headLine = lines.find((line) => line.startsWith("HEAD "));
150
+ if (!pathLine || !headLine)
151
+ return [];
152
+ const branchLine = lines.find((line) => line.startsWith("branch "));
153
+ return [{
154
+ path: resolve(pathLine.slice("worktree ".length)),
155
+ head: headLine.slice("HEAD ".length).trim(),
156
+ branch_ref: branchLine
157
+ ? branchLine.slice("branch ".length).trim()
158
+ : null,
159
+ prunable: lines.some((line) => line.startsWith("prunable")),
160
+ }];
161
+ });
162
+ }
163
+ function worktreeAt(entries, path) {
164
+ const target = resolve(path);
165
+ return entries.find((entry) => resolve(entry.path) === target) ?? null;
166
+ }
167
+ function branchWorktree(entries, branchName) {
168
+ const branchRef = `refs/heads/${branchName}`;
169
+ return entries.find((entry) => entry.branch_ref === branchRef) ?? null;
170
+ }
171
+ function sameGitRepository(repoRoot, worktreePath) {
172
+ const repo = git(repoRoot, ["rev-parse", "--git-common-dir"], true);
173
+ const workspace = git(worktreePath, ["rev-parse", "--git-common-dir"], true);
174
+ if (repo.status !== 0 || workspace.status !== 0)
175
+ return false;
176
+ const repoCommon = resolve(repoRoot, repo.stdout.trim());
177
+ const workspaceCommon = resolve(worktreePath, workspace.stdout.trim());
178
+ return repoCommon === workspaceCommon;
179
+ }
180
+ function workspaceState(worktreePath) {
181
+ const dirty = git(worktreePath, ["status", "--porcelain", "--untracked-files=all"]).stdout.trim().length > 0;
182
+ const conflicted = git(worktreePath, ["diff", "--name-only", "--diff-filter=U"]).stdout.trim().length > 0;
183
+ return { dirty, conflicted };
184
+ }
185
+ function expectedBranchRef(plan) {
186
+ return `refs/heads/${plan.branch_name}`;
187
+ }
188
+ function assertMatchingWorktree(plan, entry) {
189
+ if (entry.branch_ref !== expectedBranchRef(plan)
190
+ || !sameGitRepository(plan.repository_root, plan.working_directory)) {
191
+ throw new WorkspaceIsolationError("workspace_mismatch", `existing worktree is not owned by attempt ${plan.attempt_id}: ${plan.working_directory}`);
192
+ }
193
+ }
194
+ export function planAttemptWorkspace(request) {
195
+ const cwd = canonicalExistingDirectory(request.repository_directory, "repository_directory");
196
+ if (request.isolation === "none") {
197
+ return {
198
+ isolation: "none",
199
+ run_id: request.run_id,
200
+ step_id: request.step_id,
201
+ attempt_id: request.attempt_id,
202
+ working_directory: cwd,
203
+ };
204
+ }
205
+ const repoRoot = repositoryRoot(cwd);
206
+ const root = resolve(request.worktree_root
207
+ ? requireText(request.worktree_root, "worktree_root")
208
+ : join(repoRoot, ".dna", "worktrees", "runtime"));
209
+ assertSafeWorktreeRoot(repoRoot, root);
210
+ const key = ownershipKey(request.run_id, request.step_id, request.attempt_id);
211
+ const step = pathToken(request.step_id);
212
+ const baseRef = request.base_ref?.trim() || "HEAD";
213
+ const baseCommit = resolveCommit(repoRoot, baseRef);
214
+ return {
215
+ isolation: "worktree",
216
+ run_id: request.run_id,
217
+ step_id: request.step_id,
218
+ attempt_id: request.attempt_id,
219
+ repository_root: repoRoot,
220
+ worktree_root: root,
221
+ working_directory: join(root, `${step}-${key}`),
222
+ branch_name: `dna/attempt/${step}-${key}`,
223
+ base_ref: baseRef,
224
+ base_commit: baseCommit,
225
+ ownership_key: key,
226
+ };
227
+ }
228
+ export function allocateAttemptWorkspace(plan) {
229
+ if (plan.isolation === "none") {
230
+ return { ...plan, created: false, reused: true };
231
+ }
232
+ mkdirSync(plan.worktree_root, { recursive: true });
233
+ assertSafeWorktreeRoot(plan.repository_root, plan.worktree_root);
234
+ assertOwnedPath(plan.worktree_root, plan.working_directory, plan.repository_root, false);
235
+ let entries = listWorktrees(plan.repository_root);
236
+ const stale = worktreeAt(entries, plan.working_directory);
237
+ if (stale
238
+ && stale.prunable
239
+ && !existsSync(plan.working_directory)) {
240
+ git(plan.repository_root, ["worktree", "prune", "--expire", "now"]);
241
+ entries = listWorktrees(plan.repository_root);
242
+ }
243
+ const existing = worktreeAt(entries, plan.working_directory);
244
+ if (existing) {
245
+ assertMatchingWorktree(plan, existing);
246
+ const state = workspaceState(plan.working_directory);
247
+ return {
248
+ ...plan,
249
+ created: false,
250
+ reused: true,
251
+ dirty_at_allocation: state.dirty,
252
+ conflicted_at_allocation: state.conflicted,
253
+ };
254
+ }
255
+ if (existsSync(plan.working_directory)) {
256
+ throw new WorkspaceIsolationError("path_conflict", `worktree path is occupied by an unowned entry: ${plan.working_directory}`);
257
+ }
258
+ const inUse = branchWorktree(entries, plan.branch_name);
259
+ if (inUse) {
260
+ throw new WorkspaceIsolationError("branch_conflict", `attempt branch is already checked out at ${inUse.path}`);
261
+ }
262
+ const branchExists = git(plan.repository_root, ["show-ref", "--verify", "--quiet", expectedBranchRef(plan)], true).status === 0;
263
+ if (branchExists) {
264
+ throw new WorkspaceIsolationError("branch_conflict", `attempt branch already exists without its owned worktree: ${plan.branch_name}`);
265
+ }
266
+ git(plan.repository_root, [
267
+ "worktree",
268
+ "add",
269
+ "-b",
270
+ plan.branch_name,
271
+ plan.working_directory,
272
+ plan.base_commit,
273
+ ]);
274
+ return {
275
+ ...plan,
276
+ created: true,
277
+ reused: false,
278
+ dirty_at_allocation: false,
279
+ conflicted_at_allocation: false,
280
+ };
281
+ }
282
+ export function inspectAttemptWorkspace(workspace) {
283
+ if (workspace.isolation === "none") {
284
+ return {
285
+ exists: existsSync(workspace.working_directory),
286
+ registered: false,
287
+ owned_by_attempt: true,
288
+ dirty: false,
289
+ conflicted: false,
290
+ head: null,
291
+ branch_ref: null,
292
+ };
293
+ }
294
+ const exists = existsSync(workspace.working_directory);
295
+ const entry = worktreeAt(listWorktrees(workspace.repository_root), workspace.working_directory);
296
+ const owned = Boolean(exists
297
+ && entry
298
+ && entry.branch_ref === expectedBranchRef(workspace)
299
+ && sameGitRepository(workspace.repository_root, workspace.working_directory));
300
+ const state = owned
301
+ ? workspaceState(workspace.working_directory)
302
+ : { dirty: false, conflicted: false };
303
+ return {
304
+ exists,
305
+ registered: entry !== null,
306
+ owned_by_attempt: owned,
307
+ dirty: state.dirty,
308
+ conflicted: state.conflicted,
309
+ head: entry?.head ?? null,
310
+ branch_ref: entry?.branch_ref ?? null,
311
+ };
312
+ }
313
+ export function applyWorkspaceLifecycleDecision(workspace, decision) {
314
+ if (workspace.isolation === "none") {
315
+ return {
316
+ status: "not_applicable",
317
+ working_directory: workspace.working_directory,
318
+ };
319
+ }
320
+ if (decision.action === "preserve") {
321
+ return {
322
+ status: "preserved",
323
+ working_directory: workspace.working_directory,
324
+ reason: decision.reason,
325
+ detail: decision.detail ?? null,
326
+ };
327
+ }
328
+ assertOwnedPath(workspace.worktree_root, workspace.working_directory, workspace.repository_root, true);
329
+ const inspection = inspectAttemptWorkspace(workspace);
330
+ if (!inspection.owned_by_attempt) {
331
+ throw new WorkspaceIsolationError("workspace_mismatch", `cleanup target is not owned by attempt ${workspace.attempt_id}`);
332
+ }
333
+ if (inspection.conflicted) {
334
+ return {
335
+ status: "preserved",
336
+ working_directory: workspace.working_directory,
337
+ reason: "conflicted",
338
+ detail: "cleanup refused because the worktree contains unresolved conflicts",
339
+ };
340
+ }
341
+ if (inspection.dirty) {
342
+ return {
343
+ status: "preserved",
344
+ working_directory: workspace.working_directory,
345
+ reason: "dirty",
346
+ detail: "cleanup refused because the worktree contains uncommitted changes",
347
+ };
348
+ }
349
+ git(workspace.repository_root, [
350
+ "worktree",
351
+ "remove",
352
+ workspace.working_directory,
353
+ ]);
354
+ let branchDeleted = false;
355
+ if (decision.delete_branch !== false) {
356
+ const removed = git(workspace.repository_root, ["branch", "-d", workspace.branch_name], true);
357
+ branchDeleted = removed.status === 0;
358
+ }
359
+ return {
360
+ status: "cleaned",
361
+ working_directory: workspace.working_directory,
362
+ branch_deleted: branchDeleted,
363
+ };
364
+ }
365
+ export function defaultWorktreeRoot(repositoryRoot) {
366
+ return join(resolve(repositoryRoot), ".dna", "worktrees", "runtime");
367
+ }
368
+ export function describeWorkspace(workspace) {
369
+ if (workspace.isolation === "none") {
370
+ return `shared workspace ${workspace.working_directory}`;
371
+ }
372
+ return `${basename(dirname(workspace.working_directory))}/${basename(workspace.working_directory)} at ${workspace.base_commit}`;
373
+ }
@@ -564,6 +564,7 @@ export interface WorkflowStep {
564
564
  completion: CompletionCheck[] | null;
565
565
  checkpoints: StepCheckpoint[] | null;
566
566
  handoff: StepHandoff | null;
567
+ enforce: NonNullable<WorkflowStepDef["enforce"]> | null;
567
568
  max_attempts?: number;
568
569
  on_fail?: "retry_with_feedback" | "handoff" | "skip";
569
570
  handoff_to?: string;
@@ -444,6 +444,65 @@ function validateCompletionCheck(check, path) {
444
444
  }
445
445
  return errors;
446
446
  }
447
+ function workflowStepsForValidation(workflow, path, errors) {
448
+ const entries = [];
449
+ const rawSteps = workflow.steps;
450
+ for (let i = 0; i < rawSteps.length; i++) {
451
+ const raw = rawSteps[i];
452
+ const stepPath = `${path}.steps[${i}]`;
453
+ if (typeof raw !== "object"
454
+ || raw === null
455
+ || !("parallel" in raw)) {
456
+ entries.push({
457
+ step: (typeof raw === "object" && raw !== null ? raw : {}),
458
+ path: stepPath,
459
+ });
460
+ continue;
461
+ }
462
+ const parallel = raw.parallel;
463
+ if (typeof parallel !== "object" || parallel === null) {
464
+ errors.push({
465
+ path: `${stepPath}.parallel`,
466
+ message: "parallel block must be an object",
467
+ });
468
+ continue;
469
+ }
470
+ const block = parallel;
471
+ const isolation = block.isolation;
472
+ if (isolation !== undefined
473
+ && (typeof isolation !== "string"
474
+ || !WORKFLOW_ISOLATION_VALUES.includes(isolation))) {
475
+ errors.push({
476
+ path: `${stepPath}.parallel.isolation`,
477
+ message: `must be one of: ${WORKFLOW_ISOLATION_VALUES.join(", ")}`,
478
+ });
479
+ }
480
+ const mergeStrategy = block.merge_strategy;
481
+ if (mergeStrategy !== undefined
482
+ && (typeof mergeStrategy !== "string"
483
+ || !WORKFLOW_MERGE_STRATEGY_VALUES.includes(mergeStrategy))) {
484
+ errors.push({
485
+ path: `${stepPath}.parallel.merge_strategy`,
486
+ message: `must be one of: ${WORKFLOW_MERGE_STRATEGY_VALUES.join(", ")}`,
487
+ });
488
+ }
489
+ if (!Array.isArray(block.steps) || block.steps.length === 0) {
490
+ errors.push({
491
+ path: `${stepPath}.parallel.steps`,
492
+ message: "parallel block requires at least one step",
493
+ });
494
+ continue;
495
+ }
496
+ for (let j = 0; j < block.steps.length; j++) {
497
+ const inner = block.steps[j];
498
+ entries.push({
499
+ step: (typeof inner === "object" && inner !== null ? inner : {}),
500
+ path: `${stepPath}.parallel.steps[${j}]`,
501
+ });
502
+ }
503
+ }
504
+ return entries;
505
+ }
447
506
  function validateWorkflow(workflow, roleNames, pathPrefix) {
448
507
  const errors = [];
449
508
  const path = pathPrefix ?? "workflow";
@@ -459,12 +518,12 @@ function validateWorkflow(workflow, roleNames, pathPrefix) {
459
518
  errors.push({ path: `${path}.steps`, message: "workflow requires at least one step" });
460
519
  return errors; // early return, downstream checks need steps
461
520
  }
521
+ const workflowSteps = workflowStepsForValidation(workflow, path, errors);
462
522
  // Collect step ids for reference checking
463
523
  const stepIds = new Set();
464
524
  const seenStepIds = new Set();
465
- for (let i = 0; i < workflow.steps.length; i++) {
466
- const step = workflow.steps[i];
467
- const stepPath = `${path}.steps[${i}]`;
525
+ for (const entry of workflowSteps) {
526
+ const { step, path: stepPath } = entry;
468
527
  // step.id required and unique
469
528
  if (!step.id) {
470
529
  errors.push({ path: `${stepPath}.id`, message: "step requires 'id'" });
@@ -579,9 +638,8 @@ function validateWorkflow(workflow, roleNames, pathPrefix) {
579
638
  }
580
639
  }
581
640
  // Second pass: validate depends_on references against all step ids
582
- for (let i = 0; i < workflow.steps.length; i++) {
583
- const step = workflow.steps[i];
584
- const stepPath = `${path}.steps[${i}]`;
641
+ for (const entry of workflowSteps) {
642
+ const { step, path: stepPath } = entry;
585
643
  for (const dep of step.depends_on ?? []) {
586
644
  if (dep && !stepIds.has(dep)) {
587
645
  errors.push({