@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,125 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { loadConfig } from '../config/loader.js';
|
|
4
|
+
import * as p from '@clack/prompts';
|
|
5
|
+
import pc from 'picocolors';
|
|
6
|
+
import { execa } from 'execa';
|
|
7
|
+
import { buildPlanPrompt, buildEditPlanPrompt } from '../prompts/plan.js';
|
|
8
|
+
import { MODEL_OPTIONS } from '../models.js';
|
|
9
|
+
import { discoverPlan } from '../plans/discovery.js';
|
|
10
|
+
async function listMarkdownFiles(dir) {
|
|
11
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
12
|
+
return entries.filter((entry) => entry.isFile() && entry.name.endsWith('.md')).map((entry) => entry.name);
|
|
13
|
+
}
|
|
14
|
+
export async function runPlanCommand(options) {
|
|
15
|
+
const config = await loadConfig(options.config);
|
|
16
|
+
p.intro(pc.bgBlue(pc.white(' Claude Orchestrator: Planning Phase ')));
|
|
17
|
+
// 1. Model Selection
|
|
18
|
+
const defaultModel = config.models?.planning || 'claude-sonnet-5';
|
|
19
|
+
const modelChoice = await p.select({
|
|
20
|
+
message: 'Which model should Claude use for planning?',
|
|
21
|
+
initialValue: MODEL_OPTIONS.some((o) => o.value === defaultModel) ? defaultModel : 'other',
|
|
22
|
+
options: [...MODEL_OPTIONS, { value: 'other', label: 'Other (enter manually)' }],
|
|
23
|
+
});
|
|
24
|
+
if (p.isCancel(modelChoice)) {
|
|
25
|
+
p.cancel('Planning cancelled.');
|
|
26
|
+
process.exit(0);
|
|
27
|
+
}
|
|
28
|
+
let modelResult = modelChoice;
|
|
29
|
+
if (modelChoice === 'other') {
|
|
30
|
+
modelResult = await p.text({
|
|
31
|
+
message: 'Enter the model name or alias:',
|
|
32
|
+
initialValue: defaultModel,
|
|
33
|
+
});
|
|
34
|
+
if (p.isCancel(modelResult)) {
|
|
35
|
+
p.cancel('Planning cancelled.');
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// 2. Plan Directory Confirmation
|
|
40
|
+
const defaultPlanDir = config.planDir || '.claude-orchestrator/plans';
|
|
41
|
+
const planDirResult = await p.text({
|
|
42
|
+
message: 'Where should the plan be saved?',
|
|
43
|
+
initialValue: defaultPlanDir,
|
|
44
|
+
});
|
|
45
|
+
if (p.isCancel(planDirResult)) {
|
|
46
|
+
p.cancel('Planning cancelled.');
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
const planDir = planDirResult;
|
|
50
|
+
const resolvedPlanDir = path.resolve(process.cwd(), planDir);
|
|
51
|
+
// Ensure plan directory exists
|
|
52
|
+
try {
|
|
53
|
+
await fs.mkdir(resolvedPlanDir, { recursive: true });
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
p.log.error(`Failed to create plan directory ${resolvedPlanDir}: ${error.message}`);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
// 3. Create new plan or edit an existing one
|
|
60
|
+
const mode = await p.select({
|
|
61
|
+
message: 'What do you want to do?',
|
|
62
|
+
options: [
|
|
63
|
+
{ value: 'create', label: 'Create a new plan' },
|
|
64
|
+
{ value: 'edit', label: 'Edit an existing plan' },
|
|
65
|
+
],
|
|
66
|
+
});
|
|
67
|
+
if (p.isCancel(mode)) {
|
|
68
|
+
p.cancel('Planning cancelled.');
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|
|
71
|
+
let planPrompt;
|
|
72
|
+
let editingPlanPath = null;
|
|
73
|
+
if (mode === 'edit') {
|
|
74
|
+
editingPlanPath = await discoverPlan({ planDir });
|
|
75
|
+
if (!editingPlanPath) {
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
78
|
+
planPrompt = buildEditPlanPrompt(editingPlanPath, planDir);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
planPrompt = buildPlanPrompt(planDir);
|
|
82
|
+
}
|
|
83
|
+
p.log.info(pc.cyan('Spawning Claude Code for interactive planning...'));
|
|
84
|
+
// 3. Spawn Claude Code
|
|
85
|
+
const claudeBinary = config.claude.binary || 'claude';
|
|
86
|
+
const filesBefore = editingPlanPath ? null : new Set(await listMarkdownFiles(resolvedPlanDir));
|
|
87
|
+
const editingMtimeBefore = editingPlanPath ? (await fs.stat(editingPlanPath)).mtimeMs : null;
|
|
88
|
+
try {
|
|
89
|
+
const childProcess = execa(claudeBinary, ['--model', modelResult, planPrompt], {
|
|
90
|
+
stdio: 'inherit',
|
|
91
|
+
});
|
|
92
|
+
await childProcess;
|
|
93
|
+
if (editingPlanPath) {
|
|
94
|
+
const mtimeAfter = (await fs.stat(editingPlanPath)).mtimeMs;
|
|
95
|
+
if (mtimeAfter !== editingMtimeBefore) {
|
|
96
|
+
p.outro(pc.green(`Planning completed successfully. Plan updated at ${editingPlanPath}`));
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
p.outro(pc.yellow('The plan file was not changed.'));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
const filesAfter = await listMarkdownFiles(resolvedPlanDir);
|
|
104
|
+
const createdPlan = filesAfter.find((file) => !filesBefore.has(file));
|
|
105
|
+
if (createdPlan) {
|
|
106
|
+
p.outro(pc.green(`Planning completed successfully. Plan saved to ${path.join(resolvedPlanDir, createdPlan)}`));
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
p.outro(pc.yellow('No plan file was created. Run `claude-orchestrator plan` again to try once more.'));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error.signal === 'SIGINT' || error.isCanceled) {
|
|
115
|
+
p.outro(pc.yellow('Planning interrupted.'));
|
|
116
|
+
}
|
|
117
|
+
else if (error.exitCode !== undefined && error.exitCode !== 0) {
|
|
118
|
+
p.outro(pc.yellow(`Claude exited with code ${error.exitCode}. Please check the plan file.`));
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
p.log.error(pc.red(`Failed to spawn Claude: ${error.message}`));
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { loadConfig } from '../config/loader.js';
|
|
2
|
+
import { discoverPlan } from '../plans/discovery.js';
|
|
3
|
+
import { parsePlan, ValidationError, determineNextTask, updateTaskStatus, } from '../plans/parser.js';
|
|
4
|
+
import { checkClaudeSessionLimits, executeClaudeHeadless } from '../executor/execution.js';
|
|
5
|
+
import { runVerification } from '../executor/verification.js';
|
|
6
|
+
import { loadPlanState, savePlanState, getTaskState } from '../executor/state.js';
|
|
7
|
+
import { buildExecutionPrompt } from '../prompts/execution.js';
|
|
8
|
+
import { isGitRepository, initializeGitRepository, resolveDefaultBranch } from '../git/repo.js';
|
|
9
|
+
import { getWorktreeBranchName, createWorktree } from '../worktrees/index.js';
|
|
10
|
+
import { stageAllChanges, hasStagedChanges, createCommit, formatCommitMessage, } from '../git/commit.js';
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import * as p from '@clack/prompts';
|
|
14
|
+
import pc from 'picocolors';
|
|
15
|
+
import { truncateForTerminal } from '../logging/format.js';
|
|
16
|
+
import { MODEL_OPTIONS } from '../models.js';
|
|
17
|
+
export async function runCommand(options) {
|
|
18
|
+
p.intro(pc.bgBlue(pc.white(' Claude Orchestrator: Execution Phase ')));
|
|
19
|
+
const isGit = await isGitRepository();
|
|
20
|
+
if (!isGit) {
|
|
21
|
+
const action = await p.select({
|
|
22
|
+
message: 'This project is not a Git repository. Git is required for safe orchestration.',
|
|
23
|
+
options: [
|
|
24
|
+
{ value: 'init', label: 'Initialize Git repository now' },
|
|
25
|
+
{ value: 'halt', label: 'Halt execution' },
|
|
26
|
+
],
|
|
27
|
+
});
|
|
28
|
+
if (p.isCancel(action) || action === 'halt') {
|
|
29
|
+
p.log.info(pc.yellow('Execution halted. Please initialize Git to continue.'));
|
|
30
|
+
process.exit(0);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
await initializeGitRepository();
|
|
34
|
+
p.log.success(pc.green('Git repository initialized.'));
|
|
35
|
+
}
|
|
36
|
+
const config = await loadConfig(options.config);
|
|
37
|
+
const defaultModel = config.models?.execution || 'claude-sonnet-5';
|
|
38
|
+
const modelChoice = await p.select({
|
|
39
|
+
message: 'Which model should Claude use for execution?',
|
|
40
|
+
initialValue: MODEL_OPTIONS.some((o) => o.value === defaultModel) ? defaultModel : 'other',
|
|
41
|
+
options: [...MODEL_OPTIONS, { value: 'other', label: 'Other (enter manually)' }],
|
|
42
|
+
});
|
|
43
|
+
if (p.isCancel(modelChoice)) {
|
|
44
|
+
p.cancel('Execution cancelled.');
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
let executionModel = modelChoice;
|
|
48
|
+
if (modelChoice === 'other') {
|
|
49
|
+
const customModel = await p.text({
|
|
50
|
+
message: 'Enter the model name or alias:',
|
|
51
|
+
initialValue: defaultModel,
|
|
52
|
+
});
|
|
53
|
+
if (p.isCancel(customModel)) {
|
|
54
|
+
p.cancel('Execution cancelled.');
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
executionModel = customModel;
|
|
58
|
+
}
|
|
59
|
+
config.models = { ...config.models, execution: executionModel };
|
|
60
|
+
const defaultBranchName = config.baseBranch || 'main';
|
|
61
|
+
const baseBranch = await resolveDefaultBranch(defaultBranchName);
|
|
62
|
+
p.log.info(`Using base branch: ${pc.cyan(baseBranch)}`);
|
|
63
|
+
let planPath = options.plan;
|
|
64
|
+
if (!planPath) {
|
|
65
|
+
const defaultPlanDir = config.planDir || '.claude-orchestrator/plans';
|
|
66
|
+
const discoveredPlan = await discoverPlan({ planDir: defaultPlanDir });
|
|
67
|
+
if (!discoveredPlan) {
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
70
|
+
planPath = discoveredPlan;
|
|
71
|
+
}
|
|
72
|
+
p.log.info(`Selected plan: ${pc.cyan(planPath)}`);
|
|
73
|
+
let keepLooping = true;
|
|
74
|
+
while (keepLooping) {
|
|
75
|
+
keepLooping = options.loop === true;
|
|
76
|
+
await runOneTask(planPath, options, config, baseBranch);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async function runOneTask(planPath, options, config, baseBranch) {
|
|
80
|
+
let parsedPlan;
|
|
81
|
+
let planContent;
|
|
82
|
+
try {
|
|
83
|
+
planContent = fs.readFileSync(planPath, 'utf8');
|
|
84
|
+
const planId = path.basename(planPath, path.extname(planPath));
|
|
85
|
+
parsedPlan = parsePlan(planContent, planId);
|
|
86
|
+
const notDone = parsedPlan.tasks.filter((t) => t.status !== 'DONE').length;
|
|
87
|
+
p.log.success(pc.green(`Plan validated successfully: ${notDone}/${parsedPlan.tasks.length} tasks remaining.`));
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (error instanceof ValidationError) {
|
|
91
|
+
p.log.error(pc.red(`Plan Validation Failed: ${error.message}`));
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
const state = await loadPlanState(parsedPlan.planId, config);
|
|
97
|
+
const retryCounts = {};
|
|
98
|
+
for (const [taskId, taskState] of Object.entries(state.tasks)) {
|
|
99
|
+
retryCounts[taskId] = Math.max(0, taskState.attempts - 1);
|
|
100
|
+
}
|
|
101
|
+
let nextTask = determineNextTask(parsedPlan.tasks, config.maxRetries || 3, retryCounts);
|
|
102
|
+
if (!nextTask) {
|
|
103
|
+
let completed = 0, failed = 0, blocked = 0, totalCostUsd = 0, verified = 0, unverified = 0;
|
|
104
|
+
const commits = [];
|
|
105
|
+
for (const t of Object.values(state.tasks)) {
|
|
106
|
+
if (t.lastStatus === 'DONE')
|
|
107
|
+
completed++;
|
|
108
|
+
if (t.lastStatus === 'FAILED')
|
|
109
|
+
failed++;
|
|
110
|
+
if (t.lastStatus === 'BLOCKED')
|
|
111
|
+
blocked++;
|
|
112
|
+
if (t.totalCostUsd)
|
|
113
|
+
totalCostUsd += t.totalCostUsd;
|
|
114
|
+
if (t.commitHash)
|
|
115
|
+
commits.push(t.commitHash);
|
|
116
|
+
const lastVerification = t.verificationResults?.[t.verificationResults.length - 1];
|
|
117
|
+
if (lastVerification?.success)
|
|
118
|
+
verified++;
|
|
119
|
+
else
|
|
120
|
+
unverified++;
|
|
121
|
+
}
|
|
122
|
+
const allDone = parsedPlan.tasks.every((t) => t.status === 'DONE');
|
|
123
|
+
const hasBlocked = parsedPlan.tasks.some((t) => t.status === 'BLOCKED');
|
|
124
|
+
if (allDone) {
|
|
125
|
+
p.log.success(pc.green('All tasks complete. No further orchestration work is needed.'));
|
|
126
|
+
}
|
|
127
|
+
else if (hasBlocked) {
|
|
128
|
+
p.log.warn(pc.yellow('Plan is blocked. Resolve the blocking issue(s) shown above, then rerun.'));
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
p.log.warn(pc.yellow('Remaining tasks have exhausted their retries and need manual intervention.'));
|
|
132
|
+
}
|
|
133
|
+
p.log.info(pc.blue('--- Plan Summary ---'));
|
|
134
|
+
p.log.info(`Completed Tasks: ${completed}`);
|
|
135
|
+
p.log.info(`Failed Tasks: ${failed}`);
|
|
136
|
+
p.log.info(`Blocked Tasks: ${blocked}`);
|
|
137
|
+
p.log.info(`Verification: ${verified} passed / ${unverified} not passing`);
|
|
138
|
+
p.log.info(`Total Commits: ${commits.length}`);
|
|
139
|
+
p.log.info(`Total Cost Est.: $${totalCostUsd.toFixed(4)}`);
|
|
140
|
+
p.log.info(`Plan Path: ${planPath}`);
|
|
141
|
+
p.log.info(`Base Branch: ${baseBranch}`);
|
|
142
|
+
p.log.info(`Logs Directory: ${config.logsDir}`);
|
|
143
|
+
p.log.info(`State Directory: ${config.stateDir}`);
|
|
144
|
+
if (allDone) {
|
|
145
|
+
const branchName = getWorktreeBranchName(parsedPlan.planId);
|
|
146
|
+
p.log.info(`Work Branch: ${branchName}`);
|
|
147
|
+
p.log.info(pc.cyan(`To bring this work into another branch: git checkout <your-feature-branch> && git merge ${branchName}`));
|
|
148
|
+
}
|
|
149
|
+
process.exit(0);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
p.log.info(`Next task: ${nextTask.originalText.trim()}`);
|
|
153
|
+
const logsDir = config.logsDir || '.claude-orchestrator/logs';
|
|
154
|
+
const taskLogDir = path.join(logsDir, parsedPlan.planId, nextTask.id);
|
|
155
|
+
const worktreeDir = config.worktreeDir || '.claude-orchestrator/worktrees';
|
|
156
|
+
const taskWorktree = path.join(process.cwd(), worktreeDir, parsedPlan.planId);
|
|
157
|
+
if (options.dryRun) {
|
|
158
|
+
p.log.info(pc.blue('--- DRY RUN ---'));
|
|
159
|
+
p.log.info(`Plan: ${planPath}`);
|
|
160
|
+
p.log.info(`Task: ${nextTask.originalText.trim()}`);
|
|
161
|
+
const branchName = getWorktreeBranchName(parsedPlan.planId);
|
|
162
|
+
p.log.info(`Branch Operation: Will use branch ${pc.cyan(branchName)} at worktree ${pc.cyan(taskWorktree)} based on ${pc.cyan(baseBranch)}`);
|
|
163
|
+
if (config.verificationCommands && config.verificationCommands.length > 0) {
|
|
164
|
+
p.log.info('Verification Commands:');
|
|
165
|
+
for (const cmd of config.verificationCommands) {
|
|
166
|
+
p.log.info(` - ${cmd.command} ${cmd.args.join(' ')}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
p.log.info('Verification Commands: None configured.');
|
|
171
|
+
}
|
|
172
|
+
p.log.info(`Log Directory: ${taskLogDir}`);
|
|
173
|
+
p.log.info(`State Directory: ${config.stateDir || '.claude-orchestrator/state'}`);
|
|
174
|
+
p.outro(pc.green('Dry run complete. No state was mutated.'));
|
|
175
|
+
process.exit(0);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const limitInfo = await checkClaudeSessionLimits(config);
|
|
179
|
+
if (limitInfo.limitReached) {
|
|
180
|
+
p.log.warn(pc.yellow(`Claude session limit reached: ${limitInfo.message || 'unknown'}`));
|
|
181
|
+
process.exit(0);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const updatedPlanContent = updateTaskStatus(planContent, nextTask, 'IN_PROGRESS');
|
|
185
|
+
fs.writeFileSync(planPath, updatedPlanContent, 'utf8');
|
|
186
|
+
p.log.info(pc.blue('Marked task as IN_PROGRESS.'));
|
|
187
|
+
// Re-parse so nextTask.originalText reflects the IN_PROGRESS marker just written;
|
|
188
|
+
// later updateTaskStatus calls match against updatedPlanContent, not the stale NOT_DONE line.
|
|
189
|
+
nextTask = parsePlan(updatedPlanContent, parsedPlan.planId).tasks.find((t) => t.id === nextTask.id);
|
|
190
|
+
p.log.info('Spawning Claude Code...');
|
|
191
|
+
// The worktree/branch is shared across every task in the plan, so once
|
|
192
|
+
// task 1 creates it, later tasks just keep committing onto it here.
|
|
193
|
+
if (fs.existsSync(taskWorktree)) {
|
|
194
|
+
p.log.info(pc.blue(`Reusing existing plan worktree at ${taskWorktree}.`));
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
const branchName = getWorktreeBranchName(parsedPlan.planId);
|
|
198
|
+
await createWorktree(taskWorktree, branchName, baseBranch, process.cwd());
|
|
199
|
+
}
|
|
200
|
+
const taskState = getTaskState(state, nextTask.id);
|
|
201
|
+
const retryContext = {
|
|
202
|
+
lastError: taskState.lastError,
|
|
203
|
+
lastVerificationError: taskState.lastVerificationError,
|
|
204
|
+
};
|
|
205
|
+
const prompt = buildExecutionPrompt(planPath, nextTask.originalText, nextTask.id, taskWorktree, retryContext);
|
|
206
|
+
const abortController = new AbortController();
|
|
207
|
+
const onSigInt = () => {
|
|
208
|
+
p.log.warn(pc.yellow('\nReceived SIGINT. Cancelling current task...'));
|
|
209
|
+
abortController.abort();
|
|
210
|
+
};
|
|
211
|
+
process.on('SIGINT', onSigInt);
|
|
212
|
+
let outcome;
|
|
213
|
+
try {
|
|
214
|
+
outcome = await executeClaudeHeadless(config, prompt, taskLogDir, nextTask.id, abortController.signal);
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
process.off('SIGINT', onSigInt);
|
|
218
|
+
}
|
|
219
|
+
taskState.attempts += 1;
|
|
220
|
+
taskState.claudeExitCodes.push(outcome.exitCode ?? null);
|
|
221
|
+
p.log.info(`Attempt ${taskState.attempts} of ${config.maxRetries || 3}`);
|
|
222
|
+
if (outcome.response) {
|
|
223
|
+
taskState.claudeSessionId = outcome.response.session_id;
|
|
224
|
+
if (outcome.response.total_cost_usd !== undefined) {
|
|
225
|
+
taskState.totalCostUsd = (taskState.totalCostUsd || 0) + outcome.response.total_cost_usd;
|
|
226
|
+
}
|
|
227
|
+
if (outcome.response.usage) {
|
|
228
|
+
taskState.usage = taskState.usage || {};
|
|
229
|
+
for (const [k, v] of Object.entries(outcome.response.usage)) {
|
|
230
|
+
taskState.usage[k] = (taskState.usage[k] || 0) + (typeof v === 'number' ? v : 0);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (outcome.sentinel && 'handoffNotes' in outcome.sentinel && outcome.sentinel.handoffNotes) {
|
|
235
|
+
taskState.handoffNotes = outcome.sentinel.handoffNotes;
|
|
236
|
+
}
|
|
237
|
+
if (outcome.success && outcome.sentinel?.type === 'BLOCKED') {
|
|
238
|
+
taskState.lastStatus = 'BLOCKED';
|
|
239
|
+
taskState.blockReason = outcome.sentinel.reason;
|
|
240
|
+
await savePlanState(state, config);
|
|
241
|
+
const blockedPlanContent = updateTaskStatus(updatedPlanContent, nextTask, 'BLOCKED');
|
|
242
|
+
fs.writeFileSync(planPath, blockedPlanContent, 'utf8');
|
|
243
|
+
p.log.warn(pc.yellow(`Task blocked: ${outcome.sentinel.reason}`));
|
|
244
|
+
p.log.info(pc.blue(`Detailed logs are available at: ${taskLogDir}`));
|
|
245
|
+
p.log.info(`Resolve the blocker, then resume with: claude-orchestrator run --plan ${planPath}`);
|
|
246
|
+
}
|
|
247
|
+
else if (outcome.success) {
|
|
248
|
+
p.log.success(pc.green('Claude execution succeeded.'));
|
|
249
|
+
p.log.info(pc.blue('Running verification gates...'));
|
|
250
|
+
let verificationPassed = false;
|
|
251
|
+
const verificationResult = await runVerification(config, taskWorktree, taskLogDir);
|
|
252
|
+
taskState.verificationResults = taskState.verificationResults || [];
|
|
253
|
+
taskState.verificationResults.push(verificationResult);
|
|
254
|
+
if (verificationResult === null) {
|
|
255
|
+
const confirm = options.yes
|
|
256
|
+
? true
|
|
257
|
+
: await p.confirm({
|
|
258
|
+
message: 'No verification commands configured. Treat work as complete and commit?',
|
|
259
|
+
initialValue: true,
|
|
260
|
+
});
|
|
261
|
+
if (p.isCancel(confirm) || !confirm) {
|
|
262
|
+
verificationPassed = false;
|
|
263
|
+
taskState.lastVerificationError = 'User rejected completion without verification.';
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
verificationPassed = true;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
else if (!verificationResult.success) {
|
|
270
|
+
p.log.error(pc.red(`Verification failed with exit code ${verificationResult.exitCode ?? 'none'}.`));
|
|
271
|
+
if (verificationResult.command) {
|
|
272
|
+
p.log.error(pc.red(`Failing command: ${verificationResult.command}`));
|
|
273
|
+
}
|
|
274
|
+
p.log.error(truncateForTerminal(verificationResult.errorOutput));
|
|
275
|
+
p.log.info(pc.blue(`Full output: ${verificationResult.stderrPath}`));
|
|
276
|
+
taskState.lastVerificationError = verificationResult.errorOutput;
|
|
277
|
+
verificationPassed = false;
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
p.log.success(pc.green('Verification passed.'));
|
|
281
|
+
verificationPassed = true;
|
|
282
|
+
}
|
|
283
|
+
if (verificationPassed) {
|
|
284
|
+
try {
|
|
285
|
+
await stageAllChanges(taskWorktree);
|
|
286
|
+
const hasChanges = await hasStagedChanges(taskWorktree);
|
|
287
|
+
if (hasChanges) {
|
|
288
|
+
const commitMsg = formatCommitMessage(config.commitMessageTemplate || 'chore: complete task from plan', {
|
|
289
|
+
planName: parsedPlan.planId,
|
|
290
|
+
taskId: nextTask.id,
|
|
291
|
+
taskText: nextTask.originalText.replace(/^[-*]\s*\[.*?\]\s*/, '').trim(),
|
|
292
|
+
});
|
|
293
|
+
const commitHash = await createCommit(commitMsg, taskWorktree);
|
|
294
|
+
taskState.commitHash = commitHash;
|
|
295
|
+
p.log.success(pc.green(`Created commit ${commitHash}`));
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
p.log.info(pc.blue('No file changes to commit.'));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
p.log.warn(pc.yellow(`Failed to create commit: ${err instanceof Error ? err.message : String(err)}`));
|
|
303
|
+
}
|
|
304
|
+
taskState.lastStatus = 'DONE';
|
|
305
|
+
await savePlanState(state, config);
|
|
306
|
+
const donePlanContent = updateTaskStatus(updatedPlanContent, nextTask, 'DONE');
|
|
307
|
+
fs.writeFileSync(planPath, donePlanContent, 'utf8');
|
|
308
|
+
p.log.info(pc.blue(`Detailed logs are available at: ${taskLogDir}`));
|
|
309
|
+
p.log.success(pc.green('Marked task as DONE.'));
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
taskState.lastStatus = 'FAILED';
|
|
313
|
+
await savePlanState(state, config);
|
|
314
|
+
const failPlanContent = updateTaskStatus(updatedPlanContent, nextTask, 'FAILED');
|
|
315
|
+
fs.writeFileSync(planPath, failPlanContent, 'utf8');
|
|
316
|
+
p.log.info(pc.blue(`Detailed logs are available at: ${taskLogDir}`));
|
|
317
|
+
p.log.error(pc.red('Marked task as FAILED due to verification failure.'));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
p.log.error(pc.red(`Claude execution failed: ${outcome.error}`));
|
|
322
|
+
if (outcome.interrupted) {
|
|
323
|
+
// Leave task as IN_PROGRESS
|
|
324
|
+
taskState.lastStatus = 'IN_PROGRESS';
|
|
325
|
+
await savePlanState(state, config);
|
|
326
|
+
p.log.warn(pc.yellow(`Task interrupted. Worktree preserved at ${taskWorktree}.`));
|
|
327
|
+
p.log.info(`Resume command: claude-orchestrator run --plan ${planPath}`);
|
|
328
|
+
process.exit(130); // 130 is standard for SIGINT exit
|
|
329
|
+
}
|
|
330
|
+
else if (outcome.sessionLimitReached) {
|
|
331
|
+
taskState.lastStatus = 'BLOCKED';
|
|
332
|
+
taskState.limitResetTime = outcome.limitResetTime;
|
|
333
|
+
taskState.limitMessage = outcome.error;
|
|
334
|
+
await savePlanState(state, config);
|
|
335
|
+
p.log.warn(pc.yellow(`Session limit reached. Resets in ${outcome.limitResetTime || 'unknown'}.`));
|
|
336
|
+
p.log.info(`Resume command: claude-orchestrator run --plan ${planPath}`);
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
taskState.lastStatus = 'FAILED';
|
|
340
|
+
taskState.lastError = outcome.error;
|
|
341
|
+
await savePlanState(state, config);
|
|
342
|
+
const failPlanContent = updateTaskStatus(updatedPlanContent, nextTask, 'FAILED');
|
|
343
|
+
fs.writeFileSync(planPath, failPlanContent, 'utf8');
|
|
344
|
+
p.log.info(pc.blue(`Detailed logs are available at: ${taskLogDir}`));
|
|
345
|
+
p.log.error(pc.red('Marked task as FAILED.'));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
p.outro(pc.green('Execution engine iteration complete.'));
|
|
349
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as p from '@clack/prompts';
|
|
4
|
+
import pc from 'picocolors';
|
|
5
|
+
import { loadConfig } from '../config/loader.js';
|
|
6
|
+
import { discoverPlan } from '../plans/discovery.js';
|
|
7
|
+
import { parsePlan, determineNextTask, ValidationError } from '../plans/parser.js';
|
|
8
|
+
import { loadPlanState } from '../executor/state.js';
|
|
9
|
+
export async function runStatusCommand(options) {
|
|
10
|
+
p.intro(pc.bgBlue(pc.white(' Claude Orchestrator: Status ')));
|
|
11
|
+
const config = await loadConfig(options.config);
|
|
12
|
+
let planPath = options.plan;
|
|
13
|
+
if (!planPath) {
|
|
14
|
+
const defaultPlanDir = config.planDir || '.claude-orchestrator/plans';
|
|
15
|
+
planPath = (await discoverPlan({ planDir: defaultPlanDir })) || undefined;
|
|
16
|
+
if (!planPath) {
|
|
17
|
+
process.exit(0);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const planContent = fs.readFileSync(planPath, 'utf8');
|
|
21
|
+
const planId = path.basename(planPath, path.extname(planPath));
|
|
22
|
+
let parsedPlan;
|
|
23
|
+
try {
|
|
24
|
+
parsedPlan = parsePlan(planContent, planId);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (error instanceof ValidationError) {
|
|
28
|
+
p.log.error(pc.red(`Plan Validation Failed: ${error.message}`));
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
const state = await loadPlanState(parsedPlan.planId, config);
|
|
34
|
+
const retryCounts = {};
|
|
35
|
+
for (const [taskId, taskState] of Object.entries(state.tasks)) {
|
|
36
|
+
retryCounts[taskId] = Math.max(0, taskState.attempts - 1);
|
|
37
|
+
}
|
|
38
|
+
const nextTask = options.task
|
|
39
|
+
? parsedPlan.tasks.find((t) => t.id === options.task)
|
|
40
|
+
: determineNextTask(parsedPlan.tasks, config.maxRetries || 3, retryCounts);
|
|
41
|
+
const counts = { NOT_DONE: 0, IN_PROGRESS: 0, DONE: 0, FAILED: 0, BLOCKED: 0 };
|
|
42
|
+
for (const t of parsedPlan.tasks)
|
|
43
|
+
counts[t.status]++;
|
|
44
|
+
p.log.info(pc.blue('--- Plan Status ---'));
|
|
45
|
+
p.log.info(`Plan Path: ${planPath}`);
|
|
46
|
+
p.log.info(`Total Tasks: ${parsedPlan.tasks.length}`);
|
|
47
|
+
p.log.info(`Not Started: ${counts.NOT_DONE}`);
|
|
48
|
+
p.log.info(`In Progress: ${counts.IN_PROGRESS}`);
|
|
49
|
+
p.log.info(`Done: ${counts.DONE}`);
|
|
50
|
+
p.log.info(`Failed: ${counts.FAILED}`);
|
|
51
|
+
p.log.info(`Blocked: ${counts.BLOCKED}`);
|
|
52
|
+
if (nextTask) {
|
|
53
|
+
p.log.info(`Active Task: [${nextTask.status}] ${nextTask.originalText.trim()}`);
|
|
54
|
+
p.log.info(pc.cyan(`Resume with: claude-orchestrator run --plan ${planPath}`));
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
p.log.info('Active Task: none');
|
|
58
|
+
}
|
|
59
|
+
p.outro(pc.green('Status check complete.'));
|
|
60
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as p from '@clack/prompts';
|
|
4
|
+
import pc from 'picocolors';
|
|
5
|
+
import { loadConfig } from '../config/loader.js';
|
|
6
|
+
import { parsePlan, ValidationError } from '../plans/parser.js';
|
|
7
|
+
export async function runValidateCommand(options) {
|
|
8
|
+
p.intro(pc.bgBlue(pc.white(' Claude Orchestrator: Validate ')));
|
|
9
|
+
let config;
|
|
10
|
+
try {
|
|
11
|
+
config = await loadConfig(options.config);
|
|
12
|
+
p.log.success(pc.green('Config is valid.'));
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
p.log.error(pc.red(`Config invalid: ${error instanceof Error ? error.message : String(error)}`));
|
|
16
|
+
p.outro(pc.red('Validation failed.'));
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
let planPaths;
|
|
20
|
+
if (options.plan) {
|
|
21
|
+
planPaths = [options.plan];
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
const planDir = path.resolve(process.cwd(), config.planDir);
|
|
25
|
+
try {
|
|
26
|
+
const entries = await fs.readdir(planDir, { withFileTypes: true });
|
|
27
|
+
planPaths = entries
|
|
28
|
+
.filter((e) => e.isFile() && e.name.endsWith('.md'))
|
|
29
|
+
.map((e) => path.join(planDir, e.name));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
p.log.warn(pc.yellow(`No plan directory found at ${planDir}.`));
|
|
33
|
+
p.outro(pc.green('Config valid. No plans to check.'));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
let ok = true;
|
|
38
|
+
for (const planPath of planPaths) {
|
|
39
|
+
try {
|
|
40
|
+
const content = await fs.readFile(planPath, 'utf8');
|
|
41
|
+
const planId = path.basename(planPath, path.extname(planPath));
|
|
42
|
+
const parsed = parsePlan(content, planId);
|
|
43
|
+
p.log.success(pc.green(`${planPath}: valid (${parsed.tasks.length} tasks).`));
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
ok = false;
|
|
47
|
+
const message = error instanceof ValidationError ? error.message : error instanceof Error ? error.message : String(error);
|
|
48
|
+
p.log.error(pc.red(`${planPath}: ${message}`));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (ok) {
|
|
52
|
+
p.outro(pc.green('All plans valid.'));
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
p.outro(pc.red('One or more plans failed validation.'));
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const defaultConfig = {
|
|
2
|
+
version: '0.1.0',
|
|
3
|
+
planDir: 'workflow_generated_plans',
|
|
4
|
+
baseBranch: 'main',
|
|
5
|
+
branchPrefix: 'claude-',
|
|
6
|
+
models: {
|
|
7
|
+
planning: 'claude-3-5-sonnet-20241022',
|
|
8
|
+
},
|
|
9
|
+
claude: {
|
|
10
|
+
binary: 'claude',
|
|
11
|
+
},
|
|
12
|
+
taskTimeoutMs: 300000,
|
|
13
|
+
verificationCommands: [],
|
|
14
|
+
maxRetries: 3,
|
|
15
|
+
logsDir: '.claude-orchestrator/logs',
|
|
16
|
+
stateDir: '.claude-orchestrator/state',
|
|
17
|
+
worktreeDir: '.claude-orchestrator/worktrees',
|
|
18
|
+
commitMessageTemplate: 'chore: complete task from plan',
|
|
19
|
+
sessionLimits: {
|
|
20
|
+
showBeforeRun: true,
|
|
21
|
+
pauseOnLimit: true,
|
|
22
|
+
},
|
|
23
|
+
security: {
|
|
24
|
+
deniedCommands: ['rm', 'git reset', 'git clean', 'git push', 'git branch -D'],
|
|
25
|
+
protectedPaths: ['.env', '.env.local', 'secrets', 'credentials', '.git'],
|
|
26
|
+
allowNetwork: false,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { configSchema } from './schema.js';
|
|
4
|
+
import { defaultConfig } from './defaults.js';
|
|
5
|
+
import { ZodError } from 'zod';
|
|
6
|
+
function deepMerge(target, source) {
|
|
7
|
+
const output = { ...target };
|
|
8
|
+
for (const key of Object.keys(source)) {
|
|
9
|
+
if (source[key] instanceof Object && key in target && !Array.isArray(source[key])) {
|
|
10
|
+
output[key] = deepMerge(target[key], source[key]);
|
|
11
|
+
}
|
|
12
|
+
else {
|
|
13
|
+
output[key] = source[key];
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return output;
|
|
17
|
+
}
|
|
18
|
+
export async function loadConfig(configPath = '.claude-orchestrator.json') {
|
|
19
|
+
const resolvedPath = path.resolve(process.cwd(), configPath);
|
|
20
|
+
let userConfig = {};
|
|
21
|
+
try {
|
|
22
|
+
const fileContent = await fs.readFile(resolvedPath, 'utf8');
|
|
23
|
+
userConfig = JSON.parse(fileContent);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error.code !== 'ENOENT') {
|
|
27
|
+
throw new Error(`Failed to read config file at ${resolvedPath}: ${error.message}`);
|
|
28
|
+
}
|
|
29
|
+
// Return defaults if no user config exists
|
|
30
|
+
}
|
|
31
|
+
const mergedConfig = deepMerge(defaultConfig, userConfig);
|
|
32
|
+
try {
|
|
33
|
+
const validatedConfig = configSchema.parse(mergedConfig);
|
|
34
|
+
// Reject protected paths that escape process.cwd()
|
|
35
|
+
for (const protectedPath of validatedConfig.security.protectedPaths) {
|
|
36
|
+
if (protectedPath.startsWith('../') ||
|
|
37
|
+
protectedPath.startsWith('..\\') ||
|
|
38
|
+
path.isAbsolute(protectedPath)) {
|
|
39
|
+
throw new Error(`Protected path "${protectedPath}" escapes process.cwd(). Absolute paths or parent directory traversals are not allowed for security reasons.`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return validatedConfig;
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (error instanceof ZodError) {
|
|
46
|
+
const messages = error.issues
|
|
47
|
+
.map((err) => `${err.path.join('.')}: ${err.message}`)
|
|
48
|
+
.join(', ');
|
|
49
|
+
throw new Error(`Config validation failed: ${messages}`);
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|