wiggum-cli 0.16.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 (97) 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 +5 -0
  51. package/dist/repl/command-parser.js +5 -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 +22 -4
  62. package/dist/tui/components/IssuePicker.d.ts +27 -0
  63. package/dist/tui/components/IssuePicker.js +64 -0
  64. package/dist/tui/components/RunCompletionSummary.js +6 -3
  65. package/dist/tui/hooks/useAgentOrchestrator.d.ts +29 -0
  66. package/dist/tui/hooks/useAgentOrchestrator.js +453 -0
  67. package/dist/tui/orchestration/interview-orchestrator.d.ts +5 -1
  68. package/dist/tui/orchestration/interview-orchestrator.js +27 -6
  69. package/dist/tui/screens/AgentScreen.d.ts +21 -0
  70. package/dist/tui/screens/AgentScreen.js +159 -0
  71. package/dist/tui/screens/InitScreen.js +4 -0
  72. package/dist/tui/screens/InterviewScreen.d.ts +3 -1
  73. package/dist/tui/screens/InterviewScreen.js +146 -10
  74. package/dist/tui/screens/MainShell.d.ts +1 -1
  75. package/dist/tui/screens/MainShell.js +36 -1
  76. package/dist/tui/screens/RunScreen.js +38 -6
  77. package/dist/tui/utils/build-run-summary.d.ts +1 -1
  78. package/dist/tui/utils/build-run-summary.js +40 -84
  79. package/dist/tui/utils/clear-screen.d.ts +14 -0
  80. package/dist/tui/utils/clear-screen.js +16 -0
  81. package/dist/tui/utils/loop-status.d.ts +41 -1
  82. package/dist/tui/utils/loop-status.js +243 -35
  83. package/dist/tui/utils/pr-summary.d.ts +3 -2
  84. package/dist/tui/utils/pr-summary.js +41 -6
  85. package/dist/utils/config.d.ts +8 -0
  86. package/dist/utils/config.js +8 -0
  87. package/dist/utils/github.d.ts +32 -0
  88. package/dist/utils/github.js +106 -0
  89. package/package.json +4 -1
  90. package/src/templates/prompts/PROMPT.md.tmpl +13 -10
  91. package/src/templates/prompts/PROMPT_e2e.md.tmpl +13 -7
  92. package/src/templates/prompts/PROMPT_feature.md.tmpl +16 -3
  93. package/src/templates/prompts/PROMPT_review_auto.md.tmpl +32 -12
  94. package/src/templates/prompts/PROMPT_review_manual.md.tmpl +4 -1
  95. package/src/templates/prompts/PROMPT_review_merge.md.tmpl +39 -14
  96. package/src/templates/prompts/PROMPT_verify.md.tmpl +5 -2
  97. package/src/templates/scripts/feature-loop.sh.tmpl +441 -69
@@ -3,86 +3,10 @@
3
3
  */
4
4
  import { existsSync, readFileSync } from 'node:fs';
5
5
  import { logger } from '../../utils/logger.js';
6
+ import { execFileSync } from 'node:child_process';
6
7
  import { getCurrentCommitHash, getDiffStats, getCommitList } from './git-summary.js';
7
8
  import { getPrForBranch, getLinkedIssue } from './pr-summary.js';
