create-claude-workspace 1.1.151 → 2.0.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/{scripts/lib → scheduler/ui}/tui.mjs +84 -41
  34. package/dist/{scripts/lib → scheduler/ui}/tui.spec.js +56 -0
  35. package/dist/scheduler/util/memory.mjs +126 -0
  36. package/dist/scheduler/util/memory.spec.js +165 -0
  37. package/dist/template/.claude/{profiles/angular.md → agents/angular-engineer.md} +9 -4
  38. package/dist/template/.claude/{profiles/react.md → agents/react-engineer.md} +9 -4
  39. package/dist/template/.claude/{profiles/svelte.md → agents/svelte-engineer.md} +9 -4
  40. package/dist/template/.claude/{profiles/vue.md → agents/vue-engineer.md} +9 -4
  41. package/package.json +3 -4
  42. package/dist/scripts/autonomous.mjs +0 -492
  43. package/dist/scripts/autonomous.spec.js +0 -46
  44. package/dist/scripts/docker-run.mjs +0 -462
  45. package/dist/scripts/integration.spec.js +0 -108
  46. package/dist/scripts/lib/formatter.mjs +0 -309
  47. package/dist/scripts/lib/formatter.spec.js +0 -262
  48. package/dist/scripts/lib/state.mjs +0 -44
  49. package/dist/scripts/lib/state.spec.js +0 -59
  50. package/dist/template/.claude/docker/.dockerignore +0 -8
  51. package/dist/template/.claude/docker/Dockerfile +0 -54
  52. package/dist/template/.claude/docker/docker-compose.yml +0 -22
  53. package/dist/template/.claude/docker/docker-entrypoint.sh +0 -101
  54. /package/dist/{scripts/lib/types.mjs → scheduler/shared-types.mjs} +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
