tlc-claude-code 2.0.1 → 2.2.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/.claude/agents/builder.md +144 -0
- package/.claude/agents/planner.md +143 -0
- package/.claude/agents/reviewer.md +160 -0
- package/.claude/commands/tlc/build.md +4 -0
- package/.claude/commands/tlc/deploy.md +194 -2
- package/.claude/commands/tlc/e2e-verify.md +214 -0
- package/.claude/commands/tlc/guard.md +191 -0
- package/.claude/commands/tlc/help.md +32 -0
- package/.claude/commands/tlc/init.md +73 -37
- package/.claude/commands/tlc/llm.md +19 -4
- package/.claude/commands/tlc/preflight.md +134 -0
- package/.claude/commands/tlc/review-plan.md +363 -0
- package/.claude/commands/tlc/review.md +172 -57
- package/.claude/commands/tlc/watchci.md +159 -0
- package/.claude/hooks/tlc-block-tools.sh +41 -0
- package/.claude/hooks/tlc-capture-exchange.sh +50 -0
- package/.claude/hooks/tlc-post-build.sh +38 -0
- package/.claude/hooks/tlc-post-push.sh +22 -0
- package/.claude/hooks/tlc-prompt-guard.sh +69 -0
- package/.claude/hooks/tlc-session-init.sh +123 -0
- package/CLAUDE.md +13 -0
- package/bin/install.js +268 -2
- package/bin/postinstall.js +102 -24
- package/bin/setup-autoupdate.js +206 -0
- package/bin/setup-autoupdate.test.js +124 -0
- package/bin/tlc.js +0 -0
- package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
- package/dashboard-web/dist/index.html +2 -2
- package/docker-compose.dev.yml +18 -12
- package/package.json +4 -2
- package/scripts/project-docs.js +1 -1
- package/server/index.js +228 -2
- package/server/lib/capture-bridge.js +242 -0
- package/server/lib/capture-bridge.test.js +363 -0
- package/server/lib/capture-guard.js +140 -0
- package/server/lib/capture-guard.test.js +182 -0
- package/server/lib/command-runner.js +159 -0
- package/server/lib/command-runner.test.js +92 -0
- package/server/lib/cost-tracker.test.js +49 -12
- package/server/lib/deploy/runners/dependency-runner.js +106 -0
- package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
- package/server/lib/deploy/runners/secrets-runner.js +174 -0
- package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
- package/server/lib/deploy/security-gates.js +11 -24
- package/server/lib/deploy/security-gates.test.js +9 -2
- package/server/lib/deploy-engine.js +182 -0
- package/server/lib/deploy-engine.test.js +147 -0
- package/server/lib/docker-api.js +137 -0
- package/server/lib/docker-api.test.js +202 -0
- package/server/lib/docker-client.js +297 -0
- package/server/lib/docker-client.test.js +308 -0
- package/server/lib/input-sanitizer.js +86 -0
- package/server/lib/input-sanitizer.test.js +117 -0
- package/server/lib/launchd-agent.js +225 -0
- package/server/lib/launchd-agent.test.js +185 -0
- package/server/lib/memory-api.js +3 -1
- package/server/lib/memory-api.test.js +3 -5
- package/server/lib/memory-bridge-e2e.test.js +160 -0
- package/server/lib/memory-committer.js +18 -4
- package/server/lib/memory-committer.test.js +21 -0
- package/server/lib/memory-hooks-capture.test.js +69 -4
- package/server/lib/memory-hooks-integration.test.js +98 -0
- package/server/lib/memory-hooks.js +42 -4
- package/server/lib/memory-store-adapter.js +105 -0
- package/server/lib/memory-store-adapter.test.js +141 -0
- package/server/lib/memory-wiring-e2e.test.js +93 -0
- package/server/lib/nginx-config.js +114 -0
- package/server/lib/nginx-config.test.js +82 -0
- package/server/lib/ollama-health.js +91 -0
- package/server/lib/ollama-health.test.js +74 -0
- package/server/lib/orchestration/agent-dispatcher.js +114 -0
- package/server/lib/orchestration/agent-dispatcher.test.js +110 -0
- package/server/lib/orchestration/orchestrator.js +130 -0
- package/server/lib/orchestration/orchestrator.test.js +192 -0
- package/server/lib/orchestration/tmux-manager.js +101 -0
- package/server/lib/orchestration/tmux-manager.test.js +109 -0
- package/server/lib/orchestration/worktree-manager.js +132 -0
- package/server/lib/orchestration/worktree-manager.test.js +129 -0
- package/server/lib/port-guard.js +44 -0
- package/server/lib/port-guard.test.js +65 -0
- package/server/lib/project-scanner.js +37 -2
- package/server/lib/project-scanner.test.js +152 -0
- package/server/lib/remember-command.js +2 -0
- package/server/lib/remember-command.test.js +23 -0
- package/server/lib/review/plan-reviewer.js +260 -0
- package/server/lib/review/plan-reviewer.test.js +269 -0
- package/server/lib/review/review-schemas.js +173 -0
- package/server/lib/review/review-schemas.test.js +152 -0
- package/server/lib/security/crypto-utils.test.js +2 -2
- package/server/lib/semantic-recall.js +1 -1
- package/server/lib/semantic-recall.test.js +17 -0
- package/server/lib/ssh-client.js +184 -0
- package/server/lib/ssh-client.test.js +127 -0
- package/server/lib/vps-api.js +184 -0
- package/server/lib/vps-api.test.js +208 -0
- package/server/lib/vps-bootstrap.js +124 -0
- package/server/lib/vps-bootstrap.test.js +79 -0
- package/server/lib/vps-monitor.js +126 -0
- package/server/lib/vps-monitor.test.js +98 -0
- package/server/lib/workspace-api.js +182 -1
- package/server/lib/workspace-api.test.js +474 -0
- package/server/package-lock.json +737 -0
- package/server/package.json +3 -0
- package/server/setup.sh +271 -271
- package/dashboard-web/dist/assets/index-Uhc49PE-.css +0 -1
- package/dashboard-web/dist/assets/index-W36XHPC5.js +0 -431
- package/dashboard-web/dist/assets/index-W36XHPC5.js.map +0 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Dispatcher
|
|
3
|
+
* Routes tasks to Claude or Codex with correct CLI flags per provider.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const SUPPORTED_PROVIDERS = new Set(['claude', 'codex']);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Build a TDD-enforcing prompt from a task object.
|
|
10
|
+
* @param {object} task - Task with goal, files, criteria, testCases.
|
|
11
|
+
* @returns {string} Prompt string.
|
|
12
|
+
*/
|
|
13
|
+
export function buildPrompt(task) {
|
|
14
|
+
const { goal, files = [], criteria = [], testCases = [] } = task;
|
|
15
|
+
|
|
16
|
+
const fileList = files.map((f) => ` - ${f}`).join('\n');
|
|
17
|
+
const criteriaList = criteria.map((c) => ` - ${c}`).join('\n');
|
|
18
|
+
const testCaseList = testCases.map((t) => ` - ${t}`).join('\n');
|
|
19
|
+
|
|
20
|
+
return [
|
|
21
|
+
`Goal: ${goal}`,
|
|
22
|
+
'',
|
|
23
|
+
'Files to work with:',
|
|
24
|
+
fileList,
|
|
25
|
+
'',
|
|
26
|
+
'Acceptance criteria:',
|
|
27
|
+
criteriaList,
|
|
28
|
+
'',
|
|
29
|
+
'Test cases:',
|
|
30
|
+
testCaseList,
|
|
31
|
+
'',
|
|
32
|
+
'Methodology: Write tests first (red → green → refactor). You MUST write the test before implementing any code. Test-first is required.',
|
|
33
|
+
].join('\n');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Returns sandbox CLI arguments for Codex based on the current platform.
|
|
38
|
+
* @param {object} task - Task object (reserved for future use).
|
|
39
|
+
* @param {string} platform - OS platform string (e.g. 'darwin', 'linux', 'win32').
|
|
40
|
+
* @returns {string} Sandbox flag string or empty string.
|
|
41
|
+
*/
|
|
42
|
+
export function buildSandboxArgs(_task, platform) {
|
|
43
|
+
if (platform === 'darwin' || platform === 'linux') {
|
|
44
|
+
return '--sandbox workspace-write';
|
|
45
|
+
}
|
|
46
|
+
return '';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Dispatch a task to the specified AI provider, returning the shell command string.
|
|
51
|
+
* @param {object} task - Task object.
|
|
52
|
+
* @param {string} worktreePath - Absolute path to the git worktree.
|
|
53
|
+
* @param {string} provider - Provider name: 'claude' or 'codex'.
|
|
54
|
+
* @returns {string} Shell command string.
|
|
55
|
+
* @throws {Error} If the provider is not supported or not available.
|
|
56
|
+
*/
|
|
57
|
+
export function dispatch(task, worktreePath, provider) {
|
|
58
|
+
if (!SUPPORTED_PROVIDERS.has(provider)) {
|
|
59
|
+
throw new Error(`Provider "${provider}" is not supported or unavailable`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const prompt = buildPrompt(task);
|
|
63
|
+
// Escape shell-sensitive characters to prevent injection from task content
|
|
64
|
+
const safePrompt = prompt
|
|
65
|
+
.replace(/\\/g, '\\\\')
|
|
66
|
+
.replace(/"/g, '\\"')
|
|
67
|
+
.replace(/\$/g, '\\$')
|
|
68
|
+
.replace(/`/g, '\\`')
|
|
69
|
+
.replace(/!/g, '\\!');
|
|
70
|
+
|
|
71
|
+
if (provider === 'claude') {
|
|
72
|
+
return `claude --agent builder --worktree "${worktreePath}" -p "${safePrompt}" --permission-mode auto`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (provider === 'codex') {
|
|
76
|
+
const sandbox = buildSandboxArgs(task, process.platform);
|
|
77
|
+
const sandboxPart = sandbox ? ` ${sandbox}` : '';
|
|
78
|
+
return `codex exec --full-auto -C "${worktreePath}"${sandboxPart} "${safePrompt}"`;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Create a round-robin scheduler over a list of providers.
|
|
84
|
+
* @param {string[]} providers - Ordered list of provider names.
|
|
85
|
+
* @param {object} [routerState] - Optional router state with availability info.
|
|
86
|
+
* @returns {{ next: () => string }} Object with a `.next()` method.
|
|
87
|
+
*/
|
|
88
|
+
export function createRoundRobin(providers, routerState) {
|
|
89
|
+
// Filter to only available providers when routerState is given
|
|
90
|
+
const available = routerState
|
|
91
|
+
? providers.filter((p) => {
|
|
92
|
+
const info = routerState.providers?.[p];
|
|
93
|
+
return info ? info.available : true;
|
|
94
|
+
})
|
|
95
|
+
: providers;
|
|
96
|
+
|
|
97
|
+
// If routerState was provided and filtered everything out, respect that — don't fall back
|
|
98
|
+
const pool = routerState ? available : providers;
|
|
99
|
+
if (pool.length === 0) {
|
|
100
|
+
// No providers available — return a robin that always throws
|
|
101
|
+
return {
|
|
102
|
+
next() { throw new Error('No available providers in router state'); },
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let index = 0;
|
|
107
|
+
return {
|
|
108
|
+
next() {
|
|
109
|
+
const provider = pool[index % pool.length];
|
|
110
|
+
index += 1;
|
|
111
|
+
return provider;
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
dispatch,
|
|
4
|
+
buildPrompt,
|
|
5
|
+
buildSandboxArgs,
|
|
6
|
+
createRoundRobin,
|
|
7
|
+
} from './agent-dispatcher.js';
|
|
8
|
+
|
|
9
|
+
const SAMPLE_TASK = {
|
|
10
|
+
title: 'Create user schema',
|
|
11
|
+
goal: 'Define database schema for users table',
|
|
12
|
+
files: ['src/modules/user/user.repository.js', 'src/modules/user/user.repository.test.js'],
|
|
13
|
+
criteria: ['Schema has id, email, passwordHash', 'Email is unique'],
|
|
14
|
+
testCases: ['Schema validates correct user data', 'Schema rejects duplicate emails'],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const ROUTER_STATE = {
|
|
18
|
+
providers: {
|
|
19
|
+
claude: { available: true, path: '/usr/bin/claude' },
|
|
20
|
+
codex: { available: true, path: '/usr/bin/codex' },
|
|
21
|
+
gemini: { available: false, path: '' },
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('agent-dispatcher', () => {
|
|
26
|
+
describe('dispatch', () => {
|
|
27
|
+
it('builds correct Claude command with worktree', () => {
|
|
28
|
+
const cmd = dispatch(SAMPLE_TASK, '/path/to/worktree', 'claude');
|
|
29
|
+
expect(cmd).toContain('claude');
|
|
30
|
+
expect(cmd).toContain('--agent builder');
|
|
31
|
+
expect(cmd).toContain('/path/to/worktree');
|
|
32
|
+
expect(cmd).toContain('--permission-mode');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('builds correct Codex command with sandbox', () => {
|
|
36
|
+
const cmd = dispatch(SAMPLE_TASK, '/path/to/worktree', 'codex');
|
|
37
|
+
expect(cmd).toContain('codex exec');
|
|
38
|
+
expect(cmd).toContain('--full-auto');
|
|
39
|
+
expect(cmd).toContain('-C "/path/to/worktree"');
|
|
40
|
+
expect(cmd).toContain('--sandbox');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('skips unavailable provider', () => {
|
|
44
|
+
expect(() => dispatch(SAMPLE_TASK, '/path', 'gemini'))
|
|
45
|
+
.toThrow(/unavailable|not supported/i);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('buildPrompt', () => {
|
|
50
|
+
it('includes acceptance criteria from task', () => {
|
|
51
|
+
const prompt = buildPrompt(SAMPLE_TASK);
|
|
52
|
+
expect(prompt).toContain('Schema has id, email, passwordHash');
|
|
53
|
+
expect(prompt).toContain('Email is unique');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('includes test cases from task', () => {
|
|
57
|
+
const prompt = buildPrompt(SAMPLE_TASK);
|
|
58
|
+
expect(prompt).toContain('Schema validates correct user data');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('includes file list', () => {
|
|
62
|
+
const prompt = buildPrompt(SAMPLE_TASK);
|
|
63
|
+
expect(prompt).toContain('user.repository.js');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('enforces test-first', () => {
|
|
67
|
+
const prompt = buildPrompt(SAMPLE_TASK);
|
|
68
|
+
expect(prompt.toLowerCase()).toMatch(/test.*first|red.*green|write.*test.*before/);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('buildSandboxArgs', () => {
|
|
73
|
+
it('uses workspace-write on macOS', () => {
|
|
74
|
+
const args = buildSandboxArgs(SAMPLE_TASK, 'darwin');
|
|
75
|
+
expect(args).toContain('--sandbox workspace-write');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('uses workspace-write on Linux', () => {
|
|
79
|
+
const args = buildSandboxArgs(SAMPLE_TASK, 'linux');
|
|
80
|
+
expect(args).toContain('--sandbox workspace-write');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('returns empty string for unsupported platform', () => {
|
|
84
|
+
const args = buildSandboxArgs(SAMPLE_TASK, 'win32');
|
|
85
|
+
expect(args).toBe('');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('createRoundRobin', () => {
|
|
90
|
+
it('alternates between providers', () => {
|
|
91
|
+
const rr = createRoundRobin(['claude', 'codex']);
|
|
92
|
+
expect(rr.next()).toBe('claude');
|
|
93
|
+
expect(rr.next()).toBe('codex');
|
|
94
|
+
expect(rr.next()).toBe('claude');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('skips unavailable providers', () => {
|
|
98
|
+
const rr = createRoundRobin(['claude', 'codex'], ROUTER_STATE);
|
|
99
|
+
// Both available, should alternate
|
|
100
|
+
expect(rr.next()).toBe('claude');
|
|
101
|
+
expect(rr.next()).toBe('codex');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('handles single provider', () => {
|
|
105
|
+
const rr = createRoundRobin(['claude']);
|
|
106
|
+
expect(rr.next()).toBe('claude');
|
|
107
|
+
expect(rr.next()).toBe('claude');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestrator Engine
|
|
3
|
+
* Coordinates parallel agent execution with dependency-aware scheduling.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parses markdown plan content and extracts tasks.
|
|
8
|
+
* @param {string} planContent - Markdown plan content
|
|
9
|
+
* @returns {Array<{number: number, title: string, goal: string, files: string[], criteria: string[], testCases: string[]}>}
|
|
10
|
+
*/
|
|
11
|
+
export function parsePlanTasks(planContent) {
|
|
12
|
+
const tasks = [];
|
|
13
|
+
// Split on task headings: ### Task N: Title OR ### Task N.M: Title
|
|
14
|
+
const sections = planContent.split(/^(?=### Task [\d.]+:)/m);
|
|
15
|
+
|
|
16
|
+
for (const section of sections) {
|
|
17
|
+
const headingMatch = section.match(/^### Task ([\d.]+):\s*(.+?)(?:\s*\[.*?\])?\s*$/m);
|
|
18
|
+
if (!headingMatch) continue;
|
|
19
|
+
|
|
20
|
+
// Use integer for plain numbers (1, 2, 3), keep full string for dotted (87.1, 87.2)
|
|
21
|
+
const rawId = headingMatch[1];
|
|
22
|
+
const number = rawId.includes('.') ? rawId : parseInt(rawId, 10);
|
|
23
|
+
const title = headingMatch[2].trim();
|
|
24
|
+
|
|
25
|
+
// Goal
|
|
26
|
+
const goalMatch = section.match(/\*\*Goal:\*\*\s*(.+)/);
|
|
27
|
+
const goal = goalMatch ? goalMatch[1].trim() : '';
|
|
28
|
+
|
|
29
|
+
// Files
|
|
30
|
+
const filesMatch = section.match(/\*\*Files:\*\*\s*([\s\S]*?)(?=\*\*|---|\n##|$)/);
|
|
31
|
+
const files = filesMatch
|
|
32
|
+
? filesMatch[1].split('\n').map(l => l.replace(/^-\s*/, '').trim()).filter(Boolean)
|
|
33
|
+
: [];
|
|
34
|
+
|
|
35
|
+
// Acceptance Criteria
|
|
36
|
+
const criteriaMatch = section.match(/\*\*Acceptance Criteria:\*\*\s*([\s\S]*?)(?=\*\*|---|\n##|$)/);
|
|
37
|
+
const criteria = criteriaMatch
|
|
38
|
+
? criteriaMatch[1].split('\n').map(l => l.replace(/^-\s*\[.\]\s*/, '').replace(/^-\s*/, '').trim()).filter(Boolean)
|
|
39
|
+
: [];
|
|
40
|
+
|
|
41
|
+
// Test Cases
|
|
42
|
+
const testCasesMatch = section.match(/\*\*Test Cases:\*\*\s*([\s\S]*?)(?=\*\*|---|\n##|$)/);
|
|
43
|
+
const testCases = testCasesMatch
|
|
44
|
+
? testCasesMatch[1].split('\n').map(l => l.replace(/^-\s*/, '').trim()).filter(Boolean)
|
|
45
|
+
: [];
|
|
46
|
+
|
|
47
|
+
tasks.push({ number, title, goal, files, criteria, testCases });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return tasks;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Builds a dependency graph from tasks and plan content.
|
|
55
|
+
* @param {Array<{number: number}>} tasks
|
|
56
|
+
* @param {string} planContent
|
|
57
|
+
* @returns {Map<number, number[]>} key=taskNumber, value=array of taskNumbers it depends on
|
|
58
|
+
*/
|
|
59
|
+
export function buildDependencyGraph(tasks, planContent) {
|
|
60
|
+
const graph = new Map();
|
|
61
|
+
|
|
62
|
+
// Initialise all tasks with empty dependency lists
|
|
63
|
+
for (const task of tasks) {
|
|
64
|
+
graph.set(task.number, []);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Parse ## Dependencies section
|
|
68
|
+
const depsMatch = planContent.match(/## Dependencies([\s\S]*?)(?=\n## |\n*$)/);
|
|
69
|
+
if (!depsMatch) return graph;
|
|
70
|
+
|
|
71
|
+
const depsSection = depsMatch[1];
|
|
72
|
+
// Match lines like "Task N depends on Task M"
|
|
73
|
+
const lineRe = /Task ([\d.]+) depends on Task ([\d.]+)/g;
|
|
74
|
+
let match;
|
|
75
|
+
while ((match = lineRe.exec(depsSection)) !== null) {
|
|
76
|
+
const rawDep = match[1];
|
|
77
|
+
const rawReq = match[2];
|
|
78
|
+
const dependent = rawDep.includes('.') ? rawDep : parseInt(rawDep, 10);
|
|
79
|
+
const dependency = rawReq.includes('.') ? rawReq : parseInt(rawReq, 10);
|
|
80
|
+
if (!graph.has(dependent)) graph.set(dependent, []);
|
|
81
|
+
graph.get(dependent).push(dependency);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return graph;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Returns tasks that have no unmet dependencies and are not already completed.
|
|
89
|
+
* @param {Array<{number: number}>} tasks
|
|
90
|
+
* @param {Map<number, number[]>} graph
|
|
91
|
+
* @param {Set<number>} completedSet
|
|
92
|
+
* @returns {Array<{number: number}>}
|
|
93
|
+
*/
|
|
94
|
+
export function getIndependentTasks(tasks, graph, completedSet) {
|
|
95
|
+
return tasks.filter(task => {
|
|
96
|
+
if (completedSet.has(task.number)) return false;
|
|
97
|
+
const deps = graph.get(task.number) || [];
|
|
98
|
+
return deps.every(dep => completedSet.has(dep));
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Orchestrates agent execution for a phase.
|
|
104
|
+
* @param {number} phaseNumber
|
|
105
|
+
* @param {object} options
|
|
106
|
+
* @param {boolean} [options.dryRun]
|
|
107
|
+
* @param {string} [options.planContent]
|
|
108
|
+
* @param {number} [options.maxAgents]
|
|
109
|
+
* @returns {Promise<object>}
|
|
110
|
+
*/
|
|
111
|
+
export async function orchestrate(phaseNumber, options = {}) {
|
|
112
|
+
const { dryRun = false, planContent, maxAgents } = options;
|
|
113
|
+
|
|
114
|
+
if (dryRun) {
|
|
115
|
+
const tasks = parsePlanTasks(planContent);
|
|
116
|
+
const graph = buildDependencyGraph(tasks, planContent);
|
|
117
|
+
const independent = getIndependentTasks(tasks, graph, new Set());
|
|
118
|
+
const independentCount = independent.length;
|
|
119
|
+
return {
|
|
120
|
+
dryRun: true,
|
|
121
|
+
tasks,
|
|
122
|
+
independentCount,
|
|
123
|
+
maxAgents,
|
|
124
|
+
sequential: independentCount <= 1,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Full orchestration (not needed for current tests)
|
|
129
|
+
throw new Error('Live orchestration not yet implemented');
|
|
130
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
parsePlanTasks,
|
|
4
|
+
buildDependencyGraph,
|
|
5
|
+
getIndependentTasks,
|
|
6
|
+
orchestrate,
|
|
7
|
+
} from './orchestrator.js';
|
|
8
|
+
|
|
9
|
+
const SAMPLE_PLAN = `# Phase 42: Auth — Plan
|
|
10
|
+
|
|
11
|
+
## Tasks
|
|
12
|
+
|
|
13
|
+
### Task 1: Create user schema [ ]
|
|
14
|
+
|
|
15
|
+
**Goal:** Define schema
|
|
16
|
+
|
|
17
|
+
**Files:**
|
|
18
|
+
- src/user.js
|
|
19
|
+
|
|
20
|
+
**Acceptance Criteria:**
|
|
21
|
+
- [ ] Schema works
|
|
22
|
+
|
|
23
|
+
**Test Cases:**
|
|
24
|
+
- Schema validates
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
### Task 2: Login endpoint [ ]
|
|
29
|
+
|
|
30
|
+
**Goal:** POST /login
|
|
31
|
+
|
|
32
|
+
**Files:**
|
|
33
|
+
- src/login.js
|
|
34
|
+
|
|
35
|
+
**Acceptance Criteria:**
|
|
36
|
+
- [ ] Login works
|
|
37
|
+
|
|
38
|
+
**Test Cases:**
|
|
39
|
+
- Valid login returns token
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
### Task 3: Session middleware [ ]
|
|
44
|
+
|
|
45
|
+
**Goal:** JWT session
|
|
46
|
+
|
|
47
|
+
**Files:**
|
|
48
|
+
- src/session.js
|
|
49
|
+
|
|
50
|
+
**Acceptance Criteria:**
|
|
51
|
+
- [ ] Session persists
|
|
52
|
+
|
|
53
|
+
**Test Cases:**
|
|
54
|
+
- Session validates token
|
|
55
|
+
|
|
56
|
+
## Dependencies
|
|
57
|
+
|
|
58
|
+
Task 2 depends on Task 1
|
|
59
|
+
Task 3 depends on Task 2
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
const PARALLEL_PLAN = `# Phase 43: Utils — Plan
|
|
63
|
+
|
|
64
|
+
## Tasks
|
|
65
|
+
|
|
66
|
+
### Task 1: String helpers [ ]
|
|
67
|
+
|
|
68
|
+
**Goal:** String utilities
|
|
69
|
+
|
|
70
|
+
**Files:**
|
|
71
|
+
- src/strings.js
|
|
72
|
+
|
|
73
|
+
**Acceptance Criteria:**
|
|
74
|
+
- [ ] Helpers work
|
|
75
|
+
|
|
76
|
+
**Test Cases:**
|
|
77
|
+
- capitalize works
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
### Task 2: Date helpers [ ]
|
|
82
|
+
|
|
83
|
+
**Goal:** Date utilities
|
|
84
|
+
|
|
85
|
+
**Files:**
|
|
86
|
+
- src/dates.js
|
|
87
|
+
|
|
88
|
+
**Acceptance Criteria:**
|
|
89
|
+
- [ ] Helpers work
|
|
90
|
+
|
|
91
|
+
**Test Cases:**
|
|
92
|
+
- formatDate works
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
### Task 3: Number helpers [ ]
|
|
97
|
+
|
|
98
|
+
**Goal:** Number utilities
|
|
99
|
+
|
|
100
|
+
**Files:**
|
|
101
|
+
- src/numbers.js
|
|
102
|
+
|
|
103
|
+
**Acceptance Criteria:**
|
|
104
|
+
- [ ] Helpers work
|
|
105
|
+
|
|
106
|
+
**Test Cases:**
|
|
107
|
+
- clamp works
|
|
108
|
+
`;
|
|
109
|
+
|
|
110
|
+
describe('orchestrator', () => {
|
|
111
|
+
describe('parsePlanTasks', () => {
|
|
112
|
+
it('extracts tasks from plan content', () => {
|
|
113
|
+
const tasks = parsePlanTasks(SAMPLE_PLAN);
|
|
114
|
+
expect(tasks).toHaveLength(3);
|
|
115
|
+
expect(tasks[0].title).toContain('Create user schema');
|
|
116
|
+
expect(tasks[1].title).toContain('Login endpoint');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('buildDependencyGraph', () => {
|
|
121
|
+
it('identifies dependent tasks', () => {
|
|
122
|
+
const tasks = parsePlanTasks(SAMPLE_PLAN);
|
|
123
|
+
const graph = buildDependencyGraph(tasks, SAMPLE_PLAN);
|
|
124
|
+
expect(graph.get(2)).toContain(1); // Task 2 depends on Task 1
|
|
125
|
+
expect(graph.get(3)).toContain(2); // Task 3 depends on Task 2
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('returns empty deps for independent tasks', () => {
|
|
129
|
+
const tasks = parsePlanTasks(PARALLEL_PLAN);
|
|
130
|
+
const graph = buildDependencyGraph(tasks, PARALLEL_PLAN);
|
|
131
|
+
expect(graph.get(1)).toHaveLength(0);
|
|
132
|
+
expect(graph.get(2)).toHaveLength(0);
|
|
133
|
+
expect(graph.get(3)).toHaveLength(0);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('getIndependentTasks', () => {
|
|
138
|
+
it('returns all tasks when no dependencies', () => {
|
|
139
|
+
const tasks = parsePlanTasks(PARALLEL_PLAN);
|
|
140
|
+
const graph = buildDependencyGraph(tasks, PARALLEL_PLAN);
|
|
141
|
+
const independent = getIndependentTasks(tasks, graph, new Set());
|
|
142
|
+
expect(independent).toHaveLength(3);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('returns only root tasks when dependencies exist', () => {
|
|
146
|
+
const tasks = parsePlanTasks(SAMPLE_PLAN);
|
|
147
|
+
const graph = buildDependencyGraph(tasks, SAMPLE_PLAN);
|
|
148
|
+
const independent = getIndependentTasks(tasks, graph, new Set());
|
|
149
|
+
expect(independent).toHaveLength(1);
|
|
150
|
+
expect(independent[0].title).toContain('Create user schema');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('unblocks dependent tasks when prerequisite completed', () => {
|
|
154
|
+
const tasks = parsePlanTasks(SAMPLE_PLAN);
|
|
155
|
+
const graph = buildDependencyGraph(tasks, SAMPLE_PLAN);
|
|
156
|
+
const completed = new Set([1]); // Task 1 done
|
|
157
|
+
const independent = getIndependentTasks(tasks, graph, completed);
|
|
158
|
+
expect(independent).toHaveLength(1);
|
|
159
|
+
expect(independent[0].title).toContain('Login endpoint');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('orchestrate', () => {
|
|
164
|
+
it('generates build report on dry-run', async () => {
|
|
165
|
+
const result = await orchestrate(43, {
|
|
166
|
+
dryRun: true,
|
|
167
|
+
planContent: PARALLEL_PLAN,
|
|
168
|
+
});
|
|
169
|
+
expect(result.dryRun).toBe(true);
|
|
170
|
+
expect(result.tasks).toHaveLength(3);
|
|
171
|
+
expect(result.independentCount).toBe(3);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('respects max concurrent limit in dry-run', async () => {
|
|
175
|
+
const result = await orchestrate(43, {
|
|
176
|
+
dryRun: true,
|
|
177
|
+
planContent: PARALLEL_PLAN,
|
|
178
|
+
maxAgents: 2,
|
|
179
|
+
});
|
|
180
|
+
expect(result.maxAgents).toBe(2);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('identifies sequential plan correctly', async () => {
|
|
184
|
+
const result = await orchestrate(42, {
|
|
185
|
+
dryRun: true,
|
|
186
|
+
planContent: SAMPLE_PLAN,
|
|
187
|
+
});
|
|
188
|
+
expect(result.independentCount).toBe(1);
|
|
189
|
+
expect(result.sequential).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tmux Session Manager
|
|
3
|
+
* Manages tmux sessions with panes for agent visibility.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Sanitizes a session name by stripping special characters and replacing spaces with hyphens.
|
|
8
|
+
* @param {string} name - Raw session name
|
|
9
|
+
* @returns {string} Sanitized session name
|
|
10
|
+
*/
|
|
11
|
+
function sanitizeName(name) {
|
|
12
|
+
return name
|
|
13
|
+
.replace(/\s+/g, '-')
|
|
14
|
+
.replace(/[^a-zA-Z0-9\-_]/g, '');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Checks whether tmux is available on the system.
|
|
19
|
+
* @param {{ exec: Function }} deps - Injected exec function
|
|
20
|
+
* @returns {boolean} True if tmux is found, false otherwise
|
|
21
|
+
*/
|
|
22
|
+
export function isAvailable({ exec }) {
|
|
23
|
+
try {
|
|
24
|
+
exec('which tmux');
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Creates a new detached tmux session with the given name.
|
|
33
|
+
* @param {string} name - Session name (will be sanitized)
|
|
34
|
+
* @param {{ exec: Function }} deps - Injected exec function
|
|
35
|
+
*/
|
|
36
|
+
export function createSession(name, { exec }) {
|
|
37
|
+
const safe = sanitizeName(name);
|
|
38
|
+
exec(`tmux new-session -d -s ${safe}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Adds a pane to an existing tmux session and runs a command in it.
|
|
43
|
+
* @param {string} session - Session name
|
|
44
|
+
* @param {string} paneName - Logical pane name (unused by tmux directly)
|
|
45
|
+
* @param {string} command - Command to send to the pane
|
|
46
|
+
* @param {{ exec: Function, paneCount?: number, paneIndex?: number }} deps - Injected deps
|
|
47
|
+
*/
|
|
48
|
+
export function addPane(session, paneName, command, { exec, paneCount = 2, paneIndex = 1 }) {
|
|
49
|
+
// For 4-pane grid: alternate horizontal/vertical splits based on pane index
|
|
50
|
+
// For 2 panes: vertical split (side by side)
|
|
51
|
+
// Default: vertical split
|
|
52
|
+
let splitFlag;
|
|
53
|
+
if (paneCount === 4) {
|
|
54
|
+
// paneIndex 1,3 => horizontal split; paneIndex 2,4 => vertical split
|
|
55
|
+
splitFlag = paneIndex % 2 === 1 ? '-h' : '-v';
|
|
56
|
+
} else {
|
|
57
|
+
// 2 panes: vertical split (splits current pane side-by-side)
|
|
58
|
+
splitFlag = '-h';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
exec(`tmux split-window ${splitFlag} -t ${session}`);
|
|
62
|
+
// Use single quotes to avoid shell re-tokenizing nested double quotes in the command
|
|
63
|
+
exec(`tmux send-keys -t ${session} '${command.replace(/'/g, "'\\''")}' Enter`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Captures the current content of a pane and returns it as a string.
|
|
68
|
+
* @param {string} session - Session name
|
|
69
|
+
* @param {number} paneIndex - Zero-based pane index
|
|
70
|
+
* @param {{ exec: Function }} deps - Injected exec function
|
|
71
|
+
* @returns {string} Captured pane content
|
|
72
|
+
*/
|
|
73
|
+
export function capturePane(session, paneIndex, { exec }) {
|
|
74
|
+
// Use .N suffix to target pane N within the session's first window
|
|
75
|
+
return exec(`tmux capture-pane -t ${session}:.${paneIndex} -p`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Kills a tmux session by name.
|
|
80
|
+
* @param {string} name - Session name
|
|
81
|
+
* @param {{ exec: Function }} deps - Injected exec function
|
|
82
|
+
*/
|
|
83
|
+
export function killSession(name, { exec }) {
|
|
84
|
+
const safe = sanitizeName(name);
|
|
85
|
+
exec(`tmux kill-session -t ${safe}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Builds a layout descriptor based on the number of agents.
|
|
90
|
+
* @param {number} agentCount - Number of agents/panes
|
|
91
|
+
* @returns {{ type: string }} Layout descriptor
|
|
92
|
+
*/
|
|
93
|
+
export function buildLayout(agentCount) {
|
|
94
|
+
if (agentCount === 2) {
|
|
95
|
+
return { type: 'vertical' };
|
|
96
|
+
}
|
|
97
|
+
if (agentCount <= 4) {
|
|
98
|
+
return { type: 'grid' };
|
|
99
|
+
}
|
|
100
|
+
return { type: 'tiled' };
|
|
101
|
+
}
|