8
- /**
9
- * Phase ID to human-readable label mapping
10
- */
11
- const PHASE_LABELS = {
12
- planning: 'Planning',
13
- implementation: 'Implementation',
14
- e2e_testing: 'E2E Testing',
15
- verification: 'Verification',
16
- pr_review: 'PR & Review',
17
- };
18
- const VALID_PHASE_STATUSES = new Set(['success', 'skipped', 'failed']);
19
- /**
20
- * Parse phase information from the phases file written by feature-loop.sh.
21
- *
22
- * Format: phase_id|status|start_timestamp|end_timestamp
23
- * The parser handles duplicate phase entries defensively (last status wins,
24
- * durations aggregate), though feature-loop.sh normally writes one final
25
- * line per phase.
26
- *
27
- * @param phasesFilePath - Path to the phases file
28
- * @returns Array of phase info objects
29
- */
30
- function parsePhases(phasesFilePath) {
31
- if (!existsSync(phasesFilePath)) {
32
- logger.debug(`Phases file not found: ${phasesFilePath}`);
33
- return [];
34
- }
35
- try {
36
- const content = readFileSync(phasesFilePath, 'utf-8').trim();
37
- if (!content) {
38
- return [];
39
- }
40
- const lines = content.split('\n');
41
- const phaseMap = new Map();
42
- for (const line of lines) {
43
- const parts = line.split('|');
44
- if (parts.length < 4) {
45
- logger.warn(`Skipping malformed phase line: ${line}`);
46
- continue;
47
- }
48
- const [id, status, startStr, endStr] = parts;
49
- // Validate status
50
- if (!VALID_PHASE_STATUSES.has(status)) {
51
- logger.warn(`Unknown phase status "${status}" for phase "${id}", treating as failed`);
52
- }
53
- const validatedStatus = VALID_PHASE_STATUSES.has(status)
54
- ? status
55
- : 'failed';
56
- // Parse timestamps
57
- const startTime = parseInt(startStr, 10) || 0;
58
- const endTime = parseInt(endStr, 10) || 0;
59
- // Calculate duration (end - start) in milliseconds
60
- const durationMs = endTime > 0 && startTime > 0 ? (endTime - startTime) * 1000 : undefined;
61
- // Get or create phase entry
62
- let phase = phaseMap.get(id);
63
- if (!phase) {
64
- phase = {
65
- id,
66
- label: PHASE_LABELS[id] || id,
67
- status: validatedStatus,
68
- durationMs: 0,
69
- };
70
- phaseMap.set(id, phase);
71
- }
72
- // Update status (last status wins)
73
- phase.status = validatedStatus;
74
- // Aggregate duration
75
- if (durationMs !== undefined) {
76
- phase.durationMs = (phase.durationMs || 0) + durationMs;
77
- }
78
- }
79
- return Array.from(phaseMap.values());
80
- }
81
- catch (err) {
82
- logger.warn(`Failed to parse phases file: ${err instanceof Error ? err.message : String(err)}`);
83
- return [];
84
- }
85
- }
9
+ import { parsePhases } from './loop-status.js';
86
10
  /**
87
11
  * Read baseline commit hash from the baseline file.
88
12
  *
@@ -127,7 +51,7 @@ function readBaselineCommit(baselineFilePath) {
127
51
  * which blocks the event loop. Callers should wrap in try-catch to handle
128
52
  * failures gracefully.
129
53
  */
