@neriros/ralphy 2.20.0 → 2.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +207 -231
  2. package/dist/cli/index.js +220 -90
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -7,9 +7,25 @@
7
7
  [![GitHub issues](https://img.shields.io/github/issues/NeriRos/ralphy.svg)](https://github.com/NeriRos/ralphy/issues)
8
8
  [![Bun](https://img.shields.io/badge/runtime-Bun-fbf0df.svg)](https://bun.sh)
9
9
 
10
- An iterative AI task execution framework. Ralphy orchestrates multi-phase autonomous work using Claude or Codex engines, with built-in state management, progress tracking, and cost safeguards.
11
-
12
- ## How It Works
10
+ An iterative AI task execution framework. Ralphy orchestrates autonomous work using Claude or Codex with built-in state management, progress tracking, and cost safeguards. It can run as a one-shot task or as a long-lived **agent** that polls Linear, ships PRs, and iterates with reviewers.
11
+
12
+ ## Contents
13
+
14
+ - [How it works](#how-it-works)
15
+ - [Install](#install)
16
+ - [Task mode](#task-mode) — single-task / single-loop usage
17
+ - [Agent mode](#agent-mode) — Linear-driven autonomous loop
18
+ - [Lifecycle and triggers](#lifecycle-and-triggers)
19
+ - [Linear indicators](#linear-indicators)
20
+ - [PR + CI integration](#pr--ci-integration)
21
+ - [Worktrees, setup, teardown](#worktrees-setup-teardown)
22
+ - [Dashboard and logs](#dashboard-and-logs)
23
+ - [CLI reference](#cli-reference)
24
+ - [Change layout (OpenSpec)](#change-layout-openspec)
25
+ - [MCP server](#mcp-server)
26
+ - [Project structure and development](#project-structure-and-development)
27
+
28
+ ## How it works
13
29
 
14
30
  Ralphy runs a single continuous loop against an OpenSpec change — no phases, no phase transitions.
15
31
 
@@ -19,151 +35,139 @@ graph LR
19
35
  T -->|all tasks checked| D[Archive change]
20
36
  ```
21
37
 
22
- Each iteration reads the `## Steering` section of `proposal.md`, picks the first unchecked item from `tasks.md`, does the work, validates, and checks the item off. When all items are checked the loop archives the change automatically.
23
-
24
- ## Agent Mode Flow
25
-
26
- The full orchestration cycle from Linear poll through teardown:
27
-
28
- ```mermaid
29
- flowchart TD
30
- LINEAR_POLL["Linear poll\n(todo / in-progress / conflicted)"]
31
-
32
- LINEAR_POLL --> ISSUE_STATE{issue state}
33
- ISSUE_STATE -- todo --> MODE_FRESH[mode: fresh\nscaffold change]
34
- ISSUE_STATE -- in-progress --> MODE_RESUME[mode: resume]
35
- ISSUE_STATE -- conflicted --> MODE_CONFLICT_FIX[mode: conflict-fix\nprepend fix task]
36
-
37
- MODE_FRESH & MODE_RESUME & MODE_CONFLICT_FIX --> SET_IN_PROGRESS[Linear: setInProgress]
38
- SET_IN_PROGRESS --> USE_WORKTREE{useWorktree?}
39
- USE_WORKTREE -- yes --> SCAFFOLD[scaffolding\ncreate worktree + branch]
40
- USE_WORKTREE -- no --> WORKER
41
- SCAFFOLD --> WORKER([working\nClaude agent loop])
42
-
43
- WORKER --> EXIT_CODE{exitCode?}
44
- EXIT_CODE -- "non-zero" --> GAVE_UP
45
- EXIT_CODE -- "0" --> WANT_PR{wantPr?}
46
-
47
- WANT_PR -- no --> DONE
48
- WANT_PR -- yes --> PUSH_AND_CREATE_PR["push + pr-create\n↺ rebase/hook-fix on rejection"]
49
-
50
- PUSH_AND_CREATE_PR -- gave-up --> GAVE_UP
51
- PUSH_AND_CREATE_PR -- no commits ahead --> DONE
52
-
53
- PUSH_AND_CREATE_PR -- pr opened --> WATCH_LOOP
54
-
55
- subgraph WATCH_LOOP["watch loop"]
56
- direction LR
57
- CONFLICT_CHECK[conflict-check] --> CI_POLL[ci-poll / ci-fix]
58
- CI_POLL --> CONFLICT_CHECK
59
- end
60
-
61
- WATCH_LOOP -- green & clean --> DONE
62
- WATCH_LOOP -- gave-up --> GAVE_UP
63
-
64
- DONE([done]) --> WORKTREE_CLEANUP
65
- GAVE_UP([gave-up]) --> WORKTREE_CLEANUP
66
-
67
- subgraph WORKTREE_CLEANUP["cleanup"]
68
- direction LR
69
- SHOULD_REMOVE_WORKTREE{useWorktree\n& success\n& cleanupOnSuccess?}
70
- SHOULD_REMOVE_WORKTREE -- yes --> REMOVE_WORKTREE[remove worktree]
71
- end
72
-
73
- WORKTREE_CLEANUP --> TEARDOWN[teardown]
74
- TEARDOWN --> OUTCOME{ok?}
75
-
76
- OUTCOME -- "yes,\nnot conflict-fix" --> LINEAR_SET_DONE["Linear: setDone\nclearInProgress\npost comment"]
77
- OUTCOME -- "yes,\nconflict-fix" --> LINEAR_CLEAR_CONFLICTED["Linear: clearConflicted\npost comment"]
78
- OUTCOME -- no --> LINEAR_SET_ERROR["Linear: setError\nclearInProgress\npost comment"]
79
-
80
- LINEAR_SET_DONE & LINEAR_CLEAR_CONFLICTED & LINEAR_SET_ERROR --> LINEAR_POLL
81
- ```
38
+ Each iteration reads the `## Steering` section of `proposal.md`, picks the first unchecked item from `tasks.md`, does the work, validates, and checks the item off. When every item is checked the loop archives the change.
82
39
 
83
- ## Installation
40
+ ## Install
84
41
 
85
- ### npm (global)
42
+ Requires [Bun](https://bun.sh). For the Claude engine you also need the [Claude CLI](https://docs.anthropic.com/en/docs/claude-cli). The Makefile install path additionally needs `jq`.
86
43
 
87
44
  ```bash
45
+ # Global (recommended)
88
46
  npm install -g @neriros/ralphy
89
- # or
47
+ # or run without installing
90
48
  bunx @neriros/ralphy
49
+
50
+ # Per-project install (builds + wires .ralph/ into the repo)
51
+ bun install
52
+ make install # → ./.ralph
53
+ make install ~ # → ~/.ralph
54
+ make install /path/to # → /path/to/.ralph
91
55
  ```
92
56
 
93
- Requires [Bun](https://bun.sh) as the runtime.
57
+ The per-project install builds the CLI and MCP server, copies them to `.ralph/bin/`, sets up templates, wires `.mcp.json`, and adds a `ralph` script to `package.json`. `.ralph/` is gitignored by default.
94
58
 
95
- ### Local (per-project)
59
+ ## Task mode
96
60
 
97
61
  ```bash
98
- bun install
99
- make install # Install to ./.ralph
100
- make install ~ # Install to ~/.ralph
101
- make install /path/to # Install to /path/to/.ralph
102
- ```
62
+ # Create + run a new task
63
+ ralph task --name fix-auth --prompt "Fix the JWT validation bug" --claude opus --max-iterations 10
103
64
 
104
- This builds the CLI and MCP server, copies them to `.ralph/bin/`, sets up phase definitions and templates, configures `.mcp.json`, and adds a `ralph` script to `package.json`. The `.ralph/` directory is gitignored by default.
65
+ # Resume the same task later (state is on disk)
66
+ ralph task --name fix-auth
105
67
 
106
- ### Prerequisites
68
+ # Inspect
69
+ ralph list # all tasks (table)
70
+ ralph status --name fix-auth # one task (details)
71
+ ```
107
72
 
108
- - [Bun](https://bun.sh)
109
- - [Claude CLI](https://docs.anthropic.com/en/docs/claude-cli) (for the Claude engine)
110
- - `jq` (for installation)
73
+ Engine defaults to Claude Opus. Common safeguards: `--max-iterations`, `--max-cost`, `--max-runtime`, `--max-failures`. See the [CLI reference](#cli-reference) for the full set.
111
74
 
112
- ## Usage
75
+ ## Agent mode
113
76
 
114
- ### Create and Run a Task
77
+ `ralph agent` polls Linear, runs up to N concurrent task loops, and (optionally) opens PRs, watches CI, and iterates with reviewers. Requires `LINEAR_API_KEY`.
115
78
 
116
79
  ```bash
117
- ralph task --name fix-auth --prompt "Fix the JWT validation bug" --claude opus --max-iterations 10
80
+ export LINEAR_API_KEY=lin_api_xxx
81
+ ralph agent --linear-team ENG --linear-assignee me --concurrency 3 --poll-interval 60
118
82
  ```
119
83
 
120
- The engine defaults to Claude Opus.
84
+ A default `ralphy.config.json` is written on first run. CLI flags override config per-invocation.
121
85
 
122
- ### Resume a Change
86
+ ### Lifecycle and triggers
123
87
 
124
- ```bash
125
- ralph task --name fix-auth
126
- ```
88
+ Each poll inspects Linear (and, when configured, GitHub PRs) and routes each issue into one of these spawn modes:
127
89
 
128
- If the task already exists, it resumes from where it left off.
90
+ | Mode | When it fires | What changes |
91
+ | ---------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
92
+ | **fresh** | Issue matches `getTodo` | Scaffold a new change, spawn worker, apply `setInProgress` |
93
+ | **resume** | Issue matches `getInProgress` (typical: agent restart) | Re-attach to existing change directory, skip re-scaffold |
94
+ | **conflict-fix** | Issue matches `getConflicted`, _or_ `setDone` issue's PR is detected as `CONFLICTING` | Prepend a conflict-resolution task to `tasks.md`, reactivate state |
95
+ | **review** | Done issue carries the `getReview` marker (label trigger), _or_ a `@ralphy` mention is detected on Linear / the linked GitHub PR | Prepend a review task with the relevant comments; remove the `clearReview` label after pickup |
96
+ | **code-review** | Open tracked PR has unresolved review-thread comments newer than Ralph's last pickup ack | Prepend a digest of unresolved comments with fix-or-reply instructions; repeats until PR approved |
129
97
 
130
- ### Check Status
98
+ ```mermaid
99
+ flowchart TD
100
+ POLL["Linear poll"] --> SCAN{trigger?}
101
+ SCAN -- "getTodo" --> FRESH["mode: fresh\nscaffold change"]
102
+ SCAN -- "getInProgress" --> RESUME["mode: resume"]
103
+ SCAN -- "getConflicted\nor setDone PR is CONFLICTING" --> CFX["mode: conflict-fix\nprepend fix task"]
104
+ SCAN -- "getReview\nor @ralphy mention\n(Linear / GitHub)" --> REV["mode: review\nprepend comments"]
105
+ SCAN -- "open PR with new\nunresolved review comments" --> CR["mode: review (code-review)\nprepend thread digest"]
106
+
107
+ FRESH & RESUME & CFX & REV & CR --> IN_PROG["Linear: setInProgress\npost pickup comment"]
108
+ IN_PROG --> WT{useWorktree?}
109
+ WT -- yes --> SCAFFOLD["create worktree + branch"] --> WORKER([worker loop])
110
+ WT -- no --> WORKER
111
+
112
+ WORKER --> EXIT{exit code}
113
+ EXIT -- non-zero --> ERR_FLOW
114
+ EXIT -- 0 --> WANT_PR{wantPr?}
115
+ WANT_PR -- no --> DONE_FLOW
116
+ WANT_PR -- yes --> PR["push + gh pr create\n↺ rebase / hook-fix"]
117
+ PR -- "no commits" --> DONE_FLOW
118
+ PR -- "opened" --> WATCH
119
+
120
+ subgraph WATCH["watch loop"]
121
+ direction LR
122
+ WATCH_CHECK["conflict-check"] --> WATCH_CI["ci-poll / ci-fix"]
123
+ WATCH_CI --> WATCH_CHECK
124
+ end
125
+ WATCH -- "green & clean" --> DONE_FLOW
126
+ WATCH -- "gave up" --> ERR_FLOW
131
127
 
132
- ```bash
133
- ralph list # Table of all tasks
134
- ralph status --name fix-auth # Detailed view of one task
128
+ subgraph DONE_FLOW["clean exit"]
129
+ D1["worktree cleanup\n(if configured)"] --> D2["teardown script"] --> D3{mode == conflict-fix?}
130
+ D3 -- yes --> D4["Linear: clearConflicted"]
131
+ D3 -- no --> D5["Linear: setDone\nclearInProgress"]
132
+ end
133
+ subgraph ERR_FLOW["failure"]
134
+ E1["worktree preserved"] --> E2["Linear: setError\nclearInProgress"]
135
+ end
136
+ D4 & D5 & E2 --> POLL
135
137
  ```
136
138
 
137
- ### Agent Mode (Linear integration)
139
+ The cycle repeats every poll. For code-review-iteration in particular, `setDone` re-applies between rounds so the next poll re-checks for new reviewer activity, until the PR is approved or merged.
138
140
 
139
- `ralph agent` polls Linear for open issues and runs up to N concurrent task loops, scaffolding an OpenSpec change per new issue. Requires `LINEAR_API_KEY` in the environment.
141
+ ### Linear indicators
140
142
 
141
- ```bash
142
- export LINEAR_API_KEY=lin_api_xxx
143
- ralph agent --linear-team ENG --linear-assignee me --concurrency 3 --poll-interval 60
143
+ Linear is the source of truth for which issues Ralph has touched. The `linear.indicators` map declares how Ralph queries and mutates Linear at each lifecycle event. All keys are optional; an unset key means "Ralph doesn't perform that action".
144
144
 
145
- # Limit the number of tickets processed in this run
146
- ralph agent --max-tickets 5 --linear-team ENG --linear-assignee me
147
- ```
145
+ | Key | Type | Purpose |
146
+ | ----------------- | ------------------------------- | ------------------------------------------------------------------------------- |
147
+ | `getTodo` | `{filter: Marker[]}` | Issues to pick up (fresh) |
148
+ | `getInProgress` | `{filter: Marker[]}` | Issues to resume after restart |
149
+ | `getConflicted` | `{filter: Marker[]}` | Issues whose PR is conflicted (re-fix run) |
150
+ | `getReview` | `{filter: Marker[]}` | Done issues flagged for review follow-up |
151
+ | `setInProgress` | `Marker` or `{apply: Marker[]}` | Applied when a worker spawns (any non-resume mode) |
152
+ | `setDone` | `Marker` or `{apply: Marker[]}` | Applied on clean exit |
153
+ | `setError` | `Marker` or `{apply: Marker[]}` | Applied on non-zero exit (quarantine signal — issue is _not_ auto-resumed) |
154
+ | `setConflicted` | `Marker` or `{apply: Marker[]}` | Applied when a done-PR is detected as conflicted |
155
+ | `clearConflicted` | `Marker` or `{apply: Marker[]}` | Label(s) removed when a conflict-fix succeeds (status removal is not supported) |
156
+ | `clearReview` | `Marker` or `{apply: Marker[]}` | Label(s) removed when a review pickup happens (status removal is not supported) |
148
157
 
149
- What it does on each tick:
158
+ A `Marker` is `{type: "label", value: "ralph:foo"}` or `{type: "status", value: "In Progress"}`. Combine with `apply` when one event sets multiple — e.g. `setDone` flipping a status _and_ adding a label.
150
159
 
151
- 1. Polls Linear for open issues matching the filter (team / assignee / status / labels)
152
- 2. Dedupes against in-flight workers and any already-active issues
153
- 3. For each new issue: fetches existing comments, scaffolds `openspec/changes/<id-slug>/{proposal.md,tasks.md,design.md}` (with the comments embedded so the worker sees prior discussion), then spawns `ralph task --name <id-slug>` up to the concurrency cap
154
- 4. Posts a "🤖 started" comment on the Linear issue and applies the `setInProgress` indicator (if configured)
155
- 5. On worker exit, posts a success/failure comment and applies the `setDone` indicator on success or `setError` on failure (if configured)
156
-
157
- A default `ralphy.config.json` is written on first run with every defaulted setting filled in; CLI flags override config values per invocation.
160
+ Example `ralphy.config.json`:
158
161
 
159
162
  ```jsonc
160
163
  {
161
164
  "concurrency": 3,
162
165
  "pollIntervalSeconds": 60,
163
- "maxIterationsPerTask": 0,
164
- "maxCostUsdPerTask": 0,
165
166
  "engine": "claude",
166
167
  "model": "opus",
168
+ "useWorktree": true,
169
+ "createPrOnSuccess": true,
170
+ "fixCiOnFailure": true,
167
171
  "linear": {
168
172
  "team": "ENG",
169
173
  "assignee": "me",
@@ -191,164 +195,138 @@ A default `ralphy.config.json` is written on first run with every defaulted sett
191
195
  "clearReview": { "type": "label", "value": "ralph:review" },
192
196
  },
193
197
  },
194
- "useWorktree": true,
195
- "cleanupWorktreeOnSuccess": false,
196
- "setupScript": "bun install",
197
- "teardownScript": "git status",
198
- "appendPrompt": "Always run lint before committing.",
199
- "createPrOnSuccess": true,
200
- "prBaseBranch": "main",
201
- "fixCiOnFailure": true,
202
- "maxCiFixAttempts": 5,
203
- "ciPollIntervalSeconds": 30,
204
- "maxRuntimeMinutesPerTask": 0,
205
- "maxConsecutiveFailuresPerTask": 5,
206
- "iterationDelaySeconds": 0,
207
- "logRawStream": false,
208
- "taskVerbose": false,
209
198
  }
210
199
  ```
211
200
 
212
- Linear is the source of truth for which issues Ralph has touched. Each `linear.indicators` key names a lifecycle event:
201
+ #### Review follow-ups (label trigger)
213
202
 
214
- - `getTodo` / `getInProgress` / `getConflicted` / `getReview` `{ filter: [...] }` selectors used to find issues to pick up, resume, repair, or follow up on after review.
215
- - `setInProgress` / `setDone` / `setError` / `setConflicted` — single marker `{ type, value }` or `{ apply: [...] }` for multi-marker.
216
- - `clearConflicted` / `clearReview` — labels to remove once a conflicted PR is fixed or a review-mode issue is picked back up (status removal is not supported).
203
+ When a Linear issue is in a done state and a reviewer adds the `getReview` marker (typically a label like `ralph:review` left alongside comments), Ralph picks it up, applies `setInProgress`, removes the `clearReview` label so the trigger doesn't re-fire, filters out Ralph's own comments, and prepends every reviewer comment as a fresh task at the top of `tasks.md`. `setDone` re-applies on clean exit.
217
204
 
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.
205
+ #### `@ralphy` mention trigger
219
206
 
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.
207
+ Set `linear.mentionTrigger: true` to scan done-issue comments on Linear _and_ on the linked GitHub PR for a configurable handle (`linear.mentionHandle`, default `@ralphy`). Each unprocessed mention queues the issue as a review run, with the mention text used **verbatim** as the prepended task. Idempotency: a mention is processed when its `createdAt` is older than Ralph's latest `🔁 picked up` Linear comment, so the same comment never re-fires. Requires `gh` for the GitHub side.
221
208
 
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.
209
+ #### Code-review iteration
223
210
 
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.
211
+ Set `linear.codeReviewTrigger: true` (or pass `--code-review`) to watch open, unmerged, unapproved tracked PRs for unresolved review-thread comments. New activity on any unresolved thread queues a review run whose task is a digest of every unresolved comment + instructions:
225
212
 
226
- #### Per-task git worktrees
213
+ - **If Ralph agrees** with a comment — fix, commit, push, and resolve the thread (via `gh api graphql`'s `resolveReviewThread`).
214
+ - **If Ralph disagrees** — reply on the thread with reasoning via `gh api .../comments/{id}/replies` and leave it unresolved.
227
215
 
228
- With `--worktree` (or `useWorktree: true` in config) each task runs in an isolated worktree at `.ralph/worktrees/<change-name>` checked out onto a fresh `ralph/<change-name>` branch. The change is scaffolded _inside_ the worktree, and the loop's cwd is the worktree, so concurrent workers can't stomp on each other.
216
+ The loop exits; the next poll re-checks the PR. The cycle continues until the PR is **approved** or **merged**. If the reviewer is silent for more than `linear.codeReviewStaleHours` (default `24`, `0` disables) while Ralph is the last actor, one `@`-mention ping comment is posted on the GitHub PR.
229
217
 
230
- Use `setupScript` (run inside the worktree right after scaffolding) to install dependencies, copy `.env`, etc. Use `teardownScript` (run after the loop exits and worktree cleanup) to gather artifacts or roll back local mutations. Both run via `sh -c`; failures are logged but never block the loop. With `cleanupWorktreeOnSuccess: true` the worktree is removed when the worker exits 0 — failed workers always keep their worktree (and branch) for human inspection.
218
+ #### Conflict re-fix
231
219
 
232
- **`appendPrompt`** (or `--prompt` in agent mode) is appended to every scaffolded `proposal.md` under an `## Additional instructions` section use it for cross-cutting guidance every task should see.
220
+ Done issues whose PR `gh pr view --json mergeable` reports as `CONFLICTING` get `setConflicted` applied and a conflict-fix task prepended. The scanner is resilient to:
233
221
 
234
- **`updateEveryIterations`** (default `10`, `0` disables) posts a "🔄 Ralph progress update: iteration N" comment on the Linear issue every N task iterations. Requires `postComments: true`.
222
+ - Transient `gh` failures (failed PR-discovery is cached with a 10-minute TTL not permanent).
223
+ - Branch-name drift after a Linear title edit (falls back to `gh pr list --search "<ID> in:title state:open"`).
224
+ - GitHub's async `UNKNOWN` mergeability response (retries up to 3× with 2s gaps; logs when UNKNOWN persists).
235
225
 
236
- **`createPrOnSuccess`** (or `--create-pr`) pushes the worker's branch and opens a GitHub PR via `gh` after a clean exit. Requires `--worktree` (the PR needs a branch to point at) and the `gh` CLI authenticated. The PR title is `<ID>: <title>`, the body links the Linear issue. If a PR already exists for the branch the existing URL is reported (idempotent for retries). `prBaseBranch` defaults to `main`.
226
+ ### PR + CI integration
237
227
 
238
- **`fixCiOnFailure`** (or `--fix-ci`) watches the PR's checks via `gh pr checks` and, on failure, fetches the failed-run logs (`gh run view --log-failed`), appends them to `proposal.md` under `## Steering`, re-spawns the task loop in the worktree, and pushes the new commits — repeating until checks go green or `maxCiFixAttempts` is hit (default 5, polling interval `ciPollIntervalSeconds` defaults to 30s). Requires `--create-pr`.
228
+ | Flag / config | Behavior |
229
+ | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
230
+ | `createPrOnSuccess` / `--create-pr` | After a clean exit, push the worker's branch and `gh pr create`. Title: `<ID>: <title>`. Idempotent — surfaces the existing URL if the PR is already open. Requires `--worktree` and `gh` authenticated. `prBaseBranch` defaults to `main`. |
231
+ | `fixCiOnFailure` / `--fix-ci` | After the PR opens, poll `gh pr checks`. On failure, pull failed logs via `gh run view --log-failed`, append them to `## Steering`, re-spawn the worker, and push the new commits — repeat until green or `maxCiFixAttempts` (default `5`) is hit. While this loop runs, `setDone` is **not** applied; if CI is never green the worker is treated as failed. |
232
+ | `ciPollIntervalSeconds` | Seconds between CI status polls (default `30`). |
233
+ | `ignoreCiChecks` | Array of check names to ignore when computing pass/fail. |
234
+ | `codeReviewTrigger` / `--code-review` | See [Code-review iteration](#code-review-iteration). |
239
235
 
240
- When `fixCiOnFailure` is enabled, the `setDone` indicator is **not** applied (and the issue is not marked processed in `.ralph/agent-state.json`) until CI actually goes green. If the fix loop exhausts its attempts the worker is treated as failed for completion-marking purposes and the issue will be re-picked-up on the next poll (the `getInProgress` filter ensures that).
236
+ ### Worktrees, setup, teardown
241
237
 
242
- Every CLI flag is also configurable in `ralphy.config.json`; CLI values override config when both are set. The agent forwards `maxRuntimeMinutesPerTask` / `maxConsecutiveFailuresPerTask` / `iterationDelaySeconds` / `logRawStream` / `taskVerbose` to each spawned `ralph task` worker.
238
+ With `useWorktree: true` (or `--worktree`) each task runs in an isolated worktree at `~/.ralph/<project>/worktrees/<change-name>` checked out onto a fresh `ralph/<change-name>` branch. Concurrent workers can't stomp on each other, and the worker's cwd _is_ the worktree.
243
239
 
244
- Failed workers (non-zero exit) are not marked processed, so they'll be retried on the next poll. SIGINT/SIGTERM cleanly stops polling and kills active workers. All Linear side effects are best-effort — failures log a warning but never block the task loop.
240
+ - **`setupScript`** `sh -c`-run inside the worktree right after scaffolding (e.g. `bun install`, `cp .env.example .env`).
241
+ - **`teardownScript`** — `sh -c`-run after the loop exits and (optional) worktree cleanup.
242
+ - **`cleanupWorktreeOnSuccess`** — remove the worktree on clean exit. Failed workers always keep their worktree + branch for human inspection.
245
243
 
246
- ## CLI Options
244
+ Both scripts log failures but never block the loop. **`appendPrompt`** (or `--prompt` in agent mode) is appended to every scaffolded `proposal.md` under `## Additional instructions` — use it for cross-cutting guidance every task should see.
247
245
 
248
- | Option | Description |
249
- | ---------------------- | -------------------------------------------------------- |
250
- | `--name <name>` | Task name (required for most commands) |
251
- | `--prompt <text>` | Task description |
252
- | `--prompt-file <path>` | Read prompt from a file |
253
- | `--claude [model]` | Use Claude engine (haiku/sonnet/opus) |
254
- | `--codex` | Use Codex engine |
255
- | `--model <model>` | Set model (haiku/sonnet/opus) |
256
- | `--max-iterations <N>` | Stop after N iterations (0 = unlimited) |
257
- | `--max-cost <N>` | Stop when cost exceeds $N |
258
- | `--max-runtime <N>` | Stop after N minutes |
259
- | `--max-failures <N>` | Stop after N consecutive identical failures (default: 5) |
260
- | `--unlimited` | Set max iterations to 0 (unlimited, default) |
261
- | `--delay <N>` | Seconds to wait between iterations |
262
- | `--log` | Log raw JSON stream output |
263
- | `--verbose` | Verbose output |
246
+ ### Dashboard and logs
264
247
 
265
- ### Agent mode flags
248
+ The terminal dashboard shows three always-visible panels: **RALPH AGENT** (engine/model, concurrency, poll interval, active limits, feature flags, Linear filter), **POLL STATUS + WORKERS** (last-poll bucket breakdown — `found N │ todo · resume · conflict · review · mention` (each colored when non-zero) plus `↺ Ns` next-poll countdown, active/queued worker totals), and **TASKS tab bar** (numbered worker tabs — `Tab` / `← →` / `1-9` to switch).
266
249
 
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 |
279
-
280
- #### `--max-tickets`
281
-
282
- Use `--max-tickets N` to cap how many issues ralph picks up in a single agent run. Once N issues have been started (across fresh, resume, and conflict-fix modes), the coordinator stops enqueuing new work. In-flight workers continue to completion. The limit resets each time you restart `ralph agent`.
283
-
284
- ```bash
285
- # Process at most 3 issues this session, then idle
286
- ralph agent --max-tickets 3 --linear-team ENG
287
- ```
250
+ Each worker card shows: priority badge + identifier + title + mode badge, `↗ LINEAR`, `↗ PR`, `▶ TASK` (first unchecked task from `tasks.md`, refreshed every second), `PHASE` with color + elapsed time, `⏵ CMD` when a shell command is in flight, `LOG` path for `tail -f`, and `─ OUTPUT ─` with live stdout/stderr.
288
251
 
289
- When the limit is reached, ralph logs a yellow notice and the dashboard header shows `│ tickets ≤N`. Polling continues (to handle conflict re-fixes on already-started issues), but no new issues are queued.
252
+ Log files (every line is `[ISO] [type] message`):
290
253
 
291
- #### Dashboard
254
+ | File | Contains |
255
+ | ---------------------------------------- | ------------------------------------------------------------ |
256
+ | `~/.ralph/agent-mode.log` | Global session log, appended each agent run |
257
+ | `<projectRoot>/.ralph/logs/<change>.log` | Per-worker unified log: output + phases + coordinator events |
258
+ | `<taskDir>/LOG.jsonl` | Structured JSON event log used by the web UI |
292
259
 
293
- The `ralph agent` terminal dashboard shows a full-terminal layout with three always-visible panels:
260
+ Failed workers are not marked processed, so they retry on the next poll. SIGINT / SIGTERM cleanly stops polling and kills active workers. All Linear side effects are best-effort — failures log a warning but never block the loop.
294
261
 
295
- - **RALPH AGENT** (blue box): engine/model, concurrency, poll interval, active limits (`iter ≤N`, `cost ≤$N`, `tickets ≤N`), feature flags (● PR ● fixCI ● worktree), and the Linear filter on the second line
296
- - **POLL STATUS + WORKERS** (side-by-side): last-poll counts and next-poll countdown; active/queued worker totals with colored counts
297
- - **TASKS tab bar** (when multiple workers run): numbered worker tabs with priority glyph and phase — Tab/← → to switch, 1-9 to jump
262
+ ## CLI reference
298
263
 
299
- Each worker card shows:
264
+ **Task flags**
300
265
 
301
- - Priority badge (`▲ URGENT` / `↑ HIGH` / `· MED` / `↓ LOW`) + issue identifier + title + mode badge (`[NEW]` / `[RESUME]` / `[FIX]`)
302
- - `↗ LINEAR ISSUE-ID` and `↗ PR #N` (short labels, not full URLs)
303
- - `▶ TASK` first unchecked task from `tasks.md`, updated every second
304
- - `PHASE` with color (cyan = working, yellow = git ops, blue = CI, green = done, red = gave-up) + time in phase
305
- - `⏵ CMD` when a shell command is in flight (shows the command and how long it's been running)
306
- - `LOG` path to the worker's log file for `tail -f` (format: `[ISO] [type] message`)
307
- - `─ OUTPUT ─` section with live stdout/stderr (scales to fill remaining terminal height for the focused worker)
266
+ | Option | Description |
267
+ | ---------------------- | --------------------------------------------------------- |
268
+ | `--name <name>` | Task name (required for most commands) |
269
+ | `--prompt <text>` | Task description |
270
+ | `--prompt-file <path>` | Read prompt from file |
271
+ | `--claude [model]` | Use Claude engine (haiku / sonnet / opus, default opus) |
272
+ | `--codex` | Use Codex engine |
273
+ | `--model <model>` | Set model (haiku / sonnet / opus) |
274
+ | `--max-iterations <N>` | Stop after N iterations (`0` = unlimited) |
275
+ | `--max-cost <N>` | Stop when total cost exceeds $N |
276
+ | `--max-runtime <N>` | Stop after N minutes |
277
+ | `--max-failures <N>` | Stop after N consecutive identical failures (default `5`) |
278
+ | `--unlimited` | Sets max iterations to 0 (default) |
279
+ | `--delay <N>` | Seconds between iterations |
280
+ | `--manual-test` | Enable manual-test phase (creates test tasks) |
281
+ | `--log` | Log raw engine stream |
282
+ | `--verbose` | Verbose output |
308
283
 
309
- ### Log files
284
+ **Agent-mode flags**
310
285
 
311
- All log entries use the format `[2024-01-15T12:00:00.000Z] [type] message`. Four log types:
312
-
313
- | Type | Meaning | Destination |
314
- | --------- | -------------------------------------------------- | -------------------------------------------------- |
315
- | `session` | Worker start / stop boundaries | `agent-mode.log` + worker log |
316
- | `phase` | Phase transitions (working pushing → ci-poll …) | `agent-mode.log` + worker log |
317
- | `coord` | Coordinator events (Linear poll, worktree, labels) | `agent-mode.log` + worker log (when task-specific) |
318
- | `output` | Raw subprocess stdout/stderr lines | Worker log only |
319
-
320
- - **`~/.ralph/agent-mode.log`** global session log, appended each agent run
321
- - **`<projectRoot>/.ralph/logs/<change>.log`** — per-worker unified log; includes output, phases, and coordinator events for that task. `tail -f` this for live progress.
322
- - **`<taskDir>/LOG.jsonl`** structured JSON event log for the web UI (each line has a `ts` field)
286
+ | Option | Behavior |
287
+ | ------------------------- | ------------------------------------------------------------------------------------ |
288
+ | `--linear-team <key>` | Linear team key (e.g. `ENG`) |
289
+ | `--linear-assignee <id>` | Assignee filter (user id, email, or `me`) |
290
+ | `--poll-interval <s>` | Seconds between Linear polls (default `60`) |
291
+ | `--concurrency <n>` | Max concurrent task loops (default `1`) |
292
+ | `--max-tickets <n>` | Stop picking up new issues after N have been started this run (`0` = unlimited) |
293
+ | `--worktree` | Run each task in its own git worktree |
294
+ | `--indicator <k>:<t>:<v>` | Override one `linear.indicators` entry (repeatable, e.g. `setDone:status:Done`) |
295
+ | `--create-pr` | Push worker branch + open a GitHub PR on success (needs `--worktree`) |
296
+ | `--fix-ci` | After PR opens, re-run task on CI failures until green (needs `--create-pr`) |
297
+ | `--code-review` | Watch open tracked PRs for unresolved review comments and prepend a code-review task |
298
+ | `--json-output` | Emit JSONL to stdout instead of rendering the Ink dashboard (CI / scripting) |
323
299
 
324
- ## OpenSpec Flow
300
+ **`--max-tickets`.** Caps how many issues ralph picks up in a single agent run. Once the limit is hit the coordinator stops enqueuing new work; in-flight workers continue to completion, and the dashboard header shows `│ tickets ≤N`. The limit resets each restart.
325
301
 
326
- There are no phases. One loop, one prompt, one `tasks.md` checklist.
302
+ ## Change layout (OpenSpec)
327
303
 
328
- Each change lives in `.ralph/tasks/<name>/`:
304
+ There are no phases. One loop, one prompt, one `tasks.md` checklist. Each change lives in `<projectRoot>/openspec/changes/<name>/` (managed by OpenSpec) plus `<projectRoot>/.ralph/tasks/<name>/` (loop state only):
329
305
 
330
- | File / Directory | Purpose |
331
- | ------------------- | --------------------------------------------------------- |
332
- | `proposal.md` | Description, goals, and the `## Steering` section |
333
- | `design.md` | Technical design and architecture decisions |
334
- | `tasks.md` | Checklist driving iteration — one unchecked item per loop |
335
- | `specs/` | Detailed specifications for individual tasks |
336
- | `.ralph-state.json` | Loop state (iteration count, status, cost, history) |
337
- | `STOP` | Create this file to signal the loop to stop |
306
+ | File / Directory | Purpose |
307
+ | --------------------------------------- | --------------------------------------------------------- |
308
+ | `openspec/changes/<name>/proposal.md` | Description, goals, and the `## Steering` section |
309
+ | `openspec/changes/<name>/design.md` | Technical design and architecture decisions |
310
+ | `openspec/changes/<name>/tasks.md` | Checklist driving iteration — one unchecked item per loop |
311
+ | `openspec/changes/<name>/specs/` | Per-task specifications |
312
+ | `.ralph/tasks/<name>/.ralph-state.json` | Loop state (iteration count, status, cost, history) |
313
+ | `.ralph/tasks/<name>/STOP` | Create this file to signal the loop to stop |
338
314
 
339
315
  Steering is delivered by editing the `## Steering` section of `proposal.md`. The agent reads it at the start of every iteration.
340
316
 
341
- ## MCP Server
317
+ ## MCP server
342
318
 
343
- Ralphy includes an MCP server that exposes task management tools to Claude agents. It's automatically configured during installation. Available tools:
319
+ Ralphy includes an MCP server that exposes task-management tools to Claude agents. It's auto-configured during installation.
344
320
 
345
- - `ralph_list_changes` — List changes with status
346
- - `ralph_get_change` Get change details
347
- - `ralph_create_change` Create and optionally start a change
348
- - `ralph_append_steering` Append a steering message to `proposal.md`
349
- - `ralph_stop` Stop a running change
321
+ | Tool | Purpose |
322
+ | ----------------------- | ------------------------------------------ |
323
+ | `ralph_list_changes` | List changes with status |
324
+ | `ralph_get_change` | Get change details |
325
+ | `ralph_create_change` | Create and optionally start a change |
326
+ | `ralph_append_steering` | Append a steering message to `proposal.md` |
327
+ | `ralph_stop` | Stop a running change |
350
328
 
351
- ## Project Structure
329
+ ## Project structure and development
352
330
 
353
331
  ```
354
332
  ralphy/
@@ -359,17 +337,15 @@ ralphy/
359
337
  │ ├── core/ # State management and loop
360
338
  │ ├── context/ # Storage abstraction
361
339
  │ ├── content/ # Base prompt and task templates
362
- │ ├── engine/ # Claude/Codex engine spawning
340
+ │ ├── engine/ # Claude / Codex engine spawning
363
341
  │ ├── openspec/ # ChangeStore interface and OpenSpec adapter
364
342
  │ ├── output/ # Terminal formatting
365
343
  │ └── types/ # Zod schemas and types
366
344
  └── Makefile
367
345
  ```
368
346
 
369
- ## Development
370
-
371
347
  ```bash
372
348
  bun install
373
- bunx nx run-many -t lint,typecheck,test,build # Run checks
349
+ bunx nx run-many -t lint,typecheck,test,build # Run all checks
374
350
  bunx nx run cli:build # Build CLI only
375
351
  ```
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.20.0")
35033
- return "2.20.0";
35032
+ if ("2.20.2")
35033
+ return "2.20.2";
35034
35034
  } catch {}
35035
35035
  const dirsToTry = [];
35036
35036
  try {
@@ -59993,6 +59993,17 @@ async function fetchIssueComments(apiKey, issueId) {
59993
59993
  const data = await linearRequest(apiKey, query, { id: issueId });
59994
59994
  return data.issue?.comments.nodes ?? [];
59995
59995
  }
59996
+ async function fetchIssueAttachments(apiKey, issueId) {
59997
+ const query = `query IssueAttachments($id: String!) {
59998
+ issue(id: $id) {
59999
+ attachments(first: 25) {
60000
+ nodes { id url sourceType title }
60001
+ }
60002
+ }
60003
+ }`;
60004
+ const data = await linearRequest(apiKey, query, { id: issueId });
60005
+ return data.issue?.attachments?.nodes ?? [];
60006
+ }
59996
60007
  async function fetchWorkflowStates(apiKey, teamKey) {
59997
60008
  const query = `query States($team: String!) {
59998
60009
  workflowStates(filter: { team: { key: { eq: $team } } }, first: 50) {
@@ -60014,18 +60025,32 @@ async function updateIssueState(apiKey, issueId, stateId) {
60014
60025
  });
60015
60026
  }
60016
60027
  async function fetchIssueLabels(apiKey, teamKey) {
60017
- const query = `query Labels($team: String!) {
60028
+ const teamQuery = `query Labels($team: String!) {
60018
60029
  issueLabels(filter: { team: { key: { eq: $team } } }, first: 250) {
60019
60030
  nodes { id name parent { name } }
60020
60031
  }
60021
60032
  }`;
60022
- const data = await linearRequest(apiKey, query, {
60023
- team: teamKey
60024
- });
60025
- return data.issueLabels.nodes.map((l) => ({
60026
- id: l.id,
60027
- name: l.parent ? `${l.parent.name}:${l.name}` : l.name
60028
- }));
60033
+ const workspaceQuery = `query WorkspaceLabels {
60034
+ issueLabels(filter: { team: { null: true } }, first: 250) {
60035
+ nodes { id name parent { name } }
60036
+ }
60037
+ }`;
60038
+ const [teamData, workspaceData] = await Promise.all([
60039
+ linearRequest(apiKey, teamQuery, {
60040
+ team: teamKey
60041
+ }),
60042
+ linearRequest(apiKey, workspaceQuery, {}).catch(() => ({ issueLabels: { nodes: [] } }))
60043
+ ]);
60044
+ const seen = new Map;
60045
+ for (const l of workspaceData.issueLabels.nodes) {
60046
+ const name = l.parent ? `${l.parent.name}:${l.name}` : l.name;
60047
+ seen.set(name.toLowerCase(), { id: l.id, name });
60048
+ }
60049
+ for (const l of teamData.issueLabels.nodes) {
60050
+ const name = l.parent ? `${l.parent.name}:${l.name}` : l.name;
60051
+ seen.set(name.toLowerCase(), { id: l.id, name });
60052
+ }
60053
+ return [...seen.values()];
60029
60054
  }
60030
60055
  async function fetchTeamIdByKey(apiKey, teamKey) {
60031
60056
  const query = `query TeamId($key: String!) {
@@ -60102,7 +60127,7 @@ class AgentCoordinator {
60102
60127
  async init() {}
60103
60128
  async pollOnce() {
60104
60129
  if (this.stopped)
60105
- return { found: 0, added: 0 };
60130
+ return emptyPollResult();
60106
60131
  let todo = [];
60107
60132
  let inProgress = [];
60108
60133
  let conflicted = [];
@@ -60119,7 +60144,7 @@ class AgentCoordinator {
60119
60144
  } catch (err) {
60120
60145
  this.deps.onLog(`! Linear poll failed: ${err.message}`, "red");
60121
60146
  capture("agent_linear_poll_failed", { error: err.message });
60122
- return { found: 0, added: 0 };
60147
+ return emptyPollResult();
60123
60148
  }
60124
60149
  if (todo.length + inProgress.length + conflicted.length + review.length + mentions.length > 0) {
60125
60150
  this.deps.onLog(` poll: ${todo.length} todo, ${inProgress.length} in-progress, ${conflicted.length} conflicted, ${review.length} review, ${mentions.length} mention`, "gray");
@@ -60207,8 +60232,15 @@ class AgentCoordinator {
60207
60232
  this.spawnNext();
60208
60233
  await this.scanDoneForConflicts();
60209
60234
  await this.reportProgress();
60210
- const found = todo.length + inProgress.length + conflicted.length + review.length + mentions.length;
60211
- return { found, added };
60235
+ const buckets = {
60236
+ todo: todo.length,
60237
+ inProgress: inProgress.length,
60238
+ conflicted: conflicted.length,
60239
+ review: review.length,
60240
+ mentions: mentions.length
60241
+ };
60242
+ const found = buckets.todo + buckets.inProgress + buckets.conflicted + buckets.review + buckets.mentions;
60243
+ return { found, added, buckets };
60212
60244
  }
60213
60245
  dependenciesResolved(issue) {
60214
60246
  if (issue.blockedByIds.length === 0)
@@ -60506,6 +60538,11 @@ class AgentCoordinator {
60506
60538
  }
60507
60539
  }
60508
60540
  }
60541
+ var emptyPollResult = () => ({
60542
+ found: 0,
60543
+ added: 0,
60544
+ buckets: { todo: 0, inProgress: 0, conflicted: 0, review: 0, mentions: 0 }
60545
+ });
60509
60546
  var init_coordinator = __esm(() => {
60510
60547
  init_src();
60511
60548
  });
@@ -61297,7 +61334,10 @@ function buildAgentCoordinator(input) {
61297
61334
  onLog(` created Linear label '${name}' for team ${t}`, "gray");
61298
61335
  return newId;
61299
61336
  } catch (err) {
61300
- onLog(`! Linear label '${name}' creation threw: ${err.message}`, "yellow");
61337
+ const e = err;
61338
+ const detail = e.messages?.length ? ` \u2014 ${e.messages.join("; ")}` : "";
61339
+ onLog(`! Linear label '${name}' creation threw: ${e.message}${detail}`, "yellow");
61340
+ labelCache.delete(t);
61301
61341
  return null;
61302
61342
  }
61303
61343
  }
@@ -61305,16 +61345,20 @@ function buildAgentCoordinator(input) {
61305
61345
  if (m.type === "status") {
61306
61346
  const id = await resolveStateId(issue, m.value);
61307
61347
  if (!id) {
61308
- onLog(`! Linear status '${m.value}' not found for ${issue.identifier}`, "yellow");
61309
- return;
61348
+ const err = new Error("Linear status not found");
61349
+ err.status = m.value;
61350
+ err.issue = issue.identifier;
61351
+ throw err;
61310
61352
  }
61311
61353
  await updateIssueState(apiKey, issue.id, id);
61312
61354
  onLog(` \u2192 ${issue.identifier} status='${m.value}'`, "gray");
61313
61355
  } else {
61314
61356
  const id = await resolveLabelId(issue, m.value);
61315
61357
  if (!id) {
61316
- onLog(`! Linear label '${m.value}' could not be created for ${issue.identifier}`, "yellow");
61317
- return;
61358
+ const err = new Error("Linear label could not be resolved");
61359
+ err.label = m.value;
61360
+ err.issue = issue.identifier;
61361
+ throw err;
61318
61362
  }
61319
61363
  await addLabelToIssue(apiKey, issue.id, id);
61320
61364
  onLog(` \u2192 ${issue.identifier} +label='${m.value}'`, "gray");
@@ -61356,7 +61400,8 @@ function buildAgentCoordinator(input) {
61356
61400
  const branchByChange = new Map;
61357
61401
  const issueByChange = new Map;
61358
61402
  const prByChange = new Map;
61359
- const prUnavailable = new Set;
61403
+ const prUnavailable = new Map;
61404
+ const PR_UNAVAILABLE_TTL_MS = 10 * 60 * 1000;
61360
61405
  const stalePingedAt = new Map;
61361
61406
  const useWorktree = args.worktree || cfg.useWorktree;
61362
61407
  const scriptRunner = input.runners?.runScript ?? (async (cmd, cwd2) => {
@@ -61680,42 +61725,88 @@ PR: ${prUrl}` : ""
61680
61725
  }
61681
61726
  async function checkPrConflict(issue) {
61682
61727
  const changeName = changeNameForIssue(issue);
61683
- if (prUnavailable.has(changeName))
61728
+ if (isPrUnavailable(changeName))
61684
61729
  return null;
61685
- const branch = branchForChange(changeName);
61686
61730
  let prUrl = prByChange.get(changeName);
61687
61731
  if (!prUrl) {
61732
+ const found = await discoverPrUrl(issue, changeName);
61733
+ if (!found)
61734
+ return null;
61735
+ prUrl = found;
61736
+ prByChange.set(changeName, prUrl);
61737
+ }
61738
+ for (let attempt2 = 0;attempt2 < 3; attempt2++) {
61688
61739
  try {
61689
- const res = await cmdRunner.run([
61690
- "gh",
61691
- "pr",
61692
- "list",
61693
- "--head",
61694
- branch,
61695
- "--state",
61696
- "open",
61697
- "--json",
61698
- "url",
61699
- "--jq",
61700
- ".[0].url // empty"
61701
- ], projectRoot);
61702
- const found = res.stdout.trim();
61703
- if (!found) {
61704
- prUnavailable.add(changeName);
61705
- return null;
61740
+ const res = await cmdRunner.run(["gh", "pr", "view", prUrl, "--json", "mergeable", "--jq", ".mergeable"], projectRoot);
61741
+ const mergeable = res.stdout.trim();
61742
+ if (mergeable !== "UNKNOWN") {
61743
+ return { url: prUrl, conflicting: mergeable === "CONFLICTING" };
61706
61744
  }
61707
- prUrl = found;
61708
- prByChange.set(changeName, prUrl);
61709
- } catch {
61710
- prUnavailable.add(changeName);
61745
+ } catch (err) {
61746
+ onLog(`! gh pr view ${prUrl} failed (conflict scan): ${err.message}`, "yellow");
61711
61747
  return null;
61712
61748
  }
61749
+ await new Promise((r) => setTimeout(r, 2000));
61750
+ }
61751
+ onLog(` ${issue.identifier}: mergeability still UNKNOWN after retries (${prUrl}) \u2014 will recheck next poll`, "gray");
61752
+ return null;
61753
+ }
61754
+ function isPrUnavailable(changeName) {
61755
+ const expiry = prUnavailable.get(changeName);
61756
+ if (expiry === undefined)
61757
+ return false;
61758
+ if (Date.now() >= expiry) {
61759
+ prUnavailable.delete(changeName);
61760
+ return false;
61713
61761
  }
61762
+ return true;
61763
+ }
61764
+ function markPrUnavailable(changeName) {
61765
+ prUnavailable.set(changeName, Date.now() + PR_UNAVAILABLE_TTL_MS);
61766
+ }
61767
+ async function discoverPrUrl(issue, changeName) {
61768
+ const branch = branchForChange(changeName);
61769
+ const tryGh = async (args2) => {
61770
+ try {
61771
+ const res = await cmdRunner.run(args2, projectRoot);
61772
+ const found = res.stdout.trim();
61773
+ return found || null;
61774
+ } catch (err) {
61775
+ onLog(`! gh ${args2[1] ?? ""} failed for ${issue.identifier}: ${err.message}`, "yellow");
61776
+ return null;
61777
+ }
61778
+ };
61779
+ const byBranch = await tryGh([
61780
+ "gh",
61781
+ "pr",
61782
+ "list",
61783
+ "--head",
61784
+ branch,
61785
+ "--state",
61786
+ "open",
61787
+ "--json",
61788
+ "url",
61789
+ "--jq",
61790
+ ".[0].url // empty"
61791
+ ]);
61792
+ if (byBranch)
61793
+ return byBranch;
61794
+ const fromLinear = await discoverPrUrlFromLinear(issue);
61795
+ if (fromLinear) {
61796
+ onLog(` ${issue.identifier}: PR discovered via Linear attachment (${fromLinear})`, "gray");
61797
+ return fromLinear;
61798
+ }
61799
+ onLog(` ${issue.identifier}: no open PR found on head=${branch} or Linear attachments; conflict scan skipped for ${PR_UNAVAILABLE_TTL_MS / 60000}m`, "gray");
61800
+ markPrUnavailable(changeName);
61801
+ return null;
61802
+ }
61803
+ async function discoverPrUrlFromLinear(issue) {
61714
61804
  try {
61715
- const res = await cmdRunner.run(["gh", "pr", "view", prUrl, "--json", "mergeable", "--jq", ".mergeable"], projectRoot);
61716
- const mergeable = res.stdout.trim();
61717
- return { url: prUrl, conflicting: mergeable === "CONFLICTING" };
61718
- } catch {
61805
+ const attachments = await fetchIssueAttachments(apiKey, issue.id);
61806
+ const match = attachments.find((a) => /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/.test(a.url));
61807
+ return match?.url ?? null;
61808
+ } catch (err) {
61809
+ onLog(`! Linear attachments fetch failed for ${issue.identifier}: ${err.message}`, "yellow");
61719
61810
  return null;
61720
61811
  }
61721
61812
  }
@@ -61953,36 +62044,15 @@ PR: ${prUrl}` : ""
61953
62044
  }
61954
62045
  async function resolvePrUrlForIssue(issue) {
61955
62046
  const changeName = changeNameForIssue(issue);
61956
- if (prUnavailable.has(changeName))
62047
+ if (isPrUnavailable(changeName))
61957
62048
  return null;
61958
62049
  const cached = prByChange.get(changeName);
61959
62050
  if (cached)
61960
62051
  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
- }
62052
+ const found = await discoverPrUrl(issue, changeName);
62053
+ if (found)
61981
62054
  prByChange.set(changeName, found);
61982
- return found;
61983
- } catch {
61984
- return null;
61985
- }
62055
+ return found;
61986
62056
  }
61987
62057
  async function fetchPrIssueComments(prUrl) {
61988
62058
  const m = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(prUrl);
@@ -62218,10 +62288,10 @@ async function runAgentJson({
62218
62288
  if (cancelled)
62219
62289
  return;
62220
62290
  emit({ type: "poll_start" });
62221
- const { found, added } = await coord.pollOnce();
62291
+ const { found, added, buckets } = await coord.pollOnce();
62222
62292
  if (cancelled)
62223
62293
  return;
62224
- emit({ type: "poll_done", found, added });
62294
+ emit({ type: "poll_done", found, added, buckets });
62225
62295
  pollTimer = setTimeout(tick, pollInterval * 1000);
62226
62296
  };
62227
62297
  tick();
@@ -73191,7 +73261,14 @@ function AgentMode({ args, projectRoot, statesDir, tasksDir }) {
73191
73261
  const workerMetaRef = import_react57.useRef(new Map);
73192
73262
  const nextPollAtRef = import_react57.useRef(0);
73193
73263
  const cfgRef = import_react57.useRef(null);
73194
- const [pollStatus, setPollStatus] = import_react57.useState({ state: "idle", lastFound: null, lastAdded: null, lastAt: null, filterDesc: "" });
73264
+ const [pollStatus, setPollStatus] = import_react57.useState({
73265
+ state: "idle",
73266
+ lastFound: null,
73267
+ lastAdded: null,
73268
+ lastAt: null,
73269
+ filterDesc: "",
73270
+ lastBuckets: null
73271
+ });
73195
73272
  function appendLog(text, color, workerLogFile) {
73196
73273
  setLogs((prev) => [...prev, { id: nextId(), text, color }]);
73197
73274
  logCoord(text, workerLogFile);
@@ -73286,7 +73363,7 @@ function AgentMode({ args, projectRoot, statesDir, tasksDir }) {
73286
73363
  if (cancelled)
73287
73364
  return;
73288
73365
  setPollStatus((p) => ({ ...p, state: "polling", filterDesc }));
73289
- const { found, added } = await coord2.pollOnce();
73366
+ const { found, added, buckets } = await coord2.pollOnce();
73290
73367
  if (cancelled)
73291
73368
  return;
73292
73369
  if (added > 0) {
@@ -73297,7 +73374,8 @@ function AgentMode({ args, projectRoot, statesDir, tasksDir }) {
73297
73374
  lastFound: found,
73298
73375
  lastAdded: added,
73299
73376
  lastAt: Date.now(),
73300
- filterDesc
73377
+ filterDesc,
73378
+ lastBuckets: buckets
73301
73379
  });
73302
73380
  nextPollAtRef.current = Date.now() + pollInterval * 1000;
73303
73381
  pollTimer = setTimeout(tick, pollInterval * 1000);
@@ -73552,18 +73630,70 @@ function AgentMode({ args, projectRoot, statesDir, tasksDir }) {
73552
73630
  color: "white",
73553
73631
  children: pollStatus.lastFound
73554
73632
  }, undefined, false, undefined, this),
73555
- /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
73556
- dimColor: true,
73557
- children: "\u2502"
73558
- }, undefined, false, undefined, this),
73559
- /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
73560
- dimColor: true,
73561
- children: "new"
73562
- }, undefined, false, undefined, this),
73563
- /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
73564
- color: pollStatus.lastAdded > 0 ? "green" : "white",
73565
- children: pollStatus.lastAdded
73566
- }, undefined, false, undefined, this),
73633
+ pollStatus.lastBuckets && /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(jsx_dev_runtime9.Fragment, {
73634
+ children: [
73635
+ /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
73636
+ dimColor: true,
73637
+ children: "\u2502"
73638
+ }, undefined, false, undefined, this),
73639
+ /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
73640
+ dimColor: true,
73641
+ children: "todo"
73642
+ }, undefined, false, undefined, this),
73643
+ /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
73644
+ color: "white",
73645
+ children: pollStatus.lastBuckets.todo
73646
+ }, undefined, false, undefined, this),
73647
+ /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
73648
+ dimColor: true,
73649
+ children: "\xB7"
73650
+ }, undefined, false, undefined, this),
73651
+ /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
73652
+ dimColor: true,
73653
+ children: "resume"
73654
+ }, undefined, false, undefined, this),
73655
+ /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
73656
+ color: pollStatus.lastBuckets.inProgress > 0 ? "cyan" : "white",
73657
+ children: pollStatus.lastBuckets.inProgress
73658
+ }, undefined, false, undefined, this),
73659
+ /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
73660
+ dimColor: true,
73661
+ children: "\xB7"
73662
+ }, undefined, false, undefined, this),
73663
+ /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
73664
+ dimColor: true,
73665
+ children: "conflict"
73666
+ }, undefined, false, undefined, this),
73667
+ /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
73668
+ color: pollStatus.lastBuckets.conflicted > 0 ? "red" : "white",
73669
+ children: pollStatus.lastBuckets.conflicted
73670
+ }, undefined, false, undefined, this),
73671
+ /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
73672
+ dimColor: true,
73673
+ children: "\xB7"
73674
+ }, undefined, false, undefined, this),
73675
+ /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
73676
+ dimColor: true,
73677
+ children: "review"
73678
+ }, undefined, false, undefined, this),
73679
+ /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
73680
+ color: pollStatus.lastBuckets.review > 0 ? "yellow" : "white",
73681
+ children: pollStatus.lastBuckets.review
73682
+ }, undefined, false, undefined, this),
73683
+ /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
73684
+ dimColor: true,
73685
+ children: "\xB7"
73686
+ }, undefined, false, undefined, this),
73687
+ /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
73688
+ dimColor: true,
73689
+ children: "mention"
73690
+ }, undefined, false, undefined, this),
73691
+ /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
73692
+ color: pollStatus.lastBuckets.mentions > 0 ? "magenta" : "white",
73693
+ children: pollStatus.lastBuckets.mentions
73694
+ }, undefined, false, undefined, this)
73695
+ ]
73696
+ }, undefined, true, undefined, this),
73567
73697
  secsToNextPoll !== null && /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(jsx_dev_runtime9.Fragment, {
73568
73698
  children: [
73569
73699
  /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
@@ -73572,7 +73702,7 @@ function AgentMode({ args, projectRoot, statesDir, tasksDir }) {
73572
73702
  }, undefined, false, undefined, this),
73573
73703
  /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
73574
73704
  dimColor: true,
73575
- children: "next in"
73705
+ children: "\u21BA"
73576
73706
  }, undefined, false, undefined, this),
73577
73707
  /* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
73578
73708
  color: "gray",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neriros/ralphy",
3
- "version": "2.20.0",
3
+ "version": "2.20.2",
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",