@neriros/ralphy 2.18.0 → 2.20.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 (3) hide show
  1. package/README.md +20 -11
  2. package/dist/cli/index.js +397 -22
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -169,6 +169,10 @@ A default `ralphy.config.json` is written on first run with every defaulted sett
169
169
  "assignee": "me",
170
170
  "postComments": true,
171
171
  "updateEveryIterations": 10,
172
+ "mentionTrigger": true,
173
+ "mentionHandle": "@ralphy",
174
+ "codeReviewTrigger": true,
175
+ "codeReviewStaleHours": 24,
172
176
  "indicators": {
173
177
  "getTodo": { "filter": [{ "type": "status", "value": "Todo" }] },
174
178
  "getInProgress": { "filter": [{ "type": "status", "value": "In Progress" }] },
@@ -213,6 +217,10 @@ Linear is the source of truth for which issues Ralph has touched. Each `linear.i
213
217
 
214
218
  **Review follow-ups.** When a Linear issue is in a "done" state and a reviewer adds the `getReview` marker (typically a label like `ralph:review` after leaving comments), Ralph picks it up, applies `setInProgress`, removes the `clearReview` label so the same trigger doesn't re-fire, fetches the comment thread, filters out Ralph's own comments, and prepends those reviewer comments as a new task at the top of `tasks.md`. The worker addresses them in the same change branch and `setDone` is re-applied on success.
215
219
 
220
+ **`@ralphy` mention trigger.** Set `linear.mentionTrigger: true` (default `false`) to scan done-issue comments on both Linear and their linked GitHub PR for `@ralphy` mentions (configurable via `linear.mentionHandle`). New mentions enqueue the issue as a review run with the mention text used verbatim as the prepended task. Idempotency: a mention is considered processed when its `createdAt` is older than the most recent Ralph `🔁 picked up` comment on the Linear issue, so the same comment never re-fires across polls. Requires the `gh` CLI authenticated for the GitHub side.
221
+
222
+ **Code-review iteration.** Set `linear.codeReviewTrigger: true` (or pass `--code-review`) to watch open, unmerged, unapproved tracked PRs for unresolved review-thread comments. When any unresolved thread has activity newer than Ralph's last `🔁 picked up` ack, the issue is queued as a review run whose prepended task is a digest of every unresolved thread plus instructions: fix-and-push for comments Ralph agrees with (resolve the thread after the commit lands), or reply on GitHub with reasoning for ones it disagrees with. The cycle repeats every poll until the PR is approved or merged. If the reviewer has been silent for more than `linear.codeReviewStaleHours` (default 24) while Ralph is the last actor, a one-shot `@`-mention ping comment is posted on the GitHub PR.
223
+
216
224
  Marker types are `"label"` or `"status"`. Combine markers under `apply` when one event needs to set multiple — e.g. `setDone` flipping a status _and_ adding a "shipped" label.
217
225
 
218
226
  #### Per-task git worktrees
@@ -256,17 +264,18 @@ Failed workers (non-zero exit) are not marked processed, so they'll be retried o
256
264
 
257
265
  ### Agent mode flags
258
266
 
259
- | Option | Description |
260
- | ------------------------- | ----------------------------------------------------------------------------- |
261
- | `--linear-team <key>` | Linear team key (e.g. `ENG`) |
262
- | `--linear-assignee <id>` | Filter by assignee (user id, email, or `me`) |
263
- | `--poll-interval <s>` | Seconds between Linear polls (default: 60) |
264
- | `--concurrency <n>` | Max concurrent task loops (default: 1) |
265
- | `--max-tickets <n>` | Stop picking up new issues after N have been started this run (0 = unlimited) |
266
- | `--worktree` | Run each task in its own git worktree |
267
- | `--indicator <k>:<t>:<v>` | Override a `linear.indicators` entry; repeatable (e.g. `setDone:status:Done`) |
268
- | `--create-pr` | Push worker branch + open a GitHub PR on success (needs `--worktree`) |
269
- | `--fix-ci` | After PR opens, re-run task on CI failures until green (needs `--create-pr`) |
267
+ | Option | Description |
268
+ | ------------------------- | ------------------------------------------------------------------------------------ |
269
+ | `--linear-team <key>` | Linear team key (e.g. `ENG`) |
270
+ | `--linear-assignee <id>` | Filter by assignee (user id, email, or `me`) |
271
+ | `--poll-interval <s>` | Seconds between Linear polls (default: 60) |
272
+ | `--concurrency <n>` | Max concurrent task loops (default: 1) |
273
+ | `--max-tickets <n>` | Stop picking up new issues after N have been started this run (0 = unlimited) |
274
+ | `--worktree` | Run each task in its own git worktree |
275
+ | `--indicator <k>:<t>:<v>` | Override a `linear.indicators` entry; repeatable (e.g. `setDone:status:Done`) |
276
+ | `--create-pr` | Push worker branch + open a GitHub PR on success (needs `--worktree`) |
277
+ | `--fix-ci` | After PR opens, re-run task on CI failures until green (needs `--create-pr`) |
278
+ | `--code-review` | Watch open tracked PRs for unresolved review comments and prepend a code-review task |
270
279
 
271
280
  #### `--max-tickets`
272
281
 
package/dist/cli/index.js CHANGED
@@ -35029,8 +35029,8 @@ import { readFileSync as readFileSync2 } from "fs";
35029
35029
  import { resolve } from "path";
35030
35030
  function getVersion() {
35031
35031
  try {
35032
- if ("2.18.0")
35033
- return "2.18.0";
35032
+ if ("2.20.0")
35033
+ return "2.20.0";
35034
35034
  } catch {}
35035
35035
  const dirsToTry = [];
35036
35036
  try {
@@ -35130,6 +35130,7 @@ async function parseArgs(argv) {
35130
35130
  indicators: {},
35131
35131
  createPr: false,
35132
35132
  fixCi: false,
35133
+ codeReview: false,
35133
35134
  maxTickets: 0,
35134
35135
  projectRoot: undefined,
35135
35136
  jsonOutput: false
@@ -35323,6 +35324,9 @@ async function parseArgs(argv) {
35323
35324
  case "--fix-ci":
35324
35325
  result2.fixCi = true;
35325
35326
  break;
35327
+ case "--code-review":
35328
+ result2.codeReview = true;
35329
+ break;
35326
35330
  case "--json-output":
35327
35331
  result2.jsonOutput = true;
35328
35332
  break;
@@ -35417,6 +35421,7 @@ var init_cli = __esm(() => {
35417
35421
  " Types: label, status",
35418
35422
  " --create-pr Push the worker branch and open a GitHub PR on success (needs --worktree)",
35419
35423
  " --fix-ci After opening the PR, re-run on CI failures until green (needs --create-pr)",
35424
+ " --code-review Watch open tracked PRs for unresolved review comments and prepend a code-review task",
35420
35425
  " --max-tickets <n> Stop picking up new issues after N have been started (0 = unlimited)",
35421
35426
  " --json-output Emit JSONL to stdout instead of the Ink dashboard (for scripting/CI)",
35422
35427
  "",
@@ -59621,6 +59626,18 @@ var MarkerSchema, GetIndicatorSchema, SetIndicatorSchema, IndicatorsSchema, Ralp
59621
59626
  // Post a progress update every N iterations. 0 disables. Requires postComments.
59622
59627
  "updateEveryIterations": 10,
59623
59628
 
59629
+ // Watch done-issue Linear comments AND their linked GitHub PR comments
59630
+ // for "@ralphy" mentions. New mentions enqueue the issue as a review run
59631
+ // with the mention text as the prepended task.
59632
+ "mentionTrigger": false,
59633
+ // "mentionHandle": "@ralphy",
59634
+
59635
+ // Watch open tracked PRs for unresolved review-thread comments and
59636
+ // prepend a code-review task. Pings the reviewer on the GitHub PR
59637
+ // when stalled for more than codeReviewStaleHours.
59638
+ "codeReviewTrigger": false,
59639
+ "codeReviewStaleHours": 24,
59640
+
59624
59641
  // Indicators map Ralph lifecycle events to Linear labels/statuses.
59625
59642
  // WARNING: activating indicators will query AND mutate your Linear workspace.
59626
59643
  // Uncomment each entry after confirming the label/status names match your workspace.
@@ -59730,8 +59747,20 @@ var init_config = __esm(() => {
59730
59747
  assignee: exports_external.string().optional(),
59731
59748
  postComments: exports_external.boolean().default(true),
59732
59749
  updateEveryIterations: exports_external.number().int().nonnegative().default(10),
59750
+ mentionTrigger: exports_external.boolean().default(false),
59751
+ mentionHandle: exports_external.string().default("@ralphy"),
59752
+ codeReviewTrigger: exports_external.boolean().default(false),
59753
+ codeReviewStaleHours: exports_external.number().nonnegative().default(24),
59733
59754
  indicators: IndicatorsSchema.default({})
59734
- }).strict().default({ postComments: true, updateEveryIterations: 10, indicators: {} })
59755
+ }).strict().default({
59756
+ postComments: true,
59757
+ updateEveryIterations: 10,
59758
+ mentionTrigger: false,
59759
+ mentionHandle: "@ralphy",
59760
+ codeReviewTrigger: false,
59761
+ codeReviewStaleHours: 24,
59762
+ indicators: {}
59763
+ })
59735
59764
  }).default({
59736
59765
  concurrency: 1,
59737
59766
  pollIntervalSeconds: 60,
@@ -59740,7 +59769,15 @@ var init_config = __esm(() => {
59740
59769
  enableManualTest: false,
59741
59770
  engine: "claude",
59742
59771
  model: "opus",
59743
- linear: { postComments: true, updateEveryIterations: 10, indicators: {} }
59772
+ linear: {
59773
+ postComments: true,
59774
+ updateEveryIterations: 10,
59775
+ mentionTrigger: false,
59776
+ mentionHandle: "@ralphy",
59777
+ codeReviewTrigger: false,
59778
+ codeReviewStaleHours: 24,
59779
+ indicators: {}
59780
+ }
59744
59781
  });
59745
59782
  });
59746
59783
 
@@ -60070,20 +60107,22 @@ class AgentCoordinator {
60070
60107
  let inProgress = [];
60071
60108
  let conflicted = [];
60072
60109
  let review = [];
60110
+ let mentions = [];
60073
60111
  try {
60074
- [todo, inProgress, conflicted, review] = await Promise.all([
60112
+ [todo, inProgress, conflicted, review, mentions] = await Promise.all([
60075
60113
  this.deps.fetchTodo(),
60076
60114
  this.deps.fetchInProgress(),
60077
60115
  this.deps.fetchConflicted(),
60078
- this.deps.fetchReview()
60116
+ this.deps.fetchReview(),
60117
+ this.deps.fetchMentions()
60079
60118
  ]);
60080
60119
  } catch (err) {
60081
60120
  this.deps.onLog(`! Linear poll failed: ${err.message}`, "red");
60082
60121
  capture("agent_linear_poll_failed", { error: err.message });
60083
60122
  return { found: 0, added: 0 };
60084
60123
  }
60085
- if (todo.length + inProgress.length + conflicted.length + review.length > 0) {
60086
- this.deps.onLog(` poll: ${todo.length} todo, ${inProgress.length} in-progress, ${conflicted.length} conflicted, ${review.length} review`, "gray");
60124
+ if (todo.length + inProgress.length + conflicted.length + review.length + mentions.length > 0) {
60125
+ this.deps.onLog(` poll: ${todo.length} todo, ${inProgress.length} in-progress, ${conflicted.length} conflicted, ${review.length} review, ${mentions.length} mention`, "gray");
60087
60126
  }
60088
60127
  const queuedIds = new Set(this.queue.map((q) => q.issue.id));
60089
60128
  const activeIds = new Set(this.workers.map((w) => w.issueId));
@@ -60128,6 +60167,16 @@ class AgentCoordinator {
60128
60167
  added += 1;
60129
60168
  this.deps.onLog(` \u21B3 ${issue.identifier} queued (review)`, "gray");
60130
60169
  }
60170
+ for (const { issue, trigger } of mentions) {
60171
+ if (atTicketLimit())
60172
+ break;
60173
+ if (!eligible(issue.id))
60174
+ continue;
60175
+ this.queue.push({ issue, mode: "review", trigger });
60176
+ queuedIds.add(issue.id);
60177
+ added += 1;
60178
+ this.deps.onLog(` \u21B3 ${issue.identifier} queued (review via ${trigger.source} mention)`, "gray");
60179
+ }
60131
60180
  for (const issue of todo) {
60132
60181
  if (atTicketLimit())
60133
60182
  break;
@@ -60158,7 +60207,7 @@ class AgentCoordinator {
60158
60207
  this.spawnNext();
60159
60208
  await this.scanDoneForConflicts();
60160
60209
  await this.reportProgress();
60161
- const found = todo.length + inProgress.length + conflicted.length + review.length;
60210
+ const found = todo.length + inProgress.length + conflicted.length + review.length + mentions.length;
60162
60211
  return { found, added };
60163
60212
  }
60164
60213
  dependenciesResolved(issue) {
@@ -60267,13 +60316,13 @@ class AgentCoordinator {
60267
60316
  while (this.workers.length + this.pendingIds.size < this.opts.concurrency && this.queue.length > 0) {
60268
60317
  const next = this.queue.shift();
60269
60318
  this.pendingIds.add(next.issue.id);
60270
- this.launchWorker(next.issue, next.mode);
60319
+ this.launchWorker(next.issue, next.mode, next.trigger);
60271
60320
  }
60272
60321
  }
60273
- async launchWorker(issue, mode) {
60322
+ async launchWorker(issue, mode, trigger) {
60274
60323
  let prep;
60275
60324
  try {
60276
- prep = await this.deps.prepare(issue, mode);
60325
+ prep = await this.deps.prepare(issue, mode, trigger);
60277
60326
  } catch (err) {
60278
60327
  this.pendingIds.delete(issue.id);
60279
60328
  this.deps.onLog(`! prepare(${mode}) failed for ${issue.identifier}: ${err.message}`, "red");
@@ -60316,8 +60365,9 @@ class AgentCoordinator {
60316
60365
  }
60317
60366
  }
60318
60367
  if (mode === "review" && this.opts.postComments !== false) {
60368
+ const sourceTag = trigger ? trigger.source === "github" ? " (GitHub @mention)" : trigger.source === "github-review" ? " (GitHub code review)" : " (Linear @mention)" : "";
60319
60369
  try {
60320
- await this.deps.postComment(issue, `\uD83D\uDD01 Ralph picked up new review comments. Tracking change: \`${prep.changeName}\``);
60370
+ await this.deps.postComment(issue, `\uD83D\uDD01 Ralph picked up new review comments${sourceTag}. Tracking change: \`${prep.changeName}\``);
60321
60371
  } catch (err) {
60322
60372
  this.deps.onLog(`! Linear review comment failed for ${issue.identifier}: ${err.message}`, "yellow");
60323
60373
  }
