@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.
- package/README.md +207 -231
- package/dist/cli/index.js +220 -90
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,9 +7,25 @@
|
|
|
7
7
|
[](https://github.com/NeriRos/ralphy/issues)
|
|
8
8
|
[](https://bun.sh)
|
|
9
9
|
|
|
10
|
-
An iterative AI task execution framework. Ralphy orchestrates
|
|
11
|
-
|
|
12
|
-
##
|
|
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
|
|
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
|
-
##
|
|
40
|
+
## Install
|
|
84
41
|
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
+
## Task mode
|
|
96
60
|
|
|
97
61
|
```bash
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
65
|
+
# Resume the same task later (state is on disk)
|
|
66
|
+
ralph task --name fix-auth
|
|
105
67
|
|
|
106
|
-
|
|
68
|
+
# Inspect
|
|
69
|
+
ralph list # all tasks (table)
|
|
70
|
+
ralph status --name fix-auth # one task (details)
|
|
71
|
+
```
|
|
107
72
|
|
|
108
|
-
- [
|
|
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
|
-
##
|
|
75
|
+
## Agent mode
|
|
113
76
|
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
+
A default `ralphy.config.json` is written on first run. CLI flags override config per-invocation.
|
|
121
85
|
|
|
122
|
-
###
|
|
86
|
+
### Lifecycle and triggers
|
|
123
87
|
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
+
### Linear indicators
|
|
140
142
|
|
|
141
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
+
#### Review follow-ups (label trigger)
|
|
213
202
|
|
|
214
|
-
|
|
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
|
-
|
|
205
|
+
#### `@ralphy` mention trigger
|
|
219
206
|
|
|
220
|
-
|
|
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
|
-
|
|
209
|
+
#### Code-review iteration
|
|
223
210
|
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
218
|
+
#### Conflict re-fix
|
|
231
219
|
|
|
232
|
-
|
|
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
|
-
|
|
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
|
-
|
|
226
|
+
### PR + CI integration
|
|
237
227
|
|
|
238
|
-
|
|
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
|
-
|
|
236
|
+
### Worktrees, setup, teardown
|
|
241
237
|
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
252
|
+
Log files (every line is `[ISO] [type] message`):
|
|
290
253
|
|
|
291
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
264
|
+
**Task flags**
|
|
300
265
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
284
|
+
**Agent-mode flags**
|
|
310
285
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
|
314
|
-
|
|
|
315
|
-
|
|
|
316
|
-
|
|
|
317
|
-
|
|
|
318
|
-
| `
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
302
|
+
## Change layout (OpenSpec)
|
|
327
303
|
|
|
328
|
-
Each change lives in
|
|
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
|
|
331
|
-
|
|
|
332
|
-
| `proposal.md`
|
|
333
|
-
| `design.md`
|
|
334
|
-
| `tasks.md`
|
|
335
|
-
| `specs/`
|
|
336
|
-
| `.ralph-state.json` | Loop state (iteration count, status, cost, history) |
|
|
337
|
-
|
|
|
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
|
|
317
|
+
## MCP server
|
|
342
318
|
|
|
343
|
-
Ralphy includes an MCP server that exposes task
|
|
319
|
+
Ralphy includes an MCP server that exposes task-management tools to Claude agents. It's auto-configured during installation.
|
|
344
320
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
|
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.
|
|
35033
|
-
return "2.20.
|
|
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
|
|
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
|
|
60023
|
-
team:
|
|
60024
|
-
|
|
60025
|
-
|
|
60026
|
-
|
|
60027
|
-
|
|
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
|
|
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
|
|
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
|
|
60211
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61309
|
-
|
|
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
|
-
|
|
61317
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
61691
|
-
|
|
61692
|
-
"
|
|
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
|
-
|
|
61708
|
-
|
|
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
|
|
61716
|
-
const
|
|
61717
|
-
return
|
|
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 (
|
|
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
|
|
61962
|
-
|
|
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
|
-
|
|
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({
|
|
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(
|
|
73556
|
-
|
|
73557
|
-
|
|
73558
|
-
|
|
73559
|
-
|
|
73560
|
-
|
|
73561
|
-
|
|
73562
|
-
|
|
73563
|
-
|
|
73564
|
-
|
|
73565
|
-
|
|
73566
|
-
|
|
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: "
|
|
73705
|
+
children: "\u21BA"
|
|
73576
73706
|
}, undefined, false, undefined, this),
|
|
73577
73707
|
/* @__PURE__ */ jsx_dev_runtime9.jsxDEV(Text, {
|
|
73578
73708
|
color: "gray",
|