golem-cc 2.1.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/.claude/commands/golem/build.md +18 -0
  2. package/.claude/commands/golem/config.md +39 -0
  3. package/.claude/commands/golem/continue.md +73 -0
  4. package/.claude/commands/golem/doctor.md +46 -0
  5. package/.claude/commands/golem/document.md +138 -0
  6. package/.claude/commands/golem/help.md +58 -0
  7. package/.claude/commands/golem/pause.md +130 -0
  8. package/.claude/commands/golem/plan.md +111 -0
  9. package/.claude/commands/golem/review.md +166 -0
  10. package/.claude/commands/golem/security.md +186 -0
  11. package/.claude/commands/golem/simplify.md +76 -0
  12. package/.claude/commands/golem/spec.md +105 -0
  13. package/.claude/commands/golem/status.md +33 -0
  14. package/.golem/agents/code-simplifier.md +54 -0
  15. package/.golem/agents/review-architecture.md +59 -0
  16. package/.golem/agents/review-logic.md +50 -0
  17. package/.golem/agents/review-security.md +50 -0
  18. package/.golem/agents/review-style.md +48 -0
  19. package/.golem/agents/review-tests.md +48 -0
  20. package/.golem/agents/spec-builder.md +60 -0
  21. package/.golem/bin/golem.mjs +270 -0
  22. package/.golem/lib/build.mjs +557 -0
  23. package/.golem/lib/claude.mjs +95 -0
  24. package/.golem/lib/config.mjs +421 -0
  25. package/.golem/lib/display.mjs +191 -0
  26. package/.golem/lib/doctor.mjs +197 -0
  27. package/.golem/lib/document.mjs +792 -0
  28. package/.golem/lib/gates.mjs +78 -0
  29. package/.golem/lib/init.mjs +166 -0
  30. package/.golem/lib/output.mjs +40 -0
  31. package/.golem/lib/ratelimit.mjs +86 -0
  32. package/.golem/lib/security.mjs +603 -0
  33. package/.golem/lib/simplify.mjs +101 -0
  34. package/.golem/lib/tui.mjs +368 -0
  35. package/.golem/lib/usage.mjs +119 -0
  36. package/.golem/lib/worktree.mjs +509 -0
  37. package/.golem/prompts/build.md +23 -0
  38. package/.golem/prompts/document-inline.md +66 -0
  39. package/.golem/prompts/document-markdown.md +80 -0
  40. package/.golem/prompts/simplify.md +35 -0
  41. package/README.md +141 -142
  42. package/bin/golem-shim.mjs +36 -0
  43. package/bin/install.mjs +193 -0
  44. package/package.json +27 -32
  45. package/.env.example +0 -17
  46. package/bin/golem +0 -1040
  47. package/commands/golem/build.md +0 -235
  48. package/commands/golem/config.md +0 -55
  49. package/commands/golem/doctor.md +0 -137
  50. package/commands/golem/help.md +0 -212
  51. package/commands/golem/plan.md +0 -214
  52. package/commands/golem/review.md +0 -376
  53. package/commands/golem/security.md +0 -204
  54. package/commands/golem/simplify.md +0 -94
  55. package/commands/golem/spec.md +0 -226
  56. package/commands/golem/status.md +0 -60
  57. package/dist/api/freshworks.d.ts +0 -61
  58. package/dist/api/freshworks.d.ts.map +0 -1
  59. package/dist/api/freshworks.js +0 -119
  60. package/dist/api/freshworks.js.map +0 -1
  61. package/dist/api/gitea.d.ts +0 -96
  62. package/dist/api/gitea.d.ts.map +0 -1
  63. package/dist/api/gitea.js +0 -154
  64. package/dist/api/gitea.js.map +0 -1
  65. package/dist/cli/index.d.ts +0 -9
  66. package/dist/cli/index.d.ts.map +0 -1
  67. package/dist/cli/index.js +0 -352
  68. package/dist/cli/index.js.map +0 -1
  69. package/dist/sync/ticket-sync.d.ts +0 -53
  70. package/dist/sync/ticket-sync.d.ts.map +0 -1
  71. package/dist/sync/ticket-sync.js +0 -226
  72. package/dist/sync/ticket-sync.js.map +0 -1
  73. package/dist/types.d.ts +0 -125
  74. package/dist/types.d.ts.map +0 -1
  75. package/dist/types.js +0 -5
  76. package/dist/types.js.map +0 -1
  77. package/dist/worktree/manager.d.ts +0 -54
  78. package/dist/worktree/manager.d.ts.map +0 -1
  79. package/dist/worktree/manager.js +0 -190
  80. package/dist/worktree/manager.js.map +0 -1
  81. package/golem/agents/code-simplifier.md +0 -81
  82. package/golem/agents/spec-builder.md +0 -90
  83. package/golem/prompts/PROMPT_build.md +0 -71
  84. package/golem/prompts/PROMPT_plan.md +0 -102
