@tagma/sdk 0.4.13 → 0.4.15

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 (59) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +569 -572
  3. package/dist/dag.d.ts.map +1 -1
  4. package/dist/dag.js +22 -56
  5. package/dist/dag.js.map +1 -1
  6. package/dist/engine.d.ts.map +1 -1
  7. package/dist/engine.js +63 -37
  8. package/dist/engine.js.map +1 -1
  9. package/dist/middlewares/static-context.d.ts.map +1 -1
  10. package/dist/middlewares/static-context.js +7 -3
  11. package/dist/middlewares/static-context.js.map +1 -1
  12. package/dist/prompt-doc.d.ts +36 -0
  13. package/dist/prompt-doc.d.ts.map +1 -0
  14. package/dist/prompt-doc.js +44 -0
  15. package/dist/prompt-doc.js.map +1 -0
  16. package/dist/sdk.d.ts +3 -0
  17. package/dist/sdk.d.ts.map +1 -1
  18. package/dist/sdk.js +4 -0
  19. package/dist/sdk.js.map +1 -1
  20. package/dist/task-ref.d.ts +55 -0
  21. package/dist/task-ref.d.ts.map +1 -0
  22. package/dist/task-ref.js +101 -0
  23. package/dist/task-ref.js.map +1 -0
  24. package/dist/templates.d.ts +20 -0
  25. package/dist/templates.d.ts.map +1 -0
  26. package/dist/templates.js +93 -0
  27. package/dist/templates.js.map +1 -0
  28. package/dist/validate-raw.d.ts.map +1 -1
  29. package/dist/validate-raw.js +27 -53
  30. package/dist/validate-raw.js.map +1 -1
  31. package/package.json +2 -2
  32. package/scripts/preinstall.js +31 -31
  33. package/src/adapters/stdin-approval.ts +106 -106
  34. package/src/adapters/websocket-approval.ts +224 -224
  35. package/src/approval.ts +131 -131
  36. package/src/bootstrap.ts +37 -37
  37. package/src/completions/exit-code.ts +34 -34
  38. package/src/completions/file-exists.ts +66 -66
  39. package/src/completions/output-check.ts +86 -86
  40. package/src/config-ops.ts +307 -307
  41. package/src/dag.ts +24 -54
  42. package/src/drivers/claude-code.ts +250 -250
  43. package/src/engine.ts +1137 -1098
  44. package/src/hooks.ts +187 -187
  45. package/src/logger.ts +182 -182
  46. package/src/middlewares/static-context.ts +49 -45
  47. package/src/pipeline-runner.ts +156 -156
  48. package/src/prompt-doc.ts +49 -0
  49. package/src/registry.ts +242 -242
  50. package/src/runner.ts +395 -395
  51. package/src/schema.test.ts +101 -101
  52. package/src/schema.ts +338 -338
  53. package/src/sdk.ts +111 -92
  54. package/src/task-ref.ts +120 -0
  55. package/src/triggers/file.ts +164 -164
  56. package/src/triggers/manual.ts +86 -86
  57. package/src/types.ts +18 -18
  58. package/src/utils.ts +203 -203
  59. package/src/validate-raw.ts +412 -442
package/src/dag.ts CHANGED
@@ -5,6 +5,7 @@ import type {
5
5
  TaskConfig,
6
6
  TrackConfig,
7
7
  } from './types';
8
+ import { buildTaskIndex, qualifyTaskId, resolveTaskRef } from './task-ref';
8
9
 
