@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.
@@ -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 { execSync } from 'node:child_process';
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('-s, --skill <skill>', 'Skill to activate (tdd, review)')
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('--lint <command>', 'Lint command to run after Claude completes (e.g., "npm run lint")')
30
- .option('--gate', 'Fail session if lint fails', false)
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
- // Check if it's a GitHub URL
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('Error: GitHub token required for URL. Set GITHUB_TOKEN or use --token.');
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(`Fetching issue #${parsed.number} from ${parsed.owner}/${parsed.repo}...`);
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(`Fetching issue #${parsed}...`);
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
- branchName = options.branch ?? `task-${issue}`;
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}. Implement the solution - do not just analyze.`;
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
- // Add skill if specified (template skill takes precedence)
287
+ // Build system prompt
221
288
  let systemPrompt;
222
289
  const effectiveSkill = template?.skill || options.skill;
223
- if (effectiveSkill === 'tdd') {
224
- systemPrompt = `You MUST follow strict TDD (Test-Driven Development):
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
- 1. RED: Write a failing test FIRST - never write implementation before tests
227
- 2. GREEN: Write MINIMUM code to pass the test - no extra features
228
- 3. REFACTOR: Clean up while keeping tests green
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
- Rules:
231
- - One test at a time
232
- - Commit after each phase: "test: ...", "feat: ...", "refactor: ..."
233
- - Run tests after every change
234
- - Create PR only when all tests pass`;
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('\nStarting Claude session...');
244
- if (effectiveSkill) {
245
- console.log(` Skill: ${effectiveSkill}`);
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(` \x1b[33mBudget limit: $${options.maxCost.toFixed(2)}\x1b[0m`);
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
- // Setup event listener for recording
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
- // Map Claude output type to event type
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', // Auto-approved for now
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 parse errors
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 file write errors
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
- console.log(`Session paused: ${session.id.slice(0, 8)}`);
326
- if (session.claudeSessionId) {
327
- console.log(`Resume with: claudetree resume ${session.id.slice(0, 8)}`);
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
- // Track cumulative cost from system events
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 session...`);
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
- // Skip to end if budget was exceeded
385
- if (budgetExceeded) {
386
- console.log('\nSession stopped due to budget limit.');
387
- }
388
- else {
389
- // Session completed
390
- session.status = 'completed';
391
- session.updatedAt = new Date();
392
- await sessionRepo.save(session);
393
- console.log('\nSession completed.');
394
- // Run lint gate
395
- if (options.lint) {
396
- console.log('\n\x1b[36m[Gate]\x1b[0m Running lint check...\n');
397
- console.log(` \x1b[33mLint:\x1b[0m ${options.lint}`);
398
- try {
399
- execSync(options.lint, { cwd: worktree.path, stdio: 'inherit' });
400
- console.log(' \x1b[32m✓ Lint passed\x1b[0m\n');
401
- }
402
- catch {
403
- console.log(' \x1b[31m✗ Lint failed\x1b[0m\n');
404
- if (options.gate) {
405
- console.log('\x1b[31m[Gate]\x1b[0m Session failed lint check.');
406
- session.status = 'failed';
407
- session.updatedAt = new Date();
408
- await sessionRepo.save(session);
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
- // Send Slack notification
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: Date.now() - session.createdAt.getTime(),
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]);