@towles/tool 0.0.18 → 0.0.41
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/LICENSE +21 -0
- package/LICENSE.md +9 -10
- package/README.md +121 -78
- package/bin/run.ts +5 -0
- package/package.json +63 -53
- package/patches/prompts.patch +34 -0
- package/src/commands/base.ts +42 -0
- package/src/commands/config.test.ts +15 -0
- package/src/commands/config.ts +43 -0
- package/src/commands/doctor.ts +133 -0
- package/src/commands/gh/branch-clean.ts +110 -0
- package/src/commands/gh/branch.test.ts +124 -0
- package/src/commands/gh/branch.ts +132 -0
- package/src/commands/gh/pr.ts +168 -0
- package/src/commands/index.ts +55 -0
- package/src/commands/install.ts +148 -0
- package/src/commands/journal/daily-notes.ts +66 -0
- package/src/commands/journal/meeting.ts +83 -0
- package/src/commands/journal/note.ts +83 -0
- package/src/commands/journal/utils.ts +399 -0
- package/src/commands/observe/graph.test.ts +89 -0
- package/src/commands/observe/graph.ts +1640 -0
- package/src/commands/observe/report.ts +166 -0
- package/src/commands/observe/session.ts +385 -0
- package/src/commands/observe/setup.ts +180 -0
- package/src/commands/observe/status.ts +146 -0
- package/src/commands/ralph/lib/execution.ts +302 -0
- package/src/commands/ralph/lib/formatter.ts +298 -0
- package/src/commands/ralph/lib/index.ts +4 -0
- package/src/commands/ralph/lib/marker.ts +108 -0
- package/src/commands/ralph/lib/state.ts +191 -0
- package/src/commands/ralph/marker/create.ts +23 -0
- package/src/commands/ralph/plan.ts +73 -0
- package/src/commands/ralph/progress.ts +44 -0
- package/src/commands/ralph/ralph.test.ts +673 -0
- package/src/commands/ralph/run.ts +408 -0
- package/src/commands/ralph/task/add.ts +105 -0
- package/src/commands/ralph/task/done.ts +73 -0
- package/src/commands/ralph/task/list.test.ts +48 -0
- package/src/commands/ralph/task/list.ts +110 -0
- package/src/commands/ralph/task/remove.ts +62 -0
- package/src/config/context.ts +7 -0
- package/src/config/settings.ts +155 -0
- package/src/constants.ts +3 -0
- package/src/types/journal.ts +16 -0
- package/src/utils/anthropic/types.ts +158 -0
- package/src/utils/date-utils.test.ts +96 -0
- package/src/utils/date-utils.ts +54 -0
- package/src/utils/exec.ts +8 -0
- package/src/utils/git/gh-cli-wrapper.test.ts +14 -0
- package/src/utils/git/gh-cli-wrapper.ts +54 -0
- package/src/utils/git/git-wrapper.test.ts +26 -0
- package/src/utils/git/git-wrapper.ts +15 -0
- package/src/utils/git/git.ts +25 -0
- package/src/utils/render.test.ts +71 -0
- package/src/utils/render.ts +34 -0
- package/dist/index.d.mts +0 -1
- package/dist/index.mjs +0 -794
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import type { RalphTask, TaskStatus, RalphState } from "./state.js";
|
|
3
|
+
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// Clipboard Utility
|
|
6
|
+
// ============================================================================
|
|
7
|
+
|
|
8
|
+
export function copyToClipboard(text: string): boolean {
|
|
9
|
+
try {
|
|
10
|
+
const platform = process.platform;
|
|
11
|
+
if (platform === "darwin") {
|
|
12
|
+
execFileSync("pbcopy", [], { input: text });
|
|
13
|
+
} else if (platform === "linux") {
|
|
14
|
+
// Try xclip first, then xsel
|
|
15
|
+
try {
|
|
16
|
+
execFileSync("xclip", ["-selection", "clipboard"], { input: text });
|
|
17
|
+
} catch {
|
|
18
|
+
execFileSync("xsel", ["--clipboard", "--input"], { input: text });
|
|
19
|
+
}
|
|
20
|
+
} else if (platform === "win32") {
|
|
21
|
+
execFileSync("clip", [], { input: text });
|
|
22
|
+
} else {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// Task Formatting
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
export function formatTasksForPrompt(tasks: RalphTask[]): string {
|
|
36
|
+
if (tasks.length === 0) {
|
|
37
|
+
return "No tasks.";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const statusIcon = (status: TaskStatus): string => {
|
|
41
|
+
switch (status) {
|
|
42
|
+
case "done":
|
|
43
|
+
return "✓";
|
|
44
|
+
case "ready":
|
|
45
|
+
return "○";
|
|
46
|
+
case "blocked":
|
|
47
|
+
return "⏸";
|
|
48
|
+
case "cancelled":
|
|
49
|
+
return "✗";
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const lines: string[] = [];
|
|
54
|
+
for (const t of tasks) {
|
|
55
|
+
const checkbox = t.status === "done" ? "[x]" : "[ ]";
|
|
56
|
+
lines.push(`- ${checkbox} #${t.id} ${t.description} \`${statusIcon(t.status)} ${t.status}\``);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return lines.join("\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Format tasks as markdown with checkboxes and status badges.
|
|
64
|
+
*/
|
|
65
|
+
export function formatTasksAsMarkdown(tasks: RalphTask[]): string {
|
|
66
|
+
if (tasks.length === 0) {
|
|
67
|
+
return "# Tasks\n\nNo tasks.\n";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const statusBadge = (status: TaskStatus): string => {
|
|
71
|
+
switch (status) {
|
|
72
|
+
case "done":
|
|
73
|
+
return "`✓ done`";
|
|
74
|
+
case "ready":
|
|
75
|
+
return "`○ ready`";
|
|
76
|
+
case "blocked":
|
|
77
|
+
return "`⏸ blocked`";
|
|
78
|
+
case "cancelled":
|
|
79
|
+
return "`✗ cancelled`";
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const ready = tasks.filter((t) => t.status === "ready");
|
|
84
|
+
const done = tasks.filter((t) => t.status === "done");
|
|
85
|
+
|
|
86
|
+
const lines: string[] = ["# Tasks", ""];
|
|
87
|
+
lines.push(
|
|
88
|
+
`**Total:** ${tasks.length} | **Done:** ${done.length} | **Ready:** ${ready.length}`,
|
|
89
|
+
"",
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
if (ready.length > 0) {
|
|
93
|
+
lines.push("## Ready", "");
|
|
94
|
+
for (const t of ready) {
|
|
95
|
+
lines.push(`- [ ] **#${t.id}** ${t.description} ${statusBadge(t.status)}`);
|
|
96
|
+
}
|
|
97
|
+
lines.push("");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (done.length > 0) {
|
|
101
|
+
lines.push("## Done", "");
|
|
102
|
+
for (const t of done) {
|
|
103
|
+
lines.push(`- [x] **#${t.id}** ${t.description} ${statusBadge(t.status)}`);
|
|
104
|
+
}
|
|
105
|
+
lines.push("");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return lines.join("\n");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Format tasks as a plan with markdown and optional mermaid graph.
|
|
113
|
+
*/
|
|
114
|
+
export function formatPlanAsMarkdown(tasks: RalphTask[], state: RalphState): string {
|
|
115
|
+
const lines: string[] = ["# Ralph Plan", ""];
|
|
116
|
+
|
|
117
|
+
// Summary section
|
|
118
|
+
const ready = tasks.filter((t) => t.status === "ready").length;
|
|
119
|
+
const done = tasks.filter((t) => t.status === "done").length;
|
|
120
|
+
|
|
121
|
+
lines.push("## Summary", "");
|
|
122
|
+
lines.push(`- **Status:** ${state.status}`);
|
|
123
|
+
lines.push(`- **Iteration:** ${state.iteration}/${state.maxIterations}`);
|
|
124
|
+
lines.push(`- **Total Tasks:** ${tasks.length}`);
|
|
125
|
+
lines.push(`- **Done:** ${done} | **Ready:** ${ready}`);
|
|
126
|
+
if (state.sessionId) {
|
|
127
|
+
lines.push(`- **Session ID:** ${state.sessionId.slice(0, 8)}...`);
|
|
128
|
+
}
|
|
129
|
+
lines.push("");
|
|
130
|
+
|
|
131
|
+
// Tasks section with checkboxes
|
|
132
|
+
lines.push("## Tasks", "");
|
|
133
|
+
for (const t of tasks) {
|
|
134
|
+
const checkbox = t.status === "done" ? "[x]" : "[ ]";
|
|
135
|
+
const status = t.status === "done" ? "`done`" : "`ready`";
|
|
136
|
+
lines.push(`- ${checkbox} **#${t.id}** ${t.description} ${status}`);
|
|
137
|
+
}
|
|
138
|
+
lines.push("");
|
|
139
|
+
|
|
140
|
+
// Mermaid graph section
|
|
141
|
+
lines.push("## Progress Graph", "");
|
|
142
|
+
lines.push("```mermaid");
|
|
143
|
+
lines.push("graph LR");
|
|
144
|
+
lines.push(` subgraph Progress["Tasks: ${done}/${tasks.length} done"]`);
|
|
145
|
+
|
|
146
|
+
for (const t of tasks) {
|
|
147
|
+
const shortDesc =
|
|
148
|
+
t.description.length > 30 ? t.description.slice(0, 27) + "..." : t.description;
|
|
149
|
+
// Escape quotes in descriptions
|
|
150
|
+
const safeDesc = shortDesc.replace(/"/g, "'");
|
|
151
|
+
const nodeId = `T${t.id}`;
|
|
152
|
+
|
|
153
|
+
if (t.status === "done") {
|
|
154
|
+
lines.push(` ${nodeId}["#${t.id}: ${safeDesc}"]:::done`);
|
|
155
|
+
} else {
|
|
156
|
+
lines.push(` ${nodeId}["#${t.id}: ${safeDesc}"]:::ready`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
lines.push(" end");
|
|
161
|
+
lines.push(" classDef done fill:#22c55e,color:#fff");
|
|
162
|
+
lines.push(" classDef ready fill:#94a3b8,color:#000");
|
|
163
|
+
lines.push("```");
|
|
164
|
+
lines.push("");
|
|
165
|
+
|
|
166
|
+
return lines.join("\n");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Format tasks as JSON for programmatic consumption.
|
|
171
|
+
*/
|
|
172
|
+
export function formatPlanAsJson(tasks: RalphTask[], state: RalphState): string {
|
|
173
|
+
return JSON.stringify(
|
|
174
|
+
{
|
|
175
|
+
status: state.status,
|
|
176
|
+
iteration: state.iteration,
|
|
177
|
+
maxIterations: state.maxIterations,
|
|
178
|
+
sessionId: state.sessionId,
|
|
179
|
+
summary: {
|
|
180
|
+
total: tasks.length,
|
|
181
|
+
done: tasks.filter((t) => t.status === "done").length,
|
|
182
|
+
ready: tasks.filter((t) => t.status === "ready").length,
|
|
183
|
+
},
|
|
184
|
+
tasks: tasks.map((t) => ({
|
|
185
|
+
id: t.id,
|
|
186
|
+
description: t.description,
|
|
187
|
+
status: t.status,
|
|
188
|
+
addedAt: t.addedAt,
|
|
189
|
+
completedAt: t.completedAt,
|
|
190
|
+
})),
|
|
191
|
+
},
|
|
192
|
+
null,
|
|
193
|
+
2,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ============================================================================
|
|
198
|
+
// Duration Formatting
|
|
199
|
+
// ============================================================================
|
|
200
|
+
|
|
201
|
+
export function formatDuration(ms: number): string {
|
|
202
|
+
const seconds = Math.floor(ms / 1000);
|
|
203
|
+
const minutes = Math.floor(seconds / 60);
|
|
204
|
+
const hours = Math.floor(minutes / 60);
|
|
205
|
+
|
|
206
|
+
if (hours > 0) {
|
|
207
|
+
const remainingMins = minutes % 60;
|
|
208
|
+
return `${hours}h ${remainingMins}m`;
|
|
209
|
+
}
|
|
210
|
+
if (minutes > 0) {
|
|
211
|
+
const remainingSecs = seconds % 60;
|
|
212
|
+
return `${minutes}m ${remainingSecs}s`;
|
|
213
|
+
}
|
|
214
|
+
return `${seconds}s`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ============================================================================
|
|
218
|
+
// Output Summary
|
|
219
|
+
// ============================================================================
|
|
220
|
+
|
|
221
|
+
export function extractOutputSummary(output: string, maxLength: number = 2000): string {
|
|
222
|
+
const lines = output
|
|
223
|
+
.split("\n")
|
|
224
|
+
.filter((l) => l.trim())
|
|
225
|
+
.slice(-5);
|
|
226
|
+
let summary = lines.join(" ").trim();
|
|
227
|
+
|
|
228
|
+
if (summary.length > maxLength) {
|
|
229
|
+
summary = summary.substring(0, maxLength) + "...";
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return summary || "(no output)";
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ============================================================================
|
|
236
|
+
// Prompt Building
|
|
237
|
+
// ============================================================================
|
|
238
|
+
|
|
239
|
+
export interface BuildPromptOptions {
|
|
240
|
+
completionMarker: string;
|
|
241
|
+
progressFile: string;
|
|
242
|
+
focusedTaskId: number | null;
|
|
243
|
+
skipCommit?: boolean;
|
|
244
|
+
progressContent?: string;
|
|
245
|
+
taskList: string;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function buildIterationPrompt({
|
|
249
|
+
completionMarker,
|
|
250
|
+
progressFile,
|
|
251
|
+
focusedTaskId,
|
|
252
|
+
skipCommit = false,
|
|
253
|
+
progressContent,
|
|
254
|
+
taskList,
|
|
255
|
+
}: BuildPromptOptions): string {
|
|
256
|
+
// prompt inspired by https://www.aihero.dev/tips-for-ai-coding-with-ralph-wiggum#2-start-with-hitl-then-go-afk
|
|
257
|
+
|
|
258
|
+
let step = 1;
|
|
259
|
+
|
|
260
|
+
//IMPORTANT Always tell it to APPEND to progress file, save a lot of tokens by not reading it to update.
|
|
261
|
+
|
|
262
|
+
const prompt = `
|
|
263
|
+
<input-current-tasks>
|
|
264
|
+
${taskList}
|
|
265
|
+
</input-current-tasks>
|
|
266
|
+
|
|
267
|
+
<instructions>
|
|
268
|
+
${step++}. ${
|
|
269
|
+
focusedTaskId
|
|
270
|
+
? `**Work on Task #${focusedTaskId}** (you've been asked to focus on this one).`
|
|
271
|
+
: `**Choose** which ready task to work on next based on YOUR judgment of priority/dependencies.`
|
|
272
|
+
}
|
|
273
|
+
${step++}. Work on that single task.
|
|
274
|
+
${step++}. Run type checks and tests.
|
|
275
|
+
${step++}. Mark the task done using CLI: \`tt ralph task done <id>\`
|
|
276
|
+
${step++}. Append to @${progressFile} with what you did.
|
|
277
|
+
${skipCommit ? "" : `${step++}. Make a git commit.`}
|
|
278
|
+
|
|
279
|
+
**ONE TASK PER ITERATION**
|
|
280
|
+
|
|
281
|
+
**Before ending:** Run \`tt ralph task list\` to check remaining tasks.
|
|
282
|
+
**ONLY if ALL TASKS are done** then Output: <promise>${completionMarker}</promise>
|
|
283
|
+
</instructions>
|
|
284
|
+
|
|
285
|
+
<prior-context note="Reference only - these tasks are already completed, do not work on them">
|
|
286
|
+
${progressContent || "(No prior progress)"}
|
|
287
|
+
</prior-context>
|
|
288
|
+
`;
|
|
289
|
+
return prompt.trim();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ============================================================================
|
|
293
|
+
// Marker Detection
|
|
294
|
+
// ============================================================================
|
|
295
|
+
|
|
296
|
+
export function detectCompletionMarker(output: string, marker: string): boolean {
|
|
297
|
+
return output.includes(marker);
|
|
298
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import * as readline from "node:readline";
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// Constants
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
export const MARKER_PREFIX = "RALPH_MARKER_";
|
|
11
|
+
const CLAUDE_DIR = path.join(homedir(), ".claude");
|
|
12
|
+
const PROJECTS_DIR = path.join(CLAUDE_DIR, "projects");
|
|
13
|
+
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// Marker Generation
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Generate a random marker string (8 chars alphanumeric)
|
|
20
|
+
*/
|
|
21
|
+
export function generateMarker(): string {
|
|
22
|
+
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
23
|
+
let result = "";
|
|
24
|
+
for (let i = 0; i < 8; i++) {
|
|
25
|
+
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// Session Search
|
|
32
|
+
// ============================================================================
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get the project directory path for the current working directory
|
|
36
|
+
*/
|
|
37
|
+
function getProjectDir(): string | null {
|
|
38
|
+
const cwd = process.cwd();
|
|
39
|
+
// Claude stores projects with path separators replaced by dashes
|
|
40
|
+
const projectName = cwd.replace(/\//g, "-");
|
|
41
|
+
const projectDir = path.join(PROJECTS_DIR, projectName);
|
|
42
|
+
|
|
43
|
+
if (fs.existsSync(projectDir)) {
|
|
44
|
+
return projectDir;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Search for a marker in session files and return the session ID.
|
|
51
|
+
* Expects the full marker (e.g., RALPH_MARKER_abc123).
|
|
52
|
+
*/
|
|
53
|
+
export async function findSessionByMarker(marker: string): Promise<string | null> {
|
|
54
|
+
const projectDir = getProjectDir();
|
|
55
|
+
if (!projectDir) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Get all .jsonl files in the project directory
|
|
60
|
+
const files = fs
|
|
61
|
+
.readdirSync(projectDir)
|
|
62
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
63
|
+
.map((f) => ({
|
|
64
|
+
name: f,
|
|
65
|
+
path: path.join(projectDir, f),
|
|
66
|
+
mtime: fs.statSync(path.join(projectDir, f)).mtime.getTime(),
|
|
67
|
+
}))
|
|
68
|
+
// Sort by most recent first
|
|
69
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
70
|
+
|
|
71
|
+
for (const file of files) {
|
|
72
|
+
const found = await searchFileForMarker(file.path, marker);
|
|
73
|
+
if (found) {
|
|
74
|
+
// Session ID is the filename without .jsonl
|
|
75
|
+
return file.name.replace(".jsonl", "");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Search a single file for the marker (streaming to handle large files)
|
|
84
|
+
*/
|
|
85
|
+
async function searchFileForMarker(filePath: string, marker: string): Promise<boolean> {
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
const stream = fs.createReadStream(filePath, { encoding: "utf8" });
|
|
88
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
89
|
+
|
|
90
|
+
let found = false;
|
|
91
|
+
|
|
92
|
+
rl.on("line", (line) => {
|
|
93
|
+
if (line.includes(marker)) {
|
|
94
|
+
found = true;
|
|
95
|
+
rl.close();
|
|
96
|
+
stream.destroy();
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
rl.on("close", () => {
|
|
101
|
+
resolve(found);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
rl.on("error", () => {
|
|
105
|
+
resolve(false);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import pc from "picocolors";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import type { RalphSettings } from "../../../config/settings";
|
|
6
|
+
import { RalphSettingsSchema } from "../../../config/settings";
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// Constants
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_MAX_ITERATIONS = 10;
|
|
13
|
+
export const DEFAULT_STATE_DIR = "./.claude/.ralph";
|
|
14
|
+
export const DEFAULT_COMPLETION_MARKER = "RALPH_DONE";
|
|
15
|
+
|
|
16
|
+
// File names within stateDir
|
|
17
|
+
const STATE_FILE_NAME = "ralph-state.local.json";
|
|
18
|
+
const LOG_FILE_NAME = "ralph-log.local.md";
|
|
19
|
+
const PROGRESS_FILE_NAME = "ralph-progress.local.md";
|
|
20
|
+
const HISTORY_FILE_NAME = "ralph-history.local.log";
|
|
21
|
+
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// Path Helpers (use stateDir from settings)
|
|
24
|
+
// ============================================================================
|
|
25
|
+
|
|
26
|
+
export function getRalphPaths(settings?: RalphSettings) {
|
|
27
|
+
const stateDir = settings?.stateDir ?? RalphSettingsSchema.parse({}).stateDir;
|
|
28
|
+
return {
|
|
29
|
+
stateFile: path.join(stateDir, STATE_FILE_NAME),
|
|
30
|
+
logFile: path.join(stateDir, LOG_FILE_NAME),
|
|
31
|
+
progressFile: path.join(stateDir, PROGRESS_FILE_NAME),
|
|
32
|
+
historyFile: path.join(stateDir, HISTORY_FILE_NAME),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Legacy defaults for backwards compatibility (used in flag descriptions)
|
|
37
|
+
export const DEFAULT_STATE_FILE = `${DEFAULT_STATE_DIR}/${STATE_FILE_NAME}`;
|
|
38
|
+
export const DEFAULT_LOG_FILE = `${DEFAULT_STATE_DIR}/${LOG_FILE_NAME}`;
|
|
39
|
+
export const DEFAULT_PROGRESS_FILE = `${DEFAULT_STATE_DIR}/${PROGRESS_FILE_NAME}`;
|
|
40
|
+
export const DEFAULT_HISTORY_FILE = `${DEFAULT_STATE_DIR}/${HISTORY_FILE_NAME}`;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve ralph file path - uses flag value if provided, otherwise computes from settings
|
|
44
|
+
*/
|
|
45
|
+
export function resolveRalphPath(
|
|
46
|
+
flagValue: string | undefined,
|
|
47
|
+
pathType: "stateFile" | "logFile" | "progressFile" | "historyFile",
|
|
48
|
+
settings?: RalphSettings,
|
|
49
|
+
): string {
|
|
50
|
+
if (flagValue !== undefined) {
|
|
51
|
+
return flagValue;
|
|
52
|
+
}
|
|
53
|
+
const paths = getRalphPaths(settings);
|
|
54
|
+
return paths[pathType];
|
|
55
|
+
}
|
|
56
|
+
export const CLAUDE_DEFAULT_ARGS = [
|
|
57
|
+
"--print",
|
|
58
|
+
"--verbose",
|
|
59
|
+
"--output-format",
|
|
60
|
+
"stream-json",
|
|
61
|
+
"--permission-mode",
|
|
62
|
+
"bypassPermissions",
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
// ============================================================================
|
|
66
|
+
// State Validation Schemas
|
|
67
|
+
// ============================================================================
|
|
68
|
+
|
|
69
|
+
const TaskStatusSchema = z.enum(["ready", "done", "blocked", "cancelled"]);
|
|
70
|
+
|
|
71
|
+
const RalphTaskSchema = z.object({
|
|
72
|
+
id: z.number(),
|
|
73
|
+
description: z.string(),
|
|
74
|
+
status: TaskStatusSchema,
|
|
75
|
+
addedAt: z.string(),
|
|
76
|
+
completedAt: z.string().optional(),
|
|
77
|
+
sessionId: z.string().optional(),
|
|
78
|
+
marker: z.string().optional(),
|
|
79
|
+
label: z.string().optional(),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const RalphStateSchema = z.object({
|
|
83
|
+
version: z.number(),
|
|
84
|
+
tasks: z.array(RalphTaskSchema),
|
|
85
|
+
startedAt: z.string(),
|
|
86
|
+
iteration: z.number(),
|
|
87
|
+
maxIterations: z.number(),
|
|
88
|
+
status: z.enum(["running", "completed", "max_iterations_reached", "error"]),
|
|
89
|
+
sessionId: z.string().optional(),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ============================================================================
|
|
93
|
+
// Types (derived from Zod schemas)
|
|
94
|
+
// ============================================================================
|
|
95
|
+
|
|
96
|
+
export interface IterationHistory {
|
|
97
|
+
iteration: number;
|
|
98
|
+
startedAt: string;
|
|
99
|
+
completedAt: string;
|
|
100
|
+
durationMs: number;
|
|
101
|
+
durationHuman: string;
|
|
102
|
+
outputSummary: string;
|
|
103
|
+
markerFound: boolean;
|
|
104
|
+
contextUsedPercent?: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type TaskStatus = z.infer<typeof TaskStatusSchema>;
|
|
108
|
+
export type RalphTask = z.infer<typeof RalphTaskSchema>;
|
|
109
|
+
export type RalphState = z.infer<typeof RalphStateSchema>;
|
|
110
|
+
|
|
111
|
+
// ============================================================================
|
|
112
|
+
// State Management
|
|
113
|
+
// ============================================================================
|
|
114
|
+
|
|
115
|
+
export function createInitialState(maxIterations: number): RalphState {
|
|
116
|
+
return {
|
|
117
|
+
version: 1,
|
|
118
|
+
tasks: [],
|
|
119
|
+
startedAt: new Date().toISOString(),
|
|
120
|
+
iteration: 0,
|
|
121
|
+
maxIterations,
|
|
122
|
+
status: "running",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Append iteration history as a JSON line to the history log file.
|
|
128
|
+
* Each line is a complete JSON object for easy parsing.
|
|
129
|
+
*/
|
|
130
|
+
export function appendHistory(
|
|
131
|
+
history: IterationHistory,
|
|
132
|
+
historyFile: string = DEFAULT_HISTORY_FILE,
|
|
133
|
+
): void {
|
|
134
|
+
fs.mkdirSync(path.dirname(historyFile), { recursive: true });
|
|
135
|
+
const line = JSON.stringify(history) + "\n";
|
|
136
|
+
fs.appendFileSync(historyFile, line);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function saveState(state: RalphState, stateFile: string): void {
|
|
140
|
+
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
|
|
141
|
+
fs.writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function loadState(stateFile: string): RalphState | null {
|
|
145
|
+
try {
|
|
146
|
+
if (!fs.existsSync(stateFile)) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
const content = fs.readFileSync(stateFile, "utf-8");
|
|
150
|
+
const parsed = JSON.parse(content);
|
|
151
|
+
|
|
152
|
+
// Ensure tasks array exists for backwards compatibility
|
|
153
|
+
if (!parsed.tasks) {
|
|
154
|
+
parsed.tasks = [];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const result = RalphStateSchema.safeParse(parsed);
|
|
158
|
+
if (!result.success) {
|
|
159
|
+
const errors = result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ");
|
|
160
|
+
console.warn(pc.yellow(`Warning: Invalid state file ${stateFile}: ${errors}`));
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
return result.data;
|
|
164
|
+
} catch (err) {
|
|
165
|
+
console.warn(pc.yellow(`Warning: Failed to load state file ${stateFile}: ${err}`));
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function addTaskToState(
|
|
171
|
+
state: RalphState,
|
|
172
|
+
description: string,
|
|
173
|
+
sessionId?: string,
|
|
174
|
+
marker?: string,
|
|
175
|
+
label?: string,
|
|
176
|
+
): RalphTask {
|
|
177
|
+
const nextId = state.tasks.length > 0 ? Math.max(...state.tasks.map((t) => t.id)) + 1 : 1;
|
|
178
|
+
|
|
179
|
+
const newTask: RalphTask = {
|
|
180
|
+
id: nextId,
|
|
181
|
+
description,
|
|
182
|
+
status: "ready",
|
|
183
|
+
addedAt: new Date().toISOString(),
|
|
184
|
+
...(sessionId && { sessionId }),
|
|
185
|
+
...(marker && { marker }),
|
|
186
|
+
...(label && { label }),
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
state.tasks.push(newTask);
|
|
190
|
+
return newTask;
|
|
191
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { BaseCommand } from "../../base.js";
|
|
2
|
+
import { generateMarker, MARKER_PREFIX } from "../lib/marker.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generate a random marker for session tracking.
|
|
6
|
+
* Output this marker during research, then use --findMarker when adding tasks.
|
|
7
|
+
*/
|
|
8
|
+
export default class MarkerCreate extends BaseCommand {
|
|
9
|
+
static override description = "Generate a random marker for session tracking";
|
|
10
|
+
|
|
11
|
+
static override examples = ["<%= config.bin %> ralph marker create"];
|
|
12
|
+
|
|
13
|
+
static override flags = {
|
|
14
|
+
...BaseCommand.baseFlags,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
async run(): Promise<void> {
|
|
18
|
+
await this.parse(MarkerCreate);
|
|
19
|
+
|
|
20
|
+
const marker = generateMarker();
|
|
21
|
+
console.log(`${MARKER_PREFIX}${marker}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Flags } from "@oclif/core";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { BaseCommand } from "../base.js";
|
|
4
|
+
import { DEFAULT_STATE_FILE, loadState, resolveRalphPath } from "./lib/state.js";
|
|
5
|
+
import { formatPlanAsMarkdown, formatPlanAsJson, copyToClipboard } from "./lib/formatter.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Show plan summary with status, tasks, and mermaid graph
|
|
9
|
+
*/
|
|
10
|
+
export default class Plan extends BaseCommand {
|
|
11
|
+
static override description = "Show plan summary with status, tasks, and mermaid graph";
|
|
12
|
+
|
|
13
|
+
static override examples = [
|
|
14
|
+
"<%= config.bin %> ralph plan",
|
|
15
|
+
"<%= config.bin %> ralph plan --format json",
|
|
16
|
+
"<%= config.bin %> ralph plan --copy",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
static override flags = {
|
|
20
|
+
...BaseCommand.baseFlags,
|
|
21
|
+
stateFile: Flags.string({
|
|
22
|
+
char: "s",
|
|
23
|
+
description: `State file path (default: ${DEFAULT_STATE_FILE})`,
|
|
24
|
+
}),
|
|
25
|
+
format: Flags.string({
|
|
26
|
+
char: "f",
|
|
27
|
+
description: "Output format",
|
|
28
|
+
default: "default",
|
|
29
|
+
options: ["default", "markdown", "json"],
|
|
30
|
+
}),
|
|
31
|
+
copy: Flags.boolean({
|
|
32
|
+
description: "Copy output to clipboard",
|
|
33
|
+
default: false,
|
|
34
|
+
}),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
async run(): Promise<void> {
|
|
38
|
+
const { flags } = await this.parse(Plan);
|
|
39
|
+
const ralphSettings = this.settings.settingsFile.settings.ralphSettings;
|
|
40
|
+
const stateFile = resolveRalphPath(flags.stateFile, "stateFile", ralphSettings);
|
|
41
|
+
|
|
42
|
+
const state = loadState(stateFile);
|
|
43
|
+
|
|
44
|
+
if (!state) {
|
|
45
|
+
console.log(pc.yellow(`No state file found at: ${stateFile}`));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (state.tasks.length === 0) {
|
|
50
|
+
console.log(pc.yellow("No tasks in state file."));
|
|
51
|
+
console.log(pc.dim('Use: tt ralph task add "description"'));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let output: string;
|
|
56
|
+
|
|
57
|
+
if (flags.format === "json") {
|
|
58
|
+
output = formatPlanAsJson(state.tasks, state);
|
|
59
|
+
} else {
|
|
60
|
+
output = formatPlanAsMarkdown(state.tasks, state);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log(output);
|
|
64
|
+
|
|
65
|
+
if (flags.copy) {
|
|
66
|
+
if (copyToClipboard(output)) {
|
|
67
|
+
console.log(pc.green("✓ Copied to clipboard"));
|
|
68
|
+
} else {
|
|
69
|
+
console.log(pc.yellow("⚠ Could not copy to clipboard (xclip/xsel not installed?)"));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|