@neriros/ralphy 2.11.2 → 2.13.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 +1034 -434
  2. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -50838,7 +50838,7 @@ var require_axios = __commonJS((exports, module) => {
50838
50838
 
50839
50839
  // apps/cli/src/index.ts
50840
50840
  import { resolve, join as join19, dirname as dirname5 } from "path";
50841
- import { exists as exists2, mkdir as mkdir4, rm } from "fs/promises";
50841
+ import { exists as exists2, mkdir as mkdir5, rm } from "fs/promises";
50842
50842
 
50843
50843
  // node_modules/.bun/ink@5.2.1+1f88f629f0141b18/node_modules/ink/build/render.js
50844
50844
  import { Stream } from "stream";
@@ -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.2",
56410
+ version: "2.13.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",
@@ -56502,6 +56502,17 @@ var package_default = {
56502
56502
  var VERSION = package_default.version;
56503
56503
  var VALID_MODES = new Set(["task", "list", "status", "init", "agent", "clean"]);
56504
56504
  var VALID_MODELS = new Set(["haiku", "sonnet", "opus"]);
56505
+ var INDICATOR_KEYS = new Set([
56506
+ "getTodo",
56507
+ "getInProgress",
56508
+ "getConflicted",
56509
+ "setInProgress",
56510
+ "setDone",
56511
+ "setError",
56512
+ "setConflicted",
56513
+ "clearConflicted"
56514
+ ]);
56515
+ var GET_KEYS = new Set(["getTodo", "getInProgress", "getConflicted"]);
56505
56516
  var HELP_TEXT = [
56506
56517
  `ralph v${VERSION}`,
56507
56518
  "",
@@ -56534,23 +56545,25 @@ var HELP_TEXT = [
56534
56545
  "Agent mode options (require LINEAR_API_KEY env var):",
56535
56546
  " --linear-team <key> Linear team key (e.g. ENG)",
56536
56547
  " --linear-assignee <id> Filter by assignee (user id, email, or 'me')",
56537
- " --linear-status <name> Filter by status name (repeatable, e.g. Todo, In Progress)",
56538
- " --linear-label <name> Filter by label name (repeatable, any-of)",
56539
56548
  " --poll-interval <s> Seconds between Linear polls (default: 60)",
56540
56549
  " --concurrency <n> Max concurrent task loops (default: 1)",
56541
- " --worktree Run each task in its own git worktree (~/.ralph/<project>/worktrees/<name>)",
56542
- " --in-progress-status <name> Linear status to set when work starts on an issue",
56543
- " --done-status <name> Linear status to set when work completes successfully",
56544
- " --done-label <name> Linear label to add when work completes successfully",
56550
+ " --worktree Run each task in its own git worktree",
56551
+ " --indicator <k>:<t>:<v> Override an indicator (repeatable). Examples:",
56552
+ " --indicator getTodo:status:Todo",
56553
+ " --indicator setDone:label:shipped",
56554
+ " --indicator setDone:status:Done (combined with above \u2192 multi-marker)",
56555
+ " Keys: getTodo, getInProgress, getConflicted,",
56556
+ " setInProgress, setDone, setError, setConflicted, clearConflicted",
56557
+ " Types: label, status",
56545
56558
  " --create-pr Push the worker branch and open a GitHub PR on success (needs --worktree)",
56546
- " --fix-ci After opening the PR, re-run the task on CI failures until green (needs --create-pr)",
56559
+ " --fix-ci After opening the PR, re-run on CI failures until green (needs --create-pr)",
56547
56560
  "",
56548
56561
  " --help, -h Show this help message",
56549
56562
  "",
56550
56563
  "Examples:",
56551
56564
  ' ralph task --name my-feature --prompt "Add dark mode"',
56552
56565
  " ralph task --name my-feature --claude sonnet --max-iterations 10",
56553
- " ralph task --name my-feature",
56566
+ " ralph agent --indicator getTodo:status:Todo --indicator setDone:status:Done",
56554
56567
  " ralph list",
56555
56568
  " ralph status --name my-feature",
56556
56569
  " ralph init"
@@ -56559,6 +56572,53 @@ var HELP_TEXT = [
56559
56572
  function printHelp() {
56560
56573
  log(HELP_TEXT);
56561
56574
  }
56575
+ function parseIndicatorArg(raw) {
56576
+ const firstColon = raw.indexOf(":");
56577
+ if (firstColon < 0) {
56578
+ const err = new Error("--indicator expects key:type:value");
56579
+ err.input = raw;
56580
+ throw err;
56581
+ }
56582
+ const secondColon = raw.indexOf(":", firstColon + 1);
56583
+ if (secondColon < 0) {
56584
+ const err = new Error("--indicator expects key:type:value");
56585
+ err.input = raw;
56586
+ throw err;
56587
+ }
56588
+ const key = raw.slice(0, firstColon);
56589
+ const type = raw.slice(firstColon + 1, secondColon);
56590
+ const value = raw.slice(secondColon + 1);
56591
+ if (!INDICATOR_KEYS.has(key)) {
56592
+ const err = new Error("unknown indicator key");
56593
+ err.key = key;
56594
+ throw err;
56595
+ }
56596
+ if (type !== "label" && type !== "status") {
56597
+ const err = new Error("indicator type must be 'label' or 'status'");
56598
+ err.type = type;
56599
+ throw err;
56600
+ }
56601
+ if (!value)
56602
+ throw new Error("indicator value cannot be empty");
56603
+ return { key, marker: { type, value } };
56604
+ }
56605
+ function mergeIndicator(bag, key, marker) {
56606
+ if (GET_KEYS.has(key)) {
56607
+ const existing = bag[key];
56608
+ const filter2 = existing ? [...existing.filter, marker] : [marker];
56609
+ bag[key] = { filter: filter2 };
56610
+ } else {
56611
+ const existing = bag[key];
56612
+ let next;
56613
+ if (!existing)
56614
+ next = marker;
56615
+ else if ("apply" in existing)
56616
+ next = { apply: [...existing.apply, marker] };
56617
+ else
56618
+ next = { apply: [existing, marker] };
56619
+ bag[key] = next;
56620
+ }
56621
+ }
56562
56622
  async function parseArgs(argv) {
56563
56623
  const result2 = {
56564
56624
  mode: "task",
@@ -56576,14 +56636,10 @@ async function parseArgs(argv) {
56576
56636
  verbose: false,
56577
56637
  linearTeam: "",
56578
56638
  linearAssignee: "",
56579
- linearStatus: [],
56580
- linearLabel: [],
56581
56639
  pollInterval: 60,
56582
56640
  concurrency: 1,
56583
56641
  worktree: false,
56584
- inProgressStatus: "",
56585
- doneStatus: "",
56586
- doneLabel: "",
56642
+ indicators: {},
56587
56643
  createPr: false,
56588
56644
  fixCi: false
56589
56645
  };
@@ -56601,13 +56657,9 @@ async function parseArgs(argv) {
56601
56657
  let expectPushInterval = false;
56602
56658
  let expectLinearTeam = false;
56603
56659
  let expectLinearAssignee = false;
56604
- let expectLinearStatus = false;
56605
- let expectLinearLabel = false;
56606
56660
  let expectPollInterval = false;
56607
56661
  let expectConcurrency = false;
56608
- let expectInProgressStatus = false;
56609
- let expectDoneStatus = false;
56610
- let expectDoneLabel = false;
56662
+ let expectIndicator = false;
56611
56663
  for (const arg of argv) {
56612
56664
  if (expectModel) {
56613
56665
  if (VALID_MODELS.has(arg)) {
@@ -56683,16 +56735,6 @@ async function parseArgs(argv) {
56683
56735
  expectLinearAssignee = false;
56684
56736
  continue;
56685
56737
  }
56686
- if (expectLinearStatus) {
56687
- result2.linearStatus.push(arg);
56688
- expectLinearStatus = false;
56689
- continue;
56690
- }
56691
- if (expectLinearLabel) {
56692
- result2.linearLabel.push(arg);
56693
- expectLinearLabel = false;
56694
- continue;
56695
- }
56696
56738
  if (expectPollInterval) {
56697
56739
  result2.pollInterval = parseInt(arg, 10);
56698
56740
  expectPollInterval = false;
@@ -56703,19 +56745,10 @@ async function parseArgs(argv) {
56703
56745
  expectConcurrency = false;
56704
56746
  continue;
56705
56747
  }
56706
- if (expectInProgressStatus) {
56707
- result2.inProgressStatus = arg;
56708
- expectInProgressStatus = false;
56709
- continue;
56710
- }
56711
- if (expectDoneStatus) {
56712
- result2.doneStatus = arg;
56713
- expectDoneStatus = false;
56714
- continue;
56715
- }
56716
- if (expectDoneLabel) {
56717
- result2.doneLabel = arg;
56718
- expectDoneLabel = false;
56748
+ if (expectIndicator) {
56749
+ const { key, marker } = parseIndicatorArg(arg);
56750
+ mergeIndicator(result2.indicators, key, marker);
56751
+ expectIndicator = false;
56719
56752
  continue;
56720
56753
  }
56721
56754
  switch (arg) {
@@ -56782,12 +56815,6 @@ async function parseArgs(argv) {
56782
56815
  case "--linear-assignee":
56783
56816
  expectLinearAssignee = true;
56784
56817
  break;
56785
- case "--linear-status":
56786
- expectLinearStatus = true;
56787
- break;
56788
- case "--linear-label":
56789
- expectLinearLabel = true;
56790
- break;
56791
56818
  case "--poll-interval":
56792
56819
  expectPollInterval = true;
56793
56820
  break;
@@ -56797,14 +56824,8 @@ async function parseArgs(argv) {
56797
56824
  case "--worktree":
56798
56825
  result2.worktree = true;
56799
56826
  break;
56800
- case "--in-progress-status":
56801
- expectInProgressStatus = true;
56802
- break;
56803
- case "--done-status":
56804
- expectDoneStatus = true;
56805
- break;
56806
- case "--done-label":
56807
- expectDoneLabel = true;
56827
+ case "--indicator":
56828
+ expectIndicator = true;
56808
56829
  break;
56809
56830
  case "--create-pr":
56810
56831
  result2.createPr = true;
@@ -60904,6 +60925,9 @@ var StateSchema = exports_external.object({
60904
60925
  history: exports_external.array(HistoryEntrySchema).default([]),
60905
60926
  metadata: exports_external.object({ branch: exports_external.string().optional() }).default({})
60906
60927
  });
60928
+ function markersOf(set2) {
60929
+ return "apply" in set2 ? set2.apply : [set2];
60930
+ }
60907
60931
  var PhaseFrontmatterSchema = exports_external.object({
60908
60932
  name: exports_external.string(),
60909
60933
  order: exports_external.number(),
@@ -70106,87 +70130,44 @@ function TaskLoop({ opts }) {
70106
70130
  var import_react57 = __toESM(require_react(), 1);
70107
70131
  import { join as join16 } from "path";
70108
70132
 
70109
- // apps/cli/src/agent/state.ts
70133
+ // apps/cli/src/agent/config.ts
70110
70134
  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()
70135
+ var MarkerSchema = exports_external.object({
70136
+ type: exports_external.enum(["label", "status"]),
70137
+ value: exports_external.string().min(1)
70121
70138
  });
70122
- var AgentStateSchema = exports_external.object({
70123
- tasks: exports_external.record(exports_external.string(), TaskEntrySchema).default({}),
70124
- lastPollAt: exports_external.string().nullable().default(null)
70139
+ var GetIndicatorSchema = exports_external.object({
70140
+ filter: exports_external.array(MarkerSchema).default([])
70125
70141
  });
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()");
70142
+ var SetIndicatorSchema = exports_external.union([
70143
+ MarkerSchema,
70144
+ exports_external.object({ apply: exports_external.array(MarkerSchema).min(1) })
70145
+ ]);
70146
+ var IndicatorsSchema = exports_external.object({
70147
+ getTodo: GetIndicatorSchema.optional(),
70148
+ getInProgress: GetIndicatorSchema.optional(),
70149
+ getConflicted: GetIndicatorSchema.optional(),
70150
+ setInProgress: SetIndicatorSchema.optional(),
70151
+ setDone: SetIndicatorSchema.optional(),
70152
+ setError: SetIndicatorSchema.optional(),
70153
+ setConflicted: SetIndicatorSchema.optional(),
70154
+ clearConflicted: SetIndicatorSchema.optional()
70155
+ }).superRefine((value, ctx) => {
70156
+ const clear = value.clearConflicted;
70157
+ if (!clear)
70158
+ return;
70159
+ const markers = "apply" in clear ? clear.apply : [clear];
70160
+ for (const m of markers) {
70161
+ if (m.type !== "label") {
70162
+ ctx.addIssue({
70163
+ code: exports_external.ZodIssueCode.custom,
70164
+ path: ["clearConflicted"],
70165
+ message: "clearConflicted markers must be label-typed (status removal is not supported)"
70166
+ });
70167
+ return;
70154
70168
  }
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
  }
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";
70170
+ });
70190
70171
  var RalphyConfigSchema = exports_external.object({
70191
70172
  concurrency: exports_external.number().int().positive().default(1),
70192
70173
  pollIntervalSeconds: exports_external.number().int().positive().default(60),
@@ -70212,14 +70193,26 @@ var RalphyConfigSchema = exports_external.object({
70212
70193
  linear: exports_external.object({
70213
70194
  team: exports_external.string().optional(),
70214
70195
  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
70196
  postComments: exports_external.boolean().default(true),
70221
- updateEveryIterations: exports_external.number().int().nonnegative().default(10)
70222
- }).default({ statuses: [], labels: [], postComments: true })
70197
+ updateEveryIterations: exports_external.number().int().nonnegative().default(10),
70198
+ indicators: IndicatorsSchema.default({})
70199
+ }).passthrough().superRefine((value, ctx) => {
70200
+ const LEGACY_KEYS = [
70201
+ "statuses",
70202
+ "labels",
70203
+ "inProgressStatus",
70204
+ "doneStatus",
70205
+ "doneLabel"
70206
+ ];
70207
+ const found = LEGACY_KEYS.filter((k) => (k in value));
70208
+ if (found.length === 0)
70209
+ return;
70210
+ ctx.addIssue({
70211
+ code: exports_external.ZodIssueCode.custom,
70212
+ path: ["linear"],
70213
+ message: `legacy linear keys [${found.join(", ")}] cannot be used together with the new ` + `\`linear.indicators\` map \u2014 they describe the same lifecycle and combining them is ` + `not possible. Migrate by moving each legacy key into linear.indicators (e.g. ` + `doneStatus: "Done" \u2192 indicators.setDone: {type: "status", value: "Done"}; ` + `statuses/labels \u2192 indicators.getTodo.filter; inProgressStatus \u2192 indicators.setInProgress; ` + `doneLabel \u2192 indicators.setDone {type: "label", ...}).`
70214
+ });
70215
+ }).default({ postComments: true, updateEveryIterations: 10, indicators: {} })
70223
70216
  }).default({
70224
70217
  concurrency: 1,
70225
70218
  pollIntervalSeconds: 60,
@@ -70227,10 +70220,10 @@ var RalphyConfigSchema = exports_external.object({
70227
70220
  maxCostUsdPerTask: 0,
70228
70221
  engine: "claude",
70229
70222
  model: "opus",
70230
- linear: { statuses: [], labels: [], postComments: true }
70223
+ linear: { postComments: true, updateEveryIterations: 10, indicators: {} }
70231
70224
  });
70232
70225
  async function loadRalphyConfig(projectRoot) {
70233
- const path = join11(projectRoot, "ralphy.config.json");
70226
+ const path = join10(projectRoot, "ralphy.config.json");
70234
70227
  const file = Bun.file(path);
70235
70228
  if (!await file.exists()) {
70236
70229
  return RalphyConfigSchema.parse({});
@@ -70239,7 +70232,7 @@ async function loadRalphyConfig(projectRoot) {
70239
70232
  return RalphyConfigSchema.parse(raw);
70240
70233
  }
70241
70234
  async function ensureRalphyConfig(projectRoot) {
70242
- const path = join11(projectRoot, "ralphy.config.json");
70235
+ const path = join10(projectRoot, "ralphy.config.json");
70243
70236
  const file = Bun.file(path);
70244
70237
  if (await file.exists())
70245
70238
  return path;
@@ -70249,47 +70242,86 @@ async function ensureRalphyConfig(projectRoot) {
70249
70242
  return path;
70250
70243
  }
70251
70244
 
70245
+ // apps/cli/src/agent/wire.ts
70246
+ import { join as join15 } from "path";
70247
+ import { mkdir as mkdir3 } from "fs/promises";
70248
+
70252
70249
  // packages/core/src/layout.ts
70253
- import { join as join12 } from "path";
70250
+ import { join as join11 } from "path";
70254
70251
  var STATE_FILE2 = ".ralph-state.json";
70255
70252
  function projectLayout(root) {
70256
- const statesDir = join12(root, ".ralph", "tasks");
70257
- const tasksDir = join12(root, "openspec", "changes");
70253
+ const statesDir = join11(root, ".ralph", "tasks");
70254
+ const tasksDir = join11(root, "openspec", "changes");
70258
70255
  return {
70259
70256
  root,
70260
70257
  statesDir,
70261
70258
  tasksDir,
70262
- agentStateFile: join12(root, ".ralph", "agent-state.json"),
70263
- changeDir: (name) => join12(tasksDir, name),
70264
- taskStateDir: (name) => join12(statesDir, name),
70265
- stateFile: (name) => join12(statesDir, name, STATE_FILE2)
70259
+ agentStateFile: join11(root, ".ralph", "agent-state.json"),
70260
+ changeDir: (name) => join11(tasksDir, name),
70261
+ taskStateDir: (name) => join11(statesDir, name),
70262
+ stateFile: (name) => join11(statesDir, name, STATE_FILE2)
70266
70263
  };
70267
70264
  }
70268
70265
 
70269
70266
  // apps/cli/src/agent/linear.ts
70270
- var OPEN_STATE_TYPES = ["unstarted", "started", "backlog"];
70271
70267
  var LINEAR_API = "https://api.linear.app/graphql";
70272
- async function fetchOpenIssues(apiKey, filter2) {
70268
+ function partition2(markers) {
70269
+ const statuses = [];
70270
+ const labels = [];
70271
+ for (const m of markers) {
70272
+ if (m.type === "status")
70273
+ statuses.push(m.value);
70274
+ else
70275
+ labels.push(m.value);
70276
+ }
70277
+ return { statuses, labels };
70278
+ }
70279
+ function buildIssueFilter(spec) {
70273
70280
  const where = {};
70274
- if (filter2.team)
70275
- where.team = { key: { eq: filter2.team } };
70276
- if (filter2.assignee) {
70277
- if (filter2.assignee === "me") {
70281
+ if (spec.team)
70282
+ where.team = { key: { eq: spec.team } };
70283
+ if (spec.assignee) {
70284
+ if (spec.assignee === "me")
70278
70285
  where.assignee = { isMe: { eq: true } };
70279
- } else if (filter2.assignee.includes("@")) {
70280
- where.assignee = { email: { eq: filter2.assignee } };
70281
- } else {
70282
- where.assignee = { id: { eq: filter2.assignee } };
70283
- }
70284
- }
70285
- if (filter2.statuses && filter2.statuses.length > 0) {
70286
- where.state = { name: { in: filter2.statuses } };
70286
+ else if (spec.assignee.includes("@"))
70287
+ where.assignee = { email: { eq: spec.assignee } };
70288
+ else
70289
+ where.assignee = { id: { eq: spec.assignee } };
70290
+ }
70291
+ const inc = spec.include ?? [];
70292
+ if (inc.length > 0) {
70293
+ const { statuses, labels } = partition2(inc);
70294
+ const branches = [];
70295
+ if (statuses.length > 0)
70296
+ branches.push({ state: { name: { in: statuses } } });
70297
+ if (labels.length > 0)
70298
+ branches.push({ labels: { some: { name: { in: labels } } } });
70299
+ if (branches.length === 1)
70300
+ Object.assign(where, branches[0]);
70301
+ else
70302
+ where.or = branches;
70287
70303
  } else {
70288
- where.state = { type: { in: [...OPEN_STATE_TYPES] } };
70289
- }
70290
- if (filter2.labels && filter2.labels.length > 0) {
70291
- where.labels = { some: { name: { in: filter2.labels } } };
70304
+ where.state = { type: { in: ["unstarted", "started", "backlog"] } };
70305
+ }
70306
+ const exc = spec.exclude ?? [];
70307
+ if (exc.length > 0) {
70308
+ const { statuses, labels } = partition2(exc);
70309
+ if (statuses.length > 0) {
70310
+ const current = where.state;
70311
+ const noStatus = { state: { name: { nin: statuses } } };
70312
+ if (current === undefined)
70313
+ Object.assign(where, noStatus);
70314
+ else
70315
+ where.and = [{ state: current }, noStatus];
70316
+ }
70317
+ if (labels.length > 0) {
70318
+ where.labels = { ...where.labels, every: { name: { nin: labels } } };
70319
+ }
70292
70320
  }
70321
+ return where;
70322
+ }
70323
+ async function fetchOpenIssues(apiKey, spec) {
70324
+ const where = buildIssueFilter(spec);
70293
70325
  const query = `query Issues($filter: IssueFilter) {
70294
70326
  issues(filter: $filter, first: 50) {
70295
70327
  nodes {
@@ -70406,6 +70438,15 @@ async function addLabelToIssue(apiKey, issueId, labelId) {
70406
70438
  labelId
70407
70439
  });
70408
70440
  }
70441
+ async function removeLabelFromIssue(apiKey, issueId, labelId) {
70442
+ const mutation = `mutation RemoveLabel($id: String!, $labelId: String!) {
70443
+ issueRemoveLabel(id: $id, labelId: $labelId) { success }
70444
+ }`;
70445
+ await linearRequest(apiKey, mutation, {
70446
+ id: issueId,
70447
+ labelId
70448
+ });
70449
+ }
70409
70450
 
70410
70451
  // apps/cli/src/agent/coordinator.ts
70411
70452
  class AgentCoordinator {
@@ -70415,6 +70456,7 @@ class AgentCoordinator {
70415
70456
  pendingIds = new Set;
70416
70457
  queue = [];
70417
70458
  stopped = false;
70459
+ conflictNotified = new Set;
70418
70460
  constructor(deps, opts) {
70419
70461
  this.deps = deps;
70420
70462
  this.opts = opts;
@@ -70432,58 +70474,86 @@ class AgentCoordinator {
70432
70474
  async pollOnce() {
70433
70475
  if (this.stopped)
70434
70476
  return { found: 0, added: 0 };
70435
- let issues;
70477
+ let todo = [];
70478
+ let inProgress = [];
70479
+ let conflicted = [];
70436
70480
  try {
70437
- issues = await this.deps.fetchIssues(this.opts.filter);
70481
+ [todo, inProgress, conflicted] = await Promise.all([
70482
+ this.deps.fetchTodo(),
70483
+ this.deps.fetchInProgress(),
70484
+ this.deps.fetchConflicted()
70485
+ ]);
70438
70486
  } catch (err) {
70439
70487
  this.deps.onLog(`! Linear poll failed: ${err.message}`, "red");
70440
70488
  return { found: 0, added: 0 };
70441
70489
  }
70442
- const state = this.deps.store.snapshot();
70443
- const tasksByIssueId = new Map;
70444
- for (const entry of Object.values(state.tasks)) {
70445
- tasksByIssueId.set(entry.issueId, entry);
70446
- }
70447
- const isProcessed = (id) => tasksByIssueId.get(id)?.state === "processed";
70448
- const isFailed = (id) => tasksByIssueId.get(id)?.state === "failed";
70449
- const queued = new Set(this.queue.map((i) => i.id));
70450
- const active = new Set(this.workers.map((w) => w.issueId));
70490
+ const queuedIds = new Set(this.queue.map((q) => q.issue.id));
70491
+ const activeIds = new Set(this.workers.map((w) => w.issueId));
70492
+ const eligible = (id) => !queuedIds.has(id) && !activeIds.has(id) && !this.pendingIds.has(id);
70451
70493
  let added = 0;
70452
- for (const issue of issues) {
70453
- if (isProcessed(issue.id))
70454
- continue;
70455
- if (isFailed(issue.id))
70494
+ for (const issue of inProgress) {
70495
+ if (!eligible(issue.id))
70456
70496
  continue;
70457
- if (queued.has(issue.id))
70497
+ if (!this.dependenciesResolved(issue))
70458
70498
  continue;
70459
- if (active.has(issue.id))
70499
+ this.queue.push({ issue, mode: "resume" });
70500
+ queuedIds.add(issue.id);
70501
+ added += 1;
70502
+ }
70503
+ for (const issue of conflicted) {
70504
+ if (!eligible(issue.id))
70460
70505
  continue;
70461
- if (this.pendingIds.has(issue.id))
70506
+ this.queue.push({ issue, mode: "conflict-fix" });
70507
+ queuedIds.add(issue.id);
70508
+ added += 1;
70509
+ }
70510
+ for (const issue of todo) {
70511
+ if (!eligible(issue.id))
70462
70512
  continue;
70463
- const blocker = issue.blockedByIds.find((bid) => !isProcessed(bid));
70464
- if (blocker !== undefined) {
70465
- this.deps.onLog(` \u23F8 ${issue.identifier} skipped \u2014 blocked by unresolved dependency`, "yellow");
70513
+ if (!this.dependenciesResolved(issue))
70466
70514
  continue;
70467
- }
70468
- this.queue.push(issue);
70515
+ this.queue.push({ issue, mode: "fresh" });
70516
+ queuedIds.add(issue.id);
70469
70517
  added += 1;
70470
70518
  }
70471
70519
  if (added > 0) {
70520
+ const modeRank = {
70521
+ resume: 0,
70522
+ "conflict-fix": 1,
70523
+ fresh: 2
70524
+ };
70472
70525
  this.queue.sort((a, b) => {
70473
- const pa = a.priority === 0 ? Infinity : a.priority;
70474
- const pb = b.priority === 0 ? Infinity : b.priority;
70475
- return pa - pb;
70526
+ const pa = a.issue.priority === 0 ? Infinity : a.issue.priority;
70527
+ const pb = b.issue.priority === 0 ? Infinity : b.issue.priority;
70528
+ if (pa !== pb)
70529
+ return pa - pb;
70530
+ return modeRank[a.mode] - modeRank[b.mode];
70476
70531
  });
70477
70532
  }
70478
- await this.deps.store.setLastPollAt(new Date().toISOString());
70479
70533
  this.spawnNext();
70534
+ await this.scanDoneForConflicts();
70480
70535
  await this.reportProgress();
70481
- return { found: issues.length, added };
70536
+ const found = todo.length + inProgress.length + conflicted.length;
70537
+ return { found, added };
70538
+ }
70539
+ dependenciesResolved(issue) {
70540
+ if (issue.blockedByIds.length === 0)
70541
+ return true;
70542
+ const openIds = new Set([
70543
+ ...this.queue.map((q) => q.issue.id),
70544
+ ...this.workers.map((w) => w.issueId)
70545
+ ]);
70546
+ const blocker = issue.blockedByIds.find((bid) => openIds.has(bid));
70547
+ if (blocker !== undefined) {
70548
+ this.deps.onLog(` \u23F8 ${issue.identifier} skipped \u2014 blocked by unresolved dependency`, "yellow");
70549
+ return false;
70550
+ }
70551
+ this.deps.onLog(` \u23F8 ${issue.identifier} skipped \u2014 blocked by unresolved dependency`, "yellow");
70552
+ return false;
70482
70553
  }
70483
70554
  async reportProgress() {
70484
- const updater = this.deps.updater;
70485
70555
  const everyN = this.opts.commentEveryIterations ?? 0;
70486
- if (everyN <= 0 || !updater || this.opts.postComments === false || !this.deps.getIterationCount) {
70556
+ if (everyN <= 0 || this.opts.postComments === false || !this.deps.getIterationCount) {
70487
70557
  return;
70488
70558
  }
70489
70559
  for (const w of this.workers) {
@@ -70501,29 +70571,78 @@ class AgentCoordinator {
70501
70571
  if (currMilestone <= lastMilestone)
70502
70572
  continue;
70503
70573
  try {
70504
- await updater.postComment(w.issue, `\uD83D\uDD04 Ralph progress update: iteration ${count} on \`${w.changeName}\``);
70574
+ await this.deps.postComment(w.issue, `\uD83D\uDD04 Ralph progress update: iteration ${count} on \`${w.changeName}\``);
70505
70575
  w.lastReportedIteration = count;
70506
70576
  } catch (err) {
70507
70577
  this.deps.onLog(`! Linear progress comment failed for ${w.issueIdentifier}: ${err.message}`, "red");
70508
70578
  }
70509
70579
  }
70510
70580
  }
70581
+ async scanDoneForConflicts() {
70582
+ if (!this.opts.setConflicted)
70583
+ return;
70584
+ let candidates = [];
70585
+ try {
70586
+ candidates = await this.deps.fetchDoneCandidates();
70587
+ } catch (err) {
70588
+ this.deps.onLog(`! conflict scan fetch failed: ${err.message}`, "yellow");
70589
+ return;
70590
+ }
70591
+ if (candidates.length === 0)
70592
+ return;
70593
+ for (const issue of candidates) {
70594
+ if (this.workers.some((w) => w.issueId === issue.id))
70595
+ continue;
70596
+ if (this.pendingIds.has(issue.id))
70597
+ continue;
70598
+ if (this.queue.some((q) => q.issue.id === issue.id))
70599
+ continue;
70600
+ let pr;
70601
+ try {
70602
+ pr = await this.deps.checkPrConflict(issue);
70603
+ } catch (err) {
70604
+ this.deps.onLog(`! PR conflict check failed for ${issue.identifier}: ${err.message}`, "yellow");
70605
+ continue;
70606
+ }
70607
+ if (!pr || !pr.conflicting)
70608
+ continue;
70609
+ const alreadyNotified = this.conflictNotified.has(issue.id);
70610
+ if (alreadyNotified)
70611
+ continue;
70612
+ try {
70613
+ await this.deps.applyIndicator(issue, this.opts.setConflicted);
70614
+ } catch (err) {
70615
+ this.deps.onLog(`! Linear setConflicted failed for ${issue.identifier}: ${err.message}`, "red");
70616
+ continue;
70617
+ }
70618
+ this.conflictNotified.add(issue.id);
70619
+ if (this.opts.postComments !== false) {
70620
+ try {
70621
+ await this.deps.postComment(issue, `\u26A0 Ralph detected merge conflicts on this PR (${pr.url}) \u2014 re-running to resolve`);
70622
+ } catch (err) {
70623
+ this.deps.onLog(`! Linear conflict comment failed for ${issue.identifier}: ${err.message}`, "yellow");
70624
+ }
70625
+ }
70626
+ this.queue.push({ issue, mode: "conflict-fix" });
70627
+ }
70628
+ this.spawnNext();
70629
+ }
70511
70630
  spawnNext() {
70512
70631
  if (this.stopped)
70513
70632
  return;
70514
70633
  while (this.workers.length + this.pendingIds.size < this.opts.concurrency && this.queue.length > 0) {
70515
- const issue = this.queue.shift();
70516
- this.pendingIds.add(issue.id);
70517
- this.launchWorker(issue);
70634
+ const next = this.queue.shift();
70635
+ this.pendingIds.add(next.issue.id);
70636
+ this.launchWorker(next.issue, next.mode);
70518
70637
  }
70519
70638
  }
70520
- async launchWorker(issue) {
70521
- let changeName;
70639
+ async launchWorker(issue, mode) {
70640
+ let prep;
70522
70641
  try {
70523
- changeName = await this.deps.scaffold(issue);
70642
+ prep = await this.deps.prepare(issue, mode);
70524
70643
  } catch (err) {
70525
70644
  this.pendingIds.delete(issue.id);
70526
- this.deps.onLog(`! scaffold failed for ${issue.identifier}: ${err.message}`, "red");
70645
+ this.deps.onLog(`! prepare(${mode}) failed for ${issue.identifier}: ${err.message}`, "red");
70527
70646
  this.spawnNext();
70528
70647
  return;
70529
70648
  }
@@ -70531,113 +70650,89 @@ class AgentCoordinator {
70531
70650
  this.pendingIds.delete(issue.id);
70532
70651
  return;
70533
70652
  }
70534
- {
70535
- const existing = this.deps.store.snapshot().tasks[issue.identifier];
70536
- this.deps.store.upsertTask(issue, {
70537
- state: "started",
70538
- changeName,
70539
- startedAt: existing?.startedAt ?? new Date().toISOString()
70540
- });
70653
+ if (mode === "fresh" && this.opts.setInProgress) {
70654
+ try {
70655
+ await this.deps.applyIndicator(issue, this.opts.setInProgress);
70656
+ } catch (err) {
70657
+ this.deps.onLog(`! Linear setInProgress failed for ${issue.identifier}: ${err.message}`, "yellow");
70658
+ }
70659
+ }
70660
+ if (mode === "fresh" && this.opts.postComments !== false) {
70661
+ let alreadyPosted = false;
70662
+ try {
70663
+ const comments = await this.deps.fetchComments(issue.id);
70664
+ alreadyPosted = comments.some((c) => c.body.startsWith("\uD83E\uDD16 Ralph started working"));
70665
+ } catch (err) {
70666
+ this.deps.onLog(`! Linear comment fetch failed for ${issue.identifier}: ${err.message}`, "yellow");
70667
+ }
70668
+ if (!alreadyPosted) {
70669
+ try {
70670
+ await this.deps.postComment(issue, `\uD83E\uDD16 Ralph started working on this issue. Tracking change: \`${prep.changeName}\``);
70671
+ } catch (err) {
70672
+ this.deps.onLog(`! Linear comment failed for ${issue.identifier}: ${err.message}`, "red");
70673
+ }
70674
+ }
70541
70675
  }
70542
- this.deps.onLog(`\u25B6 ${issue.identifier} \u2192 ${changeName} (worker started)`, "cyan");
70543
- const handle = this.deps.spawnWorker(changeName, issue);
70676
+ this.deps.onLog(`\u25B6 ${issue.identifier} \u2192 ${prep.changeName} (${mode})`, mode === "conflict-fix" ? "yellow" : "cyan");
70677
+ const handle = this.deps.spawnWorker(prep.changeName, issue);
70544
70678
  const worker = {
70545
- changeName,
70679
+ changeName: prep.changeName,
70546
70680
  issueId: issue.id,
70547
70681
  issueIdentifier: issue.identifier,
70548
70682
  issue,
70683
+ mode,
70549
70684
  kill: handle.kill,
70550
70685
  lastReportedIteration: 0
70551
70686
  };
70552
70687
  this.workers.push(worker);
70553
70688
  this.pendingIds.delete(issue.id);
70554
70689
  this.deps.onWorkersChanged();
70555
- this.notifyStarted(issue, changeName);
70556
- handle.exited.then((code) => {
70690
+ handle.exited.then(async (code) => {
70557
70691
  const idx = this.workers.indexOf(worker);
70558
70692
  if (idx >= 0)
70559
70693
  this.workers.splice(idx, 1);
70560
70694
  const ok = code === 0;
70561
- this.deps.onLog(`${ok ? "\u2713" : "\u2717"} ${issue.identifier} \u2192 ${changeName} exited (code ${code})`, ok ? "green" : "red");
70562
- this.deps.store.upsertTask(issue, {
70563
- state: ok ? "processed" : "failed",
70564
- finishedAt: new Date().toISOString(),
70565
- exitCode: code
70566
- });
70567
- this.notifyExited(issue, changeName, code);
70695
+ this.deps.onLog(`${ok ? "\u2713" : "\u2717"} ${issue.identifier} \u2192 ${prep.changeName} exited (code ${code})`, ok ? "green" : "red");
70696
+ await this.notifyExited(issue, prep.changeName, code, mode);
70568
70697
  this.deps.onWorkersChanged();
70569
70698
  this.spawnNext();
70570
70699
  });
70571
70700
  }
70572
- async notifyStarted(issue, changeName) {
70573
- const updater = this.deps.updater;
70574
- if (!updater)
70575
- return;
70576
- const alreadyCommented = this.deps.store.snapshot().tasks[issue.identifier]?.commentPosted === true;
70577
- if (this.opts.postComments !== false && !alreadyCommented) {
70578
- try {
70579
- await updater.postComment(issue, `\uD83E\uDD16 Ralph started working on this issue. Tracking change: \`${changeName}\``);
70580
- await this.deps.store.upsertTask(issue, { commentPosted: true });
70581
- } catch (err) {
70582
- this.deps.onLog(`! Linear comment failed for ${issue.identifier}: ${err.message}`, "red");
70583
- }
70584
- }
70585
- if (this.opts.inProgressStatus) {
70586
- await this.moveIssue(issue, this.opts.inProgressStatus);
70587
- }
70588
- }
70589
- async notifyExited(issue, changeName, code) {
70590
- const updater = this.deps.updater;
70591
- if (!updater)
70592
- return;
70701
+ async notifyExited(issue, changeName, code, mode) {
70593
70702
  const ok = code === 0;
70594
70703
  if (this.opts.postComments !== false) {
70595
- const body = ok ? `\u2705 Ralph completed work on this issue. Change: \`${changeName}\`` : `\u2717 Ralph exited with code ${code} on this issue. Change: \`${changeName}\`
70704
+ const body = ok ? mode === "conflict-fix" ? `\u2705 Ralph resolved merge conflicts on this issue. Change: \`${changeName}\`` : `\u2705 Ralph completed work on this issue. Change: \`${changeName}\`` : `\u2717 Ralph exited with code ${code} on this issue. Change: \`${changeName}\`
70596
70705
 
70597
- ` + `This issue has been quarantined and will not be auto-resumed on the next poll. ` + `Inspect the worktree at \`~/.ralph/<project>/worktrees/${changeName}\`, fix the underlying ` + `failure (e.g. lint/typecheck), then run \`ralph clean --name ${changeName}\` to ` + `clear the quarantine and let the next poll re-pick the issue.`;
70706
+ ` + `This issue has been quarantined and will not be auto-resumed on the next poll. ` + `Inspect the worktree at \`~/.ralph/<project>/worktrees/${changeName}\`, fix the ` + `underlying failure, then remove the error marker on this Linear issue (or run ` + `\`ralph clean --name ${changeName}\`) to clear the quarantine.`;
70598
70707
  try {
70599
- await updater.postComment(issue, body);
70708
+ await this.deps.postComment(issue, body);
70600
70709
  } catch (err) {
70601
70710
  this.deps.onLog(`! Linear comment failed for ${issue.identifier}: ${err.message}`, "red");
70602
70711
  }
70603
70712
  }
70604
- if (ok && this.opts.doneStatus) {
70605
- await this.moveIssue(issue, this.opts.doneStatus);
70606
- }
70607
- if (ok && this.opts.doneLabel) {
70608
- await this.tagIssue(issue, this.opts.doneLabel);
70609
- }
70610
- }
70611
- async tagIssue(issue, labelName) {
70612
- const updater = this.deps.updater;
70613
- if (!updater.resolveLabelId || !updater.addLabel) {
70614
- this.deps.onLog(`! Linear updater does not support labels (cannot tag ${issue.identifier} with '${labelName}')`, "yellow");
70615
- return;
70616
- }
70617
- try {
70618
- const labelId = await updater.resolveLabelId(issue, labelName);
70619
- if (!labelId) {
70620
- this.deps.onLog(`! Linear label '${labelName}' not found for ${issue.identifier}`, "yellow");
70621
- return;
70713
+ if (ok) {
70714
+ if (mode === "conflict-fix") {
70715
+ if (this.opts.clearConflicted) {
70716
+ try {
70717
+ await this.deps.removeIndicator(issue, this.opts.clearConflicted);
70718
+ } catch (err) {
70719
+ this.deps.onLog(`! Linear clearConflicted failed for ${issue.identifier}: ${err.message}`, "red");
70720
+ }
70721
+ }
70722
+ this.conflictNotified.delete(issue.id);
70723
+ } else if (this.opts.setDone) {
70724
+ try {
70725
+ await this.deps.applyIndicator(issue, this.opts.setDone);
70726
+ } catch (err) {
70727
+ this.deps.onLog(`! Linear setDone failed for ${issue.identifier}: ${err.message}`, "red");
70728
+ }
70622
70729
  }
70623
- await updater.addLabel(issue, labelId);
70624
- this.deps.onLog(` \u2192 ${issue.identifier} tagged with '${labelName}'`, "gray");
70625
- } catch (err) {
70626
- this.deps.onLog(`! Linear label add failed for ${issue.identifier}: ${err.message}`, "red");
70627
- }
70628
- }
70629
- async moveIssue(issue, stateName) {
70630
- const updater = this.deps.updater;
70631
- try {
70632
- const stateId = await updater.resolveStateId(issue, stateName);
70633
- if (!stateId) {
70634
- this.deps.onLog(`! Linear state '${stateName}' not found for ${issue.identifier}`, "yellow");
70635
- return;
70730
+ } else if (this.opts.setError) {
70731
+ try {
70732
+ await this.deps.applyIndicator(issue, this.opts.setError);
70733
+ } catch (err) {
70734
+ this.deps.onLog(`! Linear setError failed for ${issue.identifier}: ${err.message}`, "red");
70636
70735
  }
70637
- await updater.setState(issue, stateId);
70638
- this.deps.onLog(` \u2192 ${issue.identifier} moved to '${stateName}'`, "gray");
70639
- } catch (err) {
70640
- this.deps.onLog(`! Linear state move failed for ${issue.identifier}: ${err.message}`, "red");
70641
70736
  }
70642
70737
  }
70643
70738
  stop() {
@@ -70651,7 +70746,7 @@ class AgentCoordinator {
70651
70746
  }
70652
70747
 
70653
70748
  // apps/cli/src/agent/scaffold.ts
70654
- import { join as join13 } from "path";
70749
+ import { join as join12 } from "path";
70655
70750
  import { mkdir as mkdir2 } from "fs/promises";
70656
70751
  function changeNameForIssue(issue) {
70657
70752
  const slug = issue.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
@@ -70659,10 +70754,10 @@ function changeNameForIssue(issue) {
70659
70754
  }
70660
70755
  async function scaffoldChangeForIssue(tasksDir, statesDir, issue, comments = [], appendPrompt = "") {
70661
70756
  const name = changeNameForIssue(issue);
70662
- const changeDir = join13(tasksDir, name);
70663
- const stateDir = join13(statesDir, name);
70757
+ const changeDir = join12(tasksDir, name);
70758
+ const stateDir = join12(statesDir, name);
70664
70759
  await mkdir2(changeDir, { recursive: true });
70665
- await mkdir2(join13(changeDir, "specs"), { recursive: true });
70760
+ await mkdir2(join12(changeDir, "specs"), { recursive: true });
70666
70761
  await mkdir2(stateDir, { recursive: true });
70667
70762
  const commentsBlock = comments.length > 0 ? [
70668
70763
  "",
@@ -70714,25 +70809,25 @@ async function scaffoldChangeForIssue(tasksDir, statesDir, issue, comments = [],
70714
70809
  ""
70715
70810
  ].join(`
70716
70811
  `);
70717
- await Bun.write(join13(changeDir, "proposal.md"), proposal);
70718
- await Bun.write(join13(changeDir, "tasks.md"), tasks);
70719
- await Bun.write(join13(changeDir, "design.md"), design);
70812
+ await Bun.write(join12(changeDir, "proposal.md"), proposal);
70813
+ await Bun.write(join12(changeDir, "tasks.md"), tasks);
70814
+ await Bun.write(join12(changeDir, "design.md"), design);
70720
70815
  return name;
70721
70816
  }
70722
70817
 
70723
70818
  // apps/cli/src/agent/worktree.ts
70724
- import { basename, join as join14 } from "path";
70819
+ import { basename, join as join13 } from "path";
70725
70820
  import { homedir as homedir2 } from "os";
70726
70821
  import { exists } from "fs/promises";
70727
70822
  function worktreesDir(projectRoot) {
70728
- return join14(homedir2(), ".ralph", basename(projectRoot), "worktrees");
70823
+ return join13(homedir2(), ".ralph", basename(projectRoot), "worktrees");
70729
70824
  }
70730
70825
  function branchForChange(changeName) {
70731
70826
  return `ralph/${changeName}`;
70732
70827
  }
70733
70828
  async function createWorktree(projectRoot, changeName, runner) {
70734
70829
  const dir = worktreesDir(projectRoot);
70735
- const cwd2 = join14(dir, changeName);
70830
+ const cwd2 = join13(dir, changeName);
70736
70831
  const branch = branchForChange(changeName);
70737
70832
  const list = await runner.run(["worktree", "list", "--porcelain"], projectRoot);
70738
70833
  if (list.stdout.includes(`worktree ${cwd2}
@@ -70789,8 +70884,8 @@ async function isWorktreeSafeToRemove(cwd2, base2, runner) {
70789
70884
  return { safe: true, dirty, unpushedCommits };
70790
70885
  }
70791
70886
  async function seedWorktreeMcpConfig(projectRoot, worktreeCwd) {
70792
- const dst = join14(worktreeCwd, ".mcp.json");
70793
- const src = join14(projectRoot, ".mcp.json");
70887
+ const dst = join13(worktreeCwd, ".mcp.json");
70888
+ const src = join13(projectRoot, ".mcp.json");
70794
70889
  const source = await exists(dst) ? dst : await exists(src) ? src : null;
70795
70890
  if (!source)
70796
70891
  return;
@@ -70804,7 +70899,7 @@ async function seedWorktreeMcpConfig(projectRoot, worktreeCwd) {
70804
70899
  if (servers && typeof servers === "object") {
70805
70900
  for (const cfg of Object.values(servers)) {
70806
70901
  if (Array.isArray(cfg.args)) {
70807
- cfg.args = cfg.args.map((a) => typeof a === "string" && a.startsWith(".ralph/") ? join14(projectRoot, a) : a);
70902
+ cfg.args = cfg.args.map((a) => typeof a === "string" && a.startsWith(".ralph/") ? join13(projectRoot, a) : a);
70808
70903
  }
70809
70904
  }
70810
70905
  }
@@ -70813,7 +70908,7 @@ async function seedWorktreeMcpConfig(projectRoot, worktreeCwd) {
70813
70908
  }
70814
70909
 
70815
70910
  // apps/cli/src/agent/post-task.ts
70816
- import { join as join15 } from "path";
70911
+ import { join as join14 } from "path";
70817
70912
 
70818
70913
  // apps/cli/src/agent/pr.ts
70819
70914
  function defaultTitle(issue) {
@@ -70864,8 +70959,32 @@ async function createPullRequest(input, runner) {
70864
70959
 
70865
70960
  // apps/cli/src/agent/ci.ts
70866
70961
  var PR_CHECKS_FIELDS = "name,bucket,link,workflow,event";
70867
- async function getPrChecksStatus(prRef, runner, cwd2) {
70868
- const out = await runner.run(["gh", "pr", "checks", prRef, "--json", PR_CHECKS_FIELDS], cwd2);
70962
+ 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;
70963
+ var GH_RETRY_DELAYS = [5000, 15000, 45000];
70964
+ async function runGhWithRetry(cmd, runner, cwd2, onRetry, sleep2 = (ms) => new Promise((r) => setTimeout(r, ms))) {
70965
+ let lastErr;
70966
+ for (let i = 0;i <= GH_RETRY_DELAYS.length; i++) {
70967
+ try {
70968
+ return await runner.run(cmd, cwd2);
70969
+ } catch (err) {
70970
+ const e = err;
70971
+ const blob = `${e.message}
70972
+ ${e.stderr ?? ""}
70973
+ ${e.stdout ?? ""}`;
70974
+ if (!TRANSIENT_GH_RE.test(blob) || i === GH_RETRY_DELAYS.length)
70975
+ throw err;
70976
+ const delay2 = GH_RETRY_DELAYS[i];
70977
+ const firstLine = (e.stderr?.trim().split(`
70978
+ `)[0] ?? e.message).slice(0, 120);
70979
+ onRetry?.(i + 1, delay2, firstLine);
70980
+ await sleep2(delay2);
70981
+ lastErr = err;
70982
+ }
70983
+ }
70984
+ throw lastErr;
70985
+ }
70986
+ async function getPrChecksStatus(prRef, runner, cwd2, onTransientRetry) {
70987
+ const out = await runGhWithRetry(["gh", "pr", "checks", prRef, "--json", PR_CHECKS_FIELDS], runner, cwd2, onTransientRetry);
70869
70988
  const checks = JSON.parse(out.stdout || "[]").filter((c) => c.bucket !== "skipping");
70870
70989
  if (checks.some((c) => c.bucket === "pending")) {
70871
70990
  return { bucket: "pending", failedRunIds: [] };
@@ -70902,17 +71021,28 @@ ${truncated}`);
70902
71021
  }
70903
71022
  async function fixCiUntilGreen(deps, opts) {
70904
71023
  for (let attempt2 = 1;attempt2 <= opts.maxAttempts; attempt2++) {
71024
+ let pollN = 0;
70905
71025
  while (true) {
70906
71026
  if (deps.cancelled?.())
70907
71027
  return { success: false, attempts: attempt2 - 1, reason: "cancelled" };
70908
- const s = await deps.getStatus();
71028
+ pollN += 1;
71029
+ deps.onPhase?.("ci-poll", `attempt ${attempt2}/${opts.maxAttempts} \xB7 poll ${pollN}`);
71030
+ let s;
71031
+ try {
71032
+ s = await deps.getStatus();
71033
+ } catch (err) {
71034
+ deps.log(`! gh pr checks failed permanently: ${err.message} \u2014 giving up CI watch`, "red");
71035
+ return { success: false, attempts: attempt2 - 1, reason: "gh-failed" };
71036
+ }
70909
71037
  if (s.bucket === "pass") {
70910
71038
  deps.log(`\u2713 CI green for PR (after ${attempt2 - 1} fix attempts)`, "green");
70911
71039
  return { success: true, attempts: attempt2 - 1 };
70912
71040
  }
70913
71041
  if (s.bucket === "fail") {
70914
71042
  deps.log(`\u2717 CI failing (attempt ${attempt2}/${opts.maxAttempts}) \u2014 fetching logs and re-running task`, "yellow");
71043
+ deps.onPhase?.("ci-fix", `attempt ${attempt2}/${opts.maxAttempts} \xB7 fetching logs`);
70915
71044
  const logs = await deps.getFailedLogs(s.failedRunIds);
71045
+ deps.onPhase?.("ci-fix", `attempt ${attempt2}/${opts.maxAttempts} \xB7 re-running worker`);
70916
71046
  const steering = `CI is failing on this PR. Investigate and fix:
70917
71047
 
70918
71048
  \`\`\`
@@ -70923,6 +71053,7 @@ ${logs}
70923
71053
  deps.log(`! task loop exited code ${code} during CI fix attempt ${attempt2}`, "red");
70924
71054
  }
70925
71055
  try {
71056
+ deps.onPhase?.("ci-fix", `attempt ${attempt2}/${opts.maxAttempts} \xB7 pushing fix`);
70926
71057
  await deps.pushBranch();
70927
71058
  } catch (err) {
70928
71059
  deps.log(`! push failed during CI fix: ${err.message}`, "red");
@@ -70930,6 +71061,7 @@ ${logs}
70930
71061
  }
70931
71062
  break;
70932
71063
  }
71064
+ deps.onPhase?.("ci-poll", `attempt ${attempt2}/${opts.maxAttempts} \xB7 pending, waiting`);
70933
71065
  await deps.sleep(opts.pollIntervalSeconds * 1000);
70934
71066
  }
70935
71067
  }
@@ -70957,6 +71089,7 @@ async function reactivateState(stateFilePath, log2, changeName) {
70957
71089
  }
70958
71090
  async function runPostTask(input, deps) {
70959
71091
  const { log: log2, cmd, git, runScript } = deps;
71092
+ const emit = (phase, detail) => deps.onPhase?.(phase, detail);
70960
71093
  const {
70961
71094
  changeName,
70962
71095
  cwd: cwd2,
@@ -70973,6 +71106,7 @@ async function runPostTask(input, deps) {
70973
71106
  respawnWorker
70974
71107
  } = input;
70975
71108
  if (cfg.teardownScript) {
71109
+ emit("teardown", cfg.teardownScript);
70976
71110
  try {
70977
71111
  await runScript("teardown", cfg.teardownScript, cwd2);
70978
71112
  } catch {}
@@ -70987,7 +71121,7 @@ async function runPostTask(input, deps) {
70987
71121
  const maxHookFixAttempts = cfg.maxCiFixAttempts;
70988
71122
  const runWorkerWithFixTask = async (heading, failureOutput) => {
70989
71123
  try {
70990
- await prependFixTask(join15(changeDir, "tasks.md"), heading, failureOutput);
71124
+ await prependFixTask(join14(changeDir, "tasks.md"), heading, failureOutput);
70991
71125
  } catch (err) {
70992
71126
  log2(`! could not prepend fix task: ${err.message}`, "red");
70993
71127
  return 1;
@@ -70998,6 +71132,7 @@ async function runPostTask(input, deps) {
70998
71132
  let hookFixAttempt = 0;
70999
71133
  let commitGaveUp = false;
71000
71134
  while (true) {
71135
+ emit("committing", "git status");
71001
71136
  let dirty = "";
71002
71137
  try {
71003
71138
  const status = await cmd.run(["git", "status", "--porcelain"], cwd2);
@@ -71009,7 +71144,9 @@ async function runPostTask(input, deps) {
71009
71144
  if (!dirty)
71010
71145
  break;
71011
71146
  try {
71147
+ emit("committing", "git add -A");
71012
71148
  await cmd.run(["git", "add", "-A"], cwd2);
71149
+ emit("committing", "git commit");
71013
71150
  await cmd.run(["git", "commit", "-m", `chore(ralph): residual changes for ${changeName}`], cwd2);
71014
71151
  log2(` committed residual changes for ${changeName}`, "gray");
71015
71152
  break;
@@ -71028,6 +71165,7 @@ ${e.stderr ?? ""}`;
71028
71165
  break;
71029
71166
  }
71030
71167
  hookFixAttempt += 1;
71168
+ emit("commit-retry", `${hookFixAttempt}/${maxHookFixAttempts}`);
71031
71169
  log2(`! commit rejected for ${changeName} \u2014 prepending fix task and re-running loop (attempt ${hookFixAttempt}/${maxHookFixAttempts})`, "yellow");
71032
71170
  log2(` detail: ${detail}`, "yellow");
71033
71171
  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.
@@ -71043,8 +71181,10 @@ ${e.stderr ?? ""}`;
71043
71181
  }
71044
71182
  let pr = null;
71045
71183
  let prGaveUp = commitGaveUp;
71184
+ let nonFfRebaseAttempted = false;
71046
71185
  while (!prGaveUp) {
71047
71186
  try {
71187
+ emit("pr-create", "git push + gh pr create");
71048
71188
  pr = await createPullRequest({ cwd: cwd2, branch, issue, base: cfg.prBaseBranch }, cmd);
71049
71189
  break;
71050
71190
  } catch (err) {
@@ -71052,8 +71192,69 @@ ${e.stderr ?? ""}`;
71052
71192
  const detail = e.stderr?.trim() || e.message;
71053
71193
  const combined = `${e.stdout ?? ""}
71054
71194
  ${e.stderr ?? ""}`;
71055
- const pushRejected = /failed to push some refs|pre-push hook|hook declined/i.test(combined);
71056
- if (!pushRejected || hookFixAttempt >= maxHookFixAttempts) {
71195
+ 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);
71196
+ const isHookReject = /pre-push hook|hook declined/i.test(combined);
71197
+ const pushRejected = isHookReject || /failed to push some refs/i.test(combined);
71198
+ if (isNonFastForward && !nonFfRebaseAttempted) {
71199
+ nonFfRebaseAttempted = true;
71200
+ emit("rebasing", `git pull --rebase origin ${branch}`);
71201
+ log2(` non-fast-forward push for ${changeName} \u2014 rebasing onto origin/${branch}`, "yellow");
71202
+ try {
71203
+ await cmd.run(["git", "fetch", "origin", branch], cwd2);
71204
+ await cmd.run(["git", "pull", "--rebase", "origin", branch], cwd2);
71205
+ continue;
71206
+ } catch (rebaseErr) {
71207
+ const re = rebaseErr;
71208
+ const reBlob = `${re.stdout ?? ""}
71209
+ ${re.stderr ?? ""}`;
71210
+ const isConflict = /CONFLICT|Merge conflict|could not apply|both modified/i.test(reBlob);
71211
+ if (!isConflict) {
71212
+ log2(`! rebase failed for ${changeName}: ${rebaseErr.message} \u2014 giving up`, "red");
71213
+ effectiveCode = PR_FAILED_EXIT;
71214
+ prGaveUp = true;
71215
+ break;
71216
+ }
71217
+ emit("rebasing", "conflicts detected \u2014 aborting + queueing fix task");
71218
+ try {
71219
+ await cmd.run(["git", "rebase", "--abort"], cwd2);
71220
+ } catch {}
71221
+ let conflictedFiles = "";
71222
+ try {
71223
+ const r = await cmd.run(["git", "diff", "--name-only", `HEAD..origin/${branch}`], cwd2);
71224
+ conflictedFiles = r.stdout.trim();
71225
+ } catch {}
71226
+ if (hookFixAttempt >= maxHookFixAttempts) {
71227
+ log2(`! merge conflict on rebase of ${branch} after ${hookFixAttempt} attempts \u2014 worktree preserved at ${cwd2}`, "red");
71228
+ log2(` detail: ${reBlob.trim().split(`
71229
+ `).slice(0, 8).join(`
71230
+ `)}`, "red");
71231
+ effectiveCode = PR_FAILED_EXIT;
71232
+ prGaveUp = true;
71233
+ break;
71234
+ }
71235
+ hookFixAttempt += 1;
71236
+ emit("rebasing", `conflict-fix ${hookFixAttempt}/${maxHookFixAttempts}`);
71237
+ log2(`! merge conflict rebasing ${branch} \u2014 prepending fix task and re-running loop (attempt ${hookFixAttempt}/${maxHookFixAttempts})`, "yellow");
71238
+ 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.
71239
+
71240
+ ` + `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.
71241
+
71242
+ ` + (conflictedFiles ? `Files that differ between your branch and origin/${branch}:
71243
+ ${conflictedFiles}
71244
+
71245
+ ` : "") + `Rebase output:
71246
+ ${reBlob.trim()}`);
71247
+ if (retryCode2 !== 0) {
71248
+ log2(`! worker re-run after merge conflict exited code ${retryCode2} \u2014 giving up`, "red");
71249
+ effectiveCode = PR_FAILED_EXIT;
71250
+ prGaveUp = true;
71251
+ break;
71252
+ }
71253
+ nonFfRebaseAttempted = false;
71254
+ continue;
71255
+ }
71256
+ }
71257
+ if (!isHookReject || hookFixAttempt >= maxHookFixAttempts) {
71057
71258
  if (pushRejected) {
71058
71259
  log2(`! push rejected for ${changeName} after ${hookFixAttempt} hook-fix attempts (host pre-push hook still failing) \u2014 worktree preserved at ${cwd2}`, "red");
71059
71260
  log2(` detail: ${detail}`, "red");
@@ -71065,6 +71266,7 @@ ${e.stderr ?? ""}`;
71065
71266
  break;
71066
71267
  }
71067
71268
  hookFixAttempt += 1;
71269
+ emit("push-retry", `${hookFixAttempt}/${maxHookFixAttempts}`);
71068
71270
  log2(`! push rejected for ${changeName} \u2014 prepending fix task and re-running loop (attempt ${hookFixAttempt}/${maxHookFixAttempts})`, "yellow");
71069
71271
  log2(` detail: ${detail}`, "yellow");
71070
71272
  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.
@@ -71082,14 +71284,17 @@ ${e.stderr ?? ""}`;
71082
71284
  log2(` no commits ahead of ${cfg.prBaseBranch} \u2014 skipping PR`, "gray");
71083
71285
  } else {
71084
71286
  log2(` ${pr.created ? "opened" : "found existing"} PR: ${pr.url}`, "green");
71287
+ deps.registerPr?.(changeName, pr.url);
71085
71288
  if (wantFixCi) {
71086
71289
  log2(` watching CI for ${pr.url} (max ${cfg.maxCiFixAttempts} fix attempts)`, "gray");
71290
+ emit("ci-poll", "starting");
71087
71291
  const result2 = await fixCiUntilGreen({
71088
- getStatus: () => getPrChecksStatus(pr.url, cmd, cwd2),
71292
+ onPhase: (p, d) => emit(p, d),
71293
+ 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")),
71089
71294
  getFailedLogs: (ids) => fetchFailedRunLogs(ids, cmd, cwd2),
71090
71295
  runTaskWithSteering: async (steering) => {
71091
71296
  try {
71092
- await prependFixTask(join15(changeDir, "tasks.md"), "Fix failing CI checks", steering);
71297
+ await prependFixTask(join14(changeDir, "tasks.md"), "Fix failing CI checks", steering);
71093
71298
  } catch (err) {
71094
71299
  log2(`! could not prepend fix task: ${err.message}`, "red");
71095
71300
  }
@@ -71112,7 +71317,12 @@ ${e.stderr ?? ""}`;
71112
71317
  }
71113
71318
  }
71114
71319
  }
71320
+ if (effectiveCode === 0)
71321
+ emit("done");
71322
+ else
71323
+ emit("gave-up", `exit ${effectiveCode}`);
71115
71324
  if (useWorktree && cwd2 !== projectRoot) {
71325
+ emit("cleanup", "checking worktree safety");
71116
71326
  if (effectiveCode === 0 && cfg.cleanupWorktreeOnSuccess) {
71117
71327
  const check = await isWorktreeSafeToRemove(cwd2, cfg.prBaseBranch, git).catch((err) => ({
71118
71328
  safe: false,
@@ -71176,6 +71386,50 @@ var bunCmdRunner = {
71176
71386
  return { stdout, stderr };
71177
71387
  }
71178
71388
  };
71389
+ function traceCmdRunner(base2, onStart, onEnd) {
71390
+ return {
71391
+ run: async (cmd, cwd2) => {
71392
+ const t0 = Date.now();
71393
+ onStart(cmd);
71394
+ try {
71395
+ const r = await base2.run(cmd, cwd2);
71396
+ onEnd(cmd, Date.now() - t0, true);
71397
+ return r;
71398
+ } catch (err) {
71399
+ onEnd(cmd, Date.now() - t0, false);
71400
+ throw err;
71401
+ }
71402
+ }
71403
+ };
71404
+ }
71405
+ function mergeIndicators(cfg, cli) {
71406
+ const out = {};
71407
+ for (const [k, v] of Object.entries(cfg)) {
71408
+ if (v !== undefined)
71409
+ out[k] = v;
71410
+ }
71411
+ for (const [k, v] of Object.entries(cli)) {
71412
+ if (v !== undefined)
71413
+ out[k] = v;
71414
+ }
71415
+ return out;
71416
+ }
71417
+ function unionMarkers(...sets) {
71418
+ const out = [];
71419
+ const seen = new Set;
71420
+ for (const s of sets) {
71421
+ if (!s)
71422
+ continue;
71423
+ for (const m of markersOf(s)) {
71424
+ const key = `${m.type}:${m.value}`;
71425
+ if (seen.has(key))
71426
+ continue;
71427
+ seen.add(key);
71428
+ out.push(m);
71429
+ }
71430
+ }
71431
+ return out;
71432
+ }
71179
71433
  function buildAgentCoordinator(input) {
71180
71434
  const {
71181
71435
  args,
@@ -71184,33 +71438,104 @@ function buildAgentCoordinator(input) {
71184
71438
  statesDir,
71185
71439
  tasksDir,
71186
71440
  apiKey,
71187
- store,
71188
71441
  onLog,
71189
71442
  onWorkersChanged,
71190
71443
  onWorkerStarted,
71191
- onWorkerExited
71444
+ onWorkerExited,
71445
+ onWorkerPhase,
71446
+ onWorkerOutput,
71447
+ onWorkerCmd
71192
71448
  } = input;
71449
+ const logsDir = join15(projectRoot, ".ralph", "logs");
71193
71450
  const concurrency = args.concurrency || cfg.concurrency;
71194
71451
  const pollInterval = args.pollInterval || cfg.pollIntervalSeconds;
71195
- const inProgressName = args.inProgressStatus || cfg.linear.inProgressStatus;
71196
- const baseStatuses = args.linearStatus.length ? args.linearStatus : cfg.linear.statuses;
71197
- const effectiveStatuses = inProgressName && baseStatuses.length > 0 && !baseStatuses.includes(inProgressName) ? [...baseStatuses, inProgressName] : baseStatuses;
71198
- const filter2 = {
71199
- team: args.linearTeam || cfg.linear.team,
71200
- assignee: args.linearAssignee || cfg.linear.assignee,
71201
- statuses: effectiveStatuses,
71202
- labels: args.linearLabel.length ? args.linearLabel : cfg.linear.labels
71203
- };
71452
+ const indicators = mergeIndicators(cfg.linear.indicators, args.indicators);
71453
+ const team = args.linearTeam || cfg.linear.team;
71454
+ const assignee = args.linearAssignee || cfg.linear.assignee;
71455
+ const excludeFromTodo = unionMarkers(indicators.setDone, indicators.setError, indicators.setConflicted);
71456
+ const gitRunner = input.runners?.git ?? bunGitRunner;
71457
+ const cmdRunner = input.runners?.cmd ?? bunCmdRunner;
71204
71458
  const stateCache = new Map;
71205
71459
  const labelCache = new Map;
71206
71460
  const teamKeyOf = (issue) => issue.identifier.split("-")[0];
71207
- const useWorktree = args.worktree || cfg.useWorktree;
71461
+ async function resolveStateId(issue, name) {
71462
+ const t = teamKeyOf(issue);
71463
+ let map2 = stateCache.get(t);
71464
+ if (!map2) {
71465
+ const states = await fetchWorkflowStates(apiKey, t);
71466
+ map2 = new Map(states.map((s) => [s.name.toLowerCase(), s.id]));
71467
+ stateCache.set(t, map2);
71468
+ }
71469
+ return map2.get(name.toLowerCase()) ?? null;
71470
+ }
71471
+ async function resolveLabelId(issue, name) {
71472
+ const t = teamKeyOf(issue);
71473
+ let map2 = labelCache.get(t);
71474
+ if (!map2) {
71475
+ const labels = await fetchIssueLabels(apiKey, t);
71476
+ map2 = new Map(labels.map((l) => [l.name.toLowerCase(), l.id]));
71477
+ labelCache.set(t, map2);
71478
+ }
71479
+ return map2.get(name.toLowerCase()) ?? null;
71480
+ }
71481
+ async function applyMarker(issue, m) {
71482
+ if (m.type === "status") {
71483
+ const id = await resolveStateId(issue, m.value);
71484
+ if (!id) {
71485
+ onLog(`! Linear status '${m.value}' not found for ${issue.identifier}`, "yellow");
71486
+ return;
71487
+ }
71488
+ await updateIssueState(apiKey, issue.id, id);
71489
+ onLog(` \u2192 ${issue.identifier} status='${m.value}'`, "gray");
71490
+ } else {
71491
+ const id = await resolveLabelId(issue, m.value);
71492
+ if (!id) {
71493
+ onLog(`! Linear label '${m.value}' not found for ${issue.identifier}`, "yellow");
71494
+ return;
71495
+ }
71496
+ await addLabelToIssue(apiKey, issue.id, id);
71497
+ onLog(` \u2192 ${issue.identifier} +label='${m.value}'`, "gray");
71498
+ }
71499
+ }
71500
+ async function applyIndicator(issue, ind) {
71501
+ for (const m of markersOf(ind))
71502
+ await applyMarker(issue, m);
71503
+ }
71504
+ async function removeIndicator(issue, ind) {
71505
+ for (const m of markersOf(ind)) {
71506
+ if (m.type !== "label")
71507
+ continue;
71508
+ const id = await resolveLabelId(issue, m.value);
71509
+ if (!id) {
71510
+ onLog(`! Linear label '${m.value}' not found for ${issue.identifier}`, "yellow");
71511
+ continue;
71512
+ }
71513
+ await removeLabelFromIssue(apiKey, issue.id, id);
71514
+ onLog(` \u2192 ${issue.identifier} -label='${m.value}'`, "gray");
71515
+ }
71516
+ }
71517
+ async function fetchByGet(inc, excl) {
71518
+ if (!inc)
71519
+ return [];
71520
+ const include = "filter" in inc ? inc.filter : [];
71521
+ if (include.length === 0)
71522
+ return [];
71523
+ const spec = {
71524
+ team,
71525
+ assignee,
71526
+ include,
71527
+ exclude: excl
71528
+ };
71529
+ return fetchOpenIssues(apiKey, spec);
71530
+ }
71208
71531
  const cwdByChange = new Map;
71209
71532
  const statesDirByChange = new Map;
71210
71533
  const branchByChange = new Map;
71211
71534
  const issueByChange = new Map;
71212
- async function runScript(label, cmd, cwd2) {
71213
- onLog(` ${label}: ${cmd}`, "gray");
71535
+ const prByChange = new Map;
71536
+ const prUnavailable = new Set;
71537
+ const useWorktree = args.worktree || cfg.useWorktree;
71538
+ const scriptRunner = input.runners?.runScript ?? (async (cmd, cwd2) => {
71214
71539
  const proc = Bun.spawn({
71215
71540
  cmd: ["sh", "-c", cmd],
71216
71541
  cwd: cwd2,
@@ -71221,51 +71546,112 @@ function buildAgentCoordinator(input) {
71221
71546
  const code = await proc.exited;
71222
71547
  if (code !== 0) {
71223
71548
  const stderr = await new Response(proc.stderr).text();
71224
- onLog(`! ${label} exited code ${code}${stderr ? `: ${stderr.trim().split(`
71549
+ onLog(`! script exited code ${code}${stderr ? `: ${stderr.trim().split(`
71225
71550
  `)[0]}` : ""}`, "yellow");
71226
71551
  }
71227
- }
71228
- async function scaffoldCallback(issue) {
71229
- let comments = [];
71230
- try {
71231
- comments = await fetchIssueComments(apiKey, issue.id);
71232
- } catch (err) {
71233
- onLog(`! Linear comment fetch failed for ${issue.identifier}: ${err.message}`, "yellow");
71552
+ return code;
71553
+ });
71554
+ async function runScript(label, cmd, cwd2) {
71555
+ onLog(` ${label}: ${cmd}`, "gray");
71556
+ const code = await scriptRunner(cmd, cwd2);
71557
+ if (code !== 0) {
71558
+ onLog(`! ${label} exited code ${code}`, "yellow");
71234
71559
  }
71560
+ }
71561
+ async function setupWorktree(issue) {
71235
71562
  let workerCwd = projectRoot;
71236
71563
  let scaffoldTasksDir = tasksDir;
71237
71564
  let scaffoldStatesDir = statesDir;
71238
- let workerBranch = null;
71565
+ let branch = null;
71566
+ if (!useWorktree)
71567
+ return { workerCwd, scaffoldTasksDir, scaffoldStatesDir, branch };
71239
71568
  const probeName = issue.identifier.toLowerCase();
71240
- if (useWorktree) {
71569
+ try {
71570
+ const wt = await createWorktree(projectRoot, probeName, gitRunner);
71571
+ workerCwd = wt.cwd;
71572
+ branch = wt.branch;
71573
+ const wtLayout = projectLayout(wt.cwd);
71574
+ scaffoldTasksDir = wtLayout.tasksDir;
71575
+ scaffoldStatesDir = wtLayout.statesDir;
71576
+ onLog(` ${issue.identifier} worktree: ${wt.cwd} (${wt.branch})`, "gray");
71241
71577
  try {
71242
- const wt = await createWorktree(projectRoot, probeName, bunGitRunner);
71243
- workerCwd = wt.cwd;
71244
- workerBranch = wt.branch;
71245
- const wtLayout = projectLayout(wt.cwd);
71246
- scaffoldTasksDir = wtLayout.tasksDir;
71247
- scaffoldStatesDir = wtLayout.statesDir;
71248
- onLog(` ${issue.identifier} worktree: ${wt.cwd} (${wt.branch})`, "gray");
71249
- try {
71250
- await seedWorktreeMcpConfig(projectRoot, wt.cwd);
71251
- } catch (err) {
71252
- onLog(`! seeding .mcp.json failed for ${issue.identifier}: ${err.message}`, "yellow");
71253
- }
71578
+ await seedWorktreeMcpConfig(projectRoot, wt.cwd);
71579
+ } catch (err) {
71580
+ onLog(`! seeding .mcp.json failed for ${issue.identifier}: ${err.message}`, "yellow");
71581
+ }
71582
+ } catch (err) {
71583
+ onLog(`! worktree create failed for ${issue.identifier}: ${err.message} \u2014 falling back to project root`, "yellow");
71584
+ }
71585
+ return { workerCwd, scaffoldTasksDir, scaffoldStatesDir, branch };
71586
+ }
71587
+ async function prepare(issue, mode) {
71588
+ const { workerCwd, scaffoldTasksDir, scaffoldStatesDir, branch } = await setupWorktree(issue);
71589
+ let changeName;
71590
+ if (mode === "fresh") {
71591
+ let comments = [];
71592
+ try {
71593
+ comments = await fetchIssueComments(apiKey, issue.id);
71254
71594
  } catch (err) {
71255
- onLog(`! worktree create failed for ${issue.identifier}: ${err.message} \u2014 falling back to project root`, "yellow");
71595
+ onLog(`! Linear comment fetch failed for ${issue.identifier}: ${err.message}`, "yellow");
71256
71596
  }
71597
+ const appendPrompt = args.prompt || cfg.appendPrompt || "";
71598
+ changeName = await scaffoldChangeForIssue(scaffoldTasksDir, scaffoldStatesDir, issue, comments, appendPrompt);
71599
+ } else {
71600
+ changeName = changeNameForIssue(issue);
71601
+ const wtLayout = projectLayout(workerCwd);
71602
+ await mkdir3(wtLayout.changeDir(changeName), { recursive: true });
71603
+ await mkdir3(wtLayout.taskStateDir(changeName), { recursive: true });
71257
71604
  }
71258
- const appendPrompt = args.prompt || cfg.appendPrompt || "";
71259
- const changeName = await scaffoldChangeForIssue(scaffoldTasksDir, scaffoldStatesDir, issue, comments, appendPrompt);
71260
71605
  cwdByChange.set(changeName, workerCwd);
71261
71606
  statesDirByChange.set(changeName, scaffoldStatesDir);
71262
71607
  issueByChange.set(changeName, issue);
71263
- if (workerBranch)
71264
- branchByChange.set(changeName, workerBranch);
71608
+ if (branch)
71609
+ branchByChange.set(changeName, branch);
71610
+ if (mode === "conflict-fix") {
71611
+ const wtLayout = projectLayout(workerCwd);
71612
+ const tasksFile = join15(wtLayout.changeDir(changeName), "tasks.md");
71613
+ const prUrl = prByChange.get(changeName);
71614
+ const body = [
71615
+ `The PR for this change has merge conflicts with \`${cfg.prBaseBranch}\`.`,
71616
+ "",
71617
+ "Steps:",
71618
+ `1. \`git fetch origin ${cfg.prBaseBranch}\` then rebase or merge \`${cfg.prBaseBranch}\` into the current branch.`,
71619
+ "2. Resolve conflicts in the files git lists.",
71620
+ "3. Stage and commit the resolution.",
71621
+ prUrl ? `
71622
+ PR: ${prUrl}` : ""
71623
+ ].filter(Boolean).join(`
71624
+ `);
71625
+ try {
71626
+ await prependFixTask(tasksFile, "Resolve PR merge conflicts", body);
71627
+ } catch (err) {
71628
+ onLog(`! could not prepend conflict-fix task: ${err.message}`, "red");
71629
+ }
71630
+ await reactivateState2(wtLayout.stateFile(changeName), changeName);
71631
+ }
71265
71632
  if (cfg.setupScript) {
71266
71633
  await runScript("setup", cfg.setupScript, workerCwd);
71267
71634
  }
71268
- return changeName;
71635
+ return {
71636
+ changeName,
71637
+ ...prByChange.has(changeName) ? { prUrl: prByChange.get(changeName) } : {}
71638
+ };
71639
+ }
71640
+ async function reactivateState2(stateFilePath, changeName) {
71641
+ const file = Bun.file(stateFilePath);
71642
+ if (!await file.exists())
71643
+ return;
71644
+ try {
71645
+ const stateObj = JSON.parse(await file.text());
71646
+ if (stateObj.status !== "active") {
71647
+ stateObj.status = "active";
71648
+ stateObj.lastModified = new Date().toISOString();
71649
+ await Bun.write(stateFilePath, JSON.stringify(stateObj, null, 2) + `
71650
+ `);
71651
+ }
71652
+ } catch (err) {
71653
+ onLog(`! could not reactivate state for ${changeName}: ${err.message}`, "yellow");
71654
+ }
71269
71655
  }
71270
71656
  function buildTaskCmdFor(changeName) {
71271
71657
  const c = [
@@ -71298,29 +71684,102 @@ function buildAgentCoordinator(input) {
71298
71684
  c.push("--verbose");
71299
71685
  return c;
71300
71686
  }
71301
- function spawnWorker(changeName) {
71302
- const cwd2 = cwdByChange.get(changeName) ?? projectRoot;
71303
- const respawn = () => {
71304
- const rp = Bun.spawn({
71305
- cmd: buildTaskCmdFor(changeName),
71306
- cwd: cwd2,
71307
- stdout: "ignore",
71308
- stderr: "ignore",
71309
- stdin: "ignore"
71310
- });
71311
- return rp.exited;
71687
+ function defaultSpawn(changeName, cmd, cwd2, note) {
71688
+ const logFilePath = join15(logsDir, `${changeName}.log`);
71689
+ let logWriter = null;
71690
+ const ensureLogWriter = async () => {
71691
+ if (logWriter)
71692
+ return logWriter;
71693
+ try {
71694
+ await Bun.write(logFilePath, "");
71695
+ logWriter = Bun.file(logFilePath).writer();
71696
+ return logWriter;
71697
+ } catch (err) {
71698
+ onLog(`! could not open worker log ${logFilePath}: ${err.message}`, "yellow");
71699
+ return null;
71700
+ }
71312
71701
  };
71313
- const proc = Bun.spawn({
71314
- cmd: buildTaskCmdFor(changeName),
71702
+ async function pump(stream, label) {
71703
+ if (!stream)
71704
+ return;
71705
+ const reader = stream.getReader();
71706
+ const decoder = new TextDecoder;
71707
+ let buf = "";
71708
+ const writer = await ensureLogWriter();
71709
+ try {
71710
+ while (true) {
71711
+ const { value, done } = await reader.read();
71712
+ if (done)
71713
+ break;
71714
+ const chunk2 = decoder.decode(value, { stream: true });
71715
+ buf += chunk2;
71716
+ let nl;
71717
+ while ((nl = buf.indexOf(`
71718
+ `)) >= 0) {
71719
+ const line = buf.slice(0, nl);
71720
+ buf = buf.slice(nl + 1);
71721
+ if (writer)
71722
+ writer.write(line + `
71723
+ `);
71724
+ if (line)
71725
+ onWorkerOutput?.(changeName, label === "err" ? `! ${line}` : line);
71726
+ }
71727
+ }
71728
+ if (buf) {
71729
+ if (writer)
71730
+ writer.write(buf + `
71731
+ `);
71732
+ onWorkerOutput?.(changeName, label === "err" ? `! ${buf}` : buf);
71733
+ }
71734
+ } catch {} finally {
71735
+ try {
71736
+ writer?.flush();
71737
+ } catch {}
71738
+ }
71739
+ }
71740
+ const p = Bun.spawn({
71741
+ cmd,
71315
71742
  cwd: cwd2,
71316
- stdout: "ignore",
71317
- stderr: "ignore",
71743
+ stdout: "pipe",
71744
+ stderr: "pipe",
71318
71745
  stdin: "ignore"
71319
71746
  });
71320
- onWorkerStarted(changeName, statesDirByChange.get(changeName) ?? statesDir);
71747
+ (async () => {
71748
+ const writer = await ensureLogWriter();
71749
+ if (note && writer)
71750
+ writer.write(`
71751
+ --- ${note} ---
71752
+ `);
71753
+ })();
71754
+ pump(p.stdout, "out");
71755
+ pump(p.stderr, "err");
71756
+ return { exited: p.exited, kill: () => p.kill(), logFilePath };
71757
+ }
71758
+ function spawnWorker(changeName) {
71759
+ const cwd2 = cwdByChange.get(changeName) ?? projectRoot;
71760
+ const injected = input.runners?.spawnWorker;
71761
+ let logFilePath;
71762
+ let handle;
71763
+ if (injected) {
71764
+ logFilePath = join15(logsDir, `${changeName}.log`);
71765
+ handle = injected(buildTaskCmdFor(changeName), cwd2);
71766
+ } else {
71767
+ const r = defaultSpawn(changeName, buildTaskCmdFor(changeName), cwd2, `spawn at ${new Date().toISOString()}`);
71768
+ logFilePath = r.logFilePath;
71769
+ handle = { exited: r.exited, kill: r.kill };
71770
+ }
71771
+ const respawn = () => {
71772
+ onWorkerPhase?.(changeName, "working", "respawn");
71773
+ if (injected)
71774
+ return injected(buildTaskCmdFor(changeName), cwd2).exited;
71775
+ return defaultSpawn(changeName, buildTaskCmdFor(changeName), cwd2, `respawn at ${new Date().toISOString()}`).exited;
71776
+ };
71777
+ onWorkerStarted(changeName, statesDirByChange.get(changeName) ?? statesDir, logFilePath);
71778
+ onWorkerPhase?.(changeName, "working");
71779
+ const tracedCmd = onWorkerCmd ? traceCmdRunner(cmdRunner, (cmd) => onWorkerCmd(changeName, cmd, "start"), (cmd, ms, ok) => onWorkerCmd(changeName, cmd, "end", ms, ok)) : cmdRunner;
71321
71780
  const wantPr = args.createPr || cfg.createPrOnSuccess;
71322
71781
  const wantFixCi = args.fixCi || cfg.fixCiOnFailure;
71323
- const wrapped = proc.exited.then(async (code) => {
71782
+ const wrapped = handle.exited.then(async (code) => {
71324
71783
  const workerLayout = projectLayout(cwd2);
71325
71784
  const effectiveCode = await runPostTask({
71326
71785
  changeName,
@@ -71342,7 +71801,19 @@ function buildAgentCoordinator(input) {
71342
71801
  cleanupWorktreeOnSuccess: cfg.cleanupWorktreeOnSuccess
71343
71802
  },
71344
71803
  respawnWorker: respawn
71345
- }, { cmd: bunCmdRunner, git: bunGitRunner, log: onLog, runScript });
71804
+ }, {
71805
+ cmd: tracedCmd,
71806
+ git: gitRunner,
71807
+ log: onLog,
71808
+ runScript,
71809
+ registerPr: (cn, url) => {
71810
+ prByChange.set(cn, url);
71811
+ prUnavailable.delete(cn);
71812
+ },
71813
+ ...onWorkerPhase && {
71814
+ onPhase: (phase, detail) => onWorkerPhase(changeName, phase, detail)
71815
+ }
71816
+ });
71346
71817
  cwdByChange.delete(changeName);
71347
71818
  statesDirByChange.delete(changeName);
71348
71819
  branchByChange.delete(changeName);
@@ -71350,13 +71821,73 @@ function buildAgentCoordinator(input) {
71350
71821
  onWorkerExited(changeName);
71351
71822
  return effectiveCode;
71352
71823
  });
71353
- return { exited: wrapped, kill: () => proc.kill() };
71824
+ return { exited: wrapped, kill: () => handle.kill() };
71825
+ }
71826
+ async function checkPrConflict(issue) {
71827
+ const changeName = changeNameForIssue(issue);
71828
+ if (prUnavailable.has(changeName))
71829
+ return null;
71830
+ const branch = branchForChange(changeName);
71831
+ let prUrl = prByChange.get(changeName);
71832
+ if (!prUrl) {
71833
+ try {
71834
+ const res = await cmdRunner.run([
71835
+ "gh",
71836
+ "pr",
71837
+ "list",
71838
+ "--head",
71839
+ branch,
71840
+ "--state",
71841
+ "open",
71842
+ "--json",
71843
+ "url",
71844
+ "--jq",
71845
+ ".[0].url // empty"
71846
+ ], projectRoot);
71847
+ const found = res.stdout.trim();
71848
+ if (!found) {
71849
+ prUnavailable.add(changeName);
71850
+ return null;
71851
+ }
71852
+ prUrl = found;
71853
+ prByChange.set(changeName, prUrl);
71854
+ } catch {
71855
+ prUnavailable.add(changeName);
71856
+ return null;
71857
+ }
71858
+ }
71859
+ try {
71860
+ const res = await cmdRunner.run(["gh", "pr", "view", prUrl, "--json", "mergeable", "--jq", ".mergeable"], projectRoot);
71861
+ const mergeable = res.stdout.trim();
71862
+ return { url: prUrl, conflicting: mergeable === "CONFLICTING" };
71863
+ } catch {
71864
+ return null;
71865
+ }
71866
+ }
71867
+ async function fetchDoneCandidates() {
71868
+ if (!indicators.setDone)
71869
+ return [];
71870
+ const include = markersOf(indicators.setDone);
71871
+ const exclude = indicators.setConflicted ? markersOf(indicators.setConflicted) : [];
71872
+ if (include.length === 0)
71873
+ return [];
71874
+ return fetchOpenIssues(apiKey, { team, assignee, include, exclude });
71354
71875
  }
71355
71876
  const coord = new AgentCoordinator({
71356
- fetchIssues: (f2) => fetchOpenIssues(apiKey, f2),
71357
- scaffold: scaffoldCallback,
71877
+ fetchTodo: () => fetchByGet(indicators.getTodo, excludeFromTodo),
71878
+ fetchInProgress: () => fetchByGet(indicators.getInProgress, []),
71879
+ fetchConflicted: () => fetchByGet(indicators.getConflicted, []),
71880
+ fetchDoneCandidates,
71881
+ prepare,
71358
71882
  spawnWorker,
71359
- store,
71883
+ applyIndicator,
71884
+ removeIndicator,
71885
+ postComment: (issue, body) => addIssueComment(apiKey, issue.id, body),
71886
+ fetchComments: async (issueId) => {
71887
+ const c = await fetchIssueComments(apiKey, issueId);
71888
+ return c.map((x) => ({ body: x.body }));
71889
+ },
71890
+ checkPrConflict,
71360
71891
  onLog,
71361
71892
  onWorkersChanged,
71362
71893
  getIterationCount: async (changeName) => {
@@ -71366,42 +71897,29 @@ function buildAgentCoordinator(input) {
71366
71897
  return 0;
71367
71898
  const json = await file.json();
71368
71899
  return json.iteration ?? 0;
71369
- },
71370
- updater: {
71371
- postComment: (issue, body) => addIssueComment(apiKey, issue.id, body),
71372
- setState: (issue, stateId) => updateIssueState(apiKey, issue.id, stateId),
71373
- resolveStateId: async (issue, stateName) => {
71374
- const team = teamKeyOf(issue);
71375
- let map2 = stateCache.get(team);
71376
- if (!map2) {
71377
- const states = await fetchWorkflowStates(apiKey, team);
71378
- map2 = new Map(states.map((s) => [s.name.toLowerCase(), s.id]));
71379
- stateCache.set(team, map2);
71380
- }
71381
- return map2.get(stateName.toLowerCase()) ?? null;
71382
- },
71383
- addLabel: (issue, labelId) => addLabelToIssue(apiKey, issue.id, labelId),
71384
- resolveLabelId: async (issue, labelName) => {
71385
- const team = teamKeyOf(issue);
71386
- let map2 = labelCache.get(team);
71387
- if (!map2) {
71388
- const labels = await fetchIssueLabels(apiKey, team);
71389
- map2 = new Map(labels.map((l) => [l.name.toLowerCase(), l.id]));
71390
- labelCache.set(team, map2);
71391
- }
71392
- return map2.get(labelName.toLowerCase()) ?? null;
71393
- }
71394
71900
  }
71395
71901
  }, {
71396
71902
  concurrency,
71397
- filter: filter2,
71398
- inProgressStatus: args.inProgressStatus || cfg.linear.inProgressStatus,
71399
- doneStatus: args.doneStatus || cfg.linear.doneStatus,
71400
- doneLabel: args.doneLabel || cfg.linear.doneLabel,
71903
+ ...indicators.setInProgress !== undefined ? { setInProgress: indicators.setInProgress } : {},
71904
+ ...indicators.setDone !== undefined ? { setDone: indicators.setDone } : {},
71905
+ ...indicators.setError !== undefined ? { setError: indicators.setError } : {},
71906
+ ...indicators.setConflicted !== undefined ? { setConflicted: indicators.setConflicted } : {},
71907
+ ...indicators.clearConflicted !== undefined ? { clearConflicted: indicators.clearConflicted } : {},
71401
71908
  postComments: cfg.linear.postComments,
71402
71909
  commentEveryIterations: cfg.linear.updateEveryIterations
71403
71910
  });
71404
- const filterDesc = `team=${filter2.team ?? "*"}, assignee=${filter2.assignee ?? "*"}, statuses=` + `${filter2.statuses?.length ? filter2.statuses.join(",") : "open"}` + `${filter2.labels?.length ? `, labels=${filter2.labels.join(",")}` : ""}`;
71911
+ (async () => {
71912
+ const legacy = Bun.file(projectLayout(projectRoot).agentStateFile);
71913
+ if (await legacy.exists()) {
71914
+ onLog(" legacy .ralph/agent-state.json detected \u2014 Linear is now the source of truth; deleting", "gray");
71915
+ try {
71916
+ await legacy.delete();
71917
+ } catch (err) {
71918
+ onLog(`! failed to delete legacy agent-state.json: ${err.message}`, "yellow");
71919
+ }
71920
+ }
71921
+ })();
71922
+ const filterDesc = describeIndicators(indicators, team, assignee);
71405
71923
  return {
71406
71924
  coord,
71407
71925
  filterDesc,
@@ -71410,6 +71928,21 @@ function buildAgentCoordinator(input) {
71410
71928
  getWorkerCwd: (changeName) => cwdByChange.get(changeName)
71411
71929
  };
71412
71930
  }
71931
+ function describeIndicators(indicators, team, assignee) {
71932
+ const parts = [];
71933
+ parts.push(`team=${team ?? "*"}`);
71934
+ parts.push(`assignee=${assignee ?? "*"}`);
71935
+ if (indicators.getTodo) {
71936
+ parts.push(`todo=[${indicators.getTodo.filter.map((m) => `${m.type}:${m.value}`).join(",")}]`);
71937
+ }
71938
+ if (indicators.getInProgress) {
71939
+ parts.push(`inProgress=[${indicators.getInProgress.filter.map((m) => `${m.type}:${m.value}`).join(",")}]`);
71940
+ }
71941
+ if (indicators.getConflicted) {
71942
+ parts.push(`conflicted=[${indicators.getConflicted.filter.map((m) => `${m.type}:${m.value}`).join(",")}]`);
71943
+ }
71944
+ return parts.join(", ");
71945
+ }
71413
71946
 
71414
71947
  // apps/cli/src/components/AgentMode.tsx
71415
71948
  var jsx_dev_runtime9 = __toESM(require_jsx_dev_runtime(), 1);
@@ -71418,6 +71951,12 @@ function nextId() {
71418
71951
  lineCounter += 1;
71419
71952
  return `${Date.now()}-${lineCounter}`;
71420
71953
  }
71954
+ var TAIL_MAX_LINES = 5;
71955
+ var CMD_DISPLAY_MAX = 80;
71956
+ function fmtCmd(argv) {
71957
+ const joined = argv.join(" ");
71958
+ return joined.length > CMD_DISPLAY_MAX ? joined.slice(0, CMD_DISPLAY_MAX - 1) + "\u2026" : joined;
71959
+ }
71421
71960
  var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
71422
71961
  function fmtElapsed(ms) {
71423
71962
  const s = Math.floor(ms / 1000);
@@ -71455,8 +71994,6 @@ function AgentMode({ args, projectRoot, statesDir, tasksDir }) {
71455
71994
  exit();
71456
71995
  return;
71457
71996
  }
71458
- const store = new AgentStateStore(projectRoot);
71459
- await store.load();
71460
71997
  const { coord: coord2, filterDesc, concurrency, pollInterval } = buildAgentCoordinator({
71461
71998
  args,
71462
71999
  cfg,
@@ -71464,18 +72001,52 @@ function AgentMode({ args, projectRoot, statesDir, tasksDir }) {
71464
72001
  statesDir,
71465
72002
  tasksDir,
71466
72003
  apiKey,
71467
- store,
71468
72004
  onLog: appendLog,
71469
72005
  onWorkersChanged: () => setTick((t) => t + 1),
71470
- onWorkerStarted: (changeName, dir) => {
72006
+ onWorkerStarted: (changeName, dir, logFile) => {
71471
72007
  workerMetaRef.current.set(changeName, {
71472
72008
  startedAt: Date.now(),
71473
72009
  statesDir: dir,
71474
- iter: 0
72010
+ logFile,
72011
+ iter: 0,
72012
+ phase: "working",
72013
+ phaseDetail: "",
72014
+ phaseStartedAt: Date.now(),
72015
+ currentCmd: null,
72016
+ lastCmd: null,
72017
+ tail: []
71475
72018
  });
71476
72019
  },
71477
72020
  onWorkerExited: (changeName) => {
71478
72021
  workerMetaRef.current.delete(changeName);
72022
+ },
72023
+ onWorkerPhase: (changeName, phase, detail) => {
72024
+ const m = workerMetaRef.current.get(changeName);
72025
+ if (!m)
72026
+ return;
72027
+ if (m.phase !== phase)
72028
+ m.phaseStartedAt = Date.now();
72029
+ m.phase = phase;
72030
+ m.phaseDetail = detail ?? "";
72031
+ },
72032
+ onWorkerOutput: (changeName, line) => {
72033
+ const m = workerMetaRef.current.get(changeName);
72034
+ if (!m)
72035
+ return;
72036
+ m.tail.push(line);
72037
+ if (m.tail.length > TAIL_MAX_LINES)
72038
+ m.tail.splice(0, m.tail.length - TAIL_MAX_LINES);
72039
+ },
72040
+ onWorkerCmd: (changeName, cmd, state, durationMs, ok) => {
72041
+ const m = workerMetaRef.current.get(changeName);
72042
+ if (!m)
72043
+ return;
72044
+ if (state === "start") {
72045
+ m.currentCmd = { argv: cmd, startedAt: Date.now() };
72046
+ } else {
72047
+ m.currentCmd = null;
72048
+ m.lastCmd = { argv: cmd, durationMs: durationMs ?? 0, ok: ok ?? true };
72049
+ }
71479
72050
  }
71480
72051
  });
71481
72052
  appendLog(`concurrency=${concurrency} pollInterval=${pollInterval}s`, "gray");
@@ -71589,19 +72160,56 @@ function AgentMode({ args, projectRoot, statesDir, tasksDir }) {
71589
72160
  const meta = workerMetaRef.current.get(w.changeName);
71590
72161
  const elapsed = meta ? fmtElapsed(now2 - meta.startedAt) : "\u2013";
71591
72162
  const iter = meta?.iter ?? 0;
71592
- return /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
71593
- color: "cyan",
72163
+ const phase = meta?.phase ?? "working";
72164
+ const phaseElapsed = meta ? fmtElapsed(now2 - meta.phaseStartedAt) : "\u2013";
72165
+ const phaseDetail = meta?.phaseDetail ? ` (${meta.phaseDetail})` : "";
72166
+ const cmd = meta?.currentCmd;
72167
+ const cmdElapsed = cmd ? fmtElapsed(now2 - cmd.startedAt) : null;
72168
+ const tail2 = meta?.tail ?? [];
72169
+ return /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Box_default, {
72170
+ flexDirection: "column",
71594
72171
  children: [
71595
- " ",
71596
- spinnerFrame,
71597
- " ",
71598
- w.issueIdentifier,
71599
- " (",
71600
- w.changeName,
71601
- ") \xB7 iter ",
71602
- iter,
71603
- " \xB7 ",
71604
- elapsed
72172
+ /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
72173
+ color: "cyan",
72174
+ children: [
72175
+ " ",
72176
+ spinnerFrame,
72177
+ " ",
72178
+ w.issueIdentifier,
72179
+ " (",
72180
+ w.changeName,
72181
+ ") \xB7 iter ",
72182
+ iter,
72183
+ " \xB7 ",
72184
+ elapsed
72185
+ ]
72186
+ }, undefined, true, undefined, this),
72187
+ /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
72188
+ dimColor: true,
72189
+ children: [
72190
+ " phase: ",
72191
+ phase,
72192
+ phaseDetail,
72193
+ " \xB7 ",
72194
+ phaseElapsed
72195
+ ]
72196
+ }, undefined, true, undefined, this),
72197
+ cmd && /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
72198
+ color: "yellow",
72199
+ children: [
72200
+ " \u23F5 ",
72201
+ fmtCmd(cmd.argv),
72202
+ " \xB7 ",
72203
+ cmdElapsed
72204
+ ]
72205
+ }, undefined, true, undefined, this),
72206
+ tail2.map((line, i) => /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
72207
+ dimColor: true,
72208
+ children: [
72209
+ " \u2502 ",
72210
+ line.length > 110 ? line.slice(0, 109) + "\u2026" : line
72211
+ ]
72212
+ }, `${w.changeName}-tail-${i}`, true, undefined, this))
71605
72213
  ]
71606
72214
  }, w.changeName, true, undefined, this);
71607
72215
  })
@@ -71613,7 +72221,7 @@ function AgentMode({ args, projectRoot, statesDir, tasksDir }) {
71613
72221
 
71614
72222
  // packages/openspec/src/openspec-change-store.ts
71615
72223
  import { join as join17, dirname as dirname4 } from "path";
71616
- import { readdir, mkdir as mkdir3 } from "fs/promises";
72224
+ import { readdir, mkdir as mkdir4 } from "fs/promises";
71617
72225
  function resolveOpenspecBin() {
71618
72226
  const pkgJsonPath = Bun.resolveSync("@fission-ai/openspec/package.json", import.meta.dir);
71619
72227
  return join17(dirname4(pkgJsonPath), "bin", "openspec.js");
@@ -71674,7 +72282,7 @@ class OpenSpecChangeStore {
71674
72282
  }
71675
72283
  async writeTaskList(name, content) {
71676
72284
  const path = join17("openspec", "changes", name, "tasks.md");
71677
- await mkdir3(dirname4(path), { recursive: true });
72285
+ await mkdir4(dirname4(path), { recursive: true });
71678
72286
  await Bun.write(path, content);
71679
72287
  }
71680
72288
  async appendSteering(name, message) {
@@ -71685,7 +72293,7 @@ class OpenSpecChangeStore {
71685
72293
 
71686
72294
  ${existing.trimStart()}` : `${message}
71687
72295
  `;
71688
- await mkdir3(dirname4(path), { recursive: true });
72296
+ await mkdir4(dirname4(path), { recursive: true });
71689
72297
  await Bun.write(path, updated);
71690
72298
  }
71691
72299
  async readSection(name, artifact, heading) {
@@ -71872,7 +72480,7 @@ try {
71872
72480
  const statesDir = layout.statesDir;
71873
72481
  const tasksDir = layout.tasksDir;
71874
72482
  if (args.mode === "init") {
71875
- await mkdir4(statesDir, { recursive: true });
72483
+ await mkdir5(statesDir, { recursive: true });
71876
72484
  const openspecBin = join19(dirname5(Bun.resolveSync("@fission-ai/openspec/package.json", import.meta.dir)), "bin", "openspec.js");
71877
72485
  Bun.spawnSync({
71878
72486
  cmd: [process.execPath, openspecBin, "init", "--tools", "none", "--force"],
@@ -71926,14 +72534,6 @@ try {
71926
72534
  await rm(stateDir, { recursive: true, force: true });
71927
72535
  removed.push(`task state ${stateDir}`);
71928
72536
  }
71929
- try {
71930
- const store = new AgentStateStore(projectRoot);
71931
- await store.load();
71932
- const removedEntry = await store.removeByChangeName(args.name);
71933
- if (removedEntry) {
71934
- removed.push(`agent-state entry for ${removedEntry.identifier} (${removedEntry.issueId})`);
71935
- }
71936
- } catch {}
71937
72537
  if (removed.length === 0) {
71938
72538
  process.stdout.write(`Nothing to clean for '${args.name}'
71939
72539
  `);
@@ -71948,13 +72548,13 @@ try {
71948
72548
  process.exit(0);
71949
72549
  }
71950
72550
  if (args.mode === "task" && args.name) {
71951
- await mkdir4(join19(statesDir, args.name), { recursive: true });
71952
- await mkdir4(join19(tasksDir, args.name), { recursive: true });
72551
+ await mkdir5(join19(statesDir, args.name), { recursive: true });
72552
+ await mkdir5(join19(tasksDir, args.name), { recursive: true });
71953
72553
  }
71954
72554
  if (args.mode === "agent") {
71955
- await mkdir4(statesDir, { recursive: true });
71956
- await mkdir4(tasksDir, { recursive: true });
71957
- await mkdir4(join19(projectRoot, ".ralph"), { recursive: true });
72555
+ await mkdir5(statesDir, { recursive: true });
72556
+ await mkdir5(tasksDir, { recursive: true });
72557
+ await mkdir5(join19(projectRoot, ".ralph"), { recursive: true });
71958
72558
  }
71959
72559
  await runWithContext(createDefaultContext(), async () => {
71960
72560
  const { waitUntilExit } = render_default(import_react59.createElement(App2, { args, statesDir, tasksDir, projectRoot }));