wiggum-cli 0.16.0 → 0.17.1

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 (101) hide show
  1. package/bin/ralph.js +0 -0
  2. package/dist/agent/memory/ingest.d.ts +14 -0
  3. package/dist/agent/memory/ingest.js +77 -0
  4. package/dist/agent/memory/store.d.ts +15 -0
  5. package/dist/agent/memory/store.js +98 -0
  6. package/dist/agent/memory/types.d.ts +16 -0
  7. package/dist/agent/memory/types.js +14 -0
  8. package/dist/agent/orchestrator.d.ts +7 -0
  9. package/dist/agent/orchestrator.js +266 -0
  10. package/dist/agent/resolve-config.d.ts +26 -0
  11. package/dist/agent/resolve-config.js +43 -0
  12. package/dist/agent/tools/backlog.d.ts +27 -0
  13. package/dist/agent/tools/backlog.js +51 -0
  14. package/dist/agent/tools/dry-run.d.ts +106 -0
  15. package/dist/agent/tools/dry-run.js +119 -0
  16. package/dist/agent/tools/execution.d.ts +51 -0
  17. package/dist/agent/tools/execution.js +256 -0
  18. package/dist/agent/tools/feature-state.d.ts +43 -0
  19. package/dist/agent/tools/feature-state.js +184 -0
  20. package/dist/agent/tools/introspection.d.ts +23 -0
  21. package/dist/agent/tools/introspection.js +40 -0
  22. package/dist/agent/tools/memory.d.ts +44 -0
  23. package/dist/agent/tools/memory.js +99 -0
  24. package/dist/agent/tools/preflight.d.ts +7 -0
  25. package/dist/agent/tools/preflight.js +137 -0
  26. package/dist/agent/tools/reporting.d.ts +58 -0
  27. package/dist/agent/tools/reporting.js +119 -0
  28. package/dist/agent/tools/schemas.d.ts +2 -0
  29. package/dist/agent/tools/schemas.js +3 -0
  30. package/dist/agent/types.d.ts +45 -0
  31. package/dist/agent/types.js +1 -0
  32. package/dist/ai/conversation/conversation-manager.js +8 -0
  33. package/dist/ai/conversation/url-fetcher.js +27 -0
  34. package/dist/ai/providers.js +5 -5
  35. package/dist/commands/agent.d.ts +17 -0
  36. package/dist/commands/agent.js +114 -0
  37. package/dist/commands/monitor.js +50 -183
  38. package/dist/commands/new-auto.d.ts +15 -0
  39. package/dist/commands/new-auto.js +237 -0
  40. package/dist/commands/run.js +20 -10
  41. package/dist/commands/sync.d.ts +15 -0
  42. package/dist/commands/sync.js +68 -0
  43. package/dist/generator/config.d.ts +1 -41
  44. package/dist/generator/config.js +7 -0
  45. package/dist/generator/index.d.ts +2 -2
  46. package/dist/generator/templates.d.ts +2 -0
  47. package/dist/generator/templates.js +9 -1
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.js +115 -4
  50. package/dist/repl/command-parser.d.ts +10 -0
  51. package/dist/repl/command-parser.js +10 -0
  52. package/dist/templates/prompts/PROMPT.md.tmpl +13 -10
  53. package/dist/templates/prompts/PROMPT_e2e.md.tmpl +13 -7
  54. package/dist/templates/prompts/PROMPT_feature.md.tmpl +16 -3
  55. package/dist/templates/prompts/PROMPT_review_auto.md.tmpl +32 -12
  56. package/dist/templates/prompts/PROMPT_review_manual.md.tmpl +4 -1
  57. package/dist/templates/prompts/PROMPT_review_merge.md.tmpl +39 -14
  58. package/dist/templates/prompts/PROMPT_verify.md.tmpl +5 -2
  59. package/dist/templates/scripts/feature-loop.sh.tmpl +441 -69
  60. package/dist/tui/app.d.ts +19 -2
  61. package/dist/tui/app.js +23 -4
  62. package/dist/tui/components/IssuePicker.d.ts +27 -0
  63. package/dist/tui/components/IssuePicker.js +73 -0
  64. package/dist/tui/components/RunCompletionSummary.js +6 -3
  65. package/dist/tui/components/StatusLine.d.ts +3 -1
  66. package/dist/tui/components/StatusLine.js +2 -2
  67. package/dist/tui/hooks/useAgentOrchestrator.d.ts +40 -0
  68. package/dist/tui/hooks/useAgentOrchestrator.js +491 -0
  69. package/dist/tui/orchestration/interview-orchestrator.d.ts +5 -1
  70. package/dist/tui/orchestration/interview-orchestrator.js +27 -6
  71. package/dist/tui/screens/AgentScreen.d.ts +24 -0
  72. package/dist/tui/screens/AgentScreen.js +209 -0
  73. package/dist/tui/screens/InitScreen.js +4 -0
  74. package/dist/tui/screens/InterviewScreen.d.ts +3 -1
  75. package/dist/tui/screens/InterviewScreen.js +146 -10
  76. package/dist/tui/screens/MainShell.d.ts +1 -1
  77. package/dist/tui/screens/MainShell.js +115 -4
  78. package/dist/tui/screens/RunScreen.js +72 -16
  79. package/dist/tui/utils/build-run-summary.d.ts +1 -1
  80. package/dist/tui/utils/build-run-summary.js +40 -84
  81. package/dist/tui/utils/clear-screen.d.ts +14 -0
  82. package/dist/tui/utils/clear-screen.js +16 -0
  83. package/dist/tui/utils/git-summary.d.ts +1 -0
  84. package/dist/tui/utils/git-summary.js +16 -0
  85. package/dist/tui/utils/loop-status.d.ts +41 -1
  86. package/dist/tui/utils/loop-status.js +243 -35
  87. package/dist/tui/utils/pr-summary.d.ts +3 -2
  88. package/dist/tui/utils/pr-summary.js +41 -6
  89. package/dist/utils/config.d.ts +8 -0
  90. package/dist/utils/config.js +8 -0
  91. package/dist/utils/github.d.ts +32 -0
  92. package/dist/utils/github.js +106 -0
  93. package/package.json +4 -1
  94. package/src/templates/prompts/PROMPT.md.tmpl +13 -10
  95. package/src/templates/prompts/PROMPT_e2e.md.tmpl +13 -7
  96. package/src/templates/prompts/PROMPT_feature.md.tmpl +16 -3
  97. package/src/templates/prompts/PROMPT_review_auto.md.tmpl +32 -12
  98. package/src/templates/prompts/PROMPT_review_manual.md.tmpl +4 -1
  99. package/src/templates/prompts/PROMPT_review_merge.md.tmpl +39 -14
  100. package/src/templates/prompts/PROMPT_verify.md.tmpl +5 -2
  101. package/src/templates/scripts/feature-loop.sh.tmpl +441 -69
