wiggum-cli 0.17.2 → 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.
Files changed (71) hide show
  1. package/README.md +58 -14
  2. package/dist/agent/orchestrator.d.ts +21 -3
  3. package/dist/agent/orchestrator.js +394 -187
  4. package/dist/agent/resolve-config.js +1 -1
  5. package/dist/agent/scheduler.d.ts +29 -0
  6. package/dist/agent/scheduler.js +1149 -0
  7. package/dist/agent/tools/backlog.d.ts +6 -0
  8. package/dist/agent/tools/backlog.js +23 -4
  9. package/dist/agent/tools/execution.js +1 -1
  10. package/dist/agent/tools/introspection.js +26 -4
  11. package/dist/agent/types.d.ts +113 -0
  12. package/dist/ai/conversation/url-fetcher.js +46 -13
  13. package/dist/ai/enhancer.js +1 -2
  14. package/dist/ai/providers.js +4 -4
  15. package/dist/commands/agent.d.ts +1 -0
  16. package/dist/commands/agent.js +53 -1
  17. package/dist/commands/config.js +100 -6
  18. package/dist/commands/run.d.ts +2 -0
  19. package/dist/commands/run.js +47 -2
  20. package/dist/commands/sync.js +2 -2
  21. package/dist/generator/config.js +13 -2
  22. package/dist/index.js +11 -3
  23. package/dist/repl/command-parser.d.ts +1 -1
  24. package/dist/repl/command-parser.js +1 -1
  25. package/dist/templates/config/ralph.config.cjs.tmpl +9 -2
  26. package/dist/templates/prompts/PROMPT_e2e.md.tmpl +16 -89
  27. package/dist/templates/prompts/PROMPT_e2e_fix.md.tmpl +55 -0
  28. package/dist/templates/prompts/PROMPT_feature.md.tmpl +12 -98
  29. package/dist/templates/prompts/PROMPT_review_auto.md.tmpl +52 -49
  30. package/dist/templates/prompts/PROMPT_review_manual.md.tmpl +30 -2
  31. package/dist/templates/prompts/PROMPT_review_merge.md.tmpl +59 -69
  32. package/dist/templates/prompts/PROMPT_verify.md.tmpl +7 -0
  33. package/dist/templates/root/README.md.tmpl +2 -3
  34. package/dist/templates/scripts/feature-loop.sh.tmpl +835 -93
  35. package/dist/templates/scripts/loop.sh.tmpl +5 -1
  36. package/dist/templates/scripts/ralph-monitor.sh.tmpl +0 -2
  37. package/dist/tui/app.d.ts +5 -1
  38. package/dist/tui/app.js +22 -3
  39. package/dist/tui/components/HeaderContent.d.ts +4 -1
  40. package/dist/tui/components/HeaderContent.js +4 -2
  41. package/dist/tui/hooks/useAgentOrchestrator.d.ts +2 -1
  42. package/dist/tui/hooks/useAgentOrchestrator.js +86 -33
  43. package/dist/tui/hooks/useInit.d.ts +5 -1
  44. package/dist/tui/hooks/useInit.js +20 -2
  45. package/dist/tui/screens/AgentScreen.js +3 -1
  46. package/dist/tui/screens/InitScreen.js +12 -1
  47. package/dist/tui/screens/MainShell.js +70 -6
  48. package/dist/tui/screens/RunScreen.d.ts +6 -2
  49. package/dist/tui/screens/RunScreen.js +48 -6
  50. package/dist/tui/utils/loop-status.d.ts +15 -0
  51. package/dist/tui/utils/loop-status.js +89 -27
  52. package/dist/tui/utils/polishGoal.js +14 -1
  53. package/dist/utils/config.d.ts +7 -0
  54. package/dist/utils/config.js +14 -0
  55. package/dist/utils/env.js +7 -1
  56. package/dist/utils/github.d.ts +13 -0
  57. package/dist/utils/github.js +63 -4
  58. package/dist/utils/logger.js +1 -1
  59. package/package.json +9 -7
  60. package/src/templates/config/ralph.config.cjs.tmpl +9 -2
  61. package/src/templates/prompts/PROMPT_e2e.md.tmpl +16 -89
  62. package/src/templates/prompts/PROMPT_e2e_fix.md.tmpl +55 -0
  63. package/src/templates/prompts/PROMPT_feature.md.tmpl +12 -98
  64. package/src/templates/prompts/PROMPT_review_auto.md.tmpl +52 -49
  65. package/src/templates/prompts/PROMPT_review_manual.md.tmpl +30 -2
  66. package/src/templates/prompts/PROMPT_review_merge.md.tmpl +59 -69
  67. package/src/templates/prompts/PROMPT_verify.md.tmpl +7 -0
  68. package/src/templates/root/README.md.tmpl +2 -3
  69. package/src/templates/scripts/feature-loop.sh.tmpl +835 -93
  70. package/src/templates/scripts/loop.sh.tmpl +5 -1
  71. package/src/templates/scripts/ralph-monitor.sh.tmpl +0 -2
