rafcode 1.1.2 → 1.3.2

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 (57) hide show
  1. package/RAF/018-workflow-forge/decisions.md +13 -0
  2. package/RAF/018-workflow-forge/input.md +2 -0
  3. package/RAF/018-workflow-forge/outcomes/001-add-task-number-progress.md +61 -0
  4. package/RAF/018-workflow-forge/outcomes/002-update-plan-do-prompts.md +62 -0
  5. package/RAF/018-workflow-forge/plans/001-add-task-number-progress.md +30 -0
  6. package/RAF/018-workflow-forge/plans/002-update-plan-do-prompts.md +34 -0
  7. package/RAF/019-verbose-chronicle/decisions.md +25 -0
  8. package/RAF/019-verbose-chronicle/input.md +3 -0
  9. package/RAF/019-verbose-chronicle/outcomes/001-amend-iteration-references.md +25 -0
  10. package/RAF/019-verbose-chronicle/outcomes/002-verbose-task-name-display.md +31 -0
  11. package/RAF/019-verbose-chronicle/outcomes/003-verbose-streaming-fix.md +48 -0
  12. package/RAF/019-verbose-chronicle/outcomes/004-commit-verification-before-halt.md +56 -0
  13. package/RAF/019-verbose-chronicle/plans/001-amend-iteration-references.md +35 -0
  14. package/RAF/019-verbose-chronicle/plans/002-verbose-task-name-display.md +38 -0
  15. package/RAF/019-verbose-chronicle/plans/003-verbose-streaming-fix.md +45 -0
  16. package/RAF/019-verbose-chronicle/plans/004-commit-verification-before-halt.md +62 -0
  17. package/dist/commands/do.js +24 -15
  18. package/dist/commands/do.js.map +1 -1
  19. package/dist/core/claude-runner.d.ts +52 -1
  20. package/dist/core/claude-runner.d.ts.map +1 -1
  21. package/dist/core/claude-runner.js +195 -17
  22. package/dist/core/claude-runner.js.map +1 -1
  23. package/dist/core/git.d.ts +15 -0
  24. package/dist/core/git.d.ts.map +1 -1
  25. package/dist/core/git.js +44 -0
  26. package/dist/core/git.js.map +1 -1
  27. package/dist/parsers/stream-renderer.d.ts +42 -0
  28. package/dist/parsers/stream-renderer.d.ts.map +1 -0
  29. package/dist/parsers/stream-renderer.js +100 -0
  30. package/dist/parsers/stream-renderer.js.map +1 -0
  31. package/dist/prompts/amend.d.ts.map +1 -1
  32. package/dist/prompts/amend.js +27 -3
  33. package/dist/prompts/amend.js.map +1 -1
  34. package/dist/prompts/execution.d.ts.map +1 -1
  35. package/dist/prompts/execution.js +1 -2
  36. package/dist/prompts/execution.js.map +1 -1
  37. package/dist/prompts/planning.d.ts.map +1 -1
  38. package/dist/prompts/planning.js +16 -3
  39. package/dist/prompts/planning.js.map +1 -1
  40. package/dist/utils/terminal-symbols.d.ts +3 -2
  41. package/dist/utils/terminal-symbols.d.ts.map +1 -1
  42. package/dist/utils/terminal-symbols.js +6 -4
  43. package/dist/utils/terminal-symbols.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/commands/do.ts +25 -15
  46. package/src/core/claude-runner.ts +270 -17
  47. package/src/core/git.ts +44 -0
  48. package/src/parsers/stream-renderer.ts +139 -0
  49. package/src/prompts/amend.ts +28 -3
  50. package/src/prompts/execution.ts +1 -2
  51. package/src/prompts/planning.ts +16 -3
  52. package/src/utils/terminal-symbols.ts +7 -4
  53. package/tests/unit/claude-runner.test.ts +567 -1
  54. package/tests/unit/git-commit-helpers.test.ts +103 -0
  55. package/tests/unit/plan-command.test.ts +51 -0
  56. package/tests/unit/stream-renderer.test.ts +286 -0
  57. package/tests/unit/terminal-symbols.test.ts +20 -0
@@ -3,7 +3,7 @@ import { Command } from 'commander';
3
3
  import { ProjectManager } from '../core/project-manager.js';
4
4
  import { ClaudeRunner } from '../core/claude-runner.js';
