wiggum-cli 0.15.0 → 0.17.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 (110) hide show
  1. package/README.md +7 -1
  2. package/bin/ralph.js +0 -0
  3. package/dist/agent/memory/ingest.d.ts +14 -0
  4. package/dist/agent/memory/ingest.js +77 -0
  5. package/dist/agent/memory/store.d.ts +15 -0
  6. package/dist/agent/memory/store.js +98 -0
  7. package/dist/agent/memory/types.d.ts +16 -0
  8. package/dist/agent/memory/types.js +14 -0
  9. package/dist/agent/orchestrator.d.ts +7 -0
  10. package/dist/agent/orchestrator.js +266 -0
  11. package/dist/agent/resolve-config.d.ts +26 -0
  12. package/dist/agent/resolve-config.js +43 -0
  13. package/dist/agent/tools/backlog.d.ts +27 -0
  14. package/dist/agent/tools/backlog.js +51 -0
  15. package/dist/agent/tools/dry-run.d.ts +106 -0
  16. package/dist/agent/tools/dry-run.js +119 -0
  17. package/dist/agent/tools/execution.d.ts +51 -0
  18. package/dist/agent/tools/execution.js +256 -0
  19. package/dist/agent/tools/feature-state.d.ts +43 -0
  20. package/dist/agent/tools/feature-state.js +184 -0
  21. package/dist/agent/tools/introspection.d.ts +23 -0
  22. package/dist/agent/tools/introspection.js +40 -0
  23. package/dist/agent/tools/memory.d.ts +44 -0
  24. package/dist/agent/tools/memory.js +99 -0
  25. package/dist/agent/tools/preflight.d.ts +7 -0
  26. package/dist/agent/tools/preflight.js +137 -0
  27. package/dist/agent/tools/reporting.d.ts +58 -0
  28. package/dist/agent/tools/reporting.js +119 -0
  29. package/dist/agent/tools/schemas.d.ts +2 -0
  30. package/dist/agent/tools/schemas.js +3 -0
  31. package/dist/agent/types.d.ts +45 -0
  32. package/dist/agent/types.js +1 -0
  33. package/dist/ai/conversation/conversation-manager.js +8 -0
  34. package/dist/ai/conversation/url-fetcher.js +27 -0
  35. package/dist/ai/providers.js +5 -5
  36. package/dist/commands/agent.d.ts +17 -0
  37. package/dist/commands/agent.js +114 -0
  38. package/dist/commands/monitor.js +50 -183
  39. package/dist/commands/new-auto.d.ts +15 -0
  40. package/dist/commands/new-auto.js +237 -0
  41. package/dist/commands/run.js +20 -10
  42. package/dist/commands/sync.d.ts +15 -0
  43. package/dist/commands/sync.js +68 -0
  44. package/dist/generator/config.d.ts +1 -41
  45. package/dist/generator/config.js +7 -0
  46. package/dist/generator/index.d.ts +2 -2
  47. package/dist/generator/templates.d.ts +3 -0
  48. package/dist/generator/templates.js +22 -1
  49. package/dist/index.d.ts +14 -1
  50. package/dist/index.js +333 -40
  51. package/dist/repl/command-parser.d.ts +5 -0
  52. package/dist/repl/command-parser.js +5 -0
  53. package/dist/templates/prompts/PROMPT.md.tmpl +13 -10
  54. package/dist/templates/prompts/PROMPT_e2e.md.tmpl +162 -5
  55. package/dist/templates/prompts/PROMPT_feature.md.tmpl +39 -3
  56. package/dist/templates/prompts/PROMPT_review_auto.md.tmpl +33 -8
  57. package/dist/templates/prompts/PROMPT_review_manual.md.tmpl +4 -1
  58. package/dist/templates/prompts/PROMPT_review_merge.md.tmpl +40 -10
  59. package/dist/templates/prompts/PROMPT_verify.md.tmpl +5 -2
  60. package/dist/templates/scripts/feature-loop.sh.tmpl +611 -95
  61. package/dist/tui/app.d.ts +34 -2
  62. package/dist/tui/app.js +31 -5
  63. package/dist/tui/components/ActivityFeed.d.ts +18 -0
  64. package/dist/tui/components/ActivityFeed.js +31 -0
  65. package/dist/tui/components/IssuePicker.d.ts +27 -0
  66. package/dist/tui/components/IssuePicker.js +64 -0
  67. package/dist/tui/components/RunCompletionSummary.d.ts +27 -1
  68. package/dist/tui/components/RunCompletionSummary.js +103 -10
  69. package/dist/tui/components/SummaryBox.d.ts +4 -0
  70. package/dist/tui/components/SummaryBox.js +4 -2
  71. package/dist/tui/hooks/useAgentOrchestrator.d.ts +29 -0
  72. package/dist/tui/hooks/useAgentOrchestrator.js +453 -0
  73. package/dist/tui/hooks/useBackgroundRuns.js +1 -1
  74. package/dist/tui/orchestration/interview-orchestrator.d.ts +5 -1
  75. package/dist/tui/orchestration/interview-orchestrator.js +27 -6
  76. package/dist/tui/screens/AgentScreen.d.ts +21 -0
  77. package/dist/tui/screens/AgentScreen.js +159 -0
  78. package/dist/tui/screens/InitScreen.js +4 -0
  79. package/dist/tui/screens/InterviewScreen.d.ts +3 -1
  80. package/dist/tui/screens/InterviewScreen.js +146 -10
  81. package/dist/tui/screens/MainShell.d.ts +1 -1
  82. package/dist/tui/screens/MainShell.js +36 -1
  83. package/dist/tui/screens/RunScreen.d.ts +15 -15
  84. package/dist/tui/screens/RunScreen.js +96 -11
  85. package/dist/tui/utils/build-run-summary.d.ts +1 -1
  86. package/dist/tui/utils/build-run-summary.js +44 -85
  87. package/dist/tui/utils/clear-screen.d.ts +14 -0
  88. package/dist/tui/utils/clear-screen.js +16 -0
  89. package/dist/tui/utils/git-summary.d.ts +13 -0
  90. package/dist/tui/utils/git-summary.js +30 -0
  91. package/dist/tui/utils/loop-status.d.ts +94 -0
  92. package/dist/tui/utils/loop-status.js +430 -10
  93. package/dist/tui/utils/pr-summary.d.ts +3 -2
  94. package/dist/tui/utils/pr-summary.js +41 -6
  95. package/dist/utils/ci.d.ts +8 -0
  96. package/dist/utils/ci.js +13 -0
  97. package/dist/utils/config.d.ts +8 -0
  98. package/dist/utils/config.js +8 -0
  99. package/dist/utils/github.d.ts +32 -0
  100. package/dist/utils/github.js +106 -0
  101. package/dist/utils/spec-names.js +5 -1
  102. package/package.json +10 -2
  103. package/src/templates/prompts/PROMPT.md.tmpl +13 -10
  104. package/src/templates/prompts/PROMPT_e2e.md.tmpl +162 -5
  105. package/src/templates/prompts/PROMPT_feature.md.tmpl +39 -3
  106. package/src/templates/prompts/PROMPT_review_auto.md.tmpl +33 -8
  107. package/src/templates/prompts/PROMPT_review_manual.md.tmpl +4 -1
  108. package/src/templates/prompts/PROMPT_review_merge.md.tmpl +40 -10
  109. package/src/templates/prompts/PROMPT_verify.md.tmpl +5 -2
  110. package/src/templates/scripts/feature-loop.sh.tmpl +611 -95
