rafcode 1.2.0 → 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 (43) hide show
  1. package/RAF/019-verbose-chronicle/decisions.md +25 -0
  2. package/RAF/019-verbose-chronicle/input.md +3 -0
  3. package/RAF/019-verbose-chronicle/outcomes/001-amend-iteration-references.md +25 -0
  4. package/RAF/019-verbose-chronicle/outcomes/002-verbose-task-name-display.md +31 -0
  5. package/RAF/019-verbose-chronicle/outcomes/003-verbose-streaming-fix.md +48 -0
  6. package/RAF/019-verbose-chronicle/outcomes/004-commit-verification-before-halt.md +56 -0
  7. package/RAF/019-verbose-chronicle/plans/001-amend-iteration-references.md +35 -0
  8. package/RAF/019-verbose-chronicle/plans/002-verbose-task-name-display.md +38 -0
  9. package/RAF/019-verbose-chronicle/plans/003-verbose-streaming-fix.md +45 -0
  10. package/RAF/019-verbose-chronicle/plans/004-commit-verification-before-halt.md +62 -0
  11. package/dist/commands/do.js +19 -11
  12. package/dist/commands/do.js.map +1 -1
  13. package/dist/core/claude-runner.d.ts +52 -1
  14. package/dist/core/claude-runner.d.ts.map +1 -1
  15. package/dist/core/claude-runner.js +195 -17
  16. package/dist/core/claude-runner.js.map +1 -1
  17. package/dist/core/git.d.ts +15 -0
  18. package/dist/core/git.d.ts.map +1 -1
  19. package/dist/core/git.js +44 -0
  20. package/dist/core/git.js.map +1 -1
  21. package/dist/parsers/stream-renderer.d.ts +42 -0
  22. package/dist/parsers/stream-renderer.d.ts.map +1 -0
  23. package/dist/parsers/stream-renderer.js +100 -0
  24. package/dist/parsers/stream-renderer.js.map +1 -0
  25. package/dist/prompts/amend.d.ts.map +1 -1
  26. package/dist/prompts/amend.js +13 -2
  27. package/dist/prompts/amend.js.map +1 -1
  28. package/dist/prompts/execution.d.ts.map +1 -1
  29. package/dist/prompts/execution.js +1 -3
  30. package/dist/prompts/execution.js.map +1 -1
  31. package/dist/prompts/planning.js +2 -2
  32. package/package.json +1 -1
  33. package/src/commands/do.ts +20 -11
  34. package/src/core/claude-runner.ts +270 -17
  35. package/src/core/git.ts +44 -0
  36. package/src/parsers/stream-renderer.ts +139 -0
  37. package/src/prompts/amend.ts +14 -2
  38. package/src/prompts/execution.ts +1 -3
  39. package/src/prompts/planning.ts +2 -2
  40. package/tests/unit/claude-runner.test.ts +567 -1
  41. package/tests/unit/git-commit-helpers.test.ts +103 -0
  42. package/tests/unit/plan-command.test.ts +51 -0
  43. package/tests/unit/stream-renderer.test.ts +286 -0
@@ -5,13 +5,40 @@ import { EventEmitter } from 'events';
5
5
  const mockSpawn = jest.fn();
6
6
  const mockExecSync = jest.fn();
7
7
 
8
+ // Create mock fs functions
9
+ const mockExistsSync = jest.fn();
10
+ const mockStatSync = jest.fn();
11
+ const mockReadFileSync = jest.fn();
12
+
13
+ // Create mock git functions
14
+ const mockGetHeadCommitHash = jest.fn();
15
+ const mockGetHeadCommitMessage = jest.fn();
16
+ const mockIsFileCommittedInHead = jest.fn();
17
+
8
18
  jest.unstable_mockModule('node:child_process', () => ({
9
19
  spawn: mockSpawn,
10
20
  execSync: mockExecSync,
11
21
  }));
12
22
 
