@neriros/ralphy 2.11.1 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli/index.js +1231 -849
  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 join20, 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.1",
56410
+ version: "2.12.0",
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 join19 } 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,170 @@ 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 join17 } 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
+ // apps/cli/src/agent/wire.ts
70253
+ import { join as join16 } from "path";
70254
+
70255
+ // packages/core/src/layout.ts
70256
+ import { join as join12 } from "path";
70257
+ var STATE_FILE2 = ".ralph-state.json";
70258
+ function projectLayout(root) {
70259
+ const statesDir = join12(root, ".ralph", "tasks");
70260
+ const tasksDir = join12(root, "openspec", "changes");
70261
+ return {
70262
+ root,
70263
+ statesDir,
70264
+ tasksDir,
70265
+ agentStateFile: join12(root, ".ralph", "agent-state.json"),
70266
+ changeDir: (name) => join12(tasksDir, name),
70267
+ taskStateDir: (name) => join12(statesDir, name),
70268
+ stateFile: (name) => join12(statesDir, name, STATE_FILE2)
70269
+ };
70270
+ }
70072
70271
 
70073
70272
  // apps/cli/src/agent/linear.ts
70074
70273
  var OPEN_STATE_TYPES = ["unstarted", "started", "backlog"];
@@ -70211,300 +70410,78 @@ async function addLabelToIssue(apiKey, issueId, labelId) {
70211
70410
  });
70212
70411
  }
70213
70412
 
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)
70413
+ // apps/cli/src/agent/coordinator.ts
70414
+ class AgentCoordinator {
70415
+ deps;
70416
+ opts;
70417
+ workers = [];
70418
+ pendingIds = new Set;
70419
+ queue = [];
70420
+ stopped = false;
70421
+ constructor(deps, opts) {
70422
+ this.deps = deps;
70423
+ this.opts = opts;
70424
+ }
70425
+ get activeCount() {
70426
+ return this.workers.length;
70427
+ }
70428
+ get queuedCount() {
70429
+ return this.queue.length;
70430
+ }
70431
+ get activeWorkers() {
70432
+ return this.workers;
70433
+ }
70434
+ async init() {}
70435
+ async pollOnce() {
70436
+ if (this.stopped)
70437
+ return { found: 0, added: 0 };
70438
+ let issues;
70439
+ try {
70440
+ issues = await this.deps.fetchIssues(this.opts.filter);
70441
+ } catch (err) {
70442
+ this.deps.onLog(`! Linear poll failed: ${err.message}`, "red");
70443
+ return { found: 0, added: 0 };
70444
+ }
70445
+ const state = this.deps.store.snapshot();
70446
+ const tasksByIssueId = new Map;
70447
+ for (const entry of Object.values(state.tasks)) {
70448
+ tasksByIssueId.set(entry.issueId, entry);
70449
+ }
70450
+ const isProcessed = (id) => tasksByIssueId.get(id)?.state === "processed";
70451
+ const isFailed = (id) => tasksByIssueId.get(id)?.state === "failed";
70452
+ const queued = new Set(this.queue.map((i) => i.id));
70453
+ const active = new Set(this.workers.map((w) => w.issueId));
70454
+ let added = 0;
70455
+ for (const issue of issues) {
70456
+ if (isProcessed(issue.id))
70252
70457
  continue;
70253
- tasks[found.identifier] = {
70254
- issueId,
70255
- identifier: found.identifier,
70256
- state,
70257
- changeName: found.changeName
70258
- };
70458
+ if (isFailed(issue.id))
70459
+ continue;
70460
+ if (queued.has(issue.id))
70461
+ continue;
70462
+ if (active.has(issue.id))
70463
+ continue;
70464
+ if (this.pendingIds.has(issue.id))
70465
+ continue;
70466
+ const blocker = issue.blockedByIds.find((bid) => !isProcessed(bid));
70467
+ if (blocker !== undefined) {
70468
+ this.deps.onLog(` \u23F8 ${issue.identifier} skipped \u2014 blocked by unresolved dependency`, "yellow");
70469
+ continue;
70470
+ }
70471
+ this.queue.push(issue);
70472
+ added += 1;
70259
70473
  }
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 };
70474
+ if (added > 0) {
70475
+ this.queue.sort((a, b) => {
70476
+ const pa = a.priority === 0 ? Infinity : a.priority;
70477
+ const pb = b.priority === 0 ? Infinity : b.priority;
70478
+ return pa - pb;
70479
+ });
70480
+ }
70481
+ await this.deps.store.setLastPollAt(new Date().toISOString());
70482
+ this.spawnNext();
70483
+ await this.reportProgress();
70484
+ return { found: issues.length, added };
70508
70485
  }
70509
70486
  async reportProgress() {
70510
70487
  const updater = this.deps.updater;
@@ -70535,7 +70512,7 @@ class AgentCoordinator {
70535
70512
  }
70536
70513
  }
