wiggum-cli 0.17.3 → 0.18.3

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/dist/tui/app.js CHANGED
@@ -55,9 +55,18 @@ interviewProps, runProps, agentProps, onComplete, onExit, }) {
55
55
  const columns = stdout?.columns ?? 80;
56
56
  const rows = stdout?.rows ?? 24;
57
57
  const compact = rows < 20 || columns < 60;
58
+ const agentModelOverride = currentScreen === 'agent'
59
+ ? (typeof screenProps?.modelOverride === 'string' ? screenProps.modelOverride : agentProps?.modelOverride)
60
+ : undefined;
61
+ const agentProviderOverride = currentScreen === 'agent'
62
+ ? (sessionState.config?.agent.defaultProvider || sessionState.provider || undefined)
63
+ : undefined;
64
+ const effectiveAgentModel = currentScreen === 'agent'
65
+ ? (agentModelOverride || sessionState.config?.agent.defaultModel || sessionState.model)
66
+ : undefined;
58
67
  // Shared header element - includes columns/rows in deps so the
59
68
  // header subtree re-renders on terminal resize (banner auto-compacts)
60
- const headerElement = useMemo(() => (_jsx(HeaderContent, { version: version, sessionState: sessionState, backgroundRuns: backgroundRuns, compact: compact })), [version, sessionState, backgroundRuns, compact, columns, rows]);
69
+ const headerElement = useMemo(() => (_jsx(HeaderContent, { version: version, sessionState: sessionState, providerOverride: agentProviderOverride, modelOverride: effectiveAgentModel, backgroundRuns: backgroundRuns, compact: compact })), [version, sessionState, agentProviderOverride, effectiveAgentModel, backgroundRuns, compact, columns, rows]);
61
70
  /**
62
71
  * Navigate to a different screen
63
72
  */
@@ -15,6 +15,9 @@ export interface HeaderContentProps {
15
15
  version: string;
16
16
  /** Current session state */
17
17
  sessionState: SessionState;
18
+ /** Optional provider/model display overrides for screen-specific modes */
19
+ providerOverride?: string;
20
+ modelOverride?: string;
18
21
  /** Background runs (only active ones are displayed) */
19
22
  backgroundRuns?: BackgroundRun[];
20
23
  /** Use compact banner for small terminals */
@@ -25,4 +28,4 @@ export interface HeaderContentProps {
25
28
  *
26
29
  * Renders the banner and status row for the AppShell header zone.
27
30
  */
28
- export declare function HeaderContent({ version, sessionState, backgroundRuns, compact, }: HeaderContentProps): React.ReactElement;
31
+ export declare function HeaderContent({ version, sessionState, providerOverride, modelOverride, backgroundRuns, compact, }: HeaderContentProps): React.ReactElement;
@@ -7,10 +7,12 @@ import { colors, theme } from '../theme.js';
7
7
  *
8
8
  * Renders the banner and status row for the AppShell header zone.
9
9
  */
10
- export function HeaderContent({ version, sessionState, backgroundRuns, compact = false, }) {
10
+ export function HeaderContent({ version, sessionState, providerOverride, modelOverride, backgroundRuns, compact = false, }) {
11
11
  const activeRuns = backgroundRuns?.filter((r) => !r.completed && !r.pollError) ?? [];
12
12
  const errorRuns = backgroundRuns?.filter((r) => r.pollError) ?? [];
13
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(WiggumBanner, { compact: compact }), _jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: colors.pink, children: ["v", version] }), _jsx(Text, { dimColor: true, children: theme.statusLine.separator }), sessionState.provider ? (_jsxs(Text, { color: colors.blue, children: [sessionState.provider, "/", sessionState.model] })) : (_jsx(Text, { color: colors.orange, children: "not configured" })), _jsx(Text, { dimColor: true, children: theme.statusLine.separator }), _jsx(Text, { color: sessionState.initialized ? colors.green : colors.orange, children: sessionState.initialized ? 'Ready' : 'Not initialized' }), activeRuns.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: theme.statusLine.separator }), _jsxs(Text, { color: colors.green, children: [theme.chars.bulletLarge, " ", activeRuns[0].featureName, activeRuns[0].lastStatus.iteration > 0
13
+ const provider = providerOverride || sessionState.provider;
14
+ const model = modelOverride || sessionState.model;
15
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(WiggumBanner, { compact: compact }), _jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: colors.pink, children: ["v", version] }), _jsx(Text, { dimColor: true, children: theme.statusLine.separator }), provider ? (_jsxs(Text, { color: colors.blue, children: [provider, "/", model] })) : (_jsx(Text, { color: colors.orange, children: "not configured" })), _jsx(Text, { dimColor: true, children: theme.statusLine.separator }), _jsx(Text, { color: sessionState.initialized ? colors.green : colors.orange, children: sessionState.initialized ? 'Ready' : 'Not initialized' }), activeRuns.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: theme.statusLine.separator }), _jsxs(Text, { color: colors.green, children: [theme.chars.bulletLarge, " ", activeRuns[0].featureName, activeRuns[0].lastStatus.iteration > 0
14
16
  ? ` (${activeRuns[0].lastStatus.iteration}/${activeRuns[0].lastStatus.maxIterations || '?'})`
15
17
  : ''] }), activeRuns.length > 1 && (_jsxs(Text, { dimColor: true, children: [" +", activeRuns.length - 1, " more"] }))] })), errorRuns.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: theme.statusLine.separator }), _jsxs(Text, { color: colors.orange, children: [theme.chars.bullet, " ", errorRuns[0].featureName, " (status unknown)"] })] }))] })] }));
