@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.
- package/LICENSE +21 -21
- package/README.md +569 -572
- package/dist/dag.d.ts.map +1 -1
- package/dist/dag.js +22 -56
- package/dist/dag.js.map +1 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +63 -37
- package/dist/engine.js.map +1 -1
- package/dist/middlewares/static-context.d.ts.map +1 -1
- package/dist/middlewares/static-context.js +7 -3
- package/dist/middlewares/static-context.js.map +1 -1
- package/dist/prompt-doc.d.ts +36 -0
- package/dist/prompt-doc.d.ts.map +1 -0
- package/dist/prompt-doc.js +44 -0
- package/dist/prompt-doc.js.map +1 -0
- package/dist/sdk.d.ts +3 -0
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +4 -0
- package/dist/sdk.js.map +1 -1
- package/dist/task-ref.d.ts +55 -0
- package/dist/task-ref.d.ts.map +1 -0
- package/dist/task-ref.js +101 -0
- package/dist/task-ref.js.map +1 -0
- package/dist/templates.d.ts +20 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +93 -0
- package/dist/templates.js.map +1 -0
- package/dist/validate-raw.d.ts.map +1 -1
- package/dist/validate-raw.js +27 -53
- package/dist/validate-raw.js.map +1 -1
- package/package.json +2 -2
- package/scripts/preinstall.js +31 -31
- package/src/adapters/stdin-approval.ts +106 -106
- package/src/adapters/websocket-approval.ts +224 -224
- package/src/approval.ts +131 -131
- package/src/bootstrap.ts +37 -37
- package/src/completions/exit-code.ts +34 -34
- package/src/completions/file-exists.ts +66 -66
- package/src/completions/output-check.ts +86 -86
- package/src/config-ops.ts +307 -307
- package/src/dag.ts +24 -54
- package/src/drivers/claude-code.ts +250 -250
- package/src/engine.ts +1137 -1098
- package/src/hooks.ts +187 -187
- package/src/logger.ts +182 -182
- package/src/middlewares/static-context.ts +49 -45
- package/src/pipeline-runner.ts +156 -156
- package/src/prompt-doc.ts +49 -0
- package/src/registry.ts +242 -242
- package/src/runner.ts +395 -395
- package/src/schema.test.ts +101 -101
- package/src/schema.ts +338 -338
- package/src/sdk.ts +111 -92
- package/src/task-ref.ts +120 -0
- package/src/triggers/file.ts +164 -164
- package/src/triggers/manual.ts +86 -86
- package/src/types.ts +18 -18
- package/src/utils.ts +203 -203
- 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 =
|
|
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
|
-
//
|
|
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
|
-
|
|
69
|
-
if (
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
202
|
-
if (nodes.has(qid)) continue;
|
|
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
|
-
|
|
185
|
+
const index = buildTaskIndex(config);
|
|
186
|
+
|
|
214
187
|
function tryResolve(ref: string, fromTrackId: string): string | null {
|
|
215
|
-
|
|
216
|
-
|
|
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 =
|
|
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
|
+
};
|