5
5
  import { shutdownHandler } from '../core/shutdown-handler.js';
6
- import { stashChanges, hasUncommittedChanges } from '../core/git.js';
6
+ import { stashChanges, hasUncommittedChanges, isGitRepo, getHeadCommitHash } from '../core/git.js';
7
7
  import { getExecutionPrompt } from '../prompts/execution.js';
8
8
  import { parseOutput, isRetryableFailure } from '../parsers/output-parser.js';
9
9
  import { validatePlansExist, resolveModelOption } from '../utils/validation.js';
@@ -445,6 +445,8 @@ async function executeSingleProject(
445
445
  const taskNumber = taskIndex + 1;
446
446
  const taskName = extractTaskNameFromPlanFile(task.planFile);
447
447
  const displayName = taskName ?? task.id;
448
+ const taskId = task.id; // Capture for closure
449
+ const taskLabel = displayName !== task.id ? `${task.id} (${displayName})` : task.id;
448
450
 
449
451
  // Handle blocked tasks separately - skip Claude execution
450
452
  if (task.status === 'blocked') {
@@ -458,10 +460,10 @@ async function executeSingleProject(
458
460
  if (verbose) {
459
461
  const taskContext = `[Task ${taskNumber}/${totalTasks}: ${displayName}]`;
460
462
  logger.setContext(taskContext);
461
- logger.warn(`Task ${task.id} blocked by failed dependency: ${blockingDep}`);
463
+ logger.warn(`Task ${taskLabel} blocked by failed dependency: ${blockingDep}`);
462
464
  } else {
463
465
  // Minimal mode: show blocked task line with distinct symbol
464
- logger.info(formatTaskProgress(taskNumber, totalTasks, 'blocked', displayName));
466
+ logger.info(formatTaskProgress(taskNumber, totalTasks, 'blocked', displayName, undefined, task.id));
465
467
  }
466
468
 
467
469
  // Generate blocked outcome file
@@ -483,11 +485,11 @@ async function executeSingleProject(
483
485
 
484
486
  // Log task execution status
485
487
  if (task.status === 'failed') {
486
- logger.info(`Retrying task ${task.id} (previously failed)...`);
488
+ logger.info(`Retrying task ${taskLabel} (previously failed)...`);
487
489
  } else if (task.status === 'completed' && force) {
488
- logger.info(`Re-running task ${task.id} (force mode)...`);
490
+ logger.info(`Re-running task ${taskLabel} (force mode)...`);
489
491
  } else {
490
- logger.info(`Executing task ${task.id}...`);
492
+ logger.info(`Executing task ${taskLabel}...`);
491
493
  }
492
494
  }
493
495
 
@@ -518,7 +520,7 @@ async function executeSingleProject(
518
520
  const statusLine = createStatusLine();
519
521
  const timer = createTaskTimer(verbose ? undefined : (elapsed) => {
520
522
  // Show running status with task name and timer (updates in place)
521
- statusLine.update(formatTaskProgress(taskNumber, totalTasks, 'running', displayName, elapsed));
523
+ statusLine.update(formatTaskProgress(taskNumber, totalTasks, 'running', displayName, elapsed, taskId));
522
524
  });
523
525
  timer.start();
524
526
 
@@ -526,7 +528,7 @@ async function executeSingleProject(
526
528
  attempts++;
527
529
 
528
530
  if (verbose && attempts > 1) {
529
- logger.info(` Retry ${attempts}/${maxRetries}...`);
531
+ logger.info(` Retry ${attempts}/${maxRetries} for task ${taskLabel}...`);
530
532
  }
531
533
 
532
534
  // Build execution prompt (inside loop to include retry context on retries)
@@ -551,10 +553,18 @@ async function executeSingleProject(
551
553
  dependencyOutcomes,
552
554
  });
553
555
 
556
+ // Capture HEAD hash before execution for commit verification
557
+ const preExecutionHead = isGitRepo() ? getHeadCommitHash() : null;
558
+ const commitContext = preExecutionHead ? {
559
+ preExecutionHead,
560
+ expectedPrefix: `RAF[${projectNumber}:${task.id}]`,
561
+ outcomeFilePath,
562
+ } : undefined;
563
+
554
564
  // Run Claude
555
565
  const result = verbose
556
- ? await claudeRunner.runVerbose(prompt, { timeout })
557
- : await claudeRunner.run(prompt, { timeout });
566
+ ? await claudeRunner.runVerbose(prompt, { timeout, outcomeFilePath, commitContext })
567
+ : await claudeRunner.run(prompt, { timeout, outcomeFilePath, commitContext });
558
568
 
559
569
  lastOutput = result.output;
560
570
 
@@ -666,10 +676,10 @@ Task completed. No detailed report provided.
666
676
  projectManager.saveOutcome(projectPath, task.id, outcomeContent);
667
677
 
668
678
  if (verbose) {
669
- logger.success(` Task ${task.id} completed (${elapsedFormatted})`);
679
+ logger.success(` Task ${taskLabel} completed (${elapsedFormatted})`);
670
680
  } else {
671
681
  // Minimal mode: show completed task line
672
- logger.info(formatTaskProgress(taskNumber, totalTasks, 'completed', displayName, elapsedMs));
682
+ logger.info(formatTaskProgress(taskNumber, totalTasks, 'completed', displayName, elapsedMs, task.id));
673
683
  }
674
684
  completedInSession.add(task.id);
675
685
  } else {
@@ -680,16 +690,16 @@ Task completed. No detailed report provided.
680
690
  stashName = `raf-${projectNum}-task-${task.id}-failed`;
681
691
  const stashed = stashChanges(stashName);
682
692
  if (verbose && stashed) {
683
- logger.info(` Changes stashed as: ${stashName}`);
693
+ logger.info(` Changes for task ${taskLabel} stashed as: ${stashName}`);
684
694
  }
685
695
  }
686
696
 
687
697
  if (verbose) {
688
- logger.error(` Task ${task.id} failed: ${failureReason} (${elapsedFormatted})`);
698
+ logger.error(` Task ${taskLabel} failed: ${failureReason} (${elapsedFormatted})`);
689
699
  logger.info(' Analyzing failure...');
690
700
  } else {
691
701
  // Minimal mode: show failed task line
692
- logger.info(formatTaskProgress(taskNumber, totalTasks, 'failed', displayName, elapsedMs));
702
+ logger.info(formatTaskProgress(taskNumber, totalTasks, 'failed', displayName, elapsedMs, task.id));
693
703
  }
694
704
 
695
705
  // Analyze failure and generate structured report
@@ -1,7 +1,10 @@
1
+ import * as fs from 'node:fs';
1
2
  import * as pty from 'node-pty';
2
3
  import type { IDisposable } from 'node-pty';
3
4
  import { execSync, spawn } from 'node:child_process';
4
5
  import { logger } from '../utils/logger.js';
6
+ import { renderStreamEvent } from '../parsers/stream-renderer.js';
7
+ import { getHeadCommitHash, getHeadCommitMessage, isFileCommittedInHead } from './git.js';
5
8
 
6
9
  function getClaudePath(): string {
7
10
  try {
@@ -26,6 +29,27 @@ export interface ClaudeRunnerOptions {
26
29
  * Claude will still ask planning interview questions.
27
30
  */
28
31
  dangerouslySkipPermissions?: boolean;
32
+ /**
33
+ * Path to the outcome file. When provided, enables completion detection:
34
+ * - Monitors stdout for completion markers (<promise>COMPLETE/FAILED</promise>)
35
+ * - Polls the outcome file for completion markers
36
+ * When detected, starts a grace period before terminating the process,
37
+ * allowing time for git commit operations to complete.
38
+ */
39
+ outcomeFilePath?: string;
40
+ /**
41
+ * Commit verification context. When provided, the grace period will verify
42
+ * that the expected git commit has been made before terminating.
43
+ * Only applies when a COMPLETE marker is detected (not FAILED).
44
+ */
45
+ commitContext?: {
46
+ /** HEAD commit hash recorded before task execution began. */
47
+ preExecutionHead: string;
48
+ /** Expected commit message prefix (e.g., "RAF[005:001]"). */
49
+ expectedPrefix: string;
50
+ /** Path to the outcome file that should be committed. */
51
+ outcomeFilePath: string;
52
+ };
29
53
  }
30
54
 
31
55
  export interface ClaudeRunnerConfig {
@@ -50,6 +74,180 @@ const CONTEXT_OVERFLOW_PATTERNS = [
50
74
  /context window/i,
51
75
  ];
52
76
 
77
+ const COMPLETION_MARKER_PATTERN = /<promise>(COMPLETE|FAILED)<\/promise>/i;
78
+
79
+ /**
80
+ * Grace period in ms after completion marker is detected before terminating.
81
+ * Allows time for git commit operations to complete.
82
+ */
83
+ export const COMPLETION_GRACE_PERIOD_MS = 60_000;
84
+
85
+ /**
86
+ * Hard maximum grace period in ms. If the commit hasn't landed by this point,
87
+ * the process is killed regardless.
88
+ */
89
+ export const COMPLETION_HARD_MAX_MS = 180_000;
90
+
91
+ /**
92
+ * Interval in ms for polling commit verification after the initial grace period expires.
93
+ */
94
+ export const COMMIT_POLL_INTERVAL_MS = 10_000;
95
+
96
+ /**
97
+ * Interval in ms for polling the outcome file for completion markers.
98
+ */
99
+ export const OUTCOME_POLL_INTERVAL_MS = 5_000;
100
+
101
+ /**
102
+ * Context for commit verification during grace period.
103
+ */
104
+ export interface CommitContext {
105
+ /** HEAD commit hash recorded before task execution began. */
106
+ preExecutionHead: string;
107
+ /** Expected commit message prefix (e.g., "RAF[005:001]"). */
108
+ expectedPrefix: string;
109
+ /** Path to the outcome file that should be committed. */
110
+ outcomeFilePath: string;
111
+ }
112
+
113
+ /**
114
+ * Monitors for task completion markers in stdout and outcome files.
115
+ * When a marker is detected, starts a grace period before killing the process.
116
+ */
117
+ interface CompletionDetector {
118
+ /** Check accumulated stdout output for completion markers. */
119
+ checkOutput(output: string): void;
120
+ /** Clean up all timers. Must be called when the process exits. */
121
+ cleanup(): void;
122
+ }
123
+
124
+ const COMPLETE_MARKER_PATTERN = /<promise>COMPLETE<\/promise>/i;
125
+
126
+ /**
127
+ * Verify that the expected commit has been made.
128
+ * Checks: HEAD changed, commit message matches prefix, outcome file is committed.
129
+ */
130
+ function verifyCommit(commitContext: CommitContext): boolean {
131
+ const currentHead = getHeadCommitHash();
132
+ if (!currentHead || currentHead === commitContext.preExecutionHead) {
133
+ return false;
134
+ }
135
+
136
+ const message = getHeadCommitMessage();
137
+ if (!message || !message.startsWith(commitContext.expectedPrefix)) {
138
+ return false;
139
+ }
140
+
141
+ if (!isFileCommittedInHead(commitContext.outcomeFilePath)) {
142
+ return false;
143
+ }
144
+
145
+ return true;
146
+ }
147
+
148
+ function createCompletionDetector(
149
+ killFn: () => void,
150
+ outcomeFilePath?: string,
151
+ commitContext?: CommitContext,
152
+ ): CompletionDetector {
153
+ let graceHandle: ReturnType<typeof setTimeout> | null = null;
154
+ let commitPollHandle: ReturnType<typeof setInterval> | null = null;
155
+ let hardMaxHandle: ReturnType<typeof setTimeout> | null = null;
156
+ let pollHandle: ReturnType<typeof setInterval> | null = null;
157
+ let initialMtime = 0;
158
+ let detectedMarkerIsComplete = false;
159
+
160
+ // Record initial mtime of outcome file to avoid false positives from previous runs
161
+ if (outcomeFilePath) {
162
+ try {
163
+ if (fs.existsSync(outcomeFilePath)) {
164
+ initialMtime = fs.statSync(outcomeFilePath).mtimeMs;
165
+ }
166
+ } catch {
167
+ // Ignore stat errors
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Called when the initial grace period expires.
173
+ * If commit verification is needed and the commit hasn't landed yet,
174
+ * start polling for the commit up to the hard maximum.
175
+ */
176
+ function onGracePeriodExpired(): void {
177
+ if (commitContext && detectedMarkerIsComplete) {
178
+ // Check if commit already landed
179
+ if (verifyCommit(commitContext)) {
180
+ logger.debug('Grace period expired - commit verified, terminating Claude process');
181
+ killFn();
182
+ return;
183
+ }
184
+
185
+ // Commit not found yet - extend with polling
186
+ logger.debug('Grace period expired but commit not verified - extending with polling');
187
+ const remainingMs = COMPLETION_HARD_MAX_MS - COMPLETION_GRACE_PERIOD_MS;
188
+
189
+ hardMaxHandle = setTimeout(() => {
190
+ logger.warn('Hard maximum grace period reached without commit verification - terminating Claude process');
191
+ if (commitPollHandle) clearInterval(commitPollHandle);
192
+ killFn();
193
+ }, remainingMs);
194
+
195
+ commitPollHandle = setInterval(() => {
196
+ if (commitContext && verifyCommit(commitContext)) {
197
+ logger.debug('Commit verified during extended grace period - terminating Claude process');
198
+ if (commitPollHandle) clearInterval(commitPollHandle);
199
+ if (hardMaxHandle) clearTimeout(hardMaxHandle);
200
+ killFn();
201
+ }
202
+ }, COMMIT_POLL_INTERVAL_MS);
203
+ } else {
204
+ // No commit verification needed (FAILED marker or no context) - kill immediately
205
+ logger.debug('Grace period expired - terminating Claude process');
206
+ killFn();
207
+ }
208
+ }
209
+
210
+ function startGracePeriod(markerOutput: string): void {
211
+ if (graceHandle) return; // Already started
212
+ detectedMarkerIsComplete = COMPLETE_MARKER_PATTERN.test(markerOutput);
213
+ logger.debug('Completion marker detected - starting grace period before termination');
214
+ graceHandle = setTimeout(onGracePeriodExpired, COMPLETION_GRACE_PERIOD_MS);
215
+ }
216
+
217
+ function checkOutput(output: string): void {
218
+ if (!graceHandle && COMPLETION_MARKER_PATTERN.test(output)) {
219
+ startGracePeriod(output);
220
+ }
221
+ }
222
+
223
+ // Start outcome file polling if path provided
224
+ if (outcomeFilePath) {
225
+ const filePath = outcomeFilePath;
226
+ pollHandle = setInterval(() => {
227
+ try {
228
+ if (!fs.existsSync(filePath)) return;
229
+ const stat = fs.statSync(filePath);
230
+ if (stat.mtimeMs <= initialMtime) return; // File unchanged from before execution
231
+ const content = fs.readFileSync(filePath, 'utf-8');
232
+ if (COMPLETION_MARKER_PATTERN.test(content)) {
233
+ startGracePeriod(content);
234
+ }
235
+ } catch {
236
+ // Ignore read errors - file may be mid-write
237
+ }
238
+ }, OUTCOME_POLL_INTERVAL_MS);
239
+ }
240
+
241
+ function cleanup(): void {
242
+ if (graceHandle) clearTimeout(graceHandle);
243
+ if (pollHandle) clearInterval(pollHandle);
244
+ if (commitPollHandle) clearInterval(commitPollHandle);
245
+ if (hardMaxHandle) clearTimeout(hardMaxHandle);
246
+ }
247
+
248
+ return { checkOutput, cleanup };
249
+ }
250
+
53
251
  export class ClaudeRunner {
54
252
  private activeProcess: pty.IPty | null = null;
55
253
  private killed = false;
@@ -169,7 +367,7 @@ export class ClaudeRunner {
169
367
  * - Default timeout is 60 minutes if not specified
170
368
  */
171
369
  async run(prompt: string, options: ClaudeRunnerOptions = {}): Promise<RunResult> {
172
- const { timeout = 60, cwd = process.cwd() } = options;
370
+ const { timeout = 60, cwd = process.cwd(), outcomeFilePath, commitContext } = options;
173
371
  // Ensure timeout is a positive number, fallback to 60 minutes
174
372
  const validatedTimeout = Number(timeout) > 0 ? Number(timeout) : 60;
175
373
  const timeoutMs = validatedTimeout * 60 * 1000;
@@ -213,11 +411,21 @@ export class ClaudeRunner {
213
411
  proc.kill('SIGTERM');
214
412
  }, timeoutMs);
215
413
 
414
+ // Set up completion detection (stdout marker + outcome file polling)
415
+ const completionDetector = createCompletionDetector(
416
+ () => proc.kill('SIGTERM'),
417
+ outcomeFilePath,
418
+ commitContext,
419
+ );
420
+
216
421
  // Collect stdout
217
422
  proc.stdout.on('data', (data) => {
218
423
  const text = data.toString();
219
424
  output += text;
220
425
 
426
+ // Check for completion marker to start grace period
427
+ completionDetector.checkOutput(output);
428
+
221
429
  // Check for context overflow
222
430
  for (const pattern of CONTEXT_OVERFLOW_PATTERNS) {
223
431
  if (pattern.test(text)) {
@@ -236,6 +444,7 @@ export class ClaudeRunner {
236
444
 
237
445
  proc.on('close', (exitCode) => {
238
446
  clearTimeout(timeoutHandle);
447
+ completionDetector.cleanup();
239
448
  this.activeProcess = null;
240
449
 
241
450
  if (stderr) {
@@ -254,7 +463,8 @@ export class ClaudeRunner {
254
463
 
255
464
  /**
256
465
  * Run Claude non-interactively with verbose output to stdout.
257
- * Uses child_process.spawn with -p flag for prompt (like ralphy).
466
+ * Uses --output-format stream-json --verbose to get real-time streaming
467
+ * of tool calls, file operations, and thinking steps.
258
468
  *
259
469
  * TIMEOUT BEHAVIOR:
260
470
  * - The timeout is applied per individual call to this method
@@ -264,7 +474,7 @@ export class ClaudeRunner {
264
474
  * - Default timeout is 60 minutes if not specified
265
475
  */
266
476
  async runVerbose(prompt: string, options: ClaudeRunnerOptions = {}): Promise<RunResult> {
267
- const { timeout = 60, cwd = process.cwd() } = options;
477
+ const { timeout = 60, cwd = process.cwd(), outcomeFilePath, commitContext } = options;
268
478
  // Ensure timeout is a positive number, fallback to 60 minutes
269
479
  const validatedTimeout = Number(timeout) > 0 ? Number(timeout) : 60;
270
480
  const timeoutMs = validatedTimeout * 60 * 1000;
@@ -277,13 +487,13 @@ export class ClaudeRunner {
277
487
 
278
488
  const claudePath = getClaudePath();
279
489
 
280
- logger.debug(`Starting Claude execution session (verbose) with model: ${this.model}`);
490
+ logger.debug(`Starting Claude execution session (verbose/stream-json) with model: ${this.model}`);
281
491
  logger.debug(`Prompt length: ${prompt.length}, timeout: ${timeoutMs}ms, cwd: ${cwd}`);
282
492
  logger.debug(`Claude path: ${claudePath}`);
283
493
 
284
494
  logger.debug('Spawning process...');
285
- // Use --append-system-prompt to add RAF instructions to system prompt
286
- // This gives RAF instructions stronger precedence than passing as user message
495
+ // Use --output-format stream-json --verbose to get real-time streaming events
496
+ // including tool calls, file operations, and intermediate output.
287
497
  // --dangerously-skip-permissions bypasses interactive prompts
288
498
  // -p enables print mode (non-interactive)
289
499
  const proc = spawn(claudePath, [
@@ -292,6 +502,9 @@ export class ClaudeRunner {
292
502
  this.model,
293
503
  '--append-system-prompt',
294
504
  prompt,
505
+ '--output-format',
506
+ 'stream-json',
507
+ '--verbose',
295
508
  '-p',
296
509
  'Execute the task as described in the system prompt.',
297
510
  ], {
@@ -311,24 +524,52 @@ export class ClaudeRunner {
311
524
  proc.kill('SIGTERM');
312
525
  }, timeoutMs);
313
526
 
314
- // Collect and display stdout
527
+ // Set up completion detection (stdout marker + outcome file polling)
528
+ const completionDetector = createCompletionDetector(
529
+ () => proc.kill('SIGTERM'),
530
+ outcomeFilePath,
531
+ commitContext,
532
+ );
533
+
534
+ // Buffer for incomplete NDJSON lines (data chunks may split across line boundaries)
535
+ let lineBuffer = '';
315
536
  let dataReceived = false;
537
+
316
538
  proc.stdout.on('data', (data) => {
317
539
  if (!dataReceived) {
318
540
  logger.debug('First data chunk received');
319
541
  dataReceived = true;
320
542
  }
321
- const text = data.toString();
322
- output += text;
323
- process.stdout.write(text);
324
543
 
325
- // Check for context overflow
326
- for (const pattern of CONTEXT_OVERFLOW_PATTERNS) {
327
- if (pattern.test(text)) {
328
- contextOverflow = true;
329
- logger.warn('Context overflow detected');
330
- proc.kill('SIGTERM');
331
- break;
544
+ lineBuffer += data.toString();
545
+
546
+ // Process complete lines from the NDJSON stream
547
+ let newlineIndex: number;
548
+ while ((newlineIndex = lineBuffer.indexOf('\n')) !== -1) {
549
+ const line = lineBuffer.substring(0, newlineIndex);
550
+ lineBuffer = lineBuffer.substring(newlineIndex + 1);
551
+
552
+ const { display, textContent } = renderStreamEvent(line);
553
+
554
+ if (textContent) {
555
+ output += textContent;
556
+
557
+ // Check for completion marker to start grace period
558
+ completionDetector.checkOutput(output);
559
+
560
+ // Check for context overflow
561
+ for (const pattern of CONTEXT_OVERFLOW_PATTERNS) {
562
+ if (pattern.test(textContent)) {
563
+ contextOverflow = true;
564
+ logger.warn('Context overflow detected');
565
+ proc.kill('SIGTERM');
566
+ break;
567
+ }
568
+ }
569
+ }
570
+
571
+ if (display) {
572
+ process.stdout.write(display);
332
573
  }
333
574
  }
334
575
  });
@@ -339,7 +580,19 @@ export class ClaudeRunner {
339
580
  });
340
581
 
341
582
  proc.on('close', (exitCode) => {
583
+ // Process any remaining data in the line buffer
584
+ if (lineBuffer.trim()) {
585
+ const { display, textContent } = renderStreamEvent(lineBuffer);
586
+ if (textContent) {
587
+ output += textContent;
588
+ }
589
+ if (display) {
590
+ process.stdout.write(display);
591
+ }
592
+ }
593
+
342
594
  clearTimeout(timeoutHandle);
595
+ completionDetector.cleanup();
343
596
  this.activeProcess = null;
344
597
  logger.debug(`Claude exited with code ${exitCode}, output length: ${output.length}, timedOut: ${timedOut}, contextOverflow: ${contextOverflow}`);
345
598
 
package/src/core/git.ts CHANGED
@@ -167,6 +167,50 @@ export function stashChanges(name: string): boolean {
167
167
  }
168
168
  }
169
169
 
170
+ /**
171
+ * Get the current HEAD commit hash.
172
+ * Returns null if not in a git repo or HEAD doesn't exist.
173
+ */
174
+ export function getHeadCommitHash(): string | null {
175
+ try {
176
+ return execSync('git rev-parse HEAD', { encoding: 'utf-8', stdio: 'pipe' }).trim() || null;
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Get the current HEAD commit message (first line only).
184
+ * Returns null if not in a git repo or HEAD doesn't exist.
185
+ */
186
+ export function getHeadCommitMessage(): string | null {
187
+ try {
188
+ return execSync('git log -1 --format=%s', { encoding: 'utf-8', stdio: 'pipe' }).trim() || null;
189
+ } catch {
190
+ return null;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Check if a file is tracked in the HEAD commit.
196
+ * Returns true if the file appears in the latest commit's tree.
197
+ */
198
+ export function isFileCommittedInHead(filePath: string): boolean {
199
+ try {
200
+ // Use git ls-tree to check if the file exists in HEAD
201
+ // We need the path relative to the repo root
202
+ const repoRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8', stdio: 'pipe' }).trim();
203
+ const relativePath = path.relative(repoRoot, path.resolve(filePath));
204
+ const result = execSync(`git ls-tree HEAD -- "${relativePath.replace(/"/g, '\\"')}"`, {
205
+ encoding: 'utf-8',
206
+ stdio: 'pipe',
207
+ }).trim();
208
+ return result.length > 0;
209
+ } catch {
210
+ return false;
211
+ }
212
+ }
213
+
170
214
  /**
171
215
  * Commit planning artifacts (input.md and decisions.md) for a project.
172
216
  * Uses commit message format: RAF[NNN] Plan: project-name
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Renders stream-json events from Claude CLI into human-readable verbose output.
3
+ *
4
+ * Event types from `claude -p --output-format stream-json --verbose`:
5
+ * - system (init): Session initialization info
6
+ * - assistant: Claude's response with text or tool_use content blocks
7
+ * - user: Tool results (tool_result content blocks)
8
+ * - result: Final result with success/failure status
9
+ */
10
+
11
+ export interface StreamEvent {
12
+ type: string;
13
+ subtype?: string;
14
+ message?: {
15
+ content?: ContentBlock[];
16
+ };
17
+ result?: string;
18
+ tool_use_result?: {
19
+ type?: string;
20
+ file?: {
21
+ filePath?: string;
22
+ };
23
+ };
24
+ }
25
+
26
+ interface ContentBlock {
27
+ type: string;
28
+ text?: string;
29
+ name?: string;
30
+ input?: Record<string, unknown>;
31
+ }
32
+
33
+ /**
34
+ * Describes what a tool is doing in human-readable form.
35
+ */
36
+ function describeToolUse(name: string, input: Record<string, unknown>): string {
37
+ switch (name) {
38
+ case 'Read':
39
+ return `Reading ${input.file_path ?? 'file'}`;
40
+ case 'Write':
41
+ return `Writing ${input.file_path ?? 'file'}`;
42
+ case 'Edit':
43
+ return `Editing ${input.file_path ?? 'file'}`;
44
+ case 'Bash':
45
+ return `Running: ${truncate(String(input.command ?? ''), 120)}`;
46
+ case 'Glob':
47
+ return `Searching files: ${input.pattern ?? ''}`;
48
+ case 'Grep':
49
+ return `Searching for: ${truncate(String(input.pattern ?? ''), 80)}`;
50
+ case 'WebFetch':
51
+ return `Fetching: ${input.url ?? ''}`;
52
+ case 'WebSearch':
53
+ return `Searching web: ${input.query ?? ''}`;
54
+ case 'TodoWrite':
55
+ return 'Updating task list';
56
+ case 'Task':
57
+ return `Launching agent: ${truncate(String(input.description ?? input.prompt ?? ''), 80)}`;
58
+ case 'NotebookEdit':
59
+ return `Editing notebook: ${input.notebook_path ?? ''}`;
60
+ default:
61
+ return `Using tool: ${name}`;
62
+ }
63
+ }
64
+
65
+ function truncate(text: string, maxLen: number): string {
66
+ if (text.length <= maxLen) return text;
67
+ return text.substring(0, maxLen - 3) + '...';
68
+ }
69
+
70
+ export interface RenderResult {
71
+ /** Text to display to stdout (may be empty if no display needed) */
72
+ display: string;
73
+ /** Text content to accumulate for output parsing (completion markers, etc.) */
74
+ textContent: string;
75
+ }
76
+
77
+ /**
78
+ * Parse and render a single NDJSON line from stream-json output.
79
+ * Returns display text for stdout and text content for output accumulation.
80
+ */
81
+ export function renderStreamEvent(line: string): RenderResult {
82
+ if (!line.trim()) {
83
+ return { display: '', textContent: '' };
84
+ }
85
+
86
+ let event: StreamEvent;
87
+ try {
88
+ event = JSON.parse(line) as StreamEvent;
89
+ } catch {
90
+ // Not valid JSON - pass through raw
91
+ return { display: line + '\n', textContent: line };
92
+ }
93
+
94
+ switch (event.type) {
95
+ case 'system':
96
+ return { display: '', textContent: '' };
97
+
98
+ case 'assistant':
99
+ return renderAssistant(event);
100
+
101
+ case 'user':
102
+ // Tool results — skip verbose display (the tool_use already described what's happening)
103
+ return { display: '', textContent: '' };
104
+
105
+ case 'result':
106
+ return renderResult(event);
107
+
108
+ default:
109
+ return { display: '', textContent: '' };
110
+ }
111
+ }
112
+
113
+ function renderAssistant(event: StreamEvent): RenderResult {
114
+ const content = event.message?.content;
115
+ if (!content || !Array.isArray(content)) {
116
+ return { display: '', textContent: '' };
117
+ }
118
+
119
+ let display = '';
120
+ let textContent = '';
121
+
122
+ for (const block of content) {
123
+ if (block.type === 'text' && block.text) {
124
+ textContent += block.text;
125
+ display += block.text + '\n';
126
+ } else if (block.type === 'tool_use' && block.name) {
127
+ const description = describeToolUse(block.name, block.input ?? {});
128
+ display += ` → ${description}\n`;
129
+ }
130
+ }
131
+
132
+ return { display, textContent };
133
+ }
134
+
135
+ function renderResult(_event: StreamEvent): RenderResult {
136
+ // The result event's text duplicates the last assistant message,
137
+ // which is already captured. Skip to avoid double-counting.
138
+ return { display: '', textContent: '' };
139
+ }