23
+ jest.unstable_mockModule('node:fs', () => ({
24
+ default: {
25
+ existsSync: mockExistsSync,
26
+ statSync: mockStatSync,
27
+ readFileSync: mockReadFileSync,
28
+ },
29
+ existsSync: mockExistsSync,
30
+ statSync: mockStatSync,
31
+ readFileSync: mockReadFileSync,
32
+ }));
33
+
34
+ jest.unstable_mockModule('../../src/core/git.js', () => ({
35
+ getHeadCommitHash: mockGetHeadCommitHash,
36
+ getHeadCommitMessage: mockGetHeadCommitMessage,
37
+ isFileCommittedInHead: mockIsFileCommittedInHead,
38
+ }));
39
+
13
40
  // Import after mocking
14
- const { ClaudeRunner } = await import('../../src/core/claude-runner.js');
41
+ const { ClaudeRunner, COMPLETION_GRACE_PERIOD_MS, COMPLETION_HARD_MAX_MS, COMMIT_POLL_INTERVAL_MS, OUTCOME_POLL_INTERVAL_MS } = await import('../../src/core/claude-runner.js');
15
42
 
16
43
  describe('ClaudeRunner', () => {
17
44
  beforeEach(() => {
@@ -539,6 +566,93 @@ describe('ClaudeRunner', () => {
539
566
  });
540
567
  });
541
568
 
569
+ describe('verbose stream-json output', () => {
570
+ function createMockProcess() {
571
+ const stdout = new EventEmitter();
572
+ const stderr = new EventEmitter();
573
+ const proc = new EventEmitter() as any;
574
+ proc.stdout = stdout;
575
+ proc.stderr = stderr;
576
+ proc.kill = jest.fn();
577
+ return proc;
578
+ }
579
+
580
+ it('should include --output-format stream-json and --verbose flags in runVerbose()', async () => {
581
+ const mockProc = createMockProcess();
582
+ mockSpawn.mockReturnValue(mockProc);
583
+
584
+ const runner = new ClaudeRunner();
585
+ const runPromise = runner.runVerbose('test prompt', { timeout: 60 });
586
+
587
+ mockProc.emit('close', 0);
588
+ await runPromise;
589
+
590
+ const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
591
+ expect(spawnArgs).toContain('--output-format');
592
+ expect(spawnArgs).toContain('stream-json');
593
+ expect(spawnArgs).toContain('--verbose');
594
+ });
595
+
596
+ it('should NOT include --output-format or --verbose flags in run()', async () => {
597
+ const mockProc = createMockProcess();
598
+ mockSpawn.mockReturnValue(mockProc);
599
+
600
+ const runner = new ClaudeRunner();
601
+ const runPromise = runner.run('test prompt', { timeout: 60 });
602
+
603
+ mockProc.emit('close', 0);
604
+ await runPromise;
605
+
606
+ const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
607
+ expect(spawnArgs).not.toContain('--output-format');
608
+ expect(spawnArgs).not.toContain('stream-json');
609
+ expect(spawnArgs).not.toContain('--verbose');
610
+ });
611
+
612
+ it('should extract text from NDJSON assistant events', async () => {
613
+ const mockProc = createMockProcess();
614
+ mockSpawn.mockReturnValue(mockProc);
615
+
616
+ const runner = new ClaudeRunner();
617
+ const runPromise = runner.runVerbose('test prompt', { timeout: 60 });
618
+
619
+ // Emit NDJSON lines like real stream-json output
620
+ const systemEvent = JSON.stringify({ type: 'system', subtype: 'init' });
621
+ const assistantEvent = JSON.stringify({
622
+ type: 'assistant',
623
+ message: { content: [{ type: 'text', text: 'Task complete.\n<promise>COMPLETE</promise>' }] },
624
+ });
625
+ const resultEvent = JSON.stringify({ type: 'result', subtype: 'success', result: 'Task complete.' });
626
+
627
+ mockProc.stdout.emit('data', Buffer.from(systemEvent + '\n' + assistantEvent + '\n' + resultEvent + '\n'));
628
+ mockProc.emit('close', 0);
629
+
630
+ const result = await runPromise;
631
+ expect(result.output).toContain('Task complete.');
632
+ expect(result.output).toContain('<promise>COMPLETE</promise>');
633
+ });
634
+
635
+ it('should extract text from tool_use NDJSON events without adding to output', async () => {
636
+ const mockProc = createMockProcess();
637
+ mockSpawn.mockReturnValue(mockProc);
638
+
639
+ const runner = new ClaudeRunner();
640
+ const runPromise = runner.runVerbose('test prompt', { timeout: 60 });
641
+
642
+ const toolEvent = JSON.stringify({
643
+ type: 'assistant',
644
+ message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/test.ts' } }] },
645
+ });
646
+
647
+ mockProc.stdout.emit('data', Buffer.from(toolEvent + '\n'));
648
+ mockProc.emit('close', 0);
649
+
650
+ const result = await runPromise;
651
+ // Tool use events don't add text to output
652
+ expect(result.output).toBe('');
653
+ });
654
+ });
655
+
542
656
  describe('retry isolation (timeout per attempt)', () => {
543
657
  function createMockProcess() {
544
658
  const stdout = new EventEmitter();
@@ -626,4 +740,456 @@ describe('ClaudeRunner', () => {
626
740
  expect(result2.timedOut).toBe(true);
627
741
  });
628
742
  });
743
+
744
+ describe('completion detection', () => {
745
+ function createMockProcess() {
746
+ const stdout = new EventEmitter();
747
+ const stderr = new EventEmitter();
748
+ const proc = new EventEmitter() as any;
749
+ proc.stdout = stdout;
750
+ proc.stderr = stderr;
751
+ proc.kill = jest.fn().mockImplementation(() => {
752
+ setImmediate(() => proc.emit('close', 1));
753
+ });
754
+ return proc;
755
+ }
756
+
757
+ beforeEach(() => {
758
+ // Default: no outcome file exists
759
+ mockExistsSync.mockReturnValue(false);
760
+ });
761
+
762
+ it('should start grace period when COMPLETE marker detected in stdout', async () => {
763
+ const mockProc = createMockProcess();
764
+ mockSpawn.mockReturnValue(mockProc);
765
+
766
+ const runner = new ClaudeRunner();
767
+ const runPromise = runner.run('test prompt', { timeout: 60 });
768
+
769
+ // Emit output with completion marker
770
+ mockProc.stdout.emit('data', Buffer.from('Writing outcome...\n<promise>COMPLETE</promise>\n'));
771
+
772
+ // Process should not be killed immediately
773
+ expect(mockProc.kill).not.toHaveBeenCalled();
774
+
775
+ // Advance to just before grace period expires
776
+ jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS - 1);
777
+ expect(mockProc.kill).not.toHaveBeenCalled();
778
+
779
+ // Advance past grace period
780
+ jest.advanceTimersByTime(2);
781
+ expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM');
782
+
783
+ const result = await runPromise;
784
+ expect(result.timedOut).toBe(false); // Not a timeout, it's a grace period kill
785
+ expect(result.output).toContain('<promise>COMPLETE</promise>');
786
+ });
787
+
788
+ it('should start grace period when FAILED marker detected in stdout', async () => {
789
+ const mockProc = createMockProcess();
790
+ mockSpawn.mockReturnValue(mockProc);
791
+
792
+ const runner = new ClaudeRunner();
793
+ const runPromise = runner.run('test prompt', { timeout: 60 });
794
+
795
+ // Emit output with failed marker
796
+ mockProc.stdout.emit('data', Buffer.from('<promise>FAILED</promise>\nReason: test error'));
797
+
798
+ // Advance past grace period
799
+ jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS + 1);
800
+ expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM');
801
+
802
+ const result = await runPromise;
803
+ expect(result.timedOut).toBe(false);
804
+ });
805
+
806
+ it('should detect marker across multiple stdout chunks', async () => {
807
+ const mockProc = createMockProcess();
808
+ mockSpawn.mockReturnValue(mockProc);
809
+
810
+ const runner = new ClaudeRunner();
811
+ const runPromise = runner.run('test prompt', { timeout: 60 });
812
+
813
+ // Emit partial output (no marker yet)
814
+ mockProc.stdout.emit('data', Buffer.from('Working on task...\n'));
815
+ jest.advanceTimersByTime(10000);
816
+ expect(mockProc.kill).not.toHaveBeenCalled();
817
+
818
+ // Emit marker in second chunk
819
+ mockProc.stdout.emit('data', Buffer.from('<promise>COMPLETE</promise>\n'));
820
+
821
+ // Grace period starts now - advance past it
822
+ jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS + 1);
823
+ expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM');
824
+
825
+ await runPromise;
826
+ });
827
+
828
+ it('should not start grace period if process exits before grace period expires', async () => {
829
+ const mockProc = createMockProcess();
830
+ // Override kill to NOT auto-close (so we can test natural close)
831
+ mockProc.kill = jest.fn();
832
+ mockSpawn.mockReturnValue(mockProc);
833
+
834
+ const runner = new ClaudeRunner();
835
+ const runPromise = runner.run('test prompt', { timeout: 60 });
836
+
837
+ // Emit completion marker
838
+ mockProc.stdout.emit('data', Buffer.from('<promise>COMPLETE</promise>\n'));
839
+
840
+ // Process exits naturally before grace period
841
+ jest.advanceTimersByTime(5000);
842
+ mockProc.emit('close', 0);
843
+
844
+ const result = await runPromise;
845
+ expect(result.exitCode).toBe(0);
846
+ expect(result.output).toContain('<promise>COMPLETE</promise>');
847
+
848
+ // Grace period should have been cleaned up - advancing further should not call kill
849
+ jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS);
850
+ expect(mockProc.kill).not.toHaveBeenCalled();
851
+ });
852
+
853
+ it('should detect completion via outcome file polling', async () => {
854
+ const mockProc = createMockProcess();
855
+ mockSpawn.mockReturnValue(mockProc);
856
+
857
+ const outcomePath = '/test/project/outcomes/001-task.md';
858
+
859
+ // Outcome file doesn't exist initially
860
+ mockExistsSync.mockReturnValue(false);
861
+
862
+ const runner = new ClaudeRunner();
863
+ const runPromise = runner.run('test prompt', {
864
+ timeout: 60,
865
+ outcomeFilePath: outcomePath,
866
+ });
867
+
868
+ // No output marker in stdout - just regular output
869
+ mockProc.stdout.emit('data', Buffer.from('Working on task...'));
870
+
871
+ // After some time, outcome file appears with marker
872
+ jest.advanceTimersByTime(OUTCOME_POLL_INTERVAL_MS - 1);
873
+ expect(mockProc.kill).not.toHaveBeenCalled();
874
+
875
+ // Mock that file now exists with COMPLETE marker
876
+ mockExistsSync.mockReturnValue(true);
877
+ mockStatSync.mockReturnValue({ mtimeMs: Date.now() + 1000 });
878
+ mockReadFileSync.mockReturnValue('# Outcome\n\n<promise>COMPLETE</promise>\n');
879
+
880
+ // Advance to trigger poll
881
+ jest.advanceTimersByTime(2);
882
+
883
+ // Grace period started - advance past it
884
+ jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS + 1);
885
+ expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM');
886
+
887
+ await runPromise;
888
+ });
889
+
890
+ it('should not trigger on pre-existing outcome file from previous run', async () => {
891
+ const mockProc = createMockProcess();
892
+ // Override kill to NOT auto-close
893
+ mockProc.kill = jest.fn();
894
+ mockSpawn.mockReturnValue(mockProc);
895
+
896
+ const outcomePath = '/test/project/outcomes/001-task.md';
897
+
898
+ // Outcome file already exists from previous failed run
899
+ mockExistsSync.mockReturnValue(true);
900
+ mockStatSync.mockReturnValue({ mtimeMs: 1000 }); // Old timestamp
901
+ mockReadFileSync.mockReturnValue('# Outcome\n\n<promise>FAILED</promise>\n');
902
+
903
+ const runner = new ClaudeRunner();
904
+ const runPromise = runner.run('test prompt', {
905
+ timeout: 60,
906
+ outcomeFilePath: outcomePath,
907
+ });
908
+
909
+ // Advance past several poll intervals - should not trigger because mtime unchanged
910
+ jest.advanceTimersByTime(OUTCOME_POLL_INTERVAL_MS * 5);
911
+ expect(mockProc.kill).not.toHaveBeenCalled();
912
+
913
+ // Complete normally
914
+ mockProc.stdout.emit('data', Buffer.from('done'));
915
+ mockProc.emit('close', 0);
916
+
917
+ await runPromise;
918
+ });
919
+
920
+ it('should work with runVerbose() too', async () => {
921
+ const mockProc = createMockProcess();
922
+ mockSpawn.mockReturnValue(mockProc);
923
+
924
+ const runner = new ClaudeRunner();
925
+ const runPromise = runner.runVerbose('test prompt', { timeout: 60 });
926
+
927
+ // Emit output with completion marker
928
+ mockProc.stdout.emit('data', Buffer.from('<promise>COMPLETE</promise>\n'));
929
+
930
+ // Advance past grace period
931
+ jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS + 1);
932
+ expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM');
933
+
934
+ const result = await runPromise;
935
+ expect(result.timedOut).toBe(false);
936
+ });
937
+
938
+ it('should not start multiple grace periods for repeated markers', async () => {
939
+ const mockProc = createMockProcess();
940
+ // Override kill to NOT auto-close (to test multiple markers)
941
+ mockProc.kill = jest.fn();
942
+ mockSpawn.mockReturnValue(mockProc);
943
+
944
+ const runner = new ClaudeRunner();
945
+ const runPromise = runner.run('test prompt', { timeout: 60 });
946
+
947
+ // Emit first marker
948
+ mockProc.stdout.emit('data', Buffer.from('<promise>COMPLETE</promise>\n'));
949
+
950
+ // Advance halfway through grace period
951
+ jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS / 2);
952
+
953
+ // Emit second marker (e.g., Claude reading back what it wrote)
954
+ mockProc.stdout.emit('data', Buffer.from('Verified: <promise>COMPLETE</promise>\n'));
955
+
956
+ // Advance remaining grace period from FIRST detection
957
+ jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS / 2 + 1);
958
+
959
+ // Should be killed once (from the first grace period)
960
+ expect(mockProc.kill).toHaveBeenCalledTimes(1);
961
+ expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM');
962
+
963
+ // Close process manually for cleanup
964
+ mockProc.emit('close', 1);
965
+ await runPromise;
966
+ });
967
+ });
968
+
969
+ describe('commit verification during grace period', () => {
970
+ function createMockProcess() {
971
+ const stdout = new EventEmitter();
972
+ const stderr = new EventEmitter();
973
+ const proc = new EventEmitter() as any;
974
+ proc.stdout = stdout;
975
+ proc.stderr = stderr;
976
+ proc.kill = jest.fn().mockImplementation(() => {
977
+ setImmediate(() => proc.emit('close', 1));
978
+ });
979
+ return proc;
980
+ }
981
+
982
+ const commitContext = {
983
+ preExecutionHead: 'aaa111',
984
+ expectedPrefix: 'RAF[005:001]',
985
+ outcomeFilePath: '/project/outcomes/001-task.md',
986
+ };
987
+
988
+ beforeEach(() => {
989
+ mockExistsSync.mockReturnValue(false);
990
+ mockGetHeadCommitHash.mockReturnValue('aaa111');
991
+ mockGetHeadCommitMessage.mockReturnValue(null);
992
+ mockIsFileCommittedInHead.mockReturnValue(false);
993
+ });
994
+
995
+ it('should kill immediately after grace period when commit is verified within grace period', async () => {
996
+ const mockProc = createMockProcess();
997
+ mockSpawn.mockReturnValue(mockProc);
998
+
999
+ // Commit lands during grace period
1000
+ mockGetHeadCommitHash.mockReturnValue('bbb222');
1001
+ mockGetHeadCommitMessage.mockReturnValue('RAF[005:001] Add feature');
1002
+ mockIsFileCommittedInHead.mockReturnValue(true);
1003
+
1004
+ const runner = new ClaudeRunner();
1005
+ const runPromise = runner.run('test prompt', {
1006
+ timeout: 60,
1007
+ commitContext,
1008
+ });
1009
+
1010
+ // Emit COMPLETE marker
1011
+ mockProc.stdout.emit('data', Buffer.from('<promise>COMPLETE</promise>\n'));
1012
+
1013
+ // Advance to grace period expiry - commit already verified
1014
+ jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS + 1);
1015
+ expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM');
1016
+
1017
+ await runPromise;
1018
+ });
1019
+
1020
+ it('should extend grace period and find commit during extended polling', async () => {
1021
+ const mockProc = createMockProcess();
1022
+ // Override kill to NOT auto-close so we can test polling
1023
+ mockProc.kill = jest.fn();
1024
+ mockSpawn.mockReturnValue(mockProc);
1025
+
1026
+ // Commit not yet made
1027
+ mockGetHeadCommitHash.mockReturnValue('aaa111');
1028
+
1029
+ const runner = new ClaudeRunner();
1030
+ const runPromise = runner.run('test prompt', {
1031
+ timeout: 60,
1032
+ commitContext,
1033
+ });
1034
+
1035
+ // Emit COMPLETE marker
1036
+ mockProc.stdout.emit('data', Buffer.from('<promise>COMPLETE</promise>\n'));
1037
+
1038
+ // Advance past initial grace period - commit not found, should extend
1039
+ jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS + 1);
1040
+ // Should NOT be killed yet because commit verification failed and we extend
1041
+ expect(mockProc.kill).not.toHaveBeenCalled();
1042
+
1043
+ // Now simulate commit landing during extended polling
1044
+ mockGetHeadCommitHash.mockReturnValue('bbb222');
1045
+ mockGetHeadCommitMessage.mockReturnValue('RAF[005:001] Add feature');
1046
+ mockIsFileCommittedInHead.mockReturnValue(true);
1047
+
1048
+ // Advance to next commit poll interval
1049
+ jest.advanceTimersByTime(COMMIT_POLL_INTERVAL_MS);
1050
+ expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM');
1051
+
1052
+ // Clean up
1053
+ mockProc.emit('close', 1);
1054
+ await runPromise;
1055
+ });
1056
+
1057
+ it('should kill after hard maximum when commit never lands', async () => {
1058
+ const mockProc = createMockProcess();
1059
+ mockSpawn.mockReturnValue(mockProc);
1060
+
1061
+ // Commit never lands
1062
+ mockGetHeadCommitHash.mockReturnValue('aaa111');
1063
+
1064
+ const runner = new ClaudeRunner();
1065
+ const runPromise = runner.run('test prompt', {
1066
+ timeout: 60,
1067
+ commitContext,
1068
+ });
1069
+
1070
+ // Emit COMPLETE marker
1071
+ mockProc.stdout.emit('data', Buffer.from('<promise>COMPLETE</promise>\n'));
1072
+
1073
+ // Advance past initial grace period
1074
+ jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS + 1);
1075
+ expect(mockProc.kill).not.toHaveBeenCalled();
1076
+
1077
+ // Advance to hard maximum
1078
+ jest.advanceTimersByTime(COMPLETION_HARD_MAX_MS - COMPLETION_GRACE_PERIOD_MS);
1079
+ expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM');
1080
+
1081
+ await runPromise;
1082
+ });
1083
+
1084
+ it('should NOT extend grace period for FAILED markers', async () => {
1085
+ const mockProc = createMockProcess();
1086
+ mockSpawn.mockReturnValue(mockProc);
1087
+
1088
+ // Commit never lands (doesn't matter - FAILED shouldn't check)
1089
+ mockGetHeadCommitHash.mockReturnValue('aaa111');
1090
+
1091
+ const runner = new ClaudeRunner();
1092
+ const runPromise = runner.run('test prompt', {
1093
+ timeout: 60,
1094
+ commitContext,
1095
+ });
1096
+
1097
+ // Emit FAILED marker (not COMPLETE)
1098
+ mockProc.stdout.emit('data', Buffer.from('<promise>FAILED</promise>\n'));
1099
+
1100
+ // Grace period should expire normally without extension
1101
+ jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS + 1);
1102
+ expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM');
1103
+
1104
+ await runPromise;
1105
+ });
1106
+
1107
+ it('should work without commitContext (backward compatible)', async () => {
1108
+ const mockProc = createMockProcess();
1109
+ mockSpawn.mockReturnValue(mockProc);
1110
+
1111
+ const runner = new ClaudeRunner();
1112
+ const runPromise = runner.run('test prompt', {
1113
+ timeout: 60,
1114
+ // No commitContext provided
1115
+ });
1116
+
1117
+ // Emit COMPLETE marker
1118
+ mockProc.stdout.emit('data', Buffer.from('<promise>COMPLETE</promise>\n'));
1119
+
1120
+ // Grace period should expire normally (no commit check)
1121
+ jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS + 1);
1122
+ expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM');
1123
+
1124
+ await runPromise;
1125
+ });
1126
+
1127
+ it('should verify commit message prefix matches', async () => {
1128
+ const mockProc = createMockProcess();
1129
+ mockProc.kill = jest.fn();
1130
+ mockSpawn.mockReturnValue(mockProc);
1131
+
1132
+ // HEAD changed but message doesn't match expected prefix
1133
+ mockGetHeadCommitHash.mockReturnValue('bbb222');
1134
+ mockGetHeadCommitMessage.mockReturnValue('RAF[005:002] Wrong task');
1135
+ mockIsFileCommittedInHead.mockReturnValue(true);
1136
+
1137
+ const runner = new ClaudeRunner();
1138
+ const runPromise = runner.run('test prompt', {
1139
+ timeout: 60,
1140
+ commitContext,
1141
+ });
1142
+
1143
+ // Emit COMPLETE marker
1144
+ mockProc.stdout.emit('data', Buffer.from('<promise>COMPLETE</promise>\n'));
1145
+
1146
+ // Grace period expires - commit message doesn't match, should extend
1147
+ jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS + 1);
1148
+ expect(mockProc.kill).not.toHaveBeenCalled();
1149
+
1150
+ // Fix commit message
1151
+ mockGetHeadCommitMessage.mockReturnValue('RAF[005:001] Correct task');
1152
+
1153
+ // Next poll finds it
1154
+ jest.advanceTimersByTime(COMMIT_POLL_INTERVAL_MS);
1155
+ expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM');
1156
+
1157
+ mockProc.emit('close', 1);
1158
+ await runPromise;
1159
+ });
1160
+
1161
+ it('should verify outcome file is committed', async () => {
1162
+ const mockProc = createMockProcess();
1163
+ mockProc.kill = jest.fn();
1164
+ mockSpawn.mockReturnValue(mockProc);
1165
+
1166
+ // HEAD changed and message matches, but file not committed
1167
+ mockGetHeadCommitHash.mockReturnValue('bbb222');
1168
+ mockGetHeadCommitMessage.mockReturnValue('RAF[005:001] Add feature');
1169
+ mockIsFileCommittedInHead.mockReturnValue(false);
1170
+
1171
+ const runner = new ClaudeRunner();
1172
+ const runPromise = runner.run('test prompt', {
1173
+ timeout: 60,
1174
+ commitContext,
1175
+ });
1176
+
1177
+ // Emit COMPLETE marker
1178
+ mockProc.stdout.emit('data', Buffer.from('<promise>COMPLETE</promise>\n'));
1179
+
1180
+ // Grace period expires - file not committed, should extend
1181
+ jest.advanceTimersByTime(COMPLETION_GRACE_PERIOD_MS + 1);
1182
+ expect(mockProc.kill).not.toHaveBeenCalled();
1183
+
1184
+ // File now committed
1185
+ mockIsFileCommittedInHead.mockReturnValue(true);
1186
+
1187
+ // Next poll finds it
1188
+ jest.advanceTimersByTime(COMMIT_POLL_INTERVAL_MS);
1189
+ expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM');
1190
+
1191
+ mockProc.emit('close', 1);
1192
+ await runPromise;
1193
+ });
1194
+ });
629
1195
  });