@@ -0,0 +1,117 @@
1
+ // ─── Version bumping, CHANGELOG.md generation, git tagging ───
2
+ // Deterministic — reads tasks, writes markdown, calls git. No AI tokens.
3
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+ import { createTag, getLatestTag } from './manager.mjs';
6
+ export function parseSemVer(version) {
7
+ const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)$/);
8
+ if (!match)
9
+ return null;
10
+ return {
11
+ major: parseInt(match[1], 10),
12
+ minor: parseInt(match[2], 10),
13
+ patch: parseInt(match[3], 10),
14
+ };
15
+ }
16
+ export function formatSemVer(v) {
17
+ return `v${v.major}.${v.minor}.${v.patch}`;
18
+ }
19
+ export function bumpVersion(current, bump) {
20
+ switch (bump) {
21
+ case 'major': return { major: current.major + 1, minor: 0, patch: 0 };
22
+ case 'minor': return { major: current.major, minor: current.minor + 1, patch: 0 };
23
+ case 'patch': return { major: current.major, minor: current.minor, patch: current.patch + 1 };
24
+ }
25
+ }
26
+ /**
27
+ * Determine bump type from completed tasks.
28
+ */
29
+ export function determineBumpType(tasks) {
30
+ if (tasks.some(t => t.changelog === 'breaking'))
31
+ return 'major';
32
+ if (tasks.some(t => t.changelog === 'added' || t.changelog === 'changed'))
33
+ return 'minor';
34
+ return 'patch';
35
+ }
36
+ // ─── CHANGELOG generation ───
37
+ const CATEGORY_HEADINGS = {
38
+ breaking: 'Breaking Changes',
39
+ added: 'Added',
40
+ changed: 'Changed',
41
+ fixed: 'Fixed',
42
+ none: '',
43
+ };
44
+ const CATEGORY_ORDER = ['breaking', 'added', 'changed', 'fixed'];
45
+ export function generateChangelogEntry(info) {
46
+ const lines = [];
47
+ lines.push(`## [${info.version}] - ${info.date}`);
48
+ for (const category of CATEGORY_ORDER) {
49
+ const entries = info.entries.get(category);
50
+ if (!entries || entries.length === 0)
51
+ continue;
52
+ lines.push(`### ${CATEGORY_HEADINGS[category]}`);
53
+ for (const entry of entries) {
54
+ lines.push(`- ${entry}`);
55
+ }
56
+ lines.push('');
57
+ }
58
+ return lines.join('\n');
59
+ }
60
+ export function prependChangelog(projectDir, entry) {
61
+ const changelogPath = resolve(projectDir, 'CHANGELOG.md');
62
+ if (existsSync(changelogPath)) {
63
+ const existing = readFileSync(changelogPath, 'utf-8');
64
+ // Insert after the # Changelog heading
65
+ const headingEnd = existing.indexOf('\n');
66
+ if (headingEnd >= 0 && existing.startsWith('# ')) {
67
+ const before = existing.slice(0, headingEnd + 1);
68
+ const after = existing.slice(headingEnd + 1);
69
+ writeFileSync(changelogPath, `${before}\n${entry}\n${after}`, 'utf-8');
70
+ }
71
+ else {
72
+ writeFileSync(changelogPath, `# Changelog\n\n${entry}\n${existing}`, 'utf-8');
73
+ }
74
+ }
75
+ else {
76
+ writeFileSync(changelogPath, `# Changelog\n\n${entry}\n`, 'utf-8');
77
+ }
78
+ }
79
+ // ─── Release flow ───
80
+ /**
81
+ * Create a release: bump version, generate changelog, create git tag.
82
+ */
83
+ export function createRelease(projectDir, completedTasks, phase, bumpOverride) {
84
+ const changeableTasks = completedTasks.filter(t => t.changelog !== 'none');
85
+ if (changeableTasks.length === 0)
86
+ return null;
87
+ // Determine version
88
+ const latestTag = getLatestTag(projectDir);
89
+ const currentVersion = latestTag ? parseSemVer(latestTag) : { major: 0, minor: 0, patch: 0 };
90
+ if (!currentVersion)
91
+ return null;
92
+ const bump = bumpOverride ?? determineBumpType(completedTasks);
93
+ const newVersion = bumpVersion(currentVersion, bump);
94
+ const versionStr = formatSemVer(newVersion);
95
+ // Build changelog entries
96
+ const entries = new Map();
97
+ for (const task of changeableTasks) {
98
+ const list = entries.get(task.changelog) ?? [];
99
+ const marker = task.issueMarker ? ` (${task.issueMarker})` : '';
100
+ list.push(`${task.title}${marker}`);
101
+ entries.set(task.changelog, list);
102
+ }
103
+ const date = new Date().toISOString().split('T')[0];
104
+ const info = {
105
+ version: versionStr,
106
+ previousVersion: latestTag,
107
+ phase,
108
+ date,
109
+ entries,
110
+ };
111
+ // Write changelog
112
+ const entry = generateChangelogEntry(info);
113
+ prependChangelog(projectDir, entry);
114
+ // Create git tag
115
+ createTag(projectDir, versionStr, `Release ${versionStr} (Phase ${phase})`);
116
+ return info;
117
+ }
@@ -0,0 +1,175 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { execSync } from 'node:child_process';
3
+ import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
4
+ import { resolve, join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { parseSemVer, formatSemVer, bumpVersion, determineBumpType, generateChangelogEntry, prependChangelog, createRelease, } from './release.mjs';
7
+ // ─── Version parsing ───
8
+ describe('parseSemVer', () => {
9
+ it('parses version with v prefix', () => {
10
+ expect(parseSemVer('v1.2.3')).toEqual({ major: 1, minor: 2, patch: 3 });
11
+ });
12
+ it('parses version without v prefix', () => {
13
+ expect(parseSemVer('0.1.0')).toEqual({ major: 0, minor: 1, patch: 0 });
14
+ });
15
+ it('returns null for invalid version', () => {
16
+ expect(parseSemVer('not-a-version')).toBeNull();
17
+ expect(parseSemVer('1.2')).toBeNull();
18
+ });
19
+ });
20
+ describe('formatSemVer', () => {
21
+ it('formats version with v prefix', () => {
22
+ expect(formatSemVer({ major: 1, minor: 2, patch: 3 })).toBe('v1.2.3');
23
+ });
24
+ });
25
+ // ─── Version bumping ───
26
+ describe('bumpVersion', () => {
27
+ const v = { major: 1, minor: 2, patch: 3 };
28
+ it('bumps major: resets minor and patch', () => {
29
+ expect(bumpVersion(v, 'major')).toEqual({ major: 2, minor: 0, patch: 0 });
30
+ });
31
+ it('bumps minor: resets patch', () => {
32
+ expect(bumpVersion(v, 'minor')).toEqual({ major: 1, minor: 3, patch: 0 });
33
+ });
34
+ it('bumps patch', () => {
35
+ expect(bumpVersion(v, 'patch')).toEqual({ major: 1, minor: 2, patch: 4 });
36
+ });
37
+ });
38
+ // ─── determineBumpType ───
39
+ describe('determineBumpType', () => {
40
+ const makeTask = (changelog) => ({
41
+ id: 'p0-1', title: 'Test', phase: 0, type: 'backend', complexity: 'S',
42
+ status: 'done', dependsOn: [], issueMarker: null, kitUpgrade: false,
43
+ lineNumber: 1, changelog,
44
+ });
45
+ it('returns major when breaking changes exist', () => {
46
+ expect(determineBumpType([makeTask('added'), makeTask('breaking')])).toBe('major');
47
+ });
48
+ it('returns minor for added features', () => {
49
+ expect(determineBumpType([makeTask('added')])).toBe('minor');
50
+ });
51
+ it('returns minor for changed features', () => {
52
+ expect(determineBumpType([makeTask('changed')])).toBe('minor');
53
+ });
54
+ it('returns patch for fixes only', () => {
55
+ expect(determineBumpType([makeTask('fixed')])).toBe('patch');
56
+ });
57
+ it('returns patch for none-only tasks', () => {
58
+ expect(determineBumpType([makeTask('none')])).toBe('patch');
59
+ });
60
+ });
61
+ // ─── generateChangelogEntry ───
62
+ describe('generateChangelogEntry', () => {
63
+ it('generates formatted changelog entry', () => {
64
+ const entries = new Map();
65
+ entries.set('added', ['User registration (#5)', 'Login form (#6)']);
66
+ entries.set('fixed', ['Redirect loop (#8)']);
67
+ const info = {
68
+ version: 'v0.1.0',
69
+ previousVersion: null,
70
+ phase: 1,
71
+ date: '2026-03-24',
72
+ entries,
73
+ };
74
+ const entry = generateChangelogEntry(info);
75
+ expect(entry).toContain('## [v0.1.0] - 2026-03-24');
76
+ expect(entry).toContain('### Added');
77
+ expect(entry).toContain('- User registration (#5)');
78
+ expect(entry).toContain('- Login form (#6)');
79
+ expect(entry).toContain('### Fixed');
80
+ expect(entry).toContain('- Redirect loop (#8)');
81
+ expect(entry).not.toContain('### Breaking');
82
+ });
83
+ it('orders sections correctly: breaking, added, changed, fixed', () => {
84
+ const entries = new Map();
85
+ entries.set('fixed', ['Fix A']);
86
+ entries.set('breaking', ['Break B']);
87
+ entries.set('added', ['Add C']);
88
+ const info = {
89
+ version: 'v1.0.0', previousVersion: 'v0.9.0', phase: 2, date: '2026-03-24', entries,
90
+ };
91
+ const entry = generateChangelogEntry(info);
92
+ const breakIdx = entry.indexOf('### Breaking');
93
+ const addIdx = entry.indexOf('### Added');
94
+ const fixIdx = entry.indexOf('### Fixed');
95
+ expect(breakIdx).toBeLessThan(addIdx);
96
+ expect(addIdx).toBeLessThan(fixIdx);
97
+ });
98
+ });
99
+ // ─── prependChangelog ───
100
+ describe('prependChangelog', () => {
101
+ let testDir;
102
+ beforeEach(() => {
103
+ testDir = mkdtempSync(join(tmpdir(), 'release-test-'));
104
+ });
105
+ afterEach(() => {
106
+ rmSync(testDir, { recursive: true, force: true });
107
+ });
108
+ it('creates CHANGELOG.md if it does not exist', () => {
109
+ prependChangelog(testDir, '## [v0.1.0] - 2026-03-24\n### Added\n- Feature\n');
110
+ const content = readFileSync(resolve(testDir, 'CHANGELOG.md'), 'utf-8');
111
+ expect(content).toContain('# Changelog');
112
+ expect(content).toContain('## [v0.1.0]');
113
+ });
114
+ it('prepends to existing CHANGELOG.md', () => {
115
+ writeFileSync(resolve(testDir, 'CHANGELOG.md'), '# Changelog\n\n## [v0.1.0] - 2026-03-20\n### Added\n- First\n');
116
+ prependChangelog(testDir, '## [v0.2.0] - 2026-03-24\n### Added\n- Second\n');
117
+ const content = readFileSync(resolve(testDir, 'CHANGELOG.md'), 'utf-8');
118
+ const v02Idx = content.indexOf('[v0.2.0]');
119
+ const v01Idx = content.indexOf('[v0.1.0]');
120
+ expect(v02Idx).toBeLessThan(v01Idx);
121
+ });
122
+ });
123
+ // ─── createRelease (integration with git) ───
124
+ describe('createRelease', () => {
125
+ let repoDir;
126
+ beforeEach(() => {
127
+ repoDir = mkdtempSync(join(tmpdir(), 'release-git-test-'));
128
+ execSync('git init -b main', { cwd: repoDir, stdio: 'pipe' });
129
+ execSync('git config user.name "Test"', { cwd: repoDir, stdio: 'pipe' });
130
+ execSync('git config user.email "test@test.com"', { cwd: repoDir, stdio: 'pipe' });
131
+ writeFileSync(resolve(repoDir, 'file.txt'), 'init');
132
+ execSync('git add . && git commit -m "init"', { cwd: repoDir, stdio: 'pipe' });
133
+ });
134
+ afterEach(() => {
135
+ rmSync(repoDir, { recursive: true, force: true });
136
+ });
137
+ it('creates a release with changelog and tag', () => {
138
+ const tasks = [
139
+ { id: 'p1-1', title: 'User auth', phase: 1, type: 'backend', complexity: 'M', status: 'done', dependsOn: [], issueMarker: '#5', kitUpgrade: false, lineNumber: 1, changelog: 'added' },
140
+ { id: 'p1-2', title: 'Login form', phase: 1, type: 'frontend', complexity: 'S', status: 'done', dependsOn: [], issueMarker: '#6', kitUpgrade: false, lineNumber: 2, changelog: 'added' },
141
+ ];
142
+ // Need to commit changelog before tagging
143
+ const info = createRelease(repoDir, tasks, 1);
144
+ expect(info).not.toBeNull();
145
+ expect(info.version).toBe('v0.1.0'); // First release: 0.0.0 → 0.1.0
146
+ expect(existsSync(resolve(repoDir, 'CHANGELOG.md'))).toBe(true);
147
+ const changelog = readFileSync(resolve(repoDir, 'CHANGELOG.md'), 'utf-8');
148
+ expect(changelog).toContain('User auth (#5)');
149
+ expect(changelog).toContain('Login form (#6)');
150
+ });
151
+ it('returns null when no changeable tasks', () => {
152
+ const tasks = [
153
+ { id: 'p0-1', title: 'Chore', phase: 0, type: 'backend', complexity: 'S', status: 'done', dependsOn: [], issueMarker: null, kitUpgrade: false, lineNumber: 1, changelog: 'none' },
154
+ ];
155
+ expect(createRelease(repoDir, tasks, 0)).toBeNull();
156
+ });
157
+ it('bumps from existing tag', () => {
158
+ execSync('git tag -a v1.0.0 -m "v1"', { cwd: repoDir, stdio: 'pipe' });
159
+ writeFileSync(resolve(repoDir, 'new.txt'), 'change');
160
+ execSync('git add . && git commit -m "change"', { cwd: repoDir, stdio: 'pipe' });
161
+ const tasks = [
162
+ { id: 'p2-1', title: 'Fix bug', phase: 2, type: 'backend', complexity: 'S', status: 'done', dependsOn: [], issueMarker: null, kitUpgrade: false, lineNumber: 1, changelog: 'fixed' },
163
+ ];
164
+ const info = createRelease(repoDir, tasks, 2);
165
+ expect(info.version).toBe('v1.0.1'); // patch bump
166
+ expect(info.previousVersion).toBe('v1.0.0');
167
+ });
168
+ it('respects bump override', () => {
169
+ const tasks = [
170
+ { id: 'p1-1', title: 'Small fix', phase: 1, type: 'backend', complexity: 'S', status: 'done', dependsOn: [], issueMarker: null, kitUpgrade: false, lineNumber: 1, changelog: 'fixed' },
171
+ ];
172
+ const info = createRelease(repoDir, tasks, 1, 'major');
173
+ expect(info.version).toBe('v1.0.0'); // forced major
174
+ });
175
+ });
@@ -0,0 +1,309 @@
1
+ #!/usr/bin/env node
2
+ // ─── Multi-agent scheduler entry point ───
3
+ // Replaces the single-orchestrator autonomous loop with parallel agent execution.
4
+ import { resolve } from 'node:path';
5
+ import { existsSync, mkdirSync } from 'node:fs';
6
+ import { config as dotenvConfig } from '@dotenvx/dotenvx';
7
+ import { SCHEDULER_DEFAULTS } from './types.mjs';
8
+ import { emptyState, readState, writeState, appendEvent, createEvent } from './state/state.mjs';
9
+ import { WorkerPool } from './agents/worker-pool.mjs';
10
+ import { OrchestratorClient } from './agents/orchestrator.mjs';
11
+ import { runIteration } from './loop.mjs';
12
+ import { checkAuth } from './agents/health-checker.mjs';
13
+ import { pollForNewWork } from './util/idle-poll.mjs';
14
+ import { TUI } from './ui/tui.mjs';
15
+ // ─── Args ───
16
+ export function parseSchedulerArgs(argv) {
17
+ const opts = { ...SCHEDULER_DEFAULTS };
18
+ const mutable = opts;
19
+ const numFlag = (flag, i) => {
20
+ const val = argv[i + 1];
21
+ if (!val || val.startsWith('--')) {
22
+ console.error(`${flag} requires a value`);
23
+ process.exit(1);
24
+ }
25
+ const n = parseInt(val, 10);
26
+ if (isNaN(n) || n < 0) {
27
+ console.error(`${flag} must be a non-negative number`);
28
+ process.exit(1);
29
+ }
30
+ return n;
31
+ };
32
+ const strFlag = (flag, i) => {
33
+ const val = argv[i + 1];
34
+ if (!val || val.startsWith('--')) {
35
+ console.error(`${flag} requires a value`);
36
+ process.exit(1);
37
+ }
38
+ return val;
39
+ };
40
+ for (let i = 0; i < argv.length; i++) {
41
+ const arg = argv[i];
42
+ if (arg === '--help' || arg === '-h') {
43
+ printSchedulerHelp();
44
+ process.exit(0);
45
+ }
46
+ if (arg === '--skip-permissions') {
47
+ mutable.skipPermissions = true;
48
+ continue;
49
+ }
50
+ if (arg === '--no-lock') {
51
+ mutable.noLock = true;
52
+ continue;
53
+ }
54
+ if (arg === '--no-pull') {
55
+ mutable.noPull = true;
56
+ continue;
57
+ }
58
+ if (arg === '--dry-run') {
59
+ mutable.dryRun = true;
60
+ continue;
61
+ }
62
+ if (arg === '--resume') {
63
+ mutable.resume = true;
64
+ continue;
65
+ }
66
+ if (arg === '--interactive' || arg === '-i') {
67
+ mutable.interactive = true;
68
+ continue;
69
+ }
70
+ if (arg === '--concurrency') {
71
+ mutable.concurrency = numFlag(arg, i);
72
+ i++;
73
+ continue;
74
+ }
75
+ if (arg === '--max-iterations') {
76
+ mutable.maxIterations = numFlag(arg, i);
77
+ i++;
78
+ continue;
79
+ }
80
+ if (arg === '--max-turns') {
81
+ mutable.maxTurns = numFlag(arg, i);
82
+ i++;
83
+ continue;
84
+ }
85
+ if (arg === '--delay') {
86
+ mutable.delay = numFlag(arg, i);
87
+ i++;
88
+ continue;
89
+ }
90
+ if (arg === '--cooldown') {
91
+ mutable.cooldown = numFlag(arg, i);
92
+ i++;
93
+ continue;
94
+ }
95
+ if (arg === '--project-dir') {
96
+ mutable.projectDir = resolve(strFlag(arg, i));
97
+ i++;
98
+ continue;
99
+ }
100
+ if (arg === '--notify-command') {
101
+ mutable.notifyCommand = strFlag(arg, i);
102
+ i++;
103
+ continue;
104
+ }
105
+ if (arg === '--log-file') {
106
+ mutable.logFile = strFlag(arg, i);
107
+ i++;
108
+ continue;
109
+ }
110
+ if (arg === '--resume-session') {
111
+ mutable.resumeSession = strFlag(arg, i);
112
+ i++;
113
+ continue;
114
+ }
115
+ if (arg === '--idle-poll') {
116
+ mutable.idlePollInterval = numFlag(arg, i);
117
+ i++;
118
+ continue;
119
+ }
120
+ if (arg === '--max-idle') {
121
+ mutable.maxIdleTime = numFlag(arg, i);
122
+ i++;
123
+ continue;
124
+ }
125
+ if (arg.startsWith('--')) {
126
+ console.error(`Unknown option: ${arg}`);
127
+ process.exit(1);
128
+ }
129
+ }
130
+ return opts;
131
+ }
132
+ function printSchedulerHelp() {
133
+ console.log(`
134
+ Multi-agent scheduler for autonomous development.
135
+
136
+ Usage: npx create-claude-workspace scheduler [options]
137
+
138
+ Options:
139
+ --concurrency <n> Max parallel agents (default: 1)
140
+ --max-iterations <n> Max iterations (default: 50)
141
+ --max-turns <n> Max turns per agent invocation (default: 50)
142
+ --delay <ms> Pause between iterations (default: 5000)
143
+ --cooldown <ms> Wait after error (default: 60000)
144
+ --project-dir <path> Project directory (default: cwd)
145
+ --skip-permissions Bypass all permission checks
146
+ --resume Resume from saved state
147
+ --resume-session <id> Resume specific Claude session
148
+ --notify-command <cmd> Shell command on critical events
149
+ --log-file <path> Log file path
150
+ --no-lock Disable lock file
151
+ --no-pull Skip auto git pull
152
+ --idle-poll <ms> Poll interval when idle (default: 300000)
153
+ --max-idle <ms> Max idle time before exit (default: 0 = unlimited)
154
+ --interactive, -i Interactive TUI with input prompt
155
+ --dry-run Validate prerequisites only
156
+ --help Show this message
157
+ `);
158
+ }
159
+ // ─── Helpers ───
160
+ function sleep(ms, ref) {
161
+ return new Promise(r => {
162
+ const t = setTimeout(r, ms);
163
+ const check = setInterval(() => {
164
+ if (ref.value) {
165
+ clearTimeout(t);
166
+ clearInterval(check);
167
+ r();
168
+ }
169
+ }, 500);
170
+ });
171
+ }
172
+ function formatDuration(ms) {
173
+ if (ms < 1000)
174
+ return `${ms}ms`;
175
+ if (ms < 60_000)
176
+ return `${(ms / 1000).toFixed(0)}s`;
177
+ return `${(ms / 60_000).toFixed(1)}min`;
178
+ }
179
+ // ─── Main ───
180
+ export async function runScheduler(opts) {
181
+ dotenvConfig({ path: resolve(opts.projectDir, '.env'), override: false, quiet: true });
182
+ // Ensure scheduler directory exists
183
+ const schedulerDir = resolve(opts.projectDir, '.claude/scheduler');
184
+ if (!existsSync(schedulerDir))
185
+ mkdirSync(schedulerDir, { recursive: true });
186
+ const logPath = resolve(opts.projectDir, opts.logFile);
187
+ const tui = new TUI(logPath, opts.interactive);
188
+ tui.setTopAgent('scheduler');
189
+ const logger = {
190
+ info: (m) => tui.info(m),
191
+ warn: (m) => tui.warn(m),
192
+ error: (m) => tui.error(m),
193
+ debug: () => { },
194
+ };
195
+ tui.banner();
196
+ logger.info(`Project: ${opts.projectDir}`);
197
+ logger.info(`Concurrency: ${opts.concurrency} │ Max iterations: ${opts.maxIterations}`);
198
+ // Auth check
199
+ if (!checkAuth()) {
200
+ logger.error('Not authenticated. Set ANTHROPIC_API_KEY or run `claude login`');
201
+ process.exit(1);
202
+ }
203
+ logger.info('Authentication verified');
204
+ if (opts.dryRun) {
205
+ logger.info('Dry run complete. All checks passed.');
206
+ tui.destroy();
207
+ return;
208
+ }
209
+ // State
210
+ let state;
211
+ const existing = readState(opts.projectDir);
212
+ if (opts.resume && existing) {
213
+ state = existing;
214
+ logger.info(`Resuming from iteration ${state.iteration}`);
215
+ }
216
+ else {
217
+ state = emptyState(opts.concurrency);
218
+ }
219
+ // Worker pool
220
+ const pool = new WorkerPool({
221
+ concurrency: opts.concurrency,
222
+ maxTurns: opts.maxTurns,
223
+ skipPermissions: opts.skipPermissions,
224
+ logger,
225
+ });
226
+ // Orchestrator client
227
+ const orchestrator = new OrchestratorClient({
228
+ pool,
229
+ projectDir: opts.projectDir,
230
+ logger,
231
+ });
232
+ // Signal handling
233
+ let stopping = false;
234
+ const stoppingRef = { value: false };
235
+ const cleanup = () => {
236
+ stopping = true;
237
+ stoppingRef.value = true;
238
+ writeState(opts.projectDir, state);
239
+ appendEvent(opts.projectDir, createEvent('health_check', { detail: 'Interrupted — state saved' }));
240
+ logger.warn('Interrupted. State saved.');
241
+ pool.killAll().finally(() => {
242
+ tui.destroy();
243
+ process.exit(0);
244
+ });
245
+ };
246
+ process.on('SIGINT', cleanup);
247
+ process.on('SIGTERM', cleanup);
248
+ // ─── Loop ───
249
+ for (let i = state.iteration; i < opts.maxIterations && !stopping; i++) {
250
+ appendEvent(opts.projectDir, createEvent('health_check', { detail: `Iteration ${i + 1}` }));
251
+ try {
252
+ const workDone = await runIteration({
253
+ pool,
254
+ orchestrator,
255
+ state,
256
+ opts,
257
+ logger,
258
+ });
259
+ if (!workDone) {
260
+ // No work → idle polling
261
+ logger.info('No work available. Entering idle polling...');
262
+ const idleStart = Date.now();
263
+ while (!stopping) {
264
+ const poll = await pollForNewWork(opts.projectDir, logger);
265
+ if (poll.hasWork) {
266
+ logger.info(`New work detected (${poll.source}). Resuming...`);
267
+ break;
268
+ }
269
+ if (opts.maxIdleTime > 0 && Date.now() - idleStart >= opts.maxIdleTime) {
270
+ logger.info('Max idle time reached. Exiting.');
271
+ stopping = true;
272
+ break;
273
+ }
274
+ await sleep(opts.idlePollInterval, stoppingRef);
275
+ }
276
+ continue;
277
+ }
278
+ }
279
+ catch (err) {
280
+ logger.error(`Iteration error: ${err.message}`);
281
+ appendEvent(opts.projectDir, createEvent('error', { detail: err.message }));
282
+ logger.warn(`Cooling down ${formatDuration(opts.cooldown)}...`);
283
+ await sleep(opts.cooldown, stoppingRef);
284
+ }
285
+ writeState(opts.projectDir, state);
286
+ // Pause support
287
+ while (tui.isPaused() && !stopping) {
288
+ await sleep(1000, stoppingRef);
289
+ }
290
+ if (!stopping && i < opts.maxIterations - 1) {
291
+ logger.info(`Next iteration in ${formatDuration(opts.delay)}...`);
292
+ await sleep(opts.delay, stoppingRef);
293
+ }
294
+ }
295
+ // End
296
+ writeState(opts.projectDir, state);
297
+ logger.info(`Scheduler ended after ${state.iteration} iterations.`);
298
+ logger.info(`Completed: ${state.completedTasks.length} │ Skipped: ${state.skippedTasks.length}`);
299
+ tui.destroy();
300
+ }
301
+ // ─── CLI entry ───
302
+ const isDirectRun = process.argv[1]?.replace(/\\/g, '/').endsWith('scheduler.mjs');
303
+ if (isDirectRun) {
304
+ const opts = parseSchedulerArgs(process.argv.slice(2));
305
+ runScheduler(opts).catch(err => {
306
+ console.error('Fatal:', err);
307
+ process.exit(1);
308
+ });
309
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseSchedulerArgs } from './index.mjs';
3
+ import { SCHEDULER_DEFAULTS } from './types.mjs';
4
+ describe('parseSchedulerArgs', () => {
5
+ it('returns defaults for empty args', () => {
6
+ const opts = parseSchedulerArgs([]);
7
+ expect(opts.concurrency).toBe(SCHEDULER_DEFAULTS.concurrency);
8
+ expect(opts.maxIterations).toBe(SCHEDULER_DEFAULTS.maxIterations);
9
+ expect(opts.maxTurns).toBe(SCHEDULER_DEFAULTS.maxTurns);
10
+ expect(opts.skipPermissions).toBe(false);
11
+ expect(opts.resume).toBe(false);
12
+ });
13
+ it('parses --concurrency', () => {
14
+ const opts = parseSchedulerArgs(['--concurrency', '3']);
15
+ expect(opts.concurrency).toBe(3);
16
+ });
17
+ it('parses --max-iterations', () => {
18
+ const opts = parseSchedulerArgs(['--max-iterations', '100']);
19
+ expect(opts.maxIterations).toBe(100);
20
+ });
21
+ it('parses --max-turns', () => {
22
+ const opts = parseSchedulerArgs(['--max-turns', '25']);
23
+ expect(opts.maxTurns).toBe(25);
24
+ });
25
+ it('parses boolean flags', () => {
26
+ const opts = parseSchedulerArgs(['--skip-permissions', '--no-lock', '--no-pull', '--dry-run', '--resume', '-i']);
27
+ expect(opts.skipPermissions).toBe(true);
28
+ expect(opts.noLock).toBe(true);
29
+ expect(opts.noPull).toBe(true);
30
+ expect(opts.dryRun).toBe(true);
31
+ expect(opts.resume).toBe(true);
32
+ expect(opts.interactive).toBe(true);
33
+ });
34
+ it('parses --delay and --cooldown', () => {
35
+ const opts = parseSchedulerArgs(['--delay', '10000', '--cooldown', '30000']);
36
+ expect(opts.delay).toBe(10000);
37
+ expect(opts.cooldown).toBe(30000);
38
+ });
39
+ it('parses --project-dir', () => {
40
+ const opts = parseSchedulerArgs(['--project-dir', '/tmp/project']);
41
+ expect(opts.projectDir).toContain('project');
42
+ });
43
+ it('parses --notify-command', () => {
44
+ const opts = parseSchedulerArgs(['--notify-command', 'echo done']);
45
+ expect(opts.notifyCommand).toBe('echo done');
46
+ });
47
+ it('parses --log-file', () => {
48
+ const opts = parseSchedulerArgs(['--log-file', '/tmp/log.txt']);
49
+ expect(opts.logFile).toBe('/tmp/log.txt');
50
+ });
51
+ it('parses --idle-poll and --max-idle', () => {
52
+ const opts = parseSchedulerArgs(['--idle-poll', '60000', '--max-idle', '300000']);
53
+ expect(opts.idlePollInterval).toBe(60000);
54
+ expect(opts.maxIdleTime).toBe(300000);
55
+ });
56
+ it('parses --resume-session', () => {
57
+ const opts = parseSchedulerArgs(['--resume-session', 'abc-123']);
58
+ expect(opts.resumeSession).toBe('abc-123');
59
+ });
60
+ it('handles multiple flags together', () => {
61
+ const opts = parseSchedulerArgs([
62
+ '--concurrency', '2',
63
+ '--max-iterations', '10',
64
+ '--skip-permissions',
65
+ '--no-pull',
66
+ ]);
67
+ expect(opts.concurrency).toBe(2);
68
+ expect(opts.maxIterations).toBe(10);
69
+ expect(opts.skipPermissions).toBe(true);
70
+ expect(opts.noPull).toBe(true);
71
+ });
72
+ });