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.
- 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/{scripts/lib → scheduler/ui}/tui.mjs +84 -41
- package/dist/{scripts/lib → scheduler/ui}/tui.spec.js +56 -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 -492
- 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/util}/idle-poll.mjs +0 -0
- /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
|
+
});
|