70537
70514
  spawnNext() {
70538
- if (this.stopped || !this.state)
70515
+ if (this.stopped)
70539
70516
  return;
70540
70517
  while (this.workers.length + this.pendingIds.size < this.opts.concurrency && this.queue.length > 0) {
70541
70518
  const issue = this.queue.shift();
@@ -70543,18 +70520,6 @@ class AgentCoordinator {
70543
70520
  this.launchWorker(issue);
70544
70521
  }
70545
70522
  }
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
70523
  async launchWorker(issue) {
70559
70524
  let changeName;
70560
70525
  try {
@@ -70569,13 +70534,13 @@ class AgentCoordinator {
70569
70534
  this.pendingIds.delete(issue.id);
70570
70535
  return;
70571
70536
  }
70572
- if (this.state) {
70573
- this.upsertTask(issue, {
70537
+ {
70538
+ const existing = this.deps.store.snapshot().tasks[issue.identifier];
70539
+ this.deps.store.upsertTask(issue, {
70574
70540
  state: "started",
70575
70541
  changeName,
70576
- startedAt: this.state.tasks[issue.identifier]?.startedAt ?? new Date().toISOString()
70542
+ startedAt: existing?.startedAt ?? new Date().toISOString()
70577
70543
  });
70578
- this.deps.saveState(this.state);
70579
70544
  }
70580
70545
  this.deps.onLog(`\u25B6 ${issue.identifier} \u2192 ${changeName} (worker started)`, "cyan");
70581
70546
  const handle = this.deps.spawnWorker(changeName, issue);
@@ -70597,14 +70562,11 @@ class AgentCoordinator {
70597
70562
  this.workers.splice(idx, 1);
70598
70563
  const ok = code === 0;
70599
70564
  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
- }
70565
+ this.deps.store.upsertTask(issue, {
70566
+ state: ok ? "processed" : "failed",
70567
+ finishedAt: new Date().toISOString(),
70568
+ exitCode: code
70569
+ });
70608
70570
  this.notifyExited(issue, changeName, code);
70609
70571
  this.deps.onWorkersChanged();
70610
70572
  this.spawnNext();
@@ -70614,14 +70576,11 @@ class AgentCoordinator {
70614
70576
  const updater = this.deps.updater;
70615
70577
  if (!updater)
70616
70578
  return;
70617
- const alreadyCommented = this.state?.tasks[issue.identifier]?.commentPosted === true;
70579
+ const alreadyCommented = this.deps.store.snapshot().tasks[issue.identifier]?.commentPosted === true;
70618
70580
  if (this.opts.postComments !== false && !alreadyCommented) {
70619
70581
  try {
70620
70582
  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
- }
70583
+ await this.deps.store.upsertTask(issue, { commentPosted: true });
70625
70584
  } catch (err) {
70626
70585
  this.deps.onLog(`! Linear comment failed for ${issue.identifier}: ${err.message}`, "red");
70627
70586
  }
@@ -70694,66 +70653,137 @@ class AgentCoordinator {
70694
70653
  }
70695
70654
  }
70696
70655
 
70697
- // apps/cli/src/agent/worktree.ts
70698
- import { basename, join as join13 } from "path";
70699
- import { homedir as homedir2 } from "os";
70700
- function worktreesDir(projectRoot) {
70701
- return join13(homedir2(), ".ralph", basename(projectRoot), "worktrees");
70702
- }
70703
- function branchForChange(changeName) {
70704
- return `ralph/${changeName}`;
70705
- }
70706
- async function createWorktree(projectRoot, changeName, runner) {
70707
- const dir = worktreesDir(projectRoot);
70708
- const cwd2 = join13(dir, changeName);
70709
- const branch = branchForChange(changeName);
70710
- const list = await runner.run(["worktree", "list", "--porcelain"], projectRoot);
70711
- if (list.stdout.includes(`worktree ${cwd2}
70712
- `)) {
70713
- return { cwd: cwd2, branch };
70714
- }
70715
- let branchExists = true;
70716
- try {
70717
- await runner.run(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], projectRoot);
70718
- } catch {
70719
- branchExists = false;
70720
- }
70721
- const cmd = branchExists ? ["worktree", "add", cwd2, branch] : ["worktree", "add", "-b", branch, cwd2];
70722
- await runner.run(cmd, projectRoot);
70723
- return { cwd: cwd2, branch };
70724
- }
70725
- async function removeWorktree(projectRoot, cwd2, runner) {
70726
- await runner.run(["worktree", "remove", "--force", cwd2], projectRoot);
70656
+ // apps/cli/src/agent/scaffold.ts
70657
+ import { join as join13 } from "path";
70658
+ import { mkdir as mkdir2 } from "fs/promises";
70659
+ function changeNameForIssue(issue) {
70660
+ const slug = issue.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
70661
+ return slug ? `${issue.identifier.toLowerCase()}-${slug}` : issue.identifier.toLowerCase();
70727
70662
  }
70728
- async function isWorktreeSafeToRemove(cwd2, base2, runner) {
70729
- const status = await runner.run(["status", "--porcelain"], cwd2);
70730
- const dirty = status.stdout.trim();
70731
- let unpushedCommits = "";
70732
- try {
70733
- const log2 = await runner.run(["log", "--oneline", `${base2}..HEAD`, "--no-merges"], cwd2);
70734
- unpushedCommits = log2.stdout.trim();
70735
- } catch {
70736
- unpushedCommits = "<unknown: failed to compare against base>";
70737
- }
70738
- if (dirty && unpushedCommits) {
70739
- return {
70740
- safe: false,
70741
- reason: "uncommitted changes AND unpushed commits present",
70742
- dirty,
70743
- unpushedCommits
70744
- };
70745
- }
70746
- if (dirty) {
70747
- return {
70748
- safe: false,
70749
- reason: "uncommitted or untracked files present",
70750
- dirty,
70751
- unpushedCommits
70752
- };
70753
- }
70754
- if (unpushedCommits) {
70755
- return {
70756
- safe: false,
70663
+ async function scaffoldChangeForIssue(tasksDir, statesDir, issue, comments = [], appendPrompt = "") {
70664
+ const name = changeNameForIssue(issue);
70665
+ const changeDir = join13(tasksDir, name);
70666
+ const stateDir = join13(statesDir, name);
70667
+ await mkdir2(changeDir, { recursive: true });
70668
+ await mkdir2(join13(changeDir, "specs"), { recursive: true });
70669
+ await mkdir2(stateDir, { recursive: true });
70670
+ const commentsBlock = comments.length > 0 ? [
70671
+ "",
70672
+ "## Linear comments",
70673
+ "",
70674
+ ...comments.flatMap((c) => [
70675
+ `**${c.user?.name ?? "unknown"}** \u2014 ${c.createdAt}`,
70676
+ "",
70677
+ c.body.trim(),
70678
+ ""
70679
+ ])
70680
+ ] : [];
70681
+ const proposal = [
70682
+ `# ${issue.identifier}: ${issue.title}`,
70683
+ "",
70684
+ `Source: [${issue.identifier}](${issue.url})`,
70685
+ `Status: ${issue.state.name}`,
70686
+ issue.assignee ? `Assignee: ${issue.assignee.name}` : "",
70687
+ issue.labels.length ? `Labels: ${issue.labels.join(", ")}` : "",
70688
+ "",
70689
+ "## Description",
70690
+ "",
70691
+ issue.description?.trim() || "_No description provided in Linear._",
70692
+ ...commentsBlock,
70693
+ ...appendPrompt.trim() ? ["", "## Additional instructions", "", appendPrompt.trim()] : [],
70694
+ "",
70695
+ "## Steering",
70696
+ "",
70697
+ "_Add steering notes here as the loop runs._",
70698
+ ""
70699
+ ].filter((l) => l !== "").join(`
70700
+ `);
70701
+ const tasks = [
70702
+ `# Tasks for ${issue.identifier}`,
70703
+ "",
70704
+ "## Subtasks",
70705
+ "",
70706
+ `- [ ] Read the Linear issue at ${issue.url} and break it into concrete subtasks`,
70707
+ `- [ ] Implement the changes described in proposal.md`,
70708
+ `- [ ] Add or update tests covering the new behavior`,
70709
+ `- [ ] Run \`bun run lint\` and \`bun run test\` and fix any failures`,
70710
+ ""
70711
+ ].join(`
70712
+ `);
70713
+ const design = [
70714
+ `# Design for ${issue.identifier}`,
70715
+ "",
70716
+ "_Fill in the technical design as you work through the issue._",
70717
+ ""
70718
+ ].join(`
70719
+ `);
70720
+ await Bun.write(join13(changeDir, "proposal.md"), proposal);
70721
+ await Bun.write(join13(changeDir, "tasks.md"), tasks);
70722
+ await Bun.write(join13(changeDir, "design.md"), design);
70723
+ return name;
70724
+ }
70725
+
70726
+ // apps/cli/src/agent/worktree.ts
70727
+ import { basename, join as join14 } from "path";
70728
+ import { homedir as homedir2 } from "os";
70729
+ import { exists } from "fs/promises";
70730
+ function worktreesDir(projectRoot) {
70731
+ return join14(homedir2(), ".ralph", basename(projectRoot), "worktrees");
70732
+ }
70733
+ function branchForChange(changeName) {
70734
+ return `ralph/${changeName}`;
70735
+ }
70736
+ async function createWorktree(projectRoot, changeName, runner) {
70737
+ const dir = worktreesDir(projectRoot);
70738
+ const cwd2 = join14(dir, changeName);
70739
+ const branch = branchForChange(changeName);
70740
+ const list = await runner.run(["worktree", "list", "--porcelain"], projectRoot);
70741
+ if (list.stdout.includes(`worktree ${cwd2}
70742
+ `)) {
70743
+ return { cwd: cwd2, branch };
70744
+ }
70745
+ let branchExists = true;
70746
+ try {
70747
+ await runner.run(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], projectRoot);
70748
+ } catch {
70749
+ branchExists = false;
70750
+ }
70751
+ const cmd = branchExists ? ["worktree", "add", cwd2, branch] : ["worktree", "add", "-b", branch, cwd2];
70752
+ await runner.run(cmd, projectRoot);
70753
+ return { cwd: cwd2, branch };
70754
+ }
70755
+ async function removeWorktree(projectRoot, cwd2, runner) {
70756
+ await runner.run(["worktree", "remove", "--force", cwd2], projectRoot);
70757
+ }
70758
+ async function isWorktreeSafeToRemove(cwd2, base2, runner) {
70759
+ const status = await runner.run(["status", "--porcelain"], cwd2);
70760
+ const dirty = status.stdout.trim();
70761
+ let unpushedCommits = "";
70762
+ try {
70763
+ const log2 = await runner.run(["log", "--oneline", `${base2}..HEAD`, "--no-merges"], cwd2);
70764
+ unpushedCommits = log2.stdout.trim();
70765
+ } catch {
70766
+ unpushedCommits = "<unknown: failed to compare against base>";
70767
+ }
70768
+ if (dirty && unpushedCommits) {
70769
+ return {
70770
+ safe: false,
70771
+ reason: "uncommitted changes AND unpushed commits present",
70772
+ dirty,
70773
+ unpushedCommits
70774
+ };
70775
+ }
70776
+ if (dirty) {
70777
+ return {
70778
+ safe: false,
70779
+ reason: "uncommitted or untracked files present",
70780
+ dirty,
70781
+ unpushedCommits
70782
+ };
70783
+ }
70784
+ if (unpushedCommits) {
70785
+ return {
70786
+ safe: false,
70757
70787
  reason: `commits ahead of ${base2} were not pushed/PR'd`,
70758
70788
  dirty,
70759
70789
  unpushedCommits
@@ -70761,6 +70791,32 @@ async function isWorktreeSafeToRemove(cwd2, base2, runner) {
70761
70791
  }
70762
70792
  return { safe: true, dirty, unpushedCommits };
70763
70793
  }
70794
+ async function seedWorktreeMcpConfig(projectRoot, worktreeCwd) {
70795
+ const dst = join14(worktreeCwd, ".mcp.json");
70796
+ const src = join14(projectRoot, ".mcp.json");
70797
+ const source = await exists(dst) ? dst : await exists(src) ? src : null;
70798
+ if (!source)
70799
+ return;
70800
+ let parsed;
70801
+ try {
70802
+ parsed = await Bun.file(source).json();
70803
+ } catch {
70804
+ return;
70805
+ }
70806
+ const servers = parsed.mcpServers;
70807
+ if (servers && typeof servers === "object") {
70808
+ for (const cfg of Object.values(servers)) {
70809
+ if (Array.isArray(cfg.args)) {
70810
+ cfg.args = cfg.args.map((a) => typeof a === "string" && a.startsWith(".ralph/") ? join14(projectRoot, a) : a);
70811
+ }
70812
+ }
70813
+ }
70814
+ await Bun.write(dst, JSON.stringify(parsed, null, 2) + `
70815
+ `);
70816
+ }
70817
+
70818
+ // apps/cli/src/agent/post-task.ts
70819
+ import { join as join15 } from "path";
70764
70820
 
70765
70821
  // apps/cli/src/agent/pr.ts
70766
70822
  function defaultTitle(issue) {
@@ -70811,8 +70867,32 @@ async function createPullRequest(input, runner) {
70811
70867
 
70812
70868
  // apps/cli/src/agent/ci.ts
70813
70869
  var PR_CHECKS_FIELDS = "name,bucket,link,workflow,event";
70814
- async function getPrChecksStatus(prRef, runner, cwd2) {
70815
- const out = await runner.run(["gh", "pr", "checks", prRef, "--json", PR_CHECKS_FIELDS], cwd2);
70870
+ var TRANSIENT_GH_RE = /HTTP 5\d\d|Gateway Timeout|Bad Gateway|Service Unavailable|connection reset|ECONNRESET|ETIMEDOUT|getaddrinfo|EAI_AGAIN|could not resolve host/i;
70871
+ var GH_RETRY_DELAYS = [5000, 15000, 45000];
70872
+ async function runGhWithRetry(cmd, runner, cwd2, onRetry, sleep2 = (ms) => new Promise((r) => setTimeout(r, ms))) {
70873
+ let lastErr;
70874
+ for (let i = 0;i <= GH_RETRY_DELAYS.length; i++) {
70875
+ try {
70876
+ return await runner.run(cmd, cwd2);
70877
+ } catch (err) {
70878
+ const e = err;
70879
+ const blob = `${e.message}
70880
+ ${e.stderr ?? ""}
70881
+ ${e.stdout ?? ""}`;
70882
+ if (!TRANSIENT_GH_RE.test(blob) || i === GH_RETRY_DELAYS.length)
70883
+ throw err;
70884
+ const delay2 = GH_RETRY_DELAYS[i];
70885
+ const firstLine = (e.stderr?.trim().split(`
70886
+ `)[0] ?? e.message).slice(0, 120);
70887
+ onRetry?.(i + 1, delay2, firstLine);
70888
+ await sleep2(delay2);
70889
+ lastErr = err;
70890
+ }
70891
+ }
70892
+ throw lastErr;
70893
+ }
70894
+ async function getPrChecksStatus(prRef, runner, cwd2, onTransientRetry) {
70895
+ const out = await runGhWithRetry(["gh", "pr", "checks", prRef, "--json", PR_CHECKS_FIELDS], runner, cwd2, onTransientRetry);
70816
70896
  const checks = JSON.parse(out.stdout || "[]").filter((c) => c.bucket !== "skipping");
70817
70897
  if (checks.some((c) => c.bucket === "pending")) {
70818
70898
  return { bucket: "pending", failedRunIds: [] };
@@ -70849,17 +70929,28 @@ ${truncated}`);
70849
70929
  }
70850
70930
  async function fixCiUntilGreen(deps, opts) {
70851
70931
  for (let attempt2 = 1;attempt2 <= opts.maxAttempts; attempt2++) {
70932
+ let pollN = 0;
70852
70933
  while (true) {
70853
70934
  if (deps.cancelled?.())
70854
70935
  return { success: false, attempts: attempt2 - 1, reason: "cancelled" };
70855
- const s = await deps.getStatus();
70936
+ pollN += 1;
70937
+ deps.onPhase?.("ci-poll", `attempt ${attempt2}/${opts.maxAttempts} \xB7 poll ${pollN}`);
70938
+ let s;
70939
+ try {
70940
+ s = await deps.getStatus();
70941
+ } catch (err) {
70942
+ deps.log(`! gh pr checks failed permanently: ${err.message} \u2014 giving up CI watch`, "red");
70943
+ return { success: false, attempts: attempt2 - 1, reason: "gh-failed" };
70944
+ }
70856
70945
  if (s.bucket === "pass") {
70857
70946
  deps.log(`\u2713 CI green for PR (after ${attempt2 - 1} fix attempts)`, "green");
70858
70947
  return { success: true, attempts: attempt2 - 1 };
70859
70948
  }
70860
70949
  if (s.bucket === "fail") {
70861
70950
  deps.log(`\u2717 CI failing (attempt ${attempt2}/${opts.maxAttempts}) \u2014 fetching logs and re-running task`, "yellow");
70951
+ deps.onPhase?.("ci-fix", `attempt ${attempt2}/${opts.maxAttempts} \xB7 fetching logs`);
70862
70952
  const logs = await deps.getFailedLogs(s.failedRunIds);
70953
+ deps.onPhase?.("ci-fix", `attempt ${attempt2}/${opts.maxAttempts} \xB7 re-running worker`);
70863
70954
  const steering = `CI is failing on this PR. Investigate and fix:
70864
70955
 
70865
70956
  \`\`\`
@@ -70870,6 +70961,7 @@ ${logs}
70870
70961
  deps.log(`! task loop exited code ${code} during CI fix attempt ${attempt2}`, "red");
70871
70962
  }
70872
70963
  try {
70964
+ deps.onPhase?.("ci-fix", `attempt ${attempt2}/${opts.maxAttempts} \xB7 pushing fix`);
70873
70965
  await deps.pushBranch();
70874
70966
  } catch (err) {
70875
70967
  deps.log(`! push failed during CI fix: ${err.message}`, "red");
@@ -70877,39 +70969,297 @@ ${logs}
70877
70969
  }
70878
70970
  break;
70879
70971
  }
70972
+ deps.onPhase?.("ci-poll", `attempt ${attempt2}/${opts.maxAttempts} \xB7 pending, waiting`);
70880
70973
  await deps.sleep(opts.pollIntervalSeconds * 1000);
70881
70974
  }
70882
70975
  }
70883
70976
  return { success: false, attempts: opts.maxAttempts, reason: "max-attempts" };
70884
70977
  }
70885
70978
 
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)
70979
+ // apps/cli/src/agent/post-task.ts
70980
+ var CI_FAILED_EXIT = 70;
70981
+ var PR_FAILED_EXIT = 71;
70982
+ async function reactivateState(stateFilePath, log2, changeName) {
70983
+ const file = Bun.file(stateFilePath);
70984
+ if (!await file.exists())
70895
70985
  return;
70896
- let parsed;
70897
70986
  try {
70898
- parsed = await Bun.file(source).json();
70899
- } catch {
70900
- return;
70987
+ const stateObj = JSON.parse(await file.text());
70988
+ if (stateObj.status !== "active") {
70989
+ stateObj.status = "active";
70990
+ stateObj.lastModified = new Date().toISOString();
70991
+ await Bun.write(stateFilePath, JSON.stringify(stateObj, null, 2) + `
70992
+ `);
70993
+ }
70994
+ } catch (err) {
70995
+ log2(`! could not reactivate state for ${changeName}: ${err.message}`, "yellow");
70901
70996
  }
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);
70997
+ }
70998
+ async function runPostTask(input, deps) {
70999
+ const { log: log2, cmd, git, runScript } = deps;
71000
+ const emit = (phase, detail) => deps.onPhase?.(phase, detail);
71001
+ const {
71002
+ changeName,
71003
+ cwd: cwd2,
71004
+ projectRoot,
71005
+ changeDir,
71006
+ stateFilePath,
71007
+ branch,
71008
+ issue,
71009
+ exitCode,
71010
+ useWorktree,
71011
+ wantPr,
71012
+ wantFixCi,
71013
+ cfg,
71014
+ respawnWorker
71015
+ } = input;
71016
+ if (cfg.teardownScript) {
71017
+ emit("teardown", cfg.teardownScript);
71018
+ try {
71019
+ await runScript("teardown", cfg.teardownScript, cwd2);
71020
+ } catch {}
71021
+ }
71022
+ let effectiveCode = exitCode;
71023
+ const ok = exitCode === 0;
71024
+ if (ok && wantPr) {
71025
+ if (!branch || !issue) {
71026
+ log2(`! createPr requested but no worktree branch is tracked for ${changeName} (use --worktree)`, "yellow");
71027
+ effectiveCode = PR_FAILED_EXIT;
71028
+ } else {
71029
+ const maxHookFixAttempts = cfg.maxCiFixAttempts;
71030
+ const runWorkerWithFixTask = async (heading, failureOutput) => {
71031
+ try {
71032
+ await prependFixTask(join15(changeDir, "tasks.md"), heading, failureOutput);
71033
+ } catch (err) {
71034
+ log2(`! could not prepend fix task: ${err.message}`, "red");
71035
+ return 1;
71036
+ }
71037
+ await reactivateState(stateFilePath, log2, changeName);
71038
+ return respawnWorker();
71039
+ };
71040
+ let hookFixAttempt = 0;
71041
+ let commitGaveUp = false;
71042
+ while (true) {
71043
+ emit("committing", "git status");
71044
+ let dirty = "";
71045
+ try {
71046
+ const status = await cmd.run(["git", "status", "--porcelain"], cwd2);
71047
+ dirty = status.stdout.trim();
71048
+ } catch (err) {
71049
+ log2(`! git status failed for ${changeName}: ${err.message}`, "yellow");
71050
+ break;
71051
+ }
71052
+ if (!dirty)
71053
+ break;
71054
+ try {
71055
+ emit("committing", "git add -A");
71056
+ await cmd.run(["git", "add", "-A"], cwd2);
71057
+ emit("committing", "git commit");
71058
+ await cmd.run(["git", "commit", "-m", `chore(ralph): residual changes for ${changeName}`], cwd2);
71059
+ log2(` committed residual changes for ${changeName}`, "gray");
71060
+ break;
71061
+ } catch (err) {
71062
+ const e = err;
71063
+ const detail = e.stderr?.trim() || e.message;
71064
+ const combined = `${e.stdout ?? ""}
71065
+ ${e.stderr ?? ""}`;
71066
+ if (/nothing to commit/i.test(combined))
71067
+ break;
71068
+ if (hookFixAttempt >= maxHookFixAttempts) {
71069
+ log2(`! commit rejected for ${changeName} after ${hookFixAttempt} hook-fix attempts (host pre-commit hook still failing) \u2014 worktree preserved at ${cwd2}`, "red");
71070
+ log2(` detail: ${detail}`, "red");
71071
+ effectiveCode = PR_FAILED_EXIT;
71072
+ commitGaveUp = true;
71073
+ break;
71074
+ }
71075
+ hookFixAttempt += 1;
71076
+ emit("commit-retry", `${hookFixAttempt}/${maxHookFixAttempts}`);
71077
+ log2(`! commit rejected for ${changeName} \u2014 prepending fix task and re-running loop (attempt ${hookFixAttempt}/${maxHookFixAttempts})`, "yellow");
71078
+ log2(` detail: ${detail}`, "yellow");
71079
+ 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.
71080
+
71081
+ ` + combined.trim());
71082
+ if (retryCode !== 0) {
71083
+ log2(`! worker re-run after commit rejection exited code ${retryCode} \u2014 giving up`, "red");
71084
+ effectiveCode = PR_FAILED_EXIT;
71085
+ commitGaveUp = true;
71086
+ break;
71087
+ }
71088
+ }
71089
+ }
71090
+ let pr = null;
71091
+ let prGaveUp = commitGaveUp;
71092
+ let nonFfRebaseAttempted = false;
71093
+ while (!prGaveUp) {
71094
+ try {
71095
+ emit("pr-create", "git push + gh pr create");
71096
+ pr = await createPullRequest({ cwd: cwd2, branch, issue, base: cfg.prBaseBranch }, cmd);
71097
+ break;
71098
+ } catch (err) {
71099
+ const e = err;
71100
+ const detail = e.stderr?.trim() || e.message;
71101
+ const combined = `${e.stdout ?? ""}
71102
+ ${e.stderr ?? ""}`;
71103
+ const isNonFastForward = /non-fast-forward|Updates were rejected because the (tip of your current branch is behind|remote contains work)/i.test(combined) && !/pre-push hook|hook declined/i.test(combined);
71104
+ const isHookReject = /pre-push hook|hook declined/i.test(combined);
71105
+ const pushRejected = isHookReject || /failed to push some refs/i.test(combined);
71106
+ if (isNonFastForward && !nonFfRebaseAttempted) {
71107
+ nonFfRebaseAttempted = true;
71108
+ emit("rebasing", `git pull --rebase origin ${branch}`);
71109
+ log2(` non-fast-forward push for ${changeName} \u2014 rebasing onto origin/${branch}`, "yellow");
71110
+ try {
71111
+ await cmd.run(["git", "fetch", "origin", branch], cwd2);
71112
+ await cmd.run(["git", "pull", "--rebase", "origin", branch], cwd2);
71113
+ continue;
71114
+ } catch (rebaseErr) {
71115
+ const re = rebaseErr;
71116
+ const reBlob = `${re.stdout ?? ""}
71117
+ ${re.stderr ?? ""}`;
71118
+ const isConflict = /CONFLICT|Merge conflict|could not apply|both modified/i.test(reBlob);
71119
+ if (!isConflict) {
71120
+ log2(`! rebase failed for ${changeName}: ${rebaseErr.message} \u2014 giving up`, "red");
71121
+ effectiveCode = PR_FAILED_EXIT;
71122
+ prGaveUp = true;
71123
+ break;
71124
+ }
71125
+ emit("rebasing", "conflicts detected \u2014 aborting + queueing fix task");
71126
+ try {
71127
+ await cmd.run(["git", "rebase", "--abort"], cwd2);
71128
+ } catch {}
71129
+ let conflictedFiles = "";
71130
+ try {
71131
+ const r = await cmd.run(["git", "diff", "--name-only", `HEAD..origin/${branch}`], cwd2);
71132
+ conflictedFiles = r.stdout.trim();
71133
+ } catch {}
71134
+ if (hookFixAttempt >= maxHookFixAttempts) {
71135
+ log2(`! merge conflict on rebase of ${branch} after ${hookFixAttempt} attempts \u2014 worktree preserved at ${cwd2}`, "red");
71136
+ log2(` detail: ${reBlob.trim().split(`
71137
+ `).slice(0, 8).join(`
71138
+ `)}`, "red");
71139
+ effectiveCode = PR_FAILED_EXIT;
71140
+ prGaveUp = true;
71141
+ break;
71142
+ }
71143
+ hookFixAttempt += 1;
71144
+ emit("rebasing", `conflict-fix ${hookFixAttempt}/${maxHookFixAttempts}`);
71145
+ log2(`! merge conflict rebasing ${branch} \u2014 prepending fix task and re-running loop (attempt ${hookFixAttempt}/${maxHookFixAttempts})`, "yellow");
71146
+ const retryCode2 = await runWorkerWithFixTask("Resolve merge conflict with origin/" + branch, `Push to origin/${branch} was rejected as non-fast-forward, and rebasing ` + `onto origin/${branch} produced merge conflicts.
71147
+
71148
+ ` + `Run \`git fetch origin ${branch}\` and \`git rebase origin/${branch}\`, ` + `resolve every conflict, \`git add\` the resolved files, and finish with ` + `\`git rebase --continue\`. The push will be retried after this loop ` + `iteration finishes.
71149
+
71150
+ ` + (conflictedFiles ? `Files that differ between your branch and origin/${branch}:
71151
+ ${conflictedFiles}
71152
+
71153
+ ` : "") + `Rebase output:
71154
+ ${reBlob.trim()}`);
71155
+ if (retryCode2 !== 0) {
71156
+ log2(`! worker re-run after merge conflict exited code ${retryCode2} \u2014 giving up`, "red");
71157
+ effectiveCode = PR_FAILED_EXIT;
71158
+ prGaveUp = true;
71159
+ break;
71160
+ }
71161
+ nonFfRebaseAttempted = false;
71162
+ continue;
71163
+ }
71164
+ }
71165
+ if (!isHookReject || hookFixAttempt >= maxHookFixAttempts) {
71166
+ if (pushRejected) {
71167
+ log2(`! push rejected for ${changeName} after ${hookFixAttempt} hook-fix attempts (host pre-push hook still failing) \u2014 worktree preserved at ${cwd2}`, "red");
71168
+ log2(` detail: ${detail}`, "red");
71169
+ } else {
71170
+ log2(`! PR create failed for ${changeName}: ${detail}`, "red");
71171
+ }
71172
+ effectiveCode = PR_FAILED_EXIT;
71173
+ prGaveUp = true;
71174
+ break;
71175
+ }
71176
+ hookFixAttempt += 1;
71177
+ emit("push-retry", `${hookFixAttempt}/${maxHookFixAttempts}`);
71178
+ log2(`! push rejected for ${changeName} \u2014 prepending fix task and re-running loop (attempt ${hookFixAttempt}/${maxHookFixAttempts})`, "yellow");
71179
+ log2(` detail: ${detail}`, "yellow");
71180
+ 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.
71181
+
71182
+ ` + combined.trim());
71183
+ if (retryCode !== 0) {
71184
+ log2(`! worker re-run after push rejection exited code ${retryCode} \u2014 giving up`, "red");
71185
+ effectiveCode = PR_FAILED_EXIT;
71186
+ prGaveUp = true;
71187
+ break;
71188
+ }
71189
+ }
71190
+ }
71191
+ if (prGaveUp) {} else if (!pr) {
71192
+ log2(` no commits ahead of ${cfg.prBaseBranch} \u2014 skipping PR`, "gray");
71193
+ } else {
71194
+ log2(` ${pr.created ? "opened" : "found existing"} PR: ${pr.url}`, "green");
71195
+ if (wantFixCi) {
71196
+ log2(` watching CI for ${pr.url} (max ${cfg.maxCiFixAttempts} fix attempts)`, "gray");
71197
+ emit("ci-poll", "starting");
71198
+ const result2 = await fixCiUntilGreen({
71199
+ onPhase: (p, d) => emit(p, d),
71200
+ getStatus: () => getPrChecksStatus(pr.url, cmd, cwd2, (n, ms, why) => log2(` gh transient (try ${n}) \u2014 retry in ${Math.round(ms / 1000)}s \xB7 ${why}`, "yellow")),
71201
+ getFailedLogs: (ids) => fetchFailedRunLogs(ids, cmd, cwd2),
71202
+ runTaskWithSteering: async (steering) => {
71203
+ try {
71204
+ await prependFixTask(join15(changeDir, "tasks.md"), "Fix failing CI checks", steering);
71205
+ } catch (err) {
71206
+ log2(`! could not prepend fix task: ${err.message}`, "red");
71207
+ }
71208
+ return respawnWorker();
71209
+ },
71210
+ pushBranch: async () => {
71211
+ await cmd.run(["git", "push", "origin", branch], cwd2);
71212
+ },
71213
+ log: log2,
71214
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms))
71215
+ }, {
71216
+ maxAttempts: cfg.maxCiFixAttempts,
71217
+ pollIntervalSeconds: cfg.ciPollIntervalSeconds
71218
+ });
71219
+ if (!result2.success) {
71220
+ log2(`! CI fix loop gave up after ${result2.attempts} attempts (${result2.reason ?? "unknown"}) \u2014 withholding done-status until CI passes`, "red");
71221
+ effectiveCode = CI_FAILED_EXIT;
71222
+ }
71223
+ }
70907
71224
  }
70908
71225
  }
70909
71226
  }
70910
- await Bun.write(dst, JSON.stringify(parsed, null, 2) + `
70911
- `);
71227
+ if (effectiveCode === 0)
71228
+ emit("done");
71229
+ else
71230
+ emit("gave-up", `exit ${effectiveCode}`);
71231
+ if (useWorktree && cwd2 !== projectRoot) {
71232
+ emit("cleanup", "checking worktree safety");
71233
+ if (effectiveCode === 0 && cfg.cleanupWorktreeOnSuccess) {
71234
+ const check = await isWorktreeSafeToRemove(cwd2, cfg.prBaseBranch, git).catch((err) => ({
71235
+ safe: false,
71236
+ reason: `safety check failed: ${err.message}`,
71237
+ dirty: "",
71238
+ unpushedCommits: ""
71239
+ }));
71240
+ if (!check.safe) {
71241
+ log2(`! preserving worktree for ${changeName}: ${check.reason}`, "yellow");
71242
+ if (check.dirty)
71243
+ log2(` uncommitted:
71244
+ ${check.dirty}`, "yellow");
71245
+ if (check.unpushedCommits)
71246
+ log2(` commits:
71247
+ ${check.unpushedCommits}`, "yellow");
71248
+ log2(` path: ${cwd2}`, "yellow");
71249
+ } else {
71250
+ try {
71251
+ await removeWorktree(projectRoot, cwd2, git);
71252
+ log2(` removed worktree ${cwd2}`, "gray");
71253
+ } catch (err) {
71254
+ log2(`! worktree remove failed for ${changeName}: ${err.message}`, "yellow");
71255
+ }
71256
+ }
71257
+ }
71258
+ }
71259
+ return effectiveCode;
70912
71260
  }
71261
+
71262
+ // apps/cli/src/agent/wire.ts
70913
71263
  var bunGitRunner = {
70914
71264
  run: async (args, cwd2) => {
70915
71265
  const proc = Bun.spawn({ cmd: ["git", ...args], cwd: cwd2, stdout: "pipe", stderr: "pipe" });
@@ -70943,35 +71293,346 @@ var bunCmdRunner = {
70943
71293
  return { stdout, stderr };
70944
71294
  }
70945
71295
  };
71296
+ function traceCmdRunner(base2, onStart, onEnd) {
71297
+ return {
71298
+ run: async (cmd, cwd2) => {
71299
+ const t0 = Date.now();
71300
+ onStart(cmd);
71301
+ try {
71302
+ const r = await base2.run(cmd, cwd2);
71303
+ onEnd(cmd, Date.now() - t0, true);
71304
+ return r;
71305
+ } catch (err) {
71306
+ onEnd(cmd, Date.now() - t0, false);
71307
+ throw err;
71308
+ }
71309
+ }
71310
+ };
71311
+ }
71312
+ function buildAgentCoordinator(input) {
71313
+ const {
71314
+ args,
71315
+ cfg,
71316
+ projectRoot,
71317
+ statesDir,
71318
+ tasksDir,
71319
+ apiKey,
71320
+ store,
71321
+ onLog,
71322
+ onWorkersChanged,
71323
+ onWorkerStarted,
71324
+ onWorkerExited,
71325
+ onWorkerPhase,
71326
+ onWorkerOutput,
71327
+ onWorkerCmd
71328
+ } = input;
71329
+ const logsDir = join16(projectRoot, ".ralph", "logs");
71330
+ const concurrency = args.concurrency || cfg.concurrency;
71331
+ const pollInterval = args.pollInterval || cfg.pollIntervalSeconds;
71332
+ const inProgressName = args.inProgressStatus || cfg.linear.inProgressStatus;
71333
+ const baseStatuses = args.linearStatus.length ? args.linearStatus : cfg.linear.statuses;
71334
+ const effectiveStatuses = inProgressName && baseStatuses.length > 0 && !baseStatuses.includes(inProgressName) ? [...baseStatuses, inProgressName] : baseStatuses;
71335
+ const filter2 = {
71336
+ team: args.linearTeam || cfg.linear.team,
71337
+ assignee: args.linearAssignee || cfg.linear.assignee,
71338
+ statuses: effectiveStatuses,
71339
+ labels: args.linearLabel.length ? args.linearLabel : cfg.linear.labels
71340
+ };
71341
+ const stateCache = new Map;
71342
+ const labelCache = new Map;
71343
+ const teamKeyOf = (issue) => issue.identifier.split("-")[0];
71344
+ const useWorktree = args.worktree || cfg.useWorktree;
71345
+ const cwdByChange = new Map;
71346
+ const statesDirByChange = new Map;
71347
+ const branchByChange = new Map;
71348
+ const issueByChange = new Map;
71349
+ async function runScript(label, cmd, cwd2) {
71350
+ onLog(` ${label}: ${cmd}`, "gray");
71351
+ const proc = Bun.spawn({
71352
+ cmd: ["sh", "-c", cmd],
71353
+ cwd: cwd2,
71354
+ stdout: "ignore",
71355
+ stderr: "pipe",
71356
+ stdin: "ignore"
71357
+ });
71358
+ const code = await proc.exited;
71359
+ if (code !== 0) {
71360
+ const stderr = await new Response(proc.stderr).text();
71361
+ onLog(`! ${label} exited code ${code}${stderr ? `: ${stderr.trim().split(`
71362
+ `)[0]}` : ""}`, "yellow");
71363
+ }
71364
+ }
71365
+ async function scaffoldCallback(issue) {
71366
+ let comments = [];
71367
+ try {
71368
+ comments = await fetchIssueComments(apiKey, issue.id);
71369
+ } catch (err) {
71370
+ onLog(`! Linear comment fetch failed for ${issue.identifier}: ${err.message}`, "yellow");
71371
+ }
71372
+ let workerCwd = projectRoot;
71373
+ let scaffoldTasksDir = tasksDir;
71374
+ let scaffoldStatesDir = statesDir;
71375
+ let workerBranch = null;
71376
+ const probeName = issue.identifier.toLowerCase();
71377
+ if (useWorktree) {
71378
+ try {
71379
+ const wt = await createWorktree(projectRoot, probeName, bunGitRunner);
71380
+ workerCwd = wt.cwd;
71381
+ workerBranch = wt.branch;
71382
+ const wtLayout = projectLayout(wt.cwd);
71383
+ scaffoldTasksDir = wtLayout.tasksDir;
71384
+ scaffoldStatesDir = wtLayout.statesDir;
71385
+ onLog(` ${issue.identifier} worktree: ${wt.cwd} (${wt.branch})`, "gray");
71386
+ try {
71387
+ await seedWorktreeMcpConfig(projectRoot, wt.cwd);
71388
+ } catch (err) {
71389
+ onLog(`! seeding .mcp.json failed for ${issue.identifier}: ${err.message}`, "yellow");
71390
+ }
71391
+ } catch (err) {
71392
+ onLog(`! worktree create failed for ${issue.identifier}: ${err.message} \u2014 falling back to project root`, "yellow");
71393
+ }
71394
+ }
71395
+ const appendPrompt = args.prompt || cfg.appendPrompt || "";
71396
+ const changeName = await scaffoldChangeForIssue(scaffoldTasksDir, scaffoldStatesDir, issue, comments, appendPrompt);
71397
+ cwdByChange.set(changeName, workerCwd);
71398
+ statesDirByChange.set(changeName, scaffoldStatesDir);
71399
+ issueByChange.set(changeName, issue);
71400
+ if (workerBranch)
71401
+ branchByChange.set(changeName, workerBranch);
71402
+ if (cfg.setupScript) {
71403
+ await runScript("setup", cfg.setupScript, workerCwd);
71404
+ }
71405
+ return changeName;
71406
+ }
71407
+ function buildTaskCmdFor(changeName) {
71408
+ const c = [
71409
+ process.execPath,
71410
+ process.argv[1] ?? "",
71411
+ "task",
71412
+ "--name",
71413
+ changeName,
71414
+ "--" + (args.engineSet ? args.engine : cfg.engine),
71415
+ args.engineSet ? args.model : cfg.model
71416
+ ];
71417
+ const maxIter = args.maxIterations || cfg.maxIterationsPerTask;
71418
+ if (maxIter > 0)
71419
+ c.push("--max-iterations", String(maxIter));
71420
+ const maxCost = args.maxCostUsd || cfg.maxCostUsdPerTask;
71421
+ if (maxCost > 0)
71422
+ c.push("--max-cost", String(maxCost));
71423
+ const maxRuntime = args.maxRuntimeMinutes || cfg.maxRuntimeMinutesPerTask;
71424
+ if (maxRuntime > 0)
71425
+ c.push("--max-runtime", String(maxRuntime));
71426
+ const maxFailures = args.maxConsecutiveFailures !== 5 ? args.maxConsecutiveFailures : cfg.maxConsecutiveFailuresPerTask;
71427
+ if (maxFailures !== 5)
71428
+ c.push("--max-failures", String(maxFailures));
71429
+ const delay2 = args.delay || cfg.iterationDelaySeconds;
71430
+ if (delay2 > 0)
71431
+ c.push("--delay", String(delay2));
71432
+ if (args.log || cfg.logRawStream)
71433
+ c.push("--log");
71434
+ if (args.verbose || cfg.taskVerbose)
71435
+ c.push("--verbose");
71436
+ return c;
71437
+ }
71438
+ function spawnWorker(changeName) {
71439
+ const cwd2 = cwdByChange.get(changeName) ?? projectRoot;
71440
+ const logFilePath = join16(logsDir, `${changeName}.log`);
71441
+ let logWriter = null;
71442
+ const ensureLogWriter = async () => {
71443
+ if (logWriter)
71444
+ return logWriter;
71445
+ try {
71446
+ await Bun.write(logFilePath, "");
71447
+ logWriter = Bun.file(logFilePath).writer();
71448
+ return logWriter;
71449
+ } catch (err) {
71450
+ onLog(`! could not open worker log ${logFilePath}: ${err.message}`, "yellow");
71451
+ return null;
71452
+ }
71453
+ };
71454
+ async function pump(stream, label) {
71455
+ if (!stream)
71456
+ return;
71457
+ const reader = stream.getReader();
71458
+ const decoder = new TextDecoder;
71459
+ let buf = "";
71460
+ const writer = await ensureLogWriter();
71461
+ try {
71462
+ while (true) {
71463
+ const { value, done } = await reader.read();
71464
+ if (done)
71465
+ break;
71466
+ const chunk2 = decoder.decode(value, { stream: true });
71467
+ buf += chunk2;
71468
+ let nl;
71469
+ while ((nl = buf.indexOf(`
71470
+ `)) >= 0) {
71471
+ const line = buf.slice(0, nl);
71472
+ buf = buf.slice(nl + 1);
71473
+ if (writer)
71474
+ writer.write(line + `
71475
+ `);
71476
+ if (line)
71477
+ onWorkerOutput?.(changeName, label === "err" ? `! ${line}` : line);
71478
+ }
71479
+ }
71480
+ if (buf) {
71481
+ if (writer)
71482
+ writer.write(buf + `
71483
+ `);
71484
+ onWorkerOutput?.(changeName, label === "err" ? `! ${buf}` : buf);
71485
+ }
71486
+ } catch {} finally {
71487
+ try {
71488
+ writer?.flush();
71489
+ } catch {}
71490
+ }
71491
+ }
71492
+ const launch = (note) => {
71493
+ const p = Bun.spawn({
71494
+ cmd: buildTaskCmdFor(changeName),
71495
+ cwd: cwd2,
71496
+ stdout: "pipe",
71497
+ stderr: "pipe",
71498
+ stdin: "ignore"
71499
+ });
71500
+ if (note && logWriter)
71501
+ logWriter.write(`
71502
+ --- ${note} ---
71503
+ `);
71504
+ pump(p.stdout, "out");
71505
+ pump(p.stderr, "err");
71506
+ return p;
71507
+ };
71508
+ const respawn = () => {
71509
+ onWorkerPhase?.(changeName, "working", "respawn");
71510
+ const rp = launch(`respawn at ${new Date().toISOString()}`);
71511
+ return rp.exited;
71512
+ };
71513
+ const proc = launch(`spawn at ${new Date().toISOString()}`);
71514
+ onWorkerStarted(changeName, statesDirByChange.get(changeName) ?? statesDir, logFilePath);
71515
+ onWorkerPhase?.(changeName, "working");
71516
+ const tracedCmd = onWorkerCmd ? traceCmdRunner(bunCmdRunner, (cmd) => onWorkerCmd(changeName, cmd, "start"), (cmd, ms, ok) => onWorkerCmd(changeName, cmd, "end", ms, ok)) : bunCmdRunner;
71517
+ const wantPr = args.createPr || cfg.createPrOnSuccess;
71518
+ const wantFixCi = args.fixCi || cfg.fixCiOnFailure;
71519
+ const wrapped = proc.exited.then(async (code) => {
71520
+ const workerLayout = projectLayout(cwd2);
71521
+ const effectiveCode = await runPostTask({
71522
+ changeName,
71523
+ cwd: cwd2,
71524
+ projectRoot,
71525
+ changeDir: workerLayout.changeDir(changeName),
71526
+ stateFilePath: workerLayout.stateFile(changeName),
71527
+ branch: branchByChange.get(changeName) ?? null,
71528
+ issue: issueByChange.get(changeName) ?? null,
71529
+ exitCode: code,
71530
+ useWorktree,
71531
+ wantPr,
71532
+ wantFixCi,
71533
+ cfg: {
71534
+ teardownScript: cfg.teardownScript ?? null,
71535
+ prBaseBranch: cfg.prBaseBranch,
71536
+ maxCiFixAttempts: cfg.maxCiFixAttempts,
71537
+ ciPollIntervalSeconds: cfg.ciPollIntervalSeconds,
71538
+ cleanupWorktreeOnSuccess: cfg.cleanupWorktreeOnSuccess
71539
+ },
71540
+ respawnWorker: respawn
71541
+ }, {
71542
+ cmd: tracedCmd,
71543
+ git: bunGitRunner,
71544
+ log: onLog,
71545
+ runScript,
71546
+ ...onWorkerPhase && {
71547
+ onPhase: (phase, detail) => onWorkerPhase(changeName, phase, detail)
71548
+ }
71549
+ });
71550
+ try {
71551
+ logWriter?.flush();
71552
+ await logWriter?.end();
71553
+ } catch {}
71554
+ cwdByChange.delete(changeName);
71555
+ statesDirByChange.delete(changeName);
71556
+ branchByChange.delete(changeName);
71557
+ issueByChange.delete(changeName);
71558
+ onWorkerExited(changeName);
71559
+ return effectiveCode;
71560
+ });
71561
+ return { exited: wrapped, kill: () => proc.kill() };
71562
+ }
71563
+ const coord = new AgentCoordinator({
71564
+ fetchIssues: (f2) => fetchOpenIssues(apiKey, f2),
71565
+ scaffold: scaffoldCallback,
71566
+ spawnWorker,
71567
+ store,
71568
+ onLog,
71569
+ onWorkersChanged,
71570
+ getIterationCount: async (changeName) => {
71571
+ const root = cwdByChange.get(changeName) ?? projectRoot;
71572
+ const file = Bun.file(projectLayout(root).stateFile(changeName));
71573
+ if (!await file.exists())
71574
+ return 0;
71575
+ const json = await file.json();
71576
+ return json.iteration ?? 0;
71577
+ },
71578
+ updater: {
71579
+ postComment: (issue, body) => addIssueComment(apiKey, issue.id, body),
71580
+ setState: (issue, stateId) => updateIssueState(apiKey, issue.id, stateId),
71581
+ resolveStateId: async (issue, stateName) => {
71582
+ const team = teamKeyOf(issue);
71583
+ let map2 = stateCache.get(team);
71584
+ if (!map2) {
71585
+ const states = await fetchWorkflowStates(apiKey, team);
71586
+ map2 = new Map(states.map((s) => [s.name.toLowerCase(), s.id]));
71587
+ stateCache.set(team, map2);
71588
+ }
71589
+ return map2.get(stateName.toLowerCase()) ?? null;
71590
+ },
71591
+ addLabel: (issue, labelId) => addLabelToIssue(apiKey, issue.id, labelId),
71592
+ resolveLabelId: async (issue, labelName) => {
71593
+ const team = teamKeyOf(issue);
71594
+ let map2 = labelCache.get(team);
71595
+ if (!map2) {
71596
+ const labels = await fetchIssueLabels(apiKey, team);
71597
+ map2 = new Map(labels.map((l) => [l.name.toLowerCase(), l.id]));
71598
+ labelCache.set(team, map2);
71599
+ }
71600
+ return map2.get(labelName.toLowerCase()) ?? null;
71601
+ }
71602
+ }
71603
+ }, {
71604
+ concurrency,
71605
+ filter: filter2,
71606
+ inProgressStatus: args.inProgressStatus || cfg.linear.inProgressStatus,
71607
+ doneStatus: args.doneStatus || cfg.linear.doneStatus,
71608
+ doneLabel: args.doneLabel || cfg.linear.doneLabel,
71609
+ postComments: cfg.linear.postComments,
71610
+ commentEveryIterations: cfg.linear.updateEveryIterations
71611
+ });
71612
+ const filterDesc = `team=${filter2.team ?? "*"}, assignee=${filter2.assignee ?? "*"}, statuses=` + `${filter2.statuses?.length ? filter2.statuses.join(",") : "open"}` + `${filter2.labels?.length ? `, labels=${filter2.labels.join(",")}` : ""}`;
71613
+ return {
71614
+ coord,
71615
+ filterDesc,
71616
+ concurrency,
71617
+ pollInterval,
71618
+ getWorkerCwd: (changeName) => cwdByChange.get(changeName)
71619
+ };
71620
+ }
71621
+
71622
+ // apps/cli/src/components/AgentMode.tsx
71623
+ var jsx_dev_runtime9 = __toESM(require_jsx_dev_runtime(), 1);
70946
71624
  var lineCounter = 0;
70947
71625
  function nextId() {
70948
71626
  lineCounter += 1;
70949
71627
  return `${Date.now()}-${lineCounter}`;
70950
71628
  }
70951
- 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);
71629
+ var TAIL_MAX_LINES = 5;
71630
+ var CMD_DISPLAY_MAX = 80;
71631
+ function fmtCmd(argv) {
71632
+ const joined = argv.join(" ");
71633
+ return joined.length > CMD_DISPLAY_MAX ? joined.slice(0, CMD_DISPLAY_MAX - 1) + "\u2026" : joined;
70974
71634
  }
71635
+ var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
70975
71636
  function fmtElapsed(ms) {
70976
71637
  const s = Math.floor(ms / 1000);
70977
71638
  if (s < 60)
@@ -70991,7 +71652,6 @@ function AgentMode({ args, projectRoot, statesDir, tasksDir }) {
70991
71652
  const coordRef = import_react57.useRef(null);
70992
71653
  const workerMetaRef = import_react57.useRef(new Map);
70993
71654
  const nextPollAtRef = import_react57.useRef(0);
70994
- const pollIntervalRef = import_react57.useRef(0);
70995
71655
  const [pollStatus, setPollStatus] = import_react57.useState({ state: "idle", lastFound: null, lastAdded: null, lastAt: null, filterDesc: "" });
70996
71656
  function appendLog(text, color) {
70997
71657
  setLogs((prev) => [...prev, { id: nextId(), text, color }]);
@@ -71002,389 +71662,74 @@ function AgentMode({ args, projectRoot, statesDir, tasksDir }) {
71002
71662
  async function init2() {
71003
71663
  const cfgPath = await ensureRalphyConfig(projectRoot);
71004
71664
  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");
71665
+ appendLog(`agent mode v${VERSION} \u2014 config: ${cfgPath}`, "gray");
71010
71666
  const apiKey = process.env["LINEAR_API_KEY"];
71011
71667
  if (!apiKey) {
71012
71668
  appendLog("! LINEAR_API_KEY not set \u2014 cannot poll Linear", "red");
71013
71669
  exit();
71014
71670
  return;
71015
71671
  }
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
- });
71672
+ const store = new AgentStateStore(projectRoot);
71673
+ await store.load();
71674
+ const { coord: coord2, filterDesc, concurrency, pollInterval } = buildAgentCoordinator({
71675
+ args,
71676
+ cfg,
71677
+ projectRoot,
71678
+ statesDir,
71679
+ tasksDir,
71680
+ apiKey,
71681
+ store,
71682
+ onLog: appendLog,
71683
+ onWorkersChanged: () => setTick((t) => t + 1),
71684
+ onWorkerStarted: (changeName, dir, logFile) => {
71132
71685
  workerMetaRef.current.set(changeName, {
71133
71686
  startedAt: Date.now(),
71134
- statesDir: statesDirByChange.get(changeName) ?? statesDir,
71135
- iter: 0
71687
+ statesDir: dir,
71688
+ logFile,
71689
+ iter: 0,
71690
+ phase: "working",
71691
+ phaseDetail: "",
71692
+ phaseStartedAt: Date.now(),
71693
+ currentCmd: null,
71694
+ lastCmd: null,
71695
+ tail: []
71136
71696
  });
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
71697
  },
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;
71698
+ onWorkerExited: (changeName) => {
71699
+ workerMetaRef.current.delete(changeName);
71350
71700
  },
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;
71701
+ onWorkerPhase: (changeName, phase, detail) => {
71702
+ const m = workerMetaRef.current.get(changeName);
71703
+ if (!m)
71704
+ return;
71705
+ if (m.phase !== phase)
71706
+ m.phaseStartedAt = Date.now();
71707
+ m.phase = phase;
71708
+ m.phaseDetail = detail ?? "";
71709
+ },
71710
+ onWorkerOutput: (changeName, line) => {
71711
+ const m = workerMetaRef.current.get(changeName);
71712
+ if (!m)
71713
+ return;
71714
+ m.tail.push(line);
71715
+ if (m.tail.length > TAIL_MAX_LINES)
71716
+ m.tail.splice(0, m.tail.length - TAIL_MAX_LINES);
71717
+ },
71718
+ onWorkerCmd: (changeName, cmd, state, durationMs, ok) => {
71719
+ const m = workerMetaRef.current.get(changeName);
71720
+ if (!m)
71721
+ return;
71722
+ if (state === "start") {
71723
+ m.currentCmd = { argv: cmd, startedAt: Date.now() };
71724
+ } else {
71725
+ m.currentCmd = null;
71726
+ m.lastCmd = { argv: cmd, durationMs: durationMs ?? 0, ok: ok ?? true };
71374
71727
  }
71375
71728
  }
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
71384
71729
  });
71730
+ appendLog(`concurrency=${concurrency} pollInterval=${pollInterval}s`, "gray");
71385
71731
  coordRef.current = coord2;
71386
71732
  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
71733
  const tick = async () => {
71389
71734
  if (cancelled)
71390
71735
  return;
@@ -71435,7 +71780,7 @@ ${check.unpushedCommits}`, "yellow");
71435
71780
  (async () => {
71436
71781
  for (const [changeName, meta] of workerMetaRef.current) {
71437
71782
  try {
71438
- const file = Bun.file(join14(meta.statesDir, changeName, ".ralph-state.json"));
71783
+ const file = Bun.file(join17(meta.statesDir, changeName, ".ralph-state.json"));
71439
71784
  if (await file.exists()) {
71440
71785
  const json = await file.json();
71441
71786
  meta.iter = json.iteration ?? meta.iter;
@@ -71493,19 +71838,56 @@ ${check.unpushedCommits}`, "yellow");
71493
71838
  const meta = workerMetaRef.current.get(w.changeName);
71494
71839
  const elapsed = meta ? fmtElapsed(now2 - meta.startedAt) : "\u2013";
71495
71840
  const iter = meta?.iter ?? 0;
71496
- return /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
71497
- color: "cyan",
71841
+ const phase = meta?.phase ?? "working";
71842
+ const phaseElapsed = meta ? fmtElapsed(now2 - meta.phaseStartedAt) : "\u2013";
71843
+ const phaseDetail = meta?.phaseDetail ? ` (${meta.phaseDetail})` : "";
71844
+ const cmd = meta?.currentCmd;
71845
+ const cmdElapsed = cmd ? fmtElapsed(now2 - cmd.startedAt) : null;
71846
+ const tail2 = meta?.tail ?? [];
71847
+ return /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Box_default, {
71848
+ flexDirection: "column",
71498
71849
  children: [
71499
- " ",
71500
- spinnerFrame,
71501
- " ",
71502
- w.issueIdentifier,
71503
- " (",
71504
- w.changeName,
71505
- ") \xB7 iter ",
71506
- iter,
71507
- " \xB7 ",
71508
- elapsed
71850
+ /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
71851
+ color: "cyan",
71852
+ children: [
71853
+ " ",
71854
+ spinnerFrame,
71855
+ " ",
71856
+ w.issueIdentifier,
71857
+ " (",
71858
+ w.changeName,
71859
+ ") \xB7 iter ",
71860
+ iter,
71861
+ " \xB7 ",
71862
+ elapsed
71863
+ ]
71864
+ }, undefined, true, undefined, this),
71865
+ /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
71866
+ dimColor: true,
71867
+ children: [
71868
+ " phase: ",
71869
+ phase,
71870
+ phaseDetail,
71871
+ " \xB7 ",
71872
+ phaseElapsed
71873
+ ]
71874
+ }, undefined, true, undefined, this),
71875
+ cmd && /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
71876
+ color: "yellow",
71877
+ children: [
71878
+ " \u23F5 ",
71879
+ fmtCmd(cmd.argv),
71880
+ " \xB7 ",
71881
+ cmdElapsed
71882
+ ]
71883
+ }, undefined, true, undefined, this),
71884
+ tail2.map((line, i) => /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
71885
+ dimColor: true,
71886
+ children: [
71887
+ " \u2502 ",
71888
+ line.length > 110 ? line.slice(0, 109) + "\u2026" : line
71889
+ ]
71890
+ }, `${w.changeName}-tail-${i}`, true, undefined, this))
71509
71891
  ]
71510
71892
  }, w.changeName, true, undefined, this);
71511
71893
  })
@@ -71516,11 +71898,11 @@ ${check.unpushedCommits}`, "yellow");
71516
71898
  }
71517
71899
 
71518
71900
  // packages/openspec/src/openspec-change-store.ts
71519
- import { join as join15, dirname as dirname4 } from "path";
71901
+ import { join as join18, dirname as dirname4 } from "path";
71520
71902
  import { readdir, mkdir as mkdir3 } from "fs/promises";
71521
71903
  function resolveOpenspecBin() {
71522
71904
  const pkgJsonPath = Bun.resolveSync("@fission-ai/openspec/package.json", import.meta.dir);
71523
- return join15(dirname4(pkgJsonPath), "bin", "openspec.js");
71905
+ return join18(dirname4(pkgJsonPath), "bin", "openspec.js");
71524
71906
  }
71525
71907
  function runOpenspec(args, options = {}) {
71526
71908
  const stdio = options.inherit ? ["inherit", "inherit", "inherit"] : ["ignore", "pipe", "pipe"];
@@ -71546,7 +71928,7 @@ class OpenSpecChangeStore {
71546
71928
  }
71547
71929
  }
71548
71930
  getChangeDirectory(name) {
71549
- return join15("openspec", "changes", name);
71931
+ return join18("openspec", "changes", name);
71550
71932
  }
71551
71933
  async listChanges() {
71552
71934
  const result2 = runOpenspec(["list", "--json"]);
@@ -71560,7 +71942,7 @@ class OpenSpecChangeStore {
71560
71942
  }
71561
71943
  } catch {}
71562
71944
  }
71563
- const changesDir = join15("openspec", "changes");
71945
+ const changesDir = join18("openspec", "changes");
71564
71946
  if (!await Bun.file(changesDir).exists())
71565
71947
  return [];
71566
71948
  try {
@@ -71571,18 +71953,18 @@ class OpenSpecChangeStore {
71571
71953
  }
71572
71954
  }
71573
71955
  async readTaskList(name) {
71574
- const file = Bun.file(join15("openspec", "changes", name, "tasks.md"));
71956
+ const file = Bun.file(join18("openspec", "changes", name, "tasks.md"));
71575
71957
  if (!await file.exists())
71576
71958
  return "";
71577
71959
  return await file.text();
71578
71960
  }
71579
71961
  async writeTaskList(name, content) {
71580
- const path = join15("openspec", "changes", name, "tasks.md");
71962
+ const path = join18("openspec", "changes", name, "tasks.md");
71581
71963
  await mkdir3(dirname4(path), { recursive: true });
71582
71964
  await Bun.write(path, content);
71583
71965
  }
71584
71966
  async appendSteering(name, message) {
71585
- const path = join15("openspec", "changes", name, "steering.md");
71967
+ const path = join18("openspec", "changes", name, "steering.md");
71586
71968
  const file = Bun.file(path);
71587
71969
  const existing = await file.exists() ? await file.text() : null;
71588
71970
  const updated = existing ? `${message}
@@ -71593,7 +71975,7 @@ ${existing.trimStart()}` : `${message}
71593
71975
  await Bun.write(path, updated);
71594
71976
  }
71595
71977
  async readSection(name, artifact, heading) {
71596
- const file = Bun.file(join15("openspec", "changes", name, artifact));
71978
+ const file = Bun.file(join18("openspec", "changes", name, artifact));
71597
71979
  if (!await file.exists())
71598
71980
  return "";
71599
71981
  const content = await file.text();
@@ -71675,8 +72057,8 @@ function App2({ args, statesDir, tasksDir, projectRoot }) {
71675
72057
  message: "Error: --name is required for status mode"
71676
72058
  }, undefined, false, undefined, this);
71677
72059
  }
71678
- const stateDir = join16(statesDir, args.name);
71679
- if (getStorage().read(join16(stateDir, ".ralph-state.json")) === null) {
72060
+ const stateDir = join19(statesDir, args.name);
72061
+ if (getStorage().read(join19(stateDir, ".ralph-state.json")) === null) {
71680
72062
  return /* @__PURE__ */ jsx_dev_runtime10.jsxDEV(ErrorMessage, {
71681
72063
  message: `Error: change '${args.name}' not found`
71682
72064
  }, undefined, false, undefined, this);
@@ -71737,7 +72119,7 @@ if (typeof globalThis.Bun === "undefined") {
71737
72119
  async function findProjectRoot() {
71738
72120
  let dir = process.cwd();
71739
72121
  while (dir !== "/") {
71740
- if (await exists2(join17(dir, "openspec")))
72122
+ if (await exists2(join20(dir, "openspec")))
71741
72123
  return dir;
71742
72124
  dir = resolve(dir, "..");
71743
72125
  }
@@ -71772,11 +72154,12 @@ try {
71772
72154
  capture("command_run", { mode: args.mode, engine: args.engine, model: args.model });
71773
72155
  try {
71774
72156
  const projectRoot = await findProjectRoot();
71775
- const statesDir = join17(projectRoot, ".ralph", "tasks");
71776
- const tasksDir = join17(projectRoot, "openspec", "changes");
72157
+ const layout = projectLayout(projectRoot);
72158
+ const statesDir = layout.statesDir;
72159
+ const tasksDir = layout.tasksDir;
71777
72160
  if (args.mode === "init") {
71778
72161
  await mkdir4(statesDir, { recursive: true });
71779
- const openspecBin = join17(dirname5(Bun.resolveSync("@fission-ai/openspec/package.json", import.meta.dir)), "bin", "openspec.js");
72162
+ const openspecBin = join20(dirname5(Bun.resolveSync("@fission-ai/openspec/package.json", import.meta.dir)), "bin", "openspec.js");
71780
72163
  Bun.spawnSync({
71781
72164
  cmd: [process.execPath, openspecBin, "init", "--tools", "none", "--force"],
71782
72165
  stdio: ["inherit", "inherit", "inherit"],
@@ -71789,9 +72172,9 @@ try {
71789
72172
  `);
71790
72173
  process.exit(1);
71791
72174
  }
71792
- const worktreeDir = join17(worktreesDir(projectRoot), args.name);
71793
- const changeDir = join17(tasksDir, args.name);
71794
- const stateDir = join17(statesDir, args.name);
72175
+ const worktreeDir = join20(worktreesDir(projectRoot), args.name);
72176
+ const changeDir = join20(tasksDir, args.name);
72177
+ const stateDir = join20(statesDir, args.name);
71795
72178
  const branch = `ralph/${args.name}`;
71796
72179
  const removed = [];
71797
72180
  if (await exists2(worktreeDir)) {
@@ -71830,12 +72213,11 @@ try {
71830
72213
  removed.push(`task state ${stateDir}`);
71831
72214
  }
71832
72215
  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})`);
72216
+ const store = new AgentStateStore(projectRoot);
72217
+ await store.load();
72218
+ const removedEntry = await store.removeByChangeName(args.name);
72219
+ if (removedEntry) {
72220
+ removed.push(`agent-state entry for ${removedEntry.identifier} (${removedEntry.issueId})`);
71839
72221
  }
71840
72222
  } catch {}
71841
72223
  if (removed.length === 0) {
@@ -71852,13 +72234,13 @@ try {
71852
72234
  process.exit(0);
71853
72235
  }
71854
72236
  if (args.mode === "task" && args.name) {
71855
- await mkdir4(join17(statesDir, args.name), { recursive: true });
71856
- await mkdir4(join17(tasksDir, args.name), { recursive: true });
72237
+ await mkdir4(join20(statesDir, args.name), { recursive: true });
72238
+ await mkdir4(join20(tasksDir, args.name), { recursive: true });
71857
72239
  }
71858
72240
  if (args.mode === "agent") {
71859
72241
  await mkdir4(statesDir, { recursive: true });
71860
72242
  await mkdir4(tasksDir, { recursive: true });
71861
- await mkdir4(join17(projectRoot, ".ralph"), { recursive: true });
72243
+ await mkdir4(join20(projectRoot, ".ralph"), { recursive: true });
71862
72244
  }
71863
72245
  await runWithContext(createDefaultContext(), async () => {
71864
72246
  const { waitUntilExit } = render_default(import_react59.createElement(App2, { args, statesDir, tasksDir, projectRoot }));