bmalph 2.3.0 → 2.5.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/README.md +105 -38
- package/dist/cli.js +19 -0
- package/dist/commands/doctor.d.ts +0 -11
- package/dist/commands/doctor.js +22 -55
- package/dist/commands/implement.d.ts +6 -0
- package/dist/commands/implement.js +82 -0
- package/dist/commands/init.js +4 -2
- package/dist/commands/reset.d.ts +7 -0
- package/dist/commands/reset.js +81 -0
- package/dist/commands/status.js +100 -11
- package/dist/commands/watch.d.ts +6 -0
- package/dist/commands/watch.js +19 -0
- package/dist/installer.d.ts +0 -6
- package/dist/installer.js +0 -10
- package/dist/platform/claude-code.js +0 -1
- package/dist/reset.d.ts +18 -0
- package/dist/reset.js +181 -0
- package/dist/transition/artifact-scan.d.ts +27 -0
- package/dist/transition/artifact-scan.js +91 -0
- package/dist/transition/artifacts.d.ts +0 -1
- package/dist/transition/artifacts.js +0 -26
- package/dist/transition/context.js +34 -0
- package/dist/transition/fix-plan.d.ts +8 -2
- package/dist/transition/fix-plan.js +33 -7
- package/dist/transition/index.d.ts +1 -1
- package/dist/transition/index.js +1 -1
- package/dist/transition/orchestration.d.ts +2 -2
- package/dist/transition/orchestration.js +120 -41
- package/dist/transition/preflight.d.ts +6 -0
- package/dist/transition/preflight.js +154 -0
- package/dist/transition/specs-index.d.ts +1 -1
- package/dist/transition/specs-index.js +24 -1
- package/dist/transition/types.d.ts +23 -1
- package/dist/utils/dryrun.d.ts +1 -1
- package/dist/utils/dryrun.js +22 -0
- package/dist/utils/state.d.ts +0 -2
- package/dist/utils/validate.js +3 -2
- package/dist/watch/dashboard.d.ts +4 -0
- package/dist/watch/dashboard.js +60 -0
- package/dist/watch/file-watcher.d.ts +9 -0
- package/dist/watch/file-watcher.js +27 -0
- package/dist/watch/renderer.d.ts +16 -0
- package/dist/watch/renderer.js +241 -0
- package/dist/watch/state-reader.d.ts +9 -0
- package/dist/watch/state-reader.js +190 -0
- package/dist/watch/types.d.ts +55 -0
- package/dist/watch/types.js +1 -0
- package/package.json +9 -4
- package/ralph/lib/circuit_breaker.sh +86 -59
- package/ralph/lib/enable_core.sh +3 -6
- package/ralph/lib/response_analyzer.sh +5 -29
- package/ralph/lib/task_sources.sh +45 -11
- package/ralph/lib/wizard_utils.sh +9 -0
- package/ralph/ralph_import.sh +7 -2
- package/ralph/ralph_loop.sh +44 -34
- package/ralph/ralph_monitor.sh +4 -0
- package/slash-commands/bmalph-doctor.md +16 -0
- package/slash-commands/bmalph-implement.md +18 -141
- package/slash-commands/bmalph-status.md +15 -0
- package/slash-commands/bmalph-upgrade.md +15 -0
- package/slash-commands/bmalph-watch.md +20 -0
package/dist/utils/dryrun.js
CHANGED
|
@@ -11,6 +11,12 @@ export function logDryRunAction(action) {
|
|
|
11
11
|
case "skip":
|
|
12
12
|
console.log(`${prefix} Would skip: ${chalk.dim(action.path)}${action.reason ? ` (${action.reason})` : ""}`);
|
|
13
13
|
break;
|
|
14
|
+
case "delete":
|
|
15
|
+
console.log(`${prefix} Would delete: ${chalk.red(action.path)}`);
|
|
16
|
+
break;
|
|
17
|
+
case "warn":
|
|
18
|
+
console.log(`${prefix} Warning: ${chalk.yellow(action.path)}${action.reason ? ` (${action.reason})` : ""}`);
|
|
19
|
+
break;
|
|
14
20
|
}
|
|
15
21
|
}
|
|
16
22
|
export function formatDryRunSummary(actions) {
|
|
@@ -19,9 +25,18 @@ export function formatDryRunSummary(actions) {
|
|
|
19
25
|
}
|
|
20
26
|
const lines = [];
|
|
21
27
|
lines.push(chalk.blue("\n[dry-run] Would perform the following actions:\n"));
|
|
28
|
+
const deletes = actions.filter((a) => a.type === "delete");
|
|
22
29
|
const creates = actions.filter((a) => a.type === "create");
|
|
23
30
|
const modifies = actions.filter((a) => a.type === "modify");
|
|
24
31
|
const skips = actions.filter((a) => a.type === "skip");
|
|
32
|
+
const warns = actions.filter((a) => a.type === "warn");
|
|
33
|
+
if (deletes.length > 0) {
|
|
34
|
+
lines.push(chalk.red("Would delete:"));
|
|
35
|
+
for (const action of deletes) {
|
|
36
|
+
lines.push(` ${action.path}`);
|
|
37
|
+
}
|
|
38
|
+
lines.push("");
|
|
39
|
+
}
|
|
25
40
|
if (creates.length > 0) {
|
|
26
41
|
lines.push(chalk.green("Would create:"));
|
|
27
42
|
for (const action of creates) {
|
|
@@ -43,6 +58,13 @@ export function formatDryRunSummary(actions) {
|
|
|
43
58
|
}
|
|
44
59
|
lines.push("");
|
|
45
60
|
}
|
|
61
|
+
if (warns.length > 0) {
|
|
62
|
+
lines.push(chalk.yellow("Warnings:"));
|
|
63
|
+
for (const action of warns) {
|
|
64
|
+
lines.push(` ${action.path}${action.reason ? ` (${action.reason})` : ""}`);
|
|
65
|
+
}
|
|
66
|
+
lines.push("");
|
|
67
|
+
}
|
|
46
68
|
lines.push(chalk.dim("No changes made."));
|
|
47
69
|
return lines.join("\n");
|
|
48
70
|
}
|
package/dist/utils/state.d.ts
CHANGED
|
@@ -21,6 +21,4 @@ export declare function readState(projectDir: string): Promise<BmalphState | nul
|
|
|
21
21
|
export declare function writeState(projectDir: string, state: BmalphState): Promise<void>;
|
|
22
22
|
export declare function getPhaseLabel(phase: number): string;
|
|
23
23
|
export declare function getPhaseInfo(phase: number): PhaseInfo;
|
|
24
|
-
/** @deprecated Use RalphLoopStatus from validate.ts instead */
|
|
25
|
-
export type RalphStatus = RalphLoopStatus;
|
|
26
24
|
export declare function readRalphStatus(projectDir: string): Promise<RalphLoopStatus>;
|
package/dist/utils/validate.js
CHANGED
|
@@ -176,6 +176,7 @@ const BASH_STATUS_MAP = {
|
|
|
176
176
|
stopped: "blocked",
|
|
177
177
|
completed: "completed",
|
|
178
178
|
success: "completed",
|
|
179
|
+
graceful_exit: "completed",
|
|
179
180
|
};
|
|
180
181
|
export function normalizeRalphStatus(data) {
|
|
181
182
|
assertObject(data, "normalizeRalphStatus");
|
|
@@ -185,8 +186,8 @@ export function normalizeRalphStatus(data) {
|
|
|
185
186
|
return {
|
|
186
187
|
loopCount,
|
|
187
188
|
status,
|
|
188
|
-
tasksCompleted: 0,
|
|
189
|
-
tasksTotal: 0,
|
|
189
|
+
tasksCompleted: typeof data.tasks_completed === "number" ? data.tasks_completed : 0,
|
|
190
|
+
tasksTotal: typeof data.tasks_total === "number" ? data.tasks_total : 0,
|
|
190
191
|
};
|
|
191
192
|
}
|
|
192
193
|
/**
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { WatchOptions } from "./types.js";
|
|
2
|
+
export declare function createRefreshCallback(projectDir: string, write: (s: string) => void): () => Promise<void>;
|
|
3
|
+
export declare function setupTerminal(): () => void;
|
|
4
|
+
export declare function startDashboard(options: WatchOptions): Promise<void>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readDashboardState } from "./state-reader.js";
|
|
2
|
+
import { renderDashboard } from "./renderer.js";
|
|
3
|
+
import { FileWatcher } from "./file-watcher.js";
|
|
4
|
+
const CLEAR_SCREEN = "\x1B[2J\x1B[H";
|
|
5
|
+
const HIDE_CURSOR = "\x1B[?25l";
|
|
6
|
+
const SHOW_CURSOR = "\x1B[?25h";
|
|
7
|
+
export function createRefreshCallback(projectDir, write) {
|
|
8
|
+
return async () => {
|
|
9
|
+
const state = await readDashboardState(projectDir);
|
|
10
|
+
const output = renderDashboard(state);
|
|
11
|
+
write(CLEAR_SCREEN + output + "\n");
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function setupTerminal() {
|
|
15
|
+
if (process.stdout.isTTY) {
|
|
16
|
+
process.stdout.write(HIDE_CURSOR);
|
|
17
|
+
}
|
|
18
|
+
return () => {
|
|
19
|
+
if (process.stdout.isTTY) {
|
|
20
|
+
process.stdout.write(SHOW_CURSOR);
|
|
21
|
+
}
|
|
22
|
+
if (process.stdin.isTTY && process.stdin.setRawMode) {
|
|
23
|
+
process.stdin.setRawMode(false);
|
|
24
|
+
}
|
|
25
|
+
process.stdin.pause();
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export async function startDashboard(options) {
|
|
29
|
+
const { projectDir, interval } = options;
|
|
30
|
+
const cleanup = setupTerminal();
|
|
31
|
+
const refresh = createRefreshCallback(projectDir, (s) => process.stdout.write(s));
|
|
32
|
+
const watcher = new FileWatcher(refresh, interval);
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const stop = () => {
|
|
35
|
+
watcher.stop();
|
|
36
|
+
cleanup();
|
|
37
|
+
resolve();
|
|
38
|
+
};
|
|
39
|
+
if (process.stdin.isTTY && process.stdin.setRawMode) {
|
|
40
|
+
process.stdin.setRawMode(true);
|
|
41
|
+
process.stdin.resume();
|
|
42
|
+
process.stdin.setEncoding("utf-8");
|
|
43
|
+
process.stdin.on("data", (data) => {
|
|
44
|
+
if (data === "q" || data === "\x03") {
|
|
45
|
+
stop();
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const onResize = () => {
|
|
50
|
+
void refresh();
|
|
51
|
+
};
|
|
52
|
+
process.stdout.on("resize", onResize);
|
|
53
|
+
const onSignal = () => {
|
|
54
|
+
stop();
|
|
55
|
+
};
|
|
56
|
+
process.on("SIGINT", onSignal);
|
|
57
|
+
process.on("SIGTERM", onSignal);
|
|
58
|
+
watcher.start();
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export class FileWatcher {
|
|
2
|
+
intervalId = null;
|
|
3
|
+
intervalMs;
|
|
4
|
+
callback;
|
|
5
|
+
constructor(callback, intervalMs = 2000) {
|
|
6
|
+
this.callback = callback;
|
|
7
|
+
this.intervalMs = intervalMs;
|
|
8
|
+
}
|
|
9
|
+
start() {
|
|
10
|
+
void this.tick();
|
|
11
|
+
this.intervalId = setInterval(() => void this.tick(), this.intervalMs);
|
|
12
|
+
}
|
|
13
|
+
stop() {
|
|
14
|
+
if (this.intervalId !== null) {
|
|
15
|
+
clearInterval(this.intervalId);
|
|
16
|
+
this.intervalId = null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
async tick() {
|
|
20
|
+
try {
|
|
21
|
+
await this.callback();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Swallow errors to keep polling alive
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { DashboardState, LoopInfo, CircuitBreakerInfo, StoryProgress, AnalysisInfo, LogEntry, ExecutionProgress, SessionInfo } from "./types.js";
|
|
2
|
+
export declare function padRight(str: string, len: number): string;
|
|
3
|
+
export declare function progressBar(completed: number, total: number, width: number): string;
|
|
4
|
+
export declare function formatSessionAge(createdAt: string): string;
|
|
5
|
+
export declare function formatStatus(status: string): string;
|
|
6
|
+
export declare function formatCBState(state: string): string;
|
|
7
|
+
export declare function box(title: string, lines: string[], cols: number): string;
|
|
8
|
+
export declare function renderHeader(cols: number): string;
|
|
9
|
+
export declare function renderLoopPanel(loop: LoopInfo | null, execution: ExecutionProgress | null, session: SessionInfo | null, cols: number): string;
|
|
10
|
+
export declare function renderCircuitBreakerPanel(cb: CircuitBreakerInfo | null, cols: number): string;
|
|
11
|
+
export declare function renderStoriesPanel(stories: StoryProgress | null, cols: number): string;
|
|
12
|
+
export declare function renderSideBySide(left: string, right: string, cols: number): string;
|
|
13
|
+
export declare function renderAnalysisPanel(analysis: AnalysisInfo | null, cols: number): string;
|
|
14
|
+
export declare function renderLogsPanel(logs: LogEntry[], cols: number): string;
|
|
15
|
+
export declare function renderFooter(lastUpdated: Date, cols: number): string;
|
|
16
|
+
export declare function renderDashboard(state: DashboardState, cols?: number): string;
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
const BOX_CHARS = {
|
|
3
|
+
topLeft: "\u250C",
|
|
4
|
+
topRight: "\u2510",
|
|
5
|
+
bottomLeft: "\u2514",
|
|
6
|
+
bottomRight: "\u2518",
|
|
7
|
+
horizontal: "\u2500",
|
|
8
|
+
vertical: "\u2502",
|
|
9
|
+
headerLeft: "\u2554",
|
|
10
|
+
headerRight: "\u2557",
|
|
11
|
+
headerBottom: "\u255A",
|
|
12
|
+
headerBottomRight: "\u255D",
|
|
13
|
+
headerHoriz: "\u2550",
|
|
14
|
+
headerVert: "\u2551",
|
|
15
|
+
};
|
|
16
|
+
const PROGRESS_FILLED = "\u2588";
|
|
17
|
+
const PROGRESS_EMPTY = "\u2591";
|
|
18
|
+
// eslint-disable-next-line no-control-regex
|
|
19
|
+
const ANSI_PATTERN = /\x1B\[\d+m/g;
|
|
20
|
+
function stripAnsi(str) {
|
|
21
|
+
return str.replace(ANSI_PATTERN, "");
|
|
22
|
+
}
|
|
23
|
+
export function padRight(str, len) {
|
|
24
|
+
const visualLen = stripAnsi(str).length;
|
|
25
|
+
if (visualLen >= len) {
|
|
26
|
+
return str;
|
|
27
|
+
}
|
|
28
|
+
return str + " ".repeat(len - visualLen);
|
|
29
|
+
}
|
|
30
|
+
export function progressBar(completed, total, width) {
|
|
31
|
+
if (total <= 0) {
|
|
32
|
+
return PROGRESS_EMPTY.repeat(width);
|
|
33
|
+
}
|
|
34
|
+
const ratio = Math.min(completed / total, 1);
|
|
35
|
+
const filled = Math.round(ratio * width);
|
|
36
|
+
return chalk.green(PROGRESS_FILLED.repeat(filled)) + PROGRESS_EMPTY.repeat(width - filled);
|
|
37
|
+
}
|
|
38
|
+
export function formatSessionAge(createdAt) {
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
const start = new Date(createdAt).getTime();
|
|
41
|
+
const diffSeconds = Math.max(0, Math.floor((now - start) / 1000));
|
|
42
|
+
const hours = Math.floor(diffSeconds / 3600);
|
|
43
|
+
const minutes = Math.floor((diffSeconds % 3600) / 60);
|
|
44
|
+
const seconds = diffSeconds % 60;
|
|
45
|
+
if (hours > 0) {
|
|
46
|
+
return `${String(hours)}h ${String(minutes)}m`;
|
|
47
|
+
}
|
|
48
|
+
return `${String(minutes)}m ${String(seconds)}s`;
|
|
49
|
+
}
|
|
50
|
+
export function formatStatus(status) {
|
|
51
|
+
switch (status) {
|
|
52
|
+
case "running":
|
|
53
|
+
return chalk.yellow(status);
|
|
54
|
+
case "completed":
|
|
55
|
+
case "success":
|
|
56
|
+
return chalk.green(status);
|
|
57
|
+
case "halted":
|
|
58
|
+
case "stopped":
|
|
59
|
+
case "blocked":
|
|
60
|
+
return chalk.red(status);
|
|
61
|
+
default:
|
|
62
|
+
return status;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export function formatCBState(state) {
|
|
66
|
+
switch (state) {
|
|
67
|
+
case "CLOSED":
|
|
68
|
+
return chalk.green(state);
|
|
69
|
+
case "HALF_OPEN":
|
|
70
|
+
return chalk.yellow(state);
|
|
71
|
+
case "OPEN":
|
|
72
|
+
return chalk.red(state);
|
|
73
|
+
default:
|
|
74
|
+
return state;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function formatTime(date) {
|
|
78
|
+
const h = String(date.getUTCHours()).padStart(2, "0");
|
|
79
|
+
const m = String(date.getUTCMinutes()).padStart(2, "0");
|
|
80
|
+
const s = String(date.getUTCSeconds()).padStart(2, "0");
|
|
81
|
+
return `${h}:${m}:${s}`;
|
|
82
|
+
}
|
|
83
|
+
function extractTime(timestamp) {
|
|
84
|
+
const timePart = timestamp.split(" ")[1];
|
|
85
|
+
return timePart ?? formatTime(new Date(timestamp));
|
|
86
|
+
}
|
|
87
|
+
export function box(title, lines, cols) {
|
|
88
|
+
const innerWidth = cols - 2;
|
|
89
|
+
const titleStr = title ? `\u2500 ${title} ` : "";
|
|
90
|
+
const topBorder = BOX_CHARS.topLeft +
|
|
91
|
+
titleStr +
|
|
92
|
+
BOX_CHARS.horizontal.repeat(Math.max(0, innerWidth - titleStr.length)) +
|
|
93
|
+
BOX_CHARS.topRight;
|
|
94
|
+
const bottomBorder = BOX_CHARS.bottomLeft + BOX_CHARS.horizontal.repeat(innerWidth) + BOX_CHARS.bottomRight;
|
|
95
|
+
const contentLines = lines.map((line) => BOX_CHARS.vertical + " " + padRight(line, innerWidth - 1) + BOX_CHARS.vertical);
|
|
96
|
+
return [topBorder, ...contentLines, bottomBorder].join("\n");
|
|
97
|
+
}
|
|
98
|
+
export function renderHeader(cols) {
|
|
99
|
+
const innerWidth = cols - 2;
|
|
100
|
+
const title = "RALPH MONITOR";
|
|
101
|
+
const padding = Math.max(0, Math.floor((innerWidth - title.length) / 2));
|
|
102
|
+
const centeredTitle = " ".repeat(padding) + title + " ".repeat(innerWidth - padding - title.length);
|
|
103
|
+
const topBorder = BOX_CHARS.headerLeft + BOX_CHARS.headerHoriz.repeat(innerWidth) + BOX_CHARS.headerRight;
|
|
104
|
+
const titleLine = BOX_CHARS.headerVert + chalk.bold(centeredTitle) + BOX_CHARS.headerVert;
|
|
105
|
+
const bottomBorder = BOX_CHARS.headerBottom + BOX_CHARS.headerHoriz.repeat(innerWidth) + BOX_CHARS.headerBottomRight;
|
|
106
|
+
return [topBorder, titleLine, bottomBorder].join("\n");
|
|
107
|
+
}
|
|
108
|
+
export function renderLoopPanel(loop, execution, session, cols) {
|
|
109
|
+
if (loop === null) {
|
|
110
|
+
return box("Loop Status", ["Status: waiting for data"], cols);
|
|
111
|
+
}
|
|
112
|
+
const apiPercent = loop.maxCallsPerHour > 0
|
|
113
|
+
? Math.round((loop.callsMadeThisHour / loop.maxCallsPerHour) * 100)
|
|
114
|
+
: 0;
|
|
115
|
+
const loopStr = `Loop: #${String(loop.loopCount)}`;
|
|
116
|
+
const statusStr = `Status: ${formatStatus(loop.status)}`;
|
|
117
|
+
const apiStr = `API: ${String(loop.callsMadeThisHour)}/${String(loop.maxCallsPerHour)} (${String(apiPercent)}%)`;
|
|
118
|
+
const line1 = `${padRight(loopStr, 17)}${padRight(statusStr, 21)}${apiStr}`;
|
|
119
|
+
const actionLabel = execution !== null ? execution.status : loop.lastAction;
|
|
120
|
+
const actionStr = `Action: ${actionLabel}`;
|
|
121
|
+
const sessionStr = session !== null ? `Session: ${formatSessionAge(session.createdAt)}` : "";
|
|
122
|
+
const innerWidth = cols - 4;
|
|
123
|
+
const sessionPad = Math.max(0, innerWidth - actionStr.length - sessionStr.length);
|
|
124
|
+
const line2 = `${actionStr}${" ".repeat(sessionPad)}${sessionStr}`;
|
|
125
|
+
return box("Loop Status", [line1, line2], cols);
|
|
126
|
+
}
|
|
127
|
+
export function renderCircuitBreakerPanel(cb, cols) {
|
|
128
|
+
const halfCols = Math.floor(cols / 2) - 1;
|
|
129
|
+
if (cb === null) {
|
|
130
|
+
return box("Circuit Breaker", ["N/A"], halfCols);
|
|
131
|
+
}
|
|
132
|
+
const lines = [
|
|
133
|
+
`State: ${formatCBState(cb.state)}`,
|
|
134
|
+
`No-progress: ${String(cb.consecutiveNoProgress)}`,
|
|
135
|
+
`Opens: ${String(cb.totalOpens)}`,
|
|
136
|
+
];
|
|
137
|
+
if (cb.state === "OPEN" && cb.reason) {
|
|
138
|
+
lines.push(`Reason: ${cb.reason}`);
|
|
139
|
+
}
|
|
140
|
+
return box("Circuit Breaker", lines, halfCols);
|
|
141
|
+
}
|
|
142
|
+
export function renderStoriesPanel(stories, cols) {
|
|
143
|
+
const halfCols = Math.floor(cols / 2) - 1;
|
|
144
|
+
if (stories === null) {
|
|
145
|
+
return box("Stories", ["N/A"], halfCols);
|
|
146
|
+
}
|
|
147
|
+
const total = stories.total;
|
|
148
|
+
const completed = stories.completed;
|
|
149
|
+
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
|
150
|
+
const bar = progressBar(completed, total, 20);
|
|
151
|
+
const lines = [
|
|
152
|
+
`Progress: ${String(completed)}/${String(total)} (${String(percent)}%)`,
|
|
153
|
+
`[${bar}]`,
|
|
154
|
+
];
|
|
155
|
+
if (stories.nextStory !== null) {
|
|
156
|
+
lines.push(`Next: ${stories.nextStory}`);
|
|
157
|
+
}
|
|
158
|
+
return box("Stories", lines, halfCols);
|
|
159
|
+
}
|
|
160
|
+
export function renderSideBySide(left, right, cols) {
|
|
161
|
+
const halfWidth = Math.floor(cols / 2) - 1;
|
|
162
|
+
const leftLines = left.split("\n");
|
|
163
|
+
const rightLines = right.split("\n");
|
|
164
|
+
const maxLines = Math.max(leftLines.length, rightLines.length);
|
|
165
|
+
const result = [];
|
|
166
|
+
for (let i = 0; i < maxLines; i++) {
|
|
167
|
+
const l = leftLines[i] ?? "";
|
|
168
|
+
const r = rightLines[i] ?? "";
|
|
169
|
+
result.push(padRight(l, halfWidth) + " " + r);
|
|
170
|
+
}
|
|
171
|
+
return result.join("\n");
|
|
172
|
+
}
|
|
173
|
+
export function renderAnalysisPanel(analysis, cols) {
|
|
174
|
+
if (analysis === null) {
|
|
175
|
+
return box("Last Analysis", ["N/A"], cols);
|
|
176
|
+
}
|
|
177
|
+
const yesNo = (v) => (v ? "yes" : "no");
|
|
178
|
+
const line1 = [
|
|
179
|
+
`Files: ${String(analysis.filesModified)}`,
|
|
180
|
+
`Confidence: ${String(analysis.confidenceScore)}%`,
|
|
181
|
+
`Test-only: ${yesNo(analysis.isTestOnly)}`,
|
|
182
|
+
`Stuck: ${yesNo(analysis.isStuck)}`,
|
|
183
|
+
].join(" ");
|
|
184
|
+
const line2 = [
|
|
185
|
+
`Exit signal: ${yesNo(analysis.exitSignal)}`,
|
|
186
|
+
`Permission denials: ${String(analysis.permissionDenialCount)}`,
|
|
187
|
+
].join(" ");
|
|
188
|
+
return box("Last Analysis", [line1, line2], cols);
|
|
189
|
+
}
|
|
190
|
+
export function renderLogsPanel(logs, cols) {
|
|
191
|
+
if (logs.length === 0) {
|
|
192
|
+
return box("Recent Activity", [chalk.dim("No activity yet")], cols);
|
|
193
|
+
}
|
|
194
|
+
const innerWidth = cols - 4;
|
|
195
|
+
const lines = logs.map((entry) => {
|
|
196
|
+
const time = extractTime(entry.timestamp);
|
|
197
|
+
const level = padRight(entry.level, 7);
|
|
198
|
+
const prefix = `[${time}] ${level}`;
|
|
199
|
+
const maxMsg = Math.max(0, innerWidth - prefix.length - 1);
|
|
200
|
+
const msg = entry.message.length > maxMsg ? entry.message.slice(0, maxMsg) : entry.message;
|
|
201
|
+
return `${chalk.dim(`[${time}]`)} ${level} ${msg}`;
|
|
202
|
+
});
|
|
203
|
+
return box("Recent Activity", lines, cols);
|
|
204
|
+
}
|
|
205
|
+
export function renderFooter(lastUpdated, cols) {
|
|
206
|
+
const left = chalk.dim("q quit");
|
|
207
|
+
const right = `Updated: ${formatTime(lastUpdated)}`;
|
|
208
|
+
const gap = Math.max(1, cols - "q quit".length - right.length);
|
|
209
|
+
return ` ${left}${" ".repeat(gap - 1)}${chalk.dim(right)}`;
|
|
210
|
+
}
|
|
211
|
+
function hasAnyData(state) {
|
|
212
|
+
return (state.loop !== null ||
|
|
213
|
+
state.circuitBreaker !== null ||
|
|
214
|
+
state.stories !== null ||
|
|
215
|
+
state.analysis !== null ||
|
|
216
|
+
state.execution !== null ||
|
|
217
|
+
state.session !== null ||
|
|
218
|
+
state.recentLogs.length > 0);
|
|
219
|
+
}
|
|
220
|
+
export function renderDashboard(state, cols) {
|
|
221
|
+
const width = cols ?? process.stdout.columns ?? 80;
|
|
222
|
+
if (!hasAnyData(state)) {
|
|
223
|
+
const lines = [];
|
|
224
|
+
lines.push(renderHeader(width));
|
|
225
|
+
lines.push("");
|
|
226
|
+
lines.push(chalk.dim(padRight(" Waiting for Ralph to start...", width)));
|
|
227
|
+
lines.push("");
|
|
228
|
+
lines.push(renderFooter(state.lastUpdated, width));
|
|
229
|
+
return lines.join("\n");
|
|
230
|
+
}
|
|
231
|
+
const sections = [];
|
|
232
|
+
sections.push(renderHeader(width));
|
|
233
|
+
sections.push(renderLoopPanel(state.loop, state.execution, state.session, width));
|
|
234
|
+
const leftPanel = renderCircuitBreakerPanel(state.circuitBreaker, width);
|
|
235
|
+
const rightPanel = renderStoriesPanel(state.stories, width);
|
|
236
|
+
sections.push(renderSideBySide(leftPanel, rightPanel, width));
|
|
237
|
+
sections.push(renderAnalysisPanel(state.analysis, width));
|
|
238
|
+
sections.push(renderLogsPanel(state.recentLogs, width));
|
|
239
|
+
sections.push(renderFooter(state.lastUpdated, width));
|
|
240
|
+
return sections.join("\n");
|
|
241
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { DashboardState, LoopInfo, CircuitBreakerInfo, StoryProgress, AnalysisInfo, ExecutionProgress, SessionInfo, LogEntry } from "./types.js";
|
|
2
|
+
export declare function readDashboardState(projectDir: string): Promise<DashboardState>;
|
|
3
|
+
export declare function readLoopInfo(projectDir: string): Promise<LoopInfo | null>;
|
|
4
|
+
export declare function readCircuitBreakerInfo(projectDir: string): Promise<CircuitBreakerInfo | null>;
|
|
5
|
+
export declare function readStoryProgress(projectDir: string): Promise<StoryProgress | null>;
|
|
6
|
+
export declare function readAnalysisInfo(projectDir: string): Promise<AnalysisInfo | null>;
|
|
7
|
+
export declare function readExecutionProgress(projectDir: string): Promise<ExecutionProgress | null>;
|
|
8
|
+
export declare function readSessionInfo(projectDir: string): Promise<SessionInfo | null>;
|
|
9
|
+
export declare function readRecentLogs(projectDir: string, maxLines?: number): Promise<LogEntry[]>;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { open, readFile } from "fs/promises";
|
|
3
|
+
import { readJsonFile } from "../utils/json.js";
|
|
4
|
+
import { RALPH_DIR } from "../utils/constants.js";
|
|
5
|
+
import { parseFixPlan } from "../transition/fix-plan.js";
|
|
6
|
+
import { validateCircuitBreakerState, validateRalphSession, normalizeRalphStatus, } from "../utils/validate.js";
|
|
7
|
+
const LOG_LINE_PATTERN = /^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] \[(\w+)\] (.+)$/;
|
|
8
|
+
const DEFAULT_MAX_LOG_LINES = 8;
|
|
9
|
+
const TAIL_BYTES = 4096;
|
|
10
|
+
export async function readDashboardState(projectDir) {
|
|
11
|
+
const [loop, circuitBreaker, stories, analysis, execution, session, recentLogs] = await Promise.all([
|
|
12
|
+
readLoopInfo(projectDir),
|
|
13
|
+
readCircuitBreakerInfo(projectDir),
|
|
14
|
+
readStoryProgress(projectDir),
|
|
15
|
+
readAnalysisInfo(projectDir),
|
|
16
|
+
readExecutionProgress(projectDir),
|
|
17
|
+
readSessionInfo(projectDir),
|
|
18
|
+
readRecentLogs(projectDir),
|
|
19
|
+
]);
|
|
20
|
+
const ralphCompleted = loop !== null && loop.status === "completed";
|
|
21
|
+
return {
|
|
22
|
+
loop,
|
|
23
|
+
circuitBreaker,
|
|
24
|
+
stories,
|
|
25
|
+
analysis,
|
|
26
|
+
execution,
|
|
27
|
+
session,
|
|
28
|
+
recentLogs,
|
|
29
|
+
ralphCompleted,
|
|
30
|
+
lastUpdated: new Date(),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export async function readLoopInfo(projectDir) {
|
|
34
|
+
try {
|
|
35
|
+
const data = await readJsonFile(join(projectDir, RALPH_DIR, "status.json"));
|
|
36
|
+
if (data === null)
|
|
37
|
+
return null;
|
|
38
|
+
const normalized = normalizeRalphStatus(data);
|
|
39
|
+
const lastAction = typeof data.last_action === "string" ? data.last_action : "";
|
|
40
|
+
const callsMadeThisHour = typeof data.calls_made_this_hour === "number" ? data.calls_made_this_hour : 0;
|
|
41
|
+
const maxCallsPerHour = typeof data.max_calls_per_hour === "number" ? data.max_calls_per_hour : 0;
|
|
42
|
+
return {
|
|
43
|
+
loopCount: normalized.loopCount,
|
|
44
|
+
status: normalized.status,
|
|
45
|
+
lastAction,
|
|
46
|
+
callsMadeThisHour,
|
|
47
|
+
maxCallsPerHour,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export async function readCircuitBreakerInfo(projectDir) {
|
|
55
|
+
try {
|
|
56
|
+
const data = await readJsonFile(join(projectDir, RALPH_DIR, ".circuit_breaker_state"));
|
|
57
|
+
if (data === null)
|
|
58
|
+
return null;
|
|
59
|
+
const validated = validateCircuitBreakerState(data);
|
|
60
|
+
const totalOpens = typeof data.total_opens === "number" ? data.total_opens : 0;
|
|
61
|
+
return {
|
|
62
|
+
state: validated.state,
|
|
63
|
+
consecutiveNoProgress: validated.consecutive_no_progress,
|
|
64
|
+
totalOpens,
|
|
65
|
+
reason: validated.reason,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export async function readStoryProgress(projectDir) {
|
|
73
|
+
let content;
|
|
74
|
+
try {
|
|
75
|
+
content = await readFile(join(projectDir, RALPH_DIR, "@fix_plan.md"), "utf-8");
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const items = parseFixPlan(content);
|
|
81
|
+
const completed = items.filter((item) => item.completed).length;
|
|
82
|
+
const total = items.length;
|
|
83
|
+
const nextItem = items.find((item) => !item.completed);
|
|
84
|
+
const nextStory = nextItem ? `Story ${nextItem.id}: ${nextItem.title ?? ""}`.trim() : null;
|
|
85
|
+
return { completed, total, nextStory };
|
|
86
|
+
}
|
|
87
|
+
export async function readAnalysisInfo(projectDir) {
|
|
88
|
+
try {
|
|
89
|
+
const data = await readJsonFile(join(projectDir, RALPH_DIR, ".response_analysis"));
|
|
90
|
+
if (data === null)
|
|
91
|
+
return null;
|
|
92
|
+
const analysis = data.analysis;
|
|
93
|
+
if (typeof analysis !== "object" || analysis === null)
|
|
94
|
+
return null;
|
|
95
|
+
const a = analysis;
|
|
96
|
+
const filesModified = typeof a.files_modified === "number" ? a.files_modified : 0;
|
|
97
|
+
const confidenceScore = typeof a.confidence_score === "number" ? a.confidence_score : 0;
|
|
98
|
+
const isTestOnly = typeof a.is_test_only === "boolean" ? a.is_test_only : false;
|
|
99
|
+
const isStuck = typeof a.is_stuck === "boolean" ? a.is_stuck : false;
|
|
100
|
+
const exitSignal = typeof a.exit_signal === "boolean" ? a.exit_signal : false;
|
|
101
|
+
const hasPermissionDenials = typeof a.has_permission_denials === "boolean" ? a.has_permission_denials : false;
|
|
102
|
+
const permissionDenialCount = typeof a.permission_denial_count === "number" ? a.permission_denial_count : 0;
|
|
103
|
+
return {
|
|
104
|
+
filesModified,
|
|
105
|
+
confidenceScore,
|
|
106
|
+
isTestOnly,
|
|
107
|
+
isStuck,
|
|
108
|
+
exitSignal,
|
|
109
|
+
hasPermissionDenials,
|
|
110
|
+
permissionDenialCount,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export async function readExecutionProgress(projectDir) {
|
|
118
|
+
try {
|
|
119
|
+
const data = await readJsonFile(join(projectDir, RALPH_DIR, "progress.json"));
|
|
120
|
+
if (data === null)
|
|
121
|
+
return null;
|
|
122
|
+
const status = typeof data.status === "string" ? data.status : "";
|
|
123
|
+
if (status !== "executing")
|
|
124
|
+
return null;
|
|
125
|
+
const elapsedSeconds = typeof data.elapsed_seconds === "number" ? data.elapsed_seconds : 0;
|
|
126
|
+
return { status, elapsedSeconds };
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export async function readSessionInfo(projectDir) {
|
|
133
|
+
try {
|
|
134
|
+
const data = await readJsonFile(join(projectDir, RALPH_DIR, ".ralph_session"));
|
|
135
|
+
if (data === null)
|
|
136
|
+
return null;
|
|
137
|
+
const validated = validateRalphSession(data);
|
|
138
|
+
return {
|
|
139
|
+
createdAt: validated.created_at,
|
|
140
|
+
lastUsed: validated.last_used,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
export async function readRecentLogs(projectDir, maxLines = DEFAULT_MAX_LOG_LINES) {
|
|
148
|
+
const logPath = join(projectDir, RALPH_DIR, "logs", "ralph.log");
|
|
149
|
+
let content;
|
|
150
|
+
try {
|
|
151
|
+
const fh = await open(logPath, "r");
|
|
152
|
+
try {
|
|
153
|
+
const stats = await fh.stat();
|
|
154
|
+
if (stats.size === 0) {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
if (stats.size <= TAIL_BYTES) {
|
|
158
|
+
content = await fh.readFile("utf-8");
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
const position = stats.size - TAIL_BYTES;
|
|
162
|
+
const buf = Buffer.alloc(TAIL_BYTES);
|
|
163
|
+
const { bytesRead } = await fh.read(buf, 0, TAIL_BYTES, position);
|
|
164
|
+
const raw = buf.toString("utf-8", 0, bytesRead);
|
|
165
|
+
const newlineIdx = raw.indexOf("\n");
|
|
166
|
+
content = newlineIdx >= 0 ? raw.slice(newlineIdx + 1) : raw;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
await fh.close();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
const lines = content.split(/\r?\n/).filter((line) => line.length > 0);
|
|
177
|
+
const tail = lines.slice(-maxLines);
|
|
178
|
+
const entries = [];
|
|
179
|
+
for (const line of tail) {
|
|
180
|
+
const match = LOG_LINE_PATTERN.exec(line);
|
|
181
|
+
if (match) {
|
|
182
|
+
entries.push({
|
|
183
|
+
timestamp: match[1],
|
|
184
|
+
level: match[2],
|
|
185
|
+
message: match[3],
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return entries;
|
|
190
|
+
}
|