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.
- 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 +157 -7
- package/dist/tui/app.js +20 -2
- package/dist/tui/components/ChatInput.d.ts +3 -1
- package/dist/tui/components/ChatInput.js +23 -4
- package/dist/tui/components/CommandDropdown.d.ts +3 -1
- package/dist/tui/components/CommandDropdown.js +10 -7
- package/dist/tui/components/SpecCompletionSummary.d.ts +3 -2
- package/dist/tui/components/SpecCompletionSummary.js +26 -9
- package/dist/tui/components/SummaryBox.d.ts +0 -3
- package/dist/tui/components/SummaryBox.js +4 -2
- package/dist/tui/orchestration/interview-orchestrator.js +35 -5
- package/dist/tui/screens/MainShell.js +2 -1
- package/dist/tui/screens/RunScreen.js +81 -12
- package/dist/tui/utils/action-inbox.d.ts +43 -0
- package/dist/tui/utils/action-inbox.js +109 -0
- package/dist/tui/utils/polishGoal.d.ts +37 -0
- package/dist/tui/utils/polishGoal.js +170 -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/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 +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
|
-
|
|
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
|
-
|
|
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 '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
433
|
-
? '
|
|
434
|
-
:
|
|
435
|
-
|
|
436
|
-
|
|
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
|
+
}
|
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
|
+
}
|
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
|
};
|