rafcode 1.0.0 → 1.1.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 (37) hide show
  1. package/RAF/016-planning-scalpel/decisions.md +7 -0
  2. package/RAF/016-planning-scalpel/input.md +1 -0
  3. package/RAF/016-planning-scalpel/outcomes/001-update-git-commit-instructions.md +38 -0
  4. package/RAF/016-planning-scalpel/plans/001-update-git-commit-instructions.md +43 -0
  5. package/RAF/017-decision-vault/decisions.md +13 -0
  6. package/RAF/017-decision-vault/input.md +2 -0
  7. package/RAF/017-decision-vault/outcomes/001-create-git-commit-utility.md +53 -0
  8. package/RAF/017-decision-vault/outcomes/002-integrate-commit-into-plan.md +44 -0
  9. package/RAF/017-decision-vault/outcomes/003-add-tests-for-planning-commit.md +51 -0
  10. package/RAF/017-decision-vault/plans/001-create-git-commit-utility.md +38 -0
  11. package/RAF/017-decision-vault/plans/002-integrate-commit-into-plan.md +39 -0
  12. package/RAF/017-decision-vault/plans/003-add-tests-for-planning-commit.md +40 -0
  13. package/README.md +83 -55
  14. package/dist/commands/plan.d.ts.map +1 -1
  15. package/dist/commands/plan.js +5 -0
  16. package/dist/commands/plan.js.map +1 -1
  17. package/dist/core/claude-runner.d.ts.map +1 -1
  18. package/dist/core/claude-runner.js +26 -6
  19. package/dist/core/claude-runner.js.map +1 -1
  20. package/dist/core/git.d.ts +8 -0
  21. package/dist/core/git.d.ts.map +1 -1
  22. package/dist/core/git.js +61 -0
  23. package/dist/core/git.js.map +1 -1
  24. package/dist/prompts/execution.d.ts.map +1 -1
  25. package/dist/prompts/execution.js +4 -2
  26. package/dist/prompts/execution.js.map +1 -1
  27. package/dist/ui/name-picker.d.ts.map +1 -1
  28. package/dist/ui/name-picker.js +27 -4
  29. package/dist/ui/name-picker.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/commands/plan.ts +7 -0
  32. package/src/core/claude-runner.ts +28 -6
  33. package/src/core/git.ts +71 -0
  34. package/src/prompts/execution.ts +4 -2
  35. package/src/ui/name-picker.ts +29 -4
  36. package/tests/manual/test-pty-cleanup.ts +86 -0
  37. package/tests/unit/commit-planning-artifacts.test.ts +232 -0
@@ -3,6 +3,7 @@ import * as path from 'node:path';
3
3
  import * as fs from 'node:fs';
4
4
  import * as os from 'node:os';
5
5
  import * as pty from 'node-pty';
6
+ import type { IDisposable } from 'node-pty';
6
7
 
7
8
  // For testing: allow direct import of @inquirer/prompts functions
8
9
  let directSelect: typeof import('@inquirer/prompts').select | null = null;
@@ -74,12 +75,33 @@ export async function pickProjectName(names: string[]): Promise<string> {
74
75
  };
75
76
  process.stdin.on('data', onData);
76
77
 
78
+ // Store disposables for proper cleanup
79
+ const disposables: IDisposable[] = [];
80
+
81
+ // Helper to clean up PTY resources
82
+ const cleanupPty = (): void => {
83
+ // Dispose all event listeners to prevent FD leaks
84
+ for (const disposable of disposables) {
85
+ try {
86
+ disposable.dispose();
87
+ } catch {
88
+ // Ignore disposal errors
89
+ }
90
+ }
91
+ // Ensure PTY is fully cleaned up
92
+ try {
93
+ ptyProcess.kill();
94
+ } catch {
95
+ // Ignore - process may already be dead
96
+ }
97
+ };
98
+
77
99
  // Forward output from PTY to our stdout
78
- ptyProcess.onData((data) => {
100
+ disposables.push(ptyProcess.onData((data) => {
79
101
  process.stdout.write(data);
80
- });
102
+ }));
81
103
 
