wiggum-cli 0.14.0 → 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 (38) 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 +157 -7
  12. package/dist/tui/app.js +20 -2
  13. package/dist/tui/components/ChatInput.d.ts +3 -1
  14. package/dist/tui/components/ChatInput.js +23 -4
  15. package/dist/tui/components/CommandDropdown.d.ts +3 -1
  16. package/dist/tui/components/CommandDropdown.js +10 -7
  17. package/dist/tui/components/SpecCompletionSummary.d.ts +3 -2
  18. package/dist/tui/components/SpecCompletionSummary.js +26 -9
  19. package/dist/tui/components/SummaryBox.d.ts +0 -3
  20. package/dist/tui/components/SummaryBox.js +4 -2
  21. package/dist/tui/orchestration/interview-orchestrator.js +35 -5
  22. package/dist/tui/screens/MainShell.js +2 -1
  23. package/dist/tui/screens/RunScreen.js +81 -12
  24. package/dist/tui/utils/action-inbox.d.ts +43 -0
  25. package/dist/tui/utils/action-inbox.js +109 -0
  26. package/dist/tui/utils/polishGoal.d.ts +37 -0
  27. package/dist/tui/utils/polishGoal.js +170 -0
  28. package/dist/utils/config.d.ts +1 -1
  29. package/dist/utils/fuzzy-match.d.ts +5 -0
  30. package/dist/utils/fuzzy-match.js +16 -0
  31. package/dist/utils/spec-names.d.ts +6 -0
  32. package/dist/utils/spec-names.js +23 -0
  33. package/package.json +9 -4
  34. package/src/templates/config/ralph.config.cjs.tmpl +1 -1
  35. package/src/templates/prompts/PROMPT_review_auto.md.tmpl +7 -41
  36. package/src/templates/prompts/PROMPT_review_merge.md.tmpl +163 -0
  37. package/src/templates/scripts/feature-loop-actions.test.ts +92 -0
  38. package/src/templates/scripts/feature-loop.sh.tmpl +157 -7
@@ -18,6 +18,7 @@ import { execFileSync, spawn } from 'node:child_process';
18
18
  import { closeSync, existsSync, openSync, readFileSync } from 'node:fs';
19
19
  import { dirname, join } from 'node:path';
20
20
  import { Confirm } from '../components/Confirm.js';
21
+ import { Select } from '../components/Select.js';
21
22
  import { AppShell } from '../components/AppShell.js';
22
23
  import { RunCompletionSummary } from '../components/RunCompletionSummary.js';
23
24
  import { colors, theme } from '../theme.js';
@@ -26,6 +27,7 @@ import { buildEnhancedRunSummary } from '../utils/build-run-summary.js';
26
27
  import { writeRunSummaryFile } from '../../utils/summary-file.js';
27
28
  import { loadConfigWithDefaults } from '../../utils/config.js';
28
29
  import { logger } from '../../utils/logger.js';
30
+ import { readActionRequest, writeActionReply, cleanupActionFiles } from '../utils/action-inbox.js';
29
31
  const POLL_INTERVAL_MS = 2500;
30
32
  const ERROR_TAIL_LINES = 12;
31
33
  function findFeatureLoopScript(projectRoot, scriptsDir) {
@@ -103,6 +105,7 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
103
105
  const [isStarting, setIsStarting] = useState(!monitorOnly);
104
106
  const [showConfirm, setShowConfirm] = useState(false);
105
107
  const [completionSummary, setCompletionSummary] = useState(null);
108
+ const [actionRequest, setActionRequest] = useState(null);
106
109
  const childRef = useRef(null);
107
110
  const stopRequestedRef = useRef(false);
108
111
  const isMountedRef = useRef(true);
@@ -113,6 +116,7 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
113
116
  const scriptsDirRef = useRef('.ralph/scripts');
114
117
  const maxIterationsRef = useRef(0);
115
118
  const maxE2eAttemptsRef = useRef(0);
119
+ const handledActionIdRef = useRef(null);
116
120
  useInput((input, key) => {
117
121
  // If showing completion summary, Enter or Esc dismisses
118
122
  if (completionSummary) {
@@ -123,6 +127,9 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
123
127
  }
124
128
  if (showConfirm)
125
129
  return;
130
+ // Action prompt handles its own input (Select component)
131
+ if (actionRequest)
132
+ return;
126
133
  if (key.ctrl && input === 'c') {
127
134
  setShowConfirm(true);
128
135
  return;
@@ -149,6 +156,21 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
149
156
  if (!isMountedRef.current)
150
157
  return;
151
158
  setBranch(getGitBranch(projectRoot));
159
+ // Check for pending action request (loop waiting for user input)
160
+ const request = readActionRequest(featureName);
161
+ if (!isMountedRef.current)
162
+ return;
163
+ if (request) {
164
+ // Only show if we haven't already handled this action
165
+ if (request.id !== handledActionIdRef.current) {
166
+ setActionRequest(request);
167
+ }
168
+ }
169
+ else {
170
+ // File cleaned up by shell — reset tracking so future requests work
171
+ handledActionIdRef.current = null;
172
+ setActionRequest((prev) => prev ? null : prev);
173
+ }
152
174
  // In monitor mode, detect completion (only fire once)
153
175
  if (monitorOnly && !nextStatus.running && !completionSentRef.current) {
154
176
  completionSentRef.current = true;
@@ -190,6 +212,10 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
190
212
  });
191
213
  }
192
214
  }, [featureName, projectRoot, monitorOnly]);