130
- export function buildEnhancedRunSummary(basicSummary, projectRoot, feature) {
54
+ export function buildEnhancedRunSummary(basicSummary, projectRoot, feature, baselineOverride) {
131
55
  const phasesFilePath = `/tmp/ralph-loop-${feature}.phases`;
132
56
  const baselineFilePath = `/tmp/ralph-loop-${feature}.baseline`;
133
57
  // Parse phases and set implementation iterations from actual loop count
@@ -153,8 +77,10 @@ export function buildEnhancedRunSummary(basicSummary, projectRoot, feature) {
153
77
  completed: basicSummary.tasksDone,
154
78
  total: basicSummary.tasksTotal,
155
79
  };
156
- // Git changes and commits
157
- const baselineCommit = readBaselineCommit(baselineFilePath);
80
+ // Git changes and commits — use override if provided (avoids re-reading a cleaned-up file)
81
+ const baselineCommit = baselineOverride !== undefined
82
+ ? (baselineOverride ? baselineOverride.substring(0, 7) : null)
83
+ : readBaselineCommit(baselineFilePath);
158
84
  const currentCommit = getCurrentCommitHash(projectRoot);
159
85
  let changes = { available: false };
160
86
  let commits = { available: false };
@@ -205,13 +131,19 @@ export function buildEnhancedRunSummary(basicSummary, projectRoot, feature) {
205
131
  available: true,
206
132
  created: true,
207
133
  };
208
- // Try to get linked issue, passing prInfo to avoid redundant gh call
209
- const issueInfo = getLinkedIssue(projectRoot, basicSummary.branch, prInfo);
134
+ // Try to get linked issue, passing prInfo and feature name for fallback detection
135
+ const issueInfo = getLinkedIssue(projectRoot, basicSummary.branch, prInfo, feature);
210
136
  if (issueInfo) {
137
+ // When PR is merged with "Closes #N", GitHub auto-closes the issue.
138
+ // The summary may be built before GitHub processes the webhook, so
139
+ // infer closure from PR state to avoid showing stale "OPEN" status.
140
+ const inferredState = prInfo.state === 'MERGED' && issueInfo.state === 'OPEN'
141
+ ? 'CLOSED'
142
+ : issueInfo.state;
211
143
  issue = {
212
144
  number: issueInfo.number,
213
145
  url: issueInfo.url,
214
- status: issueInfo.state,
146
+ status: inferredState,
215
147
  available: true,
216
148
  linked: true,
217
149
  };
@@ -219,6 +151,30 @@ export function buildEnhancedRunSummary(basicSummary, projectRoot, feature) {
219
151
  else {
220
152
  issue = { available: true, linked: false };
221
153
  }
154
+ // Enrich commits from PR when squash-merge detected (1 local commit + merged PR)
155
+ if (prInfo.state === 'MERGED' &&
156
+ commits.available &&
157
+ commits.commitList &&
158
+ commits.commitList.length <= 1) {
159
+ try {
160
+ const prCommitsOutput = execFileSync('gh', ['pr', 'view', String(prInfo.number), '--json', 'commits'], { cwd: projectRoot, encoding: 'utf-8', timeout: 10_000 }).trim();
161
+ const prCommitsData = JSON.parse(prCommitsOutput);
162
+ const prCommits = prCommitsData.commits;
163
+ if (Array.isArray(prCommits) && prCommits.length > 1) {
164
+ commits = {
165
+ ...commits,
166
+ commitList: prCommits.map((c) => ({
167
+ hash: c.oid?.substring(0, 7) ?? '',
168
+ title: c.messageHeadline ?? '',
169
+ })),
170
+ mergeType: 'squash',
171
+ };
172
+ }
173
+ }
174
+ catch (err) {
175
+ logger.debug(`Failed to fetch PR commits: ${err instanceof Error ? err.message : String(err)}`);
176
+ }
177
+ }
222
178
  }
223
179
  else {
224
180
  pr = { available: true, created: false };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Clear the terminal screen to prevent stale PTY scroll buffer artifacts.
3
+ *
4
+ * When Ink re-renders significantly shorter output (e.g. after dismissing a
5
+ * 25-row IssuePicker), the old content remains in the PTY scroll buffer,
6
+ * causing visual duplication of the banner and other content. This sends
7
+ * ANSI escape sequences to clear the entire screen and reset the cursor.
8
+ *
9
+ * This is an intentional mix of imperative stdout writes with Ink's
10
+ * declarative rendering — Ink has no API to flush the PTY scroll buffer.
11
+ * Call this *before* state updates that shrink the rendered output so
12
+ * Ink's next render paints onto a clean screen.
13
+ */
14
+ export declare function clearScreen(stdout: NodeJS.WriteStream): void;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Clear the terminal screen to prevent stale PTY scroll buffer artifacts.
3
+ *
4
+ * When Ink re-renders significantly shorter output (e.g. after dismissing a
5
+ * 25-row IssuePicker), the old content remains in the PTY scroll buffer,
6
+ * causing visual duplication of the banner and other content. This sends
7
+ * ANSI escape sequences to clear the entire screen and reset the cursor.
8
+ *
9
+ * This is an intentional mix of imperative stdout writes with Ink's
10
+ * declarative rendering — Ink has no API to flush the PTY scroll buffer.
11
+ * Call this *before* state updates that shrink the rendered output so
12
+ * Ink's next render paints onto a clean screen.
13
+ */
14
+ export function clearScreen(stdout) {
15
+ stdout.write('\x1b[2J\x1b[H');
16
+ }
@@ -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
+ }