@@ -69,6 +69,22 @@ export function getDiffStats(projectRoot, fromHash, toHash) {
69
69
  * @param toHash - Ending commit hash (inclusive)
70
70
  * @returns Array of commit entries, or null if not available
71
71
  */
72
+ export function getRecentCommits(projectRoot, count = 3) {
73
+ try {
74
+ const output = execFileSync('git', ['log', '--oneline', `-${count}`], { cwd: projectRoot, encoding: 'utf-8', timeout: 10_000 }).trim();
75
+ if (!output)
76
+ return [];
77
+ return output.split('\n').map((line) => {
78
+ const spaceIdx = line.indexOf(' ');
79
+ if (spaceIdx === -1)
80
+ return { hash: line, title: '' };
81
+ return { hash: line.substring(0, spaceIdx), title: line.substring(spaceIdx + 1) };
82
+ });
83
+ }
84
+ catch {
85
+ return [];
86
+ }
87
+ }
72
88
  export function getCommitList(projectRoot, fromHash, toHash) {
73
89
  try {
74
90
  const output = execFileSync('git', ['log', '--oneline', `${fromHash}..${toHash}`], {
@@ -1,6 +1,13 @@
1
1
  /**
2
2
  * Loop status helpers for the TUI run/monitor screens.
3
3
  */
4
+ /**
5
+ * Find the implementation plan file, checking main project and git worktrees.
6
+ *
7
+ * Shared between loop-status.ts and monitor.ts to avoid duplicating the
8
+ * worktree search logic.
9
+ */
10
+ export declare function findImplementationPlan(projectRoot: string, specsRelPath: string, feature: string): string | null;
4
11
  export interface LoopStatus {
5
12
  running: boolean;
6
13
  phase: string;
@@ -10,12 +17,14 @@ export interface LoopStatus {
10
17
  tokensOutput: number;
11
18
  cacheCreate: number;
12
19
  cacheRead: number;
20
+ tokensUpdatedAt?: number;
13
21
  }
14
22
  export interface TaskCounts {
15
23
  tasksDone: number;
16
24
  tasksPending: number;
17
25
  e2eDone: number;
18
26
  e2ePending: number;
27
+ planExists: boolean;
19
28
  }
20
29
  /**
21
30
  * Phase execution status and timing.
@@ -27,12 +36,17 @@ export interface PhaseInfo {
27
36
  /** Human-readable phase label */
28
37
  label: string;
29
38
  /** Phase completion status */
30
- status: 'success' | 'skipped' | 'failed';
39
+ status: 'success' | 'skipped' | 'failed' | 'started';
31
40
  /** Duration in milliseconds, if available */
32
41
  durationMs?: number;
33
42
  /** Number of iterations in this phase (e.g., for implementation) */
34
43
  iterations?: number;
35
44
  }
45
+ /**
46
+ * Phase ID to human-readable label mapping.
47
+ * Matches the phase IDs written by feature-loop.sh.
48
+ */
49
+ export declare const PHASE_LABELS: Record<string, string>;
36
50
  /**
37
51
  * Return the conventional log file path for a feature loop.
38
52
  */
@@ -45,6 +59,14 @@ export declare function getLoopLogPath(feature: string): string;
45
59
  * concurrent loops are rare, but callers should be aware of the limitation.
46
60
  */
47
61
  export declare function detectPhase(feature: string): string;
62
+ /**
63
+ * Read the current phase from the `.phases` file written by feature-loop.sh.
64
+ * Returns the human-readable label of the active phase, or null if unavailable.
65
+ *
66
+ * The file format is: phase_id|status|start_timestamp|end_timestamp
67
+ * A line with status "started" and no end timestamp indicates the active phase.
68
+ */
69
+ export declare function readCurrentPhase(feature: string): string | null;
48
70
  /**
49
71
  * Read loop status from temp files written by feature-loop.sh.
50
72
  *
@@ -87,6 +109,10 @@ export interface ActivityEvent {
87
109
  /** Inferred status based on event content */
88
110
  status: 'success' | 'error' | 'in-progress';
89
111
  }
112
+ /**
113
+ * Returns true if a log line should be excluded from the activity feed.
114
+ */
115
+ export declare function shouldSkipLine(line: string): boolean;
90
116
  /**
91
117
  * Parse the loop log file into structured activity events.
92
118
  *
@@ -108,3 +134,17 @@ export declare function parsePhaseChanges(feature: string, lastKnownPhases?: Pha
108
134
  events: ActivityEvent[];
109
135
  currentPhases?: PhaseInfo[];
110
136
  };
137
+ /**
138
+ * Parse phase information from the phases file written by feature-loop.sh.
139
+ *
140
+ * Format: phase_id|status|start_timestamp|end_timestamp
141
+ * Accepts both 3-field lines (started: phase_id|started|timestamp) and
142
+ * 4-field lines (completed: phase_id|status|start_ts|end_ts).
143
+ *
144
+ * The parser handles duplicate phase entries defensively (last status wins,
145
+ * durations aggregate), though feature-loop.sh normally writes one final
146
+ * line per phase.
147
+ *
148
+ * Used by build-run-summary.ts for completion summaries.
149
+ */
150
+ export declare function parsePhases(phasesFilePath: string): PhaseInfo[];
@@ -6,6 +6,51 @@ import { existsSync, readFileSync, statSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
7
  import { loadConfigWithDefaults } from '../../utils/config.js';
8
8
  import { logger } from '../../utils/logger.js';
9
+ /**
10
+ * Find the implementation plan file, checking main project and git worktrees.
11
+ *
12
+ * Shared between loop-status.ts and monitor.ts to avoid duplicating the
13
+ * worktree search logic.
14
+ */
15
+ export function findImplementationPlan(projectRoot, specsRelPath, feature) {
16
+ // 1. Check main project
17
+ const mainPath = join(projectRoot, specsRelPath, `${feature}-implementation-plan.md`);
18
+ if (existsSync(mainPath))
19
+ return mainPath;
20
+ // 2. Check git worktrees for the feature branch
21
+ try {
22
+ const output = execFileSync('git', ['worktree', 'list', '--porcelain'], {
23
+ cwd: projectRoot,
24
+ encoding: 'utf-8',
25
+ });
26
+ const worktrees = output.split('\n\n').filter(Boolean);
27
+ for (const wt of worktrees) {
28
+ const pathMatch = wt.match(/^worktree (.+)$/m);
29
+ const escapedFeature = feature.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
30
+ const branchMatch = wt.match(new RegExp(`^branch .+/feat/${escapedFeature}$`, 'm'));
31
+ if (pathMatch && branchMatch) {
32
+ const wtPlan = join(pathMatch[1], specsRelPath, `${feature}-implementation-plan.md`);
33
+ if (existsSync(wtPlan))
34
+ return wtPlan;
35
+ }
36
+ }
37
+ }
38
+ catch {
39
+ // git worktree list failed — ignore
40
+ }
41
+ return null;
42
+ }
43
+ /**
44
+ * Phase ID to human-readable label mapping.
45
+ * Matches the phase IDs written by feature-loop.sh.
46
+ */
47
+ export const PHASE_LABELS = {
48
+ planning: 'Planning',
49
+ implementation: 'Implementation',
50
+ e2e_testing: 'E2E Testing',
51
+ verification: 'Verification',
52
+ pr_review: 'PR & Review',
53
+ };
9
54
  /**
10
55
  * Track whether pgrep is available to avoid repeated failed calls.
11
56
  * null = untested, true = available, false = unavailable
@@ -66,6 +111,48 @@ export function detectPhase(feature) {
66
111
  return 'Running';
67
112
  return 'Idle';
68
113
  }
114
+ /**
115
+ * Read the current phase from the `.phases` file written by feature-loop.sh.
116
+ * Returns the human-readable label of the active phase, or null if unavailable.
117
+ *
118
+ * The file format is: phase_id|status|start_timestamp|end_timestamp
119
+ * A line with status "started" and no end timestamp indicates the active phase.
120
+ */
121
+ export function readCurrentPhase(feature) {
122
+ const phasesFile = `/tmp/ralph-loop-${feature}.phases`;
123
+ let content;
124
+ try {
125
+ content = readFileSync(phasesFile, 'utf-8').trim();
126
+ }
127
+ catch {
128
+ return null;
129
+ }
130
+ if (!content)
131
+ return null;
132
+ const lines = content.split('\n');
133
+ let lastStartedPhase = null;
134
+ let lastCompletedPhase = null;
135
+ for (const line of lines) {
136
+ const parts = line.split('|');
137
+ if (parts.length < 2)
138
+ continue;
139
+ const [id, status] = parts;
140
+ if (status === 'started') {
141
+ lastStartedPhase = id;
142
+ }
143
+ else if (status === 'success' || status === 'failed' || status === 'skipped') {
144
+ lastCompletedPhase = id;
145
+ }
146
+ }
147
+ if (lastStartedPhase) {
148
+ return PHASE_LABELS[lastStartedPhase] || lastStartedPhase;
149
+ }
150
+ if (lastCompletedPhase) {
151
+ const label = PHASE_LABELS[lastCompletedPhase] || lastCompletedPhase;
152
+ return `post-${label}`;
153
+ }
154
+ return null;
155
+ }
69
156
  /**
70
157
  * Read loop status from temp files written by feature-loop.sh.
71
158
  *
@@ -89,8 +176,14 @@ export function readLoopStatus(feature) {
89
176
  try {
90
177
  const content = readFileSync(fileToRead, 'utf-8').trim();
91
178
  const parts = content.split('|');
92
- iteration = parseInt(parts[0] || '0', 10) || 0;
93
- maxIterations = parseInt(parts[1] || '0', 10) || 0;
179
+ // Require at least 2 fields (iteration|maxIterations); skip partial writes
180
+ if (parts.length >= 2) {
181
+ iteration = parseInt(parts[0] || '0', 10) || 0;
182
+ maxIterations = parseInt(parts[1] || '0', 10) || 0;
183
+ }
184
+ else {
185
+ logger.debug(`Status file has fewer than 2 fields, using defaults: ${content}`);
186
+ }
94
187
  }
95
188
  catch (err) {
96
189
  logger.debug(`Failed to parse status file: ${err instanceof Error ? err.message : String(err)}`);
@@ -100,28 +193,48 @@ export function readLoopStatus(feature) {
100
193
  let tokensOutput = 0;
101
194
  let cacheCreate = 0;
102
195
  let cacheRead = 0;
196
+ let tokensUpdatedAt;
103
197
  if (existsSync(tokensFile)) {
104
198
  try {
105
199
  const content = readFileSync(tokensFile, 'utf-8').trim();
106
200
  const parts = content.split('|');
107
- tokensInput = parseInt(parts[0] || '0', 10) || 0;
108
- tokensOutput = parseInt(parts[1] || '0', 10) || 0;
109
- cacheCreate = parseInt(parts[2] || '0', 10) || 0;
110
- cacheRead = parseInt(parts[3] || '0', 10) || 0;
201
+ // Require at least 2 fields (input|output); cache fields are optional (legacy format)
202
+ if (parts.length >= 2) {
203
+ tokensInput = parseInt(parts[0] || '0', 10) || 0;
204
+ tokensOutput = parseInt(parts[1] || '0', 10) || 0;
205
+ cacheCreate = parts.length >= 3 ? (parseInt(parts[2] || '0', 10) || 0) : 0;
206
+ cacheRead = parts.length >= 4 ? (parseInt(parts[3] || '0', 10) || 0) : 0;
207
+ if (parts[4]) {
208
+ const epoch = parseInt(parts[4], 10);
209
+ if (epoch > 0)
210
+ tokensUpdatedAt = epoch * 1000; // convert to ms
211
+ }
212
+ }
213
+ else {
214
+ logger.debug(`Tokens file has fewer than 2 fields, using defaults: ${content}`);
215
+ }
111
216
  }
112
217
  catch (err) {
113
218
  logger.debug(`Failed to parse tokens file: ${err instanceof Error ? err.message : String(err)}`);
114
219
  }
115
220
  }
221
+ const running = isProcessRunning(`feature-loop.sh.*${feature}`);
222
+ // Prefer .phases file (written by feature-loop.sh on transitions) over process
223
+ // detection. pgrep-based detection can return stale results when old Claude
224
+ // sessions linger, causing the phase to appear stuck (e.g., "Planning" during
225
+ // Verification). The .phases file is the authoritative source of phase transitions.
226
+ const phaseFromFile = readCurrentPhase(feature);
227
+ let phase = phaseFromFile ?? detectPhase(feature);
116
228
  return {
117
- running: isProcessRunning(`feature-loop.sh.*${feature}`),
118
- phase: detectPhase(feature),
229
+ running,
230
+ phase,
119
231
  iteration,
120
232
  maxIterations,
121
233
  tokensInput,
122
234
  tokensOutput,
123
235
  cacheCreate,
124
236
  cacheRead,
237
+ tokensUpdatedAt,
125
238
  };
126
239
  }
127
240
  /**
@@ -143,12 +256,13 @@ export async function parseImplementationPlan(projectRoot, feature, specsDirOver
143
256
  }
144
257
  }
145
258
  const specsDir = specsDirOverride || config?.paths.specs || '.ralph/specs';
146
- const planPath = join(projectRoot, specsDir, `${feature}-implementation-plan.md`);
259
+ const planPath = findImplementationPlan(projectRoot, specsDir, feature);
147
260
  let tasksDone = 0;
148
261
  let tasksPending = 0;
149
262
  let e2eDone = 0;
150
263
  let e2ePending = 0;
151
- if (existsSync(planPath)) {
264
+ const planExists = planPath !== null;
265
+ if (planPath) {
152
266
  try {
153
267
  const content = readFileSync(planPath, 'utf-8');
154
268
  const lines = content.split('\n');
@@ -171,12 +285,36 @@ export async function parseImplementationPlan(projectRoot, feature, specsDirOver
171
285
  }
172
286
  }
173
287
  }
288
+ // Fallback: if no checkboxes found, count "#### Task N:" headers
289
+ if (tasksDone === 0 && tasksPending === 0 && e2eDone === 0 && e2ePending === 0) {
290
+ let headerTaskCount = 0;
291
+ for (const line of lines) {
292
+ if (line.match(/^#{1,4}\s+Task\s+\d+/i)) {
293
+ headerTaskCount++;
294
+ }
295
+ }
296
+ if (headerTaskCount > 0) {
297
+ // Check phases file to determine completion
298
+ const phasesPath = `/tmp/ralph-loop-${feature}.phases`;
299
+ let implDone = false;
300
+ if (existsSync(phasesPath)) {
301
+ const phases = readFileSync(phasesPath, 'utf-8');
302
+ implDone = phases.includes('implementation|success');
303
+ }
304
+ if (implDone) {
305
+ tasksDone = headerTaskCount;
306
+ }
307
+ else {
308
+ tasksPending = headerTaskCount;
309
+ }
310
+ }
311
+ }
174
312
  }
175
313
  catch (err) {
176
314
  logger.debug(`Failed to parse implementation plan: ${err instanceof Error ? err.message : String(err)}`);
177
315
  }
178
316
  }
179
- return { tasksDone, tasksPending, e2eDone, e2ePending };
317
+ return { tasksDone, tasksPending, e2eDone, e2ePending, planExists };
180
318
  }
181
319
  /**
182
320
  * Get current git branch.
@@ -259,14 +397,14 @@ const SKIP_LINE_PATTERNS = [
259
397
  /^Baseline commit:/,
260
398
  /^Creating branch:/,
261
399
  // Misc noise
262
- /^\{"level"/, // JSON log lines (BashTool warnings etc.)
400
+ /^\{"/, // Any line starting with JSON object (Claude result, log entries)
263
401
  /^Pending implementation tasks: \d+$/, // raw task count (redundant with progress bar)
264
402
  /^Ready for feedback\.?$/i, // Conversational filler
265
403
  ];
266
404
  /**
267
405
  * Returns true if a log line should be excluded from the activity feed.
268
406
  */
269
- function shouldSkipLine(line) {
407
+ export function shouldSkipLine(line) {
270
408
  return SKIP_LINE_PATTERNS.some((pattern) => pattern.test(line));
271
409
  }
272
410
  /**
@@ -279,13 +417,18 @@ function stripMarkdown(msg) {
279
417
  .replace(/^\s*[-*]\s+/, '') // leading bullet points
280
418
  .trim();
281
419
  }
282
- const SUCCESS_KEYWORDS = /completed|passed|success|approved|all implementation tasks completed/i;
283
- const ERROR_KEYWORDS = /error|failed|failure/i;
420
+ const SUCCESS_KEYWORDS = /\b(completed|passed|success|approved|fixed|resolved|merged|works)\b/i;
421
+ const ERROR_KEYWORDS = /\b(error|failed|failure)\b/i;
422
+ const POSITIVE_ERROR_CONTEXT = /\b(fixed|resolved|added|handled|handling|recovery|boundary|boundaries|tests?\s+passed)\b/i;
284
423
  function inferStatus(message) {
285
424
  if (SUCCESS_KEYWORDS.test(message))
286
425
  return 'success';
287
- if (ERROR_KEYWORDS.test(message))
426
+ if (ERROR_KEYWORDS.test(message)) {
427
+ // Avoid misclassifying positive actions that mention error-related words
428
+ if (POSITIVE_ERROR_CONTEXT.test(message))
429
+ return 'in-progress';
288
430
  return 'error';
431
+ }
289
432
  return 'in-progress';
290
433
  }
291
434
  /**
@@ -366,28 +509,26 @@ export function parsePhaseChanges(feature, lastKnownPhases) {
366
509
  }
367
510
  return { events: [] };
368
511
  }
369
- let parsed;
370
- try {
371
- parsed = JSON.parse(rawContent);
372
- if (!Array.isArray(parsed)) {
373
- logger.debug(`parsePhaseChanges: expected array in ${phasesFile}, got ${typeof parsed}`);
374
- return { events: [], currentPhases: lastKnownPhases };
375
- }
376
- }
377
- catch (err) {
378
- logger.debug(`parsePhaseChanges: invalid JSON in ${phasesFile}: ${err instanceof Error ? err.message : String(err)}`);
512
+ const content = rawContent.trim();
513
+ if (!content) {
379
514
  return { events: [], currentPhases: lastKnownPhases };
380
515
  }
381
- // Validate each phase has required fields
382
- const currentPhases = [];
383
- for (const item of parsed) {
384
- if (typeof item === 'object' && item !== null &&
385
- 'id' in item && typeof item.id === 'string' &&
386
- 'label' in item && typeof item.label === 'string' &&
387
- 'status' in item && typeof item.status === 'string') {
388
- currentPhases.push(item);
389
- }
516
+ // Parse pipe-delimited format: phase_id|status|start_timestamp|end_timestamp
517
+ const VALID_STATUSES = new Set(['success', 'skipped', 'failed', 'started']);
518
+ const phaseMap = new Map();
519
+ for (const line of content.split('\n')) {
520
+ const parts = line.split('|');
521
+ if (parts.length < 2)
522
+ continue;
523
+ const [id, status] = parts;
524
+ if (!id || !VALID_STATUSES.has(status))
525
+ continue;
526
+ const label = PHASE_LABELS[id] || id;
527
+ const validStatus = status;
528
+ // Last status wins for each phase id
529
+ phaseMap.set(id, { id, label, status: validStatus });
390
530
  }
531
+ const currentPhases = Array.from(phaseMap.values());
391
532
  const events = [];
392
533
  const now = Date.now();
393
534
  for (const current of currentPhases) {
@@ -411,3 +552,70 @@ export function parsePhaseChanges(feature, lastKnownPhases) {
411
552
  }
412
553
  return { events, currentPhases };
413
554
  }
555
+ /**
556
+ * Parse phase information from the phases file written by feature-loop.sh.
557
+ *
558
+ * Format: phase_id|status|start_timestamp|end_timestamp
559
+ * Accepts both 3-field lines (started: phase_id|started|timestamp) and
560
+ * 4-field lines (completed: phase_id|status|start_ts|end_ts).
561
+ *
562
+ * The parser handles duplicate phase entries defensively (last status wins,
563
+ * durations aggregate), though feature-loop.sh normally writes one final
564
+ * line per phase.
565
+ *
566
+ * Used by build-run-summary.ts for completion summaries.
567
+ */
568
+ export function parsePhases(phasesFilePath) {
569
+ if (!existsSync(phasesFilePath)) {
570
+ return [];
571
+ }
572
+ try {
573
+ const content = readFileSync(phasesFilePath, 'utf-8').trim();
574
+ if (!content) {
575
+ return [];
576
+ }
577
+ const lines = content.split('\n');
578
+ const VALID_STATUSES = new Set(['success', 'skipped', 'failed', 'started']);
579
+ const phaseMap = new Map();
580
+ for (const line of lines) {
581
+ const parts = line.split('|');
582
+ if (parts.length < 2)
583
+ continue;
584
+ const [id, status] = parts;
585
+ if (!id || !VALID_STATUSES.has(status)) {
586
+ logger.warn(`Unknown phase status "${status}" for phase "${id}", treating as failed`);
587
+ // Still process with 'failed' status
588
+ }
589
+ const validatedStatus = VALID_STATUSES.has(status)
590
+ ? status
591
+ : 'failed';
592
+ // Parse timestamps (may be absent for 3-field 'started' lines)
593
+ const startTime = parts.length >= 3 ? (parseInt(parts[2], 10) || 0) : 0;
594
+ const endTime = parts.length >= 4 ? (parseInt(parts[3], 10) || 0) : 0;
595
+ // Calculate duration (end - start) in milliseconds
596
+ const durationMs = endTime > 0 && startTime > 0 ? (endTime - startTime) * 1000 : undefined;
597
+ // Get or create phase entry
598
+ let phase = phaseMap.get(id);
599
+ if (!phase) {
600
+ phase = {
601
+ id,
602
+ label: PHASE_LABELS[id] || id,
603
+ status: validatedStatus,
604
+ durationMs: 0,
605
+ };
606
+ phaseMap.set(id, phase);
607
+ }
608
+ // Update status (last status wins)
609
+ phase.status = validatedStatus;
610
+ // Aggregate duration
611
+ if (durationMs !== undefined) {
612
+ phase.durationMs = (phase.durationMs || 0) + durationMs;
613
+ }
614
+ }
615
+ return Array.from(phaseMap.values());
616
+ }
617
+ catch (err) {
618
+ logger.warn(`Failed to parse phases file: ${err instanceof Error ? err.message : String(err)}`);
619
+ return [];
620
+ }
621
+ }
@@ -24,11 +24,12 @@ export interface IssueInfo {
24
24
  export declare function getPrForBranch(projectRoot: string, branchName: string): PrInfo | null;
25
25
  /**
26
26
  * Get linked issue for a branch by parsing the PR body for closing keywords
27
- * (Closes/Fixes/Resolves #N).
27
+ * (Closes/Fixes/Resolves #N), with fallbacks for spec file and feature name search.
28
28
  *
29
29
  * @param projectRoot - Root directory of the git repository
30
30
  * @param branchName - Branch name to look up
31
31
  * @param prInfo - Optional pre-fetched PR info to avoid redundant gh call
32
+ * @param featureName - Optional feature name for fallback issue detection
32
33
  * @returns Issue info object, or null if not found or gh not available
33
34
  */
34
- export declare function getLinkedIssue(projectRoot: string, branchName: string, prInfo?: PrInfo | null): IssueInfo | null;
35
+ export declare function getLinkedIssue(projectRoot: string, branchName: string, prInfo?: PrInfo | null, featureName?: string): IssueInfo | null;
@@ -2,6 +2,8 @@
2
2
  * PR and Issue utilities for enhanced run summary.
3
3
  */
4
4
  import { execFileSync } from 'node:child_process';
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
5
7
  import { logger } from '../../utils/logger.js';
6
8
  /**
7
9
  * Get PR information for a branch.
@@ -33,16 +35,51 @@ export function getPrForBranch(projectRoot, branchName) {
33
35
  title: pr.title,
34
36
  };
35
37
  }
38
+ /**
39
+ * Extract issue number from PR body closing keywords or fallback strategies.
40
+ */
41
+ function findIssueNumber(body, featureName, projectRoot) {
42
+ // Strategy 1: Closing keywords in PR body (Closes/Fixes/Resolves #N)
43
+ const issueMatch = body.match(/(?:closes|fixes|resolves)\s+#(\d+)/i);
44
+ if (issueMatch) {
45
+ return parseInt(issueMatch[1], 10);
46
+ }
47
+ // Strategy 2: Bare "#N" references in the body (e.g., "Closes #7" without keyword before it)
48
+ // Look for "# N" patterns that are likely issue references (not markdown headers)
49
+ const hashRefMatch = body.match(/(?:^|\s)#(\d+)\b/m);
50
+ if (hashRefMatch) {
51
+ return parseInt(hashRefMatch[1], 10);
52
+ }
53
+ // Strategy 3: Read spec file for issue reference
54
+ if (featureName && projectRoot) {
55
+ try {
56
+ const specPath = join(projectRoot, '.ralph', 'specs', `${featureName}.md`);
57
+ if (existsSync(specPath)) {
58
+ const specContent = readFileSync(specPath, 'utf-8');
59
+ // Look for "Issue: #N", "Source Issue: #N", "GitHub Issue: #N", or issue URLs
60
+ const specIssueMatch = specContent.match(/(?:issue|source|github)[:\s]*#(\d+)/i) || specContent.match(/github\.com\/[^/]+\/[^/]+\/issues\/(\d+)/i);
61
+ if (specIssueMatch) {
62
+ return parseInt(specIssueMatch[1], 10);
63
+ }
64
+ }
65
+ }
66
+ catch {
67
+ // spec read failure is non-fatal
68
+ }
69
+ }
70
+ return null;
71
+ }
36
72
  /**
37
73
  * Get linked issue for a branch by parsing the PR body for closing keywords
38
- * (Closes/Fixes/Resolves #N).
74
+ * (Closes/Fixes/Resolves #N), with fallbacks for spec file and feature name search.
39
75
  *
40
76
  * @param projectRoot - Root directory of the git repository
41
77
  * @param branchName - Branch name to look up
42
78
  * @param prInfo - Optional pre-fetched PR info to avoid redundant gh call
79
+ * @param featureName - Optional feature name for fallback issue detection
43
80
  * @returns Issue info object, or null if not found or gh not available
44
81
  */
45
- export function getLinkedIssue(projectRoot, branchName, prInfo) {
82
+ export function getLinkedIssue(projectRoot, branchName, prInfo, featureName) {
46
83
  try {
47
84
  // Use provided PR info or fetch it
48
85
  const pr = prInfo !== undefined ? prInfo : getPrForBranch(projectRoot, branchName);
@@ -57,12 +94,10 @@ export function getLinkedIssue(projectRoot, branchName, prInfo) {
57
94
  }).trim();
58
95
  const prData = JSON.parse(prBody);
59
96
  const body = prData.body || '';
60
- // Look for issue references in PR body (e.g., "Closes #123", "Fixes #456")
61
- const issueMatch = body.match(/(?:closes|fixes|resolves)\s+#(\d+)/i);
62
- if (!issueMatch) {
97
+ const issueNumber = findIssueNumber(body, featureName, projectRoot);
98
+ if (!issueNumber) {
63
99
  return null;
64
100
  }
65
- const issueNumber = parseInt(issueMatch[1], 10);
66
101
  // Fetch the issue details
67
102
  const issueOutput = execFileSync('gh', ['issue', 'view', String(issueNumber), '--json', 'number,url,state,title'], {
68
103
  cwd: projectRoot,
@@ -50,6 +50,13 @@ export interface LoopConfig {
50
50
  planningModel: string;
51
51
  reviewMode: 'manual' | 'auto' | 'merge';
52
52
  }
53
+ /**
54
+ * Agent configuration in ralph.config.cjs
55
+ */
56
+ export interface AgentSectionConfig {
57
+ defaultProvider: string;
58
+ defaultModel: string;
59
+ }
53
60
  /**
54
61
  * Full ralph.config.cjs structure
55
62
  */
@@ -59,6 +66,7 @@ export interface RalphConfig {
59
66
  commands: CommandsConfig;
60
67
  paths: PathsConfig;
61
68
  loop: LoopConfig;
69
+ agent: AgentSectionConfig;
62
70
  }
63
71
  /**
64
72
  * Default configuration values
@@ -47,6 +47,10 @@ export const DEFAULT_CONFIG = {
47
47
  planningModel: 'opus',
48
48
  reviewMode: 'manual',
49
49
  },
50
+ agent: {
51
+ defaultProvider: '',
52
+ defaultModel: '',
53
+ },
50
54
  };
51
55
  /**
52
56
  * Load ralph.config.cjs from a project directory
@@ -104,6 +108,10 @@ export async function loadConfigWithDefaults(projectRoot) {
104
108
  ...DEFAULT_CONFIG.loop,
105
109
  ...config.loop,
106
110
  },
111
+ agent: {
112
+ ...DEFAULT_CONFIG.agent,
113
+ ...config?.agent,
114
+ },
107
115
  };
108
116
  }
109
117
  /**
@@ -0,0 +1,32 @@
1
+ export declare function isGhInstalled(): Promise<boolean>;
2
+ export declare function _resetGhCache(): void;
3
+ export interface GitHubIssueDetail {
4
+ title: string;
5
+ body: string;
6
+ labels: string[];
7
+ }
8
+ export declare function fetchGitHubIssue(owner: string, repo: string, number: number): Promise<GitHubIssueDetail | null>;
9
+ export interface GitHubIssueListItem {
10
+ number: number;
11
+ title: string;
12
+ state: 'open' | 'closed';
13
+ labels: string[];
14
+ createdAt: string;
15
+ }
16
+ export interface ListIssuesResult {
17
+ issues: GitHubIssueListItem[];
18
+ error?: string;
19
+ }
20
+ export declare function listRepoIssues(owner: string, repo: string, search?: string, limit?: number): Promise<ListIssuesResult>;
21
+ export declare function detectGitHubRemote(projectRoot: string): Promise<GitHubRepo | null>;
22
+ export interface ParsedGitHubIssue {
23
+ owner: string;
24
+ repo: string;
25
+ number: number;
26
+ }
27
+ export declare function isGitHubIssueUrl(input: string): ParsedGitHubIssue | null;
28
+ export interface GitHubRepo {
29
+ owner: string;
30
+ repo: string;
31
+ }
32
+ export declare function parseGitHubRemote(remoteUrl: string): GitHubRepo | null;