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.
Files changed (109) hide show
  1. package/.claude/agents/builder.md +144 -0
  2. package/.claude/agents/planner.md +143 -0
  3. package/.claude/agents/reviewer.md +160 -0
  4. package/.claude/commands/tlc/build.md +4 -0
  5. package/.claude/commands/tlc/deploy.md +194 -2
  6. package/.claude/commands/tlc/e2e-verify.md +214 -0
  7. package/.claude/commands/tlc/guard.md +191 -0
  8. package/.claude/commands/tlc/help.md +32 -0
  9. package/.claude/commands/tlc/init.md +73 -37
  10. package/.claude/commands/tlc/llm.md +19 -4
  11. package/.claude/commands/tlc/preflight.md +134 -0
  12. package/.claude/commands/tlc/review-plan.md +363 -0
  13. package/.claude/commands/tlc/review.md +172 -57
  14. package/.claude/commands/tlc/watchci.md +159 -0
  15. package/.claude/hooks/tlc-block-tools.sh +41 -0
  16. package/.claude/hooks/tlc-capture-exchange.sh +50 -0
  17. package/.claude/hooks/tlc-post-build.sh +38 -0
  18. package/.claude/hooks/tlc-post-push.sh +22 -0
  19. package/.claude/hooks/tlc-prompt-guard.sh +69 -0
  20. package/.claude/hooks/tlc-session-init.sh +123 -0
  21. package/CLAUDE.md +13 -0
  22. package/bin/install.js +268 -2
  23. package/bin/postinstall.js +102 -24
  24. package/bin/setup-autoupdate.js +206 -0
  25. package/bin/setup-autoupdate.test.js +124 -0
  26. package/bin/tlc.js +0 -0
  27. package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
  28. package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
  29. package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
  30. package/dashboard-web/dist/index.html +2 -2
  31. package/docker-compose.dev.yml +18 -12
  32. package/package.json +4 -2
  33. package/scripts/project-docs.js +1 -1
  34. package/server/index.js +228 -2
  35. package/server/lib/capture-bridge.js +242 -0
  36. package/server/lib/capture-bridge.test.js +363 -0
  37. package/server/lib/capture-guard.js +140 -0
  38. package/server/lib/capture-guard.test.js +182 -0
  39. package/server/lib/command-runner.js +159 -0
  40. package/server/lib/command-runner.test.js +92 -0
  41. package/server/lib/cost-tracker.test.js +49 -12
  42. package/server/lib/deploy/runners/dependency-runner.js +106 -0
  43. package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
  44. package/server/lib/deploy/runners/secrets-runner.js +174 -0
  45. package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
  46. package/server/lib/deploy/security-gates.js +11 -24
  47. package/server/lib/deploy/security-gates.test.js +9 -2
  48. package/server/lib/deploy-engine.js +182 -0
  49. package/server/lib/deploy-engine.test.js +147 -0
  50. package/server/lib/docker-api.js +137 -0
  51. package/server/lib/docker-api.test.js +202 -0
  52. package/server/lib/docker-client.js +297 -0
  53. package/server/lib/docker-client.test.js +308 -0
  54. package/server/lib/input-sanitizer.js +86 -0
  55. package/server/lib/input-sanitizer.test.js +117 -0
  56. package/server/lib/launchd-agent.js +225 -0
  57. package/server/lib/launchd-agent.test.js +185 -0
  58. package/server/lib/memory-api.js +3 -1
  59. package/server/lib/memory-api.test.js +3 -5
  60. package/server/lib/memory-bridge-e2e.test.js +160 -0
  61. package/server/lib/memory-committer.js +18 -4
  62. package/server/lib/memory-committer.test.js +21 -0
  63. package/server/lib/memory-hooks-capture.test.js +69 -4
  64. package/server/lib/memory-hooks-integration.test.js +98 -0
  65. package/server/lib/memory-hooks.js +42 -4
  66. package/server/lib/memory-store-adapter.js +105 -0
  67. package/server/lib/memory-store-adapter.test.js +141 -0
  68. package/server/lib/memory-wiring-e2e.test.js +93 -0
  69. package/server/lib/nginx-config.js +114 -0
  70. package/server/lib/nginx-config.test.js +82 -0
  71. package/server/lib/ollama-health.js +91 -0
  72. package/server/lib/ollama-health.test.js +74 -0
  73. package/server/lib/orchestration/agent-dispatcher.js +114 -0
  74. package/server/lib/orchestration/agent-dispatcher.test.js +110 -0
  75. package/server/lib/orchestration/orchestrator.js +130 -0
  76. package/server/lib/orchestration/orchestrator.test.js +192 -0
  77. package/server/lib/orchestration/tmux-manager.js +101 -0
  78. package/server/lib/orchestration/tmux-manager.test.js +109 -0
  79. package/server/lib/orchestration/worktree-manager.js +132 -0
  80. package/server/lib/orchestration/worktree-manager.test.js +129 -0
  81. package/server/lib/port-guard.js +44 -0
  82. package/server/lib/port-guard.test.js +65 -0
  83. package/server/lib/project-scanner.js +37 -2
  84. package/server/lib/project-scanner.test.js +152 -0
  85. package/server/lib/remember-command.js +2 -0
  86. package/server/lib/remember-command.test.js +23 -0
  87. package/server/lib/review/plan-reviewer.js +260 -0
  88. package/server/lib/review/plan-reviewer.test.js +269 -0
  89. package/server/lib/review/review-schemas.js +173 -0
  90. package/server/lib/review/review-schemas.test.js +152 -0
  91. package/server/lib/security/crypto-utils.test.js +2 -2
  92. package/server/lib/semantic-recall.js +1 -1
  93. package/server/lib/semantic-recall.test.js +17 -0
  94. package/server/lib/ssh-client.js +184 -0
  95. package/server/lib/ssh-client.test.js +127 -0
  96. package/server/lib/vps-api.js +184 -0
  97. package/server/lib/vps-api.test.js +208 -0
  98. package/server/lib/vps-bootstrap.js +124 -0
  99. package/server/lib/vps-bootstrap.test.js +79 -0
  100. package/server/lib/vps-monitor.js +126 -0
  101. package/server/lib/vps-monitor.test.js +98 -0
  102. package/server/lib/workspace-api.js +182 -1
  103. package/server/lib/workspace-api.test.js +474 -0
  104. package/server/package-lock.json +737 -0
  105. package/server/package.json +3 -0
  106. package/server/setup.sh +271 -271
  107. package/dashboard-web/dist/assets/index-Uhc49PE-.css +0 -1
  108. package/dashboard-web/dist/assets/index-W36XHPC5.js +0 -431
  109. 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
+ }