@@ -2,11 +2,12 @@
2
2
  * Monitor Command
3
3
  * Display real-time status of a feature loop
4
4
  */
5
- import { spawn, execFileSync } from 'node:child_process';
6
- import { existsSync, readFileSync } from 'node:fs';
5
+ import { spawn } from 'node:child_process';
6
+ import { existsSync } from 'node:fs';
7
7
  import { join, dirname } from 'node:path';
8
8
  import { logger } from '../utils/logger.js';
9
9
  import { loadConfigWithDefaults } from '../utils/config.js';
10
+ import { readLoopStatus, parseImplementationPlan, getGitBranch, formatNumber, } from '../tui/utils/loop-status.js';
10
11
  import pc from 'picocolors';
11
12
  /**
12
13
  * Find the ralph-monitor.sh script
@@ -29,162 +30,6 @@ function findMonitorScript(projectRoot) {
29
30
  }
30
31
  return null;
31
32
  }
32
- /**
33
- * Check if a process matching pattern is running
34
- * Uses pgrep with -f flag for full command line matching
35
- */
36
- function isProcessRunning(pattern) {
37
- try {
38
- // Use execFileSync for safer execution
39
- const result = execFileSync('pgrep', ['-f', pattern], { encoding: 'utf-8' });
40
- return result.trim().length > 0;
41
- }
42
- catch {
43
- return false;
44
- }
45
- }
46
- /**
47
- * Detect current phase of the loop
48
- */
49
- function detectPhase(feature) {
50
- if (isProcessRunning('PROMPT_feature.md'))
51
- return 'Planning';
52
- if (isProcessRunning('PROMPT_e2e.md'))
53
- return 'E2E Testing';
54
- if (isProcessRunning('PROMPT_verify.md'))
55
- return 'Verification';
56
- if (isProcessRunning('PROMPT_review_manual.md'))
57
- return 'PR Review';
58
- if (isProcessRunning('PROMPT_review_auto.md'))
59
- return 'PR Review';
60
- if (isProcessRunning('PROMPT.md'))
61
- return 'Implementation';
62
- if (isProcessRunning(`feature-loop.sh.*${feature}`))
63
- return 'Running';
64
- return 'Idle';
65
- }
66
- /**
67
- * Read status from temp files
68
- */
69
- function readStatus(feature) {
70
- const statusFile = `/tmp/ralph-loop-${feature}.status`;
71
- const tokensFile = `/tmp/ralph-loop-${feature}.tokens`;
72
- let iteration = 0;
73
- let maxIterations = 50;
74
- // Read status file
75
- if (existsSync(statusFile)) {
76
- try {
77
- const content = readFileSync(statusFile, 'utf-8').trim();
78
- const parts = content.split('|');
79
- iteration = parseInt(parts[0]) || 0;
80
- maxIterations = parseInt(parts[1]) || 50;
81
- }
82
- catch {
83
- // Ignore errors
84
- }
85
- }
86
- // Read tokens file
87
- let tokensInput = 0;
88
- let tokensOutput = 0;
89
- if (existsSync(tokensFile)) {
90
- try {
91
- const content = readFileSync(tokensFile, 'utf-8').trim();
92
- const parts = content.split('|');
93
- tokensInput = parseInt(parts[0]) || 0;
94
- tokensOutput = parseInt(parts[1]) || 0;
95
- }
96
- catch {
97
- // Ignore errors
98
- }
99
- }
100
- return {
101
- running: isProcessRunning(`feature-loop.sh.*${feature}`),
102
- phase: detectPhase(feature),
103
- iteration,
104
- maxIterations,
105
- tokensInput,
106
- tokensOutput,
107
- tasksDone: 0,
108
- tasksPending: 0,
109
- e2eDone: 0,
110
- e2ePending: 0,
111
- branch: '',
112
- elapsed: '',
113
- };
114
- }
115
- /**
116
- * Parse implementation plan for task counts
117
- */
118
- async function parseImplementationPlan(projectRoot, feature) {
119
- const config = await loadConfigWithDefaults(projectRoot);
120
- const planPath = join(projectRoot, config.paths.specs, `${feature}-implementation-plan.md`);
121
- let tasksDone = 0;
122
- let tasksPending = 0;
123
- let e2eDone = 0;
124
- let e2ePending = 0;
125
- if (existsSync(planPath)) {
126
- try {
127
- const content = readFileSync(planPath, 'utf-8');
128
- const lines = content.split('\n');
129
- for (const line of lines) {
130
- if (line.match(/^- \[x\]/)) {
131
- if (line.includes('E2E:')) {
132
- e2eDone++;
133
- }
134
- else {
135
- tasksDone++;
136
- }
137
- }
138
- else if (line.match(/^- \[ \]/)) {
139
- if (line.includes('E2E:')) {
140
- e2ePending++;
141
- }
142
- else {
143
- tasksPending++;
144
- }
145
- }
146
- }
147
- }
148
- catch {
149
- // Ignore errors
150
- }
151
- }
152
- return { tasksDone, tasksPending, e2eDone, e2ePending };
153
- }
154
- /**
155
- * Get current git branch
156
- */
157
- function getGitBranch(projectRoot) {
158
- try {
159
- // Try app directory first
160
- const appDir = join(projectRoot, '..', 'app');
161
- if (existsSync(appDir)) {
162
- return execFileSync('git', ['branch', '--show-current'], {
163
- cwd: appDir,
164
- encoding: 'utf-8',
165
- }).trim();
166
- }
167
- return execFileSync('git', ['branch', '--show-current'], {
168
- cwd: projectRoot,
169
- encoding: 'utf-8',
170
- }).trim();
171
- }
172
- catch {
173
- return '-';
174
- }
175
- }
176
- /**
177
- * Format number with K/M suffix
178
- */
179
- function formatNumber(num) {
180
- if (num >= 1000000) {
181
- return (num / 1000000).toFixed(1) + 'M';
182
- }
183
- if (num >= 1000) {
184
- return (num / 1000).toFixed(1) + 'K';
185
- }
186
- return String(num);
187
- }
188
33
  /**
189
34
  * Create a progress bar
190
35
  */