9
10
  export interface DagNode {
10
11
  readonly taskId: string; // fully qualified: track_id.task_id or just task_id
@@ -27,33 +28,17 @@ export interface Dag {
27
28
  readonly sorted: readonly string[]; // topological order
28
29
  }
29
30
 
30
- // Build a global task ID: for cross-track refs we use "track_id.task_id"
31
- // Within a track, bare "task_id" is also valid
32
- function qualifyId(trackId: string, taskId: string): string {
33
- return `${trackId}.${taskId}`;
34
- }
35
-
36
31
  export function buildDag(config: PipelineConfig): Dag {
37
32
  const nodes = new Map<string, DagNode>();
38
- // Map bare task IDs to qualified IDs (for resolving unqualified refs)
39
- const bareToQualified = new Map<string, string>();
40
33
 
41
- // 1. Register all nodes
34
+ // 1. Register all nodes. Duplicates throw — same-track task-id collisions
35
+ // would otherwise silently overwrite one another in the DAG.
42
36
  for (const track of config.tracks) {
43
37
  for (const task of track.tasks) {
44
- const qid = qualifyId(track.id, task.id);
45
-
38
+ const qid = qualifyTaskId(track.id, task.id);
46
39
  if (nodes.has(qid)) {
47
40
  throw new Error(`Duplicate task ID: "${qid}"`);
48
41
  }
49
-
50
- // Track bare ID → qualified. If same bare ID in multiple tracks, mark ambiguous
51
- if (bareToQualified.has(task.id)) {
52
- bareToQualified.set(task.id, '__ambiguous__');
53
- } else {
54
- bareToQualified.set(task.id, qid);
55
- }
56
-
57
42
  nodes.set(qid, {
58
43
  taskId: qid,
59
44
  task,
@@ -63,34 +48,27 @@ export function buildDag(config: PipelineConfig): Dag {
63
48
  }
64
49
  }
65
50
 
66
- // Helper to resolve a dependency ref to a qualified ID
51
+ // Shared index for ref resolution same code path validate-raw uses.
52
+ const index = buildTaskIndex(config);
53
+
67
54
  function resolveRef(ref: string, fromTrackId: string): string {
68
- // Already qualified (contains dot)
69
- if (ref.includes('.')) {
70
- if (!nodes.has(ref)) {
71
- throw new Error(`Task reference "${ref}" not found`);
72
- }
73
- return ref;
74
- }
75
- // Try within same track first
76
- const sameTrack = qualifyId(fromTrackId, ref);
77
- if (nodes.has(sameTrack)) return sameTrack;
78
- // Try global bare lookup
79
- const global = bareToQualified.get(ref);
80
- if (global && global !== '__ambiguous__') return global;
81
- if (global === '__ambiguous__') {
55
+ const result = resolveTaskRef(ref, fromTrackId, index);
56
+ if (result.kind === 'ambiguous') {
82
57
  throw new Error(
83
58
  `Ambiguous task reference "${ref}" exists in multiple tracks. ` +
84
59
  `Use "track_id.task_id" format.`,
85
60
  );
86
61
  }
87
- throw new Error(`Task reference "${ref}" not found`);
62
+ if (result.kind === 'not_found') {
63
+ throw new Error(`Task reference "${ref}" not found`);
64
+ }
65
+ return result.qid;
88
66
  }
89
67
 
90
68
  // 2. Resolve depends_on and continue_from to qualified IDs
91
69
  for (const track of config.tracks) {
92
70
  for (const task of track.tasks) {
93
- const qid = qualifyId(track.id, task.id);
71
+ const qid = qualifyTaskId(track.id, task.id);
94
72
  const deps: string[] = [];
95
73
  let resolvedContinueFrom: string | undefined;
96
74
 
@@ -193,38 +171,30 @@ export interface RawDag {
193
171
  */
194
172
  export function buildRawDag(config: RawPipelineConfig): RawDag {
195
173
  const nodes = new Map<string, RawDagNode>();
196
- const bareToQualified = new Map<string, string>();
197
174
 
198
- // 1. Register all concrete tasks
175
+ // 1. Register all concrete tasks. Duplicates are skipped (not thrown) so
176
+ // partially-typed editor state doesn't produce a hard error.
199
177
  for (const track of config.tracks) {
200
178
  for (const task of track.tasks) {
201
- const qid = `${track.id}.${task.id}`;
202
- if (nodes.has(qid)) continue; // skip duplicates silently
203
-
204
- if (bareToQualified.has(task.id)) {
205
- bareToQualified.set(task.id, '__ambiguous__');
206
- } else {
207
- bareToQualified.set(task.id, qid);
208
- }
179
+ const qid = qualifyTaskId(track.id, task.id);
180
+ if (nodes.has(qid)) continue;
209
181
  nodes.set(qid, { taskId: qid, trackId: track.id, rawTask: task, dependsOn: [] });
210
182
  }
211
183
  }
212
184
 
213
- // 2. Resolve dependency refs leniently (missing / ambiguous refs are skipped)
185
+ const index = buildTaskIndex(config);
186
+
214
187
  function tryResolve(ref: string, fromTrackId: string): string | null {
215
- if (ref.includes('.')) return nodes.has(ref) ? ref : null;
216
- const sameTrack = `${fromTrackId}.${ref}`;
217
- if (nodes.has(sameTrack)) return sameTrack;
218
- const global = bareToQualified.get(ref);
219
- if (global && global !== '__ambiguous__') return global;
220
- return null;
188
+ const result = resolveTaskRef(ref, fromTrackId, index);
189
+ return result.kind === 'resolved' ? result.qid : null;
221
190
  }
222
191
 
192
+ // 2. Resolve dependency refs leniently (missing / ambiguous refs are skipped)
223
193
  const edges: { from: string; to: string }[] = [];
224
194
 
225
195
  for (const track of config.tracks) {
226
196
  for (const task of track.tasks) {
227
- const qid = `${track.id}.${task.id}`;
197
+ const qid = qualifyTaskId(track.id, task.id);
228
198
  const deps: string[] = [];
229
199
 
230
200
  for (const ref of task.depends_on ?? []) {
@@ -1,250 +1,250 @@
1
- import { existsSync } from 'node:fs';
2
- import { isAbsolute, relative, dirname, join } from 'node:path';
3
- import type {
4
- DriverPlugin,
5
- DriverCapabilities,
6
- DriverResultMeta,
7
- TaskConfig,
8
- TrackConfig,
9
- DriverContext,
10
- SpawnSpec,
11
- Permissions,
12
- } from '../types';
13
-
14
- // Claude Code CLI reference: https://code.claude.com/docs/en/cli-reference
15
-
16
- const DEFAULT_MODEL = 'sonnet';
17
-
18
- // Claude Code CLI accepts --effort low|medium|high|max. tagma's vocabulary
19
- // is low|medium|high, so low/medium/high pass through unchanged; users who
20
- // want the claude-specific "max" tier can also set it explicitly.
21
- const VALID_EFFORT = new Set(['low', 'medium', 'high', 'max']);
22
-
23
- function resolveModel(): string {
24
- return DEFAULT_MODEL;
25
- }
26
-
27
- function resolveTools(permissions: Permissions): string {
28
- const tools = ['Grep', 'Glob'];
29
- if (permissions.read) tools.push('Read');
30
- if (permissions.write) tools.push('Edit', 'Write');
31
- if (permissions.execute) tools.push('Bash');
32
- return tools.join(',');
33
- }
34
-
35
- // Maps our Permissions to Claude Code's --permission-mode. In print (-p) mode
36
- // Claude needs non-interactive permission handling:
37
- // - `bypassPermissions` skips all checks (required for reliable Bash automation
38
- // under `execute: true`, matches the "full trust" semantics of that tier).
39
- // - `dontAsk` auto-denies anything outside `--allowedTools`, which is exactly
40
- // what we want for read/write tiers: the allowedTools whitelist already
41
- // enumerates what Claude may do, and dontAsk makes violations fail fast
42
- // instead of hanging on a prompt no one can answer in headless mode.
43
- // See: https://code.claude.com/docs/en/permission-modes
44
- function resolvePermissionMode(permissions: Permissions): string {
45
- if (permissions.execute) return 'bypassPermissions';
46
- return 'dontAsk';
47
- }
48
-
49
- // Returns true if `sub` is inside `root` (or equal to it).
50
- function isInside(root: string, sub: string): boolean {
51
- const rel = relative(root, sub);
52
- return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
53
- }
54
-
55
- // Claude Code requires CLAUDE_CODE_GIT_BASH_PATH on Windows pointing to
56
- // Git Bash (bin\bash.exe under a Git for Windows install). See:
57
- // https://code.claude.com/docs/en/troubleshooting#windows-claude-code-on-windows-requires-git-bash
58
- // The path must use native Windows backslashes — forward slashes are rejected
59
- // by Claude Code's path validation.
60
- function resolveGitBashEnv(): Record<string, string> {
61
- if (process.platform !== 'win32') return {};
62
-
63
- // Respect user-provided value if it points to an actual file. If the user
64
- // set it to a non-existent path, fall through to discovery rather than
65
- // propagating the broken config.
66
- const existing = process.env.CLAUDE_CODE_GIT_BASH_PATH;
67
- if (existing && existsSync(existing)) return {};
68
-
69
- const discovered = discoverGitBash();
70
- return discovered ? { CLAUDE_CODE_GIT_BASH_PATH: discovered } : {};
71
- }
72
-
73
- function discoverGitBash(): string | null {
74
- // Strategy 1: find git.exe in PATH (equivalent to `where.exe git`) and
75
- // walk up looking for bin\bash.exe under a Git install root. Git for
76
- // Windows may expose multiple git.exe locations (cmd\git.exe,
77
- // mingw64\bin\git.exe, mingw64\libexec\git-core\git.exe), so we walk up
78
- // several levels rather than assuming a fixed depth.
79
- const gitExe = findExeInPath('git.exe');
80
- if (gitExe) {
81
- let dir = dirname(gitExe);
82
- for (let depth = 0; depth < 5; depth++) {
83
- const candidate = join(dir, 'bin', 'bash.exe');
84
- if (existsSync(candidate)) return candidate;
85
- const parent = dirname(dir);
86
- if (parent === dir) break;
87
- dir = parent;
88
- }
89
- }
90
-
91
- // Strategy 2: check common Git for Windows install locations.
92
- // Uses %ProgramFiles%/%LOCALAPPDATA%/%USERPROFILE% env vars so it works on
93
- // systems where those aren't mapped to C:\ (e.g. localized Windows).
94
- const programFiles = process.env['ProgramFiles'] ?? 'C:\\Program Files';
95
- const programFilesX86 = process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)';
96
- const localAppData = process.env['LOCALAPPDATA'];
97
- const userProfile = process.env['USERPROFILE'];
98
-
99
- const candidates = [
100
- join(programFiles, 'Git', 'bin', 'bash.exe'),
101
- join(programFilesX86, 'Git', 'bin', 'bash.exe'),
102
- // Git for Windows user-level install
103
- localAppData && join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'),
104
- // Scoop
105
- userProfile && join(userProfile, 'scoop', 'apps', 'git', 'current', 'bin', 'bash.exe'),
106
- // Chocolatey default
107
- 'C:\\tools\\git\\bin\\bash.exe',
108
- ].filter((p): p is string => Boolean(p));
109
-
110
- for (const c of candidates) {
111
- if (existsSync(c)) return c;
112
- }
113
-
114
- // Strategy 3: scan PATH for any entry containing "git" (e.g. Git's
115
- // mingw64/bin or usr/bin already in PATH), walk up to find bash.exe.
116
- // Catches custom install locations.
117
- const pathEntries = (process.env.PATH ?? '').split(';');
118
- for (const entry of pathEntries) {
119
- if (!/git/i.test(entry)) continue;
120
- const normalized = entry.replace(/\//g, '\\').replace(/\\+$/, '');
121
- const parts = normalized.split('\\');
122
- for (let depth = 1; depth <= 4; depth++) {
123
- const root = parts.slice(0, parts.length - depth).join('\\');
124
- if (!root) continue;
125
- const candidate = root + '\\bin\\bash.exe';
126
- if (existsSync(candidate)) return candidate;
127
- }
128
- }
129
-
130
- return null;
131
- }
132
-
133
- function findExeInPath(exe: string): string | null {
134
- const pathDirs = (process.env.PATH ?? '').split(';');
135
- for (const dir of pathDirs) {
136
- if (!dir) continue;
137
- const full = join(dir, exe);
138
- if (existsSync(full)) return full;
139
- }
140
- return null;
141
- }
142
-
143
- export const ClaudeCodeDriver: DriverPlugin = {
144
- name: 'claude-code',
145
-
146
- capabilities: {
147
- sessionResume: true,
148
- systemPrompt: true,
149
- outputFormat: true,
150
- } satisfies DriverCapabilities,
151
-
152
- resolveModel,
153
- resolveTools,
154
-
155
- async buildCommand(task: TaskConfig, track: TrackConfig, ctx: DriverContext): Promise<SpawnSpec> {
156
- const permissions = task.permissions ?? track.permissions!;
157
- const model = task.model ?? track.model ?? DEFAULT_MODEL;
158
- // SDK schema layer already resolved task → track → pipeline inheritance.
159
- // Drop unknown effort values so a typo can't break `claude -p` startup;
160
- // validateRaw / the UI should prevent this from reaching us in practice.
161
- const rawEffort = task.reasoning_effort ?? track.reasoning_effort;
162
- const effort = rawEffort && VALID_EFFORT.has(rawEffort) ? rawEffort : null;
163
- const tools = resolveTools(permissions);
164
- const permissionMode = resolvePermissionMode(permissions);
165
-
166
- // Pass the prompt via stdin instead of as a -p argument value. On Windows,
167
- // multi-line strings in CLI arguments break cmd.exe argument parsing when
168
- // the executable is a .cmd wrapper — newlines cause all subsequent flags
169
- // (--output-format, --model, etc.) to be silently dropped.
170
- const stdin = task.prompt!;
171
-
172
- const args: string[] = [
173
- 'claude',
174
- '-p', // no value — prompt is piped via stdin
175
- '--model',
176
- model,
177
- '--allowedTools',
178
- tools,
179
- '--permission-mode',
180
- permissionMode,
181
- '--output-format',
182
- 'json',
183
- // NOTE: do NOT use --verbose here. It changes stdout from a single JSON
184
- // result object to a JSON event-stream array, breaking parseResult's
185
- // session_id extraction (needed for continue_from) and normalizedOutput.
186
- // The engine already captures stdout/stderr for pipeline logs.
187
- // Pin to project+local settings only; don't inherit arbitrary user-level
188
- // config (hooks, MCP servers, etc.) into pipeline automation.
189
- '--setting-sources',
190
- 'project,local',
191
- ];
192
-
193
- if (effort) {
194
- args.push('--effort', effort);
195
- }
196
-
197
- // If the task runs in a subdirectory of the project, grant read/edit
198
- // access to the project root via --add-dir so Claude can still see
199
- // shared files (configs, types, etc.) outside task.cwd.
200
- const effectiveCwd = task.cwd ?? ctx.workDir;
201
- if (effectiveCwd !== ctx.workDir && isInside(ctx.workDir, effectiveCwd)) {
202
- args.push('--add-dir', ctx.workDir);
203
- }
204
-
205
- // Native session resume
206
- if (task.continue_from) {
207
- const sessionId = ctx.sessionMap.get(task.continue_from);
208
- if (sessionId) {
209
- args.push('--resume', sessionId);
210
- }
211
- }
212
-
213
- // --append-system-prompt MUST be last: its value may contain newlines,
214
- // and on Windows cmd.exe can silently drop any flags that follow a
215
- // newline-containing argument.
216
- const profile = task.agent_profile ?? track.agent_profile;
217
- if (profile) {
218
- args.push('--append-system-prompt', profile);
219
- }
220
-
221
- return { args, cwd: effectiveCwd, env: resolveGitBashEnv(), stdin };
222
- },
223
-
224
- parseResult(stdout: string): DriverResultMeta {
225
- try {
226
- let json = JSON.parse(stdout);
227
-
228
- // --verbose produces a JSON array of events; extract the final "result"
229
- // event so session_id and normalizedOutput are correctly populated.
230
- if (Array.isArray(json)) {
231
- const resultEvent = json.findLast((e: Record<string, unknown>) => e.type === 'result');
232
- if (!resultEvent) return { normalizedOutput: stdout };
233
- json = resultEvent;
234
- }
235
-
236
- // Extract canonical text: strip JSON envelope so downstream drivers
237
- // get the actual AI response, not metadata
238
- const normalizedOutput = json.result ?? json.text ?? json.content ?? stdout;
239
- return {
240
- sessionId: json.session_id,
241
- normalizedOutput:
242
- typeof normalizedOutput === 'string'
243
- ? normalizedOutput
244
- : JSON.stringify(normalizedOutput),
245
- };
246
- } catch {
247
- return { normalizedOutput: stdout };
248
- }
249
- },
250
- };
1
+ import { existsSync } from 'node:fs';
2
+ import { isAbsolute, relative, dirname, join } from 'node:path';
3
+ import type {
4
+ DriverPlugin,
5
+ DriverCapabilities,
6
+ DriverResultMeta,
7
+ TaskConfig,
8
+ TrackConfig,
9
+ DriverContext,
10
+ SpawnSpec,
11
+ Permissions,
12
+ } from '../types';
13
+
14
+ // Claude Code CLI reference: https://code.claude.com/docs/en/cli-reference
15
+
16
+ const DEFAULT_MODEL = 'sonnet';
17
+
18
+ // Claude Code CLI accepts --effort low|medium|high|max. tagma's vocabulary
19
+ // is low|medium|high, so low/medium/high pass through unchanged; users who
20
+ // want the claude-specific "max" tier can also set it explicitly.
21
+ const VALID_EFFORT = new Set(['low', 'medium', 'high', 'max']);
22
+
23
+ function resolveModel(): string {
24
+ return DEFAULT_MODEL;
25
+ }
26
+
27
+ function resolveTools(permissions: Permissions): string {
28
+ const tools = ['Grep', 'Glob'];
29
+ if (permissions.read) tools.push('Read');
30
+ if (permissions.write) tools.push('Edit', 'Write');
31
+ if (permissions.execute) tools.push('Bash');
32
+ return tools.join(',');
33
+ }
34
+
35
+ // Maps our Permissions to Claude Code's --permission-mode. In print (-p) mode
36
+ // Claude needs non-interactive permission handling:
37
+ // - `bypassPermissions` skips all checks (required for reliable Bash automation
38
+ // under `execute: true`, matches the "full trust" semantics of that tier).
39
+ // - `dontAsk` auto-denies anything outside `--allowedTools`, which is exactly
40
+ // what we want for read/write tiers: the allowedTools whitelist already
41
+ // enumerates what Claude may do, and dontAsk makes violations fail fast
42
+ // instead of hanging on a prompt no one can answer in headless mode.
43
+ // See: https://code.claude.com/docs/en/permission-modes
44
+ function resolvePermissionMode(permissions: Permissions): string {
45
+ if (permissions.execute) return 'bypassPermissions';
46
+ return 'dontAsk';
47
+ }
48
+
49
+ // Returns true if `sub` is inside `root` (or equal to it).
50
+ function isInside(root: string, sub: string): boolean {
51
+ const rel = relative(root, sub);
52
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
53
+ }
54
+
55
+ // Claude Code requires CLAUDE_CODE_GIT_BASH_PATH on Windows pointing to
56
+ // Git Bash (bin\bash.exe under a Git for Windows install). See:
57
+ // https://code.claude.com/docs/en/troubleshooting#windows-claude-code-on-windows-requires-git-bash
58
+ // The path must use native Windows backslashes — forward slashes are rejected
59
+ // by Claude Code's path validation.
60
+ function resolveGitBashEnv(): Record<string, string> {
61
+ if (process.platform !== 'win32') return {};
62
+
63
+ // Respect user-provided value if it points to an actual file. If the user
64
+ // set it to a non-existent path, fall through to discovery rather than
65
+ // propagating the broken config.
66
+ const existing = process.env.CLAUDE_CODE_GIT_BASH_PATH;
67
+ if (existing && existsSync(existing)) return {};
68
+
69
+ const discovered = discoverGitBash();
70
+ return discovered ? { CLAUDE_CODE_GIT_BASH_PATH: discovered } : {};
71
+ }
72
+
73
+ function discoverGitBash(): string | null {
74
+ // Strategy 1: find git.exe in PATH (equivalent to `where.exe git`) and
75
+ // walk up looking for bin\bash.exe under a Git install root. Git for
76
+ // Windows may expose multiple git.exe locations (cmd\git.exe,
77
+ // mingw64\bin\git.exe, mingw64\libexec\git-core\git.exe), so we walk up
78
+ // several levels rather than assuming a fixed depth.
79
+ const gitExe = findExeInPath('git.exe');
80
+ if (gitExe) {
81
+ let dir = dirname(gitExe);
82
+ for (let depth = 0; depth < 5; depth++) {
83
+ const candidate = join(dir, 'bin', 'bash.exe');
84
+ if (existsSync(candidate)) return candidate;
85
+ const parent = dirname(dir);
86
+ if (parent === dir) break;
87
+ dir = parent;
88
+ }
89
+ }
90
+
91
+ // Strategy 2: check common Git for Windows install locations.
92
+ // Uses %ProgramFiles%/%LOCALAPPDATA%/%USERPROFILE% env vars so it works on
93
+ // systems where those aren't mapped to C:\ (e.g. localized Windows).
94
+ const programFiles = process.env['ProgramFiles'] ?? 'C:\\Program Files';
95
+ const programFilesX86 = process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)';
96
+ const localAppData = process.env['LOCALAPPDATA'];
97
+ const userProfile = process.env['USERPROFILE'];
98
+
99
+ const candidates = [
100
+ join(programFiles, 'Git', 'bin', 'bash.exe'),
101
+ join(programFilesX86, 'Git', 'bin', 'bash.exe'),
102
+ // Git for Windows user-level install
103
+ localAppData && join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'),
104
+ // Scoop
105
+ userProfile && join(userProfile, 'scoop', 'apps', 'git', 'current', 'bin', 'bash.exe'),
106
+ // Chocolatey default
107
+ 'C:\\tools\\git\\bin\\bash.exe',
108
+ ].filter((p): p is string => Boolean(p));
109
+
110
+ for (const c of candidates) {
111
+ if (existsSync(c)) return c;
112
+ }
113
+
114
+ // Strategy 3: scan PATH for any entry containing "git" (e.g. Git's
115
+ // mingw64/bin or usr/bin already in PATH), walk up to find bash.exe.
116
+ // Catches custom install locations.
117
+ const pathEntries = (process.env.PATH ?? '').split(';');
118
+ for (const entry of pathEntries) {
119
+ if (!/git/i.test(entry)) continue;
120
+ const normalized = entry.replace(/\//g, '\\').replace(/\\+$/, '');
121
+ const parts = normalized.split('\\');
122
+ for (let depth = 1; depth <= 4; depth++) {
123
+ const root = parts.slice(0, parts.length - depth).join('\\');
124
+ if (!root) continue;
125
+ const candidate = root + '\\bin\\bash.exe';
126
+ if (existsSync(candidate)) return candidate;
127
+ }
128
+ }
129
+
130
+ return null;
131
+ }
132
+
133
+ function findExeInPath(exe: string): string | null {
134
+ const pathDirs = (process.env.PATH ?? '').split(';');
135
+ for (const dir of pathDirs) {
136
+ if (!dir) continue;
137
+ const full = join(dir, exe);
138
+ if (existsSync(full)) return full;
139
+ }
140
+ return null;
141
+ }
142
+
143
+ export const ClaudeCodeDriver: DriverPlugin = {
144
+ name: 'claude-code',
145
+
146
+ capabilities: {
147
+ sessionResume: true,
148
+ systemPrompt: true,
149
+ outputFormat: true,
150
+ } satisfies DriverCapabilities,
151
+
152
+ resolveModel,
153
+ resolveTools,
154
+
155
+ async buildCommand(task: TaskConfig, track: TrackConfig, ctx: DriverContext): Promise<SpawnSpec> {
156
+ const permissions = task.permissions ?? track.permissions!;
157
+ const model = task.model ?? track.model ?? DEFAULT_MODEL;
158
+ // SDK schema layer already resolved task → track → pipeline inheritance.
159
+ // Drop unknown effort values so a typo can't break `claude -p` startup;
160
+ // validateRaw / the UI should prevent this from reaching us in practice.
161
+ const rawEffort = task.reasoning_effort ?? track.reasoning_effort;
162
+ const effort = rawEffort && VALID_EFFORT.has(rawEffort) ? rawEffort : null;
163
+ const tools = resolveTools(permissions);
164
+ const permissionMode = resolvePermissionMode(permissions);
165
+
166
+ // Pass the prompt via stdin instead of as a -p argument value. On Windows,
167
+ // multi-line strings in CLI arguments break cmd.exe argument parsing when
168
+ // the executable is a .cmd wrapper — newlines cause all subsequent flags
169
+ // (--output-format, --model, etc.) to be silently dropped.
170
+ const stdin = task.prompt!;
171
+
172
+ const args: string[] = [
173
+ 'claude',
174
+ '-p', // no value — prompt is piped via stdin
175
+ '--model',
176
+ model,
177
+ '--allowedTools',
178
+ tools,
179
+ '--permission-mode',
180
+ permissionMode,
181
+ '--output-format',
182
+ 'json',
183
+ // NOTE: do NOT use --verbose here. It changes stdout from a single JSON
184
+ // result object to a JSON event-stream array, breaking parseResult's
185
+ // session_id extraction (needed for continue_from) and normalizedOutput.
186
+ // The engine already captures stdout/stderr for pipeline logs.
187
+ // Pin to project+local settings only; don't inherit arbitrary user-level
188
+ // config (hooks, MCP servers, etc.) into pipeline automation.
189
+ '--setting-sources',
190
+ 'project,local',
191
+ ];
192
+
193
+ if (effort) {
194
+ args.push('--effort', effort);
195
+ }
196
+
197
+ // If the task runs in a subdirectory of the project, grant read/edit
198
+ // access to the project root via --add-dir so Claude can still see
199
+ // shared files (configs, types, etc.) outside task.cwd.
200
+ const effectiveCwd = task.cwd ?? ctx.workDir;
201
+ if (effectiveCwd !== ctx.workDir && isInside(ctx.workDir, effectiveCwd)) {
202
+ args.push('--add-dir', ctx.workDir);
203
+ }
204
+
205
+ // Native session resume
206
+ if (task.continue_from) {
207
+ const sessionId = ctx.sessionMap.get(task.continue_from);
208
+ if (sessionId) {
209
+ args.push('--resume', sessionId);
210
+ }
211
+ }
212
+
213
+ // --append-system-prompt MUST be last: its value may contain newlines,
214
+ // and on Windows cmd.exe can silently drop any flags that follow a
215
+ // newline-containing argument.
216
+ const profile = task.agent_profile ?? track.agent_profile;
217
+ if (profile) {
218
+ args.push('--append-system-prompt', profile);
219
+ }
220
+
221
+ return { args, cwd: effectiveCwd, env: resolveGitBashEnv(), stdin };
222
+ },
223
+
224
+ parseResult(stdout: string): DriverResultMeta {
225
+ try {
226
+ let json = JSON.parse(stdout);
227
+
228
+ // --verbose produces a JSON array of events; extract the final "result"
229
+ // event so session_id and normalizedOutput are correctly populated.
230
+ if (Array.isArray(json)) {
231
+ const resultEvent = json.findLast((e: Record<string, unknown>) => e.type === 'result');
232
+ if (!resultEvent) return { normalizedOutput: stdout };
233
+ json = resultEvent;
234
+ }
235
+
236
+ // Extract canonical text: strip JSON envelope so downstream drivers
237
+ // get the actual AI response, not metadata
238
+ const normalizedOutput = json.result ?? json.text ?? json.content ?? stdout;
239
+ return {
240
+ sessionId: json.session_id,
241
+ normalizedOutput:
242
+ typeof normalizedOutput === 'string'
243
+ ? normalizedOutput
244
+ : JSON.stringify(normalizedOutput),
245
+ };
246
+ } catch {
247
+ return { normalizedOutput: stdout };
248
+ }
249
+ },
250
+ };