@neriros/ralphy 2.19.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 +16 -11
  2. package/dist/cli/index.js +220 -39
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -171,6 +171,8 @@ A default `ralphy.config.json` is written on first run with every defaulted sett
171
171
  "updateEveryIterations": 10,
172
172
  "mentionTrigger": true,
173
173
  "mentionHandle": "@ralphy",
174
+ "codeReviewTrigger": true,
175
+ "codeReviewStaleHours": 24,
174
176
  "indicators": {
175
177
  "getTodo": { "filter": [{ "type": "status", "value": "Todo" }] },
176
178
  "getInProgress": { "filter": [{ "type": "status", "value": "In Progress" }] },
@@ -217,6 +219,8 @@ Linear is the source of truth for which issues Ralph has touched. Each `linear.i
217
219
 
218
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.
219
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
+
220
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.
221
225
 
222
226
  #### Per-task git worktrees
@@ -260,17 +264,18 @@ Failed workers (non-zero exit) are not marked processed, so they'll be retried o
260
264
 
261
265
  ### Agent mode flags
262
266
 
263
- | Option | Description |
264
- | ------------------------- | ----------------------------------------------------------------------------- |
265
- | `--linear-team <key>` | Linear team key (e.g. `ENG`) |
266
- | `--linear-assignee <id>` | Filter by assignee (user id, email, or `me`) |
267
- | `--poll-interval <s>` | Seconds between Linear polls (default: 60) |
268
- | `--concurrency <n>` | Max concurrent task loops (default: 1) |
269
- | `--max-tickets <n>` | Stop picking up new issues after N have been started this run (0 = unlimited) |
270
- | `--worktree` | Run each task in its own git worktree |
271
- | `--indicator <k>:<t>:<v>` | Override a `linear.indicators` entry; repeatable (e.g. `setDone:status:Done`) |
272
- | `--create-pr` | Push worker branch + open a GitHub PR on success (needs `--worktree`) |
273
- | `--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 |
274
279
 
275
280
  #### `--max-tickets`
276
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.19.0")
35033
- return "2.19.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
  "",
@@ -59627,6 +59632,12 @@ var MarkerSchema, GetIndicatorSchema, SetIndicatorSchema, IndicatorsSchema, Ralp
59627
59632
  "mentionTrigger": false,
59628
59633
  // "mentionHandle": "@ralphy",
59629
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
+
59630
59641
  // Indicators map Ralph lifecycle events to Linear labels/statuses.
59631
59642
  // WARNING: activating indicators will query AND mutate your Linear workspace.
59632
59643
  // Uncomment each entry after confirming the label/status names match your workspace.
@@ -59738,12 +59749,16 @@ var init_config = __esm(() => {
59738
59749
  updateEveryIterations: exports_external.number().int().nonnegative().default(10),
59739
59750
  mentionTrigger: exports_external.boolean().default(false),
59740
59751
  mentionHandle: exports_external.string().default("@ralphy"),
59752
+ codeReviewTrigger: exports_external.boolean().default(false),
59753
+ codeReviewStaleHours: exports_external.number().nonnegative().default(24),
59741
59754
  indicators: IndicatorsSchema.default({})
59742
59755
  }).strict().default({
59743
59756
  postComments: true,
59744
59757
  updateEveryIterations: 10,
59745
59758
  mentionTrigger: false,
59746
59759
  mentionHandle: "@ralphy",
59760
+ codeReviewTrigger: false,
59761
+ codeReviewStaleHours: 24,
59747
59762
  indicators: {}
59748
59763
  })
59749
59764
  }).default({
@@ -59759,6 +59774,8 @@ var init_config = __esm(() => {
59759
59774
  updateEveryIterations: 10,
59760
59775
  mentionTrigger: false,
59761
59776
  mentionHandle: "@ralphy",
59777
+ codeReviewTrigger: false,
59778
+ codeReviewStaleHours: 24,
59762
59779
  indicators: {}
59763
59780
  }
59764
59781
  });
@@ -60348,7 +60365,7 @@ class AgentCoordinator {
60348
60365
  }
60349
60366
  }