16
18
  }
@@ -6,7 +6,7 @@
6
6
  * calls into structured state (active issue, queue, completed, log).
7
7
  * Exposes an abort() function for clean shutdown on q/Esc.
8
8
  */
9
- import type { AgentIssueState, AgentLogEntry, ReviewMode } from '../../agent/types.js';
9
+ import type { AgentOrchestratorEvent, AgentIssueState, AgentLogEntry, ReviewMode } from '../../agent/types.js';
10
10
  import { type LoopStatus, type TaskCounts, type ActivityEvent } from '../utils/loop-status.js';
11
11
  import { type CommitLogEntry } from '../utils/git-summary.js';
12
12
  export type AgentStatus = 'idle' | 'running' | 'complete' | 'error';
@@ -38,4 +38,5 @@ export interface UseAgentOrchestratorResult {
38
38
  error: string | null;
39
39
  abort: () => void;
40
40
  }
41
+ export declare function applyOrchestratorEvent(event: AgentOrchestratorEvent, setActiveIssue: React.Dispatch<React.SetStateAction<AgentIssueState | null>>, setQueue: React.Dispatch<React.SetStateAction<AgentIssueState[]>>, setCompleted: React.Dispatch<React.SetStateAction<AgentIssueState[]>>, setLogEntries: React.Dispatch<React.SetStateAction<AgentLogEntry[]>>, completedIssuesRef: React.MutableRefObject<Set<number>>): void;
41
42
  export declare function useAgentOrchestrator(options: UseAgentOrchestratorOptions): UseAgentOrchestratorResult;
@@ -27,11 +27,16 @@ function appendLog(prev, message, level = 'info') {
27
27
  }
28
28
  return next;
29
29
  }
30
+ function shouldReopenCompletedIssue(issue) {
31
+ return issue.recommendation === 'resume_pr_phase'
32
+ || issue.recommendation === 'pr_merged'
33
+ || issue.recommendation === 'linked_pr_merged';
34
+ }
30
35
  /**
31
36
  * Interpret a tool call name and extract relevant info from args/results
32
37
  * to drive TUI state transitions.
33
38
  */
