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.
Files changed (56) hide show
  1. package/README.md +33 -1
  2. package/dist/index.js +29 -56
  3. package/dist/scheduler/agents/health-checker.mjs +98 -0
  4. package/dist/scheduler/agents/health-checker.spec.js +143 -0
  5. package/dist/scheduler/agents/orchestrator.mjs +149 -0
  6. package/dist/scheduler/agents/orchestrator.spec.js +87 -0
  7. package/dist/scheduler/agents/prompt-builder.mjs +204 -0
  8. package/dist/scheduler/agents/prompt-builder.spec.js +240 -0
  9. package/dist/scheduler/agents/worker-pool.mjs +137 -0
  10. package/dist/scheduler/agents/worker-pool.spec.js +45 -0
  11. package/dist/scheduler/git/ci-watcher.mjs +93 -0
  12. package/dist/scheduler/git/ci-watcher.spec.js +35 -0
  13. package/dist/scheduler/git/manager.mjs +228 -0
  14. package/dist/scheduler/git/manager.spec.js +198 -0
  15. package/dist/scheduler/git/release.mjs +117 -0
  16. package/dist/scheduler/git/release.spec.js +175 -0
  17. package/dist/scheduler/index.mjs +309 -0
  18. package/dist/scheduler/index.spec.js +72 -0
  19. package/dist/scheduler/integration.spec.js +289 -0
  20. package/dist/scheduler/loop.mjs +435 -0
  21. package/dist/scheduler/loop.spec.js +139 -0
  22. package/dist/scheduler/state/session.mjs +14 -0
  23. package/dist/scheduler/state/session.spec.js +36 -0
  24. package/dist/scheduler/state/state.mjs +102 -0
  25. package/dist/scheduler/state/state.spec.js +175 -0
  26. package/dist/scheduler/tasks/inbox.mjs +98 -0
  27. package/dist/scheduler/tasks/inbox.spec.js +168 -0
  28. package/dist/scheduler/tasks/parser.mjs +228 -0
  29. package/dist/scheduler/tasks/parser.spec.js +303 -0
  30. package/dist/scheduler/tasks/queue.mjs +152 -0
  31. package/dist/scheduler/tasks/queue.spec.js +223 -0
  32. package/dist/scheduler/types.mjs +20 -0
  33. package/dist/scheduler/util/memory.mjs +126 -0
  34. package/dist/scheduler/util/memory.spec.js +165 -0
  35. package/dist/template/.claude/{profiles/angular.md → agents/angular-engineer.md} +9 -4
  36. package/dist/template/.claude/{profiles/react.md → agents/react-engineer.md} +9 -4
  37. package/dist/template/.claude/{profiles/svelte.md → agents/svelte-engineer.md} +9 -4
  38. package/dist/template/.claude/{profiles/vue.md → agents/vue-engineer.md} +9 -4
  39. package/package.json +3 -4
  40. package/dist/scripts/autonomous.mjs +0 -493
  41. package/dist/scripts/autonomous.spec.js +0 -46
  42. package/dist/scripts/docker-run.mjs +0 -462
  43. package/dist/scripts/integration.spec.js +0 -108
  44. package/dist/scripts/lib/formatter.mjs +0 -309
  45. package/dist/scripts/lib/formatter.spec.js +0 -262
  46. package/dist/scripts/lib/state.mjs +0 -44
  47. package/dist/scripts/lib/state.spec.js +0 -59
  48. package/dist/template/.claude/docker/.dockerignore +0 -8
  49. package/dist/template/.claude/docker/Dockerfile +0 -54
  50. package/dist/template/.claude/docker/docker-compose.yml +0 -22
  51. package/dist/template/.claude/docker/docker-entrypoint.sh +0 -101
  52. /package/dist/{scripts/lib/types.mjs → scheduler/shared-types.mjs} +0 -0
  53. /package/dist/{scripts/lib → scheduler/ui}/tui.mjs +0 -0
  54. /package/dist/{scripts/lib → scheduler/ui}/tui.spec.js +0 -0
  55. /package/dist/{scripts/lib → scheduler/util}/idle-poll.mjs +0 -0
  56. /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
- - **Unattended** (recommended): `npx create-claude-workspace run` — clean context per task, survives rate limits
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} — Scaffold autonomous AI-driven development with Claude Code
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] # autonomous loop
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 autonomous development after scaffolding
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}Run options:${C.n}
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
- --process-timeout <ms> Max time per invocation (default: 1800000)
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 # scaffold in current directory
87
- npx create-claude-workspace run # start autonomous loop
88
- npx create-claude-workspace run --resume # resume from checkpoint
89
- npx create-claude-workspace docker # run in Docker
90
- npx create-claude-workspace validate # check prerequisites
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/ 10 specialist agents (orchestrator, architects, etc.)
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 = ['.npmrc', '.docker-compose.auth.yml', '.claude/autonomous.log', '.claude/autonomous.log.1', '.claude/autonomous.lock', '.claude/autonomous-state.json', '.claude/.kit-version', '.worktrees/'];
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 runAutonomous(targetDir, docker, extraArgs = []) {
167
- // Run scripts from the package itself, not from .claude/scripts/ in target dir
168
- const scriptsDir = resolve(__dirname, 'scripts');
169
- const script = docker
170
- ? resolve(scriptsDir, 'docker-run.mjs')
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: run, docker, validate, plan
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
- runAutonomous(process.cwd(), true, extraArgs);
173
+ runScheduler(process.cwd(), extraArgs);
198
174
  return;
199
175
  }
200
176
  if (firstArg === 'validate') {
201
- runAutonomous(process.cwd(), false, ['--dry-run']);
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
- runAutonomous(targetDir, opts.docker);
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}# Autonomous development in Docker${C.n}`);
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
- runAutonomous(targetDir, opts.docker);
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 };