wiggum-cli 0.13.2 → 0.15.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.
- package/README.md +24 -5
- package/dist/ai/providers.js +19 -14
- package/dist/commands/run.d.ts +1 -1
- package/dist/commands/run.js +2 -2
- package/dist/index.js +7 -1
- package/dist/repl/session-state.d.ts +2 -0
- package/dist/templates/config/ralph.config.cjs.tmpl +1 -1
- package/dist/templates/prompts/PROMPT_review_auto.md.tmpl +7 -41
- package/dist/templates/prompts/PROMPT_review_merge.md.tmpl +163 -0
- package/dist/templates/scripts/feature-loop-actions.test.ts +92 -0
- package/dist/templates/scripts/feature-loop.sh.tmpl +236 -9
- package/dist/tui/app.js +22 -3
- package/dist/tui/components/ChatInput.d.ts +3 -1
- package/dist/tui/components/ChatInput.js +50 -13
- package/dist/tui/components/CommandDropdown.d.ts +3 -1
- package/dist/tui/components/CommandDropdown.js +10 -7
- package/dist/tui/components/RunCompletionSummary.d.ts +3 -9
- package/dist/tui/components/RunCompletionSummary.js +59 -14
- package/dist/tui/components/SpecCompletionSummary.d.ts +3 -2
- package/dist/tui/components/SpecCompletionSummary.js +26 -9
- package/dist/tui/components/SummaryBox.d.ts +56 -0
- package/dist/tui/components/SummaryBox.js +99 -0
- package/dist/tui/orchestration/interview-orchestrator.js +35 -5
- package/dist/tui/screens/MainShell.js +25 -3
- package/dist/tui/screens/RunScreen.d.ts +116 -1
- package/dist/tui/screens/RunScreen.js +114 -17
- package/dist/tui/utils/action-inbox.d.ts +43 -0
- package/dist/tui/utils/action-inbox.js +109 -0
- package/dist/tui/utils/build-run-summary.d.ts +24 -0
- package/dist/tui/utils/build-run-summary.js +241 -0
- package/dist/tui/utils/git-summary.d.ts +24 -0
- package/dist/tui/utils/git-summary.js +63 -0
- package/dist/tui/utils/input-utils.d.ts +20 -0
- package/dist/tui/utils/input-utils.js +27 -0
- package/dist/tui/utils/polishGoal.d.ts +37 -0
- package/dist/tui/utils/polishGoal.js +170 -0
- package/dist/tui/utils/pr-summary.d.ts +34 -0
- package/dist/tui/utils/pr-summary.js +84 -0
- package/dist/utils/config.d.ts +1 -1
- package/dist/utils/fuzzy-match.d.ts +5 -0
- package/dist/utils/fuzzy-match.js +16 -0
- package/dist/utils/spec-names.d.ts +6 -0
- package/dist/utils/spec-names.js +23 -0
- package/dist/utils/summary-file.d.ts +25 -0
- package/dist/utils/summary-file.js +37 -0
- package/package.json +9 -4
- package/src/templates/config/ralph.config.cjs.tmpl +1 -1
- package/src/templates/prompts/PROMPT_review_auto.md.tmpl +7 -41
- package/src/templates/prompts/PROMPT_review_merge.md.tmpl +163 -0
- package/src/templates/scripts/feature-loop-actions.test.ts +92 -0
- package/src/templates/scripts/feature-loop.sh.tmpl +236 -9
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git utilities for enhanced run summary.
|
|
3
|
+
*/
|
|
4
|
+
export interface FileDiffStat {
|
|
5
|
+
path: string;
|
|
6
|
+
added: number;
|
|
7
|
+
removed: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Get the current commit hash (HEAD).
|
|
11
|
+
*
|
|
12
|
+
* @param projectRoot - Root directory of the git repository
|
|
13
|
+
* @returns Short commit hash, or null if not available
|
|
14
|
+
*/
|
|
15
|
+
export declare function getCurrentCommitHash(projectRoot: string): string | null;
|
|
16
|
+
/**
|
|
17
|
+
* Get diff stats between two commits.
|
|
18
|
+
*
|
|
19
|
+
* @param projectRoot - Root directory of the git repository
|
|
20
|
+
* @param fromHash - Starting commit hash
|
|
21
|
+
* @param toHash - Ending commit hash
|
|
22
|
+
* @returns Array of file diff stats, or null if not available
|
|
23
|
+
*/
|
|
24
|
+
export declare function getDiffStats(projectRoot: string, fromHash: string, toHash: string): FileDiffStat[] | null;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git utilities for enhanced run summary.
|
|
3
|
+
*/
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { logger } from '../../utils/logger.js';
|
|
6
|
+
/**
|
|
7
|
+
* Get the current commit hash (HEAD).
|
|
8
|
+
*
|
|
9
|
+
* @param projectRoot - Root directory of the git repository
|
|
10
|
+
* @returns Short commit hash, or null if not available
|
|
11
|
+
*/
|
|
12
|
+
export function getCurrentCommitHash(projectRoot) {
|
|
13
|
+
try {
|
|
14
|
+
const hash = execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
|
|
15
|
+
cwd: projectRoot,
|
|
16
|
+
encoding: 'utf-8',
|
|
17
|
+
timeout: 10_000,
|
|
18
|
+
}).trim();
|
|
19
|
+
return hash || null;
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
logger.warn(`getCurrentCommitHash failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Get diff stats between two commits.
|
|
28
|
+
*
|
|
29
|
+
* @param projectRoot - Root directory of the git repository
|
|
30
|
+
* @param fromHash - Starting commit hash
|
|
31
|
+
* @param toHash - Ending commit hash
|
|
32
|
+
* @returns Array of file diff stats, or null if not available
|
|
33
|
+
*/
|
|
34
|
+
export function getDiffStats(projectRoot, fromHash, toHash) {
|
|
35
|
+
try {
|
|
36
|
+
const output = execFileSync('git', ['diff', '--numstat', `${fromHash}..${toHash}`], {
|
|
37
|
+
cwd: projectRoot,
|
|
38
|
+
encoding: 'utf-8',
|
|
39
|
+
timeout: 10_000,
|
|
40
|
+
}).trim();
|
|
41
|
+
if (!output) {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
const stats = [];
|
|
45
|
+
const lines = output.split('\n');
|
|
46
|
+
for (const line of lines) {
|
|
47
|
+
// Format: <added>\t<removed>\t<path>
|
|
48
|
+
const parts = line.split('\t');
|
|
49
|
+
if (parts.length !== 3)
|
|
50
|
+
continue;
|
|
51
|
+
const [addedStr, removedStr, path] = parts;
|
|
52
|
+
// Binary files show '-' for added/removed
|
|
53
|
+
const added = addedStr === '-' ? 0 : parseInt(addedStr, 10) || 0;
|
|
54
|
+
const removed = removedStr === '-' ? 0 : parseInt(removedStr, 10) || 0;
|
|
55
|
+
stats.push({ path, added, removed });
|
|
56
|
+
}
|
|
57
|
+
return stats;
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
logger.warn(`getDiffStats failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -104,6 +104,26 @@ export declare function deleteCharAfter(value: string, cursorIndex: number): Cur
|
|
|
104
104
|
* moveCursorByWordLeft("test", 0) // => 0 (no-op at start)
|
|
105
105
|
* ```
|
|
106
106
|
*/
|
|
107
|
+
/**
|
|
108
|
+
* Deletes the word before the cursor (Ctrl+W behavior)
|
|
109
|
+
*
|
|
110
|
+
* Skips trailing whitespace, then deletes the preceding word.
|
|
111
|
+
* Uses moveCursorByWordLeft to find the word boundary.
|
|
112
|
+
*
|
|
113
|
+
* @param value - Current input value
|
|
114
|
+
* @param cursorIndex - Current cursor position
|
|
115
|
+
* @returns New value and cursor index after deletion
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```ts
|
|
119
|
+
* deleteWordBefore("hello world", 11)
|
|
120
|
+
* // => { newValue: "hello ", newCursorIndex: 6 }
|
|
121
|
+
*
|
|
122
|
+
* deleteWordBefore("hello world", 0)
|
|
123
|
+
* // => { newValue: "hello world", newCursorIndex: 0 } (no-op at start)
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
126
|
+
export declare function deleteWordBefore(value: string, cursorIndex: number): CursorManipulationResult;
|
|
107
127
|
export declare function moveCursorByWordLeft(value: string, cursorIndex: number): number;
|
|
108
128
|
/**
|
|
109
129
|
* Moves cursor to the end of the next word (word-right navigation)
|
|
@@ -134,6 +134,33 @@ export function deleteCharAfter(value, cursorIndex) {
|
|
|
134
134
|
* moveCursorByWordLeft("test", 0) // => 0 (no-op at start)
|
|
135
135
|
* ```
|
|
136
136
|
*/
|
|
137
|
+
/**
|
|
138
|
+
* Deletes the word before the cursor (Ctrl+W behavior)
|
|
139
|
+
*
|
|
140
|
+
* Skips trailing whitespace, then deletes the preceding word.
|
|
141
|
+
* Uses moveCursorByWordLeft to find the word boundary.
|
|
142
|
+
*
|
|
143
|
+
* @param value - Current input value
|
|
144
|
+
* @param cursorIndex - Current cursor position
|
|
145
|
+
* @returns New value and cursor index after deletion
|
|
146
|
+
*
|
|
147
|
+
* @example
|
|
148
|
+
* ```ts
|
|
149
|
+
* deleteWordBefore("hello world", 11)
|
|
150
|
+
* // => { newValue: "hello ", newCursorIndex: 6 }
|
|
151
|
+
*
|
|
152
|
+
* deleteWordBefore("hello world", 0)
|
|
153
|
+
* // => { newValue: "hello world", newCursorIndex: 0 } (no-op at start)
|
|
154
|
+
* ```
|
|
155
|
+
*/
|
|
156
|
+
export function deleteWordBefore(value, cursorIndex) {
|
|
157
|
+
if (cursorIndex <= 0) {
|
|
158
|
+
return { newValue: value, newCursorIndex: 0 };
|
|
159
|
+
}
|
|
160
|
+
const newCursorIndex = moveCursorByWordLeft(value, cursorIndex);
|
|
161
|
+
const newValue = value.slice(0, newCursorIndex) + value.slice(cursorIndex);
|
|
162
|
+
return { newValue, newCursorIndex };
|
|
163
|
+
}
|
|
137
164
|
export function moveCursorByWordLeft(value, cursorIndex) {
|
|
138
165
|
let idx = cursorIndex;
|
|
139
166
|
// Skip trailing whitespace
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* polishGoal — Pure utility functions for generating a polished, imperative
|
|
3
|
+
* single-sentence Goal line in the spec completion summary.
|
|
4
|
+
*
|
|
5
|
+
* No AI calls, no side-effects. Deterministic string transformations only.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* selectGoalSource — Choose the best source for the goal line from a
|
|
9
|
+
* structured set of candidates, applying the spec's 3-tier fallback chain:
|
|
10
|
+
* 1. AI recap (if non-empty/non-whitespace)
|
|
11
|
+
* 2. Key decisions (if non-empty/non-whitespace after normalisation)
|
|
12
|
+
* 3. User request (fallback)
|
|
13
|
+
*
|
|
14
|
+
* For `keyDecisions`, bullet prefixes are stripped and fragments joined with `; `.
|
|
15
|
+
*/
|
|
16
|
+
export declare function selectGoalSource(opts: {
|
|
17
|
+
aiRecap: string;
|
|
18
|
+
keyDecisions: string | string[];
|
|
19
|
+
userRequest: string;
|
|
20
|
+
}): {
|
|
21
|
+
source: 'ai' | 'decisions' | 'user';
|
|
22
|
+
text: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* polishGoalSentence — Transform any goal source text into a polished,
|
|
26
|
+
* imperative, single-sentence string ending with a period.
|
|
27
|
+
*
|
|
28
|
+
* Steps applied (all deterministic):
|
|
29
|
+
* 1. Whitespace normalization
|
|
30
|
+
* 2. Strip trailing ellipses
|
|
31
|
+
* 3. Remove leading framing phrases ("I want to …", "We will …", etc.)
|
|
32
|
+
* 4. Single-sentence enforcement (take first sentence)
|
|
33
|
+
* 5. Imperative verb enforcement (prepend "Implement " if needed)
|
|
34
|
+
* 6. Capitalise first letter
|
|
35
|
+
* 7. Ensure exactly one trailing period
|
|
36
|
+
*/
|
|
37
|
+
export declare function polishGoalSentence(text: string): string;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* polishGoal — Pure utility functions for generating a polished, imperative
|
|
3
|
+
* single-sentence Goal line in the spec completion summary.
|
|
4
|
+
*
|
|
5
|
+
* No AI calls, no side-effects. Deterministic string transformations only.
|
|
6
|
+
*/
|
|
7
|
+
/** Allowed imperative verbs that may begin a polished goal sentence. */
|
|
8
|
+
const IMPERATIVE_VERBS = [
|
|
9
|
+
'Implement',
|
|
10
|
+
'Add',
|
|
11
|
+
'Improve',
|
|
12
|
+
'Fix',
|
|
13
|
+
'Refactor',
|
|
14
|
+
'Support',
|
|
15
|
+
'Enable',
|
|
16
|
+
'Create',
|
|
17
|
+
'Update',
|
|
18
|
+
'Build',
|
|
19
|
+
'Extend',
|
|
20
|
+
'Migrate',
|
|
21
|
+
'Remove',
|
|
22
|
+
'Replace',
|
|
23
|
+
'Integrate',
|
|
24
|
+
'Define',
|
|
25
|
+
'Achieve',
|
|
26
|
+
'Allow',
|
|
27
|
+
'Complete',
|
|
28
|
+
'Configure',
|
|
29
|
+
'Deploy',
|
|
30
|
+
'Design',
|
|
31
|
+
'Ensure',
|
|
32
|
+
'Generate',
|
|
33
|
+
'Handle',
|
|
34
|
+
'Introduce',
|
|
35
|
+
'Make',
|
|
36
|
+
'Optimize',
|
|
37
|
+
'Provide',
|
|
38
|
+
'Redesign',
|
|
39
|
+
'Restructure',
|
|
40
|
+
'Set',
|
|
41
|
+
'Show',
|
|
42
|
+
'Use',
|
|
43
|
+
];
|
|
44
|
+
const IMPERATIVE_VERB_PATTERN = new RegExp(`^(${IMPERATIVE_VERBS.join('|')})\\b`, 'i');
|
|
45
|
+
/** Leading framing-phrase patterns to strip/rewrite, each returning a cleaned fragment. */
|
|
46
|
+
const FRAMING_PATTERNS = [
|
|
47
|
+
[/^i want to\s*/i, ''],
|
|
48
|
+
[/^i'd like to\s*/i, ''],
|
|
49
|
+
[/^i would like to\s*/i, ''],
|
|
50
|
+
[/^we will\s*/i, ''],
|
|
51
|
+
[/^we want to\s*/i, ''],
|
|
52
|
+
[/^we need to\s*/i, ''],
|
|
53
|
+
[/^you're\s+\w+ing\s+to\s*/i, ''],
|
|
54
|
+
[/^you'd like to\s*/i, ''],
|
|
55
|
+
[/^you want to\s*/i, ''],
|
|
56
|
+
[/^you need to\s*/i, ''],
|
|
57
|
+
[/^this spec covers\s*/i, ''],
|
|
58
|
+
[/^this spec describes\s*/i, ''],
|
|
59
|
+
[/^the goal is to\s*/i, ''],
|
|
60
|
+
// Handles normalizeRecap output like "To build …" (stripped "you want" prefix)
|
|
61
|
+
[/^to\s+/i, ''],
|
|
62
|
+
];
|
|
63
|
+
/** Strip bullet prefixes (`-`, `*`, `1.`, `•`) from a single line. */
|
|
64
|
+
function stripBulletPrefix(line) {
|
|
65
|
+
return line.replace(/^(\s*[-*•]|\s*\d+[.)]\s*)\s*/, '').trim();
|
|
66
|
+
}
|
|
67
|
+
/** Collapse multi-space/newline whitespace to a single space and trim. */
|
|
68
|
+
function normalizeWhitespace(text) {
|
|
69
|
+
return text.replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
|
70
|
+
}
|
|
71
|
+
/** Return true if this part looks like an abbreviation fragment (e.g. "e.g", "i.e", "vs"). */
|
|
72
|
+
function looksLikeAbbreviation(fragment) {
|
|
73
|
+
return /^(e\.g|i\.e|etc|vs|mr|mrs|dr|prof|no|vol|fig|ch|approx|est)$/i.test(fragment.trim());
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Enforce single-sentence: split conservatively on `. ` boundaries, skipping
|
|
77
|
+
* abbreviation-like fragments, then return only the first sentence.
|
|
78
|
+
*/
|
|
79
|
+
function toOneSentence(text) {
|
|
80
|
+
// Split on ". " followed by an uppercase letter — conservative heuristic
|
|
81
|
+
const parts = text.split(/(?<=\b\w{3,})\. (?=[A-Z])/);
|
|
82
|
+
if (parts.length <= 1)
|
|
83
|
+
return text;
|
|
84
|
+
// Take first part only
|
|
85
|
+
const first = parts[0];
|
|
86
|
+
// Guard against abbreviations causing false splits
|
|
87
|
+
if (looksLikeAbbreviation(first))
|
|
88
|
+
return text;
|
|
89
|
+
return first;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* selectGoalSource — Choose the best source for the goal line from a
|
|
93
|
+
* structured set of candidates, applying the spec's 3-tier fallback chain:
|
|
94
|
+
* 1. AI recap (if non-empty/non-whitespace)
|
|
95
|
+
* 2. Key decisions (if non-empty/non-whitespace after normalisation)
|
|
96
|
+
* 3. User request (fallback)
|
|
97
|
+
*
|
|
98
|
+
* For `keyDecisions`, bullet prefixes are stripped and fragments joined with `; `.
|
|
99
|
+
*/
|
|
100
|
+
export function selectGoalSource(opts) {
|
|
101
|
+
const { aiRecap, keyDecisions, userRequest } = opts;
|
|
102
|
+
// 1. AI recap
|
|
103
|
+
const normalizedAi = normalizeWhitespace(aiRecap);
|
|
104
|
+
if (normalizedAi.length > 0) {
|
|
105
|
+
return { source: 'ai', text: normalizedAi };
|
|
106
|
+
}
|
|
107
|
+
// 2. Key decisions
|
|
108
|
+
const decisionsArr = Array.isArray(keyDecisions)
|
|
109
|
+
? keyDecisions
|
|
110
|
+
: keyDecisions
|
|
111
|
+
? [keyDecisions]
|
|
112
|
+
: [];
|
|
113
|
+
const cleanedDecisions = decisionsArr
|
|
114
|
+
.map((d) => stripBulletPrefix(normalizeWhitespace(d)))
|
|
115
|
+
.filter((d) => d.length > 0);
|
|
116
|
+
if (cleanedDecisions.length > 0) {
|
|
117
|
+
const joined = cleanedDecisions.join('; ');
|
|
118
|
+
return { source: 'decisions', text: joined };
|
|
119
|
+
}
|
|
120
|
+
// 3. User request
|
|
121
|
+
return { source: 'user', text: normalizeWhitespace(userRequest) };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* polishGoalSentence — Transform any goal source text into a polished,
|
|
125
|
+
* imperative, single-sentence string ending with a period.
|
|
126
|
+
*
|
|
127
|
+
* Steps applied (all deterministic):
|
|
128
|
+
* 1. Whitespace normalization
|
|
129
|
+
* 2. Strip trailing ellipses
|
|
130
|
+
* 3. Remove leading framing phrases ("I want to …", "We will …", etc.)
|
|
131
|
+
* 4. Single-sentence enforcement (take first sentence)
|
|
132
|
+
* 5. Imperative verb enforcement (prepend "Implement " if needed)
|
|
133
|
+
* 6. Capitalise first letter
|
|
134
|
+
* 7. Ensure exactly one trailing period
|
|
135
|
+
*/
|
|
136
|
+
export function polishGoalSentence(text) {
|
|
137
|
+
if (!text || text.trim().length === 0) {
|
|
138
|
+
return 'Implement the requested feature.';
|
|
139
|
+
}
|
|
140
|
+
// 1. Whitespace normalization
|
|
141
|
+
let result = normalizeWhitespace(text);
|
|
142
|
+
// 2. Strip trailing ellipses
|
|
143
|
+
result = result.replace(/\.{2,}$/, '').replace(/…$/, '').trim();
|
|
144
|
+
// 3. Remove leading framing phrases iteratively (apply first matching pattern)
|
|
145
|
+
for (const [pattern, replacement] of FRAMING_PATTERNS) {
|
|
146
|
+
if (pattern.test(result)) {
|
|
147
|
+
result = result.replace(pattern, replacement).trim();
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Guard: if stripping left nothing (e.g. "I want to" with no continuation)
|
|
152
|
+
if (result.length === 0) {
|
|
153
|
+
return 'Implement the requested feature.';
|
|
154
|
+
}
|
|
155
|
+
// 4. Single-sentence enforcement
|
|
156
|
+
result = toOneSentence(result);
|
|
157
|
+
// Strip any trailing sentence-ending punctuation before we add our own
|
|
158
|
+
result = result.replace(/[.!?]+$/, '').trim();
|
|
159
|
+
// 5. Imperative verb enforcement
|
|
160
|
+
if (!IMPERATIVE_VERB_PATTERN.test(result)) {
|
|
161
|
+
// Lowercase first char before prepending to avoid "Implement The thing"
|
|
162
|
+
result = result.charAt(0).toLowerCase() + result.slice(1);
|
|
163
|
+
result = `Implement ${result}`;
|
|
164
|
+
}
|
|
165
|
+
// 6. Capitalize first letter
|
|
166
|
+
result = result.charAt(0).toUpperCase() + result.slice(1);
|
|
167
|
+
// 7. Ensure exactly one trailing period
|
|
168
|
+
result = `${result}.`;
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR and Issue utilities for enhanced run summary.
|
|
3
|
+
*/
|
|
4
|
+
export interface PrInfo {
|
|
5
|
+
number: number;
|
|
6
|
+
url: string;
|
|
7
|
+
state: string;
|
|
8
|
+
title: string;
|
|
9
|
+
}
|
|
10
|
+
export interface IssueInfo {
|
|
11
|
+
number: number;
|
|
12
|
+
url: string;
|
|
13
|
+
state: string;
|
|
14
|
+
title: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Get PR information for a branch.
|
|
18
|
+
*
|
|
19
|
+
* @param projectRoot - Root directory of the git repository
|
|
20
|
+
* @param branchName - Branch name to look up
|
|
21
|
+
* @returns PR info object, or null if no PR exists for this branch
|
|
22
|
+
* @throws When gh CLI is unavailable or the command fails
|
|
23
|
+
*/
|
|
24
|
+
export declare function getPrForBranch(projectRoot: string, branchName: string): PrInfo | null;
|
|
25
|
+
/**
|
|
26
|
+
* Get linked issue for a branch by parsing the PR body for closing keywords
|
|
27
|
+
* (Closes/Fixes/Resolves #N).
|
|
28
|
+
*
|
|
29
|
+
* @param projectRoot - Root directory of the git repository
|
|
30
|
+
* @param branchName - Branch name to look up
|
|
31
|
+
* @param prInfo - Optional pre-fetched PR info to avoid redundant gh call
|
|
32
|
+
* @returns Issue info object, or null if not found or gh not available
|
|
33
|
+
*/
|
|
34
|
+
export declare function getLinkedIssue(projectRoot: string, branchName: string, prInfo?: PrInfo | null): IssueInfo | null;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR and Issue utilities for enhanced run summary.
|
|
3
|
+
*/
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { logger } from '../../utils/logger.js';
|
|
6
|
+
/**
|
|
7
|
+
* Get PR information for a branch.
|
|
8
|
+
*
|
|
9
|
+
* @param projectRoot - Root directory of the git repository
|
|
10
|
+
* @param branchName - Branch name to look up
|
|
11
|
+
* @returns PR info object, or null if no PR exists for this branch
|
|
12
|
+
* @throws When gh CLI is unavailable or the command fails
|
|
13
|
+
*/
|
|
14
|
+
export function getPrForBranch(projectRoot, branchName) {
|
|
15
|
+
// Use gh pr list to find PR for this branch
|
|
16
|
+
const output = execFileSync('gh', ['pr', 'list', '--head', branchName, '--state', 'all', '--json', 'number,url,state,title', '--limit', '1'], {
|
|
17
|
+
cwd: projectRoot,
|
|
18
|
+
encoding: 'utf-8',
|
|
19
|
+
timeout: 10_000,
|
|
20
|
+
}).trim();
|
|
21
|
+
if (!output) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const prs = JSON.parse(output);
|
|
25
|
+
if (!Array.isArray(prs) || prs.length === 0) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const pr = prs[0];
|
|
29
|
+
return {
|
|
30
|
+
number: pr.number,
|
|
31
|
+
url: pr.url,
|
|
32
|
+
state: pr.state,
|
|
33
|
+
title: pr.title,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Get linked issue for a branch by parsing the PR body for closing keywords
|
|
38
|
+
* (Closes/Fixes/Resolves #N).
|
|
39
|
+
*
|
|
40
|
+
* @param projectRoot - Root directory of the git repository
|
|
41
|
+
* @param branchName - Branch name to look up
|
|
42
|
+
* @param prInfo - Optional pre-fetched PR info to avoid redundant gh call
|
|
43
|
+
* @returns Issue info object, or null if not found or gh not available
|
|
44
|
+
*/
|
|
45
|
+
export function getLinkedIssue(projectRoot, branchName, prInfo) {
|
|
46
|
+
try {
|
|
47
|
+
// Use provided PR info or fetch it
|
|
48
|
+
const pr = prInfo !== undefined ? prInfo : getPrForBranch(projectRoot, branchName);
|
|
49
|
+
if (!pr) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
// Get the PR body to look for issue references
|
|
53
|
+
const prBody = execFileSync('gh', ['pr', 'view', String(pr.number), '--json', 'body'], {
|
|
54
|
+
cwd: projectRoot,
|
|
55
|
+
encoding: 'utf-8',
|
|
56
|
+
timeout: 10_000,
|
|
57
|
+
}).trim();
|
|
58
|
+
const prData = JSON.parse(prBody);
|
|
59
|
+
const body = prData.body || '';
|
|
60
|
+
// Look for issue references in PR body (e.g., "Closes #123", "Fixes #456")
|
|
61
|
+
const issueMatch = body.match(/(?:closes|fixes|resolves)\s+#(\d+)/i);
|
|
62
|
+
if (!issueMatch) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const issueNumber = parseInt(issueMatch[1], 10);
|
|
66
|
+
// Fetch the issue details
|
|
67
|
+
const issueOutput = execFileSync('gh', ['issue', 'view', String(issueNumber), '--json', 'number,url,state,title'], {
|
|
68
|
+
cwd: projectRoot,
|
|
69
|
+
encoding: 'utf-8',
|
|
70
|
+
timeout: 10_000,
|
|
71
|
+
}).trim();
|
|
72
|
+
const issue = JSON.parse(issueOutput);
|
|
73
|
+
return {
|
|
74
|
+
number: issue.number,
|
|
75
|
+
url: issue.url,
|
|
76
|
+
state: issue.state,
|
|
77
|
+
title: issue.title,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
logger.warn(`getLinkedIssue failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
package/dist/utils/config.d.ts
CHANGED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns true if all characters in `query` appear in `target` in order
|
|
3
|
+
* (case-insensitive). An empty query always matches.
|
|
4
|
+
*/
|
|
5
|
+
export function fuzzyMatch(query, target) {
|
|
6
|
+
if (query.length === 0)
|
|
7
|
+
return true;
|
|
8
|
+
const q = query.toLowerCase();
|
|
9
|
+
const t = target.toLowerCase();
|
|
10
|
+
let qi = 0;
|
|
11
|
+
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
|
|
12
|
+
if (t[ti] === q[qi])
|
|
13
|
+
qi++;
|
|
14
|
+
}
|
|
15
|
+
return qi === q.length;
|
|
16
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads top-level .md files from the given directory and returns their names
|
|
3
|
+
* without the .md extension, sorted alphabetically.
|
|
4
|
+
* Returns an empty array if the directory does not exist or is unreadable.
|
|
5
|
+
*/
|
|
6
|
+
export declare function listSpecNames(specsDir: string): Promise<string[]>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { logger } from './logger.js';
|
|
3
|
+
/**
|
|
4
|
+
* Reads top-level .md files from the given directory and returns their names
|
|
5
|
+
* without the .md extension, sorted alphabetically.
|
|
6
|
+
* Returns an empty array if the directory does not exist or is unreadable.
|
|
7
|
+
*/
|
|
8
|
+
export async function listSpecNames(specsDir) {
|
|
9
|
+
let entries;
|
|
10
|
+
try {
|
|
11
|
+
entries = await readdir(specsDir, { withFileTypes: true });
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
if (err instanceof Error && 'code' in err && err.code !== 'ENOENT') {
|
|
15
|
+
logger.debug(`Failed to list spec names from ${specsDir}: ${err.message}`);
|
|
16
|
+
}
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
return entries
|
|
20
|
+
.filter((e) => e.isFile() && e.name.endsWith('.md'))
|
|
21
|
+
.map((e) => e.name.slice(0, -3))
|
|
22
|
+
.sort();
|
|
23
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Summary File Writer
|
|
3
|
+
* Persists enhanced run summaries to JSON files for later retrieval
|
|
4
|
+
*/
|
|
5
|
+
import type { RunSummary } from '../tui/screens/RunScreen.js';
|
|
6
|
+
/**
|
|
7
|
+
* Write enhanced run summary to a JSON file in the temp directory
|
|
8
|
+
*
|
|
9
|
+
* @param featureName - The feature name used to construct the file path
|
|
10
|
+
* @param summary - The complete RunSummary object to persist
|
|
11
|
+
* @returns Promise that resolves when the file is written, or rejects on error
|
|
12
|
+
*
|
|
13
|
+
* Uses RALPH_SUMMARY_TMP_DIR environment variable if set, otherwise os.tmpdir().
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* await writeRunSummaryFile('my-feature', {
|
|
18
|
+
* feature: 'my-feature',
|
|
19
|
+
* exitCode: 0,
|
|
20
|
+
* // ... other RunSummary fields
|
|
21
|
+
* });
|
|
22
|
+
* // Writes to: <tmpdir>/ralph-loop-my-feature.summary.json
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export declare function writeRunSummaryFile(featureName: string, summary: RunSummary): Promise<void>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Summary File Writer
|
|
3
|
+
* Persists enhanced run summaries to JSON files for later retrieval
|
|
4
|
+
*/
|
|
5
|
+
import { writeFile } from 'node:fs/promises';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { logger } from './logger.js';
|
|
9
|
+
/**
|
|
10
|
+
* Write enhanced run summary to a JSON file in the temp directory
|
|
11
|
+
*
|
|
12
|
+
* @param featureName - The feature name used to construct the file path
|
|
13
|
+
* @param summary - The complete RunSummary object to persist
|
|
14
|
+
* @returns Promise that resolves when the file is written, or rejects on error
|
|
15
|
+
*
|
|
16
|
+
* Uses RALPH_SUMMARY_TMP_DIR environment variable if set, otherwise os.tmpdir().
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```ts
|
|
20
|
+
* await writeRunSummaryFile('my-feature', {
|
|
21
|
+
* feature: 'my-feature',
|
|
22
|
+
* exitCode: 0,
|
|
23
|
+
* // ... other RunSummary fields
|
|
24
|
+
* });
|
|
25
|
+
* // Writes to: <tmpdir>/ralph-loop-my-feature.summary.json
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export async function writeRunSummaryFile(featureName, summary) {
|
|
29
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(featureName)) {
|
|
30
|
+
throw new Error(`Invalid feature name: "${featureName}"`);
|
|
31
|
+
}
|
|
32
|
+
const dir = process.env.RALPH_SUMMARY_TMP_DIR ?? tmpdir();
|
|
33
|
+
const filePath = join(dir, `ralph-loop-${featureName}.summary.json`);
|
|
34
|
+
const jsonContent = JSON.stringify(summary, null, 2);
|
|
35
|
+
await writeFile(filePath, jsonContent, 'utf8');
|
|
36
|
+
logger.debug(`Summary written to ${filePath}`);
|
|
37
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiggum-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "AI-powered feature development loop CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -26,11 +26,16 @@
|
|
|
26
26
|
},
|
|
27
27
|
"keywords": [
|
|
28
28
|
"cli",
|
|
29
|
-
"ai",
|
|
30
|
-
"
|
|
31
|
-
"
|
|
29
|
+
"ai-agent",
|
|
30
|
+
"autonomous-coding",
|
|
31
|
+
"ralph-loop",
|
|
32
|
+
"spec-generation",
|
|
33
|
+
"claude-code",
|
|
34
|
+
"codex",
|
|
35
|
+
"ai-coding",
|
|
32
36
|
"feature-loop",
|
|
33
37
|
"code-generation",
|
|
38
|
+
"developer-tools",
|
|
34
39
|
"tech-stack-detection"
|
|
35
40
|
],
|
|
36
41
|
"repository": {
|
|
@@ -34,6 +34,6 @@ module.exports = {
|
|
|
34
34
|
maxE2eAttempts: 5,
|
|
35
35
|
defaultModel: 'sonnet',
|
|
36
36
|
planningModel: 'opus',
|
|
37
|
-
reviewMode: 'manual', // 'manual' = stop at PR, 'auto' = review + auto-merge
|
|
37
|
+
reviewMode: 'manual', // 'manual' = stop at PR, 'auto' = review (no merge), 'merge' = review + auto-merge
|
|
38
38
|
},
|
|
39
39
|
};
|