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.
Files changed (51) hide show
  1. package/README.md +24 -5
  2. package/dist/ai/providers.js +19 -14
  3. package/dist/commands/run.d.ts +1 -1
  4. package/dist/commands/run.js +2 -2
  5. package/dist/index.js +7 -1
  6. package/dist/repl/session-state.d.ts +2 -0
  7. package/dist/templates/config/ralph.config.cjs.tmpl +1 -1
  8. package/dist/templates/prompts/PROMPT_review_auto.md.tmpl +7 -41
  9. package/dist/templates/prompts/PROMPT_review_merge.md.tmpl +163 -0
  10. package/dist/templates/scripts/feature-loop-actions.test.ts +92 -0
  11. package/dist/templates/scripts/feature-loop.sh.tmpl +236 -9
  12. package/dist/tui/app.js +22 -3
  13. package/dist/tui/components/ChatInput.d.ts +3 -1
  14. package/dist/tui/components/ChatInput.js +50 -13
  15. package/dist/tui/components/CommandDropdown.d.ts +3 -1
  16. package/dist/tui/components/CommandDropdown.js +10 -7
  17. package/dist/tui/components/RunCompletionSummary.d.ts +3 -9
  18. package/dist/tui/components/RunCompletionSummary.js +59 -14
  19. package/dist/tui/components/SpecCompletionSummary.d.ts +3 -2
  20. package/dist/tui/components/SpecCompletionSummary.js +26 -9
  21. package/dist/tui/components/SummaryBox.d.ts +56 -0
  22. package/dist/tui/components/SummaryBox.js +99 -0
  23. package/dist/tui/orchestration/interview-orchestrator.js +35 -5
  24. package/dist/tui/screens/MainShell.js +25 -3
  25. package/dist/tui/screens/RunScreen.d.ts +116 -1
  26. package/dist/tui/screens/RunScreen.js +114 -17
  27. package/dist/tui/utils/action-inbox.d.ts +43 -0
  28. package/dist/tui/utils/action-inbox.js +109 -0
  29. package/dist/tui/utils/build-run-summary.d.ts +24 -0
  30. package/dist/tui/utils/build-run-summary.js +241 -0
  31. package/dist/tui/utils/git-summary.d.ts +24 -0
  32. package/dist/tui/utils/git-summary.js +63 -0
  33. package/dist/tui/utils/input-utils.d.ts +20 -0
  34. package/dist/tui/utils/input-utils.js +27 -0
  35. package/dist/tui/utils/polishGoal.d.ts +37 -0
  36. package/dist/tui/utils/polishGoal.js +170 -0
  37. package/dist/tui/utils/pr-summary.d.ts +34 -0
  38. package/dist/tui/utils/pr-summary.js +84 -0
  39. package/dist/utils/config.d.ts +1 -1
  40. package/dist/utils/fuzzy-match.d.ts +5 -0
  41. package/dist/utils/fuzzy-match.js +16 -0
  42. package/dist/utils/spec-names.d.ts +6 -0
  43. package/dist/utils/spec-names.js +23 -0
  44. package/dist/utils/summary-file.d.ts +25 -0
  45. package/dist/utils/summary-file.js +37 -0
  46. package/package.json +9 -4
  47. package/src/templates/config/ralph.config.cjs.tmpl +1 -1
  48. package/src/templates/prompts/PROMPT_review_auto.md.tmpl +7 -41
  49. package/src/templates/prompts/PROMPT_review_merge.md.tmpl +163 -0
  50. package/src/templates/scripts/feature-loop-actions.test.ts +92 -0
  51. 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
+ }
@@ -48,7 +48,7 @@ export interface LoopConfig {
48
48
  maxE2eAttempts: number;
49
49
  defaultModel: string;
50
50
  planningModel: string;
51
- reviewMode: 'manual' | 'auto';
51
+ reviewMode: 'manual' | 'auto' | 'merge';
52
52
  }
53
53
  /**
54
54
  * Full ralph.config.cjs structure
@@ -0,0 +1,5 @@
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 declare function fuzzyMatch(query: string, target: string): boolean;
@@ -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.13.2",
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
- "development",
31
- "automation",
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
  };