@neriros/ralphy 2.11.0 → 2.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli/index.js +876 -780
  2. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -50837,7 +50837,7 @@ var require_axios = __commonJS((exports, module) => {
50837
50837
  });
50838
50838
 
50839
50839
  // apps/cli/src/index.ts
50840
- import { resolve, join as join17, dirname as dirname5 } from "path";
50840
+ import { resolve, join as join19, dirname as dirname5 } from "path";
50841
50841
  import { exists as exists2, mkdir as mkdir4, rm } from "fs/promises";
50842
50842
 
50843
50843
  // node_modules/.bun/ink@5.2.1+1f88f629f0141b18/node_modules/ink/build/render.js
@@ -56407,7 +56407,7 @@ function log(msg) {
56407
56407
  // package.json
56408
56408
  var package_default = {
56409
56409
  name: "@neriros/ralphy",
56410
- version: "2.11.0",
56410
+ version: "2.11.2",
56411
56411
  description: "An iterative AI task execution framework. Orchestrates multi-phase autonomous work using Claude or Codex engines.",
56412
56412
  keywords: [
56413
56413
  "agent",
@@ -56879,7 +56879,7 @@ function createDefaultContext() {
56879
56879
 
56880
56880
  // apps/cli/src/components/App.tsx
56881
56881
  var import_react58 = __toESM(require_react(), 1);
56882
- import { join as join16 } from "path";
56882
+ import { join as join18 } from "path";
56883
56883
 
56884
56884
  // packages/core/src/state.ts
56885
56885
  import { join as join2 } from "path";
@@ -65702,11 +65702,16 @@ async function runEngine(opts) {
65702
65702
  stderr: isClaude ? "inherit" : "pipe",
65703
65703
  ...opts.cwd ? { cwd: opts.cwd } : {}
65704
65704
  });
65705
+ let intentionalKill = false;
65706
+ const killProc = () => {
65707
+ intentionalKill = true;
65708
+ proc.kill();
65709
+ };
65705
65710
  if (opts.signal) {
65706
65711
  if (opts.signal.aborted) {
65707
- proc.kill();
65712
+ killProc();
65708
65713
  } else {
65709
- opts.signal.addEventListener("abort", () => proc.kill(), { once: true });
65714
+ opts.signal.addEventListener("abort", killProc, { once: true });
65710
65715
  }
65711
65716
  }
65712
65717
  const stdin = proc.stdin;
@@ -65742,7 +65747,7 @@ async function runEngine(opts) {
65742
65747
  if (opts.signal) {
65743
65748
  const onAbort = () => {
65744
65749
  aborted = true;
65745
- proc.kill();
65750
+ killProc();
65746
65751
  };
65747
65752
  if (opts.signal.aborted) {
65748
65753
  onAbort();
@@ -65778,7 +65783,7 @@ async function runEngine(opts) {
65778
65783
  emitEvent(event);
65779
65784
  }
65780
65785
  if (claudeState.gotResult) {
65781
- proc.kill();
65786
+ killProc();
65782
65787
  break;
65783
65788
  }
65784
65789
  }
@@ -65807,7 +65812,7 @@ async function runEngine(opts) {
65807
65812
  }
65808
65813
  await closeRaw();
65809
65814
  const exitCode = await proc.exited;
65810
- const wasIntentionalKill = (exitCode === 143 || exitCode === 137) && (usage !== null || aborted);
65815
+ const wasIntentionalKill = intentionalKill && (exitCode === 143 || exitCode === 137);
65811
65816
  const normalizedExitCode = wasIntentionalKill ? 0 : exitCode;
65812
65817
  return { exitCode: normalizedExitCode, usage, sessionId, rateLimited: detectedRateLimit };
65813
65818
  }
@@ -69477,8 +69482,9 @@ async function shutdown() {
69477
69482
 
69478
69483
  // packages/core/src/loop.ts
69479
69484
  import { join as join7 } from "path";
69480
- var STEERING_MAX_LINES = 20;
69481
- function extractFirstUncheckedSection(tasksContent) {
69485
+
69486
+ // packages/core/src/tasks-md.ts
69487
+ function firstUnchecked(tasksContent) {
69482
69488
  const sections = tasksContent.split(/(?=^## )/m);
69483
69489
  for (const section of sections) {
69484
69490
  if (/^## /m.test(section) && /^- \[ \]/m.test(section))
@@ -69488,12 +69494,41 @@ function extractFirstUncheckedSection(tasksContent) {
69488
69494
  return tasksContent.trim();
69489
69495
  return null;
69490
69496
  }
69491
- function countUncheckedTasks(tasksContent) {
69497
+ function countUnchecked(tasksContent) {
69492
69498
  return (tasksContent.match(/^- \[ \]/gm) ?? []).length;
69493
69499
  }
69494
- function allTasksCompleted(tasksContent) {
69500
+ function allCompleted(tasksContent) {
69495
69501
  return !/^- \[ \]/m.test(tasksContent);
69496
69502
  }
69503
+ function prependSection(existing, heading, body) {
69504
+ const section = `## ${heading}
69505
+
69506
+ ${body.trimEnd()}
69507
+
69508
+ `;
69509
+ const headingIdx = existing.search(/^## /m);
69510
+ if (headingIdx === -1) {
69511
+ return existing.trimEnd() + (existing ? `
69512
+
69513
+ ` : "") + section;
69514
+ }
69515
+ return existing.slice(0, headingIdx) + section + existing.slice(headingIdx);
69516
+ }
69517
+ async function prependFixTask(tasksPath, heading, failureOutput) {
69518
+ const file = Bun.file(tasksPath);
69519
+ const existing = await file.exists() ? await file.text() : "";
69520
+ const stamped = `${heading} (${new Date().toISOString()})`;
69521
+ const fence = "```";
69522
+ const body = `- [ ] ${heading}. Read the error block below, fix the underlying ` + `problem (do not just retry the failing command), then check this box.
69523
+
69524
+ ` + `${fence}
69525
+ ${failureOutput.trim()}
69526
+ ${fence}`;
69527
+ await Bun.write(tasksPath, prependSection(existing, stamped, body));
69528
+ }
69529
+
69530
+ // packages/core/src/loop.ts
69531
+ var STEERING_MAX_LINES = 20;
69497
69532
  function buildTaskPrompt(state, taskDir) {
69498
69533
  const storage = getStorage();
69499
69534
  let prompt = "";
@@ -69518,7 +69553,7 @@ function buildTaskPrompt(state, taskDir) {
69518
69553
  }
69519
69554
  const tasksContent = storage.read(join7(taskDir, "tasks.md"));
69520
69555
  if (tasksContent !== null) {
69521
- const section = extractFirstUncheckedSection(tasksContent);
69556
+ const section = firstUnchecked(tasksContent);
69522
69557
  if (section) {
69523
69558
  prompt += `---
69524
69559
 
@@ -69753,10 +69788,10 @@ function useLoop(opts) {
69753
69788
  }
69754
69789
  const tasksContent = storage.read(join8(tasksDir, "tasks.md"));
69755
69790
  if (tasksContent !== null) {
69756
- const remaining = countUncheckedTasks(tasksContent);
69791
+ const remaining = countUnchecked(tasksContent);
69757
69792
  addInfo(`tasks.md: ${remaining} unchecked item${remaining === 1 ? "" : "s"} remaining`);
69758
69793
  }
69759
- if (tasksContent !== null && allTasksCompleted(tasksContent)) {
69794
+ if (tasksContent !== null && allCompleted(tasksContent)) {
69760
69795
  addInfo("All tasks completed \u2014 archiving change.");
69761
69796
  currentState = {
69762
69797
  ...currentState,
@@ -70069,6 +70104,167 @@ function TaskLoop({ opts }) {
70069
70104
 
70070
70105
  // apps/cli/src/components/AgentMode.tsx
70071
70106
  var import_react57 = __toESM(require_react(), 1);
70107
+ import { join as join16 } from "path";
70108
+
70109
+ // apps/cli/src/agent/state.ts
70110
+ import { join as join10 } from "path";
70111
+ var TaskStateSchema = exports_external.enum(["started", "processed", "failed"]);
70112
+ var TaskEntrySchema = exports_external.object({
70113
+ issueId: exports_external.string(),
70114
+ identifier: exports_external.string(),
70115
+ state: TaskStateSchema,
70116
+ changeName: exports_external.string().optional(),
70117
+ startedAt: exports_external.string().optional(),
70118
+ finishedAt: exports_external.string().optional(),
70119
+ exitCode: exports_external.number().optional(),
70120
+ commentPosted: exports_external.boolean().optional()
70121
+ });
70122
+ var AgentStateSchema = exports_external.object({
70123
+ tasks: exports_external.record(exports_external.string(), TaskEntrySchema).default({}),
70124
+ lastPollAt: exports_external.string().nullable().default(null)
70125
+ });
70126
+ function statePath(projectRoot) {
70127
+ return join10(projectRoot, ".ralph", "agent-state.json");
70128
+ }
70129
+ async function readState2(projectRoot) {
70130
+ const file = Bun.file(statePath(projectRoot));
70131
+ if (!await file.exists()) {
70132
+ return AgentStateSchema.parse({});
70133
+ }
70134
+ const raw = await file.json();
70135
+ return AgentStateSchema.parse(raw);
70136
+ }
70137
+ async function writeState2(projectRoot, state) {
70138
+ await Bun.write(statePath(projectRoot), JSON.stringify(state, null, 2) + `
70139
+ `);
70140
+ }
70141
+
70142
+ class AgentStateStore {
70143
+ projectRoot;
70144
+ state = null;
70145
+ constructor(projectRoot) {
70146
+ this.projectRoot = projectRoot;
70147
+ }
70148
+ async load() {
70149
+ this.state = await readState2(this.projectRoot);
70150
+ }
70151
+ snapshot() {
70152
+ if (!this.state) {
70153
+ throw new Error("AgentStateStore: load() must be called before snapshot()");
70154
+ }
70155
+ return this.state;
70156
+ }
70157
+ async upsertTask(issue, patch) {
70158
+ const s = this.snapshot();
70159
+ const existing = s.tasks[issue.identifier];
70160
+ s.tasks[issue.identifier] = {
70161
+ issueId: issue.id,
70162
+ identifier: issue.identifier,
70163
+ state: existing?.state ?? "started",
70164
+ ...existing,
70165
+ ...patch
70166
+ };
70167
+ await this.flush();
70168
+ }
70169
+ async setLastPollAt(when) {
70170
+ const s = this.snapshot();
70171
+ s.lastPollAt = when;
70172
+ await this.flush();
70173
+ }
70174
+ async removeByChangeName(changeName) {
70175
+ const s = this.snapshot();
70176
+ const entry = Object.values(s.tasks).find((t) => t.changeName === changeName);
70177
+ if (!entry)
70178
+ return null;
70179
+ delete s.tasks[entry.identifier];
70180
+ await this.flush();
70181
+ return { identifier: entry.identifier, issueId: entry.issueId };
70182
+ }
70183
+ async flush() {
70184
+ await writeState2(this.projectRoot, this.snapshot());
70185
+ }
70186
+ }
70187
+
70188
+ // apps/cli/src/agent/config.ts
70189
+ import { join as join11 } from "path";
70190
+ var RalphyConfigSchema = exports_external.object({
70191
+ concurrency: exports_external.number().int().positive().default(1),
70192
+ pollIntervalSeconds: exports_external.number().int().positive().default(60),
70193
+ maxIterationsPerTask: exports_external.number().int().nonnegative().default(0),
70194
+ maxCostUsdPerTask: exports_external.number().nonnegative().default(0),
70195
+ maxRuntimeMinutesPerTask: exports_external.number().nonnegative().default(0),
70196
+ maxConsecutiveFailuresPerTask: exports_external.number().int().nonnegative().default(5),
70197
+ iterationDelaySeconds: exports_external.number().int().nonnegative().default(0),
70198
+ logRawStream: exports_external.boolean().default(false),
70199
+ taskVerbose: exports_external.boolean().default(false),
70200
+ useWorktree: exports_external.boolean().default(false),
70201
+ cleanupWorktreeOnSuccess: exports_external.boolean().default(false),
70202
+ setupScript: exports_external.string().optional(),
70203
+ teardownScript: exports_external.string().optional(),
70204
+ appendPrompt: exports_external.string().optional(),
70205
+ createPrOnSuccess: exports_external.boolean().default(false),
70206
+ prBaseBranch: exports_external.string().default("main"),
70207
+ fixCiOnFailure: exports_external.boolean().default(false),
70208
+ maxCiFixAttempts: exports_external.number().int().positive().default(5),
70209
+ ciPollIntervalSeconds: exports_external.number().int().positive().default(30),
70210
+ engine: exports_external.enum(["claude", "codex"]).default("claude"),
70211
+ model: exports_external.enum(["haiku", "sonnet", "opus"]).default("opus"),
70212
+ linear: exports_external.object({
70213
+ team: exports_external.string().optional(),
70214
+ assignee: exports_external.string().optional(),
70215
+ statuses: exports_external.array(exports_external.string()).default([]),
70216
+ labels: exports_external.union([exports_external.array(exports_external.string()), exports_external.string()]).transform((v) => typeof v === "string" ? [v] : v).default([]),
70217
+ inProgressStatus: exports_external.string().optional(),
70218
+ doneStatus: exports_external.string().optional(),
70219
+ doneLabel: exports_external.string().optional(),
70220
+ postComments: exports_external.boolean().default(true),
70221
+ updateEveryIterations: exports_external.number().int().nonnegative().default(10)
70222
+ }).default({ statuses: [], labels: [], postComments: true })
70223
+ }).default({
70224
+ concurrency: 1,
70225
+ pollIntervalSeconds: 60,
70226
+ maxIterationsPerTask: 0,
70227
+ maxCostUsdPerTask: 0,
70228
+ engine: "claude",
70229
+ model: "opus",
70230
+ linear: { statuses: [], labels: [], postComments: true }
70231
+ });
70232
+ async function loadRalphyConfig(projectRoot) {
70233
+ const path = join11(projectRoot, "ralphy.config.json");
70234
+ const file = Bun.file(path);
70235
+ if (!await file.exists()) {
70236
+ return RalphyConfigSchema.parse({});
70237
+ }
70238
+ const raw = await file.json();
70239
+ return RalphyConfigSchema.parse(raw);
70240
+ }
70241
+ async function ensureRalphyConfig(projectRoot) {
70242
+ const path = join11(projectRoot, "ralphy.config.json");
70243
+ const file = Bun.file(path);
70244
+ if (await file.exists())
70245
+ return path;
70246
+ const defaults2 = RalphyConfigSchema.parse({});
70247
+ await Bun.write(path, JSON.stringify(defaults2, null, 2) + `
70248
+ `);
70249
+ return path;
70250
+ }
70251
+
70252
+ // packages/core/src/layout.ts
70253
+ import { join as join12 } from "path";
70254
+ var STATE_FILE2 = ".ralph-state.json";
70255
+ function projectLayout(root) {
70256
+ const statesDir = join12(root, ".ralph", "tasks");
70257
+ const tasksDir = join12(root, "openspec", "changes");
70258
+ return {
70259
+ root,
70260
+ statesDir,
70261
+ tasksDir,
70262
+ agentStateFile: join12(root, ".ralph", "agent-state.json"),
70263
+ changeDir: (name) => join12(tasksDir, name),
70264
+ taskStateDir: (name) => join12(statesDir, name),
70265
+ stateFile: (name) => join12(statesDir, name, STATE_FILE2)
70266
+ };
70267
+ }
70072
70268
 
70073
70269
  // apps/cli/src/agent/linear.ts
70074
70270
  var OPEN_STATE_TYPES = ["unstarted", "started", "backlog"];
@@ -70211,300 +70407,78 @@ async function addLabelToIssue(apiKey, issueId, labelId) {
70211
70407
  });
70212
70408
  }
70213
70409
 
70214
- // apps/cli/src/agent/state.ts
70215
- import { join as join10 } from "path";
70216
- var TaskStateSchema = exports_external.enum(["started", "processed", "failed"]);
70217
- var TaskEntrySchema = exports_external.object({
70218
- issueId: exports_external.string(),
70219
- identifier: exports_external.string(),
70220
- state: TaskStateSchema,
70221
- changeName: exports_external.string().optional(),
70222
- startedAt: exports_external.string().optional(),
70223
- finishedAt: exports_external.string().optional(),
70224
- exitCode: exports_external.number().optional(),
70225
- commentPosted: exports_external.boolean().optional()
70226
- });
70227
- var AgentStateSchema = exports_external.object({
70228
- tasks: exports_external.record(exports_external.string(), TaskEntrySchema).default({}),
70229
- lastPollAt: exports_external.string().nullable().default(null)
70230
- });
70231
- var LegacyAgentStateSchema = exports_external.object({
70232
- processedIssueIds: exports_external.array(exports_external.string()).default([]),
70233
- startedIssueIds: exports_external.array(exports_external.string()).default([]),
70234
- failedIssueIds: exports_external.array(exports_external.string()).default([]),
70235
- lastPollAt: exports_external.string().nullable().default(null),
70236
- changeMeta: exports_external.record(exports_external.string(), exports_external.object({ issueId: exports_external.string(), identifier: exports_external.string() })).default({})
70237
- }).partial();
70238
- function migrateLegacy(raw) {
70239
- const parsed = LegacyAgentStateSchema.safeParse(raw);
70240
- if (!parsed.success)
70241
- return AgentStateSchema.parse({});
70242
- const legacy = parsed.data;
70243
- const tasks = {};
70244
- const byIssueId = new Map;
70245
- for (const [changeName, meta] of Object.entries(legacy.changeMeta ?? {})) {
70246
- byIssueId.set(meta.issueId, { identifier: meta.identifier, changeName });
70247
- }
70248
- const fold = (ids, state) => {
70249
- for (const issueId of ids ?? []) {
70250
- const found = byIssueId.get(issueId);
70251
- if (!found)
70410
+ // apps/cli/src/agent/coordinator.ts
70411
+ class AgentCoordinator {
70412
+ deps;
70413
+ opts;
70414
+ workers = [];
70415
+ pendingIds = new Set;
70416
+ queue = [];
70417
+ stopped = false;
70418
+ constructor(deps, opts) {
70419
+ this.deps = deps;
70420
+ this.opts = opts;
70421
+ }
70422
+ get activeCount() {
70423
+ return this.workers.length;
70424
+ }
70425
+ get queuedCount() {
70426
+ return this.queue.length;
70427
+ }
70428
+ get activeWorkers() {
70429
+ return this.workers;
70430
+ }
70431
+ async init() {}
70432
+ async pollOnce() {
70433
+ if (this.stopped)
70434
+ return { found: 0, added: 0 };
70435
+ let issues;
70436
+ try {
70437
+ issues = await this.deps.fetchIssues(this.opts.filter);
70438
+ } catch (err) {
70439
+ this.deps.onLog(`! Linear poll failed: ${err.message}`, "red");
70440
+ return { found: 0, added: 0 };
70441
+ }
70442
+ const state = this.deps.store.snapshot();
70443
+ const tasksByIssueId = new Map;
70444
+ for (const entry of Object.values(state.tasks)) {
70445
+ tasksByIssueId.set(entry.issueId, entry);
70446
+ }
70447
+ const isProcessed = (id) => tasksByIssueId.get(id)?.state === "processed";
70448
+ const isFailed = (id) => tasksByIssueId.get(id)?.state === "failed";
70449
+ const queued = new Set(this.queue.map((i) => i.id));
70450
+ const active = new Set(this.workers.map((w) => w.issueId));
70451
+ let added = 0;
70452
+ for (const issue of issues) {
70453
+ if (isProcessed(issue.id))
70252
70454
  continue;
70253
- tasks[found.identifier] = {
70254
- issueId,
70255
- identifier: found.identifier,
70256
- state,
70257
- changeName: found.changeName
70258
- };
70455
+ if (isFailed(issue.id))
70456
+ continue;
70457
+ if (queued.has(issue.id))
70458
+ continue;
70459
+ if (active.has(issue.id))
70460
+ continue;
70461
+ if (this.pendingIds.has(issue.id))
70462
+ continue;
70463
+ const blocker = issue.blockedByIds.find((bid) => !isProcessed(bid));
70464
+ if (blocker !== undefined) {
70465
+ this.deps.onLog(` \u23F8 ${issue.identifier} skipped \u2014 blocked by unresolved dependency`, "yellow");
70466
+ continue;
70467
+ }
70468
+ this.queue.push(issue);
70469
+ added += 1;
70259
70470
  }
70260
- };
70261
- fold(legacy.startedIssueIds, "started");
70262
- fold(legacy.processedIssueIds, "processed");
70263
- fold(legacy.failedIssueIds, "failed");
70264
- for (const issueId of legacy.startedIssueIds ?? []) {
70265
- const found = byIssueId.get(issueId);
70266
- if (!found)
70267
- continue;
70268
- const entry = tasks[found.identifier];
70269
- if (entry)
70270
- entry.commentPosted = true;
70271
- }
70272
- return { tasks, lastPollAt: legacy.lastPollAt ?? null };
70273
- }
70274
- function statePath(projectRoot) {
70275
- return join10(projectRoot, ".ralph", "agent-state.json");
70276
- }
70277
- function looksLegacy(raw) {
70278
- if (typeof raw !== "object" || raw === null)
70279
- return false;
70280
- const r = raw;
70281
- return "processedIssueIds" in r || "startedIssueIds" in r || "failedIssueIds" in r || "changeMeta" in r;
70282
- }
70283
- async function readAgentState(projectRoot) {
70284
- const file = Bun.file(statePath(projectRoot));
70285
- if (!await file.exists()) {
70286
- return AgentStateSchema.parse({});
70287
- }
70288
- const raw = await file.json();
70289
- if (looksLegacy(raw))
70290
- return migrateLegacy(raw);
70291
- return AgentStateSchema.parse(raw);
70292
- }
70293
- async function writeAgentState(projectRoot, state) {
70294
- await Bun.write(statePath(projectRoot), JSON.stringify(state, null, 2) + `
70295
- `);
70296
- }
70297
-
70298
- // apps/cli/src/agent/scaffold.ts
70299
- import { join as join11 } from "path";
70300
- import { mkdir as mkdir2 } from "fs/promises";
70301
- function changeNameForIssue(issue) {
70302
- const slug = issue.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
70303
- return slug ? `${issue.identifier.toLowerCase()}-${slug}` : issue.identifier.toLowerCase();
70304
- }
70305
- async function scaffoldChangeForIssue(tasksDir, statesDir, issue, comments = [], appendPrompt = "") {
70306
- const name = changeNameForIssue(issue);
70307
- const changeDir = join11(tasksDir, name);
70308
- const stateDir = join11(statesDir, name);
70309
- await mkdir2(changeDir, { recursive: true });
70310
- await mkdir2(join11(changeDir, "specs"), { recursive: true });
70311
- await mkdir2(stateDir, { recursive: true });
70312
- const commentsBlock = comments.length > 0 ? [
70313
- "",
70314
- "## Linear comments",
70315
- "",
70316
- ...comments.flatMap((c) => [
70317
- `**${c.user?.name ?? "unknown"}** \u2014 ${c.createdAt}`,
70318
- "",
70319
- c.body.trim(),
70320
- ""
70321
- ])
70322
- ] : [];
70323
- const proposal = [
70324
- `# ${issue.identifier}: ${issue.title}`,
70325
- "",
70326
- `Source: [${issue.identifier}](${issue.url})`,
70327
- `Status: ${issue.state.name}`,
70328
- issue.assignee ? `Assignee: ${issue.assignee.name}` : "",
70329
- issue.labels.length ? `Labels: ${issue.labels.join(", ")}` : "",
70330
- "",
70331
- "## Description",
70332
- "",
70333
- issue.description?.trim() || "_No description provided in Linear._",
70334
- ...commentsBlock,
70335
- ...appendPrompt.trim() ? ["", "## Additional instructions", "", appendPrompt.trim()] : [],
70336
- "",
70337
- "## Steering",
70338
- "",
70339
- "_Add steering notes here as the loop runs._",
70340
- ""
70341
- ].filter((l) => l !== "").join(`
70342
- `);
70343
- const tasks = [
70344
- `# Tasks for ${issue.identifier}`,
70345
- "",
70346
- "## Subtasks",
70347
- "",
70348
- `- [ ] Read the Linear issue at ${issue.url} and break it into concrete subtasks`,
70349
- `- [ ] Implement the changes described in proposal.md`,
70350
- `- [ ] Add or update tests covering the new behavior`,
70351
- `- [ ] Run \`bun run lint\` and \`bun run test\` and fix any failures`,
70352
- ""
70353
- ].join(`
70354
- `);
70355
- const design = [
70356
- `# Design for ${issue.identifier}`,
70357
- "",
70358
- "_Fill in the technical design as you work through the issue._",
70359
- ""
70360
- ].join(`
70361
- `);
70362
- await Bun.write(join11(changeDir, "proposal.md"), proposal);
70363
- await Bun.write(join11(changeDir, "tasks.md"), tasks);
70364
- await Bun.write(join11(changeDir, "design.md"), design);
70365
- return name;
70366
- }
70367
-
70368
- // apps/cli/src/agent/config.ts
70369
- import { join as join12 } from "path";
70370
- var RalphyConfigSchema = exports_external.object({
70371
- concurrency: exports_external.number().int().positive().default(1),
70372
- pollIntervalSeconds: exports_external.number().int().positive().default(60),
70373
- maxIterationsPerTask: exports_external.number().int().nonnegative().default(0),
70374
- maxCostUsdPerTask: exports_external.number().nonnegative().default(0),
70375
- maxRuntimeMinutesPerTask: exports_external.number().nonnegative().default(0),
70376
- maxConsecutiveFailuresPerTask: exports_external.number().int().nonnegative().default(5),
70377
- iterationDelaySeconds: exports_external.number().int().nonnegative().default(0),
70378
- logRawStream: exports_external.boolean().default(false),
70379
- taskVerbose: exports_external.boolean().default(false),
70380
- useWorktree: exports_external.boolean().default(false),
70381
- cleanupWorktreeOnSuccess: exports_external.boolean().default(false),
70382
- setupScript: exports_external.string().optional(),
70383
- teardownScript: exports_external.string().optional(),
70384
- appendPrompt: exports_external.string().optional(),
70385
- createPrOnSuccess: exports_external.boolean().default(false),
70386
- prBaseBranch: exports_external.string().default("main"),
70387
- fixCiOnFailure: exports_external.boolean().default(false),
70388
- maxCiFixAttempts: exports_external.number().int().positive().default(5),
70389
- ciPollIntervalSeconds: exports_external.number().int().positive().default(30),
70390
- engine: exports_external.enum(["claude", "codex"]).default("claude"),
70391
- model: exports_external.enum(["haiku", "sonnet", "opus"]).default("opus"),
70392
- linear: exports_external.object({
70393
- team: exports_external.string().optional(),
70394
- assignee: exports_external.string().optional(),
70395
- statuses: exports_external.array(exports_external.string()).default([]),
70396
- labels: exports_external.union([exports_external.array(exports_external.string()), exports_external.string()]).transform((v) => typeof v === "string" ? [v] : v).default([]),
70397
- inProgressStatus: exports_external.string().optional(),
70398
- doneStatus: exports_external.string().optional(),
70399
- doneLabel: exports_external.string().optional(),
70400
- postComments: exports_external.boolean().default(true),
70401
- updateEveryIterations: exports_external.number().int().nonnegative().default(10)
70402
- }).default({ statuses: [], labels: [], postComments: true })
70403
- }).default({
70404
- concurrency: 1,
70405
- pollIntervalSeconds: 60,
70406
- maxIterationsPerTask: 0,
70407
- maxCostUsdPerTask: 0,
70408
- engine: "claude",
70409
- model: "opus",
70410
- linear: { statuses: [], labels: [], postComments: true }
70411
- });
70412
- async function loadRalphyConfig(projectRoot) {
70413
- const path = join12(projectRoot, "ralphy.config.json");
70414
- const file = Bun.file(path);
70415
- if (!await file.exists()) {
70416
- return RalphyConfigSchema.parse({});
70417
- }
70418
- const raw = await file.json();
70419
- return RalphyConfigSchema.parse(raw);
70420
- }
70421
- async function ensureRalphyConfig(projectRoot) {
70422
- const path = join12(projectRoot, "ralphy.config.json");
70423
- const file = Bun.file(path);
70424
- if (await file.exists())
70425
- return path;
70426
- const defaults2 = RalphyConfigSchema.parse({});
70427
- await Bun.write(path, JSON.stringify(defaults2, null, 2) + `
70428
- `);
70429
- return path;
70430
- }
70431
-
70432
- // apps/cli/src/agent/coordinator.ts
70433
- class AgentCoordinator {
70434
- deps;
70435
- opts;
70436
- workers = [];
70437
- pendingIds = new Set;
70438
- queue = [];
70439
- state = null;
70440
- stopped = false;
70441
- constructor(deps, opts) {
70442
- this.deps = deps;
70443
- this.opts = opts;
70444
- }
70445
- get activeCount() {
70446
- return this.workers.length;
70447
- }
70448
- get queuedCount() {
70449
- return this.queue.length;
70450
- }
70451
- get activeWorkers() {
70452
- return this.workers;
70453
- }
70454
- async init() {
70455
- this.state = await this.deps.loadState();
70456
- }
70457
- async pollOnce() {
70458
- if (this.stopped)
70459
- return { found: 0, added: 0 };
70460
- let issues;
70461
- try {
70462
- issues = await this.deps.fetchIssues(this.opts.filter);
70463
- } catch (err) {
70464
- this.deps.onLog(`! Linear poll failed: ${err.message}`, "red");
70465
- return { found: 0, added: 0 };
70466
- }
70467
- const state = this.state;
70468
- const tasksByIssueId = new Map;
70469
- for (const entry of Object.values(state.tasks)) {
70470
- tasksByIssueId.set(entry.issueId, entry);
70471
- }
70472
- const isProcessed = (id) => tasksByIssueId.get(id)?.state === "processed";
70473
- const isFailed = (id) => tasksByIssueId.get(id)?.state === "failed";
70474
- const queued = new Set(this.queue.map((i) => i.id));
70475
- const active = new Set(this.workers.map((w) => w.issueId));
70476
- let added = 0;
70477
- for (const issue of issues) {
70478
- if (isProcessed(issue.id))
70479
- continue;
70480
- if (isFailed(issue.id))
70481
- continue;
70482
- if (queued.has(issue.id))
70483
- continue;
70484
- if (active.has(issue.id))
70485
- continue;
70486
- if (this.pendingIds.has(issue.id))
70487
- continue;
70488
- const blocker = issue.blockedByIds.find((bid) => !isProcessed(bid));
70489
- if (blocker !== undefined) {
70490
- this.deps.onLog(` \u23F8 ${issue.identifier} skipped \u2014 blocked by unresolved dependency`, "yellow");
70491
- continue;
70492
- }
70493
- this.queue.push(issue);
70494
- added += 1;
70495
- }
70496
- if (added > 0) {
70497
- this.queue.sort((a, b) => {
70498
- const pa = a.priority === 0 ? Infinity : a.priority;
70499
- const pb = b.priority === 0 ? Infinity : b.priority;
70500
- return pa - pb;
70501
- });
70502
- }
70503
- state.lastPollAt = new Date().toISOString();
70504
- await this.deps.saveState(state);
70505
- this.spawnNext();
70506
- await this.reportProgress();
70507
- return { found: issues.length, added };
70471
+ if (added > 0) {
70472
+ this.queue.sort((a, b) => {
70473
+ const pa = a.priority === 0 ? Infinity : a.priority;
70474
+ const pb = b.priority === 0 ? Infinity : b.priority;
70475
+ return pa - pb;
70476
+ });
70477
+ }
70478
+ await this.deps.store.setLastPollAt(new Date().toISOString());
70479
+ this.spawnNext();
70480
+ await this.reportProgress();
70481
+ return { found: issues.length, added };
70508
70482
  }
70509
70483
  async reportProgress() {
70510
70484
  const updater = this.deps.updater;
@@ -70535,7 +70509,7 @@ class AgentCoordinator {
70535
70509
  }
70536
70510
  }
70537
70511
  spawnNext() {
70538
- if (this.stopped || !this.state)
70512
+ if (this.stopped)
70539
70513
  return;
70540
70514
  while (this.workers.length + this.pendingIds.size < this.opts.concurrency && this.queue.length > 0) {
70541
70515
  const issue = this.queue.shift();
@@ -70543,18 +70517,6 @@ class AgentCoordinator {
70543
70517
  this.launchWorker(issue);
70544
70518
  }
70545
70519
  }
70546
- upsertTask(issue, patch) {
70547
- if (!this.state)
70548
- return;
70549
- const existing = this.state.tasks[issue.identifier];
70550
- this.state.tasks[issue.identifier] = {
70551
- issueId: issue.id,
70552
- identifier: issue.identifier,
70553
- state: existing?.state ?? "started",
70554
- ...existing,
70555
- ...patch
70556
- };
70557
- }
70558
70520
  async launchWorker(issue) {
70559
70521
  let changeName;
70560
70522
  try {
@@ -70569,13 +70531,13 @@ class AgentCoordinator {
70569
70531
  this.pendingIds.delete(issue.id);
70570
70532
  return;
70571
70533
  }
70572
- if (this.state) {
70573
- this.upsertTask(issue, {
70534
+ {
70535
+ const existing = this.deps.store.snapshot().tasks[issue.identifier];
70536
+ this.deps.store.upsertTask(issue, {
70574
70537
  state: "started",
70575
70538
  changeName,
70576
- startedAt: this.state.tasks[issue.identifier]?.startedAt ?? new Date().toISOString()
70539
+ startedAt: existing?.startedAt ?? new Date().toISOString()
70577
70540
  });
70578
- this.deps.saveState(this.state);
70579
70541
  }
70580
70542
  this.deps.onLog(`\u25B6 ${issue.identifier} \u2192 ${changeName} (worker started)`, "cyan");
70581
70543
  const handle = this.deps.spawnWorker(changeName, issue);
@@ -70597,14 +70559,11 @@ class AgentCoordinator {
70597
70559
  this.workers.splice(idx, 1);
70598
70560
  const ok = code === 0;
70599
70561
  this.deps.onLog(`${ok ? "\u2713" : "\u2717"} ${issue.identifier} \u2192 ${changeName} exited (code ${code})`, ok ? "green" : "red");
70600
- if (this.state) {
70601
- this.upsertTask(issue, {
70602
- state: ok ? "processed" : "failed",
70603
- finishedAt: new Date().toISOString(),
70604
- exitCode: code
70605
- });
70606
- this.deps.saveState(this.state);
70607
- }
70562
+ this.deps.store.upsertTask(issue, {
70563
+ state: ok ? "processed" : "failed",
70564
+ finishedAt: new Date().toISOString(),
70565
+ exitCode: code
70566
+ });
70608
70567
  this.notifyExited(issue, changeName, code);
70609
70568
  this.deps.onWorkersChanged();
70610
70569
  this.spawnNext();
@@ -70614,14 +70573,11 @@ class AgentCoordinator {
70614
70573
  const updater = this.deps.updater;
70615
70574
  if (!updater)
70616
70575
  return;
70617
- const alreadyCommented = this.state?.tasks[issue.identifier]?.commentPosted === true;
70576
+ const alreadyCommented = this.deps.store.snapshot().tasks[issue.identifier]?.commentPosted === true;
70618
70577
  if (this.opts.postComments !== false && !alreadyCommented) {
70619
70578
  try {
70620
70579
  await updater.postComment(issue, `\uD83E\uDD16 Ralph started working on this issue. Tracking change: \`${changeName}\``);
70621
- if (this.state) {
70622
- this.upsertTask(issue, { commentPosted: true });
70623
- await this.deps.saveState(this.state);
70624
- }
70580
+ await this.deps.store.upsertTask(issue, { commentPosted: true });
70625
70581
  } catch (err) {
70626
70582
  this.deps.onLog(`! Linear comment failed for ${issue.identifier}: ${err.message}`, "red");
70627
70583
  }
@@ -70694,18 +70650,89 @@ class AgentCoordinator {
70694
70650
  }
70695
70651
  }
70696
70652
 
70653
+ // apps/cli/src/agent/scaffold.ts
70654
+ import { join as join13 } from "path";
70655
+ import { mkdir as mkdir2 } from "fs/promises";
70656
+ function changeNameForIssue(issue) {
70657
+ const slug = issue.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
70658
+ return slug ? `${issue.identifier.toLowerCase()}-${slug}` : issue.identifier.toLowerCase();
70659
+ }
70660
+ async function scaffoldChangeForIssue(tasksDir, statesDir, issue, comments = [], appendPrompt = "") {
70661
+ const name = changeNameForIssue(issue);
70662
+ const changeDir = join13(tasksDir, name);
70663
+ const stateDir = join13(statesDir, name);
70664
+ await mkdir2(changeDir, { recursive: true });
70665
+ await mkdir2(join13(changeDir, "specs"), { recursive: true });
70666
+ await mkdir2(stateDir, { recursive: true });
70667
+ const commentsBlock = comments.length > 0 ? [
70668
+ "",
70669
+ "## Linear comments",
70670
+ "",
70671
+ ...comments.flatMap((c) => [
70672
+ `**${c.user?.name ?? "unknown"}** \u2014 ${c.createdAt}`,
70673
+ "",
70674
+ c.body.trim(),
70675
+ ""
70676
+ ])
70677
+ ] : [];
70678
+ const proposal = [
70679
+ `# ${issue.identifier}: ${issue.title}`,
70680
+ "",
70681
+ `Source: [${issue.identifier}](${issue.url})`,
70682
+ `Status: ${issue.state.name}`,
70683
+ issue.assignee ? `Assignee: ${issue.assignee.name}` : "",
70684
+ issue.labels.length ? `Labels: ${issue.labels.join(", ")}` : "",
70685
+ "",
70686
+ "## Description",
70687
+ "",
70688
+ issue.description?.trim() || "_No description provided in Linear._",
70689
+ ...commentsBlock,
70690
+ ...appendPrompt.trim() ? ["", "## Additional instructions", "", appendPrompt.trim()] : [],
70691
+ "",
70692
+ "## Steering",
70693
+ "",
70694
+ "_Add steering notes here as the loop runs._",
70695
+ ""
70696
+ ].filter((l) => l !== "").join(`
70697
+ `);
70698
+ const tasks = [
70699
+ `# Tasks for ${issue.identifier}`,
70700
+ "",
70701
+ "## Subtasks",
70702
+ "",
70703
+ `- [ ] Read the Linear issue at ${issue.url} and break it into concrete subtasks`,
70704
+ `- [ ] Implement the changes described in proposal.md`,
70705
+ `- [ ] Add or update tests covering the new behavior`,
70706
+ `- [ ] Run \`bun run lint\` and \`bun run test\` and fix any failures`,
70707
+ ""
70708
+ ].join(`
70709
+ `);
70710
+ const design = [
70711
+ `# Design for ${issue.identifier}`,
70712
+ "",
70713
+ "_Fill in the technical design as you work through the issue._",
70714
+ ""
70715
+ ].join(`
70716
+ `);
70717
+ await Bun.write(join13(changeDir, "proposal.md"), proposal);
70718
+ await Bun.write(join13(changeDir, "tasks.md"), tasks);
70719
+ await Bun.write(join13(changeDir, "design.md"), design);
70720
+ return name;
70721
+ }
70722
+
70697
70723
  // apps/cli/src/agent/worktree.ts
70698
- import { basename, join as join13 } from "path";
70724
+ import { basename, join as join14 } from "path";
70699
70725
  import { homedir as homedir2 } from "os";
70726
+ import { exists } from "fs/promises";
70700
70727
  function worktreesDir(projectRoot) {
70701
- return join13(homedir2(), ".ralph", basename(projectRoot), "worktrees");
70728
+ return join14(homedir2(), ".ralph", basename(projectRoot), "worktrees");
70702
70729
  }
70703
70730
  function branchForChange(changeName) {
70704
70731
  return `ralph/${changeName}`;
70705
70732
  }
70706
70733
  async function createWorktree(projectRoot, changeName, runner) {
70707
70734
  const dir = worktreesDir(projectRoot);
70708
- const cwd2 = join13(dir, changeName);
70735
+ const cwd2 = join14(dir, changeName);
70709
70736
  const branch = branchForChange(changeName);
70710
70737
  const list = await runner.run(["worktree", "list", "--porcelain"], projectRoot);
70711
70738
  if (list.stdout.includes(`worktree ${cwd2}
@@ -70761,6 +70788,32 @@ async function isWorktreeSafeToRemove(cwd2, base2, runner) {
70761
70788
  }
70762
70789
  return { safe: true, dirty, unpushedCommits };
70763
70790
  }
70791
+ async function seedWorktreeMcpConfig(projectRoot, worktreeCwd) {
70792
+ const dst = join14(worktreeCwd, ".mcp.json");
70793
+ const src = join14(projectRoot, ".mcp.json");
70794
+ const source = await exists(dst) ? dst : await exists(src) ? src : null;
70795
+ if (!source)
70796
+ return;
70797
+ let parsed;
70798
+ try {
70799
+ parsed = await Bun.file(source).json();
70800
+ } catch {
70801
+ return;
70802
+ }
70803
+ const servers = parsed.mcpServers;
70804
+ if (servers && typeof servers === "object") {
70805
+ for (const cfg of Object.values(servers)) {
70806
+ if (Array.isArray(cfg.args)) {
70807
+ cfg.args = cfg.args.map((a) => typeof a === "string" && a.startsWith(".ralph/") ? join14(projectRoot, a) : a);
70808
+ }
70809
+ }
70810
+ }
70811
+ await Bun.write(dst, JSON.stringify(parsed, null, 2) + `
70812
+ `);
70813
+ }
70814
+
70815
+ // apps/cli/src/agent/post-task.ts
70816
+ import { join as join15 } from "path";
70764
70817
 
70765
70818
  // apps/cli/src/agent/pr.ts
70766
70819
  function defaultTitle(issue) {
@@ -70883,33 +70936,213 @@ ${logs}
70883
70936
  return { success: false, attempts: opts.maxAttempts, reason: "max-attempts" };
70884
70937
  }
70885
70938
 
70886
- // apps/cli/src/components/AgentMode.tsx
70887
- var jsx_dev_runtime9 = __toESM(require_jsx_dev_runtime(), 1);
70888
- import { join as join14 } from "path";
70889
- import { exists } from "fs/promises";
70890
- async function seedWorktreeMcpConfig(projectRoot, worktreeCwd) {
70891
- const dst = join14(worktreeCwd, ".mcp.json");
70892
- const src = join14(projectRoot, ".mcp.json");
70893
- const source = await exists(dst) ? dst : await exists(src) ? src : null;
70894
- if (!source)
70939
+ // apps/cli/src/agent/post-task.ts
70940
+ var CI_FAILED_EXIT = 70;
70941
+ var PR_FAILED_EXIT = 71;
70942
+ async function reactivateState(stateFilePath, log2, changeName) {
70943
+ const file = Bun.file(stateFilePath);
70944
+ if (!await file.exists())
70895
70945
  return;
70896
- let parsed;
70897
70946
  try {
70898
- parsed = await Bun.file(source).json();
70899
- } catch {
70900
- return;
70947
+ const stateObj = JSON.parse(await file.text());
70948
+ if (stateObj.status !== "active") {
70949
+ stateObj.status = "active";
70950
+ stateObj.lastModified = new Date().toISOString();
70951
+ await Bun.write(stateFilePath, JSON.stringify(stateObj, null, 2) + `
70952
+ `);
70953
+ }
70954
+ } catch (err) {
70955
+ log2(`! could not reactivate state for ${changeName}: ${err.message}`, "yellow");
70901
70956
  }
70902
- const servers = parsed.mcpServers;
70903
- if (servers && typeof servers === "object") {
70904
- for (const cfg of Object.values(servers)) {
70905
- if (Array.isArray(cfg.args)) {
70906
- cfg.args = cfg.args.map((a) => typeof a === "string" && a.startsWith(".ralph/") ? join14(projectRoot, a) : a);
70957
+ }
70958
+ async function runPostTask(input, deps) {
70959
+ const { log: log2, cmd, git, runScript } = deps;
70960
+ const {
70961
+ changeName,
70962
+ cwd: cwd2,
70963
+ projectRoot,
70964
+ changeDir,
70965
+ stateFilePath,
70966
+ branch,
70967
+ issue,
70968
+ exitCode,
70969
+ useWorktree,
70970
+ wantPr,
70971
+ wantFixCi,
70972
+ cfg,
70973
+ respawnWorker
70974
+ } = input;
70975
+ if (cfg.teardownScript) {
70976
+ try {
70977
+ await runScript("teardown", cfg.teardownScript, cwd2);
70978
+ } catch {}
70979
+ }
70980
+ let effectiveCode = exitCode;
70981
+ const ok = exitCode === 0;
70982
+ if (ok && wantPr) {
70983
+ if (!branch || !issue) {
70984
+ log2(`! createPr requested but no worktree branch is tracked for ${changeName} (use --worktree)`, "yellow");
70985
+ effectiveCode = PR_FAILED_EXIT;
70986
+ } else {
70987
+ const maxHookFixAttempts = cfg.maxCiFixAttempts;
70988
+ const runWorkerWithFixTask = async (heading, failureOutput) => {
70989
+ try {
70990
+ await prependFixTask(join15(changeDir, "tasks.md"), heading, failureOutput);
70991
+ } catch (err) {
70992
+ log2(`! could not prepend fix task: ${err.message}`, "red");
70993
+ return 1;
70994
+ }
70995
+ await reactivateState(stateFilePath, log2, changeName);
70996
+ return respawnWorker();
70997
+ };
70998
+ let hookFixAttempt = 0;
70999
+ let commitGaveUp = false;
71000
+ while (true) {
71001
+ let dirty = "";
71002
+ try {
71003
+ const status = await cmd.run(["git", "status", "--porcelain"], cwd2);
71004
+ dirty = status.stdout.trim();
71005
+ } catch (err) {
71006
+ log2(`! git status failed for ${changeName}: ${err.message}`, "yellow");
71007
+ break;
71008
+ }
71009
+ if (!dirty)
71010
+ break;
71011
+ try {
71012
+ await cmd.run(["git", "add", "-A"], cwd2);
71013
+ await cmd.run(["git", "commit", "-m", `chore(ralph): residual changes for ${changeName}`], cwd2);
71014
+ log2(` committed residual changes for ${changeName}`, "gray");
71015
+ break;
71016
+ } catch (err) {
71017
+ const e = err;
71018
+ const detail = e.stderr?.trim() || e.message;
71019
+ const combined = `${e.stdout ?? ""}
71020
+ ${e.stderr ?? ""}`;
71021
+ if (/nothing to commit/i.test(combined))
71022
+ break;
71023
+ if (hookFixAttempt >= maxHookFixAttempts) {
71024
+ log2(`! commit rejected for ${changeName} after ${hookFixAttempt} hook-fix attempts (host pre-commit hook still failing) \u2014 worktree preserved at ${cwd2}`, "red");
71025
+ log2(` detail: ${detail}`, "red");
71026
+ effectiveCode = PR_FAILED_EXIT;
71027
+ commitGaveUp = true;
71028
+ break;
71029
+ }
71030
+ hookFixAttempt += 1;
71031
+ log2(`! commit rejected for ${changeName} \u2014 prepending fix task and re-running loop (attempt ${hookFixAttempt}/${maxHookFixAttempts})`, "yellow");
71032
+ log2(` detail: ${detail}`, "yellow");
71033
+ const retryCode = await runWorkerWithFixTask("Fix host pre-commit hook rejection", `Committing residual changes was rejected by the host repo's pre-commit hook. ` + `Fix the underlying problem, then the commit will be retried.
71034
+
71035
+ ` + combined.trim());
71036
+ if (retryCode !== 0) {
71037
+ log2(`! worker re-run after commit rejection exited code ${retryCode} \u2014 giving up`, "red");
71038
+ effectiveCode = PR_FAILED_EXIT;
71039
+ commitGaveUp = true;
71040
+ break;
71041
+ }
71042
+ }
71043
+ }
71044
+ let pr = null;
71045
+ let prGaveUp = commitGaveUp;
71046
+ while (!prGaveUp) {
71047
+ try {
71048
+ pr = await createPullRequest({ cwd: cwd2, branch, issue, base: cfg.prBaseBranch }, cmd);
71049
+ break;
71050
+ } catch (err) {
71051
+ const e = err;
71052
+ const detail = e.stderr?.trim() || e.message;
71053
+ const combined = `${e.stdout ?? ""}
71054
+ ${e.stderr ?? ""}`;
71055
+ const pushRejected = /failed to push some refs|pre-push hook|hook declined/i.test(combined);
71056
+ if (!pushRejected || hookFixAttempt >= maxHookFixAttempts) {
71057
+ if (pushRejected) {
71058
+ log2(`! push rejected for ${changeName} after ${hookFixAttempt} hook-fix attempts (host pre-push hook still failing) \u2014 worktree preserved at ${cwd2}`, "red");
71059
+ log2(` detail: ${detail}`, "red");
71060
+ } else {
71061
+ log2(`! PR create failed for ${changeName}: ${detail}`, "red");
71062
+ }
71063
+ effectiveCode = PR_FAILED_EXIT;
71064
+ prGaveUp = true;
71065
+ break;
71066
+ }
71067
+ hookFixAttempt += 1;
71068
+ log2(`! push rejected for ${changeName} \u2014 prepending fix task and re-running loop (attempt ${hookFixAttempt}/${maxHookFixAttempts})`, "yellow");
71069
+ log2(` detail: ${detail}`, "yellow");
71070
+ const retryCode = await runWorkerWithFixTask("Fix host pre-push hook rejection", `Push to origin/${branch} was rejected by the host repo's pre-push hook. ` + `Fix the underlying problem, then the push will be retried.
71071
+
71072
+ ` + combined.trim());
71073
+ if (retryCode !== 0) {
71074
+ log2(`! worker re-run after push rejection exited code ${retryCode} \u2014 giving up`, "red");
71075
+ effectiveCode = PR_FAILED_EXIT;
71076
+ prGaveUp = true;
71077
+ break;
71078
+ }
71079
+ }
71080
+ }
71081
+ if (prGaveUp) {} else if (!pr) {
71082
+ log2(` no commits ahead of ${cfg.prBaseBranch} \u2014 skipping PR`, "gray");
71083
+ } else {
71084
+ log2(` ${pr.created ? "opened" : "found existing"} PR: ${pr.url}`, "green");
71085
+ if (wantFixCi) {
71086
+ log2(` watching CI for ${pr.url} (max ${cfg.maxCiFixAttempts} fix attempts)`, "gray");
71087
+ const result2 = await fixCiUntilGreen({
71088
+ getStatus: () => getPrChecksStatus(pr.url, cmd, cwd2),
71089
+ getFailedLogs: (ids) => fetchFailedRunLogs(ids, cmd, cwd2),
71090
+ runTaskWithSteering: async (steering) => {
71091
+ try {
71092
+ await prependFixTask(join15(changeDir, "tasks.md"), "Fix failing CI checks", steering);
71093
+ } catch (err) {
71094
+ log2(`! could not prepend fix task: ${err.message}`, "red");
71095
+ }
71096
+ return respawnWorker();
71097
+ },
71098
+ pushBranch: async () => {
71099
+ await cmd.run(["git", "push", "origin", branch], cwd2);
71100
+ },
71101
+ log: log2,
71102
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms))
71103
+ }, {
71104
+ maxAttempts: cfg.maxCiFixAttempts,
71105
+ pollIntervalSeconds: cfg.ciPollIntervalSeconds
71106
+ });
71107
+ if (!result2.success) {
71108
+ log2(`! CI fix loop gave up after ${result2.attempts} attempts (${result2.reason ?? "unknown"}) \u2014 withholding done-status until CI passes`, "red");
71109
+ effectiveCode = CI_FAILED_EXIT;
71110
+ }
71111
+ }
70907
71112
  }
70908
71113
  }
70909
71114
  }
70910
- await Bun.write(dst, JSON.stringify(parsed, null, 2) + `
70911
- `);
71115
+ if (useWorktree && cwd2 !== projectRoot) {
71116
+ if (effectiveCode === 0 && cfg.cleanupWorktreeOnSuccess) {
71117
+ const check = await isWorktreeSafeToRemove(cwd2, cfg.prBaseBranch, git).catch((err) => ({
71118
+ safe: false,
71119
+ reason: `safety check failed: ${err.message}`,
71120
+ dirty: "",
71121
+ unpushedCommits: ""
71122
+ }));
71123
+ if (!check.safe) {
71124
+ log2(`! preserving worktree for ${changeName}: ${check.reason}`, "yellow");
71125
+ if (check.dirty)
71126
+ log2(` uncommitted:
71127
+ ${check.dirty}`, "yellow");
71128
+ if (check.unpushedCommits)
71129
+ log2(` commits:
71130
+ ${check.unpushedCommits}`, "yellow");
71131
+ log2(` path: ${cwd2}`, "yellow");
71132
+ } else {
71133
+ try {
71134
+ await removeWorktree(projectRoot, cwd2, git);
71135
+ log2(` removed worktree ${cwd2}`, "gray");
71136
+ } catch (err) {
71137
+ log2(`! worktree remove failed for ${changeName}: ${err.message}`, "yellow");
71138
+ }
71139
+ }
71140
+ }
71141
+ }
71142
+ return effectiveCode;
70912
71143
  }
71144
+
71145
+ // apps/cli/src/agent/wire.ts
70913
71146
  var bunGitRunner = {
70914
71147
  run: async (args, cwd2) => {
70915
71148
  const proc = Bun.spawn({ cmd: ["git", ...args], cwd: cwd2, stdout: "pipe", stderr: "pipe" });
@@ -70943,35 +71176,249 @@ var bunCmdRunner = {
70943
71176
  return { stdout, stderr };
70944
71177
  }
70945
71178
  };
71179
+ function buildAgentCoordinator(input) {
71180
+ const {
71181
+ args,
71182
+ cfg,
71183
+ projectRoot,
71184
+ statesDir,
71185
+ tasksDir,
71186
+ apiKey,
71187
+ store,
71188
+ onLog,
71189
+ onWorkersChanged,
71190
+ onWorkerStarted,
71191
+ onWorkerExited
71192
+ } = input;
71193
+ const concurrency = args.concurrency || cfg.concurrency;
71194
+ const pollInterval = args.pollInterval || cfg.pollIntervalSeconds;
71195
+ const inProgressName = args.inProgressStatus || cfg.linear.inProgressStatus;
71196
+ const baseStatuses = args.linearStatus.length ? args.linearStatus : cfg.linear.statuses;
71197
+ const effectiveStatuses = inProgressName && baseStatuses.length > 0 && !baseStatuses.includes(inProgressName) ? [...baseStatuses, inProgressName] : baseStatuses;
71198
+ const filter2 = {
71199
+ team: args.linearTeam || cfg.linear.team,
71200
+ assignee: args.linearAssignee || cfg.linear.assignee,
71201
+ statuses: effectiveStatuses,
71202
+ labels: args.linearLabel.length ? args.linearLabel : cfg.linear.labels
71203
+ };
71204
+ const stateCache = new Map;
71205
+ const labelCache = new Map;
71206
+ const teamKeyOf = (issue) => issue.identifier.split("-")[0];
71207
+ const useWorktree = args.worktree || cfg.useWorktree;
71208
+ const cwdByChange = new Map;
71209
+ const statesDirByChange = new Map;
71210
+ const branchByChange = new Map;
71211
+ const issueByChange = new Map;
71212
+ async function runScript(label, cmd, cwd2) {
71213
+ onLog(` ${label}: ${cmd}`, "gray");
71214
+ const proc = Bun.spawn({
71215
+ cmd: ["sh", "-c", cmd],
71216
+ cwd: cwd2,
71217
+ stdout: "ignore",
71218
+ stderr: "pipe",
71219
+ stdin: "ignore"
71220
+ });
71221
+ const code = await proc.exited;
71222
+ if (code !== 0) {
71223
+ const stderr = await new Response(proc.stderr).text();
71224
+ onLog(`! ${label} exited code ${code}${stderr ? `: ${stderr.trim().split(`
71225
+ `)[0]}` : ""}`, "yellow");
71226
+ }
71227
+ }
71228
+ async function scaffoldCallback(issue) {
71229
+ let comments = [];
71230
+ try {
71231
+ comments = await fetchIssueComments(apiKey, issue.id);
71232
+ } catch (err) {
71233
+ onLog(`! Linear comment fetch failed for ${issue.identifier}: ${err.message}`, "yellow");
71234
+ }
71235
+ let workerCwd = projectRoot;
71236
+ let scaffoldTasksDir = tasksDir;
71237
+ let scaffoldStatesDir = statesDir;
71238
+ let workerBranch = null;
71239
+ const probeName = issue.identifier.toLowerCase();
71240
+ if (useWorktree) {
71241
+ try {
71242
+ const wt = await createWorktree(projectRoot, probeName, bunGitRunner);
71243
+ workerCwd = wt.cwd;
71244
+ workerBranch = wt.branch;
71245
+ const wtLayout = projectLayout(wt.cwd);
71246
+ scaffoldTasksDir = wtLayout.tasksDir;
71247
+ scaffoldStatesDir = wtLayout.statesDir;
71248
+ onLog(` ${issue.identifier} worktree: ${wt.cwd} (${wt.branch})`, "gray");
71249
+ try {
71250
+ await seedWorktreeMcpConfig(projectRoot, wt.cwd);
71251
+ } catch (err) {
71252
+ onLog(`! seeding .mcp.json failed for ${issue.identifier}: ${err.message}`, "yellow");
71253
+ }
71254
+ } catch (err) {
71255
+ onLog(`! worktree create failed for ${issue.identifier}: ${err.message} \u2014 falling back to project root`, "yellow");
71256
+ }
71257
+ }
71258
+ const appendPrompt = args.prompt || cfg.appendPrompt || "";
71259
+ const changeName = await scaffoldChangeForIssue(scaffoldTasksDir, scaffoldStatesDir, issue, comments, appendPrompt);
71260
+ cwdByChange.set(changeName, workerCwd);
71261
+ statesDirByChange.set(changeName, scaffoldStatesDir);
71262
+ issueByChange.set(changeName, issue);
71263
+ if (workerBranch)
71264
+ branchByChange.set(changeName, workerBranch);
71265
+ if (cfg.setupScript) {
71266
+ await runScript("setup", cfg.setupScript, workerCwd);
71267
+ }
71268
+ return changeName;
71269
+ }
71270
+ function buildTaskCmdFor(changeName) {
71271
+ const c = [
71272
+ process.execPath,
71273
+ process.argv[1] ?? "",
71274
+ "task",
71275
+ "--name",
71276
+ changeName,
71277
+ "--" + (args.engineSet ? args.engine : cfg.engine),
71278
+ args.engineSet ? args.model : cfg.model
71279
+ ];
71280
+ const maxIter = args.maxIterations || cfg.maxIterationsPerTask;
71281
+ if (maxIter > 0)
71282
+ c.push("--max-iterations", String(maxIter));
71283
+ const maxCost = args.maxCostUsd || cfg.maxCostUsdPerTask;
71284
+ if (maxCost > 0)
71285
+ c.push("--max-cost", String(maxCost));
71286
+ const maxRuntime = args.maxRuntimeMinutes || cfg.maxRuntimeMinutesPerTask;
71287
+ if (maxRuntime > 0)
71288
+ c.push("--max-runtime", String(maxRuntime));
71289
+ const maxFailures = args.maxConsecutiveFailures !== 5 ? args.maxConsecutiveFailures : cfg.maxConsecutiveFailuresPerTask;
71290
+ if (maxFailures !== 5)
71291
+ c.push("--max-failures", String(maxFailures));
71292
+ const delay2 = args.delay || cfg.iterationDelaySeconds;
71293
+ if (delay2 > 0)
71294
+ c.push("--delay", String(delay2));
71295
+ if (args.log || cfg.logRawStream)
71296
+ c.push("--log");
71297
+ if (args.verbose || cfg.taskVerbose)
71298
+ c.push("--verbose");
71299
+ return c;
71300
+ }
71301
+ function spawnWorker(changeName) {
71302
+ const cwd2 = cwdByChange.get(changeName) ?? projectRoot;
71303
+ const respawn = () => {
71304
+ const rp = Bun.spawn({
71305
+ cmd: buildTaskCmdFor(changeName),
71306
+ cwd: cwd2,
71307
+ stdout: "ignore",
71308
+ stderr: "ignore",
71309
+ stdin: "ignore"
71310
+ });
71311
+ return rp.exited;
71312
+ };
71313
+ const proc = Bun.spawn({
71314
+ cmd: buildTaskCmdFor(changeName),
71315
+ cwd: cwd2,
71316
+ stdout: "ignore",
71317
+ stderr: "ignore",
71318
+ stdin: "ignore"
71319
+ });
71320
+ onWorkerStarted(changeName, statesDirByChange.get(changeName) ?? statesDir);
71321
+ const wantPr = args.createPr || cfg.createPrOnSuccess;
71322
+ const wantFixCi = args.fixCi || cfg.fixCiOnFailure;
71323
+ const wrapped = proc.exited.then(async (code) => {
71324
+ const workerLayout = projectLayout(cwd2);
71325
+ const effectiveCode = await runPostTask({
71326
+ changeName,
71327
+ cwd: cwd2,
71328
+ projectRoot,
71329
+ changeDir: workerLayout.changeDir(changeName),
71330
+ stateFilePath: workerLayout.stateFile(changeName),
71331
+ branch: branchByChange.get(changeName) ?? null,
71332
+ issue: issueByChange.get(changeName) ?? null,
71333
+ exitCode: code,
71334
+ useWorktree,
71335
+ wantPr,
71336
+ wantFixCi,
71337
+ cfg: {
71338
+ teardownScript: cfg.teardownScript ?? null,
71339
+ prBaseBranch: cfg.prBaseBranch,
71340
+ maxCiFixAttempts: cfg.maxCiFixAttempts,
71341
+ ciPollIntervalSeconds: cfg.ciPollIntervalSeconds,
71342
+ cleanupWorktreeOnSuccess: cfg.cleanupWorktreeOnSuccess
71343
+ },
71344
+ respawnWorker: respawn
71345
+ }, { cmd: bunCmdRunner, git: bunGitRunner, log: onLog, runScript });
71346
+ cwdByChange.delete(changeName);
71347
+ statesDirByChange.delete(changeName);
71348
+ branchByChange.delete(changeName);
71349
+ issueByChange.delete(changeName);
71350
+ onWorkerExited(changeName);
71351
+ return effectiveCode;
71352
+ });
71353
+ return { exited: wrapped, kill: () => proc.kill() };
71354
+ }
71355
+ const coord = new AgentCoordinator({
71356
+ fetchIssues: (f2) => fetchOpenIssues(apiKey, f2),
71357
+ scaffold: scaffoldCallback,
71358
+ spawnWorker,
71359
+ store,
71360
+ onLog,
71361
+ onWorkersChanged,
71362
+ getIterationCount: async (changeName) => {
71363
+ const root = cwdByChange.get(changeName) ?? projectRoot;
71364
+ const file = Bun.file(projectLayout(root).stateFile(changeName));
71365
+ if (!await file.exists())
71366
+ return 0;
71367
+ const json = await file.json();
71368
+ return json.iteration ?? 0;
71369
+ },
71370
+ updater: {
71371
+ postComment: (issue, body) => addIssueComment(apiKey, issue.id, body),
71372
+ setState: (issue, stateId) => updateIssueState(apiKey, issue.id, stateId),
71373
+ resolveStateId: async (issue, stateName) => {
71374
+ const team = teamKeyOf(issue);
71375
+ let map2 = stateCache.get(team);
71376
+ if (!map2) {
71377
+ const states = await fetchWorkflowStates(apiKey, team);
71378
+ map2 = new Map(states.map((s) => [s.name.toLowerCase(), s.id]));
71379
+ stateCache.set(team, map2);
71380
+ }
71381
+ return map2.get(stateName.toLowerCase()) ?? null;
71382
+ },
71383
+ addLabel: (issue, labelId) => addLabelToIssue(apiKey, issue.id, labelId),
71384
+ resolveLabelId: async (issue, labelName) => {
71385
+ const team = teamKeyOf(issue);
71386
+ let map2 = labelCache.get(team);
71387
+ if (!map2) {
71388
+ const labels = await fetchIssueLabels(apiKey, team);
71389
+ map2 = new Map(labels.map((l) => [l.name.toLowerCase(), l.id]));
71390
+ labelCache.set(team, map2);
71391
+ }
71392
+ return map2.get(labelName.toLowerCase()) ?? null;
71393
+ }
71394
+ }
71395
+ }, {
71396
+ concurrency,
71397
+ filter: filter2,
71398
+ inProgressStatus: args.inProgressStatus || cfg.linear.inProgressStatus,
71399
+ doneStatus: args.doneStatus || cfg.linear.doneStatus,
71400
+ doneLabel: args.doneLabel || cfg.linear.doneLabel,
71401
+ postComments: cfg.linear.postComments,
71402
+ commentEveryIterations: cfg.linear.updateEveryIterations
71403
+ });
71404
+ const filterDesc = `team=${filter2.team ?? "*"}, assignee=${filter2.assignee ?? "*"}, statuses=` + `${filter2.statuses?.length ? filter2.statuses.join(",") : "open"}` + `${filter2.labels?.length ? `, labels=${filter2.labels.join(",")}` : ""}`;
71405
+ return {
71406
+ coord,
71407
+ filterDesc,
71408
+ concurrency,
71409
+ pollInterval,
71410
+ getWorkerCwd: (changeName) => cwdByChange.get(changeName)
71411
+ };
71412
+ }
71413
+
71414
+ // apps/cli/src/components/AgentMode.tsx
71415
+ var jsx_dev_runtime9 = __toESM(require_jsx_dev_runtime(), 1);
70946
71416
  var lineCounter = 0;
70947
71417
  function nextId() {
70948
71418
  lineCounter += 1;
70949
71419
  return `${Date.now()}-${lineCounter}`;
70950
71420
  }
70951
71421
  var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
70952
- async function injectFixSteering(changeDir, heading, steering) {
70953
- const steeringFile = Bun.file(join14(changeDir, "steering.md"));
70954
- const existing = await steeringFile.exists() ? await steeringFile.text() : "";
70955
- const stamped = `## ${heading} (${new Date().toISOString()})
70956
-
70957
- ${steering}
70958
- `;
70959
- const nextSteering = existing ? `${stamped}
70960
- ${existing.trimStart()}` : `${stamped}
70961
- `;
70962
- await Bun.write(join14(changeDir, "steering.md"), nextSteering);
70963
- const tasksFile = Bun.file(join14(changeDir, "tasks.md"));
70964
- const tasks = await tasksFile.exists() ? await tasksFile.text() : "";
70965
- const taskSection = `
70966
- ## ${heading} (${new Date().toISOString()})
70967
-
70968
- ` + `- [ ] ${heading}. The error output is recorded in steering.md \u2014 read it first, ` + `then fix the underlying problem (do not just retry the failing command).
70969
- `;
70970
- const nextTasks = tasks.endsWith(`
70971
- `) ? tasks + taskSection : tasks + `
70972
- ` + taskSection;
70973
- await Bun.write(join14(changeDir, "tasks.md"), nextTasks);
70974
- }
70975
71422
  function fmtElapsed(ms) {
70976
71423
  const s = Math.floor(ms / 1000);
70977
71424
  if (s < 60)
@@ -70991,7 +71438,6 @@ function AgentMode({ args, projectRoot, statesDir, tasksDir }) {
70991
71438
  const coordRef = import_react57.useRef(null);
70992
71439
  const workerMetaRef = import_react57.useRef(new Map);
70993
71440
  const nextPollAtRef = import_react57.useRef(0);
70994
- const pollIntervalRef = import_react57.useRef(0);
70995
71441
  const [pollStatus, setPollStatus] = import_react57.useState({ state: "idle", lastFound: null, lastAdded: null, lastAt: null, filterDesc: "" });
70996
71442
  function appendLog(text, color) {
70997
71443
  setLogs((prev) => [...prev, { id: nextId(), text, color }]);
@@ -71002,389 +71448,39 @@ function AgentMode({ args, projectRoot, statesDir, tasksDir }) {
71002
71448
  async function init2() {
71003
71449
  const cfgPath = await ensureRalphyConfig(projectRoot);
71004
71450
  const cfg = await loadRalphyConfig(projectRoot);
71005
- appendLog(`agent mode \u2014 config: ${cfgPath}`, "gray");
71006
- const concurrency = args.concurrency || cfg.concurrency;
71007
- const pollInterval = args.pollInterval || cfg.pollIntervalSeconds;
71008
- pollIntervalRef.current = pollInterval;
71009
- appendLog(`concurrency=${concurrency} pollInterval=${pollInterval}s`, "gray");
71451
+ appendLog(`agent mode v${VERSION} \u2014 config: ${cfgPath}`, "gray");
71010
71452
  const apiKey = process.env["LINEAR_API_KEY"];
71011
71453
  if (!apiKey) {
71012
71454
  appendLog("! LINEAR_API_KEY not set \u2014 cannot poll Linear", "red");
71013
71455
  exit();
71014
71456
  return;
71015
71457
  }
71016
- const inProgressName = args.inProgressStatus || cfg.linear.inProgressStatus;
71017
- const baseStatuses = args.linearStatus.length ? args.linearStatus : cfg.linear.statuses;
71018
- const effectiveStatuses = inProgressName && baseStatuses.length > 0 && !baseStatuses.includes(inProgressName) ? [...baseStatuses, inProgressName] : baseStatuses;
71019
- const filter2 = {
71020
- team: args.linearTeam || cfg.linear.team,
71021
- assignee: args.linearAssignee || cfg.linear.assignee,
71022
- statuses: effectiveStatuses,
71023
- labels: args.linearLabel.length ? args.linearLabel : cfg.linear.labels
71024
- };
71025
- const stateCache = new Map;
71026
- const labelCache = new Map;
71027
- const teamKeyOf = (issue) => issue.identifier.split("-")[0];
71028
- const useWorktree = args.worktree || cfg.useWorktree;
71029
- const cwdByChange = new Map;
71030
- const statesDirByChange = new Map;
71031
- const branchByChange = new Map;
71032
- const issueByChange = new Map;
71033
- async function runScript(label, cmd, cwd2) {
71034
- appendLog(` ${label}: ${cmd}`, "gray");
71035
- const proc = Bun.spawn({
71036
- cmd: ["sh", "-c", cmd],
71037
- cwd: cwd2,
71038
- stdout: "ignore",
71039
- stderr: "pipe",
71040
- stdin: "ignore"
71041
- });
71042
- const code = await proc.exited;
71043
- if (code !== 0) {
71044
- const stderr = await new Response(proc.stderr).text();
71045
- appendLog(`! ${label} exited code ${code}${stderr ? `: ${stderr.trim().split(`
71046
- `)[0]}` : ""}`, "yellow");
71047
- }
71048
- }
71049
- const coord2 = new AgentCoordinator({
71050
- fetchIssues: (f2) => fetchOpenIssues(apiKey, f2),
71051
- scaffold: async (issue) => {
71052
- let comments = [];
71053
- try {
71054
- comments = await fetchIssueComments(apiKey, issue.id);
71055
- } catch (err) {
71056
- appendLog(`! Linear comment fetch failed for ${issue.identifier}: ${err.message}`, "yellow");
71057
- }
71058
- let workerCwd = projectRoot;
71059
- let scaffoldTasksDir = tasksDir;
71060
- let scaffoldStatesDir = statesDir;
71061
- let workerBranch = null;
71062
- const probeName = issue.identifier.toLowerCase();
71063
- if (useWorktree) {
71064
- try {
71065
- const wt = await createWorktree(projectRoot, probeName, bunGitRunner);
71066
- workerCwd = wt.cwd;
71067
- workerBranch = wt.branch;
71068
- scaffoldTasksDir = join14(wt.cwd, "openspec", "changes");
71069
- scaffoldStatesDir = join14(wt.cwd, ".ralph", "tasks");
71070
- appendLog(` ${issue.identifier} worktree: ${wt.cwd} (${wt.branch})`, "gray");
71071
- try {
71072
- await seedWorktreeMcpConfig(projectRoot, wt.cwd);
71073
- } catch (err) {
71074
- appendLog(`! seeding .mcp.json failed for ${issue.identifier}: ${err.message}`, "yellow");
71075
- }
71076
- } catch (err) {
71077
- appendLog(`! worktree create failed for ${issue.identifier}: ${err.message} \u2014 falling back to project root`, "yellow");
71078
- }
71079
- }
71080
- const appendPrompt = args.prompt || cfg.appendPrompt || "";
71081
- const changeName = await scaffoldChangeForIssue(scaffoldTasksDir, scaffoldStatesDir, issue, comments, appendPrompt);
71082
- cwdByChange.set(changeName, workerCwd);
71083
- statesDirByChange.set(changeName, scaffoldStatesDir);
71084
- issueByChange.set(changeName, issue);
71085
- if (workerBranch)
71086
- branchByChange.set(changeName, workerBranch);
71087
- if (cfg.setupScript) {
71088
- await runScript("setup", cfg.setupScript, workerCwd);
71089
- }
71090
- return changeName;
71091
- },
71092
- spawnWorker: (changeName) => {
71093
- const buildTaskCmd = () => {
71094
- const c = [
71095
- process.execPath,
71096
- process.argv[1] ?? "",
71097
- "task",
71098
- "--name",
71099
- changeName,
71100
- "--" + (args.engineSet ? args.engine : cfg.engine),
71101
- args.engineSet ? args.model : cfg.model
71102
- ];
71103
- const maxIter = args.maxIterations || cfg.maxIterationsPerTask;
71104
- if (maxIter > 0)
71105
- c.push("--max-iterations", String(maxIter));
71106
- const maxCost = args.maxCostUsd || cfg.maxCostUsdPerTask;
71107
- if (maxCost > 0)
71108
- c.push("--max-cost", String(maxCost));
71109
- const maxRuntime = args.maxRuntimeMinutes || cfg.maxRuntimeMinutesPerTask;
71110
- if (maxRuntime > 0)
71111
- c.push("--max-runtime", String(maxRuntime));
71112
- const maxFailures = args.maxConsecutiveFailures !== 5 ? args.maxConsecutiveFailures : cfg.maxConsecutiveFailuresPerTask;
71113
- if (maxFailures !== 5)
71114
- c.push("--max-failures", String(maxFailures));
71115
- const delay2 = args.delay || cfg.iterationDelaySeconds;
71116
- if (delay2 > 0)
71117
- c.push("--delay", String(delay2));
71118
- if (args.log || cfg.logRawStream)
71119
- c.push("--log");
71120
- if (args.verbose || cfg.taskVerbose)
71121
- c.push("--verbose");
71122
- return c;
71123
- };
71124
- const cwd2 = cwdByChange.get(changeName) ?? projectRoot;
71125
- const proc = Bun.spawn({
71126
- cmd: buildTaskCmd(),
71127
- cwd: cwd2,
71128
- stdout: "ignore",
71129
- stderr: "ignore",
71130
- stdin: "ignore"
71131
- });
71458
+ const store = new AgentStateStore(projectRoot);
71459
+ await store.load();
71460
+ const { coord: coord2, filterDesc, concurrency, pollInterval } = buildAgentCoordinator({
71461
+ args,
71462
+ cfg,
71463
+ projectRoot,
71464
+ statesDir,
71465
+ tasksDir,
71466
+ apiKey,
71467
+ store,
71468
+ onLog: appendLog,
71469
+ onWorkersChanged: () => setTick((t) => t + 1),
71470
+ onWorkerStarted: (changeName, dir) => {
71132
71471
  workerMetaRef.current.set(changeName, {
71133
71472
  startedAt: Date.now(),
71134
- statesDir: statesDirByChange.get(changeName) ?? statesDir,
71473
+ statesDir: dir,
71135
71474
  iter: 0
71136
71475
  });
71137
- const wantPr = args.createPr || cfg.createPrOnSuccess;
71138
- const CI_FAILED_EXIT = 70;
71139
- const PR_FAILED_EXIT = 71;
71140
- const wrapped = proc.exited.then(async (code) => {
71141
- if (cfg.teardownScript) {
71142
- try {
71143
- await runScript("teardown", cfg.teardownScript, cwd2);
71144
- } catch {}
71145
- }
71146
- let effectiveCode = code;
71147
- const ok = code === 0;
71148
- if (ok && wantPr) {
71149
- const branch = branchByChange.get(changeName);
71150
- const prIssue = issueByChange.get(changeName);
71151
- if (!branch || !prIssue) {
71152
- appendLog(`! createPr requested but no worktree branch is tracked for ${changeName} (use --worktree)`, "yellow");
71153
- effectiveCode = PR_FAILED_EXIT;
71154
- } else {
71155
- const changeDir = join14(statesDirByChange.get(changeName) ?? statesDir, "..", "..", "openspec", "changes", changeName);
71156
- const maxHookFixAttempts = cfg.maxCiFixAttempts;
71157
- const runWorkerWithFixSteering = async (heading, steering) => {
71158
- try {
71159
- await injectFixSteering(changeDir, heading, steering);
71160
- } catch (steerErr) {
71161
- appendLog(`! could not inject steering: ${steerErr.message}`, "red");
71162
- return 1;
71163
- }
71164
- const rp = Bun.spawn({
71165
- cmd: buildTaskCmd(),
71166
- cwd: cwd2,
71167
- stdout: "ignore",
71168
- stderr: "ignore",
71169
- stdin: "ignore"
71170
- });
71171
- return rp.exited;
71172
- };
71173
- let commitFixAttempt = 0;
71174
- let commitGaveUp = false;
71175
- while (true) {
71176
- let dirty = "";
71177
- try {
71178
- const status = await bunCmdRunner.run(["git", "status", "--porcelain"], cwd2);
71179
- dirty = status.stdout.trim();
71180
- } catch (err) {
71181
- appendLog(`! git status failed for ${changeName}: ${err.message}`, "yellow");
71182
- break;
71183
- }
71184
- if (!dirty)
71185
- break;
71186
- try {
71187
- await bunCmdRunner.run(["git", "add", "-A"], cwd2);
71188
- await bunCmdRunner.run(["git", "commit", "-m", `ralph: residual changes for ${changeName}`], cwd2);
71189
- appendLog(` committed residual changes for ${changeName}`, "gray");
71190
- break;
71191
- } catch (err) {
71192
- const e = err;
71193
- const detail = e.stderr?.trim() || e.message;
71194
- const combined = `${e.stdout ?? ""}
71195
- ${e.stderr ?? ""}`;
71196
- if (/nothing to commit/i.test(combined))
71197
- break;
71198
- if (commitFixAttempt >= maxHookFixAttempts) {
71199
- appendLog(`! commit rejected for ${changeName} after ${commitFixAttempt} fix attempts (host pre-commit hook still failing) \u2014 worktree preserved at ${cwd2}`, "red");
71200
- appendLog(` detail: ${detail}`, "red");
71201
- effectiveCode = PR_FAILED_EXIT;
71202
- commitGaveUp = true;
71203
- break;
71204
- }
71205
- commitFixAttempt += 1;
71206
- appendLog(`! commit rejected for ${changeName} \u2014 feeding error back to worker (attempt ${commitFixAttempt}/${maxHookFixAttempts})`, "yellow");
71207
- appendLog(` detail: ${detail}`, "yellow");
71208
- const retryCode = await runWorkerWithFixSteering("Fix host pre-commit hook rejection", `Committing residual changes was rejected by the host repo's pre-commit hook. ` + `Fix the underlying problem reported below, then the commit will be retried.
71209
-
71210
- ` + "```\n" + combined.trim() + "\n```");
71211
- if (retryCode !== 0) {
71212
- appendLog(`! worker re-run after commit rejection exited code ${retryCode} \u2014 giving up`, "red");
71213
- effectiveCode = PR_FAILED_EXIT;
71214
- commitGaveUp = true;
71215
- break;
71216
- }
71217
- }
71218
- }
71219
- let pushFixAttempt = 0;
71220
- let pr = null;
71221
- let prGaveUp = commitGaveUp;
71222
- while (!prGaveUp) {
71223
- try {
71224
- pr = await createPullRequest({ cwd: cwd2, branch, issue: prIssue, base: cfg.prBaseBranch }, bunCmdRunner);
71225
- break;
71226
- } catch (err) {
71227
- const e = err;
71228
- const detail = e.stderr?.trim() || e.message;
71229
- const combined = `${e.stdout ?? ""}
71230
- ${e.stderr ?? ""}`;
71231
- const pushRejected = /failed to push some refs|pre-push hook|hook declined/i.test(combined);
71232
- if (!pushRejected || pushFixAttempt >= maxHookFixAttempts) {
71233
- if (pushRejected) {
71234
- appendLog(`! push rejected for ${changeName} after ${pushFixAttempt} fix attempts (host pre-push hook still failing) \u2014 worktree preserved at ${cwd2}`, "red");
71235
- appendLog(` detail: ${detail}`, "red");
71236
- } else {
71237
- appendLog(`! PR create failed for ${changeName}: ${detail}`, "red");
71238
- }
71239
- effectiveCode = PR_FAILED_EXIT;
71240
- prGaveUp = true;
71241
- break;
71242
- }
71243
- pushFixAttempt += 1;
71244
- appendLog(`! push rejected for ${changeName} \u2014 feeding error back to worker (attempt ${pushFixAttempt}/${maxHookFixAttempts})`, "yellow");
71245
- appendLog(` detail: ${detail}`, "yellow");
71246
- const retryCode = await runWorkerWithFixSteering("Fix host pre-push hook rejection", `Push to origin/${branch} was rejected by the host repo's pre-push hook. ` + `Fix the underlying problem reported below, then the push will be retried.
71247
-
71248
- ` + "```\n" + combined.trim() + "\n```");
71249
- if (retryCode !== 0) {
71250
- appendLog(`! worker re-run after push rejection exited code ${retryCode} \u2014 giving up`, "red");
71251
- effectiveCode = PR_FAILED_EXIT;
71252
- prGaveUp = true;
71253
- break;
71254
- }
71255
- }
71256
- }
71257
- if (prGaveUp) {} else if (!pr) {
71258
- appendLog(` no commits ahead of ${cfg.prBaseBranch} \u2014 skipping PR`, "gray");
71259
- } else {
71260
- appendLog(` ${pr.created ? "opened" : "found existing"} PR: ${pr.url}`, "green");
71261
- const wantFixCi = args.fixCi || cfg.fixCiOnFailure;
71262
- if (wantFixCi) {
71263
- appendLog(` watching CI for ${pr.url} (max ${cfg.maxCiFixAttempts} fix attempts)`, "gray");
71264
- const result2 = await fixCiUntilGreen({
71265
- getStatus: () => getPrChecksStatus(pr.url, bunCmdRunner, cwd2),
71266
- getFailedLogs: (ids) => fetchFailedRunLogs(ids, bunCmdRunner, cwd2),
71267
- runTaskWithSteering: async (steering) => {
71268
- try {
71269
- await injectFixSteering(changeDir, "Fix failing CI checks", `CI feedback:
71270
-
71271
- ${steering}`);
71272
- } catch (err) {
71273
- appendLog(`! could not inject steering: ${err.message}`, "red");
71274
- }
71275
- const p = Bun.spawn({
71276
- cmd: buildTaskCmd(),
71277
- cwd: cwd2,
71278
- stdout: "ignore",
71279
- stderr: "ignore",
71280
- stdin: "ignore"
71281
- });
71282
- return p.exited;
71283
- },
71284
- pushBranch: async () => {
71285
- await bunCmdRunner.run(["git", "push", "origin", branch], cwd2);
71286
- },
71287
- log: appendLog,
71288
- sleep: (ms) => new Promise((r) => setTimeout(r, ms))
71289
- }, {
71290
- maxAttempts: cfg.maxCiFixAttempts,
71291
- pollIntervalSeconds: cfg.ciPollIntervalSeconds
71292
- });
71293
- if (!result2.success) {
71294
- appendLog(`! CI fix loop gave up after ${result2.attempts} attempts (${result2.reason ?? "unknown"}) \u2014 withholding done-status until CI passes`, "red");
71295
- effectiveCode = CI_FAILED_EXIT;
71296
- }
71297
- }
71298
- }
71299
- }
71300
- }
71301
- if (useWorktree && cwd2 !== projectRoot) {
71302
- if (effectiveCode === 0 && cfg.cleanupWorktreeOnSuccess) {
71303
- const check = await isWorktreeSafeToRemove(cwd2, cfg.prBaseBranch, bunGitRunner).catch((err) => ({
71304
- safe: false,
71305
- reason: `safety check failed: ${err.message}`,
71306
- dirty: "",
71307
- unpushedCommits: ""
71308
- }));
71309
- if (!check.safe) {
71310
- appendLog(`! preserving worktree for ${changeName}: ${check.reason}`, "yellow");
71311
- if (check.dirty) {
71312
- appendLog(` uncommitted:
71313
- ${check.dirty}`, "yellow");
71314
- }
71315
- if (check.unpushedCommits) {
71316
- appendLog(` commits:
71317
- ${check.unpushedCommits}`, "yellow");
71318
- }
71319
- appendLog(` path: ${cwd2}`, "yellow");
71320
- } else {
71321
- try {
71322
- await removeWorktree(projectRoot, cwd2, bunGitRunner);
71323
- appendLog(` removed worktree ${cwd2}`, "gray");
71324
- } catch (err) {
71325
- appendLog(`! worktree remove failed for ${changeName}: ${err.message}`, "yellow");
71326
- }
71327
- }
71328
- }
71329
- }
71330
- cwdByChange.delete(changeName);
71331
- statesDirByChange.delete(changeName);
71332
- branchByChange.delete(changeName);
71333
- issueByChange.delete(changeName);
71334
- workerMetaRef.current.delete(changeName);
71335
- return effectiveCode;
71336
- });
71337
- return { exited: wrapped, kill: () => proc.kill() };
71338
71476
  },
71339
- loadState: () => readAgentState(projectRoot),
71340
- saveState: (s) => writeAgentState(projectRoot, s),
71341
- onLog: appendLog,
71342
- onWorkersChanged: () => setTick((t) => t + 1),
71343
- getIterationCount: async (changeName) => {
71344
- const dir = statesDirByChange.get(changeName) ?? statesDir;
71345
- const file = Bun.file(join14(dir, changeName, ".ralph-state.json"));
71346
- if (!await file.exists())
71347
- return 0;
71348
- const json = await file.json();
71349
- return json.iteration ?? 0;
71350
- },
71351
- updater: {
71352
- postComment: (issue, body) => addIssueComment(apiKey, issue.id, body),
71353
- setState: (issue, stateId) => updateIssueState(apiKey, issue.id, stateId),
71354
- resolveStateId: async (issue, stateName) => {
71355
- const team = teamKeyOf(issue);
71356
- let map2 = stateCache.get(team);
71357
- if (!map2) {
71358
- const states = await fetchWorkflowStates(apiKey, team);
71359
- map2 = new Map(states.map((s) => [s.name.toLowerCase(), s.id]));
71360
- stateCache.set(team, map2);
71361
- }
71362
- return map2.get(stateName.toLowerCase()) ?? null;
71363
- },
71364
- addLabel: (issue, labelId) => addLabelToIssue(apiKey, issue.id, labelId),
71365
- resolveLabelId: async (issue, labelName) => {
71366
- const team = teamKeyOf(issue);
71367
- let map2 = labelCache.get(team);
71368
- if (!map2) {
71369
- const labels = await fetchIssueLabels(apiKey, team);
71370
- map2 = new Map(labels.map((l) => [l.name.toLowerCase(), l.id]));
71371
- labelCache.set(team, map2);
71372
- }
71373
- return map2.get(labelName.toLowerCase()) ?? null;
71374
- }
71375
- }
71376
- }, {
71377
- concurrency,
71378
- filter: filter2,
71379
- inProgressStatus: args.inProgressStatus || cfg.linear.inProgressStatus,
71380
- doneStatus: args.doneStatus || cfg.linear.doneStatus,
71381
- doneLabel: args.doneLabel || cfg.linear.doneLabel,
71382
- postComments: cfg.linear.postComments,
71383
- commentEveryIterations: cfg.linear.updateEveryIterations
71477
+ onWorkerExited: (changeName) => {
71478
+ workerMetaRef.current.delete(changeName);
71479
+ }
71384
71480
  });
71481
+ appendLog(`concurrency=${concurrency} pollInterval=${pollInterval}s`, "gray");
71385
71482
  coordRef.current = coord2;
71386
71483
  await coord2.init();
71387
- const filterDesc = `team=${filter2.team ?? "*"}, assignee=${filter2.assignee ?? "*"}, statuses=${filter2.statuses?.length ? filter2.statuses.join(",") : "open"}${filter2.labels?.length ? `, labels=${filter2.labels.join(",")}` : ""}`;
71388
71484
  const tick = async () => {
71389
71485
  if (cancelled)
71390
71486
  return;
@@ -71435,7 +71531,7 @@ ${check.unpushedCommits}`, "yellow");
71435
71531
  (async () => {
71436
71532
  for (const [changeName, meta] of workerMetaRef.current) {
71437
71533
  try {
71438
- const file = Bun.file(join14(meta.statesDir, changeName, ".ralph-state.json"));
71534
+ const file = Bun.file(join16(meta.statesDir, changeName, ".ralph-state.json"));
71439
71535
  if (await file.exists()) {
71440
71536
  const json = await file.json();
71441
71537
  meta.iter = json.iteration ?? meta.iter;
@@ -71516,11 +71612,11 @@ ${check.unpushedCommits}`, "yellow");
71516
71612
  }
71517
71613
 
71518
71614
  // packages/openspec/src/openspec-change-store.ts
71519
- import { join as join15, dirname as dirname4 } from "path";
71615
+ import { join as join17, dirname as dirname4 } from "path";
71520
71616
  import { readdir, mkdir as mkdir3 } from "fs/promises";
71521
71617
  function resolveOpenspecBin() {
71522
71618
  const pkgJsonPath = Bun.resolveSync("@fission-ai/openspec/package.json", import.meta.dir);
71523
- return join15(dirname4(pkgJsonPath), "bin", "openspec.js");
71619
+ return join17(dirname4(pkgJsonPath), "bin", "openspec.js");
71524
71620
  }
71525
71621
  function runOpenspec(args, options = {}) {
71526
71622
  const stdio = options.inherit ? ["inherit", "inherit", "inherit"] : ["ignore", "pipe", "pipe"];
@@ -71546,7 +71642,7 @@ class OpenSpecChangeStore {
71546
71642
  }
71547
71643
  }
71548
71644
  getChangeDirectory(name) {
71549
- return join15("openspec", "changes", name);
71645
+ return join17("openspec", "changes", name);
71550
71646
  }
71551
71647
  async listChanges() {
71552
71648
  const result2 = runOpenspec(["list", "--json"]);
@@ -71560,7 +71656,7 @@ class OpenSpecChangeStore {
71560
71656
  }
71561
71657
  } catch {}
71562
71658
  }
71563
- const changesDir = join15("openspec", "changes");
71659
+ const changesDir = join17("openspec", "changes");
71564
71660
  if (!await Bun.file(changesDir).exists())
71565
71661
  return [];
71566
71662
  try {
@@ -71571,18 +71667,18 @@ class OpenSpecChangeStore {
71571
71667
  }
71572
71668
  }
71573
71669
  async readTaskList(name) {
71574
- const file = Bun.file(join15("openspec", "changes", name, "tasks.md"));
71670
+ const file = Bun.file(join17("openspec", "changes", name, "tasks.md"));
71575
71671
  if (!await file.exists())
71576
71672
  return "";
71577
71673
  return await file.text();
71578
71674
  }
71579
71675
  async writeTaskList(name, content) {
71580
- const path = join15("openspec", "changes", name, "tasks.md");
71676
+ const path = join17("openspec", "changes", name, "tasks.md");
71581
71677
  await mkdir3(dirname4(path), { recursive: true });
71582
71678
  await Bun.write(path, content);
71583
71679
  }
71584
71680
  async appendSteering(name, message) {
71585
- const path = join15("openspec", "changes", name, "steering.md");
71681
+ const path = join17("openspec", "changes", name, "steering.md");
71586
71682
  const file = Bun.file(path);
71587
71683
  const existing = await file.exists() ? await file.text() : null;
71588
71684
  const updated = existing ? `${message}
@@ -71593,7 +71689,7 @@ ${existing.trimStart()}` : `${message}
71593
71689
  await Bun.write(path, updated);
71594
71690
  }
71595
71691
  async readSection(name, artifact, heading) {
71596
- const file = Bun.file(join15("openspec", "changes", name, artifact));
71692
+ const file = Bun.file(join17("openspec", "changes", name, artifact));
71597
71693
  if (!await file.exists())
71598
71694
  return "";
71599
71695
  const content = await file.text();
@@ -71675,8 +71771,8 @@ function App2({ args, statesDir, tasksDir, projectRoot }) {
71675
71771
  message: "Error: --name is required for status mode"
71676
71772
  }, undefined, false, undefined, this);
71677
71773
  }
71678
- const stateDir = join16(statesDir, args.name);
71679
- if (getStorage().read(join16(stateDir, ".ralph-state.json")) === null) {
71774
+ const stateDir = join18(statesDir, args.name);
71775
+ if (getStorage().read(join18(stateDir, ".ralph-state.json")) === null) {
71680
71776
  return /* @__PURE__ */ jsx_dev_runtime10.jsxDEV(ErrorMessage, {
71681
71777
  message: `Error: change '${args.name}' not found`
71682
71778
  }, undefined, false, undefined, this);
@@ -71737,7 +71833,7 @@ if (typeof globalThis.Bun === "undefined") {
71737
71833
  async function findProjectRoot() {
71738
71834
  let dir = process.cwd();
71739
71835
  while (dir !== "/") {
71740
- if (await exists2(join17(dir, "openspec")))
71836
+ if (await exists2(join19(dir, "openspec")))
71741
71837
  return dir;
71742
71838
  dir = resolve(dir, "..");
71743
71839
  }
@@ -71772,11 +71868,12 @@ try {
71772
71868
  capture("command_run", { mode: args.mode, engine: args.engine, model: args.model });
71773
71869
  try {
71774
71870
  const projectRoot = await findProjectRoot();
71775
- const statesDir = join17(projectRoot, ".ralph", "tasks");
71776
- const tasksDir = join17(projectRoot, "openspec", "changes");
71871
+ const layout = projectLayout(projectRoot);
71872
+ const statesDir = layout.statesDir;
71873
+ const tasksDir = layout.tasksDir;
71777
71874
  if (args.mode === "init") {
71778
71875
  await mkdir4(statesDir, { recursive: true });
71779
- const openspecBin = join17(dirname5(Bun.resolveSync("@fission-ai/openspec/package.json", import.meta.dir)), "bin", "openspec.js");
71876
+ const openspecBin = join19(dirname5(Bun.resolveSync("@fission-ai/openspec/package.json", import.meta.dir)), "bin", "openspec.js");
71780
71877
  Bun.spawnSync({
71781
71878
  cmd: [process.execPath, openspecBin, "init", "--tools", "none", "--force"],
71782
71879
  stdio: ["inherit", "inherit", "inherit"],
@@ -71789,9 +71886,9 @@ try {
71789
71886
  `);
71790
71887
  process.exit(1);
71791
71888
  }
71792
- const worktreeDir = join17(worktreesDir(projectRoot), args.name);
71793
- const changeDir = join17(tasksDir, args.name);
71794
- const stateDir = join17(statesDir, args.name);
71889
+ const worktreeDir = join19(worktreesDir(projectRoot), args.name);
71890
+ const changeDir = join19(tasksDir, args.name);
71891
+ const stateDir = join19(statesDir, args.name);
71795
71892
  const branch = `ralph/${args.name}`;
71796
71893
  const removed = [];
71797
71894
  if (await exists2(worktreeDir)) {
@@ -71830,12 +71927,11 @@ try {
71830
71927
  removed.push(`task state ${stateDir}`);
71831
71928
  }
71832
71929
  try {
71833
- const agentState = await readAgentState(projectRoot);
71834
- const entry = Object.values(agentState.tasks).find((t) => t.changeName === args.name);
71835
- if (entry) {
71836
- delete agentState.tasks[entry.identifier];
71837
- await writeAgentState(projectRoot, agentState);
71838
- removed.push(`agent-state entry for ${entry.identifier} (${entry.issueId})`);
71930
+ const store = new AgentStateStore(projectRoot);
71931
+ await store.load();
71932
+ const removedEntry = await store.removeByChangeName(args.name);
71933
+ if (removedEntry) {
71934
+ removed.push(`agent-state entry for ${removedEntry.identifier} (${removedEntry.issueId})`);
71839
71935
  }
71840
71936
  } catch {}
71841
71937
  if (removed.length === 0) {
@@ -71852,13 +71948,13 @@ try {
71852
71948
  process.exit(0);
71853
71949
  }
71854
71950
  if (args.mode === "task" && args.name) {
71855
- await mkdir4(join17(statesDir, args.name), { recursive: true });
71856
- await mkdir4(join17(tasksDir, args.name), { recursive: true });
71951
+ await mkdir4(join19(statesDir, args.name), { recursive: true });
71952
+ await mkdir4(join19(tasksDir, args.name), { recursive: true });
71857
71953
  }
71858
71954
  if (args.mode === "agent") {
71859
71955
  await mkdir4(statesDir, { recursive: true });
71860
71956
  await mkdir4(tasksDir, { recursive: true });
71861
- await mkdir4(join17(projectRoot, ".ralph"), { recursive: true });
71957
+ await mkdir4(join19(projectRoot, ".ralph"), { recursive: true });
71862
71958
  }
71863
71959
  await runWithContext(createDefaultContext(), async () => {
71864
71960
  const { waitUntilExit } = render_default(import_react59.createElement(App2, { args, statesDir, tasksDir, projectRoot }));