@@ -0,0 +1,557 @@
1
+ import { readFile, writeFile, readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { execSync } from 'node:child_process';
4
+ import chalk from 'chalk';
5
+ import { header, success, fail, warn, info, spinner, table } from './output.mjs';
6
+ import { loadConfig } from './config.mjs';
7
+ import { runClaude, checkClaudeCli } from './claude.mjs';
8
+ import { attachDisplay } from './display.mjs';
9
+ import { createTui } from './tui.mjs';
10
+ import { getUsageData } from './usage.mjs';
11
+ import { runGates } from './gates.mjs';
12
+ import { runSecurityScan } from './security.mjs';
13
+ import { fetchUsage, formatResetTime } from './ratelimit.mjs';
14
+ import { runDocument } from './document.mjs';
15
+
16
+ const PLAN_PATH = join(process.cwd(), '.golem/IMPLEMENTATION_PLAN.md');
17
+
18
+ export function parseNextTask(planContent) {
19
+ const lines = planContent.split('\n');
20
+ let currentStage = '';
21
+ let stageCommit = '';
22
+
23
+ for (let i = 0; i < lines.length; i++) {
24
+ const line = lines[i];
25
+
26
+ if (/^## Stage \d+/.test(line)) {
27
+ currentStage = line.replace(/^## /, '');
28
+ }
29
+ if (/^Commit:/.test(line)) {
30
+ stageCommit = line.replace(/^Commit:\s*/, '');
31
+ }
32
+
33
+ const match = line.match(/^### \[ \] (\d+\.\d+)\.\s+(.*)/);
34
+ if (match) {
35
+ const taskId = match[1];
36
+ const taskTitle = match[2];
37
+
38
+ let details = '';
39
+ for (let j = i + 1; j < lines.length; j++) {
40
+ if (/^###\s|^##\s|^---/.test(lines[j])) break;
41
+ details += lines[j] + '\n';
42
+ }
43
+
44
+ const modelMatch = details.match(/^Model:\s*(\S+)/m);
45
+
46
+ return {
47
+ id: taskId,
48
+ title: taskTitle,
49
+ stage: currentStage,
50
+ commit: stageCommit,
51
+ details: details.trim(),
52
+ model: modelMatch ? modelMatch[1] : null,
53
+ lineIndex: i,
54
+ };
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+
60
+ function countTasks(planContent) {
61
+ const total = (planContent.match(/^### \[[ x]\]/gm) || []).length;
62
+ const done = (planContent.match(/^### \[x\]/gm) || []).length;
63
+ return { total, done };
64
+ }
65
+
66
+ function markTaskComplete(planContent, lineIndex) {
67
+ const lines = planContent.split('\n');
68
+ lines[lineIndex] = lines[lineIndex].replace('### [ ]', '### [x]');
69
+ return lines.join('\n');
70
+ }
71
+
72
+ export async function buildPrompt(task, config, retryContext) {
73
+ let prompt;
74
+ try {
75
+ prompt = await readFile(join(process.cwd(), '.golem/prompts/build.md'), 'utf-8');
76
+ } catch {
77
+ prompt = 'You are an autonomous coding agent. Complete the task below.';
78
+ }
79
+
80
+ const taskBlock = `
81
+ ## Current Task: ${task.id}. ${task.title}
82
+
83
+ Stage: ${task.stage}
84
+
85
+ ${task.details}
86
+ `;
87
+
88
+ return `${prompt}\n${taskBlock}${retryContext
89
+ ? formatGateErrors(retryContext.gateResults, retryContext.attempt, retryContext.maxRetries)
90
+ : ''}`;
91
+ }
92
+
93
+ function formatGateErrors(gateResult, attempt, maxRetries) {
94
+ let ctx = `\n## Previous Attempt Failed (attempt ${attempt} of ${maxRetries})\n\nThe following quality gates failed:\n`;
95
+ for (const r of gateResult.results) {
96
+ if (r.skipped) continue;
97
+ if (!r.passed) {
98
+ ctx += `\n### ${r.name} (exit code ${r.exitCode})\n\`\`\`\n${r.output}\n\`\`\`\n`;
99
+ }
100
+ }
101
+ ctx += '\nFix the issues above and try again. Do not repeat the same mistakes.\n';
102
+ return ctx;
103
+ }
104
+
105
+ function logGateResults(tui, gateResults) {
106
+ const maxNameLen = Math.max(...gateResults.map(r => r.name.length));
107
+
108
+ for (const r of gateResults) {
109
+ const dots = '.'.repeat(Math.max(1, maxNameLen - r.name.length + 3));
110
+ const durationStr = chalk.dim(` (${(r.duration / 1000).toFixed(1)}s)`);
111
+ let status;
112
+ if (r.skipped) {
113
+ status = chalk.dim('skipped');
114
+ } else if (r.passed) {
115
+ status = chalk.green('PASS') + durationStr;
116
+ } else {
117
+ status = chalk.red('FAIL') + durationStr;
118
+ }
119
+ tui.appendLog(`${chalk.white('[Gate]')} ${r.name} ${chalk.dim(dots)} ${status}`);
120
+
121
+ if (!r.passed && !r.skipped && r.output) {
122
+ for (const line of r.output.split('\n').filter(l => l.trim()).slice(0, 5)) {
123
+ tui.appendLog(chalk.dim(` ${line}`));
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ const SKIP_SIMPLIFY = [
130
+ /\.test\.\w+$/, /\.spec\.\w+$/, /\.config\.\w+$/, /\.d\.ts$/,
131
+ /lock\.\w+$/, /\.lock$/, /\.generated\./, /node_modules\//, /dist\//, /\.min\.\w+$/,
132
+ /^\.golem\//, /^\.claude\//,
133
+ ];
134
+
135
+ function getChangedFiles() {
136
+ try {
137
+ return execSync('git diff --name-only HEAD', gitOpts())
138
+ .toString().trim().split('\n').filter(Boolean);
139
+ } catch {
140
+ return execSync('git status --porcelain', gitOpts())
141
+ .toString().trim().split('\n')
142
+ .map(l => l.slice(3).trim()).filter(Boolean);
143
+ }
144
+ }
145
+
146
+ async function runSimplifyPass(files, config, tui) {
147
+ const targets = files.filter(f => !SKIP_SIMPLIFY.some(p => p.test(f)));
148
+ if (targets.length === 0) return;
149
+
150
+ tui.appendLog(chalk.cyan(`[Simplify] ${targets.length} file(s)`));
151
+
152
+ let prompt;
153
+ try {
154
+ prompt = await readFile(join(process.cwd(), '.golem/prompts/simplify.md'), 'utf-8');
155
+ } catch {
156
+ prompt = 'Simplify the given files. Run tests after each change. Revert on failure.';
157
+ }
158
+
159
+ const fileList = targets.map(f => `- ${f}`).join('\n');
160
+ const fullPrompt = prompt.replace('{{FILES}}', fileList) + '\n\n## Test Command\n\n`node --test`\n';
161
+
162
+ const emitter = runClaude(fullPrompt, { model: 'sonnet' });
163
+ attachDisplay(emitter, { tui });
164
+
165
+ await new Promise((resolve) => {
166
+ emitter.on('close', resolve);
167
+ emitter.on('error', resolve);
168
+ });
169
+ }
170
+
171
+ async function runSecurityCheck(tui) {
172
+ tui.appendLog(chalk.cyan('[Security] Running scan...'));
173
+ const { verdict, findings } = await runSecurityScan();
174
+
175
+ const critical = findings.filter(f => f.severity === 'CRITICAL');
176
+ const high = findings.filter(f => f.severity === 'HIGH');
177
+
178
+ if (verdict === 'PASS') {
179
+ tui.appendLog(chalk.green('[Security] PASS — no issues'));
180
+ } else if (critical.length > 0 || high.length > 0) {
181
+ tui.appendLog(chalk.red(`[Security] FAIL — ${critical.length} critical, ${high.length} high`));
182
+ for (const f of [...critical, ...high].slice(0, 5)) {
183
+ tui.appendLog(chalk.dim(` ${f.severity}: ${f.message.slice(0, 60)} (${f.file})`));
184
+ }
185
+ } else {
186
+ tui.appendLog(chalk.yellow(`[Security] PARTIAL — ${findings.length} finding(s), none critical`));
187
+ }
188
+
189
+ return { verdict, findings, critical, high };
190
+ }
191
+
192
+ function gitOpts() {
193
+ return { cwd: process.cwd(), stdio: 'pipe' };
194
+ }
195
+
196
+ export function gitSnapshot() {
197
+ return execSync('git rev-parse HEAD', gitOpts()).toString().trim();
198
+ }
199
+
200
+ export function gitRestore(sha) {
201
+ execSync('git checkout .', gitOpts());
202
+ execSync('git clean -fd', gitOpts());
203
+ execSync(`git reset --hard ${sha}`, gitOpts());
204
+ }
205
+
206
+ export function gitHasChanges() {
207
+ return execSync('git status --porcelain', gitOpts()).toString().length > 0;
208
+ }
209
+
210
+ function gitDiffStats() {
211
+ try {
212
+ const out = execSync('git diff --cached --stat', gitOpts()).toString();
213
+ const lines = out.trim().split('\n');
214
+ const summary = lines[lines.length - 1] || '';
215
+ const extract = (pattern) => {
216
+ const match = summary.match(pattern);
217
+ return match ? parseInt(match[1]) : 0;
218
+ };
219
+ return {
220
+ files: extract(/(\d+) files? changed/),
221
+ ins: extract(/(\d+) insertions?/),
222
+ del: extract(/(\d+) deletions?/),
223
+ };
224
+ } catch {
225
+ return { files: 0, ins: 0, del: 0 };
226
+ }
227
+ }
228
+
229
+ function gitCommit(message) {
230
+ try {
231
+ execSync('git add -A', gitOpts());
232
+ const stats = gitDiffStats();
233
+ execSync(`git commit -m "${message}"`, gitOpts());
234
+ const hash = execSync('git rev-parse --short HEAD', gitOpts()).toString().trim();
235
+ return { hash, ...stats };
236
+ } catch {
237
+ return null;
238
+ }
239
+ }
240
+
241
+ export async function runBuild(opts = {}) {
242
+ if (!checkClaudeCli()) {
243
+ fail('Claude CLI not found. Install it from https://docs.anthropic.com/en/docs/claude-code');
244
+ process.exit(1);
245
+ }
246
+
247
+ try {
248
+ const specsDir = join(process.cwd(), '.golem/specs');
249
+ const specs = (await readdir(specsDir)).filter(f => f.endsWith('.md'));
250
+ if (specs.length === 0) {
251
+ warn('No spec files found in .golem/specs/. Run /golem:spec first to create specs.');
252
+ }
253
+ } catch {
254
+ warn('No .golem/specs/ directory found. Run golem init first.');
255
+ }
256
+
257
+ const config = await loadConfig();
258
+ const maxRetries = opts.retry === false ? 1 : (opts.retries || config.maxRetries || 3);
259
+ const maxTasks = opts.tasks || Infinity;
260
+ let tasksProcessed = 0;
261
+ let claudeCalls = 0;
262
+ let stopping = false;
263
+ let buildSucceeded = false;
264
+ const summaryRows = [];
265
+ let tui = null;
266
+
267
+ const onSigint = () => {
268
+ if (stopping) {
269
+ if (tui) tui.destroy();
270
+ warn('Force quitting...');
271
+ process.exit(1);
272
+ }
273
+ stopping = true;
274
+ if (tui) {
275
+ tui.appendLog('\nStopping after current task... (Ctrl+C again to force quit)');
276
+ } else {
277
+ warn('Stopping after current task... (Ctrl+C again to force quit)');
278
+ }
279
+ };
280
+ process.on('SIGINT', onSigint);
281
+
282
+ if (opts.dryRun) {
283
+ header('Golem Build Loop');
284
+
285
+ let plan;
286
+ try {
287
+ plan = await readFile(PLAN_PATH, 'utf-8');
288
+ } catch {
289
+ fail('No implementation plan found. Run /golem:plan first.');
290
+ process.exit(1);
291
+ }
292
+
293
+ const { total, done } = countTasks(plan);
294
+ const task = parseNextTask(plan);
295
+
296
+ if (!task) {
297
+ success(`All ${total} tasks complete!`);
298
+ return;
299
+ }
300
+
301
+ header(`Task ${task.id}: ${task.title}`);
302
+ info(`Progress: ${done}/${total} tasks (${Math.round(done / total * 100)}%)`);
303
+ info('Dry run — showing next task:');
304
+ console.log();
305
+ console.log(task.details);
306
+ return;
307
+ }
308
+
309
+ let usageData;
310
+ try {
311
+ usageData = await getUsageData();
312
+ } catch {
313
+ usageData = null;
314
+ }
315
+
316
+ let plan;
317
+ try {
318
+ plan = await readFile(PLAN_PATH, 'utf-8');
319
+ } catch {
320
+ fail('No implementation plan found. Run /golem:plan first.');
321
+ process.exit(1);
322
+ }
323
+
324
+ const initialCounts = countTasks(plan);
325
+ const firstTask = parseNextTask(plan);
326
+
327
+ if (!firstTask) {
328
+ success(`All ${initialCounts.total} tasks complete!`);
329
+ return;
330
+ }
331
+
332
+ tui = createTui();
333
+ tui.init({
334
+ taskId: firstTask.id,
335
+ taskTitle: firstTask.title,
336
+ stage: firstTask.stage,
337
+ done: initialCounts.done,
338
+ total: initialCounts.total,
339
+ }, usageData);
340
+
341
+ try {
342
+ while (tasksProcessed < maxTasks && !stopping) {
343
+ plan = await readFile(PLAN_PATH, 'utf-8');
344
+ const { total, done } = countTasks(plan);
345
+ const task = parseNextTask(plan);
346
+
347
+ if (!task) {
348
+ tui.appendLog(`\nAll ${total} tasks complete!`);
349
+ buildSucceeded = true;
350
+ break;
351
+ }
352
+
353
+ const snapshot = gitSnapshot();
354
+ tasksProcessed++;
355
+
356
+ tui.updateTask({
357
+ taskId: task.id,
358
+ taskTitle: task.title,
359
+ stage: task.stage,
360
+ done,
361
+ total,
362
+ });
363
+
364
+ const taskModel = task.model || config.model;
365
+ tui.appendLog(`\n--- Task ${tasksProcessed} — ${task.id}: ${task.title} ---`);
366
+ tui.appendLog(chalk.dim(`[Model] ${taskModel} (fallback: sonnet)`));
367
+ tui.setTaskModel(taskModel);
368
+
369
+ // Pre-task rate limit check (informational — fallback is automatic)
370
+ const usage = await fetchUsage();
371
+ if (usage) {
372
+ tui.updateRateLimit(usage);
373
+
374
+ if (usage.fiveHour.utilization >= config.rateLimitWarnThreshold) {
375
+ const resetTime = formatResetTime(usage.fiveHour.resetsAt);
376
+ tui.appendLog(chalk.yellow(`[Rate Limit] 5h at ${Math.round(usage.fiveHour.utilization)}% — resets ${resetTime}, will fallback to sonnet`));
377
+ }
378
+ }
379
+
380
+ let passed = false;
381
+ let lastGateOutput = null;
382
+ let lastDisplay = null;
383
+ const attemptHistory = [];
384
+
385
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
386
+ if (stopping) break;
387
+
388
+ tui.resetTimer();
389
+ tui.updateTask({ attempt, maxAttempts: maxRetries });
390
+
391
+ if (attempt > 1) {
392
+ gitRestore(snapshot);
393
+ tui.appendLog(chalk.dim(`\n── Retrying task ${task.id} (attempt ${attempt}/${maxRetries}) ──`));
394
+ }
395
+
396
+ const retryContext = (attempt > 1 && lastGateOutput)
397
+ ? { attempt: attempt - 1, maxRetries, gateResults: lastGateOutput }
398
+ : null;
399
+ const prompt = await buildPrompt(task, config, retryContext);
400
+
401
+ claudeCalls++;
402
+ tui.updateTask({ claudeCalls });
403
+ const fallback = taskModel === 'sonnet' ? undefined : 'sonnet';
404
+ const emitter = runClaude(prompt, { model: taskModel, fallbackModel: fallback });
405
+ lastDisplay = attachDisplay(emitter, { tui });
406
+
407
+ const result = await new Promise((resolve) => {
408
+ let resultEvent = null;
409
+ emitter.on('result', (event) => { resultEvent = event; });
410
+ emitter.on('close', () => { resolve(resultEvent); });
411
+ emitter.on('error', (err) => {
412
+ tui.appendLog(`Error: ${err.message}`);
413
+ resolve(null);
414
+ });
415
+ });
416
+
417
+ if (!result || result.is_error) {
418
+ tui.appendLog(`Task ${task.id} — Claude error (attempt ${attempt}/${maxRetries})`);
419
+ break;
420
+ }
421
+
422
+ if (opts.skipGates) {
423
+ passed = true;
424
+ break;
425
+ }
426
+
427
+ const gateResult = await runGates(config);
428
+ if (gateResult.results.length > 0) {
429
+ logGateResults(tui, gateResult.results);
430
+ }
431
+
432
+ if (gateResult.passed) {
433
+ passed = true;
434
+ break;
435
+ }
436
+
437
+ lastGateOutput = gateResult;
438
+ attemptHistory.push({
439
+ attempt,
440
+ failedGate: gateResult.failedGate,
441
+ summary: `${gateResult.failedGate} failed`,
442
+ });
443
+ }
444
+
445
+ const elapsed = (tui.getCumulativeElapsed() / 1000).toFixed(1);
446
+
447
+ if (passed) {
448
+ const finalAttempt = attemptHistory.length + 1;
449
+ if (finalAttempt > 1) {
450
+ tui.appendLog(chalk.green(`Task ${task.id} PASSED on attempt ${finalAttempt}/${maxRetries}`));
451
+ }
452
+
453
+ if (config.simplifyOnBuild && !opts.skipGates) {
454
+ const changedFiles = getChangedFiles();
455
+ if (changedFiles.length > 0) {
456
+ claudeCalls++;
457
+ tui.updateTask({ claudeCalls });
458
+ const preSimplifySnapshot = gitSnapshot();
459
+ await runSimplifyPass(changedFiles, config, tui);
460
+
461
+ const postSimplifyGates = await runGates(config);
462
+ if (!postSimplifyGates.passed) {
463
+ tui.appendLog(chalk.yellow('[Simplify] Broke tests — reverting simplification'));
464
+ gitRestore(preSimplifySnapshot);
465
+ }
466
+ }
467
+ }
468
+
469
+ if (!opts.skipGates) {
470
+ const sec = await runSecurityCheck(tui);
471
+ if (sec.critical.length > 0 || sec.high.length > 0) {
472
+ tui.appendLog(chalk.red(`[Security] Blocking commit — ${sec.critical.length + sec.high.length} critical/high issue(s)`));
473
+ gitRestore(snapshot);
474
+ summaryRows.push([task.id, task.title.slice(0, 35), `${elapsed}s`, 'SECURITY', '-']);
475
+ buildSucceeded = false;
476
+ break; // Hard stop
477
+ }
478
+ }
479
+
480
+ plan = await readFile(PLAN_PATH, 'utf-8');
481
+ const updatedPlan = markTaskComplete(plan, task.lineIndex);
482
+ await writeFile(PLAN_PATH, updatedPlan);
483
+ tui.appendLog(`Task ${task.id} marked complete`);
484
+
485
+ let commitInfo = null;
486
+ if (config.autoCommit) {
487
+ const commitMsg = task.commit || `feat: complete task ${task.id} — ${task.title}`;
488
+ commitInfo = gitCommit(commitMsg);
489
+ if (commitInfo) {
490
+ tui.appendLog(`Committed: ${commitInfo.hash} ${commitMsg}`);
491
+ } else {
492
+ tui.appendLog('No changes to commit');
493
+ }
494
+ }
495
+
496
+ const changes = commitInfo
497
+ ? `${commitInfo.files}f +${commitInfo.ins} -${commitInfo.del}`
498
+ : lastDisplay?.stats.filesModified.length
499
+ ? `${lastDisplay.stats.filesModified.length} files`
500
+ : '-';
501
+ const commit = commitInfo ? commitInfo.hash : '-';
502
+
503
+ summaryRows.push([
504
+ task.id,
505
+ task.title.slice(0, 35),
506
+ `${elapsed}s`,
507
+ changes,
508
+ commit,
509
+ ]);
510
+
511
+ try {
512
+ usageData = await getUsageData();
513
+ tui.updateUsage(usageData);
514
+ } catch {}
515
+
516
+ const newCounts = countTasks(await readFile(PLAN_PATH, 'utf-8'));
517
+ tui.updateTask({ done: newCounts.done });
518
+ buildSucceeded = true;
519
+ } else {
520
+ gitRestore(snapshot);
521
+ tui.appendLog(chalk.red(`\nTask ${task.id} FAILED after ${maxRetries} attempts`));
522
+ for (const h of attemptHistory) {
523
+ tui.appendLog(chalk.dim(` Attempt ${h.attempt}: ${h.summary}`));
524
+ }
525
+ summaryRows.push([
526
+ task.id,
527
+ task.title.slice(0, 35),
528
+ `${elapsed}s`,
529
+ 'FAILED',
530
+ '-',
531
+ ]);
532
+ buildSucceeded = false;
533
+ break; // Hard stop
534
+ }
535
+ }
536
+
537
+ // Post-build documentation pass
538
+ if (buildSucceeded && config.documentOnBuild) {
539
+ tui.appendLog(chalk.cyan('\n[Document] Running post-build documentation pass...'));
540
+ try {
541
+ await runDocument({ model: config.model });
542
+ tui.appendLog(chalk.green('[Document] Documentation pass complete'));
543
+ } catch (err) {
544
+ tui.appendLog(chalk.yellow(`[Document] Documentation pass failed: ${err.message}`));
545
+ }
546
+ }
547
+ } finally {
548
+ if (tui) tui.destroy();
549
+ process.removeListener('SIGINT', onSigint);
550
+ }
551
+
552
+ if (summaryRows.length > 0) {
553
+ console.log();
554
+ header('Build Summary');
555
+ table(['Task', 'Title', 'Time', 'Changes', 'Commit'], summaryRows);
556
+ }
557
+ }
@@ -0,0 +1,95 @@
1
+ import { spawn, execSync } from 'node:child_process';
2
+ import { EventEmitter } from 'node:events';
3
+
4
+ /**
5
+ * Check if the claude CLI is installed and accessible.
6
+ * Returns the version string if found, null otherwise.
7
+ */
8
+ export function checkClaudeCli() {
9
+ try {
10
+ return execSync('claude --version', { stdio: 'pipe', encoding: 'utf-8' }).trim();
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Run claude -p with stream-json output.
18
+ * Returns an EventEmitter that emits:
19
+ * 'init' - { session_id, model, tools }
20
+ * 'assistant' - { message } (content blocks: text, tool_use, tool_result)
21
+ * 'result' - { result, duration_ms, total_cost_usd, usage, is_error }
22
+ * 'error' - Error
23
+ * 'close' - { code }
24
+ */
25
+ export function runClaude(prompt, opts = {}) {
26
+ const emitter = new EventEmitter();
27
+ const args = ['-p', '--output-format', 'stream-json'];
28
+
29
+ if (opts.model) {
30
+ args.push('--model', opts.model);
31
+ }
32
+ if (opts.fallbackModel) {
33
+ args.push('--fallback-model', opts.fallbackModel);
34
+ }
35
+ if (opts.allowedTools) {
36
+ for (const tool of opts.allowedTools) {
37
+ args.push('--allowedTools', tool);
38
+ }
39
+ }
40
+
41
+ const proc = spawn('claude', args, {
42
+ stdio: ['pipe', 'pipe', 'pipe'],
43
+ cwd: opts.cwd || process.cwd(),
44
+ });
45
+
46
+ let stderrBuf = '';
47
+
48
+ proc.stdout.on('data', (chunk) => {
49
+ const lines = chunk.toString().split('\n').filter(Boolean);
50
+ for (const line of lines) {
51
+ try {
52
+ const event = JSON.parse(line);
53
+ switch (event.type) {
54
+ case 'system':
55
+ emitter.emit('init', event);
56
+ break;
57
+ case 'assistant':
58
+ emitter.emit('assistant', event);
59
+ break;
60
+ case 'result':
61
+ emitter.emit('result', event);
62
+ break;
63
+ default:
64
+ emitter.emit(event.type, event);
65
+ }
66
+ } catch {
67
+ // partial JSON line, ignore
68
+ }
69
+ }
70
+ });
71
+
72
+ proc.stderr.on('data', (chunk) => {
73
+ stderrBuf += chunk.toString();
74
+ });
75
+
76
+ proc.on('close', (code) => {
77
+ if (code !== 0 && stderrBuf) {
78
+ emitter.emit('error', new Error(stderrBuf.trim()));
79
+ }
80
+ emitter.emit('close', { code });
81
+ });
82
+
83
+ proc.on('error', (err) => {
84
+ emitter.emit('error', err);
85
+ });
86
+
87
+ // Write prompt to stdin and close
88
+ proc.stdin.write(prompt);
89
+ proc.stdin.end();
90
+
91
+ // Expose process for kill/signal handling
92
+ emitter.process = proc;
93
+
94
+ return emitter;
95
+ }