@@ -0,0 +1,103 @@
1
+ import { jest } from '@jest/globals';
2
+
3
+ const mockExecSync = jest.fn();
4
+
5
+ jest.unstable_mockModule('node:child_process', () => ({
6
+ execSync: mockExecSync,
7
+ }));
8
+
9
+ const { getHeadCommitHash, getHeadCommitMessage, isFileCommittedInHead } = await import('../../src/core/git.js');
10
+
11
+ describe('git commit helper functions', () => {
12
+ beforeEach(() => {
13
+ jest.clearAllMocks();
14
+ });
15
+
16
+ describe('getHeadCommitHash', () => {
17
+ it('should return the current HEAD hash', () => {
18
+ mockExecSync.mockReturnValue('abc123def456\n');
19
+ expect(getHeadCommitHash()).toBe('abc123def456');
20
+ });
21
+
22
+ it('should return null if not in a git repo', () => {
23
+ mockExecSync.mockImplementation(() => {
24
+ throw new Error('not a git repository');
25
+ });
26
+ expect(getHeadCommitHash()).toBeNull();
27
+ });
28
+
29
+ it('should return null for empty output', () => {
30
+ mockExecSync.mockReturnValue('');
31
+ expect(getHeadCommitHash()).toBeNull();
32
+ });
33
+
34
+ it('should trim whitespace from hash', () => {
35
+ mockExecSync.mockReturnValue(' abc123 \n');
36
+ expect(getHeadCommitHash()).toBe('abc123');
37
+ });
38
+ });
39
+
40
+ describe('getHeadCommitMessage', () => {
41
+ it('should return the HEAD commit message', () => {
42
+ mockExecSync.mockReturnValue('RAF[005:001] Add validation\n');
43
+ expect(getHeadCommitMessage()).toBe('RAF[005:001] Add validation');
44
+ });
45
+
46
+ it('should return null if not in a git repo', () => {
47
+ mockExecSync.mockImplementation(() => {
48
+ throw new Error('not a git repository');
49
+ });
50
+ expect(getHeadCommitMessage()).toBeNull();
51
+ });
52
+
53
+ it('should return null for empty output', () => {
54
+ mockExecSync.mockReturnValue('');
55
+ expect(getHeadCommitMessage()).toBeNull();
56
+ });
57
+ });
58
+
59
+ describe('isFileCommittedInHead', () => {
60
+ it('should return true when file exists in HEAD', () => {
61
+ mockExecSync.mockImplementation((cmd: string) => {
62
+ if (typeof cmd === 'string' && cmd.includes('--show-toplevel')) {
63
+ return '/project\n';
64
+ }
65
+ if (typeof cmd === 'string' && cmd.includes('ls-tree')) {
66
+ return '100644 blob abc123\tRAF/outcomes/001-task.md\n';
67
+ }
68
+ return '';
69
+ });
70
+ expect(isFileCommittedInHead('/project/RAF/outcomes/001-task.md')).toBe(true);
71
+ });
72
+
73
+ it('should return false when file does not exist in HEAD', () => {
74
+ mockExecSync.mockImplementation((cmd: string) => {
75
+ if (typeof cmd === 'string' && cmd.includes('--show-toplevel')) {
76
+ return '/project\n';
77
+ }
78
+ if (typeof cmd === 'string' && cmd.includes('ls-tree')) {
79
+ return '';
80
+ }
81
+ return '';
82
+ });
83
+ expect(isFileCommittedInHead('/project/RAF/outcomes/001-task.md')).toBe(false);
84
+ });
85
+
86
+ it('should return false if not in a git repo', () => {
87
+ mockExecSync.mockImplementation(() => {
88
+ throw new Error('not a git repository');
89
+ });
90
+ expect(isFileCommittedInHead('/any/path')).toBe(false);
91
+ });
92
+
93
+ it('should return false on git command failure', () => {
94
+ mockExecSync.mockImplementation((cmd: string) => {
95
+ if (typeof cmd === 'string' && cmd.includes('--show-toplevel')) {
96
+ return '/project\n';
97
+ }
98
+ throw new Error('git ls-tree failed');
99
+ });
100
+ expect(isFileCommittedInHead('/project/file.ts')).toBe(false);
101
+ });
102
+ });
103
+ });