create-claude-workspace 1.1.152 → 2.1.0
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/README.md +33 -1
- package/dist/index.js +29 -56
- package/dist/scheduler/agents/health-checker.mjs +98 -0
- package/dist/scheduler/agents/health-checker.spec.js +143 -0
- package/dist/scheduler/agents/orchestrator.mjs +149 -0
- package/dist/scheduler/agents/orchestrator.spec.js +87 -0
- package/dist/scheduler/agents/prompt-builder.mjs +204 -0
- package/dist/scheduler/agents/prompt-builder.spec.js +240 -0
- package/dist/scheduler/agents/worker-pool.mjs +137 -0
- package/dist/scheduler/agents/worker-pool.spec.js +45 -0
- package/dist/scheduler/git/ci-watcher.mjs +93 -0
- package/dist/scheduler/git/ci-watcher.spec.js +35 -0
- package/dist/scheduler/git/manager.mjs +228 -0
- package/dist/scheduler/git/manager.spec.js +198 -0
- package/dist/scheduler/git/release.mjs +117 -0
- package/dist/scheduler/git/release.spec.js +175 -0
- package/dist/scheduler/index.mjs +309 -0
- package/dist/scheduler/index.spec.js +72 -0
- package/dist/scheduler/integration.spec.js +289 -0
- package/dist/scheduler/loop.mjs +435 -0
- package/dist/scheduler/loop.spec.js +139 -0
- package/dist/scheduler/state/session.mjs +14 -0
- package/dist/scheduler/state/session.spec.js +36 -0
- package/dist/scheduler/state/state.mjs +102 -0
- package/dist/scheduler/state/state.spec.js +175 -0
- package/dist/scheduler/tasks/inbox.mjs +98 -0
- package/dist/scheduler/tasks/inbox.spec.js +168 -0
- package/dist/scheduler/tasks/parser.mjs +228 -0
- package/dist/scheduler/tasks/parser.spec.js +303 -0
- package/dist/scheduler/tasks/queue.mjs +152 -0
- package/dist/scheduler/tasks/queue.spec.js +223 -0
- package/dist/scheduler/types.mjs +20 -0
- package/dist/scheduler/util/memory.mjs +126 -0
- package/dist/scheduler/util/memory.spec.js +165 -0
- package/dist/template/.claude/{profiles/angular.md → agents/angular-engineer.md} +9 -4
- package/dist/template/.claude/{profiles/react.md → agents/react-engineer.md} +9 -4
- package/dist/template/.claude/{profiles/svelte.md → agents/svelte-engineer.md} +9 -4
- package/dist/template/.claude/{profiles/vue.md → agents/vue-engineer.md} +9 -4
- package/package.json +3 -4
- package/dist/scripts/autonomous.mjs +0 -493
- package/dist/scripts/autonomous.spec.js +0 -46
- package/dist/scripts/docker-run.mjs +0 -462
- package/dist/scripts/integration.spec.js +0 -108
- package/dist/scripts/lib/formatter.mjs +0 -309
- package/dist/scripts/lib/formatter.spec.js +0 -262
- package/dist/scripts/lib/state.mjs +0 -44
- package/dist/scripts/lib/state.spec.js +0 -59
- package/dist/template/.claude/docker/.dockerignore +0 -8
- package/dist/template/.claude/docker/Dockerfile +0 -54
- package/dist/template/.claude/docker/docker-compose.yml +0 -22
- package/dist/template/.claude/docker/docker-entrypoint.sh +0 -101
- /package/dist/{scripts/lib/types.mjs → scheduler/shared-types.mjs} +0 -0
- /package/dist/{scripts/lib → scheduler/ui}/tui.mjs +0 -0
- /package/dist/{scripts/lib → scheduler/ui}/tui.spec.js +0 -0
- /package/dist/{scripts/lib → scheduler/util}/idle-poll.mjs +0 -0
- /package/dist/{scripts/lib → scheduler/util}/idle-poll.spec.js +0 -0
package/README.md
CHANGED
|
@@ -29,10 +29,42 @@ Answer the discovery questions (non-technical). Claude generates the full pipeli
|
|
|
29
29
|
|
|
30
30
|
Pick one:
|
|
31
31
|
|
|
32
|
-
- **
|
|
32
|
+
- **Multi-agent scheduler (v2)**: `npx create-claude-workspace scheduler` — parallel agents, short contexts, TS-driven workflow
|
|
33
|
+
- **Multi-agent parallel**: `npx create-claude-workspace scheduler --concurrency 3` — up to N agents working simultaneously
|
|
34
|
+
- **Classic loop (v1)**: `npx create-claude-workspace run` — single orchestrator, sequential tasks
|
|
33
35
|
- **Docker** (fully isolated): `npx create-claude-workspace docker` — container sandbox, safe `--skip-permissions`
|
|
34
36
|
- **Interactive**: `claude --agent orchestrator`, then `/ralph-loop:ralph-loop Continue autonomous development according to CLAUDE.md`
|
|
35
37
|
|
|
38
|
+
### Scheduler v2 (Multi-Agent)
|
|
39
|
+
|
|
40
|
+
The scheduler replaces the single-orchestrator model with parallel agent execution:
|
|
41
|
+
|
|
42
|
+
- **TS scheduler** handles workflow (task picking, git, CI, merges) — zero AI tokens on routing
|
|
43
|
+
- **Claude agents** get short, focused prompts (~500 tokens vs ~20k+ in v1)
|
|
44
|
+
- **Each task = fresh context** — no accumulating context across tasks
|
|
45
|
+
- **N agents in parallel** — independent tasks run concurrently in separate git worktrees
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npx create-claude-workspace scheduler # start (1 agent)
|
|
49
|
+
npx create-claude-workspace scheduler --concurrency 3 # 3 parallel agents
|
|
50
|
+
npx create-claude-workspace scheduler --resume # resume from saved state
|
|
51
|
+
npx create-claude-workspace scheduler --help # all options
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
#### Communicate at runtime
|
|
55
|
+
|
|
56
|
+
Write to `.claude/scheduler/inbox.json` while the scheduler is running:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
[
|
|
60
|
+
{ "type": "add-task", "title": "Add dark mode toggle", "taskType": "frontend", "complexity": "S" },
|
|
61
|
+
{ "type": "message", "text": "Focus on performance optimization" },
|
|
62
|
+
{ "type": "stop" }
|
|
63
|
+
]
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The scheduler reads the inbox every iteration and processes messages immediately.
|
|
67
|
+
|
|
36
68
|
### npx Options
|
|
37
69
|
|
|
38
70
|
```bash
|
package/dist/index.js
CHANGED
|
@@ -25,7 +25,6 @@ function parseArgs() {
|
|
|
25
25
|
targetDir: process.cwd(),
|
|
26
26
|
update: false,
|
|
27
27
|
run: false,
|
|
28
|
-
docker: false,
|
|
29
28
|
help: false,
|
|
30
29
|
};
|
|
31
30
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -40,9 +39,6 @@ function parseArgs() {
|
|
|
40
39
|
case '--run':
|
|
41
40
|
opts.run = true;
|
|
42
41
|
break;
|
|
43
|
-
case '--docker':
|
|
44
|
-
opts.docker = true;
|
|
45
|
-
break;
|
|
46
42
|
default:
|
|
47
43
|
if (!args[i].startsWith('-')) {
|
|
48
44
|
opts.targetDir = resolve(args[i]);
|
|
@@ -57,44 +53,39 @@ function parseArgs() {
|
|
|
57
53
|
}
|
|
58
54
|
function printHelp() {
|
|
59
55
|
console.log(`
|
|
60
|
-
${C.b}create-claude-workspace${C.n} —
|
|
56
|
+
${C.b}create-claude-workspace${C.n} — Autonomous AI-driven development with Claude Code
|
|
61
57
|
|
|
62
58
|
${C.b}Usage:${C.n}
|
|
63
59
|
npx create-claude-workspace [directory] [options] # scaffold
|
|
64
|
-
npx create-claude-workspace run [options] #
|
|
65
|
-
npx create-claude-workspace docker [options] # Docker runner
|
|
60
|
+
npx create-claude-workspace run [options] # multi-agent scheduler
|
|
66
61
|
npx create-claude-workspace validate # check prerequisites
|
|
67
62
|
|
|
68
63
|
${C.b}Scaffold options:${C.n}
|
|
69
64
|
directory Target directory (default: current directory)
|
|
70
65
|
--update Overwrite existing agent files with latest version
|
|
71
|
-
--run Start
|
|
72
|
-
--docker Use Docker for autonomous run (implies --run)
|
|
66
|
+
--run Start scheduler after scaffolding
|
|
73
67
|
-h, --help Show this help
|
|
74
68
|
|
|
75
|
-
${C.b}
|
|
69
|
+
${C.b}Scheduler options:${C.n}
|
|
70
|
+
--concurrency <n> Parallel agents (default: 1)
|
|
76
71
|
--max-iterations <n> Max iterations (default: 50)
|
|
77
|
-
--max-turns <n> Max turns per invocation (default: 50)
|
|
78
|
-
--
|
|
79
|
-
--activity-timeout <ms> Max silence before kill (default: 300000)
|
|
80
|
-
--notify-command <cmd> Shell command on critical events
|
|
81
|
-
--resume Resume from checkpoint
|
|
72
|
+
--max-turns <n> Max turns per agent invocation (default: 50)
|
|
73
|
+
--resume Resume from saved state
|
|
82
74
|
--dry-run Validate prerequisites only
|
|
83
75
|
See 'npx create-claude-workspace run --help' for all options.
|
|
84
76
|
|
|
85
77
|
${C.b}Examples:${C.n}
|
|
86
|
-
npx create-claude-workspace
|
|
87
|
-
npx create-claude-workspace run
|
|
88
|
-
npx create-claude-workspace run --
|
|
89
|
-
npx create-claude-workspace
|
|
90
|
-
npx create-claude-workspace validate
|
|
78
|
+
npx create-claude-workspace # scaffold in current directory
|
|
79
|
+
npx create-claude-workspace run # start scheduler
|
|
80
|
+
npx create-claude-workspace run --concurrency 3 # 3 parallel agents
|
|
81
|
+
npx create-claude-workspace run --resume # resume from state
|
|
82
|
+
npx create-claude-workspace validate # check prerequisites
|
|
91
83
|
|
|
92
84
|
${C.b}What it creates:${C.n}
|
|
93
|
-
.claude/agents/
|
|
85
|
+
.claude/agents/ Specialist agents (orchestrator, architects, etc.)
|
|
94
86
|
.claude/templates/ CLAUDE.md template for project initialization
|
|
95
87
|
.claude/profiles/ Frontend framework profiles (Angular, React, Vue, Svelte)
|
|
96
88
|
.claude/CLAUDE.md Agent routing instructions
|
|
97
|
-
.claude/docker/ Docker config (Dockerfile, compose, entrypoint)
|
|
98
89
|
.claude/.kit-version Kit version (for upgrade detection)
|
|
99
90
|
`);
|
|
100
91
|
}
|
|
@@ -144,7 +135,7 @@ function writeKitVersion(targetDir) {
|
|
|
144
135
|
}
|
|
145
136
|
function ensureGitignore(targetDir) {
|
|
146
137
|
const gitignorePath = join(targetDir, '.gitignore');
|
|
147
|
-
const entries = ['.
|
|
138
|
+
const entries = ['.claude/scheduler/', '.claude/.kit-version', '.worktrees/'];
|
|
148
139
|
if (existsSync(gitignorePath)) {
|
|
149
140
|
let content = readFileSync(gitignorePath, 'utf-8');
|
|
150
141
|
const added = [];
|
|
@@ -163,21 +154,11 @@ function ensureGitignore(targetDir) {
|
|
|
163
154
|
}
|
|
164
155
|
}
|
|
165
156
|
// ─── Run ───
|
|
166
|
-
function
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
: resolve(scriptsDir, 'autonomous.mjs');
|
|
172
|
-
const args = docker ? [script, ...extraArgs] : [script, '--skip-permissions', ...extraArgs];
|
|
173
|
-
if (docker) {
|
|
174
|
-
info('Starting Docker-based autonomous development...');
|
|
175
|
-
}
|
|
176
|
-
else {
|
|
177
|
-
info('Starting autonomous development...');
|
|
178
|
-
info('(Use Ctrl+C to stop)');
|
|
179
|
-
}
|
|
180
|
-
const child = spawn('node', args, {
|
|
157
|
+
function runScheduler(targetDir, extraArgs = []) {
|
|
158
|
+
const script = resolve(__dirname, 'scheduler', 'index.mjs');
|
|
159
|
+
info('Starting multi-agent scheduler...');
|
|
160
|
+
info('(Use Ctrl+C to stop)');
|
|
161
|
+
const child = spawn('node', [script, '--project-dir', targetDir, ...extraArgs], {
|
|
181
162
|
cwd: targetDir,
|
|
182
163
|
stdio: 'inherit',
|
|
183
164
|
});
|
|
@@ -185,20 +166,15 @@ function runAutonomous(targetDir, docker, extraArgs = []) {
|
|
|
185
166
|
}
|
|
186
167
|
// ─── Main ───
|
|
187
168
|
async function main() {
|
|
188
|
-
// Subcommand routing
|
|
169
|
+
// Subcommand routing
|
|
189
170
|
const firstArg = process.argv[2];
|
|
190
|
-
if (firstArg === 'run') {
|
|
191
|
-
const extraArgs = process.argv.slice(3);
|
|
192
|
-
runAutonomous(process.cwd(), false, extraArgs);
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
if (firstArg === 'docker') {
|
|
171
|
+
if (firstArg === 'run' || firstArg === 'scheduler') {
|
|
196
172
|
const extraArgs = process.argv.slice(3);
|
|
197
|
-
|
|
173
|
+
runScheduler(process.cwd(), extraArgs);
|
|
198
174
|
return;
|
|
199
175
|
}
|
|
200
176
|
if (firstArg === 'validate') {
|
|
201
|
-
|
|
177
|
+
runScheduler(process.cwd(), ['--dry-run']);
|
|
202
178
|
return;
|
|
203
179
|
}
|
|
204
180
|
const opts = parseArgs();
|
|
@@ -206,8 +182,6 @@ async function main() {
|
|
|
206
182
|
printHelp();
|
|
207
183
|
process.exit(0);
|
|
208
184
|
}
|
|
209
|
-
if (opts.docker)
|
|
210
|
-
opts.run = true;
|
|
211
185
|
console.log(`\n${C.c} create-claude-workspace${C.n}\n`);
|
|
212
186
|
const targetDir = opts.targetDir;
|
|
213
187
|
const hasExisting = existsSync(join(targetDir, '.claude'));
|
|
@@ -220,7 +194,7 @@ async function main() {
|
|
|
220
194
|
else {
|
|
221
195
|
info('Skipping file copy. Use --update to force.');
|
|
222
196
|
if (opts.run) {
|
|
223
|
-
|
|
197
|
+
runScheduler(targetDir);
|
|
224
198
|
return;
|
|
225
199
|
}
|
|
226
200
|
process.exit(0);
|
|
@@ -256,7 +230,6 @@ async function main() {
|
|
|
256
230
|
info(`Copied ${copied} files`);
|
|
257
231
|
if (skipped > 0)
|
|
258
232
|
info(`Skipped ${skipped} existing files`);
|
|
259
|
-
// Write kit version for orchestrator to detect upgrades
|
|
260
233
|
writeKitVersion(targetDir);
|
|
261
234
|
ensureGitignore(targetDir);
|
|
262
235
|
console.log('');
|
|
@@ -268,15 +241,15 @@ async function main() {
|
|
|
268
241
|
console.log(` ${C.d}# Interactive setup (recommended first time)${C.n}`);
|
|
269
242
|
console.log(` claude --agent project-initializer`);
|
|
270
243
|
console.log('');
|
|
271
|
-
console.log(` ${C.d}#
|
|
272
|
-
console.log(` npx create-claude-workspace docker`);
|
|
273
|
-
console.log('');
|
|
274
|
-
console.log(` ${C.d}# Autonomous development without Docker${C.n}`);
|
|
244
|
+
console.log(` ${C.d}# Multi-agent scheduler${C.n}`);
|
|
275
245
|
console.log(` npx create-claude-workspace run`);
|
|
276
246
|
console.log('');
|
|
247
|
+
console.log(` ${C.d}# With parallel agents${C.n}`);
|
|
248
|
+
console.log(` npx create-claude-workspace run --concurrency 3`);
|
|
249
|
+
console.log('');
|
|
277
250
|
}
|
|
278
251
|
if (opts.run) {
|
|
279
|
-
|
|
252
|
+
runScheduler(targetDir);
|
|
280
253
|
}
|
|
281
254
|
}
|
|
282
255
|
main().catch((err) => {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// ─── Startup validation: auth, files, agents, worktrees, git identity ───
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { listOrphanedWorktrees } from '../git/manager.mjs';
|
|
5
|
+
import { hasGitIdentity } from '../git/manager.mjs';
|
|
6
|
+
// ─── Auth ───
|
|
7
|
+
export function checkAuth() {
|
|
8
|
+
if (process.env.ANTHROPIC_API_KEY)
|
|
9
|
+
return true;
|
|
10
|
+
try {
|
|
11
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
12
|
+
const creds = resolve(home, '.claude', '.credentials.json');
|
|
13
|
+
if (existsSync(creds)) {
|
|
14
|
+
const data = JSON.parse(readFileSync(creds, 'utf-8'));
|
|
15
|
+
if (data.claudeAiOauth?.accessToken)
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
catch { /* ignore */ }
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
// ─── Required files ───
|
|
23
|
+
const REQUIRED_FILES = [
|
|
24
|
+
{ path: 'CLAUDE.md', critical: false },
|
|
25
|
+
{ path: 'PRODUCT.md', critical: false },
|
|
26
|
+
{ path: '.claude/scheduler/tasks.json', critical: false },
|
|
27
|
+
];
|
|
28
|
+
export function checkRequiredFiles(projectDir) {
|
|
29
|
+
return REQUIRED_FILES
|
|
30
|
+
.filter(f => !existsSync(resolve(projectDir, f.path)))
|
|
31
|
+
.map(f => ({ path: f.path, critical: f.critical }));
|
|
32
|
+
}
|
|
33
|
+
// ─── Agent scanning ───
|
|
34
|
+
export function scanAgents(projectDir) {
|
|
35
|
+
const agentsDir = resolve(projectDir, '.claude', 'agents');
|
|
36
|
+
if (!existsSync(agentsDir))
|
|
37
|
+
return [];
|
|
38
|
+
const agents = [];
|
|
39
|
+
for (const file of readdirSync(agentsDir)) {
|
|
40
|
+
if (!file.endsWith('.md'))
|
|
41
|
+
continue;
|
|
42
|
+
const content = readFileSync(resolve(agentsDir, file), 'utf-8');
|
|
43
|
+
const agent = parseAgentFile(file, content);
|
|
44
|
+
if (agent)
|
|
45
|
+
agents.push(agent);
|
|
46
|
+
}
|
|
47
|
+
return agents;
|
|
48
|
+
}
|
|
49
|
+
export function parseAgentFile(filename, content) {
|
|
50
|
+
const name = filename.replace('.md', '');
|
|
51
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
52
|
+
if (!fmMatch)
|
|
53
|
+
return null;
|
|
54
|
+
const fm = fmMatch[1];
|
|
55
|
+
const description = extractYamlValue(fm, 'description') ?? name;
|
|
56
|
+
const model = extractYamlValue(fm, 'model') ?? 'sonnet';
|
|
57
|
+
const stepsRaw = extractYamlValue(fm, 'steps');
|
|
58
|
+
const steps = stepsRaw
|
|
59
|
+
? stepsRaw.split(',').map(s => s.trim()).filter(s => s.length > 0)
|
|
60
|
+
: inferSteps(name);
|
|
61
|
+
const prompt = content.slice(fmMatch[0].length).trim();
|
|
62
|
+
return { name, description, model, steps, prompt };
|
|
63
|
+
}
|
|
64
|
+
function extractYamlValue(yaml, key) {
|
|
65
|
+
const match = yaml.match(new RegExp(`^${key}:\\s*["']?(.*?)["']?\\s*$`, 'm'));
|
|
66
|
+
return match ? match[1].trim() : null;
|
|
67
|
+
}
|
|
68
|
+
function inferSteps(name) {
|
|
69
|
+
if (name.includes('reviewer') || name.includes('review'))
|
|
70
|
+
return ['review'];
|
|
71
|
+
if (name.includes('test'))
|
|
72
|
+
return ['test'];
|
|
73
|
+
return ['plan', 'implement', 'rework'];
|
|
74
|
+
}
|
|
75
|
+
// ─── Kit version ───
|
|
76
|
+
export function checkKitVersion(projectDir) {
|
|
77
|
+
const versionFile = resolve(projectDir, '.claude', '.kit-version');
|
|
78
|
+
if (!existsSync(versionFile))
|
|
79
|
+
return null;
|
|
80
|
+
try {
|
|
81
|
+
const current = readFileSync(versionFile, 'utf-8').trim();
|
|
82
|
+
return { current, latest: null };
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// ─── Full health check ───
|
|
89
|
+
export function runHealthCheck(projectDir, knownWorktrees) {
|
|
90
|
+
return {
|
|
91
|
+
auth: checkAuth(),
|
|
92
|
+
requiredFiles: checkRequiredFiles(projectDir),
|
|
93
|
+
agents: scanAgents(projectDir).map(a => a.name),
|
|
94
|
+
orphanedWorktrees: listOrphanedWorktrees(projectDir, knownWorktrees),
|
|
95
|
+
gitIdentity: hasGitIdentity(projectDir),
|
|
96
|
+
kitVersion: checkKitVersion(projectDir),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { resolve, join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { execSync } from 'node:child_process';
|
|
6
|
+
import { checkRequiredFiles, scanAgents, parseAgentFile, checkKitVersion, runHealthCheck, } from './health-checker.mjs';
|
|
7
|
+
let testDir;
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
testDir = mkdtempSync(join(tmpdir(), 'health-checker-test-'));
|
|
10
|
+
mkdirSync(resolve(testDir, '.claude/agents'), { recursive: true });
|
|
11
|
+
mkdirSync(resolve(testDir, '.claude/scheduler'), { recursive: true });
|
|
12
|
+
});
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
// ─── checkRequiredFiles ───
|
|
17
|
+
describe('checkRequiredFiles', () => {
|
|
18
|
+
it('reports all missing files', () => {
|
|
19
|
+
const missing = checkRequiredFiles(testDir);
|
|
20
|
+
expect(missing.length).toBeGreaterThan(0);
|
|
21
|
+
expect(missing.some(f => f.path === 'CLAUDE.md')).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
it('does not report existing files', () => {
|
|
24
|
+
writeFileSync(resolve(testDir, 'CLAUDE.md'), '# Project');
|
|
25
|
+
writeFileSync(resolve(testDir, 'PRODUCT.md'), '# Product');
|
|
26
|
+
writeFileSync(resolve(testDir, '.claude/scheduler/tasks.json'), '{}');
|
|
27
|
+
const missing = checkRequiredFiles(testDir);
|
|
28
|
+
expect(missing).toEqual([]);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
// ─── parseAgentFile ───
|
|
32
|
+
describe('parseAgentFile', () => {
|
|
33
|
+
it('parses agent with full frontmatter', () => {
|
|
34
|
+
const content = `---
|
|
35
|
+
name: backend-ts-architect
|
|
36
|
+
description: Backend TypeScript specialist
|
|
37
|
+
model: opus
|
|
38
|
+
steps: plan, implement, rework
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
You are a backend architect...`;
|
|
42
|
+
const agent = parseAgentFile('backend-ts-architect.md', content);
|
|
43
|
+
expect(agent).not.toBeNull();
|
|
44
|
+
expect(agent.name).toBe('backend-ts-architect');
|
|
45
|
+
expect(agent.description).toBe('Backend TypeScript specialist');
|
|
46
|
+
expect(agent.model).toBe('opus');
|
|
47
|
+
expect(agent.steps).toEqual(['plan', 'implement', 'rework']);
|
|
48
|
+
expect(agent.prompt).toBe('You are a backend architect...');
|
|
49
|
+
});
|
|
50
|
+
it('infers steps for reviewer agents', () => {
|
|
51
|
+
const content = `---
|
|
52
|
+
name: senior-code-reviewer
|
|
53
|
+
description: Code reviewer
|
|
54
|
+
model: opus
|
|
55
|
+
---
|
|
56
|
+
Review code.`;
|
|
57
|
+
const agent = parseAgentFile('senior-code-reviewer.md', content);
|
|
58
|
+
expect(agent.steps).toEqual(['review']);
|
|
59
|
+
});
|
|
60
|
+
it('infers steps for test agents', () => {
|
|
61
|
+
const content = `---
|
|
62
|
+
name: test-engineer
|
|
63
|
+
description: Test specialist
|
|
64
|
+
model: sonnet
|
|
65
|
+
---
|
|
66
|
+
Write tests.`;
|
|
67
|
+
const agent = parseAgentFile('test-engineer.md', content);
|
|
68
|
+
expect(agent.steps).toEqual(['test']);
|
|
69
|
+
});
|
|
70
|
+
it('defaults to plan/implement/rework for unknown agents', () => {
|
|
71
|
+
const content = `---
|
|
72
|
+
name: custom-agent
|
|
73
|
+
description: Custom specialist
|
|
74
|
+
---
|
|
75
|
+
Do things.`;
|
|
76
|
+
const agent = parseAgentFile('custom-agent.md', content);
|
|
77
|
+
expect(agent.steps).toEqual(['plan', 'implement', 'rework']);
|
|
78
|
+
expect(agent.model).toBe('sonnet');
|
|
79
|
+
});
|
|
80
|
+
it('returns null for files without frontmatter', () => {
|
|
81
|
+
expect(parseAgentFile('no-frontmatter.md', '# Just a heading')).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
// ─── scanAgents ───
|
|
85
|
+
describe('scanAgents', () => {
|
|
86
|
+
it('scans all agent files in directory', () => {
|
|
87
|
+
writeFileSync(resolve(testDir, '.claude/agents/backend.md'), `---
|
|
88
|
+
name: backend
|
|
89
|
+
description: Backend specialist
|
|
90
|
+
model: opus
|
|
91
|
+
---
|
|
92
|
+
Backend agent.`);
|
|
93
|
+
writeFileSync(resolve(testDir, '.claude/agents/frontend.md'), `---
|
|
94
|
+
name: frontend
|
|
95
|
+
description: Frontend specialist
|
|
96
|
+
model: opus
|
|
97
|
+
---
|
|
98
|
+
Frontend agent.`);
|
|
99
|
+
writeFileSync(resolve(testDir, '.claude/agents/readme.txt'), 'Not an agent');
|
|
100
|
+
const agents = scanAgents(testDir);
|
|
101
|
+
expect(agents).toHaveLength(2);
|
|
102
|
+
expect(agents.map(a => a.name)).toContain('backend');
|
|
103
|
+
expect(agents.map(a => a.name)).toContain('frontend');
|
|
104
|
+
});
|
|
105
|
+
it('returns empty array when no agents directory', () => {
|
|
106
|
+
rmSync(resolve(testDir, '.claude/agents'), { recursive: true });
|
|
107
|
+
expect(scanAgents(testDir)).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
// ─── checkKitVersion ───
|
|
111
|
+
describe('checkKitVersion', () => {
|
|
112
|
+
it('reads kit version from .kit-version file', () => {
|
|
113
|
+
writeFileSync(resolve(testDir, '.claude/.kit-version'), '1.5.0');
|
|
114
|
+
const result = checkKitVersion(testDir);
|
|
115
|
+
expect(result).not.toBeNull();
|
|
116
|
+
expect(result.current).toBe('1.5.0');
|
|
117
|
+
expect(result.latest).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
it('returns null when no .kit-version file', () => {
|
|
120
|
+
expect(checkKitVersion(testDir)).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
// ─── runHealthCheck ───
|
|
124
|
+
describe('runHealthCheck', () => {
|
|
125
|
+
it('returns complete health check result', () => {
|
|
126
|
+
// Set up a git repo
|
|
127
|
+
execSync('git init -b main', { cwd: testDir, stdio: 'pipe' });
|
|
128
|
+
execSync('git config user.name "Test"', { cwd: testDir, stdio: 'pipe' });
|
|
129
|
+
execSync('git config user.email "test@test.com"', { cwd: testDir, stdio: 'pipe' });
|
|
130
|
+
writeFileSync(resolve(testDir, 'file.txt'), 'init');
|
|
131
|
+
execSync('git add . && git commit -m "init"', { cwd: testDir, stdio: 'pipe' });
|
|
132
|
+
writeFileSync(resolve(testDir, '.claude/agents/test-agent.md'), `---
|
|
133
|
+
name: test-agent
|
|
134
|
+
description: Test agent
|
|
135
|
+
---
|
|
136
|
+
Agent.`);
|
|
137
|
+
const result = runHealthCheck(testDir, []);
|
|
138
|
+
expect(result.gitIdentity).toBe(true);
|
|
139
|
+
expect(result.agents).toContain('test-agent');
|
|
140
|
+
expect(result.requiredFiles.length).toBeGreaterThan(0);
|
|
141
|
+
expect(result.orphanedWorktrees).toEqual([]);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// ─── Spawn orchestrator AI only for decisions scheduler can't make ───
|
|
2
|
+
// Short, decision-focused prompts. Returns structured JSON decisions.
|
|
3
|
+
import { buildRoutingPrompt } from './prompt-builder.mjs';
|
|
4
|
+
const ORCHESTRATOR_MODEL = 'claude-sonnet-4-6';
|
|
5
|
+
export class OrchestratorClient {
|
|
6
|
+
pool;
|
|
7
|
+
projectDir;
|
|
8
|
+
logger;
|
|
9
|
+
constructor(opts) {
|
|
10
|
+
this.pool = opts.pool;
|
|
11
|
+
this.projectDir = opts.projectDir;
|
|
12
|
+
this.logger = opts.logger;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Ask orchestrator to route a task to the best agent.
|
|
16
|
+
*/
|
|
17
|
+
async routeTask(task, step, agents) {
|
|
18
|
+
const prompt = buildRoutingPrompt(task, step, agents);
|
|
19
|
+
const result = await this.consult(prompt);
|
|
20
|
+
return parseRoutingDecision(result.output);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Ask orchestrator what to do after a failure.
|
|
24
|
+
*/
|
|
25
|
+
async handleFailure(taskTitle, step, error, attempts) {
|
|
26
|
+
const prompt = [
|
|
27
|
+
`An agent failed. Decide what to do next.`,
|
|
28
|
+
``,
|
|
29
|
+
`## Context`,
|
|
30
|
+
`- Task: ${taskTitle}`,
|
|
31
|
+
`- Pipeline step: ${step}`,
|
|
32
|
+
`- Error: ${error}`,
|
|
33
|
+
`- Attempts so far: ${attempts}`,
|
|
34
|
+
``,
|
|
35
|
+
`## Options`,
|
|
36
|
+
`1. "retry" — try again (maybe with different approach)`,
|
|
37
|
+
`2. "skip" — skip this task, log as blocked`,
|
|
38
|
+
`3. "escalate" — needs human intervention`,
|
|
39
|
+
``,
|
|
40
|
+
`## Required Output`,
|
|
41
|
+
`Respond with JSON only:`,
|
|
42
|
+
'```json',
|
|
43
|
+
`{ "action": "retry" | "skip" | "escalate", "reason": "why", "guidance": "optional instructions for retry" }`,
|
|
44
|
+
'```',
|
|
45
|
+
].join('\n');
|
|
46
|
+
const result = await this.consult(prompt);
|
|
47
|
+
return parseFailureDecision(result.output);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Ask orchestrator to resolve a merge conflict.
|
|
51
|
+
*/
|
|
52
|
+
async handleMergeConflict(taskTitle, conflictFiles) {
|
|
53
|
+
const prompt = [
|
|
54
|
+
`A merge conflict occurred. Decide the resolution strategy.`,
|
|
55
|
+
``,
|
|
56
|
+
`## Task: ${taskTitle}`,
|
|
57
|
+
`## Conflicting files`,
|
|
58
|
+
...conflictFiles.map(f => `- ${f}`),
|
|
59
|
+
``,
|
|
60
|
+
`## Options`,
|
|
61
|
+
`1. "rebase" — rebase the feature branch on main`,
|
|
62
|
+
`2. "resolve" — spawn an agent to resolve conflicts manually`,
|
|
63
|
+
`3. "skip" — skip this task`,
|
|
64
|
+
``,
|
|
65
|
+
`## Required Output`,
|
|
66
|
+
`Respond with JSON only:`,
|
|
67
|
+
'```json',
|
|
68
|
+
`{ "action": "rebase" | "resolve" | "skip", "reason": "why" }`,
|
|
69
|
+
'```',
|
|
70
|
+
].join('\n');
|
|
71
|
+
const result = await this.consult(prompt);
|
|
72
|
+
return parseMergeConflictDecision(result.output);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Low-level: send a prompt to the orchestrator and get raw result.
|
|
76
|
+
*/
|
|
77
|
+
async consult(prompt) {
|
|
78
|
+
const slot = this.pool.idleSlot();
|
|
79
|
+
if (!slot) {
|
|
80
|
+
throw new Error('No idle worker available for orchestrator consultation');
|
|
81
|
+
}
|
|
82
|
+
const spawnOpts = {
|
|
83
|
+
cwd: this.projectDir,
|
|
84
|
+
prompt,
|
|
85
|
+
model: ORCHESTRATOR_MODEL,
|
|
86
|
+
};
|
|
87
|
+
this.logger.info('Consulting orchestrator AI...');
|
|
88
|
+
return this.pool.spawn(slot.id, spawnOpts);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// ─── JSON parsing helpers ───
|
|
92
|
+
function extractJson(output) {
|
|
93
|
+
// Try to find JSON in code blocks
|
|
94
|
+
const codeBlock = output.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
|
|
95
|
+
if (codeBlock)
|
|
96
|
+
return codeBlock[1].trim();
|
|
97
|
+
// Try to find raw JSON object
|
|
98
|
+
const jsonMatch = output.match(/\{[\s\S]*\}/);
|
|
99
|
+
return jsonMatch ? jsonMatch[0] : null;
|
|
100
|
+
}
|
|
101
|
+
function parseRoutingDecision(output) {
|
|
102
|
+
const json = extractJson(output);
|
|
103
|
+
if (!json)
|
|
104
|
+
return { agent: null, reason: 'Failed to parse orchestrator response' };
|
|
105
|
+
try {
|
|
106
|
+
const parsed = JSON.parse(json);
|
|
107
|
+
return {
|
|
108
|
+
agent: parsed.agent ?? null,
|
|
109
|
+
reason: parsed.reason ?? 'No reason provided',
|
|
110
|
+
create: parsed.create ?? undefined,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return { agent: null, reason: 'Invalid JSON from orchestrator' };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function parseFailureDecision(output) {
|
|
118
|
+
const json = extractJson(output);
|
|
119
|
+
if (!json)
|
|
120
|
+
return { action: 'skip', reason: 'Failed to parse orchestrator response' };
|
|
121
|
+
try {
|
|
122
|
+
const parsed = JSON.parse(json);
|
|
123
|
+
return {
|
|
124
|
+
action: parsed.action ?? 'skip',
|
|
125
|
+
reason: parsed.reason ?? 'No reason provided',
|
|
126
|
+
guidance: parsed.guidance,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return { action: 'skip', reason: 'Invalid JSON from orchestrator' };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function parseMergeConflictDecision(output) {
|
|
134
|
+
const json = extractJson(output);
|
|
135
|
+
if (!json)
|
|
136
|
+
return { action: 'skip', reason: 'Failed to parse orchestrator response' };
|
|
137
|
+
try {
|
|
138
|
+
const parsed = JSON.parse(json);
|
|
139
|
+
return {
|
|
140
|
+
action: parsed.action ?? 'skip',
|
|
141
|
+
reason: parsed.reason ?? 'No reason provided',
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return { action: 'skip', reason: 'Invalid JSON from orchestrator' };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Export for testing
|
|
149
|
+
export { extractJson, parseRoutingDecision, parseFailureDecision, parseMergeConflictDecision };
|