34
- function interpretToolCalls(event, setActiveIssue, setQueue, setCompleted, setLogEntries, pollingRef, stopLoopPolling, ranLoopRef, activeIssueRef, issueNumberFilter) {
39
+ function interpretToolCalls(event, setActiveIssue, setQueue, setCompleted, setLogEntries, pollingRef, stopLoopPolling, ranLoopRef, activeIssueRef) {
35
40
  // If a new tool call arrives while polling, the runLoop tool has finished — stop polling
36
41
  for (const tc of event.toolCalls) {
37
42
  if (tc.toolName !== 'runLoop' && pollingRef.current) {
@@ -188,15 +193,6 @@ function interpretToolCalls(event, setActiveIssue, setQueue, setCompleted, setLo
188
193
  break;
189
194
  }
190
195
  }
191
- // Detect whether listIssues was called with a label filter (e.g. P0 check)
192
- // so we don't overwrite the queue with a filtered subset.
193
- const listIssuesHasLabelFilter = event.toolCalls.some((tc) => {
194
- if (tc.toolName !== 'listIssues')
195
- return false;
196
- const args = tc.args;
197
- const labels = args?.labels;
198
- return Array.isArray(labels) && labels.length > 0;
199
- });
200
196
  // Process tool results for additional state updates
201
197
  for (const tr of event.toolResults) {
202
198
  const result = tr.result;
@@ -204,35 +200,10 @@ function interpretToolCalls(event, setActiveIssue, setQueue, setCompleted, setLo
204
200
  case 'listIssues': {
205
201
  const issues = (result?.issues ?? result);
206
202
  if (Array.isArray(issues)) {
207
- // Only update queue from unfiltered listIssues calls (full backlog scan).
208
- // Filtered calls (e.g. labels: ["bug"]) are P0/blocker checks — not the backlog.
209
- if (!listIssuesHasLabelFilter) {
210
- // When --issues is configured, defensively filter queue to only those issues
211
- // (the tool should already filter, but this guards against edge cases)
212
- const relevantIssues = issueNumberFilter
213
- ? issues.filter(i => issueNumberFilter.has((i.number ?? i.issueNumber)))
214
- : issues;
215
- const queueItems = relevantIssues.map((issue) => ({
216
- issueNumber: (issue.number ?? issue.issueNumber),
217
- title: issue.title ?? `Issue #${issue.number ?? issue.issueNumber}`,
218
- labels: Array.isArray(issue.labels) ? issue.labels : [],
219
- phase: 'idle',
220
- }));
221
- setQueue(queueItems);
222
- }
223
203
  setLogEntries((prev) => appendLog(prev, `Found ${issues.length} issue(s) in backlog`));
224
204
  }
225
205
  break;
226
206
  }
227
- case 'readIssue': {
228
- // Update queue entry titles from full issue data (agent reads many issues during triage)
229
- const issueNumber = (result?.number ?? result?.issueNumber);
230
- const title = result?.title;
231
- if (issueNumber && title) {
232
- setQueue((prev) => prev.map((i) => i.issueNumber === issueNumber ? { ...i, title } : i));
233
- }
234
- break;
235
- }
236
207
  case 'checkLoopStatus': {
237
208
  const iteration = (result?.iteration ?? result?.currentIteration);
238
209
  if (iteration != null) {
@@ -245,6 +216,78 @@ function interpretToolCalls(event, setActiveIssue, setQueue, setCompleted, setLo
245
216
  }
246
217
  }
247
218
  }
219
+ export function applyOrchestratorEvent(event, setActiveIssue, setQueue, setCompleted, setLogEntries, completedIssuesRef) {
220
+ switch (event.type) {
221
+ case 'scope_expanded':
222
+ setLogEntries((prev) => appendLog(prev, `Expanded scope with ${event.expansions.map(expansion => `#${expansion.issueNumber}`).join(', ')}`));
223
+ break;
224
+ case 'backlog_progress':
225
+ setLogEntries((prev) => appendLog(prev, event.message));
226
+ break;
227
+ case 'backlog_timing':
228
+ setLogEntries((prev) => appendLog(prev, `${event.phase} took ${event.durationMs}ms${event.count != null ? ` (${event.count})` : ''}`));
229
+ break;
230
+ case 'backlog_scanned':
231
+ setLogEntries((prev) => appendLog(prev, `Scanned ${event.total} backlog issue(s)`));
232
+ break;
233
+ case 'candidate_enriched':
234
+ setLogEntries((prev) => appendLog(prev, `Enriched #${event.issue.issueNumber}: ${event.issue.title}`));
235
+ break;
236
+ case 'dependencies_inferred':
237
+ if (event.edges.length > 0) {
238
+ setLogEntries((prev) => appendLog(prev, `Inferred ${event.edges.length} dependency edge(s) for #${event.issueNumber}`));
239
+ }
240
+ break;
241
+ case 'queue_ranked':
242
+ {
243
+ const resumedIssueNumbers = new Set(event.queue
244
+ .filter(shouldReopenCompletedIssue)
245
+ .map(issue => issue.issueNumber));
246
+ if (resumedIssueNumbers.size > 0) {
247
+ for (const issueNumber of resumedIssueNumbers) {
248
+ completedIssuesRef.current.delete(issueNumber);
249
+ }
250
+ setCompleted((prev) => prev.filter((issue) => !resumedIssueNumbers.has(issue.issueNumber)));
251
+ }
252
+ setQueue(event.queue.filter((issue) => !completedIssuesRef.current.has(issue.issueNumber)));
253
+ }
254
+ break;
255
+ case 'task_selected':
256
+ completedIssuesRef.current.delete(event.issue.issueNumber);
257
+ setCompleted((prev) => prev.filter((issue) => issue.issueNumber !== event.issue.issueNumber));
258
+ setActiveIssue({ ...event.issue, phase: 'planning' });
259
+ setQueue((prev) => prev.filter((issue) => issue.issueNumber !== event.issue.issueNumber));
260
+ setLogEntries((prev) => appendLog(prev, `Selected #${event.issue.issueNumber}: ${event.issue.title}`));
261
+ break;
262
+ case 'task_blocked':
263
+ setLogEntries((prev) => appendLog(prev, `Blocked #${event.issue.issueNumber}: ${event.issue.blockedBy?.[0]?.reason ?? event.issue.actionability ?? 'blocked'}`, 'warn'));
264
+ break;
265
+ case 'task_started':
266
+ setActiveIssue((prev) => ({ ...(prev ?? event.issue), ...event.issue }));
267
+ setLogEntries((prev) => appendLog(prev, `Started #${event.issue.issueNumber}`));
268
+ break;
269
+ case 'task_completed':
270
+ setCompleted((prev) => {
271
+ const filtered = prev.filter((issue) => issue.issueNumber !== event.issue.issueNumber);
272
+ return [...filtered, {
273
+ ...event.issue,
274
+ error: event.outcome === 'failure' ? 'failed' : event.issue.error,
275
+ }];
276
+ });
277
+ if (event.outcome === 'success' || event.outcome === 'skipped') {
278
+ completedIssuesRef.current.add(event.issue.issueNumber);
279
+ }
280
+ else {
281
+ completedIssuesRef.current.delete(event.issue.issueNumber);
282
+ }
283
+ setQueue((prev) => prev.filter((issue) => issue.issueNumber !== event.issue.issueNumber));
284
+ setActiveIssue((prev) => prev?.issueNumber === event.issue.issueNumber ? null : prev);
285
+ setLogEntries((prev) => appendLog(prev, `${event.outcome === 'failure' ? 'Failed' : event.outcome === 'partial' ? 'Paused' : 'Completed'} #${event.issue.issueNumber} (${event.outcome})`, event.outcome === 'failure' ? 'error' : event.outcome === 'partial' ? 'warn' : 'success'));
286
+ break;
287
+ default:
288
+ break;
289
+ }
290
+ }
248
291
  export function useAgentOrchestrator(options) {
249
292
  const [status, setStatus] = useState('idle');
250
293
  const [activeIssue, setActiveIssue] = useState(null);
@@ -258,6 +301,7 @@ export function useAgentOrchestrator(options) {
258
301
  const pollingRef = useRef(null);
259
302
  const ranLoopRef = useRef(new Set());
260
303
  const activeIssueRef = useRef(null);
304
+ const completedIssuesRef = useRef(new Set());
261
305
  // Keep ref in sync for use inside polling callback
262
306
  useEffect(() => { activeIssueRef.current = activeIssue; }, [activeIssue]);
263
307
  const stopLoopPolling = useCallback(() => {
@@ -380,9 +424,6 @@ export function useAgentOrchestrator(options) {
380
424
  model: options.modelOverride,
381
425
  });
382
426
  setLogEntries((prev) => appendLog(prev, `Using ${env.provider}/${env.modelId ?? 'default'} on ${env.owner}/${env.repo}`));
383
- const issueFilter = options.issues?.length
384
- ? new Set(options.issues)
385
- : undefined;
386
427
  const agentConfig = {
387
428
  model: env.model,
388
429
  modelId: env.modelId,
@@ -397,7 +438,10 @@ export function useAgentOrchestrator(options) {
397
438
  reviewMode: options.reviewMode,
398
439
  dryRun: options.dryRun,
399
440
  onStepUpdate: (event) => {
400
- interpretToolCalls(event, setActiveIssue, setQueue, setCompleted, setLogEntries, pollingRef, stopLoopPolling, ranLoopRef, activeIssueRef, issueFilter);
441
+ interpretToolCalls(event, setActiveIssue, setQueue, setCompleted, setLogEntries, pollingRef, stopLoopPolling, ranLoopRef, activeIssueRef);
442
+ },
443
+ onOrchestratorEvent: (event) => {
444
+ applyOrchestratorEvent(event, setActiveIssue, setQueue, setCompleted, setLogEntries, completedIssuesRef);
401
445
  },
402
446
  onProgress: (toolName, line) => {
403
447
  // Detect generateSpec execution start (onStepFinish fires too late)
@@ -82,7 +82,9 @@ function SectionSeparator({ width }) {
82
82
  * Issues panel content — shared between wide and narrow layouts
83
83
  */
84
84
  function IssuesPanel({ activeIssue, queue, completed, panelWidth, }) {
85
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: colors.yellow, children: "Active Issue" }), activeIssue ? (_jsxs(Box, { flexDirection: "column", marginLeft: 1, children: [_jsxs(Text, { children: [_jsxs(Text, { color: colors.blue, children: ["#", activeIssue.issueNumber] }), _jsxs(Text, { children: [" ", activeIssue.title] })] }), _jsxs(Text, { dimColor: true, children: [phase.active, " ", phaseLabel(activeIssue.phase, activeIssue), activeIssue.loopIterations != null ? ` (iter ${activeIssue.loopIterations})` : ''] })] })) : (_jsx(Box, { marginLeft: 1, children: _jsx(Text, { dimColor: true, children: "No active issue" }) })), _jsx(SectionSeparator, { width: panelWidth }), _jsxs(Text, { bold: true, color: colors.yellow, children: ["Queue", _jsxs(Text, { dimColor: true, children: [" (", queue.length, ")"] })] }), queue.length === 0 ? (_jsx(Box, { marginLeft: 1, children: _jsx(Text, { dimColor: true, children: "Empty" }) })) : (queue.slice(0, 5).map((issue) => (_jsx(Box, { marginLeft: 1, children: _jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: ["#", issue.issueNumber] }), _jsxs(Text, { children: [" ", issue.title] })] }) }, issue.issueNumber)))), queue.length > 5 && (_jsx(Box, { marginLeft: 1, children: _jsxs(Text, { dimColor: true, children: ["...and ", queue.length - 5, " more"] }) })), _jsx(SectionSeparator, { width: panelWidth }), _jsxs(Text, { bold: true, color: colors.yellow, children: ["Completed", _jsxs(Text, { dimColor: true, children: [" (", completed.length, ")"] })] }), completed.length === 0 ? (_jsx(Box, { marginLeft: 1, children: _jsx(Text, { dimColor: true, children: "None yet" }) })) : (completed.slice(-5).map((issue) => (_jsxs(Box, { marginLeft: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: issue.error ? colors.pink : colors.green, children: issue.error ? phase.error : phase.complete }), _jsxs(Text, { dimColor: true, children: [" #", issue.issueNumber] }), _jsxs(Text, { children: [" ", issue.title] })] }), issue.prUrl && (_jsxs(Text, { dimColor: true, children: [" \\u2514 PR: ", issue.prUrl] }))] }, issue.issueNumber))))] }));
85
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: colors.yellow, children: "Active Issue" }), activeIssue ? (_jsxs(Box, { flexDirection: "column", marginLeft: 1, children: [_jsxs(Text, { children: [_jsxs(Text, { color: colors.blue, children: ["#", activeIssue.issueNumber] }), _jsxs(Text, { children: [" ", activeIssue.title] })] }), _jsxs(Text, { dimColor: true, children: [phase.active, " ", phaseLabel(activeIssue.phase, activeIssue), activeIssue.loopIterations != null ? ` (iter ${activeIssue.loopIterations})` : ''] })] })) : (_jsx(Box, { marginLeft: 1, children: _jsx(Text, { dimColor: true, children: "No active issue" }) })), _jsx(SectionSeparator, { width: panelWidth }), _jsxs(Text, { bold: true, color: colors.yellow, children: ["Queue", _jsxs(Text, { dimColor: true, children: [" (", queue.length, ")"] })] }), queue.length === 0 ? (_jsx(Box, { marginLeft: 1, children: _jsx(Text, { dimColor: true, children: "Empty" }) })) : (queue.slice(0, 5).map((issue) => (_jsxs(Box, { marginLeft: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: ["#", issue.issueNumber] }), _jsxs(Text, { children: [" ", issue.title] })] }), (issue.actionability || issue.recommendation || issue.dependsOn?.length || issue.inferredDependsOn?.length) && (_jsxs(Text, { dimColor: true, children: [issue.scopeOrigin === 'dependency' && issue.requestedBy?.length
86
+ ? `dependency for ${issue.requestedBy.map(n => `#${n}`).join(', ')} · `
87
+ : '', issue.actionability ?? 'ready', issue.recommendation ? ` · ${issue.recommendation}` : '', issue.dependsOn?.length ? ` · explicit: ${issue.dependsOn.map(n => `#${n}`).join(', ')}` : '', issue.inferredDependsOn?.length ? ` · inferred: ${issue.inferredDependsOn.map(dep => `#${dep.issueNumber} (${dep.confidence})`).join(', ')}` : ''] })), issue.blockedBy?.length ? (_jsxs(Text, { color: colors.orange, children: [" blocked: ", issue.blockedBy[0].reason] })) : null] }, issue.issueNumber)))), queue.length > 5 && (_jsx(Box, { marginLeft: 1, children: _jsxs(Text, { dimColor: true, children: ["...and ", queue.length - 5, " more"] }) })), _jsx(SectionSeparator, { width: panelWidth }), _jsxs(Text, { bold: true, color: colors.yellow, children: ["Completed", _jsxs(Text, { dimColor: true, children: [" (", completed.length, ")"] })] }), completed.length === 0 ? (_jsx(Box, { marginLeft: 1, children: _jsx(Text, { dimColor: true, children: "None yet" }) })) : (completed.slice(-5).map((issue) => (_jsxs(Box, { marginLeft: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: issue.error ? colors.pink : colors.green, children: issue.error ? phase.error : phase.complete }), _jsxs(Text, { dimColor: true, children: [" #", issue.issueNumber] }), _jsxs(Text, { children: [" ", issue.title] })] }), issue.prUrl && (_jsxs(Text, { dimColor: true, children: [" \\u2514 PR: ", issue.prUrl] }))] }, issue.issueNumber))))] }));
86
88
  }
87
89
  /**
88
90
  * Log panel content — shared between wide and narrow layouts.
@@ -72,6 +72,19 @@ function normalizeWhitespace(text) {
72
72
  function looksLikeAbbreviation(fragment) {
73
73
  return /^(e\.g|i\.e|etc|vs|mr|mrs|dr|prof|no|vol|fig|ch|approx|est)$/i.test(fragment.trim());
74
74
  }
75
+ /** Remove trailing sentence punctuation without regex backtracking. */
76
+ function stripTrailingSentencePunctuation(text) {
77
+ let end = text.length;
78
+ while (end > 0) {
79
+ const char = text.charCodeAt(end - 1);
80
+ if (char === 46 || char === 33 || char === 63) { // . ! ?
81
+ end -= 1;
82
+ continue;
83
+ }
84
+ break;
85
+ }
86
+ return text.slice(0, end);
87
+ }
75
88
  /**
76
89
  * Enforce single-sentence: split conservatively on `. ` boundaries, skipping
77
90
  * abbreviation-like fragments, then return only the first sentence.
@@ -155,7 +168,7 @@ export function polishGoalSentence(text) {
155
168
  // 4. Single-sentence enforcement
156
169
  result = toOneSentence(result);
157
170
  // Strip any trailing sentence-ending punctuation before we add our own
158
- result = result.replace(/[.!?]+$/, '').trim();
171
+ result = stripTrailingSentencePunctuation(result).trim();
159
172
  // 5. Imperative verb enforcement
160
173
  if (!IMPERATIVE_VERB_PATTERN.test(result)) {
161
174
  // Lowercase first char before prepending to avoid "Implement The thing"
package/dist/utils/env.js CHANGED
@@ -77,7 +77,13 @@ export function writeKeysToEnvFile(filePath, keys) {
77
77
  envContent = envContent.trimEnd() + (envContent ? '\n' : '') + `${envVar}=${value}\n`;
78
78
  }
79
79
  }
80
- fs.writeFileSync(filePath, envContent);
80
+ fs.writeFileSync(filePath, envContent, { mode: 0o600 });
81
+ try {
82
+ fs.chmodSync(filePath, 0o600);
83
+ }
84
+ catch {
85
+ // Best-effort hardening; ignore chmod failures on unusual filesystems.
86
+ }
81
87
  }
82
88
  /**
83
89
  * Load known AI provider API keys from .ralph/.env.local into process.env.
@@ -1,9 +1,12 @@
1
1
  export declare function isGhInstalled(): Promise<boolean>;
2
2
  export declare function _resetGhCache(): void;
3
3
  export interface GitHubIssueDetail {
4
+ number: number;
4
5
  title: string;
5
6
  body: string;
6
7
  labels: string[];
8
+ state: 'open' | 'closed';
9
+ createdAt: string;
7
10
  }
8
11
  export declare function fetchGitHubIssue(owner: string, repo: string, number: number): Promise<GitHubIssueDetail | null>;
9
12
  export interface GitHubIssueListItem {
@@ -17,7 +20,17 @@ export interface ListIssuesResult {
17
20
  issues: GitHubIssueListItem[];
18
21
  error?: string;
19
22
  }
23
+ export interface GitHubDiagnosticCheck {
24
+ name: string;
25
+ ok: boolean;
26
+ message: string;
27
+ }
28
+ export interface GitHubDiagnostics {
29
+ success: boolean;
30
+ checks: GitHubDiagnosticCheck[];
31
+ }
20
32
  export declare function listRepoIssues(owner: string, repo: string, search?: string, limit?: number): Promise<ListIssuesResult>;
33
+ export declare function runGitHubDiagnostics(owner: string, repo: string, issueNumbers?: number[]): Promise<GitHubDiagnostics>;
21
34
  export declare function detectGitHubRemote(projectRoot: string): Promise<GitHubRepo | null>;
22
35
  export interface ParsedGitHubIssue {
23
36
  owner: string;
@@ -12,6 +12,29 @@ function safeExec(cmd, args, cwd) {
12
12
  });
13
13
  });
14
14
  }
15
+ function shouldRetryGhError(error) {
16
+ const message = error instanceof Error ? error.message : String(error);
17
+ return message.includes('error connecting to api.github.com')
18
+ || message.includes('Client.Timeout exceeded')
19
+ || message.includes('i/o timeout')
20
+ || message.includes('TLS handshake timeout');
21
+ }
22
+ async function safeExecWithRetry(cmd, args, cwd, attempts = 3) {
23
+ let lastError;
24
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
25
+ try {
26
+ return await safeExec(cmd, args, cwd);
27
+ }
28
+ catch (error) {
29
+ lastError = error;
30
+ const canRetry = cmd === 'gh' && attempt < attempts && shouldRetryGhError(error);
31
+ if (!canRetry)
32
+ break;
33
+ await new Promise(resolve => setTimeout(resolve, 200 * attempt));
34
+ }
35
+ }
36
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
37
+ }
15
38
  let ghInstalledCache = null;
16
39
  export async function isGhInstalled() {
17
40
  if (ghInstalledCache !== null)
@@ -30,16 +53,19 @@ export function _resetGhCache() {
30
53
  }
31
54
  export async function fetchGitHubIssue(owner, repo, number) {
32
55
  try {
33
- const stdout = await safeExec('gh', [
56
+ const stdout = await safeExecWithRetry('gh', [
34
57
  'issue', 'view', String(number),
35
58
  '--repo', `${owner}/${repo}`,
36
- '--json', 'title,body,labels',
59
+ '--json', 'number,title,body,labels,state,createdAt',
37
60
  ]);
38
61
  const data = JSON.parse(stdout);
39
62
  return {
63
+ number: data.number ?? number,
40
64
  title: data.title ?? '',
41
65
  body: data.body ?? '',
42
66
  labels: (data.labels ?? []).map((l) => l.name),
67
+ state: data.state?.toLowerCase?.() === 'closed' ? 'closed' : 'open',
68
+ createdAt: data.createdAt ?? '',
43
69
  };
44
70
  }
45
71
  catch {
@@ -58,7 +84,7 @@ export async function listRepoIssues(owner, repo, search, limit = 20) {
58
84
  if (search) {
59
85
  args.push('--search', search);
60
86
  }
61
- const stdout = await safeExec('gh', args);
87
+ const stdout = await safeExecWithRetry('gh', args);
62
88
  const data = JSON.parse(stdout);
63
89
  const issues = data.map((item) => ({
64
90
  number: item.number,
@@ -74,8 +100,41 @@ export async function listRepoIssues(owner, repo, search, limit = 20) {
74
100
  if (msg.includes('auth') || msg.includes('login') || msg.includes('not logged')) {
75
101
  return { issues: [], error: 'Run "gh auth login" to enable issue browsing' };
76
102
  }
77
- return { issues: [] };
103
+ return { issues: [], error: `GitHub issue listing failed: ${msg}` };
104
+ }
105
+ }
106
+ async function runDiagnosticCheck(name, cmd, args) {
107
+ try {
108
+ await safeExecWithRetry(cmd, args);
109
+ return { name, ok: true, message: 'ok' };
110
+ }
111
+ catch (err) {
112
+ const message = err instanceof Error ? err.message : String(err);
113
+ return { name, ok: false, message };
114
+ }
115
+ }
116
+ export async function runGitHubDiagnostics(owner, repo, issueNumbers) {
117
+ const checks = [];
118
+ checks.push(await runDiagnosticCheck('gh version', 'gh', ['--version']));
119
+ checks.push(await runDiagnosticCheck('gh auth status', 'gh', ['auth', 'status']));
120
+ checks.push(await runDiagnosticCheck('gh issue list', 'gh', [
121
+ 'issue', 'list',
122
+ '--repo', `${owner}/${repo}`,
123
+ '--limit', '1',
124
+ '--state', 'open',
125
+ '--json', 'number',
126
+ ]));
127
+ for (const issueNumber of issueNumbers ?? []) {
128
+ checks.push(await runDiagnosticCheck(`gh issue view #${issueNumber}`, 'gh', [
129
+ 'issue', 'view', String(issueNumber),
130
+ '--repo', `${owner}/${repo}`,
131
+ '--json', 'number,title',
132
+ ]));
78
133
  }
134
+ return {
135
+ success: checks.every(check => check.ok),
136
+ checks,
137
+ };
79
138
  }
80
139
  export async function detectGitHubRemote(projectRoot) {
81
140
  try {
@@ -13,7 +13,7 @@ export const logger = {
13
13
  console.log(pc.yellow('warn'), message);
14
14
  },
15
15
  error(message) {
16
- console.log(pc.red('error'), message);
16
+ process.stderr.write(`${pc.red('error')} ${message}\n`);
17
17
  },
18
18
  debug(message) {
19
19
  if (process.env.DEBUG) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "wiggum-cli",
3
- "version": "0.17.3",
4
- "description": "AI-powered feature development loop CLI",
3
+ "version": "0.18.3",
4
+ "description": "AI agent CLI for spec-driven feature loops with Claude Code and Codex",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "files": [
@@ -29,15 +29,17 @@
29
29
  "cli",
30
30
  "ai-agent",
31
31
  "autonomous-coding",
32
- "ralph-loop",
33
32
  "spec-generation",
33
+ "feature-specs",
34
+ "feature-loop",
35
+ "backlog-automation",
34
36
  "claude-code",
35
37
  "codex",
36
- "ai-coding",
37
- "feature-loop",
38
- "code-generation",
39
38
  "developer-tools",
40
- "tech-stack-detection"
39
+ "terminal-ui",
40
+ "tech-stack-detection",
41
+ "ralph-loop",
42
+ "typescript"
41
43
  ],
42
44
  "repository": {
43
45
  "type": "git",