60350
60367
  if (mode === "review" && this.opts.postComments !== false) {
60351
- const sourceTag = trigger ? trigger.source === "github" ? " (GitHub @mention)" : " (Linear @mention)" : "";
60368
+ const sourceTag = trigger ? trigger.source === "github" ? " (GitHub @mention)" : trigger.source === "github-review" ? " (GitHub code review)" : " (Linear @mention)" : "";
60352
60369
  try {
60353
60370
  await this.deps.postComment(issue, `\uD83D\uDD01 Ralph picked up new review comments${sourceTag}. Tracking change: \`${prep.changeName}\``);
60354
60371
  } catch (err) {
@@ -61145,6 +61162,26 @@ function escapeRegex(s) {
61145
61162
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
61146
61163
  }
61147
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
+ }
61148
61185
  const sourceLabel = trigger.source === "github" ? "GitHub PR" : "Linear issue";
61149
61186
  const permalink = trigger.url ?? issueUrl;
61150
61187
  const header = `${trigger.author ?? "unknown"} \u2014 ${trigger.createdAt} (${sourceLabel})`;
@@ -61320,6 +61357,7 @@ function buildAgentCoordinator(input) {
61320
61357
  const issueByChange = new Map;
61321
61358
  const prByChange = new Map;
61322
61359
  const prUnavailable = new Set;
61360
+ const stalePingedAt = new Map;
61323
61361
  const useWorktree = args.worktree || cfg.useWorktree;
61324
61362
  const scriptRunner = input.runners?.runScript ?? (async (cmd, cwd2) => {
61325
61363
  const proc = Bun.spawn({
@@ -61691,7 +61729,9 @@ PR: ${prUrl}` : ""
61691
61729
  return fetchOpenIssues(apiKey, { team, assignee, include, exclude });
61692
61730
  }
61693
61731
  async function fetchMentions() {
61694
- if (!cfg.linear.mentionTrigger)
61732
+ const wantMention = cfg.linear.mentionTrigger;
61733
+ const wantCodeReview = args.codeReview || cfg.linear.codeReviewTrigger;
61734
+ if (!wantMention && !wantCodeReview)
61695
61735
  return [];
61696
61736
  const handle = cfg.linear.mentionHandle;
61697
61737
  let candidates = [];
@@ -61702,6 +61742,7 @@ PR: ${prUrl}` : ""
61702
61742
  return [];
61703
61743
  }
61704
61744
  const out = [];
61745
+ const queued = new Set;
61705
61746
  for (const issue of candidates) {
61706
61747
  let comments = [];
61707
61748
  try {
@@ -61711,51 +61752,191 @@ PR: ${prUrl}` : ""
61711
61752
  continue;
61712
61753
  }
61713
61754
  const lastRalphPickup = findLastRalphPickupISO(comments);
61714
- for (const c of comments) {
61715
- if (isRalphComment(c.body))
61716
- continue;
61717
- if (!containsHandle(c.body, handle))
61718
- continue;
61719
- if (lastRalphPickup && c.createdAt <= lastRalphPickup)
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))
61720
61777
  continue;
61721
- out.push({
61722
- issue,
61723
- trigger: {
61724
- source: "linear",
61725
- body: c.body,
61726
- createdAt: c.createdAt,
61727
- ...c.user?.name ? { author: c.user.name } : {},
61728
- url: issue.url
61729
- }
61730
- });
61731
- break;
61732
61778
  }
61733
- if (out.length > 0 && out[out.length - 1].issue.id === issue.id)
61734
- continue;
61735
61779
  const prUrl = await resolvePrUrlForIssue(issue);
61736
61780
  if (!prUrl)
61737
61781
  continue;
61738
- const ghComments = await fetchPrIssueComments(prUrl);
61739
- for (const c of ghComments) {
61740
- if (!containsHandle(c.body, handle))
61741
- continue;
61742
- if (lastRalphPickup && c.createdAt <= lastRalphPickup)
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))
61743
61803
  continue;
61744
- out.push({
61745
- issue,
61746
- trigger: {
61747
- source: "github",
61748
- body: c.body,
61749
- createdAt: c.createdAt,
61750
- ...c.author ? { author: c.author } : {},
61751
- url: c.url
61752
- }
61753
- });
61754
- break;
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
+ }
61755
61811
  }
61756
61812
  }
61757
61813
  return out;
61758
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
+ }
61759
61940
  function findLastRalphPickupISO(comments) {
61760
61941
  let latest = null;
61761
61942
  for (const c of comments) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neriros/ralphy",
3
- "version": "2.19.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",