@@ -61108,6 +61158,45 @@ ${c.body.trim()}`;
61108
61158
  ].join(`
61109
61159
  `);
61110
61160
  }
61161
+ function escapeRegex(s) {
61162
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
61163
+ }
61164
+ function buildMentionTaskBody(trigger, issueUrl) {
61165
+ if (trigger.source === "github-review") {
61166
+ return [
61167
+ `Open code-review on ${trigger.url ?? issueUrl} has unresolved comments:`,
61168
+ "",
61169
+ trigger.body.trim(),
61170
+ "",
61171
+ "For every comment above, decide:",
61172
+ "- If you agree, fix the code, commit, and push. The push will surface",
61173
+ " the new commit on the PR; the worker should then resolve the thread",
61174
+ " via `gh api graphql` (`resolveReviewThread`) \u2014 see GitHub docs.",
61175
+ "- If you disagree, post a polite reply on the thread explaining your",
61176
+ " reasoning via `gh api repos/{owner}/{repo}/pulls/{num}/comments/{id}/replies`,",
61177
+ " and leave the thread unresolved.",
61178
+ "",
61179
+ "When this round is done the loop exits; the agent will re-poll the",
61180
+ "PR on the next cycle and pick up any new reviewer activity until the",
61181
+ "PR is approved or merged."
61182
+ ].join(`
61183
+ `);
61184
+ }
61185
+ const sourceLabel = trigger.source === "github" ? "GitHub PR" : "Linear issue";
61186
+ const permalink = trigger.url ?? issueUrl;
61187
+ const header = `${trigger.author ?? "unknown"} \u2014 ${trigger.createdAt} (${sourceLabel})`;
61188
+ return [
61189
+ `An @ralphy mention was left on ${sourceLabel} (${permalink}):`,
61190
+ "",
61191
+ `**${header}**`,
61192
+ "",
61193
+ trigger.body.trim(),
61194
+ "",
61195
+ "Treat this comment as the next concrete request. If it's ambiguous,",
61196
+ "note your interpretation in proposal.md `## Steering` before acting."
61197
+ ].join(`
61198
+ `);
61199
+ }
61111
61200
  function unionMarkers(...sets) {
61112
61201
  const out = [];
61113
61202
  const seen = new Set;
@@ -61268,6 +61357,7 @@ function buildAgentCoordinator(input) {
61268
61357
  const issueByChange = new Map;
61269
61358
  const prByChange = new Map;
61270
61359
  const prUnavailable = new Set;
61360
+ const stalePingedAt = new Map;
61271
61361
  const useWorktree = args.worktree || cfg.useWorktree;
61272
61362
  const scriptRunner = input.runners?.runScript ?? (async (cmd, cwd2) => {
61273
61363
  const proc = Bun.spawn({
@@ -61318,7 +61408,7 @@ function buildAgentCoordinator(input) {
61318
61408
  }
61319
61409
  return { workerCwd, scaffoldTasksDir, scaffoldStatesDir, branch };
61320
61410
  }
61321
- async function prepare(issue, mode) {
61411
+ async function prepare(issue, mode, trigger) {
61322
61412
  const { workerCwd, scaffoldTasksDir, scaffoldStatesDir, branch } = await setupWorktree(issue);
61323
61413
  let changeName;
61324
61414
  const isFresh = mode === "fresh";
@@ -61345,16 +61435,24 @@ function buildAgentCoordinator(input) {
61345
61435
  if (mode === "review") {
61346
61436
  const wtLayout = projectLayout(workerCwd);
61347
61437
  const tasksFile = join16(wtLayout.changeDir(changeName), "tasks.md");
61348
- let comments = [];
61349
- try {
61350
- comments = await fetchIssueComments(apiKey, issue.id);
61351
- } catch (err) {
61352
- onLog(`! Linear comment fetch failed for ${issue.identifier}: ${err.message}`, "yellow");
61438
+ let body;
61439
+ let heading;
61440
+ if (trigger) {
61441
+ heading = trigger.source === "github" ? "Address GitHub @ralphy mention" : "Address Linear @ralphy mention";
61442
+ body = buildMentionTaskBody(trigger, issue.url);
61443
+ } else {
61444
+ heading = "Address reviewer comments";
61445
+ let comments = [];
61446
+ try {
61447
+ comments = await fetchIssueComments(apiKey, issue.id);
61448
+ } catch (err) {
61449
+ onLog(`! Linear comment fetch failed for ${issue.identifier}: ${err.message}`, "yellow");
61450
+ }
61451
+ const reviewerComments = comments.filter((c) => !isRalphComment(c.body));
61452
+ body = buildReviewTaskBody(reviewerComments, issue.url);
61353
61453
  }
61354
- const reviewerComments = comments.filter((c) => !isRalphComment(c.body));
61355
- const body = buildReviewTaskBody(reviewerComments, issue.url);
61356
61454
  try {
61357
- await prependFixTask(tasksFile, "Address reviewer comments", body);
61455
+ await prependFixTask(tasksFile, heading, body);
61358
61456
  } catch (err) {
61359
61457
  onLog(`! could not prepend review task: ${err.message}`, "red");
61360
61458
  }
@@ -61630,11 +61728,288 @@ PR: ${prUrl}` : ""
61630
61728
  return [];
61631
61729
  return fetchOpenIssues(apiKey, { team, assignee, include, exclude });
61632
61730
  }
61731
+ async function fetchMentions() {
61732
+ const wantMention = cfg.linear.mentionTrigger;
61733
+ const wantCodeReview = args.codeReview || cfg.linear.codeReviewTrigger;
61734
+ if (!wantMention && !wantCodeReview)
61735
+ return [];
61736
+ const handle = cfg.linear.mentionHandle;
61737
+ let candidates = [];
61738
+ try {
61739
+ candidates = await fetchDoneCandidates();
61740
+ } catch (err) {
61741
+ onLog(`! mention scan: fetchDoneCandidates failed: ${err.message}`, "yellow");
61742
+ return [];
61743
+ }
61744
+ const out = [];
61745
+ const queued = new Set;
61746
+ for (const issue of candidates) {
61747
+ let comments = [];
61748
+ try {
61749
+ comments = await fetchIssueComments(apiKey, issue.id);
61750
+ } catch (err) {
61751
+ onLog(`! mention scan: Linear comments failed for ${issue.identifier}: ${err.message}`, "yellow");
61752
+ continue;
61753
+ }
61754
+ const lastRalphPickup = findLastRalphPickupISO(comments);
61755
+ if (wantMention) {
61756
+ for (const c of comments) {
61757
+ if (isRalphComment(c.body))
61758
+ continue;
61759
+ if (!containsHandle(c.body, handle))
61760
+ continue;
61761
+ if (lastRalphPickup && c.createdAt <= lastRalphPickup)
61762
+ continue;
61763
+ out.push({
61764
+ issue,
61765
+ trigger: {
61766
+ source: "linear",
61767
+ body: c.body,
61768
+ createdAt: c.createdAt,
61769
+ ...c.user?.name ? { author: c.user.name } : {},
61770
+ url: issue.url
61771
+ }
61772
+ });
61773
+ queued.add(issue.id);
61774
+ break;
61775
+ }
61776
+ if (queued.has(issue.id))
61777
+ continue;
61778
+ }
61779
+ const prUrl = await resolvePrUrlForIssue(issue);
61780
+ if (!prUrl)
61781
+ continue;
61782
+ if (wantMention) {
61783
+ const ghComments = await fetchPrIssueComments(prUrl);
61784
+ for (const c of ghComments) {
61785
+ if (!containsHandle(c.body, handle))
61786
+ continue;
61787
+ if (lastRalphPickup && c.createdAt <= lastRalphPickup)
61788
+ continue;
61789
+ out.push({
61790
+ issue,
61791
+ trigger: {
61792
+ source: "github",
61793
+ body: c.body,
61794
+ createdAt: c.createdAt,
61795
+ ...c.author ? { author: c.author } : {},
61796
+ url: c.url
61797
+ }
61798
+ });
61799
+ queued.add(issue.id);
61800
+ break;
61801
+ }
61802
+ if (queued.has(issue.id))
61803
+ continue;
61804
+ }
61805
+ if (wantCodeReview) {
61806
+ const trigger = await scanCodeReview(issue, prUrl, lastRalphPickup);
61807
+ if (trigger) {
61808
+ out.push({ issue, trigger });
61809
+ queued.add(issue.id);
61810
+ }
61811
+ }
61812
+ }
61813
+ return out;
61814
+ }
61815
+ async function scanCodeReview(issue, prUrl, lastRalphPickup) {
61816
+ const state = await fetchPrReviewState(prUrl);
61817
+ if (!state || !state.isOpen || state.merged || state.approved)
61818
+ return null;
61819
+ const unresolved = state.threads.filter((t) => !t.isResolved && t.comments.length > 0);
61820
+ if (unresolved.length === 0)
61821
+ return null;
61822
+ const newestReviewerActivity = unresolved.reduce((acc, t) => {
61823
+ const last2 = t.comments[t.comments.length - 1].createdAt;
61824
+ return last2 > acc ? last2 : acc;
61825
+ }, "");
61826
+ if (!lastRalphPickup || newestReviewerActivity > lastRalphPickup) {
61827
+ const body = unresolved.map((t) => {
61828
+ const head3 = t.path ? `_${t.path}${t.line ? `:${t.line}` : ""}_` : "_(general)_";
61829
+ const lines = t.comments.map((c) => `> **${c.author ?? "reviewer"}** (${c.createdAt})
61830
+ >
61831
+ > ${c.body.trim().replace(/\n/g, `
61832
+ > `)}`);
61833
+ return [head3, "", ...lines].join(`
61834
+ `);
61835
+ }).join(`
61836
+
61837
+ ---
61838
+
61839
+ `);
61840
+ return {
61841
+ source: "github-review",
61842
+ body,
61843
+ createdAt: newestReviewerActivity || new Date().toISOString(),
61844
+ ...state.lastReviewer ? { author: state.lastReviewer } : {},
61845
+ url: prUrl
61846
+ };
61847
+ }
61848
+ await maybePingStaleReviewer(issue, prUrl, state, newestReviewerActivity);
61849
+ return null;
61850
+ }
61851
+ async function maybePingStaleReviewer(issue, prUrl, state, newestReviewerActivity) {
61852
+ const staleHours = cfg.linear.codeReviewStaleHours;
61853
+ if (staleHours <= 0)
61854
+ return;
61855
+ const reviewer = state.requestedReviewer ?? state.lastReviewer;
61856
+ if (!reviewer)
61857
+ return;
61858
+ const lastPinged = stalePingedAt.get(prUrl);
61859
+ const now2 = Date.now();
61860
+ if (lastPinged && now2 - lastPinged < staleHours * 3600000)
61861
+ return;
61862
+ const elapsedH = newestReviewerActivity ? (now2 - Date.parse(newestReviewerActivity)) / 3600000 : Infinity;
61863
+ if (elapsedH < staleHours)
61864
+ return;
61865
+ const m = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(prUrl);
61866
+ if (!m)
61867
+ return;
61868
+ const [, owner, repo, num] = m;
61869
+ const body = `\uD83D\uDD14 @${reviewer} \u2014 Ralph has been waiting ${elapsedH.toFixed(0)}h on a re-review for ${prUrl}. Could you take another look when you have a moment?`;
61870
+ try {
61871
+ await cmdRunner.run(["gh", "api", `repos/${owner}/${repo}/issues/${num}/comments`, "-f", `body=${body}`], projectRoot);
61872
+ stalePingedAt.set(prUrl, now2);
61873
+ onLog(` ${issue.identifier}: pinged reviewer @${reviewer} on ${prUrl}`, "gray");
61874
+ } catch (err) {
61875
+ onLog(`! reviewer ping failed for ${prUrl}: ${err.message}`, "yellow");
61876
+ }
61877
+ }
61878
+ async function fetchPrReviewState(prUrl) {
61879
+ const m = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(prUrl);
61880
+ if (!m)
61881
+ return null;
61882
+ const [, owner, repo, num] = m;
61883
+ const query = `query($owner:String!,$repo:String!,$num:Int!){
61884
+ repository(owner:$owner,name:$repo){
61885
+ pullRequest(number:$num){
61886
+ state merged reviewDecision
61887
+ reviewRequests(first:5){nodes{requestedReviewer{... on User{login}}}}
61888
+ latestReviews(first:5){nodes{author{login} state submittedAt}}
61889
+ reviewThreads(first:50){nodes{
61890
+ isResolved path line
61891
+ comments(first:20){nodes{body author{login} createdAt url}}
61892
+ }}
61893
+ }
61894
+ }
61895
+ }`;
61896
+ try {
61897
+ const res = await cmdRunner.run([
61898
+ "gh",
61899
+ "api",
61900
+ "graphql",
61901
+ "-f",
61902
+ `query=${query}`,
61903
+ "-F",
61904
+ `owner=${owner}`,
61905
+ "-F",
61906
+ `repo=${repo}`,
61907
+ "-F",
61908
+ `num=${num}`
61909
+ ], projectRoot);
61910
+ const parsed = JSON.parse(res.stdout);
61911
+ const pr = parsed.data?.repository?.pullRequest;
61912
+ if (!pr)
61913
+ return null;
61914
+ const requested = pr.reviewRequests?.nodes.map((n) => n.requestedReviewer?.login).filter((x) => !!x)[0];
61915
+ const latestReviews = pr.latestReviews?.nodes ?? [];
61916
+ const lastReviewer = latestReviews.slice().sort((a, b) => b.submittedAt > a.submittedAt ? 1 : -1).map((n) => n.author?.login).filter((x) => !!x)[0];
61917
+ return {
61918
+ isOpen: pr.state === "OPEN",
61919
+ merged: pr.merged,
61920
+ approved: pr.reviewDecision === "APPROVED",
61921
+ threads: (pr.reviewThreads?.nodes ?? []).map((t) => ({
61922
+ isResolved: t.isResolved,
61923
+ ...t.path ? { path: t.path } : {},
61924
+ ...t.line != null ? { line: t.line } : {},
61925
+ comments: t.comments.nodes.map((c) => ({
61926
+ ...c.author?.login ? { author: c.author.login } : {},
61927
+ body: c.body,
61928
+ createdAt: c.createdAt,
61929
+ ...c.url ? { url: c.url } : {}
61930
+ }))
61931
+ })),
61932
+ ...requested ? { requestedReviewer: requested } : {},
61933
+ ...lastReviewer ? { lastReviewer } : {}
61934
+ };
61935
+ } catch (err) {
61936
+ onLog(`! gh graphql review-state failed for ${prUrl}: ${err.message}`, "yellow");
61937
+ return null;
61938
+ }
61939
+ }
61940
+ function findLastRalphPickupISO(comments) {
61941
+ let latest = null;
61942
+ for (const c of comments) {
61943
+ if (!/^\uD83D\uDD01\s*Ralph picked up/.test(c.body.trimStart()))
61944
+ continue;
61945
+ if (latest === null || c.createdAt > latest)
61946
+ latest = c.createdAt;
61947
+ }
61948
+ return latest;
61949
+ }
61950
+ function containsHandle(body, handle) {
61951
+ const re = new RegExp(`(^|\\s|[^A-Za-z0-9_])${escapeRegex(handle)}\\b`, "i");
61952
+ return re.test(body);
61953
+ }
61954
+ async function resolvePrUrlForIssue(issue) {
61955
+ const changeName = changeNameForIssue(issue);
61956
+ if (prUnavailable.has(changeName))
61957
+ return null;
61958
+ const cached = prByChange.get(changeName);
61959
+ if (cached)
61960
+ return cached;
61961
+ const branch = branchForChange(changeName);
61962
+ try {
61963
+ const res = await cmdRunner.run([
61964
+ "gh",
61965
+ "pr",
61966
+ "list",
61967
+ "--head",
61968
+ branch,
61969
+ "--state",
61970
+ "all",
61971
+ "--json",
61972
+ "url",
61973
+ "--jq",
61974
+ ".[0].url // empty"
61975
+ ], projectRoot);
61976
+ const found = res.stdout.trim();
61977
+ if (!found) {
61978
+ prUnavailable.add(changeName);
61979
+ return null;
61980
+ }
61981
+ prByChange.set(changeName, found);
61982
+ return found;
61983
+ } catch {
61984
+ return null;
61985
+ }
61986
+ }
61987
+ async function fetchPrIssueComments(prUrl) {
61988
+ const m = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(prUrl);
61989
+ if (!m)
61990
+ return [];
61991
+ const [, owner, repo, num] = m;
61992
+ try {
61993
+ const res = await cmdRunner.run([
61994
+ "gh",
61995
+ "api",
61996
+ `repos/${owner}/${repo}/issues/${num}/comments`,
61997
+ "--jq",
61998
+ "[.[] | {body: .body, createdAt: .created_at, author: .user.login, url: .html_url}]"
61999
+ ], projectRoot);
62000
+ const parsed = JSON.parse(res.stdout || "[]");
62001
+ return parsed;
62002
+ } catch (err) {
62003
+ onLog(`! mention scan: gh comments failed for ${prUrl}: ${err.message}`, "yellow");
62004
+ return [];
62005
+ }
62006
+ }
61633
62007
  const coord = new AgentCoordinator({
61634
62008
  fetchTodo: () => fetchByGet(indicators.getTodo, excludeFromTodo),
61635
62009
  fetchInProgress: () => fetchByGet(indicators.getInProgress, []),
61636
62010
  fetchConflicted: () => fetchByGet(indicators.getConflicted, []),
61637
62011
  fetchReview: () => fetchByGet(indicators.getReview, excludeFromReview),
62012
+ fetchMentions,
61638
62013
  fetchDoneCandidates,
61639
62014
  prepare,
61640
62015
  spawnWorker,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neriros/ralphy",
3
- "version": "2.18.0",
3
+ "version": "2.20.0",
4
4
  "description": "An iterative AI task execution framework. Orchestrates multi-phase autonomous work using Claude or Codex engines.",
5
5
  "keywords": [
6
6
  "agent",