215
+ // Keep a stable ref to the latest refreshStatus so the spawn effect
216
+ // can schedule polls without re-running when refreshStatus changes.
217
+ const refreshStatusRef = useRef(refreshStatus);
218
+ useEffect(() => { refreshStatusRef.current = refreshStatus; }, [refreshStatus]);
193
219
  const stopLoop = useCallback(() => {
194
220
  stopRequestedRef.current = true;
195
221
  if (childRef.current) {
@@ -244,11 +270,11 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
244
270
  if (cancelled)
245
271
  return;
246
272
  setIsStarting(false);
247
- refreshStatus().catch((err) => {
273
+ refreshStatusRef.current().catch((err) => {
248
274
  logger.warn(`Status refresh failed: ${err instanceof Error ? err.message : String(err)}`);
249
275
  });
250
276
  pollTimer = setInterval(() => {
251
- refreshStatus().catch((err) => {
277
+ refreshStatusRef.current().catch((err) => {
252
278
  logger.warn(`Status refresh failed: ${err instanceof Error ? err.message : String(err)}`);
253
279
  });
254
280
  }, POLL_INTERVAL_MS);
@@ -288,11 +314,15 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
288
314
  return;
289
315
  }
290
316
  const reviewMode = reviewModeProp ?? config.loop.reviewMode ?? 'manual';
291
- if (reviewMode !== 'manual' && reviewMode !== 'auto') {
292
- setError(`Invalid reviewMode '${reviewMode}'. Allowed values are 'manual' or 'auto'.`);
317
+ if (reviewMode !== 'manual' && reviewMode !== 'auto' && reviewMode !== 'merge') {
318
+ setError(`Invalid reviewMode '${reviewMode}'. Allowed values are 'manual', 'auto', or 'merge'.`);
293
319
  setIsStarting(false);
294
320
  return;
295
321
  }
322
+ // Clean up stale action files from previous runs
323
+ await cleanupActionFiles(featureName).catch((err) => {
324
+ logger.warn(`Failed to clean up stale action files: ${err instanceof Error ? err.message : String(err)}`);
325
+ });
296
326
  const logPath = getLoopLogPath(featureName);
297
327
  const logFd = openSync(logPath, 'a');
298
328
  let logFdClosed = false;
@@ -321,11 +351,11 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
321
351
  child.kill('SIGINT');
322
352
  }
323
353
  pollTimer = setInterval(() => {
324
- refreshStatus().catch((err) => {
354
+ refreshStatusRef.current().catch((err) => {
325
355
  logger.warn(`Status refresh failed: ${err instanceof Error ? err.message : String(err)}`);
326
356
  });
327
357
  }, POLL_INTERVAL_MS);
328
- refreshStatus().catch((err) => {
358
+ refreshStatusRef.current().catch((err) => {
329
359
  logger.warn(`Status refresh failed: ${err instanceof Error ? err.message : String(err)}`);
330
360
  });
331
361
  child.on('error', (err) => {
@@ -415,7 +445,10 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
415
445
  if (pollTimer)
416
446
  clearInterval(pollTimer);
417
447
  };
418
- }, [featureName, projectRoot, refreshStatus, monitorOnly, sessionState.config]);
448
+ // Note: refreshStatusRef (not refreshStatus) is used inside to avoid re-spawning
449
+ // the child process when the callback identity changes due to actionRequest updates.
450
+ // eslint-disable-next-line react-hooks/exhaustive-deps
451
+ }, [featureName, projectRoot, monitorOnly, sessionState.config]);
419
452
  const totalTasks = tasks.tasksDone + tasks.tasksPending;
420
453
  const totalE2e = tasks.e2eDone + tasks.e2ePending;
421
454
  const totalAll = totalTasks + totalE2e;
@@ -429,11 +462,47 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
429
462
  // Tips text
430
463
  const tips = completionSummary
431
464
  ? 'Enter to return to shell'
432
- : monitorOnly
433
- ? 'Ctrl+C stop, Esc back'
434
- : 'Ctrl+C stop, Esc background';
435
- // Input element (only show Confirm when stopping)
436
- const inputElement = showConfirm ? (_jsx(Confirm, { message: stopRequestedRef.current ? 'Stopping loop...' : 'Stop the feature loop?', onConfirm: handleConfirm, onCancel: () => setShowConfirm(false), initialValue: false })) : null;
465
+ : actionRequest
466
+ ? 'Select an option, Esc for default'
467
+ : monitorOnly
468
+ ? 'Ctrl+C stop, Esc back'
469
+ : 'Ctrl+C stop, Esc background';
470
+ // Action select handler — awaits write before clearing prompt
471
+ const handleActionSelect = useCallback(async (choiceId) => {
472
+ if (!actionRequest)
473
+ return;
474
+ try {
475
+ await writeActionReply(featureName, { id: actionRequest.id, choice: choiceId });
476
+ handledActionIdRef.current = actionRequest.id;
477
+ setActionRequest(null);
478
+ }
479
+ catch (err) {
480
+ logger.error(`Failed to write action reply: ${err instanceof Error ? err.message : String(err)}`);
481
+ setError(`Failed to send action reply. The loop may time out to the default.`);
482
+ }
483
+ }, [actionRequest, featureName]);
484
+ // Action cancel handler (Esc = use default) — awaits write before clearing
485
+ const handleActionCancel = useCallback(async () => {
486
+ if (!actionRequest)
487
+ return;
488
+ try {
489
+ await writeActionReply(featureName, { id: actionRequest.id, choice: actionRequest.default });
490
+ handledActionIdRef.current = actionRequest.id;
491
+ setActionRequest(null);
492
+ }
493
+ catch (err) {
494
+ logger.error(`Failed to write action reply (default): ${err instanceof Error ? err.message : String(err)}`);
495
+ setError(`Failed to send action reply. The loop may time out to the default.`);
496
+ }
497
+ }, [actionRequest, featureName]);
498
+ // Input element: completionSummary > showConfirm > actionRequest > null
499
+ const actionSelectOptions = actionRequest
500
+ ? actionRequest.choices.map((c) => ({ value: c.id, label: c.label }))
501
+ : null;
502
+ const actionInitialIndex = actionRequest
503
+ ? Math.max(0, actionRequest.choices.findIndex((c) => c.id === actionRequest.default))
504
+ : 0;
505
+ const inputElement = showConfirm ? (_jsx(Confirm, { message: stopRequestedRef.current ? 'Stopping loop...' : 'Stop the feature loop?', onConfirm: handleConfirm, onCancel: () => setShowConfirm(false), initialValue: false })) : !completionSummary && actionRequest && actionSelectOptions ? (_jsx(Select, { message: actionRequest.prompt, options: actionSelectOptions, onSelect: handleActionSelect, onCancel: handleActionCancel, initialIndex: actionInitialIndex })) : null;
437
506
  return (_jsx(AppShell, { header: header, tips: tips, isWorking: isRunning && !isStarting, workingStatus: `${phaseLine} \u2014 ${featureName}`, workingHint: monitorOnly ? 'esc to go back' : 'esc to background', input: inputElement, error: error, footerStatus: {
438
507
  action: 'Run Loop',
439
508
  phase: phaseLine,
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Action inbox helpers for file-based IPC between loop processes and the TUI.
3
+ *
4
+ * The loop writes an action request file; the TUI reads it and writes a reply.
5
+ * Both files live in /tmp with the conventional ralph-loop-<feature> prefix.
6
+ */
7
+ export interface ActionChoice {
8
+ id: string;
9
+ label: string;
10
+ }
11
+ export interface ActionRequest {
12
+ id: string;
13
+ prompt: string;
14
+ choices: ActionChoice[];
15
+ default: string;
16
+ }
17
+ export interface ActionReply {
18
+ id: string;
19
+ choice: string;
20
+ }
21
+ /**
22
+ * Return the path to the action request file for a feature.
23
+ */
24
+ export declare function getActionRequestPath(feature: string): string;
25
+ /**
26
+ * Return the path to the action reply file for a feature.
27
+ */
28
+ export declare function getActionReplyPath(feature: string): string;
29
+ /**
30
+ * Read and validate the action request file for a feature.
31
+ *
32
+ * Returns null if the file does not exist, cannot be parsed, or is missing
33
+ * required fields. Logs a warning on parse errors.
34
+ */
35
+ export declare function readActionRequest(feature: string): ActionRequest | null;
36
+ /**
37
+ * Write an action reply file atomically (write to .tmp then rename).
38
+ */
39
+ export declare function writeActionReply(feature: string, reply: ActionReply): Promise<void>;
40
+ /**
41
+ * Remove both action request and reply files. Only suppresses ENOENT (file not found).
42
+ */
43
+ export declare function cleanupActionFiles(feature: string): Promise<void>;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Action inbox helpers for file-based IPC between loop processes and the TUI.
3
+ *
4
+ * The loop writes an action request file; the TUI reads it and writes a reply.
5
+ * Both files live in /tmp with the conventional ralph-loop-<feature> prefix.
6
+ */
7
+ import { rename, unlink, writeFile } from 'node:fs/promises';
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { logger } from '../../utils/logger.js';
10
+ const FEATURE_REGEX = /^[a-zA-Z0-9_-]+$/;
11
+ function validateFeature(feature) {
12
+ if (!FEATURE_REGEX.test(feature)) {
13
+ throw new Error(`Invalid feature name: "${feature}". Must contain only letters, numbers, hyphens, and underscores.`);
14
+ }
15
+ }
16
+ /**
17
+ * Return the path to the action request file for a feature.
18
+ */
19
+ export function getActionRequestPath(feature) {
20
+ validateFeature(feature);
21
+ return `/tmp/ralph-loop-${feature}.action.json`;
22
+ }
23
+ /**
24
+ * Return the path to the action reply file for a feature.
25
+ */
26
+ export function getActionReplyPath(feature) {
27
+ validateFeature(feature);
28
+ return `/tmp/ralph-loop-${feature}.action.reply.json`;
29
+ }
30
+ /**
31
+ * Read and validate the action request file for a feature.
32
+ *
33
+ * Returns null if the file does not exist, cannot be parsed, or is missing
34
+ * required fields. Logs a warning on parse errors.
35
+ */
36
+ export function readActionRequest(feature) {
37
+ validateFeature(feature);
38
+ const path = getActionRequestPath(feature);
39
+ if (!existsSync(path)) {
40
+ return null;
41
+ }
42
+ let raw;
43
+ try {
44
+ raw = readFileSync(path, 'utf-8');
45
+ }
46
+ catch (err) {
47
+ logger.warn(`Failed to read action request file: ${err instanceof Error ? err.message : String(err)}`);
48
+ return null;
49
+ }
50
+ let parsed;
51
+ try {
52
+ parsed = JSON.parse(raw);
53
+ }
54
+ catch (err) {
55
+ logger.warn(`Failed to parse action request JSON: ${err instanceof Error ? err.message : String(err)}`);
56
+ return null;
57
+ }
58
+ if (typeof parsed !== 'object' ||
59
+ parsed === null ||
60
+ typeof parsed.id !== 'string' ||
61
+ typeof parsed.prompt !== 'string' ||
62
+ !Array.isArray(parsed.choices) ||
63
+ parsed.choices.length === 0 ||
64
+ typeof parsed.default !== 'string') {
65
+ logger.warn('Action request file is missing required fields (id, prompt, choices, default) or choices is empty');
66
+ return null;
67
+ }
68
+ const record = parsed;
69
+ const choices = record.choices;
70
+ for (const choice of choices) {
71
+ if (typeof choice !== 'object' ||
72
+ choice === null ||
73
+ typeof choice.id !== 'string' ||
74
+ typeof choice.label !== 'string') {
75
+ logger.warn('Action request choices contain invalid entries (each must have id and label)');
76
+ return null;
77
+ }
78
+ }
79
+ return {
80
+ id: record.id,
81
+ prompt: record.prompt,
82
+ choices: choices,
83
+ default: record.default,
84
+ };
85
+ }
86
+ /**
87
+ * Write an action reply file atomically (write to .tmp then rename).
88
+ */
89
+ export async function writeActionReply(feature, reply) {
90
+ validateFeature(feature);
91
+ const replyPath = getActionReplyPath(feature);
92
+ const tmpPath = `${replyPath}.tmp`;
93
+ const json = JSON.stringify(reply);
94
+ await writeFile(tmpPath, json, 'utf-8');
95
+ await rename(tmpPath, replyPath);
96
+ }
97
+ /**
98
+ * Remove both action request and reply files. Only suppresses ENOENT (file not found).
99
+ */
100
+ export async function cleanupActionFiles(feature) {
101
+ validateFeature(feature);
102
+ const requestPath = getActionRequestPath(feature);
103
+ const replyPath = getActionReplyPath(feature);
104
+ const safeUnlink = (path) => unlink(path).catch((err) => {
105
+ if (err.code !== 'ENOENT')
106
+ throw err;
107
+ });
108
+ await Promise.all([safeUnlink(requestPath), safeUnlink(replyPath)]);
109
+ }
@@ -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
+ }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiggum-cli",
3
- "version": "0.14.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
- "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
  };