@dhananjay_kaushik/claude-orchestrator 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +370 -0
- package/bin/claude-orchestrator.js +2 -0
- package/dist/cli.js +79 -0
- package/dist/commands/doctor.js +43 -0
- package/dist/commands/init.js +36 -0
- package/dist/commands/logs.js +43 -0
- package/dist/commands/plan.js +125 -0
- package/dist/commands/run.js +349 -0
- package/dist/commands/status.js +60 -0
- package/dist/commands/validate.js +58 -0
- package/dist/config/defaults.js +28 -0
- package/dist/config/index.js +3 -0
- package/dist/config/loader.js +53 -0
- package/dist/config/schema.js +50 -0
- package/dist/executor/command.js +18 -0
- package/dist/executor/execution.js +191 -0
- package/dist/executor/parser.js +33 -0
- package/dist/executor/policy.js +64 -0
- package/dist/executor/state.js +36 -0
- package/dist/executor/verification.js +101 -0
- package/dist/git/branch.js +82 -0
- package/dist/git/commit.js +47 -0
- package/dist/git/repo.js +55 -0
- package/dist/logging/format.js +7 -0
- package/dist/logging/redact.js +11 -0
- package/dist/models.js +5 -0
- package/dist/plans/discovery.js +54 -0
- package/dist/plans/parser.js +136 -0
- package/dist/prompts/execution.js +41 -0
- package/dist/prompts/plan.js +42 -0
- package/dist/types/index.js +7 -0
- package/dist/worktrees/index.js +82 -0
- package/package.json +63 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const verificationCommandSchema = z.object({
|
|
3
|
+
command: z.string().min(1, 'Command must be a non-empty string'),
|
|
4
|
+
args: z.array(z.string()),
|
|
5
|
+
timeoutMs: z
|
|
6
|
+
.number()
|
|
7
|
+
.int()
|
|
8
|
+
.positive('Timeout must be a positive integer')
|
|
9
|
+
.max(3600000, 'Verification command timeout cannot exceed 1 hour'),
|
|
10
|
+
name: z.string().optional(),
|
|
11
|
+
cwd: z.string().optional(),
|
|
12
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
13
|
+
allowFailure: z.boolean().optional(),
|
|
14
|
+
});
|
|
15
|
+
export const configSchema = z.object({
|
|
16
|
+
version: z.string(),
|
|
17
|
+
planDir: z.string(),
|
|
18
|
+
baseBranch: z.string(),
|
|
19
|
+
branchPrefix: z.string(),
|
|
20
|
+
models: z.object({
|
|
21
|
+
planning: z.string(),
|
|
22
|
+
execution: z.string().optional(),
|
|
23
|
+
}),
|
|
24
|
+
claude: z.object({
|
|
25
|
+
binary: z.string(),
|
|
26
|
+
permissionMode: z.string().optional(),
|
|
27
|
+
allowedTools: z.array(z.string()).optional(),
|
|
28
|
+
extraSafeArgs: z.array(z.string()).optional(),
|
|
29
|
+
}),
|
|
30
|
+
taskTimeoutMs: z.number().int().positive().max(7200000, 'Task timeout cannot exceed 2 hours'),
|
|
31
|
+
verificationCommands: z.array(verificationCommandSchema),
|
|
32
|
+
maxRetries: z.number().int().min(0).max(10, 'Max retries cannot exceed 10'),
|
|
33
|
+
logsDir: z.string(),
|
|
34
|
+
stateDir: z.string(),
|
|
35
|
+
worktreeDir: z.string(),
|
|
36
|
+
commitMessageTemplate: z.string(),
|
|
37
|
+
sessionLimits: z
|
|
38
|
+
.object({
|
|
39
|
+
showBeforeRun: z.boolean(),
|
|
40
|
+
pauseOnLimit: z.boolean(),
|
|
41
|
+
})
|
|
42
|
+
.strict(),
|
|
43
|
+
security: z.object({
|
|
44
|
+
allowedCommands: z.array(z.string()).optional(),
|
|
45
|
+
deniedCommands: z.array(z.string()),
|
|
46
|
+
protectedPaths: z.array(z.string()),
|
|
47
|
+
allowNetwork: z.boolean(),
|
|
48
|
+
}),
|
|
49
|
+
notifications: z.unknown().optional(),
|
|
50
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function buildClaudeCommand(config, prompt) {
|
|
2
|
+
const command = config.claude?.binary || 'claude';
|
|
3
|
+
const args = ['-p', prompt, '--output-format', 'json'];
|
|
4
|
+
if (config.models?.execution) {
|
|
5
|
+
args.push('--model', config.models.execution);
|
|
6
|
+
}
|
|
7
|
+
if (config.claude?.permissionMode) {
|
|
8
|
+
args.push('--permission-mode', config.claude.permissionMode);
|
|
9
|
+
}
|
|
10
|
+
if (config.claude?.allowedTools && config.claude.allowedTools.length > 0) {
|
|
11
|
+
args.push('--allowedTools', config.claude.allowedTools.join(','));
|
|
12
|
+
}
|
|
13
|
+
if (config.claude?.extraSafeArgs && config.claude.extraSafeArgs.length > 0) {
|
|
14
|
+
const safeArgs = config.claude.extraSafeArgs.filter((arg) => arg !== '--dangerously-skip-permissions');
|
|
15
|
+
args.push(...safeArgs);
|
|
16
|
+
}
|
|
17
|
+
return { command, args };
|
|
18
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { buildClaudeCommand } from './command.js';
|
|
5
|
+
import { extractOrchestratorResult } from './parser.js';
|
|
6
|
+
import { redactSecrets } from '../logging/redact.js';
|
|
7
|
+
export async function checkClaudeSessionLimits(config) {
|
|
8
|
+
// Attempt to read from Claude status.
|
|
9
|
+
// Claude Code does not yet provide a structured 'status' CLI command,
|
|
10
|
+
// so this is a best-effort preflight that fails gracefully.
|
|
11
|
+
try {
|
|
12
|
+
const result = await execa(config.claude.binary, ['-p', 'status', '--output-format', 'json'], {
|
|
13
|
+
shell: false,
|
|
14
|
+
timeout: 5000,
|
|
15
|
+
stdin: 'ignore',
|
|
16
|
+
stdout: 'pipe',
|
|
17
|
+
stderr: 'pipe',
|
|
18
|
+
});
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(result.stdout);
|
|
21
|
+
let limitReached = false;
|
|
22
|
+
let resetTime;
|
|
23
|
+
const msg = parsed.result || '';
|
|
24
|
+
if (msg.match(/limit reached|usage limit/i)) {
|
|
25
|
+
limitReached = true;
|
|
26
|
+
const resetMatch = msg.match(/resets? in ([a-zA-Z0-9 ]+)/i);
|
|
27
|
+
if (resetMatch) {
|
|
28
|
+
resetTime = resetMatch[1].trim();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
limitReached,
|
|
33
|
+
usage: parsed.usage,
|
|
34
|
+
resetTime,
|
|
35
|
+
message: limitReached ? msg : undefined,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return { limitReached: false };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
let limitReached = false;
|
|
44
|
+
let resetTime;
|
|
45
|
+
const errorMsg = [
|
|
46
|
+
error?.message,
|
|
47
|
+
String(error?.stdout || ''),
|
|
48
|
+
String(error?.stderr || ''),
|
|
49
|
+
].join(' ');
|
|
50
|
+
if (errorMsg.match(/limit reached|usage limit/i)) {
|
|
51
|
+
limitReached = true;
|
|
52
|
+
const resetMatch = errorMsg.match(/resets? in ([a-zA-Z0-9 ]+)/i);
|
|
53
|
+
if (resetMatch) {
|
|
54
|
+
resetTime = resetMatch[1].trim();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
limitReached,
|
|
59
|
+
resetTime,
|
|
60
|
+
message: limitReached ? errorMsg : undefined,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function executeClaudeHeadless(config, prompt, logDir, taskId, signal) {
|
|
65
|
+
const { command, args } = buildClaudeCommand(config, prompt);
|
|
66
|
+
await fs.mkdir(logDir, { recursive: true });
|
|
67
|
+
const rawLogPath = path.join(logDir, `${taskId}-claude-response.json`);
|
|
68
|
+
try {
|
|
69
|
+
const result = await execa(command, args, {
|
|
70
|
+
shell: false,
|
|
71
|
+
timeout: config.taskTimeoutMs,
|
|
72
|
+
stdin: 'ignore',
|
|
73
|
+
stdout: 'pipe',
|
|
74
|
+
stderr: 'pipe',
|
|
75
|
+
cancelSignal: signal,
|
|
76
|
+
});
|
|
77
|
+
const timestamp = new Date().toISOString();
|
|
78
|
+
const header = `\n\n--- Claude Execution at ${timestamp} ---\n`;
|
|
79
|
+
const stdoutRedacted = redactSecrets(result.stdout);
|
|
80
|
+
await fs.appendFile(rawLogPath, header + stdoutRedacted + '\n', 'utf-8');
|
|
81
|
+
if (result.stderr) {
|
|
82
|
+
const stderrLogPath = path.join(logDir, `${taskId}-claude-stderr.log`);
|
|
83
|
+
await fs.appendFile(stderrLogPath, header + redactSecrets(result.stderr) + '\n', 'utf-8');
|
|
84
|
+
}
|
|
85
|
+
let parsed;
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(result.stdout);
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
return {
|
|
91
|
+
success: false,
|
|
92
|
+
error: 'Malformed JSON response from Claude',
|
|
93
|
+
exitCode: result.exitCode,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (parsed.is_error) {
|
|
97
|
+
let sessionLimitReached = false;
|
|
98
|
+
let limitResetTime;
|
|
99
|
+
const errorMsg = parsed.result || '';
|
|
100
|
+
if (errorMsg.match(/limit reached|usage limit/i)) {
|
|
101
|
+
sessionLimitReached = true;
|
|
102
|
+
const resetMatch = errorMsg.match(/resets? in ([a-zA-Z0-9 ]+)/i);
|
|
103
|
+
if (resetMatch) {
|
|
104
|
+
limitResetTime = resetMatch[1].trim();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
success: false,
|
|
109
|
+
response: parsed,
|
|
110
|
+
error: 'Claude execution returned is_error: true',
|
|
111
|
+
exitCode: result.exitCode,
|
|
112
|
+
sessionLimitReached,
|
|
113
|
+
limitResetTime,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (!parsed.result) {
|
|
117
|
+
return {
|
|
118
|
+
success: false,
|
|
119
|
+
response: parsed,
|
|
120
|
+
error: 'Claude execution missing required result field',
|
|
121
|
+
exitCode: result.exitCode,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const sentinel = extractOrchestratorResult(parsed.result);
|
|
125
|
+
if (!sentinel) {
|
|
126
|
+
return {
|
|
127
|
+
success: false,
|
|
128
|
+
response: parsed,
|
|
129
|
+
error: 'Missing required ORCHESTRATOR_RESULT sentinel',
|
|
130
|
+
exitCode: result.exitCode,
|
|
131
|
+
sentinel: null,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
success: true,
|
|
136
|
+
response: parsed,
|
|
137
|
+
exitCode: result.exitCode,
|
|
138
|
+
sentinel,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
const timestamp = new Date().toISOString();
|
|
143
|
+
const header = `\n\n--- Claude Execution at ${timestamp} ---\n`;
|
|
144
|
+
if (error.stdout) {
|
|
145
|
+
await fs.appendFile(rawLogPath, header + redactSecrets(String(error.stdout)) + '\n', 'utf-8');
|
|
146
|
+
}
|
|
147
|
+
if (error.stderr) {
|
|
148
|
+
const stderrLogPath = path.join(logDir, `${taskId}-claude-stderr.log`);
|
|
149
|
+
await fs.appendFile(stderrLogPath, header + redactSecrets(String(error.stderr)) + '\n', 'utf-8');
|
|
150
|
+
}
|
|
151
|
+
if (error.isCanceled || error.signal === 'SIGINT') {
|
|
152
|
+
return {
|
|
153
|
+
success: false,
|
|
154
|
+
error: 'Execution interrupted by user',
|
|
155
|
+
exitCode: error.exitCode ?? null,
|
|
156
|
+
interrupted: true,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
let parsed;
|
|
160
|
+
if (error.stdout) {
|
|
161
|
+
try {
|
|
162
|
+
parsed = JSON.parse(String(error.stdout));
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
// ignore
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
let sessionLimitReached = false;
|
|
169
|
+
let limitResetTime;
|
|
170
|
+
const errorMsg = [
|
|
171
|
+
error?.message,
|
|
172
|
+
String(error?.stdout || ''),
|
|
173
|
+
String(error?.stderr || ''),
|
|
174
|
+
].join(' ');
|
|
175
|
+
if (errorMsg.match(/limit reached|usage limit/i)) {
|
|
176
|
+
sessionLimitReached = true;
|
|
177
|
+
const resetMatch = errorMsg.match(/resets? in ([a-zA-Z0-9 ]+)/i);
|
|
178
|
+
if (resetMatch) {
|
|
179
|
+
limitResetTime = resetMatch[1].trim();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
success: false,
|
|
184
|
+
response: parsed,
|
|
185
|
+
error: error.message || 'Claude execution failed',
|
|
186
|
+
exitCode: error.exitCode ?? null,
|
|
187
|
+
sessionLimitReached,
|
|
188
|
+
limitResetTime,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function parseClaudeJSON(output) {
|
|
2
|
+
try {
|
|
3
|
+
const parsed = JSON.parse(output);
|
|
4
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
5
|
+
throw new Error('Claude JSON output must be an object');
|
|
6
|
+
}
|
|
7
|
+
// We expect some basic fields, mainly `result`.
|
|
8
|
+
if (typeof parsed.result !== 'string') {
|
|
9
|
+
throw new Error('Claude JSON output must contain a string "result" field');
|
|
10
|
+
}
|
|
11
|
+
return parsed;
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
throw new Error(`Failed to parse Claude JSON response: ${error.message}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function extractOrchestratorResult(resultString) {
|
|
18
|
+
const match = resultString.match(/ORCHESTRATOR_RESULT:\s*(SUCCESS|BLOCKED|NEEDS_RETRY_CONTEXT)/);
|
|
19
|
+
if (!match) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
const type = match[1];
|
|
23
|
+
const handoffNotes = resultString.substring(0, match.index).trim() || undefined;
|
|
24
|
+
if (type === 'BLOCKED') {
|
|
25
|
+
const reasonMatch = resultString.match(/BLOCKED_REASON:\s*(.+)$/m);
|
|
26
|
+
return {
|
|
27
|
+
type: 'BLOCKED',
|
|
28
|
+
reason: reasonMatch ? reasonMatch[1].trim() : 'Unknown block reason',
|
|
29
|
+
handoffNotes,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return { type, handoffNotes };
|
|
33
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export class PolicyViolationError extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = 'PolicyViolationError';
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Validates a verification command against the security policy.
|
|
9
|
+
* Throws PolicyViolationError if the command is unsafe.
|
|
10
|
+
*/
|
|
11
|
+
export function validateVerificationCommand(config, cmd) {
|
|
12
|
+
const DENIED_COMMANDS = ['rm', 'rmdir', 'chmod', 'chown', 'kill', 'pkill', 'killall'];
|
|
13
|
+
const deniedList = new Set([...DENIED_COMMANDS, ...(config.security?.deniedCommands || [])]);
|
|
14
|
+
// Strip potential paths to get the base executable name
|
|
15
|
+
const commandParts = cmd.command.split('/');
|
|
16
|
+
const baseCommand = commandParts[commandParts.length - 1];
|
|
17
|
+
if (deniedList.has(baseCommand)) {
|
|
18
|
+
throw new PolicyViolationError(`Command '${baseCommand}' is in the denylist.`);
|
|
19
|
+
}
|
|
20
|
+
// Block destructive Git commands regardless of config
|
|
21
|
+
if (baseCommand === 'git') {
|
|
22
|
+
const destructiveGitArgs = [
|
|
23
|
+
'reset',
|
|
24
|
+
'clean',
|
|
25
|
+
'push',
|
|
26
|
+
'rebase',
|
|
27
|
+
'commit',
|
|
28
|
+
'checkout',
|
|
29
|
+
'branch',
|
|
30
|
+
'rm',
|
|
31
|
+
'amend',
|
|
32
|
+
];
|
|
33
|
+
// Check if any argument is a destructive command
|
|
34
|
+
// (A naive check, but sufficient for MVP safety given we don't expect git to be used as a verification command anyway)
|
|
35
|
+
if (cmd.args.some((arg) => destructiveGitArgs.includes(arg))) {
|
|
36
|
+
throw new PolicyViolationError(`Destructive git operations are blocked.`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Block shell fragments, inline command chains, redirection, command substitution, and environment interpolation
|
|
40
|
+
const SHELL_CHARACTERS = /[&|<>;$()`\n]/;
|
|
41
|
+
if (SHELL_CHARACTERS.test(cmd.command)) {
|
|
42
|
+
throw new PolicyViolationError(`Shell fragments and operators are not allowed in command executables.`);
|
|
43
|
+
}
|
|
44
|
+
for (const arg of cmd.args) {
|
|
45
|
+
if (SHELL_CHARACTERS.test(arg)) {
|
|
46
|
+
throw new PolicyViolationError(`Shell fragments and operators are not allowed in command arguments.`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// Require explicit user confirmation for any command outside the allowlist
|
|
50
|
+
if (config.security?.allowedCommands && config.security.allowedCommands.length > 0) {
|
|
51
|
+
if (!config.security.allowedCommands.includes(baseCommand)) {
|
|
52
|
+
throw new PolicyViolationError(`Command '${baseCommand}' is not in the allowedCommands list. Explicit user confirmation is required.`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Validates Claude permission mode and allowed tools before execution.
|
|
58
|
+
* Throws PolicyViolationError if settings are unsafe.
|
|
59
|
+
*/
|
|
60
|
+
export function validateClaudePermissions(config) {
|
|
61
|
+
if (config.claude?.extraSafeArgs?.includes('--dangerously-skip-permissions')) {
|
|
62
|
+
throw new PolicyViolationError(`'--dangerously-skip-permissions' is not allowed for normal execution.`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export async function loadPlanState(planId, config) {
|
|
4
|
+
const stateDir = path.resolve(process.cwd(), config.stateDir);
|
|
5
|
+
const stateFile = path.join(stateDir, `${planId}.json`);
|
|
6
|
+
try {
|
|
7
|
+
const existing = await fs.readFile(stateFile, 'utf-8');
|
|
8
|
+
return JSON.parse(existing);
|
|
9
|
+
}
|
|
10
|
+
catch (e) {
|
|
11
|
+
if (e.code !== 'ENOENT') {
|
|
12
|
+
console.warn(`Warning: Failed to parse state file for plan ${planId}: ${e.message}`);
|
|
13
|
+
}
|
|
14
|
+
return { planId, tasks: {} };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function savePlanState(state, config) {
|
|
18
|
+
const stateDir = path.resolve(process.cwd(), config.stateDir);
|
|
19
|
+
await fs.mkdir(stateDir, { recursive: true });
|
|
20
|
+
const stateFile = path.join(stateDir, `${state.planId}.json`);
|
|
21
|
+
await fs.writeFile(stateFile, JSON.stringify(state, null, 2), 'utf-8');
|
|
22
|
+
}
|
|
23
|
+
export function getTaskState(state, taskId) {
|
|
24
|
+
if (!state.tasks[taskId]) {
|
|
25
|
+
state.tasks[taskId] = {
|
|
26
|
+
id: taskId,
|
|
27
|
+
attempts: 0,
|
|
28
|
+
lastStatus: 'NOT_DONE',
|
|
29
|
+
logFilePaths: [],
|
|
30
|
+
claudeExitCodes: [],
|
|
31
|
+
jsonResponsePaths: [],
|
|
32
|
+
verificationResults: [],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return state.tasks[taskId];
|
|
36
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
import { resolve, isAbsolute } from 'path';
|
|
3
|
+
import * as fs from 'fs/promises';
|
|
4
|
+
import { validateVerificationCommand } from './policy.js';
|
|
5
|
+
export async function runVerification(config, taskWorktreePath, logDir) {
|
|
6
|
+
if (!config.verificationCommands || config.verificationCommands.length === 0) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
try {
|
|
10
|
+
await fs.mkdir(logDir, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
// ignore
|
|
14
|
+
}
|
|
15
|
+
let totalDurationMs = 0;
|
|
16
|
+
let lastStdoutPath = '';
|
|
17
|
+
let lastStderrPath = '';
|
|
18
|
+
for (let i = 0; i < config.verificationCommands.length; i++) {
|
|
19
|
+
const cmd = config.verificationCommands[i];
|
|
20
|
+
validateVerificationCommand(config, cmd);
|
|
21
|
+
let cwd = taskWorktreePath;
|
|
22
|
+
if (cmd.cwd) {
|
|
23
|
+
cwd = isAbsolute(cmd.cwd) ? cmd.cwd : resolve(taskWorktreePath, cmd.cwd);
|
|
24
|
+
if (!cwd.startsWith(resolve(taskWorktreePath))) {
|
|
25
|
+
return {
|
|
26
|
+
success: false,
|
|
27
|
+
durationMs: 0,
|
|
28
|
+
errorOutput: `Command cwd ${cwd} escapes task worktree ${taskWorktreePath}`,
|
|
29
|
+
exitCode: 1,
|
|
30
|
+
stdoutPath: '',
|
|
31
|
+
stderrPath: '',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const stdoutPath = resolve(logDir, `verification_${i}_stdout.log`);
|
|
36
|
+
const stderrPath = resolve(logDir, `verification_${i}_stderr.log`);
|
|
37
|
+
lastStdoutPath = stdoutPath;
|
|
38
|
+
lastStderrPath = stderrPath;
|
|
39
|
+
const timestamp = new Date().toISOString();
|
|
40
|
+
const header = `\n\n--- Verification Command: ${cmd.command} ${cmd.args.join(' ')} at ${timestamp} ---\n`;
|
|
41
|
+
await fs.appendFile(stdoutPath, header);
|
|
42
|
+
await fs.appendFile(stderrPath, header);
|
|
43
|
+
const startTime = Date.now();
|
|
44
|
+
let exitCode = null;
|
|
45
|
+
let success = false;
|
|
46
|
+
let errorOutput = '';
|
|
47
|
+
try {
|
|
48
|
+
const subprocess = execa(cmd.command, cmd.args, {
|
|
49
|
+
cwd,
|
|
50
|
+
env: { ...process.env, ...cmd.env },
|
|
51
|
+
shell: false,
|
|
52
|
+
timeout: cmd.timeoutMs,
|
|
53
|
+
});
|
|
54
|
+
subprocess.stdout?.on('data', (chunk) => {
|
|
55
|
+
const text = redactSecrets(chunk.toString());
|
|
56
|
+
fs.appendFile(stdoutPath, text).catch(() => { });
|
|
57
|
+
});
|
|
58
|
+
subprocess.stderr?.on('data', (chunk) => {
|
|
59
|
+
const text = redactSecrets(chunk.toString());
|
|
60
|
+
fs.appendFile(stderrPath, text).catch(() => { });
|
|
61
|
+
});
|
|
62
|
+
const result = await subprocess;
|
|
63
|
+
exitCode = result.exitCode ?? null;
|
|
64
|
+
success = true;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
exitCode = error.exitCode ?? null;
|
|
68
|
+
success = !!cmd.allowFailure;
|
|
69
|
+
errorOutput = redactSecrets(error.stderr || error.message || String(error));
|
|
70
|
+
}
|
|
71
|
+
const durationMs = Date.now() - startTime;
|
|
72
|
+
const footer = `\n--- Exit Code: ${exitCode}, Duration: ${durationMs}ms ---\n`;
|
|
73
|
+
await fs.appendFile(stdoutPath, footer).catch(() => { });
|
|
74
|
+
await fs.appendFile(stderrPath, footer).catch(() => { });
|
|
75
|
+
totalDurationMs += durationMs;
|
|
76
|
+
if (!success) {
|
|
77
|
+
return {
|
|
78
|
+
success: false,
|
|
79
|
+
durationMs: totalDurationMs,
|
|
80
|
+
errorOutput,
|
|
81
|
+
exitCode,
|
|
82
|
+
stdoutPath,
|
|
83
|
+
stderrPath,
|
|
84
|
+
command: `${cmd.command} ${cmd.args.join(' ')}`.trim(),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
success: true,
|
|
90
|
+
durationMs: totalDurationMs,
|
|
91
|
+
stdoutPath: lastStdoutPath,
|
|
92
|
+
stderrPath: lastStderrPath,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
export function redactSecrets(text) {
|
|
96
|
+
// Redact potential secrets.
|
|
97
|
+
let redacted = text;
|
|
98
|
+
redacted = redacted.replace(/((?:password|secret|token|key|api_key|auth)[=:]\s*['"]?)[^\s'"]+(['"]?)/gi, '$1***$2');
|
|
99
|
+
redacted = redacted.replace(/((?:bearer)\s+)[^\s"']+/gi, '$1***');
|
|
100
|
+
return redacted;
|
|
101
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
import { isCancel, text, select, confirm } from '@clack/prompts';
|
|
3
|
+
/**
|
|
4
|
+
* Sanitizes a string to be a valid git branch name.
|
|
5
|
+
* Replaces invalid characters with hyphens and removes consecutive hyphens.
|
|
6
|
+
*/
|
|
7
|
+
export function sanitizeBranchName(name) {
|
|
8
|
+
return name
|
|
9
|
+
.toLowerCase()
|
|
10
|
+
.replace(/[^a-z0-9/]/g, '-')
|
|
11
|
+
.replace(/-+/g, '-')
|
|
12
|
+
.replace(/^-|-$/g, '');
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Checks if the current git repository has uncommitted changes.
|
|
16
|
+
*/
|
|
17
|
+
export async function hasUncommittedChanges(cwd = process.cwd()) {
|
|
18
|
+
try {
|
|
19
|
+
const { stdout } = await execa('git', ['status', '--porcelain'], { cwd });
|
|
20
|
+
return stdout.trim().length > 0;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Prompts the user for a branch name with a default derived from the plan name.
|
|
28
|
+
*/
|
|
29
|
+
export async function promptForBranchName(defaultName) {
|
|
30
|
+
const sanitizedDefault = sanitizeBranchName(defaultName);
|
|
31
|
+
const branchName = await text({
|
|
32
|
+
message: 'What should we name the branch for this plan?',
|
|
33
|
+
initialValue: sanitizedDefault,
|
|
34
|
+
validate: (value) => {
|
|
35
|
+
if (!value)
|
|
36
|
+
return 'Branch name is required';
|
|
37
|
+
const sanitized = sanitizeBranchName(value);
|
|
38
|
+
if (value !== sanitized)
|
|
39
|
+
return `Invalid characters. Try: ${sanitized}`;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
if (isCancel(branchName)) {
|
|
43
|
+
throw new Error('Operation cancelled');
|
|
44
|
+
}
|
|
45
|
+
return branchName;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Prompts the user on how to handle a dirty working tree.
|
|
49
|
+
*/
|
|
50
|
+
export async function promptForDirtyWorktree() {
|
|
51
|
+
const action = await select({
|
|
52
|
+
message: 'The working tree has uncommitted changes. How would you like to proceed?',
|
|
53
|
+
options: [
|
|
54
|
+
{ value: 'continue', label: 'Continue on current branch (keep changes)' },
|
|
55
|
+
{ value: 'branch', label: 'Create a new branch with these changes' },
|
|
56
|
+
{ value: 'halt', label: 'Halt execution' },
|
|
57
|
+
]
|
|
58
|
+
});
|
|
59
|
+
if (isCancel(action)) {
|
|
60
|
+
return 'halt';
|
|
61
|
+
}
|
|
62
|
+
return action;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Prompts to confirm the base branch.
|
|
66
|
+
*/
|
|
67
|
+
export async function confirmBaseBranch(baseBranch) {
|
|
68
|
+
const isConfirmed = await confirm({
|
|
69
|
+
message: `Use '${baseBranch}' as the base branch?`,
|
|
70
|
+
initialValue: true,
|
|
71
|
+
});
|
|
72
|
+
if (isCancel(isConfirmed)) {
|
|
73
|
+
throw new Error('Operation cancelled');
|
|
74
|
+
}
|
|
75
|
+
return isConfirmed;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Creates and checks out a new branch.
|
|
79
|
+
*/
|
|
80
|
+
export async function createAndCheckoutBranch(branchName, cwd = process.cwd()) {
|
|
81
|
+
await execa('git', ['checkout', '-b', branchName], { cwd });
|
|
82
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
/**
|
|
3
|
+
* Stages all changes in the given directory using `git add -A`.
|
|
4
|
+
*/
|
|
5
|
+
export async function stageAllChanges(cwd = process.cwd()) {
|
|
6
|
+
await execa('git', ['add', '-A'], { cwd });
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Checks if there are any staged changes ready to be committed.
|
|
10
|
+
*/
|
|
11
|
+
export async function hasStagedChanges(cwd = process.cwd()) {
|
|
12
|
+
try {
|
|
13
|
+
const { exitCode } = await execa('git', ['diff', '--cached', '--quiet'], { cwd, reject: false });
|
|
14
|
+
return exitCode === 1;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Creates a commit with the specified message.
|
|
22
|
+
* Returns the commit hash.
|
|
23
|
+
*/
|
|
24
|
+
export async function createCommit(message, cwd = process.cwd()) {
|
|
25
|
+
await execa('git', ['commit', '-m', message], { cwd });
|
|
26
|
+
return getLatestCommitHash(cwd);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Returns the hash of the latest commit.
|
|
30
|
+
*/
|
|
31
|
+
export async function getLatestCommitHash(cwd = process.cwd()) {
|
|
32
|
+
const { stdout } = await execa('git', ['rev-parse', 'HEAD'], { cwd });
|
|
33
|
+
return stdout.trim();
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Formats a commit message using the template and variables.
|
|
37
|
+
*/
|
|
38
|
+
export function formatCommitMessage(template, vars = {}) {
|
|
39
|
+
let msg = template;
|
|
40
|
+
if (vars.planName)
|
|
41
|
+
msg = msg.replace(/\{planName\}/g, vars.planName);
|
|
42
|
+
if (vars.taskId)
|
|
43
|
+
msg = msg.replace(/\{taskId\}/g, vars.taskId);
|
|
44
|
+
if (vars.taskText)
|
|
45
|
+
msg = msg.replace(/\{taskText\}/g, vars.taskText);
|
|
46
|
+
return msg;
|
|
47
|
+
}
|
package/dist/git/repo.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
/**
|
|
3
|
+
* Checks whether the specified directory is within a Git repository.
|
|
4
|
+
*/
|
|
5
|
+
export async function isGitRepository(cwd = process.cwd()) {
|
|
6
|
+
try {
|
|
7
|
+
const { exitCode } = await execa('git', ['rev-parse', '--is-inside-work-tree'], {
|
|
8
|
+
cwd,
|
|
9
|
+
reject: false,
|
|
10
|
+
});
|
|
11
|
+
return exitCode === 0;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Initializes a new Git repository in the specified directory.
|
|
19
|
+
*/
|
|
20
|
+
export async function initializeGitRepository(cwd = process.cwd()) {
|
|
21
|
+
await execa('git', ['init'], { cwd });
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Gets all local branches in the Git repository.
|
|
25
|
+
*/
|
|
26
|
+
export async function getAvailableBranches(cwd = process.cwd()) {
|
|
27
|
+
try {
|
|
28
|
+
const { stdout } = await execa('git', ['branch', '--format=%(refname:short)'], { cwd });
|
|
29
|
+
return stdout
|
|
30
|
+
.split('\n')
|
|
31
|
+
.map((b) => b.trim())
|
|
32
|
+
.filter((b) => b.length > 0);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Resolves the default branch. If the configured default branch exists, returns it.
|
|
40
|
+
* Otherwise, falls back to common branch names or the first available branch.
|
|
41
|
+
* If no branches exist, returns the configured default.
|
|
42
|
+
*/
|
|
43
|
+
export async function resolveDefaultBranch(configDefaultBranch, cwd = process.cwd()) {
|
|
44
|
+
const branches = await getAvailableBranches(cwd);
|
|
45
|
+
if (branches.includes(configDefaultBranch)) {
|
|
46
|
+
return configDefaultBranch;
|
|
47
|
+
}
|
|
48
|
+
if (branches.includes('main'))
|
|
49
|
+
return 'main';
|
|
50
|
+
if (branches.includes('master'))
|
|
51
|
+
return 'master';
|
|
52
|
+
if (branches.length > 0)
|
|
53
|
+
return branches[0];
|
|
54
|
+
return configDefaultBranch;
|
|
55
|
+
}
|