@claudetree/cli 0.3.1 → 0.4.1
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.ja.md +23 -1
- package/README.ko.md +23 -1
- package/README.md +23 -1
- package/README.zh.md +23 -1
- package/dist/commands/bustercall.d.ts.map +1 -1
- package/dist/commands/bustercall.js +97 -11
- package/dist/commands/bustercall.js.map +1 -1
- package/dist/commands/start.d.ts.map +1 -1
- package/dist/commands/start.js +269 -109
- package/dist/commands/start.js.map +1 -1
- package/dist/commands/tdd.d.ts +3 -0
- package/dist/commands/tdd.d.ts.map +1 -0
- package/dist/commands/tdd.js +472 -0
- package/dist/commands/tdd.js.map +1 -0
- package/package.json +3 -3
package/dist/commands/start.js
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
-
import { access, readFile } from 'node:fs/promises';
|
|
5
|
-
import {
|
|
6
|
-
import { GitWorktreeAdapter, ClaudeSessionAdapter, FileSessionRepository, FileEventRepository, FileToolApprovalRepository, GitHubAdapter, TemplateLoader, DEFAULT_TEMPLATES, SlackNotifier, } from '@claudetree/core';
|
|
4
|
+
import { access, readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
5
|
+
import { GitWorktreeAdapter, ClaudeSessionAdapter, FileSessionRepository, FileEventRepository, FileToolApprovalRepository, GitHubAdapter, TemplateLoader, DEFAULT_TEMPLATES, SlackNotifier, ValidationGateRunner, } from '@claudetree/core';
|
|
7
6
|
const CONFIG_DIR = '.claudetree';
|
|
7
|
+
/**
|
|
8
|
+
* Sanitize natural language input to a valid branch name
|
|
9
|
+
*/
|
|
10
|
+
function sanitizeBranchName(input) {
|
|
11
|
+
return input
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.replace(/[^a-z0-9가-힣\s-]/g, '') // Allow Korean characters
|
|
14
|
+
.replace(/\s+/g, '-')
|
|
15
|
+
.replace(/-+/g, '-')
|
|
16
|
+
.replace(/^-|-$/g, '')
|
|
17
|
+
.slice(0, 50); // Max length
|
|
18
|
+
}
|
|
8
19
|
async function loadConfig(cwd) {
|
|
9
20
|
try {
|
|
10
21
|
const configPath = join(cwd, CONFIG_DIR, 'config.json');
|
|
@@ -16,18 +27,56 @@ async function loadConfig(cwd) {
|
|
|
16
27
|
return null;
|
|
17
28
|
}
|
|
18
29
|
}
|
|
30
|
+
function parseGates(gatesStr, testCommand) {
|
|
31
|
+
const gateNames = gatesStr.split(',').map(g => g.trim().toLowerCase());
|
|
32
|
+
const gates = [];
|
|
33
|
+
// Always run pnpm install first to ensure dependencies are available
|
|
34
|
+
gates.push({ name: 'install', command: 'pnpm install --frozen-lockfile', required: true });
|
|
35
|
+
for (const name of gateNames) {
|
|
36
|
+
switch (name) {
|
|
37
|
+
case 'test':
|
|
38
|
+
gates.push({ name: 'test', command: testCommand ?? 'pnpm test:run', required: true });
|
|
39
|
+
break;
|
|
40
|
+
case 'type':
|
|
41
|
+
// Use pnpm -r to run in all workspace packages
|
|
42
|
+
gates.push({ name: 'type', command: 'pnpm -r exec tsc --noEmit', required: true });
|
|
43
|
+
break;
|
|
44
|
+
case 'lint':
|
|
45
|
+
gates.push({ name: 'lint', command: 'pnpm lint', required: false });
|
|
46
|
+
break;
|
|
47
|
+
case 'build':
|
|
48
|
+
gates.push({ name: 'build', command: 'pnpm build', required: false });
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return gates;
|
|
53
|
+
}
|
|
54
|
+
function formatDuration(ms) {
|
|
55
|
+
const seconds = Math.floor(ms / 1000);
|
|
56
|
+
const minutes = Math.floor(seconds / 60);
|
|
57
|
+
const hours = Math.floor(minutes / 60);
|
|
58
|
+
if (hours > 0)
|
|
59
|
+
return `${hours}h ${minutes % 60}m`;
|
|
60
|
+
if (minutes > 0)
|
|
61
|
+
return `${minutes}m ${seconds % 60}s`;
|
|
62
|
+
return `${seconds}s`;
|
|
63
|
+
}
|
|
19
64
|
export const startCommand = new Command('start')
|
|
20
|
-
.description('Create worktree from issue and start Claude session')
|
|
65
|
+
.description('Create worktree from issue and start Claude session (TDD mode by default)')
|
|
21
66
|
.argument('<issue>', 'Issue number, GitHub URL, or task name')
|
|
22
67
|
.option('-p, --prompt <prompt>', 'Initial prompt for Claude')
|
|
23
68
|
.option('--no-session', 'Create worktree without starting Claude')
|
|
24
|
-
.option('-
|
|
69
|
+
.option('--no-tdd', 'Disable TDD mode (just implement without test-first)')
|
|
70
|
+
.option('-s, --skill <skill>', 'Skill to activate (review)')
|
|
25
71
|
.option('-T, --template <template>', 'Session template (bugfix, feature, refactor, review)')
|
|
26
72
|
.option('-b, --branch <branch>', 'Custom branch name')
|
|
27
73
|
.option('-t, --token <token>', 'GitHub token (or use GITHUB_TOKEN env)')
|
|
28
74
|
.option('--max-cost <cost>', 'Maximum cost in USD (stops session if exceeded)', parseFloat)
|
|
29
|
-
.option('--
|
|
30
|
-
.option('--
|
|
75
|
+
.option('--timeout <minutes>', 'Total session timeout in minutes (default: 120)')
|
|
76
|
+
.option('--idle-timeout <minutes>', 'Idle timeout in minutes (default: 10)')
|
|
77
|
+
.option('--max-retries <n>', 'Max retries per validation gate (default: 3)')
|
|
78
|
+
.option('--gates <gates>', 'Validation gates: test,type,lint,build (default: test,type)')
|
|
79
|
+
.option('--test-command <cmd>', 'Custom test command (default: pnpm test)')
|
|
31
80
|
.action(async (issue, options) => {
|
|
32
81
|
const cwd = process.cwd();
|
|
33
82
|
const config = await loadConfig(cwd);
|
|
@@ -35,15 +84,42 @@ export const startCommand = new Command('start')
|
|
|
35
84
|
console.error('Error: claudetree not initialized. Run "claudetree init" first.');
|
|
36
85
|
process.exit(1);
|
|
37
86
|
}
|
|
87
|
+
// Build TDD config if TDD mode enabled
|
|
88
|
+
const tddEnabled = options.tdd !== false;
|
|
89
|
+
let tddConfig = null;
|
|
90
|
+
if (tddEnabled) {
|
|
91
|
+
tddConfig = {
|
|
92
|
+
timeout: parseInt(options.timeout ?? '120', 10) * 60 * 1000,
|
|
93
|
+
idleTimeout: parseInt(options.idleTimeout ?? '10', 10) * 60 * 1000,
|
|
94
|
+
maxIterations: 10,
|
|
95
|
+
maxRetries: parseInt(options.maxRetries ?? '3', 10),
|
|
96
|
+
gates: parseGates(options.gates ?? 'test,type', options.testCommand),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
// Header
|
|
100
|
+
if (tddEnabled) {
|
|
101
|
+
console.log('\n\x1b[36m╔══════════════════════════════════════════╗\x1b[0m');
|
|
102
|
+
console.log('\x1b[36m║ TDD Mode Session (Default) ║\x1b[0m');
|
|
103
|
+
console.log('\x1b[36m╚══════════════════════════════════════════╝\x1b[0m');
|
|
104
|
+
console.log('\n\x1b[90mUse --no-tdd to disable TDD mode\x1b[0m\n');
|
|
105
|
+
console.log('\x1b[33m⏱️ Time Limits:\x1b[0m');
|
|
106
|
+
console.log(` Session: ${formatDuration(tddConfig.timeout)}`);
|
|
107
|
+
console.log(` Idle: ${formatDuration(tddConfig.idleTimeout)}`);
|
|
108
|
+
console.log(` Max retries: ${tddConfig.maxRetries}`);
|
|
109
|
+
console.log('\n\x1b[33m✅ Validation Gates:\x1b[0m');
|
|
110
|
+
for (const gate of tddConfig.gates) {
|
|
111
|
+
const status = gate.required ? '\x1b[31m(required)\x1b[0m' : '\x1b[90m(optional)\x1b[0m';
|
|
112
|
+
console.log(` • ${gate.name}: ${gate.command} ${status}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
38
115
|
let issueNumber = null;
|
|
39
116
|
let issueData = null;
|
|
40
117
|
let branchName;
|
|
41
|
-
|
|
118
|
+
let taskDescription = null; // For natural language input
|
|
42
119
|
const ghToken = options.token ?? process.env.GITHUB_TOKEN ?? config.github?.token;
|
|
43
120
|
if (issue.includes('github.com')) {
|
|
44
|
-
// Parse GitHub URL
|
|
45
121
|
if (!ghToken) {
|
|
46
|
-
console.error('
|
|
122
|
+
console.error('\nError: GitHub token required for URL. Set GITHUB_TOKEN or use --token.');
|
|
47
123
|
process.exit(1);
|
|
48
124
|
}
|
|
49
125
|
const ghAdapter = new GitHubAdapter(ghToken);
|
|
@@ -52,7 +128,7 @@ export const startCommand = new Command('start')
|
|
|
52
128
|
console.error('Error: Invalid GitHub URL format.');
|
|
53
129
|
process.exit(1);
|
|
54
130
|
}
|
|
55
|
-
console.log(
|
|
131
|
+
console.log(`\nFetching issue #${parsed.number} from ${parsed.owner}/${parsed.repo}...`);
|
|
56
132
|
try {
|
|
57
133
|
issueData = await ghAdapter.getIssue(parsed.owner, parsed.repo, parsed.number);
|
|
58
134
|
issueNumber = issueData.number;
|
|
@@ -66,21 +142,18 @@ export const startCommand = new Command('start')
|
|
|
66
142
|
}
|
|
67
143
|
}
|
|
68
144
|
else {
|
|
69
|
-
// Parse as issue number or task name
|
|
70
145
|
const parsed = parseInt(issue, 10);
|
|
71
146
|
const isNumber = !isNaN(parsed);
|
|
72
147
|
if (isNumber && ghToken && config.github?.owner && config.github?.repo) {
|
|
73
|
-
// Try to fetch issue from configured repo
|
|
74
148
|
const ghAdapter = new GitHubAdapter(ghToken);
|
|
75
149
|
try {
|
|
76
|
-
console.log(
|
|
150
|
+
console.log(`\nFetching issue #${parsed}...`);
|
|
77
151
|
issueData = await ghAdapter.getIssue(config.github.owner, config.github.repo, parsed);
|
|
78
152
|
issueNumber = issueData.number;
|
|
79
153
|
branchName = options.branch ?? ghAdapter.generateBranchName(issueNumber, issueData.title);
|
|
80
154
|
console.log(` Title: ${issueData.title}`);
|
|
81
155
|
}
|
|
82
156
|
catch {
|
|
83
|
-
// Fall back to simple issue number
|
|
84
157
|
issueNumber = parsed;
|
|
85
158
|
branchName = options.branch ?? `issue-${issueNumber}`;
|
|
86
159
|
}
|
|
@@ -90,11 +163,13 @@ export const startCommand = new Command('start')
|
|
|
90
163
|
branchName = options.branch ?? `issue-${issueNumber}`;
|
|
91
164
|
}
|
|
92
165
|
else {
|
|
93
|
-
|
|
166
|
+
// Natural language input - use as task description
|
|
167
|
+
taskDescription = issue;
|
|
168
|
+
branchName = options.branch ?? `task-${sanitizeBranchName(issue)}`;
|
|
169
|
+
console.log(`\n📝 Task: "${taskDescription}"`);
|
|
94
170
|
}
|
|
95
171
|
}
|
|
96
172
|
const worktreePath = join(cwd, config.worktreeDir, branchName);
|
|
97
|
-
// Check if worktree already exists
|
|
98
173
|
const gitAdapter = new GitWorktreeAdapter(cwd);
|
|
99
174
|
const existingWorktrees = await gitAdapter.list();
|
|
100
175
|
const existingWorktree = existingWorktrees.find((wt) => wt.branch === branchName || wt.path.endsWith(branchName));
|
|
@@ -132,7 +207,6 @@ export const startCommand = new Command('start')
|
|
|
132
207
|
console.log('\nWorktree created. Use "cd" to navigate and start working.');
|
|
133
208
|
return;
|
|
134
209
|
}
|
|
135
|
-
// Check Claude availability
|
|
136
210
|
const claudeAdapter = new ClaudeSessionAdapter();
|
|
137
211
|
const available = await claudeAdapter.isClaudeAvailable();
|
|
138
212
|
if (!available) {
|
|
@@ -140,7 +214,6 @@ export const startCommand = new Command('start')
|
|
|
140
214
|
console.log('Worktree created but Claude session not started.');
|
|
141
215
|
return;
|
|
142
216
|
}
|
|
143
|
-
// Create session record
|
|
144
217
|
const sessionRepo = new FileSessionRepository(join(cwd, CONFIG_DIR));
|
|
145
218
|
const session = {
|
|
146
219
|
id: randomUUID(),
|
|
@@ -151,15 +224,12 @@ export const startCommand = new Command('start')
|
|
|
151
224
|
prompt: options.prompt ?? null,
|
|
152
225
|
createdAt: new Date(),
|
|
153
226
|
updatedAt: new Date(),
|
|
154
|
-
// Recovery fields
|
|
155
227
|
processId: null,
|
|
156
228
|
osProcessId: null,
|
|
157
229
|
lastHeartbeat: null,
|
|
158
230
|
errorCount: 0,
|
|
159
231
|
worktreePath: worktree.path,
|
|
160
|
-
// Token usage
|
|
161
232
|
usage: null,
|
|
162
|
-
// Progress tracking
|
|
163
233
|
progress: {
|
|
164
234
|
currentStep: 'analyzing',
|
|
165
235
|
completedSteps: [],
|
|
@@ -172,7 +242,6 @@ export const startCommand = new Command('start')
|
|
|
172
242
|
if (options.template) {
|
|
173
243
|
const templateLoader = new TemplateLoader(join(cwd, CONFIG_DIR));
|
|
174
244
|
template = await templateLoader.load(options.template);
|
|
175
|
-
// Fall back to default templates
|
|
176
245
|
if (!template && options.template in DEFAULT_TEMPLATES) {
|
|
177
246
|
template = DEFAULT_TEMPLATES[options.template] ?? null;
|
|
178
247
|
}
|
|
@@ -195,69 +264,125 @@ Issue Description:
|
|
|
195
264
|
${issueData.body || 'No description provided.'}
|
|
196
265
|
|
|
197
266
|
IMPORTANT: Do NOT just analyze or suggest. Actually IMPLEMENT the solution.
|
|
198
|
-
|
|
199
|
-
Workflow:
|
|
200
|
-
1. Read the relevant code files
|
|
201
|
-
2. Write the code to solve this issue
|
|
202
|
-
3. Run tests to verify your implementation
|
|
203
|
-
4. When done, commit your changes with a clear message
|
|
204
|
-
5. Create a PR to the develop branch
|
|
205
|
-
|
|
206
|
-
Start implementing now.`;
|
|
267
|
+
${tddEnabled ? '\nStart with TDD - write a failing test first!' : ''}`;
|
|
207
268
|
}
|
|
208
269
|
else if (issueNumber) {
|
|
209
|
-
prompt = `Working on issue #${issueNumber}.
|
|
270
|
+
prompt = `Working on issue #${issueNumber}. ${tddEnabled ? 'Start with TDD - write a failing test first!' : 'Implement the solution.'}`;
|
|
271
|
+
}
|
|
272
|
+
else if (taskDescription) {
|
|
273
|
+
// Natural language task
|
|
274
|
+
prompt = `Your task: ${taskDescription}
|
|
275
|
+
|
|
276
|
+
IMPORTANT: Do NOT just analyze or suggest. Actually IMPLEMENT the solution.
|
|
277
|
+
${tddEnabled ? '\nStart with TDD - write a failing test first!' : ''}`;
|
|
210
278
|
}
|
|
211
279
|
else {
|
|
212
|
-
prompt = `Working on ${branchName}. Implement any required changes
|
|
280
|
+
prompt = `Working on ${branchName}. ${tddEnabled ? 'Start with TDD - write a failing test first!' : 'Implement any required changes.'}`;
|
|
213
281
|
}
|
|
214
|
-
// Apply template to prompt
|
|
215
282
|
if (template) {
|
|
216
283
|
const prefix = template.promptPrefix ? `${template.promptPrefix}\n\n` : '';
|
|
217
284
|
const suffix = template.promptSuffix ? `\n\n${template.promptSuffix}` : '';
|
|
218
285
|
prompt = `${prefix}${prompt}${suffix}`;
|
|
219
286
|
}
|
|
220
|
-
//
|
|
287
|
+
// Build system prompt
|
|
221
288
|
let systemPrompt;
|
|
222
289
|
const effectiveSkill = template?.skill || options.skill;
|
|
223
|
-
if (
|
|
224
|
-
|
|
290
|
+
if (tddEnabled) {
|
|
291
|
+
// TDD system prompt (default)
|
|
292
|
+
systemPrompt = `You are in TDD (Test-Driven Development) mode. Follow this STRICT workflow:
|
|
293
|
+
|
|
294
|
+
## TDD Cycle (Repeat until done)
|
|
295
|
+
|
|
296
|
+
### 1. RED Phase - Write Failing Test
|
|
297
|
+
- Write ONE failing test that describes the expected behavior
|
|
298
|
+
- Run the test to confirm it fails
|
|
299
|
+
- Commit: "test: add test for <feature>"
|
|
300
|
+
|
|
301
|
+
### 2. GREEN Phase - Minimal Implementation
|
|
302
|
+
- Write the MINIMUM code to make the test pass
|
|
303
|
+
- Run tests to confirm they pass
|
|
304
|
+
- Commit: "feat: implement <feature>"
|
|
305
|
+
|
|
306
|
+
### 3. REFACTOR Phase (Optional)
|
|
307
|
+
- Clean up code while keeping tests green
|
|
308
|
+
- Commit: "refactor: improve <description>"
|
|
225
309
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
310
|
+
## Rules
|
|
311
|
+
- NEVER write implementation before tests
|
|
312
|
+
- ONE test at a time
|
|
313
|
+
- Run tests after EVERY change
|
|
314
|
+
- Stop when all requirements are met
|
|
229
315
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
-
|
|
316
|
+
## Validation Gates (Must Pass Before PR)
|
|
317
|
+
${tddConfig.gates.map(g => `- ${g.name}: \`${g.command}\` ${g.required ? '(REQUIRED)' : '(optional)'}`).join('\n')}
|
|
318
|
+
|
|
319
|
+
## Time Limits
|
|
320
|
+
- Total: ${formatDuration(tddConfig.timeout)}
|
|
321
|
+
- Idle: ${formatDuration(tddConfig.idleTimeout)}
|
|
322
|
+
|
|
323
|
+
When done, create a PR to the develop branch.`;
|
|
235
324
|
}
|
|
236
325
|
else if (effectiveSkill === 'review') {
|
|
237
326
|
systemPrompt = 'Review code thoroughly for security, quality, and best practices.';
|
|
238
327
|
}
|
|
239
|
-
// Template system prompt overrides
|
|
240
328
|
if (template?.systemPrompt) {
|
|
241
329
|
systemPrompt = template.systemPrompt;
|
|
242
330
|
}
|
|
243
|
-
console.log('\
|
|
244
|
-
if (
|
|
245
|
-
console.log(
|
|
331
|
+
console.log('\n\x1b[36m🚀 Starting Claude session...\x1b[0m');
|
|
332
|
+
if (tddEnabled) {
|
|
333
|
+
console.log(' Mode: \x1b[32mTDD\x1b[0m (Test-Driven Development)');
|
|
246
334
|
}
|
|
247
335
|
if (options.maxCost) {
|
|
248
|
-
console.log(`
|
|
336
|
+
console.log(` Budget: \x1b[33m$${options.maxCost.toFixed(2)}\x1b[0m`);
|
|
249
337
|
}
|
|
250
|
-
// Initialize event repositories
|
|
251
338
|
const eventRepo = new FileEventRepository(join(cwd, CONFIG_DIR));
|
|
252
339
|
const approvalRepo = new FileToolApprovalRepository(join(cwd, CONFIG_DIR));
|
|
253
|
-
//
|
|
340
|
+
// Save TDD state if enabled
|
|
341
|
+
let tddState = null;
|
|
342
|
+
let tddStatePath = null;
|
|
343
|
+
if (tddEnabled) {
|
|
344
|
+
tddState = {
|
|
345
|
+
phase: 'writing_test',
|
|
346
|
+
currentIteration: 1,
|
|
347
|
+
gateResults: [],
|
|
348
|
+
failureCount: 0,
|
|
349
|
+
lastActivity: new Date(),
|
|
350
|
+
config: tddConfig,
|
|
351
|
+
};
|
|
352
|
+
tddStatePath = join(cwd, CONFIG_DIR, 'tdd-state', `${session.id}.json`);
|
|
353
|
+
await mkdir(join(cwd, CONFIG_DIR, 'tdd-state'), { recursive: true });
|
|
354
|
+
await writeFile(tddStatePath, JSON.stringify(tddState, null, 2));
|
|
355
|
+
}
|
|
356
|
+
// Track timeouts
|
|
357
|
+
const sessionStartTime = Date.now();
|
|
358
|
+
let lastOutputTime = Date.now();
|
|
359
|
+
let sessionTimedOut = false;
|
|
360
|
+
let idleTimedOut = false;
|
|
361
|
+
let timeoutChecker = null;
|
|
362
|
+
if (tddEnabled && tddConfig) {
|
|
363
|
+
timeoutChecker = setInterval(() => {
|
|
364
|
+
const elapsed = Date.now() - sessionStartTime;
|
|
365
|
+
const idleTime = Date.now() - lastOutputTime;
|
|
366
|
+
if (elapsed >= tddConfig.timeout) {
|
|
367
|
+
sessionTimedOut = true;
|
|
368
|
+
console.log(`\n\x1b[31m[Timeout]\x1b[0m Session timeout (${formatDuration(tddConfig.timeout)}) exceeded.`);
|
|
369
|
+
if (timeoutChecker)
|
|
370
|
+
clearInterval(timeoutChecker);
|
|
371
|
+
}
|
|
372
|
+
else if (idleTime >= tddConfig.idleTimeout) {
|
|
373
|
+
idleTimedOut = true;
|
|
374
|
+
console.log(`\n\x1b[31m[Timeout]\x1b[0m Idle timeout (${formatDuration(tddConfig.idleTimeout)}) exceeded.`);
|
|
375
|
+
if (timeoutChecker)
|
|
376
|
+
clearInterval(timeoutChecker);
|
|
377
|
+
}
|
|
378
|
+
}, 5000);
|
|
379
|
+
}
|
|
254
380
|
claudeAdapter.on('output', async (event) => {
|
|
255
381
|
const { output } = event;
|
|
256
|
-
|
|
382
|
+
lastOutputTime = Date.now();
|
|
257
383
|
let eventType = 'output';
|
|
258
384
|
if (output.type === 'tool_use') {
|
|
259
385
|
eventType = 'tool_call';
|
|
260
|
-
// Record tool approval request and update progress
|
|
261
386
|
try {
|
|
262
387
|
const parsed = parseToolCall(output.content);
|
|
263
388
|
if (parsed) {
|
|
@@ -266,12 +391,11 @@ Rules:
|
|
|
266
391
|
sessionId: session.id,
|
|
267
392
|
toolName: parsed.toolName,
|
|
268
393
|
parameters: parsed.parameters,
|
|
269
|
-
status: 'approved',
|
|
394
|
+
status: 'approved',
|
|
270
395
|
approvedBy: 'auto',
|
|
271
396
|
requestedAt: output.timestamp,
|
|
272
397
|
resolvedAt: output.timestamp,
|
|
273
398
|
});
|
|
274
|
-
// Update progress based on tool usage
|
|
275
399
|
if (session.progress) {
|
|
276
400
|
const detectedStep = detectProgressStep(parsed.toolName, parsed.parameters);
|
|
277
401
|
if (detectedStep) {
|
|
@@ -282,13 +406,12 @@ Rules:
|
|
|
282
406
|
}
|
|
283
407
|
}
|
|
284
408
|
catch {
|
|
285
|
-
// Ignore
|
|
409
|
+
// Ignore
|
|
286
410
|
}
|
|
287
411
|
}
|
|
288
412
|
else if (output.type === 'error') {
|
|
289
413
|
eventType = 'error';
|
|
290
414
|
}
|
|
291
|
-
// Record event
|
|
292
415
|
try {
|
|
293
416
|
await eventRepo.append({
|
|
294
417
|
id: randomUUID(),
|
|
@@ -299,33 +422,34 @@ Rules:
|
|
|
299
422
|
});
|
|
300
423
|
}
|
|
301
424
|
catch {
|
|
302
|
-
// Ignore
|
|
425
|
+
// Ignore
|
|
303
426
|
}
|
|
304
427
|
});
|
|
305
|
-
// Start Claude session
|
|
306
428
|
const result = await claudeAdapter.start({
|
|
307
429
|
workingDir: worktree.path,
|
|
308
430
|
prompt,
|
|
309
431
|
systemPrompt,
|
|
310
432
|
allowedTools: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'],
|
|
311
433
|
});
|
|
312
|
-
// Update session with process info
|
|
313
434
|
session.processId = result.processId;
|
|
314
435
|
session.osProcessId = result.osProcessId;
|
|
315
436
|
session.lastHeartbeat = new Date();
|
|
316
437
|
session.status = 'running';
|
|
317
438
|
session.updatedAt = new Date();
|
|
318
439
|
await sessionRepo.save(session);
|
|
319
|
-
// Setup graceful shutdown
|
|
320
440
|
const handleShutdown = async () => {
|
|
321
441
|
console.log('\n[Info] Pausing session...');
|
|
442
|
+
if (timeoutChecker)
|
|
443
|
+
clearInterval(timeoutChecker);
|
|
322
444
|
session.status = 'paused';
|
|
323
445
|
session.updatedAt = new Date();
|
|
324
446
|
await sessionRepo.save(session);
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
447
|
+
if (tddState && tddStatePath) {
|
|
448
|
+
tddState.phase = 'failed';
|
|
449
|
+
await writeFile(tddStatePath, JSON.stringify(tddState, null, 2));
|
|
328
450
|
}
|
|
451
|
+
console.log(`Session paused: ${session.id.slice(0, 8)}`);
|
|
452
|
+
console.log(`Resume with: claudetree resume ${session.id.slice(0, 8)}`);
|
|
329
453
|
process.exit(0);
|
|
330
454
|
};
|
|
331
455
|
process.on('SIGINT', handleShutdown);
|
|
@@ -333,20 +457,26 @@ Rules:
|
|
|
333
457
|
console.log(`\nSession started: ${session.id.slice(0, 8)}`);
|
|
334
458
|
console.log(`Working directory: ${worktree.path}`);
|
|
335
459
|
console.log('Claude is now working on the issue...\n');
|
|
336
|
-
// Wait for Claude to complete and show output
|
|
337
460
|
let outputCount = 0;
|
|
338
461
|
let currentCost = 0;
|
|
339
462
|
let budgetExceeded = false;
|
|
340
463
|
for await (const output of claudeAdapter.getOutput(result.processId)) {
|
|
341
464
|
outputCount++;
|
|
342
465
|
session.lastHeartbeat = new Date();
|
|
343
|
-
|
|
466
|
+
lastOutputTime = Date.now();
|
|
467
|
+
// Check timeouts
|
|
468
|
+
if (sessionTimedOut || idleTimedOut) {
|
|
469
|
+
await claudeAdapter.stop(result.processId);
|
|
470
|
+
session.status = 'failed';
|
|
471
|
+
if (tddState)
|
|
472
|
+
tddState.phase = 'failed';
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
344
475
|
if (output.cumulativeCost !== undefined) {
|
|
345
476
|
currentCost = output.cumulativeCost;
|
|
346
|
-
// Budget check
|
|
347
477
|
if (options.maxCost && currentCost >= options.maxCost && !budgetExceeded) {
|
|
348
478
|
budgetExceeded = true;
|
|
349
|
-
console.log(`\x1b[31m[Budget]\x1b[0m Cost $${currentCost.toFixed(4)} exceeded limit $${options.maxCost.toFixed(4)}. Stopping
|
|
479
|
+
console.log(`\x1b[31m[Budget]\x1b[0m Cost $${currentCost.toFixed(4)} exceeded limit $${options.maxCost.toFixed(4)}. Stopping...`);
|
|
350
480
|
await claudeAdapter.stop(result.processId);
|
|
351
481
|
session.status = 'failed';
|
|
352
482
|
session.updatedAt = new Date();
|
|
@@ -365,66 +495,102 @@ Rules:
|
|
|
365
495
|
}
|
|
366
496
|
else if (output.type === 'done') {
|
|
367
497
|
console.log(`\x1b[32m[Done]\x1b[0m Session ID: ${output.content}`);
|
|
368
|
-
// Capture Claude session ID for resume
|
|
369
498
|
if (output.content) {
|
|
370
499
|
session.claudeSessionId = output.content;
|
|
371
500
|
}
|
|
372
|
-
// Capture token usage
|
|
373
501
|
if (output.usage) {
|
|
374
502
|
session.usage = output.usage;
|
|
375
503
|
console.log(`\x1b[32m[Usage]\x1b[0m Tokens: ${output.usage.inputTokens} in / ${output.usage.outputTokens} out | Cost: $${output.usage.totalCostUsd.toFixed(4)}`);
|
|
376
504
|
}
|
|
377
505
|
}
|
|
378
|
-
// Update heartbeat periodically
|
|
379
506
|
if (outputCount % 10 === 0) {
|
|
380
507
|
session.updatedAt = new Date();
|
|
381
508
|
await sessionRepo.save(session);
|
|
382
509
|
}
|
|
383
510
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
511
|
+
if (timeoutChecker)
|
|
512
|
+
clearInterval(timeoutChecker);
|
|
513
|
+
// Run validation gates if TDD mode and session didn't fail
|
|
514
|
+
if (tddEnabled && tddConfig && session.status !== 'failed' && !budgetExceeded) {
|
|
515
|
+
console.log('\n\x1b[36m╔══════════════════════════════════════════╗\x1b[0m');
|
|
516
|
+
console.log('\x1b[36m║ Running Validation Gates ║\x1b[0m');
|
|
517
|
+
console.log('\x1b[36m╚══════════════════════════════════════════╝\x1b[0m\n');
|
|
518
|
+
if (tddState) {
|
|
519
|
+
tddState.phase = 'validating';
|
|
520
|
+
if (tddStatePath)
|
|
521
|
+
await writeFile(tddStatePath, JSON.stringify(tddState, null, 2));
|
|
522
|
+
}
|
|
523
|
+
const gateRunner = new ValidationGateRunner();
|
|
524
|
+
const gateResults = await gateRunner.runWithAutoRetry(tddConfig.gates, {
|
|
525
|
+
cwd: worktree.path,
|
|
526
|
+
maxRetries: tddConfig.maxRetries,
|
|
527
|
+
onRetry: (attempt, failedGate) => {
|
|
528
|
+
console.log(`\x1b[33m[Retry]\x1b[0m Gate '${failedGate}' failed, attempt ${attempt + 1}/${tddConfig.maxRetries}`);
|
|
529
|
+
},
|
|
530
|
+
});
|
|
531
|
+
console.log('\n\x1b[33m📊 Gate Results:\x1b[0m');
|
|
532
|
+
for (const res of gateResults.results) {
|
|
533
|
+
const icon = res.passed ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m';
|
|
534
|
+
const attempts = res.attempts > 1 ? ` (${res.attempts} attempts)` : '';
|
|
535
|
+
console.log(` ${icon} ${res.gateName}${attempts}`);
|
|
536
|
+
}
|
|
537
|
+
console.log(`\n Total time: ${formatDuration(gateResults.totalTime)}`);
|
|
538
|
+
if (tddState) {
|
|
539
|
+
tddState.gateResults = gateResults.results;
|
|
540
|
+
}
|
|
541
|
+
if (gateResults.allPassed) {
|
|
542
|
+
console.log('\n\x1b[32m✅ All validation gates passed!\x1b[0m');
|
|
543
|
+
session.status = 'completed';
|
|
544
|
+
if (tddState)
|
|
545
|
+
tddState.phase = 'completed';
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
console.log('\n\x1b[31m❌ Validation gates failed.\x1b[0m');
|
|
549
|
+
session.status = 'failed';
|
|
550
|
+
if (tddState)
|
|
551
|
+
tddState.phase = 'failed';
|
|
552
|
+
const failedGate = gateResults.results.find(r => !r.passed);
|
|
553
|
+
if (failedGate?.output) {
|
|
554
|
+
console.log(`\n\x1b[33mFailed gate output (${failedGate.gateName}):\x1b[0m`);
|
|
555
|
+
console.log(failedGate.output);
|
|
410
556
|
}
|
|
411
557
|
}
|
|
412
558
|
}
|
|
413
|
-
|
|
559
|
+
else if (!tddEnabled && session.status !== 'failed' && !budgetExceeded) {
|
|
560
|
+
session.status = 'completed';
|
|
561
|
+
}
|
|
562
|
+
// Final summary
|
|
563
|
+
const totalDuration = Date.now() - sessionStartTime;
|
|
564
|
+
console.log('\n\x1b[36m╔══════════════════════════════════════════╗\x1b[0m');
|
|
565
|
+
console.log('\x1b[36m║ Session Summary ║\x1b[0m');
|
|
566
|
+
console.log('\x1b[36m╚══════════════════════════════════════════╝\x1b[0m\n');
|
|
567
|
+
console.log(` Status: ${session.status === 'completed' ? '\x1b[32mcompleted\x1b[0m' : '\x1b[31mfailed\x1b[0m'}`);
|
|
568
|
+
console.log(` Mode: ${tddEnabled ? 'TDD' : 'Standard'}`);
|
|
569
|
+
console.log(` Duration: ${formatDuration(totalDuration)}`);
|
|
570
|
+
if (session.usage) {
|
|
571
|
+
console.log(` Cost: $${session.usage.totalCostUsd.toFixed(4)}`);
|
|
572
|
+
}
|
|
573
|
+
session.updatedAt = new Date();
|
|
574
|
+
await sessionRepo.save(session);
|
|
575
|
+
if (tddState && tddStatePath) {
|
|
576
|
+
await writeFile(tddStatePath, JSON.stringify(tddState, null, 2));
|
|
577
|
+
}
|
|
414
578
|
if (config.slack?.webhookUrl) {
|
|
415
579
|
const slack = new SlackNotifier(config.slack.webhookUrl);
|
|
416
580
|
await slack.notifySession({
|
|
417
581
|
sessionId: session.id,
|
|
418
|
-
status: 'completed',
|
|
582
|
+
status: session.status === 'completed' ? 'completed' : 'failed',
|
|
419
583
|
issueNumber,
|
|
420
584
|
branch: branchName,
|
|
421
585
|
worktreePath: worktree.path,
|
|
422
|
-
duration:
|
|
586
|
+
duration: totalDuration,
|
|
423
587
|
});
|
|
424
588
|
}
|
|
589
|
+
if (session.status === 'failed') {
|
|
590
|
+
process.exit(1);
|
|
591
|
+
}
|
|
425
592
|
}
|
|
426
593
|
catch (error) {
|
|
427
|
-
// Send failure notification
|
|
428
594
|
if (config.slack?.webhookUrl) {
|
|
429
595
|
const slack = new SlackNotifier(config.slack.webhookUrl);
|
|
430
596
|
await slack.notifySession({
|
|
@@ -442,7 +608,6 @@ Rules:
|
|
|
442
608
|
}
|
|
443
609
|
});
|
|
444
610
|
function parseToolCall(content) {
|
|
445
|
-
// Format: "ToolName: {json}"
|
|
446
611
|
const match = content.match(/^(\w+):\s*(.+)$/);
|
|
447
612
|
if (!match)
|
|
448
613
|
return null;
|
|
@@ -458,7 +623,6 @@ function parseToolCall(content) {
|
|
|
458
623
|
}
|
|
459
624
|
function detectProgressStep(toolName, params) {
|
|
460
625
|
const command = String(params.command ?? '');
|
|
461
|
-
// Detect test running
|
|
462
626
|
if (toolName === 'Bash') {
|
|
463
627
|
if (command.includes('test') || command.includes('jest') || command.includes('vitest') || command.includes('pytest')) {
|
|
464
628
|
return 'testing';
|
|
@@ -470,11 +634,9 @@ function detectProgressStep(toolName, params) {
|
|
|
470
634
|
return 'creating_pr';
|
|
471
635
|
}
|
|
472
636
|
}
|
|
473
|
-
// Detect code writing
|
|
474
637
|
if (toolName === 'Edit' || toolName === 'Write') {
|
|
475
638
|
return 'implementing';
|
|
476
639
|
}
|
|
477
|
-
// Detect code reading/analysis
|
|
478
640
|
if (toolName === 'Read' || toolName === 'Glob' || toolName === 'Grep') {
|
|
479
641
|
return 'analyzing';
|
|
480
642
|
}
|
|
@@ -484,9 +646,7 @@ function updateProgress(progress, newStep) {
|
|
|
484
646
|
const stepOrder = ['analyzing', 'implementing', 'testing', 'committing', 'creating_pr'];
|
|
485
647
|
const currentIdx = stepOrder.indexOf(progress.currentStep);
|
|
486
648
|
const newIdx = stepOrder.indexOf(newStep);
|
|
487
|
-
// Only advance forward, don't go backwards
|
|
488
649
|
if (newIdx > currentIdx) {
|
|
489
|
-
// Mark all steps between current and new as completed
|
|
490
650
|
const completed = new Set(progress.completedSteps);
|
|
491
651
|
for (let i = 0; i <= currentIdx; i++) {
|
|
492
652
|
completed.add(stepOrder[i]);
|