@@ -1,6 +1,9 @@
1
1
  export interface BacklogToolsOptions {
2
2
  defaultLabels?: string[];
3
3
  issueNumbers?: number[];
4
+ scopeListIssuesToIssueNumbers?: boolean;
5
+ scopeReadIssueToIssueNumbers?: boolean;
6
+ allowGlobalBugDuplicateChecks?: boolean;
4
7
  }
5
8
  export declare function createBacklogTools(owner: string, repo: string, options?: BacklogToolsOptions): {
6
9
  listIssues: import("ai").Tool<{
@@ -20,9 +23,12 @@ export declare function createBacklogTools(owner: string, repo: string, options?
20
23
  error: string;
21
24
  } | {
22
25
  dependsOn: number[];
26
+ number: number;
23
27
  title: string;
24
28
  body: string;
25
29
  labels: string[];
30
+ state: "open" | "closed";
31
+ createdAt: string;
26
32
  error?: undefined;
27
33
  }>;
28
34
  };
@@ -8,6 +8,10 @@ function extractDependencyHints(body) {
8
8
  return [...new Set(numbers)].sort((a, b) => a - b);
9
9
  }
10
10
  export function createBacklogTools(owner, repo, options = {}) {
11
+ const issueNumberSet = options.issueNumbers?.length
12
+ ? new Set(options.issueNumbers)
13
+ : undefined;
14
+ const listVisibleIssueNumbers = new Set();
11
15
  const listIssues = tool({
12
16
  description: 'List open GitHub issues from the backlog, optionally filtered by labels or milestone.',
13
17
  inputSchema: zodSchema(z.object({
@@ -30,10 +34,19 @@ export function createBacklogTools(owner, repo, options = {}) {
30
34
  return { issues: [], error: result.error };
31
35
  // Sort by issue number ascending — lower numbers are typically more foundational
32
36
  const sorted = [...result.issues].sort((a, b) => a.number - b.number);
33
- const filtered = options.issueNumbers?.length
34
- ? sorted.filter(i => options.issueNumbers.includes(i.number))
35
- : sorted;
36
- return { issues: filtered };
37
+ const isBugDuplicateCheck = options.allowGlobalBugDuplicateChecks === true
38
+ && milestone == null
39
+ && uniqueLabels.length === 1
40
+ && uniqueLabels[0] === 'bug';
41
+ if (issueNumberSet && options.scopeListIssuesToIssueNumbers !== false && !isBugDuplicateCheck) {
42
+ const filtered = sorted.filter(i => issueNumberSet.has(Number(i.number)));
43
+ for (const issue of filtered)
44
+ listVisibleIssueNumbers.add(Number(issue.number));
45
+ return { issues: filtered };
46
+ }
47
+ for (const issue of sorted)
48
+ listVisibleIssueNumbers.add(Number(issue.number));
49
+ return { issues: sorted };
37
50
  },
38
51
  });
39
52
  const readIssue = tool({
@@ -42,6 +55,12 @@ export function createBacklogTools(owner, repo, options = {}) {
42
55
  issueNumber: z.number().int().min(1).describe('The issue number to read'),
43
56
  })),
44
57
  execute: async ({ issueNumber }) => {
58
+ if (issueNumberSet
59
+ && options.scopeReadIssueToIssueNumbers !== false
60
+ && !issueNumberSet.has(issueNumber)
61
+ && !listVisibleIssueNumbers.has(issueNumber)) {
62
+ return { error: `Issue #${issueNumber} is outside the selected worker scope` };
63
+ }
45
64
  const detail = await fetchGitHubIssue(owner, repo, issueNumber);
46
65
  if (!detail)
47
66
  return { error: `Issue #${issueNumber} not found` };
@@ -106,7 +106,7 @@ export function createExecutionTools(projectRoot, options) {
106
106
  },
107
107
  });
108
108
  const runLoop = tool({
109
- description: 'Run the development loop for a feature. Spawns a background process and returns when complete. The loop uses Claude Code internally with its own model config do NOT forward the agent model here.',
109
+ description: 'Run the development loop for a feature. Spawns a background process and returns when complete. The loop uses the coding CLI configured in ralph.config.cjs; do NOT forward the agent model here.',
110
110
  inputSchema: zodSchema(z.object({
111
111
  featureName: FEATURE_NAME_SCHEMA,
112
112
  worktree: z.boolean().default(true).describe('Use git worktree isolation'),
@@ -1,9 +1,9 @@
1
1
  import { tool, zodSchema } from 'ai';
2
2
  import { z } from 'zod';
3
- import { existsSync } from 'node:fs';
4
- import { readFile } from 'node:fs/promises';
3
+ import { readFile, stat, open } from 'node:fs/promises';
5
4
  import { join } from 'node:path';
6
5
  import { FEATURE_NAME_SCHEMA } from './schemas.js';
6
+ const MAX_LOG_BYTES = 1_048_576; // 1 MB
7
7
  export function createIntrospectionTools(projectRoot) {
8
8
  const readLoopLog = tool({
9
9
  description: 'Read the stdout/stderr log of a development loop (running or completed).',
@@ -13,10 +13,32 @@ export function createIntrospectionTools(projectRoot) {
13
13
  })),
14
14
  execute: async ({ featureName, tailLines }) => {
15
15
  const logPath = join('/tmp', `ralph-loop-${featureName}.log`);
16
- if (!existsSync(logPath)) {
16
+ let fileSize;
17
+ try {
18
+ const fileStat = await stat(logPath);
19
+ fileSize = fileStat.size;
20
+ }
21
+ catch {
17
22
  return { error: `No log found at ${logPath} — verify featureName matches exactly what runLoop used` };
18
23
  }
19
- const content = await readFile(logPath, 'utf-8');
24
+ let content;
25
+ if (fileSize <= MAX_LOG_BYTES) {
26
+ content = await readFile(logPath, 'utf-8');
27
+ }
28
+ else {
29
+ // For large files, read only the last MAX_LOG_BYTES to bound memory usage.
30
+ // totalLines will reflect lines in the chunk, not the full file.
31
+ const offset = fileSize - MAX_LOG_BYTES;
32
+ const fd = await open(logPath, 'r');
33
+ try {
34
+ const buffer = Buffer.allocUnsafe(MAX_LOG_BYTES);
35
+ const { bytesRead } = await fd.read(buffer, 0, MAX_LOG_BYTES, offset);
36
+ content = buffer.subarray(0, bytesRead).toString('utf-8');
37
+ }
38
+ finally {
39
+ await fd.close();
40
+ }
41
+ }
20
42
  const allLines = content.split('\n');
21
43
  const lines = allLines.slice(-tailLines);
22
44
  return { lines, totalLines: allLines.length };
@@ -1,5 +1,39 @@
1
1
  import type { LanguageModel } from 'ai';
2
2
  export type ReviewMode = 'manual' | 'auto' | 'merge';
3
+ export type DependencyKind = 'explicit' | 'inferred';
4
+ export type DependencyConfidence = 'high' | 'medium' | 'low';
5
+ export type TaskActionability = 'ready' | 'housekeeping' | 'waiting_pr' | 'blocked_dependency' | 'blocked_cycle' | 'blocked_out_of_scope';
6
+ export type AttemptState = 'never_tried' | 'partial' | 'failure' | 'success' | 'skipped';
7
+ export type PriorityTier = 'P0' | 'P1' | 'P2' | 'unlabeled';
8
+ export type ScopeOrigin = 'requested' | 'dependency';
9
+ export interface DependencyEvidence {
10
+ summary: string;
11
+ codebaseSignals?: string[];
12
+ backlogSignals?: string[];
13
+ }
14
+ export interface DependencyEdge {
15
+ sourceIssue: number;
16
+ targetIssue: number;
17
+ kind: DependencyKind;
18
+ confidence: DependencyConfidence;
19
+ evidence: DependencyEvidence;
20
+ blocking: boolean;
21
+ }
22
+ export interface SelectionReason {
23
+ kind: 'priority' | 'explicit_dependency' | 'inferred_dependency' | 'scope_expansion' | 'retry' | 'existing_work' | 'housekeeping' | 'blocked' | 'tie_break';
24
+ message: string;
25
+ confidence?: DependencyConfidence;
26
+ issueNumber?: number;
27
+ }
28
+ export interface TaskScoreBreakdown {
29
+ actionability: number;
30
+ retryResume: number;
31
+ priority: number;
32
+ dependencyHint: number;
33
+ existingWork: number;
34
+ issueNumber: number;
35
+ total: number;
36
+ }
3
37
  export interface AgentConfig {
4
38
  model: LanguageModel;
5
39
  modelId?: string;
@@ -14,6 +48,7 @@ export interface AgentConfig {
14
48
  reviewMode?: ReviewMode;
15
49
  dryRun?: boolean;
16
50
  onStepUpdate?: (event: AgentStepEvent) => void;
51
+ onOrchestratorEvent?: (event: AgentOrchestratorEvent) => void;
17
52
  onProgress?: (toolName: string, line: string) => void;
18
53
  }
19
54
  export interface AgentStepEvent {
@@ -33,14 +68,92 @@ export interface AgentLogEntry {
33
68
  level: 'info' | 'warn' | 'error' | 'success';
34
69
  }
35
70
  export type AgentPhase = 'idle' | 'planning' | 'generating_spec' | 'running_loop' | 'reporting' | 'reflecting';
71
+ export interface FeatureStateSummary {
72
+ recommendation?: string;
73
+ hasExistingBranch?: boolean;
74
+ commitsAhead?: number;
75
+ hasPlan?: boolean;
76
+ hasOpenPr?: boolean;
77
+ }
36
78
  export interface AgentIssueState {
37
79
  issueNumber: number;
38
80
  title: string;
39
81
  labels: string[];
40
82
  phase: AgentPhase;
83
+ scopeOrigin?: ScopeOrigin;
84
+ requestedBy?: number[];
85
+ actionability?: TaskActionability;
86
+ priorityTier?: PriorityTier;
87
+ dependsOn?: number[];
88
+ inferredDependsOn?: Array<{
89
+ issueNumber: number;
90
+ confidence: DependencyConfidence;
91
+ }>;
92
+ blockedBy?: Array<{
93
+ issueNumber: number;
94
+ reason: string;
95
+ confidence?: DependencyConfidence;
96
+ }>;
97
+ recommendation?: string;
98
+ selectionReasons?: SelectionReason[];
99
+ score?: TaskScoreBreakdown;
100
+ attemptState?: AttemptState;
101
+ featureState?: FeatureStateSummary;
41
102
  loopPhase?: string;
42
103
  loopFeatureName?: string;
43
104
  loopIterations?: number;
44
105
  prUrl?: string;
45
106
  error?: string;
46
107
  }
108
+ export interface BacklogCandidate extends AgentIssueState {
109
+ body: string;
110
+ createdAt: string;
111
+ explicitDependencyEdges: DependencyEdge[];
112
+ inferredDependencyEdges: DependencyEdge[];
113
+ }
114
+ export interface ScopeExpansion {
115
+ issueNumber: number;
116
+ requestedBy: number[];
117
+ }
118
+ export type AgentOrchestratorEvent = {
119
+ type: 'scope_expanded';
120
+ expansions: ScopeExpansion[];
121
+ } | {
122
+ type: 'backlog_progress';
123
+ phase: 'listing' | 'scope_expansion' | 'hydration' | 'enrichment' | 'dependency_inference' | 'ranking';
124
+ message: string;
125
+ completed?: number;
126
+ total?: number;
127
+ } | {
128
+ type: 'backlog_timing';
129
+ phase: 'listing' | 'scope_expansion' | 'hydration' | 'enrichment' | 'dependency_inference' | 'ranking';
130
+ durationMs: number;
131
+ count?: number;
132
+ } | {
133
+ type: 'backlog_scanned';
134
+ total: number;
135
+ issues: AgentIssueState[];
136
+ } | {
137
+ type: 'candidate_enriched';
138
+ issue: AgentIssueState;
139
+ } | {
140
+ type: 'dependencies_inferred';
141
+ issueNumber: number;
142
+ edges: DependencyEdge[];
143
+ } | {
144
+ type: 'queue_ranked';
145
+ queue: AgentIssueState[];
146
+ } | {
147
+ type: 'task_selected';
148
+ issue: AgentIssueState;
149
+ } | {
150
+ type: 'task_blocked';
151
+ issue: AgentIssueState;
152
+ } | {
153
+ type: 'task_started';
154
+ issue: AgentIssueState;
155
+ } | {
156
+ type: 'task_completed';
157
+ issue: AgentIssueState;
158
+ outcome: 'success' | 'partial' | 'failure' | 'skipped' | 'unknown';
159
+ };
@@ -24,25 +24,58 @@ export function isUrl(input) {
24
24
  * Simple extraction that removes scripts, styles, and HTML tags
25
25
  */
26
26
  function extractTextFromHtml(html) {
27
- // Remove script and style tags with their content
28
- let text = html
29
- .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
30
- .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
31
- .replace(/<noscript[^>]*>[\s\S]*?<\/noscript>/gi, '');
27
+ // Remove script, style, and noscript blocks before generic tag stripping.
28
+ let text = stripElementBlocks(html, 'script');
29
+ text = stripElementBlocks(text, 'style');
30
+ text = stripElementBlocks(text, 'noscript');
32
31
  // Remove HTML tags but keep content
33
32
  text = text.replace(/<[^>]+>/g, ' ');
34
- // Decode common HTML entities
35
- text = text
36
- .replace(/&nbsp;/g, ' ')
37
- .replace(/&amp;/g, '&')
38
- .replace(/&lt;/g, '<')
39
- .replace(/&gt;/g, '>')
40
- .replace(/&quot;/g, '"')
41
- .replace(/&#39;/g, "'");
33
+ // Decode only safe presentation entities; keep angle brackets encoded.
34
+ text = decodeSafeHtmlEntities(text);
42
35
  // Clean up whitespace
43
36
  text = text.replace(/\s+/g, ' ').trim();
44
37
  return text;
45
38
  }
39
+ /**
40
+ * Remove full HTML element blocks (open tag + content + closing tag) using
41
+ * deterministic string scanning instead of regex.
42
+ */
43
+ function stripElementBlocks(input, tagName) {
44
+ let output = input;
45
+ const openToken = `<${tagName}`;
46
+ const closeToken = `</${tagName}`;
47
+ while (true) {
48
+ const lower = output.toLowerCase();
49
+ const openStart = lower.indexOf(openToken);
50
+ if (openStart === -1) {
51
+ break;
52
+ }
53
+ const openEnd = lower.indexOf('>', openStart + openToken.length);
54
+ if (openEnd === -1) {
55
+ output = output.slice(0, openStart);
56
+ break;
57
+ }
58
+ const closeStart = lower.indexOf(closeToken, openEnd + 1);
59
+ if (closeStart === -1) {
60
+ output = output.slice(0, openStart);
61
+ break;
62
+ }
63
+ const closeEnd = lower.indexOf('>', closeStart + closeToken.length);
64
+ if (closeEnd === -1) {
65
+ output = output.slice(0, openStart);
66
+ break;
67
+ }
68
+ output = output.slice(0, openStart) + output.slice(closeEnd + 1);
69
+ }
70
+ return output;
71
+ }
72
+ /** Decode non-structural entities only (quotes/spaces), preserving `<`/`>`/`&`. */
73
+ function decodeSafeHtmlEntities(input) {
74
+ return input
75
+ .replace(/&nbsp;/gi, ' ')
76
+ .replace(/&quot;/gi, '"')
77
+ .replace(/&#39;/g, "'");
78
+ }
46
79
  /**
47
80
  * Fetch content from a URL
48
81
  */
@@ -102,11 +102,10 @@ export class AIEnhancer {
102
102
  async enhance(scanResult) {
103
103
  // Check if API key is available
104
104
  if (!this.isAvailable()) {
105
- const envVar = this.getRequiredEnvVar();
106
105
  return {
107
106
  ...scanResult,
108
107
  aiEnhanced: false,
109
- aiError: `API key not found. Set ${envVar} to enable AI enhancement.`,
108
+ aiError: 'API key not found. Configure credentials to enable AI enhancement.',
110
109
  };
111
110
  }
112
111
  try {
@@ -100,11 +100,11 @@ export function hasApiKey(provider) {
100
100
  */
101
101
  function getApiKey(provider) {
102
102
  const envVar = API_KEY_ENV_VARS[provider];
103
- const apiKey = process.env[envVar];
104
- if (!apiKey) {
105
- throw new Error(`API key not found. Set ${envVar} environment variable to use ${provider} provider.`);
103
+ const credential = process.env[envVar];
104
+ if (!credential) {
105
+ throw new Error(`API key not found for provider: ${provider}.`);
106
106
  }
107
- return apiKey;
107
+ return credential;
108
108
  }
109
109
  /**
110
110
  * Get a configured AI model for the specified provider
@@ -14,5 +14,6 @@ export interface AgentOptions {
14
14
  reviewMode?: 'manual' | 'auto' | 'merge';
15
15
  dryRun?: boolean;
16
16
  stream?: boolean;
17
+ diagnoseGh?: boolean;
17
18
  }
18
19
  export declare function agentCommand(options?: AgentOptions): Promise<void>;
@@ -9,8 +9,25 @@ import { logger } from '../utils/logger.js';
9
9
  import { createAgentOrchestrator, } from '../agent/orchestrator.js';
10
10
  import { resolveAgentEnv } from '../agent/resolve-config.js';
11
11
  import { initTracing, flushTracing, traced, currentSpan } from '../utils/tracing.js';
12
+ import { detectGitHubRemote, runGitHubDiagnostics } from '../utils/github.js';
12
13
  export async function agentCommand(options = {}) {
13
14
  const projectRoot = process.cwd();
15
+ if (options.diagnoseGh) {
16
+ const repo = await detectGitHubRemote(projectRoot);
17
+ if (!repo) {
18
+ console.error('Error: No GitHub remote detected. Run this inside a git repo with an origin remote.');
19
+ process.exit(1);
20
+ }
21
+ const diagnostics = await runGitHubDiagnostics(repo.owner, repo.repo, options.issues);
22
+ for (const check of diagnostics.checks) {
23
+ const status = check.ok ? 'OK' : 'FAIL';
24
+ console.log(`[diagnose-gh] ${status} ${check.name}: ${check.message}`);
25
+ }
26
+ if (!diagnostics.success) {
27
+ process.exit(1);
28
+ }
29
+ return;
30
+ }
14
31
  // Initialize Braintrust tracing (no-op if BRAINTRUST_API_KEY not set)
15
32
  initTracing();
16
33
  // Resolve provider, model, and GitHub remote
@@ -19,7 +36,7 @@ export async function agentCommand(options = {}) {
19
36
  env = await resolveAgentEnv(projectRoot, { model: options.model });
20
37
  }
21
38
  catch (err) {
22
- console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
39
+ logger.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
23
40
  process.exit(1);
24
41
  }
25
42
  const { provider, model, modelId, owner, repo } = env;
@@ -51,6 +68,38 @@ export async function agentCommand(options = {}) {
51
68
  log(`[tool:done] ${tr.toolName} → ${summary}`);
52
69
  }
53
70
  },
71
+ onOrchestratorEvent: (event) => {
72
+ const log = options.stream
73
+ ? (msg) => process.stdout.write(`${msg}\n`)
74
+ : (msg) => logger.info(msg);
75
+ switch (event.type) {
76
+ case 'scope_expanded':
77
+ log(`[orchestrator] expanded scope with ${event.expansions.map(expansion => `#${expansion.issueNumber}`).join(', ')}`);
78
+ break;
79
+ case 'backlog_progress':
80
+ log(`[orchestrator] ${event.message}`);
81
+ break;
82
+ case 'backlog_timing':
83
+ log(`[orchestrator] ${event.phase} took ${event.durationMs}ms${event.count != null ? ` (${event.count})` : ''}`);
84
+ break;
85
+ case 'queue_ranked':
86
+ log(`[orchestrator] ranked ${event.queue.length} issue(s)`);
87
+ break;
88
+ case 'task_selected': {
89
+ const reason = event.issue.selectionReasons?.[0]?.message;
90
+ log(`[orchestrator] selected #${event.issue.issueNumber}${reason ? ` — ${reason}` : ''}`);
91
+ break;
92
+ }
93
+ case 'task_blocked':
94
+ log(`[orchestrator] blocked #${event.issue.issueNumber} — ${event.issue.blockedBy?.[0]?.reason ?? event.issue.actionability ?? 'blocked'}`);
95
+ break;
96
+ case 'task_completed':
97
+ log(`[orchestrator] completed #${event.issue.issueNumber} (${event.outcome})`);
98
+ break;
99
+ default:
100
+ break;
101
+ }
102
+ },
54
103
  onProgress: (toolName, line) => {
55
104
  const log = options.stream
56
105
  ? (msg) => process.stdout.write(`${msg}\n`)
@@ -107,6 +156,9 @@ export async function agentCommand(options = {}) {
107
156
  catch (err) {
108
157
  const message = err instanceof Error ? err.message : String(err);
109
158
  console.error(`Error: Agent failed — ${message}`);
159
+ if (message.includes('GitHub') || message.includes('gh ')) {
160
+ console.error(`Hint: run 'wiggum agent --diagnose-gh${options.issues?.length ? ` --issues ${options.issues.join(',')}` : ''}' to inspect GitHub connectivity.`);
161
+ }
110
162
  process.exit(1);
111
163
  }
112
164
  finally {
@@ -9,6 +9,7 @@ import { logger } from '../utils/logger.js';
9
9
  import { simpson } from '../utils/colors.js';
10
10
  import { getAvailableProvider, AVAILABLE_MODELS } from '../ai/providers.js';
11
11
  import { writeKeysToEnvFile } from '../utils/env.js';
12
+ import { loadConfigWithDefaults } from '../utils/config.js';
12
13
  /**
13
14
  * Supported services for API key configuration
14
15
  */
@@ -26,6 +27,11 @@ const CONFIGURABLE_SERVICES = {
26
27
  description: 'AI tracing and analytics',
27
28
  },
28
29
  };
30
+ const LOOP_CLI_SETTINGS = ['cli', 'review-cli'];
31
+ const LOOP_CLI_VALUES = ['claude', 'codex'];
32
+ const DEFAULT_CLAUDE_IMPL_MODEL = 'sonnet';
33
+ const DEFAULT_CLAUDE_REVIEW_MODEL = 'opus';
34
+ const DEFAULT_CODEX_MODEL = 'gpt-5.3-codex';
29
35
  /**
30
36
  * Check if a service API key is configured
31
37
  */
@@ -45,6 +51,69 @@ function saveKeyToEnvLocal(projectRoot, envVar, value) {
45
51
  const envLocalPath = path.join(ralphDir, '.env.local');
46
52
  writeKeysToEnvFile(envLocalPath, { [envVar]: value });
47
53
  }
54
+ function toConfigFileContent(config) {
55
+ const content = `module.exports = ${JSON.stringify(config, null, 2)};
56
+ `;
57
+ return content
58
+ .replace(/"(\w+)":/g, '$1:')
59
+ .replace(/: "([^"]+)"/g, ": '$1'");
60
+ }
61
+ function normalizeLoopCliSetting(raw) {
62
+ if (raw === 'cli')
63
+ return 'cli';
64
+ if (raw === 'review-cli' || raw === 'reviewCli')
65
+ return 'review-cli';
66
+ return null;
67
+ }
68
+ function isLoopCliValue(value) {
69
+ return LOOP_CLI_VALUES.includes(value);
70
+ }
71
+ function reconcileLoopModelsForCliSelection(loop, codingCli, reviewCli) {
72
+ let defaultModel = loop.defaultModel;
73
+ let planningModel = loop.planningModel;
74
+ const usesClaude = codingCli === 'claude' || reviewCli === 'claude';
75
+ const codexOnly = codingCli === 'codex' && reviewCli === 'codex';
76
+ if (usesClaude) {
77
+ if (defaultModel === DEFAULT_CODEX_MODEL) {
78
+ defaultModel = DEFAULT_CLAUDE_IMPL_MODEL;
79
+ }
80
+ if (planningModel === DEFAULT_CODEX_MODEL) {
81
+ planningModel = DEFAULT_CLAUDE_REVIEW_MODEL;
82
+ }
83
+ }
84
+ else if (codexOnly) {
85
+ if (defaultModel === DEFAULT_CLAUDE_IMPL_MODEL) {
86
+ defaultModel = DEFAULT_CODEX_MODEL;
87
+ }
88
+ if (planningModel === DEFAULT_CLAUDE_REVIEW_MODEL) {
89
+ planningModel = DEFAULT_CODEX_MODEL;
90
+ }
91
+ }
92
+ return { defaultModel, planningModel };
93
+ }
94
+ async function saveLoopCliToConfig(projectRoot, setting, value) {
95
+ // Check that .ralph/ exists (project is initialized)
96
+ const ralphDir = path.join(projectRoot, '.ralph');
97
+ if (!fs.existsSync(ralphDir) || !fs.statSync(ralphDir).isDirectory()) {
98
+ throw new Error('This project is not initialized. Run \'wiggum init\' before using loop CLI settings.');
99
+ }
100
+ const configPath = path.join(projectRoot, 'ralph.config.cjs');
101
+ const config = await loadConfigWithDefaults(projectRoot);
102
+ const nextCodingCli = setting === 'cli' ? value : config.loop.codingCli;
103
+ const nextReviewCli = setting === 'review-cli' ? value : config.loop.reviewCli;
104
+ const { defaultModel, planningModel } = reconcileLoopModelsForCliSelection(config.loop, nextCodingCli, nextReviewCli);
105
+ const nextConfig = {
106
+ ...config,
107
+ loop: {
108
+ ...config.loop,
109
+ defaultModel,
110
+ planningModel,
111
+ codingCli: nextCodingCli,
112
+ reviewCli: nextReviewCli,
113
+ },
114
+ };
115
+ fs.writeFileSync(configPath, toConfigFileContent(nextConfig), 'utf-8');
116
+ }
48
117
  /**
49
118
  * Display current configuration status
50
119
  */
@@ -75,6 +144,8 @@ function displayConfigStatus(state) {
75
144
  console.log(` ${simpson.yellow('/config set tavily')} ${pc.dim('<api-key>')}`);
76
145
  console.log(` ${simpson.yellow('/config set context7')} ${pc.dim('<api-key>')}`);
77
146
  console.log(` ${simpson.yellow('/config set braintrust')} ${pc.dim('<api-key>')}`);
147
+ console.log(` ${simpson.yellow('/config set cli')} ${pc.dim('<claude|codex>')}`);
148
+ console.log(` ${simpson.yellow('/config set review-cli')} ${pc.dim('<claude|codex>')}`);
78
149
  console.log('');
79
150
  }
80
151
  /**
@@ -93,17 +164,37 @@ export async function handleConfigCommand(args, state) {
93
164
  }
94
165
  // /config set <service> <key>
95
166
  if (args.length < 3) {
96
- logger.error('Usage: /config set <service> <api-key>');
167
+ logger.error('Usage: /config set <service> <value>');
97
168
  console.log('');
98
169
  console.log('Available services:');
99
170
  for (const [service, config] of Object.entries(CONFIGURABLE_SERVICES)) {
100
171
  console.log(` ${service.padEnd(12)} ${pc.dim(config.description)}`);
101
172
  }
173
+ for (const setting of LOOP_CLI_SETTINGS) {
174
+ console.log(` ${setting.padEnd(12)} ${pc.dim('Loop CLI setting')}`);
175
+ }
102
176
  console.log('');
103
177
  return state;
104
178
  }
105
- const service = args[1]?.toLowerCase();
106
- const apiKey = args[2];
179
+ const rawService = args[1]?.toLowerCase() ?? '';
180
+ const value = args[2];
181
+ const loopCliSetting = normalizeLoopCliSetting(rawService);
182
+ if (loopCliSetting) {
183
+ if (!isLoopCliValue(value)) {
184
+ logger.error(`Invalid ${loopCliSetting} value: '${value}'. Allowed values: ${LOOP_CLI_VALUES.join(', ')}`);
185
+ return state;
186
+ }
187
+ try {
188
+ await saveLoopCliToConfig(state.projectRoot, loopCliSetting, value);
189
+ logger.success(`${loopCliSetting} saved to ralph.config.cjs (${value})`);
190
+ console.log('');
191
+ }
192
+ catch (error) {
193
+ logger.error(`Failed to save ${loopCliSetting}: ${error instanceof Error ? error.message : String(error)}`);
194
+ }
195
+ return state;
196
+ }
197
+ const service = rawService;
107
198
  if (!(service in CONFIGURABLE_SERVICES)) {
108
199
  logger.error(`Unknown service: ${service}`);
109
200
  console.log('');
@@ -111,20 +202,23 @@ export async function handleConfigCommand(args, state) {
111
202
  for (const [svc, config] of Object.entries(CONFIGURABLE_SERVICES)) {
112
203
  console.log(` ${svc.padEnd(12)} ${pc.dim(config.description)}`);
113
204
  }
205
+ for (const setting of LOOP_CLI_SETTINGS) {
206
+ console.log(` ${setting.padEnd(12)} ${pc.dim('Loop CLI setting')}`);
207
+ }
114
208
  console.log('');
115
209
  return state;
116
210
  }
117
211
  const { envVar } = CONFIGURABLE_SERVICES[service];
118
212
  // Validate API key format (basic check)
119
- if (!apiKey || apiKey.length < 10) {
213
+ if (!value || value.length < 10) {
120
214
  logger.error('Invalid API key. Key appears too short.');
121
215
  return state;
122
216
  }
123
217
  try {
124
218
  // Save to .env.local
125
- saveKeyToEnvLocal(state.projectRoot, envVar, apiKey);
219
+ saveKeyToEnvLocal(state.projectRoot, envVar, value);
126
220
  // Also set in current process environment
127
- process.env[envVar] = apiKey;
221
+ process.env[envVar] = value;
128
222
  logger.success(`${envVar} saved to .ralph/.env.local`);
129
223
  console.log(pc.dim('Restart Wiggum to apply changes to tool availability.'));
130
224
  console.log('');
@@ -6,6 +6,8 @@ export interface RunOptions {
6
6
  worktree?: boolean;
7
7
  resume?: boolean;
8
8
  model?: string;
9
+ cli?: 'claude' | 'codex';
10
+ reviewCli?: 'claude' | 'codex';
9
11
  maxIterations?: number;
10
12
  maxE2eAttempts?: number;
11
13
  reviewMode?: 'manual' | 'auto' | 'merge';