82
- ptyProcess.onExit(({ exitCode }) => {
104
+ disposables.push(ptyProcess.onExit(({ exitCode }) => {
83
105
  // Cleanup stdin
84
106
  process.stdin.off('data', onData);
85
107
  if (process.stdin.isTTY) {
@@ -87,6 +109,9 @@ export async function pickProjectName(names: string[]): Promise<string> {
87
109
  }
88
110
  process.stdin.pause();
89
111
 
112
+ // Clean up PTY resources
113
+ cleanupPty();
114
+
90
115
  if (exitCode === 130) {
91
116
  // SIGINT - user cancelled
92
117
  process.exit(130);
@@ -130,7 +155,7 @@ export async function pickProjectName(names: string[]): Promise<string> {
130
155
  } catch (error) {
131
156
  reject(error);
132
157
  }
133
- });
158
+ }));
134
159
  });
135
160
  }
136
161
 
@@ -0,0 +1,86 @@
1
+ import * as pty from 'node-pty';
2
+ import type { IDisposable } from 'node-pty';
3
+ import { execSync } from 'node:child_process';
4
+
5
+ function getPtyCount(): number {
6
+ // Count open file descriptors for this process
7
+ try {
8
+ const result = execSync(`lsof -p ${process.pid} 2>/dev/null | grep -c "/dev/pty\\|/dev/ttys" || echo 0`, { encoding: 'utf-8' });
9
+ return parseInt(result.trim(), 10);
10
+ } catch {
11
+ return 0;
12
+ }
13
+ }
14
+
15
+ async function spawnAndCleanup(withCleanup: boolean): Promise<void> {
16
+ return new Promise((resolve) => {
17
+ const ptyProcess = pty.spawn('echo', ['hello'], {
18
+ name: 'xterm-256color',
19
+ cols: 80,
20
+ rows: 24,
21
+ });
22
+
23
+ const disposables: IDisposable[] = [];
24
+
25
+ disposables.push(ptyProcess.onData(() => {
26
+ // ignore data
27
+ }));
28
+
29
+ disposables.push(ptyProcess.onExit(() => {
30
+ if (withCleanup) {
31
+ // Proper cleanup
32
+ for (const d of disposables) {
33
+ try { d.dispose(); } catch {}
34
+ }
35
+ try { ptyProcess.kill(); } catch {}
36
+ }
37
+ resolve();
38
+ }));
39
+ });
40
+ }
41
+
42
+ async function main() {
43
+ const iterations = 20;
44
+
45
+ console.log('Testing PTY cleanup...\n');
46
+
47
+ // Test WITHOUT cleanup
48
+ console.log('=== Without proper cleanup ===');
49
+ const beforeNoCleanup = getPtyCount();
50
+ console.log(`PTY FDs before: ${beforeNoCleanup}`);
51
+
52
+ for (let i = 0; i < iterations; i++) {
53
+ await spawnAndCleanup(false);
54
+ }
55
+
56
+ // Give time for any cleanup
57
+ await new Promise(r => setTimeout(r, 500));
58
+ const afterNoCleanup = getPtyCount();
59
+ console.log(`PTY FDs after ${iterations} spawns: ${afterNoCleanup}`);
60
+ console.log(`Leaked: ${afterNoCleanup - beforeNoCleanup}\n`);
61
+
62
+ // Test WITH cleanup
63
+ console.log('=== With proper cleanup ===');
64
+ const beforeCleanup = getPtyCount();
65
+ console.log(`PTY FDs before: ${beforeCleanup}`);
66
+
67
+ for (let i = 0; i < iterations; i++) {
68
+ await spawnAndCleanup(true);
69
+ }
70
+
71
+ // Give time for any cleanup
72
+ await new Promise(r => setTimeout(r, 500));
73
+ const afterCleanup = getPtyCount();
74
+ console.log(`PTY FDs after ${iterations} spawns: ${afterCleanup}`);
75
+ console.log(`Leaked: ${afterCleanup - beforeCleanup}\n`);
76
+
77
+ // Summary
78
+ console.log('=== Summary ===');
79
+ if (afterCleanup - beforeCleanup === 0) {
80
+ console.log('✓ Proper cleanup prevents PTY leaks');
81
+ } else {
82
+ console.log('✗ Still leaking PTYs even with cleanup');
83
+ }
84
+ }
85
+
86
+ main().catch(console.error);
@@ -0,0 +1,232 @@
1
+ import { jest } from '@jest/globals';
2
+
3
+ // Mock execSync before importing the module
4
+ const mockExecSync = jest.fn();
5
+ jest.unstable_mockModule('node:child_process', () => ({
6
+ execSync: mockExecSync,
7
+ }));
8
+
9
+ // Mock logger to prevent console output
10
+ const mockLogger = {
11
+ debug: jest.fn(),
12
+ warn: jest.fn(),
13
+ error: jest.fn(),
14
+ };
15
+ jest.unstable_mockModule('../../src/utils/logger.js', () => ({
16
+ logger: mockLogger,
17
+ }));
18
+
19
+ // Import after mocking
20
+ const { commitPlanningArtifacts } = await import('../../src/core/git.js');
21
+
22
+ describe('commitPlanningArtifacts', () => {
23
+ beforeEach(() => {
24
+ jest.clearAllMocks();
25
+ });
26
+
27
+ it('should commit input.md and decisions.md with correct message format', async () => {
28
+ mockExecSync.mockImplementation((cmd: unknown) => {
29
+ const cmdStr = cmd as string;
30
+ if (cmdStr.includes('rev-parse')) {
31
+ return 'true\n';
32
+ }
33
+ if (cmdStr.includes('git add')) {
34
+ return '';
35
+ }
36
+ if (cmdStr.includes('git diff --cached')) {
37
+ return 'RAF/017-decision-vault/input.md\nRAF/017-decision-vault/decisions.md\n';
38
+ }
39
+ if (cmdStr.includes('git commit')) {
40
+ return '';
41
+ }
42
+ return '';
43
+ });
44
+
45
+ await commitPlanningArtifacts('/Users/test/RAF/017-decision-vault');
46
+
47
+ // Verify git add was called with both files
48
+ expect(mockExecSync).toHaveBeenCalledWith(
49
+ expect.stringContaining('git add'),
50
+ expect.any(Object)
51
+ );
52
+ const addCall = mockExecSync.mock.calls.find(
53
+ (call) => (call[0] as string).includes('git add')
54
+ );
55
+ expect(addCall?.[0]).toContain('input.md');
56
+ expect(addCall?.[0]).toContain('decisions.md');
57
+
58
+ // Verify commit message format
59
+ expect(mockExecSync).toHaveBeenCalledWith(
60
+ expect.stringMatching(/git commit -m "RAF\[017\] Plan: decision-vault"/),
61
+ expect.any(Object)
62
+ );
63
+ });
64
+
65
+ it('should handle base36 project numbers', async () => {
66
+ mockExecSync.mockImplementation((cmd: unknown) => {
67
+ const cmdStr = cmd as string;
68
+ if (cmdStr.includes('rev-parse')) {
69
+ return 'true\n';
70
+ }
71
+ if (cmdStr.includes('git add') || cmdStr.includes('git commit')) {
72
+ return '';
73
+ }
74
+ if (cmdStr.includes('git diff --cached')) {
75
+ return 'RAF/a01-feature/input.md\n';
76
+ }
77
+ return '';
78
+ });
79
+
80
+ await commitPlanningArtifacts('/Users/test/RAF/a01-my-feature');
81
+
82
+ expect(mockExecSync).toHaveBeenCalledWith(
83
+ expect.stringMatching(/git commit -m "RAF\[a01\] Plan: my-feature"/),
84
+ expect.any(Object)
85
+ );
86
+ });
87
+
88
+ it('should warn and return when not in git repository', async () => {
89
+ mockExecSync.mockImplementation(() => {
90
+ throw new Error('not a git repository');
91
+ });
92
+
93
+ await commitPlanningArtifacts('/Users/test/RAF/017-decision-vault');
94
+
95
+ expect(mockLogger.warn).toHaveBeenCalledWith(
96
+ 'Not in a git repository, skipping planning artifacts commit'
97
+ );
98
+ });
99
+
100
+ it('should warn when project number cannot be extracted', async () => {
101
+ mockExecSync.mockImplementation((cmd: unknown) => {
102
+ const cmdStr = cmd as string;
103
+ if (cmdStr.includes('rev-parse')) {
104
+ return 'true\n';
105
+ }
106
+ return '';
107
+ });
108
+
109
+ await commitPlanningArtifacts('/Users/test/RAF/invalid-project');
110
+
111
+ expect(mockLogger.warn).toHaveBeenCalledWith(
112
+ 'Could not extract project number or name from path, skipping commit'
113
+ );
114
+ });
115
+
116
+ it('should handle "nothing to commit" gracefully', async () => {
117
+ mockExecSync.mockImplementation((cmd: unknown) => {
118
+ const cmdStr = cmd as string;
119
+ if (cmdStr.includes('rev-parse')) {
120
+ return 'true\n';
121
+ }
122
+ if (cmdStr.includes('git add')) {
123
+ return '';
124
+ }
125
+ if (cmdStr.includes('git diff --cached')) {
126
+ return ''; // No staged changes
127
+ }
128
+ return '';
129
+ });
130
+
131
+ await commitPlanningArtifacts('/Users/test/RAF/017-decision-vault');
132
+
133
+ // Should log debug message and not throw
134
+ expect(mockLogger.debug).toHaveBeenCalledWith(
135
+ 'No changes to planning artifacts to commit'
136
+ );
137
+ expect(mockLogger.warn).not.toHaveBeenCalled();
138
+ });
139
+
140
+ it('should handle commit error with "nothing to commit" message', async () => {
141
+ mockExecSync.mockImplementation((cmd: unknown) => {
142
+ const cmdStr = cmd as string;
143
+ if (cmdStr.includes('rev-parse')) {
144
+ return 'true\n';
145
+ }
146
+ if (cmdStr.includes('git add')) {
147
+ return '';
148
+ }
149
+ if (cmdStr.includes('git diff --cached')) {
150
+ return 'RAF/017-decision-vault/input.md\n';
151
+ }
152
+ if (cmdStr.includes('git commit')) {
153
+ throw new Error('nothing to commit, working tree clean');
154
+ }
155
+ return '';
156
+ });
157
+
158
+ await commitPlanningArtifacts('/Users/test/RAF/017-decision-vault');
159
+
160
+ // Should log debug message for "nothing to commit"
161
+ expect(mockLogger.debug).toHaveBeenCalledWith(
162
+ 'Planning artifacts already committed or no changes'
163
+ );
164
+ expect(mockLogger.warn).not.toHaveBeenCalled();
165
+ });
166
+
167
+ it('should warn on other git errors without throwing', async () => {
168
+ mockExecSync.mockImplementation((cmd: unknown) => {
169
+ const cmdStr = cmd as string;
170
+ if (cmdStr.includes('rev-parse')) {
171
+ return 'true\n';
172
+ }
173
+ if (cmdStr.includes('git add')) {
174
+ return '';
175
+ }
176
+ if (cmdStr.includes('git diff --cached')) {
177
+ return 'RAF/017-decision-vault/input.md\n';
178
+ }
179
+ if (cmdStr.includes('git commit')) {
180
+ throw new Error('commit failed for unknown reason');
181
+ }
182
+ return '';
183
+ });
184
+
185
+ // Should not throw
186
+ await expect(
187
+ commitPlanningArtifacts('/Users/test/RAF/017-decision-vault')
188
+ ).resolves.toBeUndefined();
189
+
190
+ // Should log warning
191
+ expect(mockLogger.warn).toHaveBeenCalledWith(
192
+ expect.stringContaining('Failed to commit planning artifacts')
193
+ );
194
+ });
195
+
196
+ it('should only stage input.md and decisions.md', async () => {
197
+ mockExecSync.mockImplementation((cmd: unknown) => {
198
+ const cmdStr = cmd as string;
199
+ if (cmdStr.includes('rev-parse')) {
200
+ return 'true\n';
201
+ }
202
+ if (cmdStr.includes('git add')) {
203
+ return '';
204
+ }
205
+ if (cmdStr.includes('git diff --cached')) {
206
+ return 'RAF/017-decision-vault/input.md\n';
207
+ }
208
+ if (cmdStr.includes('git commit')) {
209
+ return '';
210
+ }
211
+ return '';
212
+ });
213
+
214
+ await commitPlanningArtifacts('/Users/test/RAF/017-decision-vault');
215
+
216
+ // Verify git add was called with explicit file paths
217
+ const addCall = mockExecSync.mock.calls.find(
218
+ (call) => (call[0] as string).includes('git add')
219
+ );
220
+ expect(addCall).toBeDefined();
221
+ const addCmd = addCall?.[0] as string;
222
+
223
+ // Should contain explicit file paths
224
+ expect(addCmd).toContain('/Users/test/RAF/017-decision-vault/input.md');
225
+ expect(addCmd).toContain('/Users/test/RAF/017-decision-vault/decisions.md');
226
+
227
+ // Should NOT use wildcards or add all
228
+ expect(addCmd).not.toContain('-A');
229
+ expect(addCmd).not.toContain('--all');
230
+ expect(addCmd).not.toContain('*');
231
+ });
232
+ });