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.
- package/bin/ralph.js +0 -0
- package/dist/agent/memory/ingest.d.ts +14 -0
- package/dist/agent/memory/ingest.js +77 -0
- package/dist/agent/memory/store.d.ts +15 -0
- package/dist/agent/memory/store.js +98 -0
- package/dist/agent/memory/types.d.ts +16 -0
- package/dist/agent/memory/types.js +14 -0
- package/dist/agent/orchestrator.d.ts +7 -0
- package/dist/agent/orchestrator.js +266 -0
- package/dist/agent/resolve-config.d.ts +26 -0
- package/dist/agent/resolve-config.js +43 -0
- package/dist/agent/tools/backlog.d.ts +27 -0
- package/dist/agent/tools/backlog.js +51 -0
- package/dist/agent/tools/dry-run.d.ts +106 -0
- package/dist/agent/tools/dry-run.js +119 -0
- package/dist/agent/tools/execution.d.ts +51 -0
- package/dist/agent/tools/execution.js +256 -0
- package/dist/agent/tools/feature-state.d.ts +43 -0
- package/dist/agent/tools/feature-state.js +184 -0
- package/dist/agent/tools/introspection.d.ts +23 -0
- package/dist/agent/tools/introspection.js +40 -0
- package/dist/agent/tools/memory.d.ts +44 -0
- package/dist/agent/tools/memory.js +99 -0
- package/dist/agent/tools/preflight.d.ts +7 -0
- package/dist/agent/tools/preflight.js +137 -0
- package/dist/agent/tools/reporting.d.ts +58 -0
- package/dist/agent/tools/reporting.js +119 -0
- package/dist/agent/tools/schemas.d.ts +2 -0
- package/dist/agent/tools/schemas.js +3 -0
- package/dist/agent/types.d.ts +45 -0
- package/dist/agent/types.js +1 -0
- package/dist/ai/conversation/conversation-manager.js +8 -0
- package/dist/ai/conversation/url-fetcher.js +27 -0
- package/dist/ai/providers.js +5 -5
- package/dist/commands/agent.d.ts +17 -0
- package/dist/commands/agent.js +114 -0
- package/dist/commands/monitor.js +50 -183
- package/dist/commands/new-auto.d.ts +15 -0
- package/dist/commands/new-auto.js +237 -0
- package/dist/commands/run.js +20 -10
- package/dist/commands/sync.d.ts +15 -0
- package/dist/commands/sync.js +68 -0
- package/dist/generator/config.d.ts +1 -41
- package/dist/generator/config.js +7 -0
- package/dist/generator/index.d.ts +2 -2
- package/dist/generator/templates.d.ts +2 -0
- package/dist/generator/templates.js +9 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +115 -4
- package/dist/repl/command-parser.d.ts +5 -0
- package/dist/repl/command-parser.js +5 -0
- package/dist/templates/prompts/PROMPT.md.tmpl +13 -10
- package/dist/templates/prompts/PROMPT_e2e.md.tmpl +13 -7
- package/dist/templates/prompts/PROMPT_feature.md.tmpl +16 -3
- package/dist/templates/prompts/PROMPT_review_auto.md.tmpl +32 -12
- package/dist/templates/prompts/PROMPT_review_manual.md.tmpl +4 -1
- package/dist/templates/prompts/PROMPT_review_merge.md.tmpl +39 -14
- package/dist/templates/prompts/PROMPT_verify.md.tmpl +5 -2
- package/dist/templates/scripts/feature-loop.sh.tmpl +441 -69
- package/dist/tui/app.d.ts +19 -2
- package/dist/tui/app.js +22 -4
- package/dist/tui/components/IssuePicker.d.ts +27 -0
- package/dist/tui/components/IssuePicker.js +64 -0
- package/dist/tui/components/RunCompletionSummary.js +6 -3
- package/dist/tui/hooks/useAgentOrchestrator.d.ts +29 -0
- package/dist/tui/hooks/useAgentOrchestrator.js +453 -0
- package/dist/tui/orchestration/interview-orchestrator.d.ts +5 -1
- package/dist/tui/orchestration/interview-orchestrator.js +27 -6
- package/dist/tui/screens/AgentScreen.d.ts +21 -0
- package/dist/tui/screens/AgentScreen.js +159 -0
- package/dist/tui/screens/InitScreen.js +4 -0
- package/dist/tui/screens/InterviewScreen.d.ts +3 -1
- package/dist/tui/screens/InterviewScreen.js +146 -10
- package/dist/tui/screens/MainShell.d.ts +1 -1
- package/dist/tui/screens/MainShell.js +36 -1
- package/dist/tui/screens/RunScreen.js +38 -6
- package/dist/tui/utils/build-run-summary.d.ts +1 -1
- package/dist/tui/utils/build-run-summary.js +40 -84
- package/dist/tui/utils/clear-screen.d.ts +14 -0
- package/dist/tui/utils/clear-screen.js +16 -0
- package/dist/tui/utils/loop-status.d.ts +41 -1
- package/dist/tui/utils/loop-status.js +243 -35
- package/dist/tui/utils/pr-summary.d.ts +3 -2
- package/dist/tui/utils/pr-summary.js +41 -6
- package/dist/utils/config.d.ts +8 -0
- package/dist/utils/config.js +8 -0
- package/dist/utils/github.d.ts +32 -0
- package/dist/utils/github.js +106 -0
- package/package.json +4 -1
- package/src/templates/prompts/PROMPT.md.tmpl +13 -10
- package/src/templates/prompts/PROMPT_e2e.md.tmpl +13 -7
- package/src/templates/prompts/PROMPT_feature.md.tmpl +16 -3
- package/src/templates/prompts/PROMPT_review_auto.md.tmpl +32 -12
- package/src/templates/prompts/PROMPT_review_manual.md.tmpl +4 -1
- package/src/templates/prompts/PROMPT_review_merge.md.tmpl +39 -14
- package/src/templates/prompts/PROMPT_verify.md.tmpl +5 -2
- 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 =
|
|
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
|
|
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:
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
118
|
-
phase
|
|
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 =
|
|
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
|
-
|
|
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
|
-
/^\{"
|
|
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 =
|
|
283
|
-
const ERROR_KEYWORDS =
|
|
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
|
-
|
|
370
|
-
|
|
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
|
-
//
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
+
}
|