@@ -196,9 +41,9 @@ function progressBar(percent, width = 15) {
196
41
  /**
197
42
  * Display built-in monitor dashboard
198
43
  */
199
- async function displayDashboard(feature, projectRoot, interval = 5) {
200
- const status = readStatus(feature);
201
- const tasks = await parseImplementationPlan(projectRoot, feature);
44
+ async function displayDashboard(feature, projectRoot, specsDir, interval = 5) {
45
+ const status = readLoopStatus(feature);
46
+ const tasks = await parseImplementationPlan(projectRoot, feature, specsDir);
202
47
  const branch = getGitBranch(projectRoot);
203
48
  // Calculate progress
204
49
  const totalTasks = tasks.tasksDone + tasks.tasksPending;
@@ -233,14 +78,28 @@ async function displayDashboard(feature, projectRoot, interval = 5) {
233
78
  console.log(` Phase: ${phaseColor(pc.bold(status.phase))}` +
234
79
  ` | Iter: ${pc.bold(String(status.iteration))}/${pc.dim(String(status.maxIterations))}` +
235
80
  ` | Branch: ${pc.cyan(branch)}`);
236
- const totalTokens = status.tokensInput + status.tokensOutput;
81
+ const totalTokens = status.tokensInput + status.tokensOutput + status.cacheCreate + status.cacheRead;
82
+ let tokensSuffix = '';
83
+ if (status.tokensUpdatedAt) {
84
+ const agoMs = Date.now() - status.tokensUpdatedAt;
85
+ const agoSec = Math.floor(agoMs / 1000);
86
+ if (agoSec >= 60) {
87
+ tokensSuffix = pc.dim(` updated ${Math.floor(agoSec / 60)}m ago`);
88
+ }
89
+ }
237
90
  console.log(` Tokens: ${pc.magenta(formatNumber(totalTokens))}` +
238
- pc.dim(` (in:${formatNumber(status.tokensInput)} out:${formatNumber(status.tokensOutput)})`));
91
+ pc.dim(` (in:${formatNumber(status.tokensInput)} out:${formatNumber(status.tokensOutput)} cache:${formatNumber(status.cacheRead)})`) +
92
+ tokensSuffix);
239
93
  console.log(pc.dim(' ' + '-'.repeat(74)));
240
94
  // Progress
241
95
  console.log('');
242
- console.log(` ${pc.bold('Implementation:')} ${progressBar(percentTasks)} ${pc.bold(percentTasks + '%')}` +
243
- ` ${pc.green('\u2713 ' + tasks.tasksDone)} / ${pc.yellow('\u25cb ' + tasks.tasksPending)}`);
96
+ if (!tasks.planExists && status.running) {
97
+ console.log(` ${pc.bold('Implementation:')} ${pc.dim('[waiting for plan...]')}`);
98
+ }
99
+ else {
100
+ console.log(` ${pc.bold('Implementation:')} ${progressBar(percentTasks)} ${pc.bold(percentTasks + '%')}` +
101
+ ` ${pc.green('\u2713 ' + tasks.tasksDone)} / ${pc.yellow('\u25cb ' + tasks.tasksPending)}`);
102
+ }
244
103
  if (totalE2e > 0) {
245
104
  console.log(` ${pc.bold('E2E Tests: ')} ${progressBar(percentE2e)} ${pc.bold(percentE2e + '%')}` +
246
105
  ` ${pc.green('\u2713 ' + tasks.e2eDone)} / ${pc.yellow('\u25cb ' + tasks.e2ePending)}`);
@@ -305,12 +164,21 @@ export async function monitorCommand(feature, options = {}) {
305
164
  logger.warn('Python TUI monitor not yet implemented');
306
165
  logger.info('Using built-in monitor instead');
307
166
  }
308
- // Built-in monitor
167
+ // Load config for correct specs path
168
+ let specsDir = '.ralph/specs';
169
+ try {
170
+ const config = await loadConfigWithDefaults(projectRoot);
171
+ specsDir = config.paths.specs;
172
+ }
173
+ catch (err) {
174
+ logger.debug(`Failed to load config: ${err instanceof Error ? err.message : String(err)}`);
175
+ }
176
+ // Built-in monitor with sequential polling
309
177
  const intervalSeconds = options.interval || 5;
310
178
  const intervalMs = intervalSeconds * 1000;
311
179
  // Initial display
312
180
  try {
313
- await displayDashboard(feature, projectRoot, intervalSeconds);
181
+ await displayDashboard(feature, projectRoot, specsDir, intervalSeconds);
314
182
  }
315
183
  catch (error) {
316
184
  logger.error(`Failed to display dashboard: ${error instanceof Error ? error.message : String(error)}`);
@@ -319,25 +187,24 @@ export async function monitorCommand(feature, options = {}) {
319
187
  }
320
188
  process.exit(1);
321
189
  }
322
- // Refresh loop
323
- const refreshTimer = setInterval(async () => {
190
+ // Sequential refresh loop (prevents overlapping refreshes)
191
+ let running = true;
192
+ const cleanup = () => {
193
+ running = false;
194
+ console.log('');
195
+ logger.info('Monitor stopped');
196
+ };
197
+ process.on('SIGINT', cleanup);
198
+ process.on('SIGTERM', cleanup);
199
+ while (running) {
200
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
201
+ if (!running)
202
+ break;
324
203
  try {
325
- await displayDashboard(feature, projectRoot, intervalSeconds);
204
+ await displayDashboard(feature, projectRoot, specsDir, intervalSeconds);
326
205
  }
327
206
  catch (error) {
328
- // Log error but continue monitoring
329
207
  logger.debug(`Dashboard refresh error: ${error instanceof Error ? error.message : String(error)}`);
330
208
  }
331
- }, intervalMs);
332
- // Return a Promise that resolves on SIGINT
333
- return new Promise((resolve) => {
334
- const cleanup = () => {
335
- clearInterval(refreshTimer);
336
- console.log('');
337
- logger.info('Monitor stopped');
338
- resolve();
339
- };
340
- process.on('SIGINT', cleanup);
341
- process.on('SIGTERM', cleanup);
342
- });
209
+ }
343
210
  }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Headless Autonomous Spec Generation
3
+ * Drives InterviewOrchestrator without the Ink TUI for non-interactive use.
4
+ * Used by AI agents (e.g. OpenClaw orchestrator) to generate feature specs.
5
+ */
6
+ import type { AIProvider } from '../ai/providers.js';
7
+ export interface NewAutoOptions {
8
+ goals?: string;
9
+ initialReferences?: string[];
10
+ model?: string;
11
+ provider?: AIProvider;
12
+ /** Timeout in ms for spec generation (default: 5 minutes) */
13
+ timeoutMs?: number;
14
+ }
15
+ export declare function newAutoCommand(featureName: string, options?: NewAutoOptions): Promise<void>;
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Headless Autonomous Spec Generation
3
+ * Drives InterviewOrchestrator without the Ink TUI for non-interactive use.
4
+ * Used by AI agents (e.g. OpenClaw orchestrator) to generate feature specs.
5
+ */
6
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { logger } from '../utils/logger.js';
9
+ import { getAvailableProvider, normalizeModelId, AVAILABLE_MODELS, } from '../ai/providers.js';
10
+ import { loadConfigWithDefaults, hasConfig } from '../utils/config.js';
11
+ import { loadContext, toScanResultFromPersisted } from '../context/index.js';
12
+ import { detectGitHubRemote, fetchGitHubIssue } from '../utils/github.js';
13
+ import { initTracing, flushTracing, traced, currentSpan } from '../utils/tracing.js';
14
+ import { InterviewOrchestrator, } from '../tui/orchestration/interview-orchestrator.js';
15
+ function createDeferred() {
16
+ let resolve;
17
+ let reject;
18
+ const promise = new Promise((res, rej) => {
19
+ resolve = res;
20
+ reject = rej;
21
+ });
22
+ return { promise, resolve, reject };
23
+ }
24
+ export async function newAutoCommand(featureName, options = {}) {
25
+ // Validate inputs
26
+ if (!featureName) {
27
+ console.error('Error: feature name is required for --auto mode');
28
+ process.exit(1);
29
+ }
30
+ // Detect provider
31
+ const provider = options.provider ?? getAvailableProvider();
32
+ if (!provider) {
33
+ console.error('Error: No AI provider available. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or OPENROUTER_API_KEY.');
34
+ process.exit(1);
35
+ }
36
+ // Load config
37
+ const projectRoot = process.cwd();
38
+ let specsDir = join(projectRoot, '.ralph/specs');
39
+ if (hasConfig(projectRoot)) {
40
+ const config = await loadConfigWithDefaults(projectRoot);
41
+ specsDir = join(projectRoot, config.paths.specs);
42
+ }
43
+ // Determine model — resolve aliases (e.g. 'sonnet' → full model ID)
44
+ const recommendedModel = AVAILABLE_MODELS[provider].find((m) => m.hint?.includes('recommended'));
45
+ const defaultModel = recommendedModel?.value ?? AVAILABLE_MODELS[provider][0].value;
46
+ const model = normalizeModelId(provider, options.model ?? defaultModel);
47
+ // Init tracing
48
+ try {
49
+ initTracing();
50
+ }
51
+ catch (err) {
52
+ logger.debug(`Failed to init tracing: ${err instanceof Error ? err.message : String(err)}`);
53
+ }
54
+ // Load project context (same logic as InterviewScreen.tsx)
55
+ let resolvedScanResult;
56
+ let resolvedSessionContext;
57
+ try {
58
+ const persisted = await loadContext(projectRoot);
59
+ if (persisted) {
60
+ resolvedSessionContext = {
61
+ entryPoints: persisted.aiAnalysis.projectContext?.entryPoints,
62
+ keyDirectories: persisted.aiAnalysis.projectContext?.keyDirectories,
63
+ commands: persisted.aiAnalysis.commands,
64
+ namingConventions: persisted.aiAnalysis.projectContext?.namingConventions,
65
+ implementationGuidelines: persisted.aiAnalysis.implementationGuidelines,
66
+ keyPatterns: persisted.aiAnalysis.technologyPractices?.practices,
67
+ };
68
+ resolvedScanResult = toScanResultFromPersisted(persisted.scanResult, projectRoot);
69
+ logger.info('Loaded cached project context from .ralph/.context.json');
70
+ }
71
+ }
72
+ catch (err) {
73
+ logger.debug(`Unable to load cached project context: ${err instanceof Error ? err.message : String(err)}`);
74
+ }
75
+ // Drive the orchestrator headlessly
76
+ const spec = await traced(async () => {
77
+ currentSpan().log({
78
+ input: {
79
+ featureName,
80
+ provider,
81
+ model,
82
+ goals: options.goals,
83
+ hasReferences: (options.initialReferences?.length ?? 0) > 0,
84
+ },
85
+ metadata: {
86
+ command: 'new-auto',
87
+ provider,
88
+ model,
89
+ },
90
+ tags: ['new-auto'],
91
+ });
92
+ return driveOrchestrator({
93
+ featureName,
94
+ projectRoot,
95
+ provider,
96
+ model,
97
+ scanResult: resolvedScanResult,
98
+ sessionContext: resolvedSessionContext,
99
+ goals: options.goals,
100
+ initialReferences: options.initialReferences,
101
+ timeoutMs: options.timeoutMs,
102
+ });
103
+ }, { name: 'new-auto-run' });
104
+ // Save spec to disk
105
+ if (!existsSync(specsDir)) {
106
+ mkdirSync(specsDir, { recursive: true });
107
+ }
108
+ const specPath = join(specsDir, `${featureName}.md`);
109
+ writeFileSync(specPath, spec, 'utf-8');
110
+ // Print spec path to stdout (for piping/scripting)
111
+ console.log(specPath);
112
+ // Flush tracing before exit
113
+ try {
114
+ await flushTracing();
115
+ }
116
+ catch {
117
+ // Non-critical
118
+ }
119
+ process.exit(0);
120
+ }
121
+ async function driveOrchestrator(opts) {
122
+ let readyDeferred = createDeferred();
123
+ const completionDeferred = createDeferred();
124
+ let toolIdCounter = 0;
125
+ const orchestrator = new InterviewOrchestrator({
126
+ featureName: opts.featureName,
127
+ projectRoot: opts.projectRoot,
128
+ provider: opts.provider,
129
+ model: opts.model,
130
+ scanResult: opts.scanResult,
131
+ sessionContext: opts.sessionContext,
132
+ onMessage: (role, content) => {
133
+ logger.info(`[${role}] ${content}`);
134
+ },
135
+ onStreamChunk: () => {
136
+ // No-op in headless mode
137
+ },
138
+ onStreamComplete: () => {
139
+ // No-op in headless mode
140
+ },
141
+ onToolStart: (toolName, _input) => {
142
+ const id = `tool_${++toolIdCounter}`;
143
+ logger.debug(`Tool start: ${toolName} (${id})`);
144
+ return id;
145
+ },
146
+ onToolEnd: (toolId, _output, error) => {
147
+ if (error) {
148
+ logger.debug(`Tool error: ${toolId}: ${error}`);
149
+ }
150
+ else {
151
+ logger.debug(`Tool end: ${toolId}`);
152
+ }
153
+ },
154
+ onPhaseChange: (phase) => {
155
+ logger.info(`Phase: ${phase}`);
156
+ },
157
+ onComplete: (spec) => {
158
+ completionDeferred.resolve(spec);
159
+ },
160
+ onError: (error) => {
161
+ completionDeferred.reject(new Error(error));
162
+ // Also unblock readyDeferred so the flow doesn't hang
163
+ readyDeferred.resolve();
164
+ },
165
+ onWorkingChange: (_isWorking, status) => {
166
+ logger.debug(status);
167
+ },
168
+ onReady: () => {
169
+ readyDeferred.resolve();
170
+ },
171
+ onQuestion: () => {
172
+ // Auto-mode: skip Q&A when a question arrives
173
+ // skipToGeneration is called below after we detect interview phase
174
+ },
175
+ });
176
+ // Step 1: Start orchestrator (enters context phase)
177
+ await orchestrator.start();
178
+ await readyDeferred.promise;
179
+ readyDeferred = createDeferred();
180
+ // Step 2: Process initial references
181
+ if (opts.initialReferences && opts.initialReferences.length > 0) {
182
+ for (const ref of opts.initialReferences) {
183
+ if (ref.startsWith('issue:')) {
184
+ const value = ref.slice(6);
185
+ if (/^\d+$/.test(value)) {
186
+ // Bare issue number — resolve from repo remote
187
+ const repo = await detectGitHubRemote(opts.projectRoot);
188
+ if (repo) {
189
+ const detail = await fetchGitHubIssue(repo.owner, repo.repo, parseInt(value, 10));
190
+ if (detail) {
191
+ const content = `# ${detail.title}\n\n${detail.body ?? ''}`;
192
+ orchestrator.addReferenceContent(content, `GitHub issue #${value}`);
193
+ logger.info(`Added: GitHub issue #${value} ${detail.title}`);
194
+ continue;
195
+ }
196
+ }
197
+ logger.warn(`Could not fetch issue #${value} — no GitHub remote detected or gh CLI unavailable`);
198
+ }
199
+ else {
200
+ // Full URL — use addReference which handles GitHub URLs
201
+ await orchestrator.addReference(value);
202
+ readyDeferred = createDeferred();
203
+ }
204
+ }
205
+ else {
206
+ await orchestrator.addReference(ref);
207
+ readyDeferred = createDeferred();
208
+ }
209
+ }
210
+ }
211
+ // Step 3: Advance to goals
212
+ await orchestrator.advanceToGoals();
213
+ await readyDeferred.promise;
214
+ readyDeferred = createDeferred();
215
+ // Step 4: Submit goals — this triggers codebase exploration + first question
216
+ // The onQuestion callback will fire, and we handle it after submitGoals returns
217
+ await orchestrator.submitGoals(opts.goals ?? '');
218
+ await readyDeferred.promise;
219
+ readyDeferred = createDeferred();
220
+ // Step 5: Skip to generation (auto-mode skips Q&A)
221
+ if (orchestrator.getPhase() === 'interview' ||
222
+ orchestrator.getPhase() === 'goals') {
223
+ await orchestrator.skipToGeneration();
224
+ }
225
+ // Wait for spec generation to complete (with timeout to prevent silent hangs)
226
+ const TIMEOUT_MS = opts.timeoutMs ?? 5 * 60 * 1000; // default: 5 minutes
227
+ let timeoutId;
228
+ const timeout = new Promise((_, reject) => {
229
+ timeoutId = setTimeout(() => reject(new Error('Spec generation timed out after 5 minutes')), TIMEOUT_MS);
230
+ });
231
+ try {
232
+ return await Promise.race([completionDeferred.promise, timeout]);
233
+ }
234
+ finally {
235
+ clearTimeout(timeoutId);
236
+ }
237
+ }
@@ -74,15 +74,24 @@ export async function runCommand(feature, options = {}) {
74
74
  }
75
75
  // Load config
76
76
  const config = await loadConfigWithDefaults(projectRoot);
77
- // Validate spec file exists
78
- const specFile = await validateSpecFile(projectRoot, feature);
79
- if (!specFile) {
80
- logger.error(`Spec file not found: ${feature}.md`);
81
- logger.info(`Create the spec first: wiggum new ${feature}`);
82
- logger.info(`Expected location: ${join(projectRoot, config.paths.specs, `${feature}.md`)}`);
83
- process.exit(1);
77
+ // Validate spec file exists (skip when resuming — spec lives on the feature branch)
78
+ let specFile = null;
79
+ if (options.resume) {
80
+ // Best-effort: find it if it's here, but don't fail
81
+ specFile = await validateSpecFile(projectRoot, feature);
82
+ }
83
+ else {
84
+ specFile = await validateSpecFile(projectRoot, feature);
85
+ if (!specFile) {
86
+ logger.error(`Spec file not found: ${feature}.md`);
87
+ logger.info(`Create the spec first: wiggum new ${feature}`);
88
+ logger.info(`Expected location: ${join(projectRoot, config.paths.specs, `${feature}.md`)}`);
89
+ process.exit(1);
90
+ }
91
+ }
92
+ if (specFile) {
93
+ logger.info(`Found spec: ${specFile}`);
84
94
  }
85
- logger.info(`Found spec: ${specFile}`);
86
95
  // Find the feature-loop.sh script
87
96
  const scriptPath = findFeatureLoopScript(projectRoot);
88
97
  if (!scriptPath) {
@@ -121,7 +130,7 @@ export async function runCommand(feature, options = {}) {
121
130
  // Display configuration
122
131
  console.log(pc.cyan('--- Run Configuration ---'));
123
132
  console.log(` Feature: ${pc.bold(feature)}`);
124
- console.log(` Spec: ${specFile}`);
133
+ console.log(` Spec: ${specFile ?? '(on feature branch)'}`);
125
134
  console.log(` Max Iterations: ${maxIterations}`);
126
135
  console.log(` Max E2E Attempts: ${maxE2eAttempts}`);
127
136
  console.log(` Model: ${options.model || config.loop.defaultModel}`);
@@ -167,7 +176,8 @@ export async function runCommand(feature, options = {}) {
167
176
  else {
168
177
  logger.error(`Feature loop exited with code: ${code}`);
169
178
  logger.info('Use --resume to continue from where you left off');
170
- reject(new Error(`Feature loop exited with code: ${code || 1}`));
179
+ process.exitCode = code || 1;
180
+ resolve();
171
181
  }
172
182
  });
173
183
  }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Headless Sync Command
3
+ * Runs project scan + AI enhancement and persists context to .ralph/.context.json
4
+ * CLI equivalent of the TUI /sync command, for non-interactive use.
5
+ */
6
+ /**
7
+ * Pure sync logic — scans, enhances, persists context.
8
+ * Returns the context file path on success. Throws on failure.
9
+ * Safe to call from tools/agents (no process.exit).
10
+ */
11
+ export declare function syncProjectContext(projectRoot: string): Promise<string>;
12
+ /**
13
+ * CLI entry point — wraps syncProjectContext with process.exit behavior.
14
+ */
15
+ export declare function syncCommand(): Promise<void>;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Headless Sync Command
3
+ * Runs project scan + AI enhancement and persists context to .ralph/.context.json
4
+ * CLI equivalent of the TUI /sync command, for non-interactive use.
5
+ */
6
+ import { join } from 'node:path';
7
+ import { logger } from '../utils/logger.js';
8
+ import { Scanner } from '../scanner/index.js';
9
+ import { AIEnhancer } from '../ai/enhancer.js';
10
+ import { saveContext, toPersistedScanResult, toPersistedAIAnalysis, getGitMetadata, } from '../context/index.js';
11
+ import { getAvailableProvider, AVAILABLE_MODELS, normalizeModelId, } from '../ai/providers.js';
12
+ /**
13
+ * Pure sync logic — scans, enhances, persists context.
14
+ * Returns the context file path on success. Throws on failure.
15
+ * Safe to call from tools/agents (no process.exit).
16
+ */
17
+ export async function syncProjectContext(projectRoot) {
18
+ // Detect provider
19
+ const provider = getAvailableProvider();
20
+ if (!provider) {
21
+ throw new Error('No AI provider available. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or OPENROUTER_API_KEY.');
22
+ }
23
+ // Resolve model
24
+ const recommendedModel = AVAILABLE_MODELS[provider].find((m) => m.hint?.includes('recommended'));
25
+ const defaultModel = recommendedModel?.value ?? AVAILABLE_MODELS[provider][0].value;
26
+ const model = normalizeModelId(provider, defaultModel);
27
+ logger.info('Scanning project...');
28
+ // Step 1: Scan
29
+ const scanner = new Scanner();
30
+ const scanResult = await scanner.scan(projectRoot);
31
+ logger.info('Running AI analysis...');
32
+ // Step 2: AI enhancement
33
+ const enhancer = new AIEnhancer({
34
+ provider,
35
+ model,
36
+ agentic: true,
37
+ });
38
+ const enhanced = await enhancer.enhance(scanResult);
39
+ if (enhanced.aiError) {
40
+ throw new Error(`AI analysis failed: ${enhanced.aiError}`);
41
+ }
42
+ // Step 3: Persist
43
+ const git = await getGitMetadata(projectRoot);
44
+ await saveContext({
45
+ lastAnalyzedAt: new Date().toISOString(),
46
+ gitCommitHash: git.gitCommitHash,
47
+ gitBranch: git.gitBranch,
48
+ scanResult: toPersistedScanResult(enhanced),
49
+ aiAnalysis: toPersistedAIAnalysis(enhanced.aiAnalysis),
50
+ }, projectRoot);
51
+ return join(projectRoot, '.ralph', '.context.json');
52
+ }
53
+ /**
54
+ * CLI entry point — wraps syncProjectContext with process.exit behavior.
55
+ */
56
+ export async function syncCommand() {
57
+ let contextPath;
58
+ try {
59
+ contextPath = await syncProjectContext(process.cwd());
60
+ }
61
+ catch (err) {
62
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
63
+ process.exit(1);
64
+ return; // unreachable, but satisfies TS control flow
65
+ }
66
+ console.log(contextPath);
67
+ process.exit(0);
68
+ }