Skip to content

Commit b0346f0

Browse files
robertherberclaude
andcommitted
feat: [US-018] - Create main entry point with interactive setup and main loop
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 070d3d0 commit b0346f0

1 file changed

Lines changed: 164 additions & 1 deletion

File tree

scripts/eternity-loop/index.ts

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,165 @@
11
#!/usr/bin/env bun
2-
// Entry point - to be implemented in US-018
2+
3+
import { parseArgs } from "util";
4+
import { isInsideSession, bootstrap, cleanupWorktree } from "./bootstrap";
5+
import { loadSettings, saveSettings } from "./config";
6+
import { log, logErr } from "./logger";
7+
import { LinearProvider } from "./providers/linear";
8+
import { ReviewWorkflow } from "./workflows/review";
9+
import { CiFixWorkflow } from "./workflows/ci-fix";
10+
import { NewFeatureWorkflow } from "./workflows/new-feature";
11+
import { runRalph } from "./ralph";
12+
import { loadSkillGuidelines } from "./prompts";
13+
import { join, dirname } from "node:path";
14+
import type { Settings, WorkflowContext } from "./types";
15+
import type { Workflow } from "./workflows/types";
16+
17+
// Export env vars for child processes
18+
process.env.DISABLE_PUSHOVER_NOTIFICATIONS = "true";
19+
process.env.RALPH_LOOP = "true";
20+
21+
const { values } = parseArgs({
22+
args: process.argv.slice(2),
23+
options: {
24+
"max-iterations": { type: "string", default: "50" },
25+
},
26+
allowPositionals: true,
27+
});
28+
29+
const maxIterations = parseInt(values["max-iterations"] ?? "50", 10);
30+
31+
// If not inside session, bootstrap into tmux
32+
if (!isInsideSession()) {
33+
await bootstrap(process.argv.slice(2));
34+
process.exit(0);
35+
}
36+
37+
// Register cleanup handlers
38+
const cleanup = async () => {
39+
log("[loop] Cleaning up worktree...");
40+
await cleanupWorktree();
41+
process.exit(0);
42+
};
43+
44+
process.on("SIGINT", cleanup);
45+
process.on("SIGTERM", cleanup);
46+
47+
// Determine working directory and repo root
48+
const workDir = process.cwd();
49+
const repoRoot = process.env.ETERNITY_LOOP_REPO_ROOT ?? workDir;
50+
51+
// Ensure settings
52+
async function ensureSettings(provider: LinearProvider): Promise<Settings> {
53+
let settings = await loadSettings(repoRoot);
54+
if (settings) {
55+
log("[loop] Loaded existing settings");
56+
return settings;
57+
}
58+
59+
log("[loop] No settings found, running interactive setup...");
60+
61+
const { select } = await import("@inquirer/prompts");
62+
const teamsAndProjects = await provider.fetchTeamsAndProjects();
63+
64+
let teamId: string;
65+
if (teamsAndProjects.length === 1) {
66+
teamId = teamsAndProjects[0].teamId;
67+
log(`[loop] Auto-selected team: ${teamsAndProjects[0].teamName}`);
68+
} else {
69+
teamId = await select({
70+
message: "Select a Linear team:",
71+
choices: teamsAndProjects.map((t) => ({ name: t.teamName, value: t.teamId })),
72+
});
73+
}
74+
75+
const team = teamsAndProjects.find((t) => t.teamId === teamId)!;
76+
let projectId = "";
77+
if (team.projects.length > 0) {
78+
projectId = await select({
79+
message: "Select a project (or none):",
80+
choices: [
81+
{ name: "(no project filter)", value: "" },
82+
...team.projects.map((p) => ({ name: p.name, value: p.id })),
83+
],
84+
});
85+
}
86+
87+
settings = {
88+
teamId,
89+
projectId,
90+
workingDirectory: workDir,
91+
};
92+
93+
await saveSettings(repoRoot, settings);
94+
log("[loop] Settings saved");
95+
return settings;
96+
}
97+
98+
// Main
99+
async function main() {
100+
const apiKey = process.env.LINEAR_API_KEY;
101+
if (!apiKey) {
102+
logErr("[loop] LINEAR_API_KEY not set");
103+
process.exit(1);
104+
}
105+
106+
const provider = new LinearProvider(apiKey);
107+
const settings = await ensureSettings(provider);
108+
109+
const ralphDir = join(workDir, "scripts/ralph");
110+
const promptsDir = join(dirname(import.meta.path), "eternity-loop-prompts");
111+
const skillGuidelines = await loadSkillGuidelines();
112+
113+
const ctx: WorkflowContext = {
114+
workDir,
115+
ralphDir,
116+
settings,
117+
tool: "claude",
118+
maxIterations,
119+
promptsDir,
120+
skillGuidelines,
121+
};
122+
123+
const workflows: Workflow[] = [
124+
new ReviewWorkflow(),
125+
new CiFixWorkflow(),
126+
new NewFeatureWorkflow(),
127+
].sort((a, b) => a.priority - b.priority);
128+
129+
log(`[loop] Starting eternity loop (max iterations per ralph run: ${maxIterations})`);
130+
131+
while (true) {
132+
let workFound = false;
133+
134+
for (const workflow of workflows) {
135+
log(`[loop] Checking workflow: ${workflow.name} (priority ${workflow.priority})`);
136+
137+
try {
138+
const issue = await workflow.check(ctx, provider);
139+
if (!issue) continue;
140+
141+
workFound = true;
142+
log(`[loop] Workflow "${workflow.name}" found work: ${issue.identifier} - ${issue.title}`);
143+
144+
await workflow.prepare(ctx, issue);
145+
const exitCode = await runRalph({ projectDir: workDir, maxIterations });
146+
await workflow.finalize(ctx, issue, exitCode);
147+
148+
log(`[loop] Workflow "${workflow.name}" completed for ${issue.identifier}`);
149+
break; // Restart workflow priority loop
150+
} catch (err) {
151+
logErr(`[loop] Error in workflow "${workflow.name}": ${err}`);
152+
}
153+
}
154+
155+
if (!workFound) {
156+
log("[loop] No work found. Sleeping 120s...");
157+
await Bun.sleep(120_000);
158+
}
159+
}
160+
}
161+
162+
main().catch((err) => {
163+
logErr(`[loop] Fatal error: ${err}`);
164+
process.exit(1);
165+
});

0 commit comments

Comments
 (0)