@wemuda/launchrail 1.13.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +3 -3
  2. package/assets/agents-docs/domain.md +2 -2
  3. package/assets/agents-docs/issue-tracker-github.md +1 -1
  4. package/assets/agents-docs/issue-tracker-linear.md +1 -1
  5. package/assets/ralph.workflow.js +635 -276
  6. package/assets/skills/NOTICE.md +4 -0
  7. package/assets/skills/launchrail/launch/SKILL.md +6 -6
  8. package/assets/skills/launchrail/launch/workflow.md +4 -3
  9. package/assets/skills/launchrail/launch-browser-smoke/SKILL.md +36 -40
  10. package/assets/skills/launchrail/launch-code-review/SKILL.md +1 -1
  11. package/assets/skills/launchrail/launch-design-handoff/SKILL.md +1 -1
  12. package/assets/skills/launchrail/launch-design-validation/SKILL.md +2 -2
  13. package/assets/skills/launchrail/launch-discovery/SKILL.md +2 -2
  14. package/assets/skills/launchrail/launch-grill/SKILL.md +8 -2
  15. package/assets/skills/launchrail/launch-grill/domain-modeling.md +3 -1
  16. package/assets/skills/launchrail/launch-implement/SKILL.md +11 -11
  17. package/assets/skills/launchrail/launch-loop-readiness/SKILL.md +79 -0
  18. package/assets/skills/launchrail/launch-project-alignment/SKILL.md +2 -2
  19. package/assets/skills/launchrail/launch-ralph/SKILL.md +79 -60
  20. package/assets/skills/launchrail/launch-ralph-implement/SKILL.md +9 -8
  21. package/assets/skills/launchrail/launch-resolving-merge-conflicts/SKILL.md +1 -1
  22. package/assets/skills/launchrail/launch-spec/SKILL.md +3 -3
  23. package/assets/skills/launchrail/launch-tickets/SKILL.md +6 -7
  24. package/assets/skills/launchrail/launch-wayfinder/SKILL.md +6 -6
  25. package/dist/commands/add.js +7 -10
  26. package/dist/commands/add.js.map +1 -1
  27. package/dist/commands/doctor.js +84 -8
  28. package/dist/commands/doctor.js.map +1 -1
  29. package/dist/commands/init.js +2 -2
  30. package/dist/commands/init.js.map +1 -1
  31. package/dist/commands/verify.d.ts +13 -3
  32. package/dist/commands/verify.js +24 -8
  33. package/dist/commands/verify.js.map +1 -1
  34. package/dist/index.js +3 -15
  35. package/dist/index.js.map +1 -1
  36. package/dist/lib/adr.d.ts +27 -0
  37. package/dist/lib/adr.js +87 -0
  38. package/dist/lib/adr.js.map +1 -0
  39. package/dist/lib/browser-testing.d.ts +20 -3
  40. package/dist/lib/browser-testing.js +79 -79
  41. package/dist/lib/browser-testing.js.map +1 -1
  42. package/dist/lib/detect.d.ts +2 -0
  43. package/dist/lib/detect.js +3 -0
  44. package/dist/lib/detect.js.map +1 -1
  45. package/dist/lib/manifest.d.ts +12 -1
  46. package/dist/lib/manifest.js +24 -5
  47. package/dist/lib/manifest.js.map +1 -1
  48. package/dist/lib/migrations.js +76 -1
  49. package/dist/lib/migrations.js.map +1 -1
  50. package/dist/lib/project.d.ts +2 -0
  51. package/dist/lib/project.js +2 -1
  52. package/dist/lib/project.js.map +1 -1
  53. package/dist/lib/readiness.d.ts +57 -0
  54. package/dist/lib/readiness.js +129 -0
  55. package/dist/lib/readiness.js.map +1 -0
  56. package/dist/lib/seeds.d.ts +2 -0
  57. package/dist/lib/seeds.js +20 -11
  58. package/dist/lib/seeds.js.map +1 -1
  59. package/package.json +1 -1
  60. package/dist/commands/smoke.d.ts +0 -21
  61. package/dist/commands/smoke.js +0 -171
  62. package/dist/commands/smoke.js.map +0 -1
@@ -6,20 +6,31 @@
6
6
  // so long or wide runs cannot compact away their own state. This is the engine for
7
7
  // every multi-ticket run (ADR-0022); the launch-ralph skill carries the same policy
8
8
  // block as the supervisor's contract and the declared-exception watchable mode, and
9
- // a policy change belongs in both places (ADR-0005, field-revised by ADR-0010, ADR-0022).
9
+ // a policy change belongs in both places (ADR-0005, field-revised by ADR-0010,
10
+ // ADR-0022, and ADR-0032).
11
+ //
12
+ // The lean shape (ADR-0032): builders push their branch from the first commit on, so
13
+ // a lost container costs minutes, not a build; each finished branch is landed by the
14
+ // loop itself — a local squash-merge onto the integration base gated by the FAST
15
+ // verification tier, in this checkout, one land at a time — and pushed; the FULL
16
+ // gate runs at checkpoints (every N lands) and at release, with one bounded repair
17
+ // when a checkpoint is red. No per-ticket PR, no cloud-CI wait anywhere in the loop:
18
+ // cloud CI runs once, on the release PR the run offers. A work pool keeps `width`
19
+ // builders busy continuously — a slow ticket never holds a round.
10
20
  export const meta = {
11
21
  name: 'ralph',
12
22
  description: 'Autonomous Ralph loop: implement ready tickets with fresh-context subagents, verification-gated',
13
23
  whenToUse:
14
- 'The engine for any multi-ticket Ralph run. Scope a run via args: { only: [9, 10], width: 2 }, just [9, 10], or { max: 5 } to stop after 5 verified merges ("the next five" — the frontier picks which, in dependency order). The front door consolidates by DEFAULT (ADR-0026): it passes { target: "spec/44-mvp" } to collect the campaign onto that branch (default branch untouched; released later by one offered PR). In a session pinned to a designated working branch (hosted sessions), the front door passes that branch as the target (ADR-0028). Omitting target is the explicit trunk opt-in — each ticket merged straight into the default branch. { canary: true } holds width at 1 until the first verified merge. Args must be JSON — resolve any natural-language scope to ticket numbers, a cap, and a target before launching. For a watchable run (an explicit user ask, or a targeted intervention), use the launch-ralph skill instead — and say why.',
24
+ 'The engine for any multi-ticket Ralph run. Scope a run via args: { only: [9, 10], width: 2 }, just [9, 10], or { max: 5 } to stop after 5 verified lands ("the next five" — the frontier picks which, in dependency order). The front door consolidates by DEFAULT (ADR-0026): it passes { target: "spec/44-mvp" } to collect the campaign onto that branch (default branch untouched; released later by one offered PR). In a session pinned to a designated working branch (hosted sessions), the front door passes that branch as the target (ADR-0028). Omitting target is the explicit trunk opt-in — each ticket landed straight onto the default branch. { canary: true } holds width at 1 until the first verified land. { knownGreen: "<sha>" } lets a relaunch skip the preflight gate when the base still sits at a sha a previous run verified. Args must be JSON — resolve any natural-language scope to ticket numbers, a cap, and a target before launching. For a watchable run (an explicit user ask, or a targeted intervention), use the launch-ralph skill instead — and say why.',
15
25
  phases: [
16
- { title: 'Preflight', detail: 'read project config, resolve the integration target, run the verification gate' },
26
+ { title: 'Preflight', detail: 'read project config, sync the integration base into this checkout, run the full gate (or honor knownGreen)' },
17
27
  { title: 'Graph', detail: 'list ready tickets and their blocking edges, verbatim' },
18
- { title: 'Build', detail: 'one fresh-context implementer per ticket, handing off at PR-open' },
19
- { title: 'Gate', detail: 'per-ticket merge gate: CI wait, squash-merge, explicit close' },
20
- { title: 'Verify', detail: 'remote ground truth for every claimed merge' },
28
+ { title: 'Build', detail: 'one fresh-context implementer per ticket on a pushed ralph/<n>-* branch, handing off at a green fast gate' },
29
+ { title: 'Land', detail: 'local squash-merge onto the base under the fast gate, push, explicit close — one land at a time' },
30
+ { title: 'Verify', detail: 'remote ground truth for every claimed land' },
31
+ { title: 'Checkpoint', detail: 'the full gate on the base every N lands, with one bounded repair when red' },
21
32
  { title: 'Park', detail: 'comment failure history, label needs-info' },
22
- { title: 'Release', detail: 'final verification gate and the where-it-lives recap' },
33
+ { title: 'Release', detail: 'final full gate, prune landed branches, the where-it-lives recap' },
23
34
  ],
24
35
  }
25
36
 
@@ -48,42 +59,56 @@ const A = resolveArgs(args)
48
59
  const POLICY = {
49
60
  // Scope the run to specific ticket numbers ([] = the whole ready frontier).
50
61
  only: A.only ?? [],
51
- // Stop after this many verified merges (0 = no cap). "The next five": the frontier
52
- // decides which five, in dependency order. Batches never exceed the remainder, so a
53
- // run has at most `max` merges and leaves the rest of the frontier ready, not parked.
62
+ // Stop after this many verified lands (0 = no cap). "The next five": the frontier
63
+ // decides which five, in dependency order. Dispatch never exceeds the remainder, so a
64
+ // run has at most `max` lands and leaves the rest of the frontier ready, not parked.
54
65
  max: A.max ?? 0,
55
- // Parallel implementers. Width also caps local build concurrency — several implementers
56
- // share one machine, and fanning out test runs buys backpressure, not speed. Use 1 until
57
- // a run has landed tickets cleanly on this project — or pass canary: true, which does it
58
- // for you. Tickets that add DB migrations collide on the next migration number when run
59
- // in parallel; the pre-PR sync renumbers, but serializing them is cheaper.
66
+ // Parallel implementers, kept busy continuously by a work pool: when one finishes the
67
+ // next ready ticket dispatches — a slow ticket never holds the others. Width also caps
68
+ // local build concurrency — several implementers share one machine, and fanning out
69
+ // test runs buys backpressure, not speed. Use 1 until a run has landed tickets cleanly
70
+ // on this project — or pass canary: true, which does it for you. Tickets that add DB
71
+ // migrations are serialized by the loop itself (see the graph's `migration` flag).
60
72
  width: A.width ?? 3,
61
73
  // Integration target. The front door consolidates by DEFAULT (ADR-0026): it resolves a
62
74
  // scope-native branch name and passes it here, so the campaign collects on that branch and
63
75
  // the default branch is never touched — the run ends by offering ONE release PR
64
- // target -> default. A session pinned to a designated working branch passes that
65
- // branch here (ADR-0028) — the pin re-targets the run, it never changes the engine. '' is the explicit trunk opt-in: each ticket PR merged straight into
76
+ // target -> default, which is also where cloud CI runs. A session pinned to a designated
77
+ // working branch passes that branch here (ADR-0028) — the pin re-targets the run, it never
78
+ // changes the engine. '' is the explicit trunk opt-in: each ticket landed straight onto
66
79
  // the default branch; a bare launch with no target is therefore trunk mode — non-default.
67
80
  target: A.target ?? '',
68
- // Canary: hold width at 1 until the run's first verified merge proves the plumbing
69
- // end to end (branch, PR, CI, merge gate, close). For a project's first campaign.
81
+ // Canary: hold width at 1 until the run's first verified land proves the plumbing
82
+ // end to end (branch, push, land, gate, close). For a project's first campaign.
70
83
  canary: A.canary ?? false,
71
- // Tries per ticket: 1 attempt + 1 retry with a fresh context, then park. Deferrals
72
- // (a declared blocker had not landed yet) hand their attempt back, capped separately.
84
+ // Tries per ticket: 1 attempt + 1 retry with a fresh context, then park. A retry adopts
85
+ // the pushed branch and fixes forward — the work persists, only the context is fresh.
86
+ // Deferrals (a declared blocker had not landed yet) hand their attempt back, capped
87
+ // separately at the same number.
73
88
  attempts: A.attempts ?? 2,
74
- // CI-wait re-polls for the merge gate — distinct from build attempts. A gate agent
75
- // gets one turn and cannot idle-wait (a background sleep never resumes it), so a PR
76
- // whose CI is still running comes back "ci-timeout": not done yet, not a failure.
77
- // Re-poll the cheap gate this many times (reads only until it can merge) before the
78
- // ticket spends a fresh implementer attempt — a slow CI must never cost a rebuild
79
- // (ADR-0027). Concurrent tickets in the round supply the wall-clock CI needs.
80
- gateWaits: A.gateWaits ?? 6,
81
- // Backstop against a graph that never drains; deferral rounds spend from this too.
82
- maxRounds: A.maxRounds ?? 25,
83
- // Re-read the tracker between rounds so externally closed tickets unblock things.
89
+ // Land hand-backs that spend no attempt: the base moved under a finished branch and the
90
+ // squash-merge conflicted, or the merged tree failed the loop's gate although the branch
91
+ // was fine on its own. The builder did nothing wrong, so a fresh implementer re-syncs the
92
+ // pushed branch (minutes, not a rebuild) — up to this many times per ticket before it
93
+ // counts as a real failure.
94
+ resyncs: A.resyncs ?? 2,
95
+ // Run the FULL verification gate on the base after this many lands (0 = only at release).
96
+ // Every land already passed the fast gate on the merged tree; the full suite (e2e
97
+ // specs included) is paid once per checkpoint instead of once per ticket, and a red
98
+ // checkpoint has at most this many suspects.
99
+ checkpointEvery: A.checkpointEvery ?? 5,
100
+ // A sha the caller vouches for: a previous run verified the base green at exactly this
101
+ // commit. Preflight still syncs and installs, but skips the full gate when the base tip
102
+ // equals it — relaunching after a lost container costs minutes, not a re-proof.
103
+ knownGreen: A.knownGreen ?? '',
104
+ // Backstop against a graph that never drains: total build dispatches (attempts, retries,
105
+ // deferrals, re-syncs, repairs included).
106
+ maxBuilds: A.maxBuilds ?? 60,
107
+ // Re-read the tracker when the frontier is empty but tickets remain, so externally closed
108
+ // blockers unblock things.
84
109
  refreshGraph: A.refreshGraph ?? true,
85
- // Stop starting new rounds when the remaining token budget drops below this — a round
86
- // that starts without enough budget to merge is worse than one that never starts.
110
+ // Stop dispatching when the remaining token budget drops below this — a build that starts
111
+ // without enough budget to land is worse than one that never starts.
87
112
  reserve: A.reserve ?? 200_000,
88
113
  }
89
114
 
@@ -92,12 +117,12 @@ const POLICY = {
92
117
  // ---------------------------------------------------------------------------
93
118
  const INTEGRITY = `INTEGRITY: No placeholders, no stubs, no "simplified for now". Never delete,
94
119
  skip, or weaken a test to get a green run; if a test is genuinely wrong, fix it deliberately
95
- and say so in the PR body. Never claim verification passed without having run it.`
120
+ and say so in the commit message. Never claim a gate passed without having run it.`
96
121
 
97
122
  const IDEMPOTENCY = `IDEMPOTENCY: This step can be replayed after an interruption, so check
98
123
  before you act: if the ticket is already closed, report status "already-done" and stop; if a
99
- ralph/<n>-* branch or an open PR for this ticket already exists, adopt it and continue from
100
- where it left off — do not start over. Never open a second PR for the same ticket.`
124
+ pushed ralph/<n>-* branch for this ticket already exists, adopt it and continue from its last
125
+ commit — do not start over, and never create a second branch for the same ticket.`
101
126
 
102
127
  // ---------------------------------------------------------------------------
103
128
  // Schemas — stages return validated structure, never prose the script must parse.
@@ -105,10 +130,11 @@ where it left off — do not start over. Never open a second PR for the same tic
105
130
  const PREFLIGHT_SCHEMA = {
106
131
  type: 'object',
107
132
  additionalProperties: false,
108
- required: ['green', 'base', 'defaultBranch', 'trackerAccess', 'verifyCommand', 'localCommands', 'failures'],
133
+ required: ['green', 'base', 'defaultBranch', 'trackerAccess', 'installCommand', 'verifyCommand', 'fastGateCommand', 'localCommands', 'pushedBranches', 'failures'],
109
134
  properties: {
110
- green: { type: 'boolean', description: 'base is synced and the verification gate passed' },
111
- headSha: { type: 'string', description: 'commit sha the gate ran against' },
135
+ green: { type: 'boolean', description: 'base is synced into this checkout and the full gate passed (or was skipped as known green)' },
136
+ headSha: { type: 'string', description: 'the base tip the gate ran against (or that matched knownGreen)' },
137
+ skippedGate: { type: 'boolean', description: 'true when the gate was skipped because the base tip equals knownGreen' },
112
138
  repo: { type: 'string', description: 'owner/name from the git remote, or empty' },
113
139
  base: { type: 'string', description: "the run's integration base: the declared target branch when one is set, else the default branch" },
114
140
  defaultBranch: { type: 'string', description: 'the repository default branch name' },
@@ -119,13 +145,27 @@ const PREFLIGHT_SCHEMA = {
119
145
  description:
120
146
  'instruction for reaching the tracker from THIS execution environment (exact CLI or MCP tools available here), for inclusion in dispatch prompts',
121
147
  },
122
- verifyCommand: { type: 'string', description: 'the verification gate command, verbatim' },
148
+ installCommand: { type: 'string', description: 'the verbatim dependency install command (e.g. pnpm install --frozen-lockfile)' },
149
+ verifyCommand: { type: 'string', description: 'the FULL verification gate command, verbatim' },
150
+ fastGateCommand: { type: 'string', description: 'the FAST verification gate command, verbatim' },
123
151
  localCommands: {
124
152
  type: 'array',
125
153
  items: { type: 'string' },
126
- description: 'other verbatim local commands (install, typecheck, unit tests) implementers should use',
154
+ description: 'other verbatim local commands (typecheck, lint, unit tests) implementers should use',
155
+ },
156
+ pushedBranches: {
157
+ type: 'array',
158
+ items: {
159
+ type: 'object',
160
+ additionalProperties: false,
161
+ required: ['branch', 'sha'],
162
+ properties: {
163
+ branch: { type: 'string', description: 'a ralph/<n>-<slug> branch present on the remote' },
164
+ sha: { type: 'string' },
165
+ },
166
+ },
167
+ description: 'every ralph/* branch on the remote — in-flight work a previous run pushed, which this run adopts',
127
168
  },
128
- browserTesting: { type: 'boolean', description: '.launchrail.yml modules.browser-testing' },
129
169
  failures: { type: 'array', items: { type: 'string' } },
130
170
  },
131
171
  }
@@ -140,7 +180,7 @@ const GRAPH_SCHEMA = {
140
180
  items: {
141
181
  type: 'object',
142
182
  additionalProperties: false,
143
- required: ['number', 'title', 'blockedByLine'],
183
+ required: ['number', 'title', 'blockedByLine', 'migration'],
144
184
  properties: {
145
185
  number: { type: 'integer' },
146
186
  title: { type: 'string' },
@@ -149,6 +189,11 @@ const GRAPH_SCHEMA = {
149
189
  description:
150
190
  'The ticket\'s "Blocked by" line copied VERBATIM (e.g. "**Blocked by:** #11, #9"), or "" if it has none. Do NOT interpret or resolve the edges — copy the characters; the caller parses the #n itself.',
151
191
  },
192
+ migration: {
193
+ type: 'boolean',
194
+ description:
195
+ 'true when the ticket plainly adds or changes a database schema or migration (a new table, column, or migration file); false otherwise or when unsure. Used only to serialize such tickets — never to skip one.',
196
+ },
152
197
  },
153
198
  },
154
199
  },
@@ -168,12 +213,16 @@ const BUILD_SCHEMA = {
168
213
  properties: {
169
214
  status: {
170
215
  type: 'string',
171
- enum: ['pr-open', 'merged', 'already-done', 'blocked', 'conflict', 'verify-failed', 'failed'],
216
+ enum: ['ready', 'already-done', 'blocked', 'conflict', 'verify-failed', 'failed'],
172
217
  description:
173
- '"pr-open" is the normal hand-off (the loop owns CI and merge); "merged" only when an adopted PR turned out to be merged already (idempotency)',
218
+ '"ready" is the normal hand-off: the branch is pushed and the fast gate is green on it (the loop lands it); "already-done" when the ticket turned out to be closed already (idempotency)',
219
+ },
220
+ branch: { type: 'string', description: 'the pushed ralph/<n>-<slug> branch' },
221
+ headSha: { type: 'string', description: 'the pushed tip, as the remote reports it' },
222
+ commitTitle: {
223
+ type: 'string',
224
+ description: 'a Conventional Commit title for the squash the loop will make, e.g. "feat(auth): add magic-link sign-in"',
174
225
  },
175
- pr: { type: 'integer', description: 'PR number, when one was opened or adopted' },
176
- mergeCommit: { type: 'string' },
177
226
  summary: { type: 'string', description: 'what happened, short; on failure, enough for a retry to act on' },
178
227
  failure: {
179
228
  type: 'string',
@@ -187,20 +236,26 @@ const BUILD_SCHEMA = {
187
236
  },
188
237
  }
189
238
 
190
- const GATE_SCHEMA = {
239
+ const LAND_SCHEMA = {
191
240
  type: 'object',
192
241
  additionalProperties: false,
193
242
  required: ['status', 'summary'],
194
243
  properties: {
195
244
  status: {
196
245
  type: 'string',
197
- enum: ['merged', 'ci-failed', 'ci-timeout', 'not-mergeable', 'failed'],
246
+ enum: ['landed', 'conflict', 'gate-failed', 'stale', 'failed'],
247
+ description:
248
+ '"landed": squashed onto the base, gate green, pushed, remote confirmed; "conflict": the squash-merge conflicted; "gate-failed": the merged tree failed the gate; "stale": the remote base kept moving outside this loop; "failed": a precondition (dirty or diverged checkout, empty squash)',
249
+ },
250
+ mergeCommit: { type: 'string', description: 'on landed: the landing commit sha, as the remote reports it' },
251
+ baseMoved: {
252
+ type: 'boolean',
253
+ description: 'true when the base had moved since the implementer synced (its tip was not contained in the branch)',
198
254
  },
199
- mergeCommit: { type: 'string' },
200
255
  issueClosed: { type: 'boolean' },
201
256
  summary: {
202
257
  type: 'string',
203
- description: 'on merged: the API facts; on failure: the failing check or conflicting files, enough for a fresh implementer to act on',
258
+ description: 'on landed: the facts (sha, gate); on conflict: the conflicting files; on gate-failed: the failing checks and key lines a fresh implementer can act on',
204
259
  },
205
260
  },
206
261
  }
@@ -208,11 +263,11 @@ const GATE_SCHEMA = {
208
263
  const VERIFY_SCHEMA = {
209
264
  type: 'object',
210
265
  additionalProperties: false,
211
- required: ['merged', 'issueClosed', 'evidence'],
266
+ required: ['landed', 'issueClosed', 'evidence'],
212
267
  properties: {
213
- merged: {
268
+ landed: {
214
269
  type: 'boolean',
215
- description: 'true only if the PR is merged AND its merge commit appears in the base branch history',
270
+ description: 'true only if the reported landing commit exists on the remote AND is on the base branch',
216
271
  },
217
272
  issueClosed: { type: 'boolean' },
218
273
  mergeCommit: { type: 'string' },
@@ -220,14 +275,30 @@ const VERIFY_SCHEMA = {
220
275
  },
221
276
  }
222
277
 
278
+ const CHECKPOINT_SCHEMA = {
279
+ type: 'object',
280
+ additionalProperties: false,
281
+ required: ['green', 'headSha', 'summary'],
282
+ properties: {
283
+ green: { type: 'boolean', description: 'the full verification gate exited 0 on the synced base' },
284
+ headSha: { type: 'string', description: 'the base tip the gate ran against' },
285
+ summary: { type: 'string' },
286
+ failures: {
287
+ type: 'array',
288
+ items: { type: 'string' },
289
+ description: 'on red: the failing checks/tests with their key lines — a repair implementer acts on exactly this',
290
+ },
291
+ },
292
+ }
293
+
223
294
  const RELEASE_SCHEMA = {
224
295
  type: 'object',
225
296
  additionalProperties: false,
226
297
  required: ['verified', 'summary'],
227
298
  properties: {
228
- verified: { type: 'boolean', description: 'verification gate green on the final base' },
299
+ verified: { type: 'boolean', description: 'the full verification gate is green on the final base (run now, or already proven at this exact tip)' },
229
300
  headSha: { type: 'string' },
230
- smokeBundle: { type: 'string', description: 'path of the smoke evidence bundle, when one was produced' },
301
+ prunedBranches: { type: 'array', items: { type: 'string' }, description: 'the landed ralph/* branches deleted from the remote' },
231
302
  summary: { type: 'string' },
232
303
  failures: { type: 'array', items: { type: 'string' } },
233
304
  },
@@ -237,57 +308,123 @@ const RELEASE_SCHEMA = {
237
308
  // Dispatch prompts
238
309
  // ---------------------------------------------------------------------------
239
310
  function preamble(pre) {
240
- return `You are working in a Launchrail project${pre.repo ? ` (${pre.repo})` : ''}; base branch: ${pre.base}.
241
- Binding docs: AGENTS.md and CLAUDE.md, plus the vision, specs, and ADRs under docs/. Read them before touching code.
311
+ return `You are working in a Launchrail project${pre.repo ? ` (${pre.repo})` : ''}; integration base: ${pre.base}.
312
+ Binding docs: AGENTS.md and CLAUDE.md, plus the vision and specs under docs/. Read them before touching code.
313
+ Architecture decisions: read the registry index (docs/adr/README.md) and open only the ADRs touching your ticket's area — an ADR records a decision, not the current system; the code is the evidence for what exists.
242
314
  Tracker access from this environment: ${pre.trackerAccess}
243
315
  Blocking edges live on tickets as "Blocked by: #n" lines.
244
- Verbatim local commands: ${[...pre.localCommands, pre.verifyCommand].join(' ; ')}
245
- The verification gate is: ${pre.verifyCommand} — a ticket is not done while it fails.
316
+ Verbatim local commands: install: ${pre.installCommand}${pre.localCommands.length > 0 ? ` ; ${pre.localCommands.join(' ; ')}` : ''}
317
+ Two gates. The FAST gate is ${pre.fastGateCommand} — run it before every hand-off; it must exit 0. The FULL gate (${pre.verifyCommand}) belongs to the loop, which runs it on the base at its checkpoints — do not spend your turn on the whole suite; run only the slow test files your change touches (an e2e spec you edited).
246
318
  Several implementers share this machine — run single test files while iterating and save full runs for the gate.`
247
319
  }
248
320
 
249
- function buildPrompt(pre, ticket, attempt, priorFailure) {
321
+ function buildPrompt(pre, ticket, s, pushed, resync) {
250
322
  const retry =
251
- attempt > 1
252
- ? `\nThis is a RETRY with a fresh context. Prior attempt failed: ${priorFailure}
253
- Start clean: delete the failed ralph/${ticket.number}-* branch first, re-sync the base, and take a different approach where the failure suggests one.\n`
323
+ s.attempts > 1
324
+ ? `\nThis is a RETRY with a fresh context. Prior attempt failed: ${s.failures.join(' | ')}
325
+ The pushed branch (if any) holds the previous attempt's work — adopt it and fix forward from the failure. Only if the failure shows the approach itself was wrong, reset the branch to origin/${pre.base} and start over (force-pushing your own ralph/${ticket.number}-* branch is allowed; nothing else ever is).\n`
254
326
  : ''
327
+ const resyncNote = resync
328
+ ? `\nThe loop could not land the pushed branch because ${pre.base} moved under it: ${resync}
329
+ This is a RE-SYNC, not a failure: adopt the branch, merge the latest ${pre.base} into it (conflicts are ordinary work — the launch-resolving-merge-conflicts skill; regenerate migrations that now collide), make the fast gate green on the merged result, push, and hand off again.\n`
330
+ : ''
331
+ const adopt = pushed
332
+ ? `A pushed branch already exists for this ticket: ${pushed.branch} at ${pushed.sha} — a previous session's work. Adopt it (\`git fetch origin ${pushed.branch} && git checkout -b ${pushed.branch} origin/${pushed.branch}\`), read its log and its diff against origin/${pre.base}, and continue from where it stopped — never start over.`
333
+ : `Check the remote first (\`git ls-remote --heads origin 'ralph/${ticket.number}-*'\`) and adopt any pushed branch you find (\`git fetch origin <branch> && git checkout -b <branch> origin/<branch>\`, then continue from its last commit). Otherwise branch from a fresh fetch and push at once: \`git fetch origin ${pre.base} && git checkout -b ralph/${ticket.number}-<short-slug> origin/${pre.base} && git push -u origin HEAD\`. Never check out ${pre.base} itself in this worktree — the loop lands in the main checkout.`
255
334
  return `${preamble(pre)}
256
335
 
257
- Implement ticket #${ticket.number} ("${ticket.title}") through to an open PR. You own the build alone; assume no knowledge of any other session. Other implementers are working on other tickets against the same base right now, so ${pre.base} will move under you. That is expected.
258
- ${retry}
336
+ Implement ticket #${ticket.number} ("${ticket.title}") through to a pushed, fast-gate-green branch. You own the build alone; assume no knowledge of any other session. Other implementers are working on other tickets against the same base right now, so ${pre.base} will move under you. That is expected.
337
+ ${retry}${resyncNote}
259
338
  Steps, in order:
260
- 1. Dependency gate: before anything else, confirm every ticket on this ticket's "Blocked by" line is CLOSED with its work merged into ${pre.base}. If any blocker is still open, do NOT build on a missing dependency — report status "blocked", name the open blocker in "failure", and stop. That is a deferral, not a failure; the loop retries you after the blocker lands.
261
- 2. Read the ticket and everything it links (spec sections, ADRs, journeys). If the tracker tool truncates the body (long code spans are a known trigger), fetch the full text by another route — the tracker's search API, the spec file in the repo — and never implement from a truncated ticket. Report status "already-done" if the ticket is already closed.
339
+ 1. Dependency gate: before anything else, confirm every ticket on this ticket's "Blocked by" line is CLOSED with its work landed on ${pre.base}. If any blocker is still open, do NOT build on a missing dependency — report status "blocked", name the open blocker in "failure", and stop. That is a deferral, not a failure; the loop retries you after the blocker lands.
340
+ 2. Read the ticket and everything it links (spec sections, ADRs, designs). If the tracker tool truncates the body (long code spans are a known trigger), fetch the full text by another route — the tracker's search API, the spec file in the repo — and never implement from a truncated ticket. Report status "already-done" if the ticket is already closed.
262
341
  3. Label the ticket ralph:building so a lost session leaves a trace.
263
- 4. Branch from a fresh sync of ${pre.base}: ralph/${ticket.number}-<short-slug>.
264
- 5. Implement by invoking the launch-ralph-implement skill — it owns the per-ticket contract: TDD, the verification gate, browser smoke for user-facing changes, self-review via /code-review, commit conventions.
265
- 6. Pre-PR sync: merge the latest ${pre.base} into your branch. Conflicts are ordinary work — resolve them with the launch-resolving-merge-conflicts skill. If ${pre.base} gained DB migrations since you branched, regenerate yours to follow them with the project's migration tool — never hand-edit the migration journal. Re-run the verification gate if anything changed.
266
- 7. Open a PR against ${pre.base}, titled from the ticket, with "Closes #${ticket.number}" in the body. Never open a second PR if one already exists — adopt it. Opening against an up-to-date base means CI tests the state that will actually land. Then report status "pr-open" with the PR number and STOP: the CI wait, the merge, and the issue close belong to the loop's merge gate, not to you — a subagent cannot wait on CI (a background sleep will not resume you). Never push to ${pre.base} directly; the PR is the only door.
342
+ 4. Branch and push immediately. ${adopt} From here on the pushed branch is your checkpoint: commit and push after every green step (a passing test slice, a finished subtask) — a session that dies keeps everything up to its last push, and its successor resumes from there instead of rebuilding. The pushes are also the loop's liveness signal.
343
+ 5. Implement by invoking the launch-ralph-implement skill — it owns the per-ticket contract: TDD, commit-and-push cadence, the fast gate, browser smoke for user-facing changes, self-review via launch-code-review, commit conventions.
344
+ 6. Pre-land sync: merge the latest origin/${pre.base} into your branch. Conflicts are ordinary work — resolve them with the launch-resolving-merge-conflicts skill. If ${pre.base} gained DB migrations since you branched, regenerate yours to follow them with the project's migration tool — never hand-edit the migration journal. Re-run the fast gate if anything changed, then push.
345
+ 7. Hand off: confirm the tip is pushed (\`git ls-remote origin refs/heads/<branch>\` equals \`git rev-parse HEAD\`) and report status "ready" with branch, headSha, and commitTitle — a Conventional Commit title for the squash the loop will make (e.g. "feat(auth): add magic-link sign-in"). Then STOP: the loop lands your branch (a local squash-merge onto ${pre.base} under its own gate run, the push, the explicit issue close). Never push to ${pre.base} yourself, never merge your work into ${pre.base}, and never open a PR — the campaign is released by one PR at the end.
267
346
 
268
347
  ${INTEGRITY}
269
348
 
270
349
  ${IDEMPOTENCY}
271
350
 
272
- Report honestly via the schema: "pr-open" once the PR exists against ${pre.base}; "merged" only when an adopted PR turned out to be already merged; "blocked" when a declared blocker had not landed; "verify-failed" when the verification gate would not go green; "conflict" when a conflict was too ambiguous to resolve without losing behavior (say which files and why); "failed" otherwise, with a summary a fresh retry can act on. List deliberately-out-of-scope discoveries in "punted".`
351
+ Report honestly via the schema: "ready" once the branch is pushed with the fast gate green; "already-done" when the ticket was closed already; "blocked" when a declared blocker had not landed; "verify-failed" when the fast gate would not go green; "conflict" when a conflict was too ambiguous to resolve without losing behavior (say which files and why); "failed" otherwise, with a summary a fresh retry can act on. List deliberately-out-of-scope discoveries in "punted".`
273
352
  }
274
353
 
275
- function gatePrompt(pre, ticket, build, waited = 0) {
276
- return `You are the merge gate for ticket #${ticket.number}: PR #${build.pr} is open against ${pre.base}.
354
+ // The lander is the loop's own gate: it re-runs the gate on the merged tree in THIS
355
+ // checkout (warm caches, seconds to minutes), never trusting the builder's word, and
356
+ // pushes only a green base. One land at a time — the script serializes them.
357
+ function landPrompt(pre, subject, full) {
358
+ const gate = full ? `the FULL gate: ${pre.verifyCommand}` : `the FAST gate: ${pre.fastGateCommand}`
359
+ const what = subject.repair
360
+ ? `a repair of the red base`
361
+ : `ticket #${subject.number} ("${subject.title}")`
362
+ const title = subject.commitTitle || (subject.repair ? `fix: repair ${pre.base}` : `feat: ${subject.title}`)
363
+ const trailer = subject.repair ? '' : ` -m "Closes #${subject.number}"`
364
+ return `You are landing ${what}: the implementer pushed branch ${subject.branch} (head ${subject.headSha || 'see remote'}) and reports the fast gate green there. You own the local squash-merge onto ${pre.base}, the loop's own gate run, the push, and the tracker bookkeeping — nothing else. You never write code and never repair a branch: a squash that conflicts or a gate that fails is reported, not fixed here. Work in THIS checkout (no worktree); exactly one land runs at a time, so nothing else touches it while you do.
277
365
  Tracker access from this environment: ${pre.trackerAccess}
278
- You own the CI wait, the squash-merge, and the tracker bookkeeping — and nothing else. You never write code, never push commits, never repair a failing branch; a failing PR is reported, not fixed here.
279
- ${waited > 0 ? `This is CI re-poll ${waited}: the CI was still running when the gate last looked, so the loop sent you back — it has very likely finished by now. Start again from step 1.\n` : ''}1. Check the PR's CI, if the repository has it. You have ONE turn and cannot idle-wait — a background sleep will not resume you, so do NOT try to sit on a long wait. Poll a handful of times, spacing the checks with the Monitor tool (never a bare sleep, never a busy loop), across the minute or two you can cover, then act on what you see: green → step 2; still in progress when your turn is ending → report status "ci-timeout" and STOP. A "ci-timeout" is not a failure — the loop simply re-polls you, cheaply, until CI lands; never merge on a run that has not finished green.
280
- 2. CI green (or absent): check mergeability against ${pre.base} — the base may have moved since CI started. Mergeable: squash-merge via the tracker API; if the base moves between check and merge, re-check and retry up to 3 times. A real conflict is status "not-mergeable" — name the conflicting files if the API reports them.
281
- 3. Merged: read issue #${ticket.number} back and close it explicitly if it is still open — "Closes #n" only auto-fires from the default branch${POLICY.target ? ', and this run does not merge there' : ', and squash-merge does not reliably fire it even there'} — then remove the ralph:building label. Report status "merged" with the merge commit sha.
282
- 4. CI failed on the PR: report status "ci-failed" with the failing check and a summary a fresh implementer can act on. Fix nothing.`
366
+ 1. Preconditions: \`git status --porcelain\` must show no modified or staged files (untracked files are fine) — a dirty tree is status "failed" ("dirty checkout"); never stash or discard anything. Then \`git fetch origin ${pre.base} ${subject.branch}\`.
367
+ 2. Check out the base: \`git checkout ${pre.base}\` (or \`git checkout -b ${pre.base} --track origin/${pre.base}\` when no local branch exists), then \`git merge --ff-only origin/${pre.base}\`. If the fast-forward is refused, the local ${pre.base} has diverged from the remote — report status "failed" saying so; do not reset it.
368
+ 3. Record whether the base moved since the implementer synced: \`git merge-base --is-ancestor origin/${pre.base} origin/${subject.branch}\` — exit 0 means the branch already contains the base tip (baseMoved: false); otherwise baseMoved: true.
369
+ 4. Squash-merge: \`git merge --squash origin/${subject.branch}\`. On conflicts, restore the exact clean state of step 2 with \`git reset --hard origin/${pre.base}\` and report status "conflict" naming the conflicting files. Otherwise commit: \`git commit -m ${JSON.stringify(subject.repair ? title : `${title} (#${subject.number})`)}${trailer} -m "Landed by the Ralph loop from ${subject.branch}@${subject.headSha || 'HEAD'}"\`. Nothing to commit → status "failed" (the branch adds nothing to the base).
370
+ 5. If the landed change touched a dependency manifest or lockfile (\`git diff --name-only HEAD~1 HEAD\`), run the install command first: ${pre.installCommand}. Then run ${gate}. It must exit 0. On failure, undo the landing with \`git reset --hard origin/${pre.base}\` and report status "gate-failed" with the failing checks/tests and their key lines — enough for a fresh implementer to act on. A red base is never pushed.
371
+ 6. \`git push origin ${pre.base}\`. A rejected (non-fast-forward) push means the remote moved outside this loop: \`git fetch origin ${pre.base} && git reset --hard origin/${pre.base}\` and redo steps 3–6 ONCE; a second rejection is status "stale".
372
+ 7. Confirm on the remote: \`git ls-remote origin refs/heads/${pre.base}\` must equal \`git rev-parse HEAD\`. That sha is mergeCommit.${
373
+ subject.repair
374
+ ? ''
375
+ : `
376
+ 8. Tracker bookkeeping: close issue #${subject.number} explicitly (auto-close never fires off the default branch) and read it back closed; remove the ralph:building label; post one comment: "Landed on ${pre.base} at <sha> (squash of ${subject.branch}) by the Ralph loop." Report status "landed" with mergeCommit and issueClosed.`
377
+ }
378
+ Report "landed" only after step 7 confirmed the remote; every other outcome by its status, with a summary the loop can act on.`
283
379
  }
284
380
 
285
- function verifyPrompt(pre, ticket, build) {
286
- return `Establish ground truth for ticket #${ticket.number} using the tracker API only.
381
+ function verifyPrompt(pre, ticket, land) {
382
+ return `Establish ground truth for ticket #${ticket.number} using the tracker/API tools only.
287
383
  Tracker access: ${pre.trackerAccess}
288
- An implementer claims it merged via PR #${build.pr}${build.mergeCommit ? ` (merge commit ${build.mergeCommit})` : ''}.
289
- Check, against the remote: (1) the PR exists and is merged; (2) its merge commit actually appears in ${pre.base}'s history; (3) the issue is closed.
290
- Report merged: true only when (1) and (2) both hold. A PR description or comment is NOT evidence — only API state counts. Do not run local git, do not use a shell. Fix nothing, close nothing; report only.`
384
+ The loop reports it landed on ${pre.base} as commit ${land.mergeCommit}.
385
+ Check, against the remote: (1) commit ${land.mergeCommit} exists in the repository and is on branch ${pre.base} (list the branch's recent commits, or compare the branch with the sha); (2) issue #${ticket.number} is closed.
386
+ Report landed: true only when (1) holds, issueClosed from (2). A comment or a report is NOT evidence — only API state counts. Do not run local git, do not use a shell. Fix nothing, close nothing; report only.`
387
+ }
388
+
389
+ function checkpointPrompt(pre, k, suspects) {
390
+ return `Full verification checkpoint ${k} for the Ralph loop's base ${pre.base}, in THIS checkout (exactly one land or checkpoint runs at a time, so nothing else touches it). Fix nothing.
391
+ Since the last green checkpoint these tickets landed: ${suspects.map((t) => `#${t.number} (${t.title})`).join(', ') || 'none'}.
392
+ 1. \`git fetch origin ${pre.base}\`; \`git checkout ${pre.base}\` and \`git merge --ff-only origin/${pre.base}\`; record \`git rev-parse HEAD\` as headSha.
393
+ 2. Run the install command (${pre.installCommand}) so the tree's dependencies are current, then the FULL verification gate: ${pre.verifyCommand}. Report the actual exit code, not the reassuring summary line.
394
+ green means: the gate exited 0. On red, list every failing check or test with its key lines in failures — a repair implementer will act on exactly that.`
395
+ }
396
+
397
+ function repairPrompt(pre, cp, suspects, k) {
398
+ return `${preamble(pre)}
399
+
400
+ The Ralph loop's base ${pre.base} is RED at ${cp.headSha}: the full verification gate failed at checkpoint ${k} after these tickets landed since the last green base (${cp.greenSha || 'the run start'}): ${suspects.map((t) => `#${t.number} (${t.title}, landed as ${t.mergeCommit})`).join(', ') || 'none'}.
401
+ Failures: ${(cp.failures ?? []).join(' | ') || cp.summary}
402
+
403
+ Repair the base through to a pushed, green branch:
404
+ 1. Branch from a fresh fetch and push at once: \`git fetch origin ${pre.base} && git checkout -b ralph/repair-${k}-<short-slug> origin/${pre.base} && git push -u origin HEAD\`. Never check out ${pre.base} itself in this worktree.
405
+ 2. Reproduce the failure — the failing test files first, the full gate if needed — and find the root cause among the listed landings (\`git log ${cp.greenSha || ''}..${cp.headSha}\`): an integration break between two tickets, a migration-number collision, an e2e spec a landing invalidated. Fix it properly; regenerate colliding migrations with the project's migration tool.
406
+ 3. Make the fast gate green, then the FULL gate (${pre.verifyCommand}) green — this repair lands under the full gate. Self-review via the launch-code-review skill, commit conventionally, push after every green step.
407
+ 4. Hand off: report status "ready" with branch, headSha, and a commitTitle like "fix(<scope>): <what>". Then STOP — the loop lands it. Never push to ${pre.base} yourself.
408
+ Report "failed" with the one fact a human must know if the base cannot be repaired without losing behavior.
409
+
410
+ ${INTEGRITY}`
411
+ }
412
+
413
+ function releasePrompt(pre, opts) {
414
+ const gateStep = opts.baseRed
415
+ ? `2. The base is known RED (${opts.baseRedReason}) — skip the gate and report verified: false with that reason in failures.`
416
+ : opts.needsGate
417
+ ? `2. Run the install command (${pre.installCommand}), then the FULL verification gate: ${pre.verifyCommand}. Report the actual exit code.`
418
+ : `2. The full gate already passed at exactly this tip (${opts.greenSha}) during the run — report verified: true without re-running it, unless the tip you synced differs, in which case run ${pre.verifyCommand}.`
419
+ const pruneStep =
420
+ opts.prune.length > 0
421
+ ? `
422
+ 3. Prune the remote branches of the verified-landed tickets, and ONLY these: ${opts.prune.join(', ')} (\`git push origin --delete <branch>\` each; a branch already gone is fine). Never delete any other branch. Report prunedBranches.`
423
+ : ''
424
+ return `Release verification for a finished Ralph loop run, in THIS checkout. Fix nothing.
425
+ 1. Sync a fresh ${pre.base} (\`git fetch origin ${pre.base}\`, \`git checkout ${pre.base}\`, \`git merge --ff-only origin/${pre.base}\`) and record its head sha.
426
+ ${gateStep}${pruneStep}
427
+ verified means: the full verification gate is green on the final base.`
291
428
  }
292
429
 
293
430
  const graphPrompt = (pre) => `List the open, ready tickets for a Ralph loop run. Change nothing on the tracker.
@@ -295,7 +432,8 @@ Tracker access: ${pre.trackerAccess}
295
432
  Include every open ticket labeled ready-for-agent, excluding any labeled needs-info.
296
433
  An open issue wearing ready-for-agent that is plainly not an implementable ticket — a published spec, research notes, an epic — is a labeling error: leave it out of tickets and report its number in notTickets instead. When in doubt, include it as a ticket.
297
434
  For each, report its number, its exact title, and its "Blocked by" line copied VERBATIM (the whole line, e.g. "**Blocked by:** #11, #9"), or "" when it has none. If the tracker records blocking through native relations instead of a body line, render those relations as one "Blocked by: #n, #m" line and nothing else.
298
- Do NOT interpret, resolve, or filter the edges — copy the characters and let the caller parse the #n. Getting a blocker wrong dispatches a ticket before its dependency lands.`
435
+ Do NOT interpret, resolve, or filter the edges — copy the characters and let the caller parse the #n. Getting a blocker wrong dispatches a ticket before its dependency lands.
436
+ Also flag migration: true for a ticket that plainly adds or changes a database schema or migration (a new table, column, or migration file) — false otherwise or when unsure. The loop only uses it to run such tickets one at a time.`
299
437
 
300
438
  // A non-ticket wearing ready-for-agent (a published spec, research notes) is excluded from
301
439
  // the frontier but never silently: the label is the bug, and the supervisor should fix it.
@@ -311,6 +449,7 @@ function parseGraph(graph) {
311
449
  return (graph?.tickets ?? []).map((t) => ({
312
450
  number: t.number,
313
451
  title: t.title,
452
+ migration: t.migration === true,
314
453
  blockedBy: [...(t.blockedByLine ?? '').matchAll(/#(\d+)/g)]
315
454
  .map((m) => Number(m[1]))
316
455
  .filter((n) => n !== t.number),
@@ -318,130 +457,226 @@ function parseGraph(graph) {
318
457
  }
319
458
 
320
459
  // ---------------------------------------------------------------------------
321
- // Stages
460
+ // State
322
461
  // ---------------------------------------------------------------------------
323
- const state = new Map() // number -> { ticket, attempts, defers, failures[], status, pr, mergeCommit, punted[] }
462
+ // number -> { ticket, attempts, defers, resyncs, failures[], status, branch, mergeCommit, punted[], waiting }
463
+ // status: pending | landed | done-before | external | parked | held
464
+ const state = new Map()
324
465
 
325
466
  function entry(ticket) {
326
467
  if (!state.has(ticket.number)) {
327
- state.set(ticket.number, { ticket, attempts: 0, defers: 0, failures: [], status: 'pending', punted: [] })
468
+ state.set(ticket.number, {
469
+ ticket,
470
+ attempts: 0,
471
+ defers: 0,
472
+ resyncs: 0,
473
+ failures: [],
474
+ status: 'pending',
475
+ branch: '',
476
+ mergeCommit: '',
477
+ punted: [],
478
+ waiting: false,
479
+ })
328
480
  }
329
481
  return state.get(ticket.number)
330
482
  }
331
483
 
332
- async function drive(pre, ticket) {
333
- const s = entry(ticket)
334
- s.attempts += 1
335
- const build = await agent(buildPrompt(pre, ticket, s.attempts, s.failures.join(' | ')), {
336
- label: `build:#${ticket.number}${s.attempts > 1 ? ':retry' : ''}`,
337
- phase: 'Build',
338
- schema: BUILD_SCHEMA,
339
- isolation: 'worktree', // parallel implementers must never fight over one checkout
484
+ let builds = 0 // every build-type dispatch: attempts, retries, deferrals, re-syncs, repairs
485
+ let landedCount = 0 // verified lands
486
+ const landedSinceGreen = [] // { number, title, mergeCommit } landed since the last green full gate
487
+ let sinceCheckpoint = 0
488
+ let lastGreenSha = ''
489
+ let baseTip = ''
490
+ let baseRed = false
491
+ let baseRedReason = ''
492
+ let checkpointRuns = 0
493
+ const checkpoints = [] // every full-gate verdict on the base, repairs included
494
+
495
+ // The land lock: exactly one land or checkpoint touches the main checkout at a time.
496
+ // A promise chain, so it needs no timers and survives a resumed run unchanged.
497
+ let landLock = Promise.resolve()
498
+ function withLandLock(fn) {
499
+ const run = landLock.then(fn, fn)
500
+ landLock = run.then(
501
+ () => {},
502
+ () => {},
503
+ )
504
+ return run
505
+ }
506
+
507
+ // ---------------------------------------------------------------------------
508
+ // Stages
509
+ // ---------------------------------------------------------------------------
510
+
511
+ // A green full gate on the base: after every POLICY.checkpointEvery lands, and whenever
512
+ // a repair lands. Called holding the land lock. A red checkpoint gets exactly one repair
513
+ // dispatch (a fresh implementer with the failures and the suspects); if that does not land
514
+ // green, the base is declared red and nothing lands until a human looks — built tickets
515
+ // keep their pushed branches and are reported as held.
516
+ async function checkpoint(pre) {
517
+ checkpointRuns += 1
518
+ const k = checkpointRuns
519
+ const suspects = [...landedSinceGreen]
520
+ const cp = await agent(checkpointPrompt(pre, k, suspects), {
521
+ label: `checkpoint:${k}`,
522
+ phase: 'Checkpoint',
523
+ schema: CHECKPOINT_SCHEMA,
524
+ effort: 'low',
340
525
  })
341
- if (!build) {
342
- s.failures.push('implementer died (infrastructure)')
343
- return { ticket, ok: false, dead: true }
526
+ if (!cp) {
527
+ log(`checkpoint ${k}: agent died (infrastructure) — the next land re-runs it`)
528
+ return
344
529
  }
345
- s.punted.push(...(build.punted ?? []))
346
- if (build.status === 'already-done') {
347
- s.status = 'merged'
348
- return { ticket, ok: true }
530
+ checkpoints.push({ k, headSha: cp.headSha, green: cp.green, suspects: suspects.map((t) => t.number) })
531
+ if (cp.green) {
532
+ lastGreenSha = cp.headSha
533
+ sinceCheckpoint = 0
534
+ landedSinceGreen.length = 0
535
+ log(`checkpoint ${k}: full gate green at ${cp.headSha}`)
536
+ return
349
537
  }
350
- if (build.status === 'blocked') {
351
- // A declared blocker had not actually landed — the frontier's view was stale, or the
352
- // blocker is open but outside the ready set. Hand the attempt back: a deferral is not
353
- // a failure. Capped so a permanently missing dependency still parks eventually.
354
- s.defers += 1
355
- if (s.defers <= POLICY.attempts) {
356
- s.attempts -= 1
357
- return { ticket, ok: false, deferred: true, why: build.failure ?? build.summary }
538
+ log(`checkpoint ${k}: full gate RED at ${cp.headSha} after ${suspects.map((t) => `#${t.number}`).join(', ') || 'no lands'} — dispatching one repair`)
539
+ builds += 1
540
+ const fix = await agent(repairPrompt(pre, { ...cp, greenSha: lastGreenSha }, suspects, k), {
541
+ label: `repair:${k}`,
542
+ phase: 'Build',
543
+ schema: BUILD_SCHEMA,
544
+ isolation: 'worktree',
545
+ })
546
+ if (fix && fix.status === 'ready' && fix.branch) {
547
+ const land = await agent(landPrompt(pre, { repair: true, ...fix }, true), {
548
+ label: `land:repair:${k}`,
549
+ phase: 'Land',
550
+ schema: LAND_SCHEMA,
551
+ effort: 'low',
552
+ })
553
+ if (land && land.status === 'landed') {
554
+ lastGreenSha = land.mergeCommit
555
+ baseTip = land.mergeCommit
556
+ sinceCheckpoint = 0
557
+ landedSinceGreen.length = 0
558
+ checkpoints.push({ k, headSha: land.mergeCommit, green: true, suspects: [], repair: fix.branch })
559
+ log(`repair landed at ${land.mergeCommit} under the full gate — base green again`)
560
+ return
358
561
  }
359
- s.failures.push(`still blocked after ${s.defers} deferrals: ${build.failure ?? build.summary}`)
360
- return { ticket, ok: false }
361
- }
362
- if (build.status !== 'pr-open' && build.status !== 'merged') {
363
- s.failures.push(`[attempt ${s.attempts}] ${build.status}: ${build.failure ?? build.summary}`)
364
- return { ticket, ok: false }
562
+ baseRedReason = `repair branch ${fix.branch} did not land: ${land ? `${land.status}: ${land.summary}` : 'lander died'}`
563
+ } else {
564
+ baseRedReason = `repair did not produce a branch: ${fix ? (fix.failure ?? fix.summary) : 'repair agent died'}`
365
565
  }
366
- if (!build.pr) {
367
- s.failures.push(`[attempt ${s.attempts}] reported ${build.status} but returned no PR number`)
368
- return { ticket, ok: false }
369
- }
370
- let mergeCommit = build.mergeCommit
371
- if (build.status === 'pr-open') {
372
- // The loop owns the merge gate (ADR-0022): an implementer cannot wait on CI (a
373
- // subagent's background sleep never resumes it), and a single gate owner keeps
374
- // merge ordering sane. A failing gate hands the ticket back as a failed attempt;
375
- // the fresh retry adopts the PR via the idempotency clause, repairs, hands off again.
376
- //
377
- // But the gate agent cannot idle-wait either — given one turn it can only poll CI a
378
- // few times, so a PR whose CI is still running comes back "ci-timeout": not done
379
- // yet, not a failure of the code. Re-poll the cheap gate in place (reads + at most
380
- // one merge, effort low) up to POLICY.gateWaits times before the ticket falls back
381
- // to a fresh implementer attempt, so a slow CI can never cost a full rebuild
382
- // (ADR-0027). Only a real verdict — merged, ci-failed, not-mergeable — leaves the
383
- // loop early; a CI that never lands still parks the ticket once re-polls run out.
384
- let gate
385
- for (let waited = 0; ; waited += 1) {
386
- gate = await agent(gatePrompt(pre, ticket, build, waited), {
387
- label: `gate:#${ticket.number}${waited > 0 ? `:ci-wait${waited}` : ''}`,
388
- phase: 'Gate',
389
- schema: GATE_SCHEMA,
566
+ baseRed = true
567
+ log(`base RED at ${cp.headSha} and the repair did not land (${baseRedReason}) — no further lands; finished tickets stay on their pushed branches as held`)
568
+ }
569
+
570
+ // Land a finished branch: one at a time, in the main checkout, under the fast gate.
571
+ function landTicket(pre, ticket, build) {
572
+ return withLandLock(async () => {
573
+ if (baseRed) return { status: 'held', summary: baseRedReason }
574
+ const land = await agent(landPrompt(pre, { number: ticket.number, title: ticket.title, ...build }, false), {
575
+ label: `land:#${ticket.number}${build.resyncLabel ?? ''}`,
576
+ phase: 'Land',
577
+ schema: LAND_SCHEMA,
578
+ effort: 'low',
579
+ })
580
+ if (land && land.status === 'landed') {
581
+ baseTip = land.mergeCommit
582
+ sinceCheckpoint += 1
583
+ landedSinceGreen.push({ number: ticket.number, title: ticket.title, mergeCommit: land.mergeCommit })
584
+ if (POLICY.checkpointEvery > 0 && sinceCheckpoint >= POLICY.checkpointEvery) await checkpoint(pre)
585
+ }
586
+ return land
587
+ })
588
+ }
589
+
590
+ async function drive(pre, ticket, pushed) {
591
+ const s = entry(ticket)
592
+ let resync = null
593
+ for (;;) {
594
+ s.attempts += 1
595
+ builds += 1
596
+ const label = `build:#${ticket.number}${s.attempts > 1 ? ':retry' : ''}${resync ? `:resync${s.resyncs}` : ''}`
597
+ const build = await agent(buildPrompt(pre, ticket, s, pushed, resync), {
598
+ label,
599
+ phase: 'Build',
600
+ schema: BUILD_SCHEMA,
601
+ isolation: 'worktree', // parallel implementers must never fight over one checkout
602
+ })
603
+ if (!build) {
604
+ s.failures.push('implementer died (infrastructure)')
605
+ return { ticket, ok: false, dead: true }
606
+ }
607
+ s.punted.push(...(build.punted ?? []))
608
+ if (build.branch) s.branch = build.branch
609
+ if (build.status === 'already-done') {
610
+ s.status = 'done-before'
611
+ return { ticket, ok: false, doneBefore: true }
612
+ }
613
+ if (build.status === 'blocked') {
614
+ // A declared blocker had not actually landed — the frontier's view was stale, or the
615
+ // blocker is open but outside the ready set. Hand the attempt back: a deferral is not
616
+ // a failure. Capped so a permanently missing dependency still parks eventually.
617
+ s.defers += 1
618
+ if (s.defers <= POLICY.attempts) {
619
+ s.attempts -= 1
620
+ return { ticket, ok: false, deferred: true, why: build.failure ?? build.summary }
621
+ }
622
+ s.failures.push(`still blocked after ${s.defers} deferrals: ${build.failure ?? build.summary}`)
623
+ return { ticket, ok: false }
624
+ }
625
+ if (build.status !== 'ready') {
626
+ s.failures.push(`[attempt ${s.attempts}] ${build.status}: ${build.failure ?? build.summary}`)
627
+ return { ticket, ok: false }
628
+ }
629
+ if (!build.branch) {
630
+ s.failures.push(`[attempt ${s.attempts}] reported ready but returned no branch`)
631
+ return { ticket, ok: false }
632
+ }
633
+ // The loop owns the landing (ADR-0022, ADR-0032): a local squash-merge onto the base
634
+ // under the fast gate, one at a time. No PR, no CI wait — the builder's branch is
635
+ // pushed, so nothing is lost whatever happens next.
636
+ const land = await landTicket(pre, ticket, { ...build, resyncLabel: resync ? `:resync${s.resyncs}` : '' })
637
+ if (!land) {
638
+ s.failures.push('lander died (infrastructure)')
639
+ return { ticket, ok: false, dead: true }
640
+ }
641
+ if (land.status === 'held') {
642
+ s.status = 'held'
643
+ return { ticket, ok: false, held: true }
644
+ }
645
+ if (land.status === 'landed') {
646
+ // Nothing is trusted from a report — a claimed land is checked against the remote
647
+ // by a separate, cheap agent with tracker access only.
648
+ const verdict = await agent(verifyPrompt(pre, ticket, land), {
649
+ label: `verify:#${ticket.number}`,
650
+ phase: 'Verify',
651
+ schema: VERIFY_SCHEMA,
652
+ model: 'haiku',
390
653
  effort: 'low',
391
654
  })
392
- if (!gate) {
393
- s.failures.push('gate agent died (infrastructure)')
394
- return { ticket, ok: false, dead: true }
655
+ if (verdict?.landed && verdict.issueClosed) {
656
+ s.status = 'landed'
657
+ s.mergeCommit = verdict.mergeCommit || land.mergeCommit
658
+ return { ticket, ok: true }
395
659
  }
396
- if (gate.status !== 'ci-timeout' || waited >= POLICY.gateWaits) break
397
- log(`#${ticket.number}: CI still running on PR #${build.pr} — re-polling the gate (${waited + 1}/${POLICY.gateWaits})`)
398
- }
399
- if (gate.status !== 'merged') {
400
- s.failures.push(`[attempt ${s.attempts}] PR #${build.pr} ${gate.status}: ${gate.summary}`)
660
+ // Landed-but-issue-open fails verification too: the retry finds the ticket's work on
661
+ // the base (empty squash) or the issue open, finishes the bookkeeping, and settles.
662
+ s.failures.push(`[attempt ${s.attempts}] claimed landed, remote disagrees: ${verdict ? verdict.evidence : 'verifier died'}`)
401
663
  return { ticket, ok: false }
402
664
  }
403
- mergeCommit = gate.mergeCommit || mergeCommit
404
- }
405
- // Nothing is trusted from a report — a claimed merge is checked against the remote
406
- // by a separate, cheap agent with tracker access only.
407
- const verdict = await agent(verifyPrompt(pre, ticket, { pr: build.pr, mergeCommit }), {
408
- label: `verify:#${ticket.number}`,
409
- phase: 'Verify',
410
- schema: VERIFY_SCHEMA,
411
- model: 'haiku',
412
- effort: 'low',
413
- })
414
- if (verdict?.merged && verdict.issueClosed) {
415
- s.status = 'merged'
416
- s.pr = build.pr
417
- s.mergeCommit = verdict.mergeCommit || mergeCommit
418
- return { ticket, ok: true }
665
+ // The base moved under a finished branch: a conflict, a gate that only fails on the
666
+ // merged tree, or a remote that kept moving. The builder did nothing wrong — hand the
667
+ // pushed branch to a fresh implementer to re-sync, without spending an attempt.
668
+ const integration = land.status === 'conflict' || land.status === 'stale' || (land.status === 'gate-failed' && land.baseMoved === true)
669
+ if (integration && s.resyncs < POLICY.resyncs) {
670
+ s.resyncs += 1
671
+ s.attempts -= 1
672
+ resync = `${land.status}: ${land.summary}`
673
+ log(`#${ticket.number}: land hand-back (${land.status}) — re-syncing the pushed branch (${s.resyncs}/${POLICY.resyncs}), no attempt spent`)
674
+ pushed = { branch: build.branch, sha: build.headSha ?? '' }
675
+ continue
676
+ }
677
+ s.failures.push(`[attempt ${s.attempts}] land ${land.status}: ${land.summary}`)
678
+ return { ticket, ok: false }
419
679
  }
420
- // Merged-but-issue-open fails verification too: the retry adopts the merged PR (the
421
- // idempotency clause), closes the issue explicitly, and the ticket settles cleanly.
422
- s.failures.push(
423
- `[attempt ${s.attempts}] claimed merged, remote disagrees: ${verdict ? verdict.evidence : 'verifier died'}`,
424
- )
425
- return { ticket, ok: false }
426
- }
427
-
428
- // A ticket is ready when it isn't settled, hasn't exhausted its attempts, every blocker
429
- // is closed-before-the-run or merged-by-us, and no blocker is parked. Pure code — the
430
- // orchestrator never asks a subagent what's ready. The implementer's dependency gate is
431
- // the backstop for what this check cannot see (a blocker that is open but never entered
432
- // the ready set).
433
- function frontier(tickets, closedBefore) {
434
- return tickets.filter((t) => {
435
- const s = entry(t)
436
- if (s.status !== 'pending') return false
437
- if (s.attempts >= POLICY.attempts) return false
438
- if (POLICY.only.length > 0 && !POLICY.only.includes(t.number)) return false
439
- return t.blockedBy.every((b) => {
440
- if (closedBefore.has(b)) return true
441
- const blocker = state.get(b)
442
- return blocker !== undefined && blocker.status === 'merged'
443
- })
444
- })
445
680
  }
446
681
 
447
682
  // ---------------------------------------------------------------------------
@@ -449,16 +684,22 @@ function frontier(tickets, closedBefore) {
449
684
  // ---------------------------------------------------------------------------
450
685
  phase('Preflight')
451
686
  const pre = await agent(
452
- `Preflight for a Ralph loop run in this repository. Report actual state; fix nothing — the one permitted mutation is creating the declared integration branch in step 2.
687
+ `Preflight for a Ralph loop run in THIS checkout — the loop lands tickets here (one land at a time), while builders use their own worktrees. Report actual state; fix nothing — the permitted mutations are creating the declared integration branch (step 2) and syncing this checkout onto the base.
453
688
  1. Read .launchrail.yml (issueTracker, testing commands, modules) and AGENTS.md (verbatim commands).
454
689
  2. Identify the repo (git remote) and its default branch; report the default branch name as defaultBranch. ${
455
690
  POLICY.target
456
691
  ? `This run consolidates onto the integration branch "${POLICY.target}" — that branch is the base. If it does not exist on the remote, create it from the default branch's tip (no force; the default branch itself is never touched) and report targetCreated: true. A missing DEFAULT branch is still not green — do not guess.`
457
- : `This run merges into the default branch (trunk) — that branch is the base. If it does not exist on the remote, report not green and say the base is missing — do not guess another branch.`
458
- } Sync the base fresh (clean tree) and report its name as base.
692
+ : `This run lands onto the default branch (trunk) — that branch is the base. If it does not exist on the remote, report not green and say the base is missing — do not guess another branch.`
693
+ } Sync the base INTO THIS CHECKOUT: the tree must be clean (\`git status --porcelain\` shows no modified or staged files — untracked files are fine; a dirty tree is not green: say so, stash nothing); \`git fetch origin\`, check out the base (tracking origin) and \`git merge --ff-only origin/<base>\` — a local base that has diverged from origin is not green (say "push or reset it first"). Report the base name as base and its tip as headSha.
459
694
  3. Determine how the tracker is reachable from THIS environment: check whether the CLI the project docs assume (e.g. gh) is installed; if not, name the concrete substitute available here (e.g. GitHub MCP tools) as an instruction future agents can follow.
460
- 4. Run the project's install command, then the verification gate: npx @wemuda/launchrail verify. Report the actual exit codes, not the reassuring summary line. An empty verification contract failing the gate is a refusal condition, not something to work around.
461
- green means: base synced AND the verification gate exited 0.`,
695
+ 4. List in-flight work from previous sessions: \`git ls-remote --heads origin 'ralph/*'\` — report every branch with its sha as pushedBranches (the loop adopts them; delete nothing).
696
+ 5. Run the project's install command (report it verbatim as installCommand). ${
697
+ POLICY.knownGreen
698
+ ? `Then compare headSha with "${POLICY.knownGreen}": if they are EQUAL, a previous run verified the base green at exactly this commit — skip the verification gate, report skippedGate: true and green: true. If they differ, run the FULL gate as below.`
699
+ : 'Then run the FULL verification gate'
700
+ }: npx @wemuda/launchrail verify. Report the actual exit codes, not the reassuring summary line. An empty verification contract failing the gate is a refusal condition, not something to work around.
701
+ Report verifyCommand as "npx @wemuda/launchrail verify" and fastGateCommand as "npx @wemuda/launchrail verify --fast" (the fast tier: testing.checkCommand, else the unit command — never e2e).
702
+ green means: base synced in this checkout AND (the full gate exited 0 OR it was skipped as known green).`,
462
703
  { label: 'preflight', phase: 'Preflight', schema: PREFLIGHT_SCHEMA },
463
704
  )
464
705
  if (!pre) throw new Error('preflight agent died — refusing to start')
@@ -470,18 +711,30 @@ if (!pre.green) {
470
711
  if ((pre.issueTracker ?? 'none') === 'none') {
471
712
  return { refused: true, reason: 'no issue tracker configured (.launchrail.yml issueTracker: none) — Ralph needs tickets' }
472
713
  }
714
+ lastGreenSha = pre.headSha ?? ''
715
+ baseTip = pre.headSha ?? ''
716
+
717
+ // Pushed ralph/<n>-* branches are a previous session's in-flight work; the number in the
718
+ // branch name says whose. The dispatch tells the builder to adopt it — resume costs the
719
+ // minutes since its last push, never a rebuild.
720
+ const pushedByTicket = new Map()
721
+ for (const b of pre.pushedBranches ?? []) {
722
+ const m = /^(?:refs\/heads\/)?ralph\/(\d+)-/.exec(b.branch ?? '')
723
+ if (m) pushedByTicket.set(Number(m[1]), { branch: b.branch.replace(/^refs\/heads\//, ''), sha: b.sha })
724
+ }
473
725
 
474
726
  phase('Graph')
475
727
  log(
476
- `Base green at ${pre.headSha ?? pre.base} on ${pre.base}. ` +
728
+ `Base green at ${pre.headSha ?? pre.base} on ${pre.base}${pre.skippedGate ? ' (known green — gate skipped)' : ''}. ` +
477
729
  (POLICY.target
478
730
  ? `Consolidating onto ${pre.base}${pre.targetCreated ? ' (created from the default branch tip)' : ''}; ${pre.defaultBranch || 'the default branch'} stays untouched. `
479
- : `Trunk mode — each ticket merges into ${pre.base}. `) +
731
+ : `Trunk mode — each ticket lands on ${pre.base}. `) +
480
732
  (POLICY.only.length > 0
481
733
  ? `Scoped to ${POLICY.only.map((n) => `#${n}`).join(', ')}.`
482
734
  : 'No scope — building the whole ready frontier.') +
483
- (POLICY.max > 0 ? ` Stopping after ${POLICY.max} verified merge(s).` : '') +
484
- ` Width ${POLICY.width}${POLICY.canary ? ' (canary: width 1 until the first verified merge)' : ''}, ${POLICY.attempts} attempts per ticket.`,
735
+ (POLICY.max > 0 ? ` Stopping after ${POLICY.max} verified land(s).` : '') +
736
+ ` Width ${POLICY.width}${POLICY.canary ? ' (canary: width 1 until the first verified land)' : ''}, ${POLICY.attempts} attempts per ticket, full gate every ${POLICY.checkpointEvery || 'release-only'} land(s).` +
737
+ (pushedByTicket.size > 0 ? ` Adopting pushed branches for ${[...pushedByTicket.keys()].map((n) => `#${n}`).join(', ')}.` : ''),
485
738
  )
486
739
  let graph = await agent(graphPrompt(pre), { label: 'read-graph', phase: 'Graph', schema: GRAPH_SCHEMA, model: 'haiku', effort: 'low' })
487
740
  if (!graph) throw new Error('graph agent died — refusing to start')
@@ -496,98 +749,195 @@ for (const t of tickets) {
496
749
  }
497
750
  }
498
751
 
499
- const mergedCount = () => [...state.values()].filter((s) => s.status === 'merged').length
752
+ function settled(n) {
753
+ if (closedBefore.has(n)) return true
754
+ const s = state.get(n)
755
+ return s !== undefined && (s.status === 'landed' || s.status === 'done-before' || s.status === 'external')
756
+ }
500
757
 
501
- let rounds = 0
502
- let maxReached = false
503
- while (rounds < POLICY.maxRounds) {
504
- if (budget.total && budget.remaining() < POLICY.reserve) {
505
- log(`token budget at reserve (${Math.round(budget.remaining() / 1000)}k left) — stopping before a new round`)
506
- break
758
+ // Transitive dependents: the tickets that cannot start until this one lands. The frontier
759
+ // dispatches the most-depended-on first, so a wide graph unblocks quickly — pure code.
760
+ function dependents(n) {
761
+ const seen = new Set()
762
+ const queue = [n]
763
+ while (queue.length > 0) {
764
+ const x = queue.pop()
765
+ for (const t of tickets) {
766
+ if (t.blockedBy.includes(x) && !seen.has(t.number)) {
767
+ seen.add(t.number)
768
+ queue.push(t.number)
769
+ }
770
+ }
507
771
  }
508
- // The cap counts verified merges only — a failed or deferred dispatch frees its slot
509
- // for a different ticket next round. Capping the batch at the remainder means even a
510
- // fully successful round cannot overshoot.
511
- const capLeft = POLICY.max > 0 ? POLICY.max - mergedCount() : Infinity
512
- if (capLeft <= 0) {
513
- maxReached = true
514
- log(`cap reached: ${POLICY.max} verified merge(s) — stopping; the rest of the frontier stays ready`)
515
- break
772
+ return seen.size
773
+ }
774
+
775
+ const inFlight = new Map() // number -> promise of a drive() result
776
+
777
+ // A ticket is ready when it isn't settled, isn't in flight or waiting on a state change,
778
+ // hasn't exhausted its attempts, every blocker is closed-before-the-run or landed-by-us,
779
+ // and no blocker is parked or held. Pure code — the orchestrator never asks a subagent
780
+ // what's ready. The implementer's dependency gate is the backstop for what this check
781
+ // cannot see (a blocker that is open but never entered the ready set).
782
+ function frontier() {
783
+ return tickets
784
+ .filter((t) => {
785
+ const s = entry(t)
786
+ if (s.status !== 'pending' || s.waiting || inFlight.has(t.number)) return false
787
+ if (s.attempts >= POLICY.attempts) return false
788
+ if (POLICY.only.length > 0 && !POLICY.only.includes(t.number)) return false
789
+ return t.blockedBy.every(settled)
790
+ })
791
+ .sort((a, b) => dependents(b.number) - dependents(a.number) || a.number - b.number)
792
+ }
793
+
794
+ function unsettledRemain() {
795
+ return tickets.some((t) => {
796
+ const s = entry(t)
797
+ return s.status === 'pending' && s.attempts < POLICY.attempts && (POLICY.only.length === 0 || POLICY.only.includes(t.number))
798
+ })
799
+ }
800
+
801
+ async function refreshGraph(reason) {
802
+ graph = await agent(graphPrompt(pre), { label: `read-graph:${reason}`, phase: 'Graph', schema: GRAPH_SCHEMA, model: 'haiku', effort: 'low' })
803
+ if (!graph) return
804
+ warnNotTickets(graph)
805
+ const fresh = parseGraph(graph)
806
+ for (const t of fresh) {
807
+ if (!tickets.some((x) => x.number === t.number)) tickets.push(t)
516
808
  }
517
- const ready = frontier(tickets, closedBefore)
518
- if (ready.length === 0) break
519
- rounds += 1
520
- // Canary: the first verified merge proves the plumbing end to end (branch, PR, CI,
521
- // merge gate, explicit close); until it lands, dispatch one ticket at a time.
522
- const width = POLICY.canary && mergedCount() === 0 ? 1 : POLICY.width
523
- const batch = ready.slice(0, Math.min(width, capLeft))
524
- log(`round ${rounds}: dispatching ${batch.map((t) => `#${t.number}`).join(', ')} (${ready.length} unblocked)`)
525
- const results = await parallel(batch.map((t) => () => drive(pre, t)))
526
- const landed = results.filter((r) => r?.ok)
527
- for (const r of results.filter((x) => x?.deferred)) {
528
- log(`#${r.ticket.number} deferred (blocker not landed yet): ${r.why}`)
809
+ for (const t of tickets) {
810
+ const still = fresh.some((x) => x.number === t.number)
811
+ const s = state.get(t.number)
812
+ // Ticket left the ready set without us touching it (closed or re-labeled
813
+ // externally): treat it as settled for blockers and never dispatch it.
814
+ if (!still && (!s || (s.status === 'pending' && s.attempts === 0 && !inFlight.has(t.number)))) {
815
+ entry(t).status = 'external'
816
+ closedBefore.add(t.number)
817
+ }
529
818
  }
530
- if (results.every((r) => !r || r.dead)) {
531
- log('every agent in the round died — infrastructure, not tickets; stopping the run')
532
- break
819
+ for (const s of state.values()) s.waiting = false
820
+ }
821
+
822
+ // The work pool: keep `width` builders busy, land each as it finishes, dispatch the next.
823
+ let stopReason = ''
824
+ let maxReached = false
825
+ let deadStreak = 0
826
+ let refreshedAt = -1
827
+ for (;;) {
828
+ const budgetLow = Boolean(budget.total) && budget.remaining() < POLICY.reserve
829
+ // Canary: the first verified land proves the plumbing end to end (branch, push, land,
830
+ // gate, explicit close); until it does, one ticket at a time.
831
+ const width = POLICY.canary && landedCount === 0 ? 1 : POLICY.width
832
+ const migrationInFlight = () => [...inFlight.keys()].some((n) => tickets.find((t) => t.number === n)?.migration)
833
+ while (!baseRed && !budgetLow && inFlight.size < width && builds < POLICY.maxBuilds) {
834
+ const ready = frontier()
835
+ // Migration-adding tickets collide on the next migration number when built in
836
+ // parallel — the loop keeps one such ticket in flight at a time.
837
+ const next = ready.find((t) => !t.migration || !migrationInFlight())
838
+ if (!next) break
839
+ // The cap counts verified lands; in-flight tickets reserve their share so even a
840
+ // fully successful pool cannot overshoot.
841
+ if (POLICY.max > 0 && landedCount + inFlight.size >= POLICY.max) break
842
+ const pushed = pushedByTicket.get(next.number) ?? null
843
+ log(`dispatch #${next.number}${pushed ? ` (adopting ${pushed.branch})` : ''} — ${inFlight.size + 1} in flight, ${ready.length - 1} more ready`)
844
+ inFlight.set(
845
+ next.number,
846
+ drive(pre, next, pushed)
847
+ .catch((err) => ({ ticket: next, ok: false, dead: true, error: String(err) }))
848
+ .then((r) => ({ ...r, number: next.number })),
849
+ )
533
850
  }
534
- for (const t of batch) {
535
- const s = entry(t)
536
- if (s.status !== 'merged' && s.attempts >= POLICY.attempts) s.status = 'parked'
851
+ if (inFlight.size === 0) {
852
+ if (baseRed) {
853
+ stopReason = 'base red'
854
+ break
855
+ }
856
+ if (budgetLow) {
857
+ stopReason = `token budget at reserve (${Math.round(budget.remaining() / 1000)}k left)`
858
+ break
859
+ }
860
+ if (builds >= POLICY.maxBuilds) {
861
+ stopReason = `maxBuilds (${POLICY.maxBuilds}) reached`
862
+ break
863
+ }
864
+ if (POLICY.max > 0 && landedCount >= POLICY.max) {
865
+ maxReached = true
866
+ stopReason = `cap reached: ${POLICY.max} verified land(s) — the rest of the frontier stays ready`
867
+ break
868
+ }
869
+ // Nothing running and nothing ready: a tracker refresh is the only thing that can
870
+ // change that (an externally closed blocker), and it is worth one look per change.
871
+ if (POLICY.refreshGraph && unsettledRemain() && refreshedAt !== landedCount) {
872
+ refreshedAt = landedCount
873
+ await refreshGraph(`l${landedCount}`)
874
+ continue
875
+ }
876
+ stopReason = unsettledRemain() ? 'frontier stuck — remaining tickets wait on parked, held, or open blockers' : 'frontier drained'
877
+ break
537
878
  }
538
- log(`round ${rounds}: ${landed.length}/${batch.length} verified merged`)
539
- if (POLICY.refreshGraph && frontier(tickets, closedBefore).length > 0) {
540
- graph = await agent(graphPrompt(pre), { label: `read-graph:r${rounds}`, phase: 'Graph', schema: GRAPH_SCHEMA, model: 'haiku', effort: 'low' })
541
- if (graph) {
542
- warnNotTickets(graph)
543
- const fresh = parseGraph(graph)
544
- for (const t of fresh) {
545
- if (!tickets.some((x) => x.number === t.number)) tickets.push(t)
546
- }
547
- for (const t of tickets) {
548
- const still = fresh.some((x) => x.number === t.number)
549
- const s = state.get(t.number)
550
- // Ticket left the ready set without us touching it (closed or re-labeled
551
- // externally): treat it as settled for blockers and never dispatch it.
552
- if (!still && (!s || (s.status === 'pending' && s.attempts === 0))) {
553
- entry(t).status = 'external'
554
- closedBefore.add(t.number)
555
- }
556
- }
879
+ const done = await Promise.race([...inFlight.values()])
880
+ inFlight.delete(done.number)
881
+ const s = entry(done.ticket)
882
+ if (done.dead) {
883
+ deadStreak += 1
884
+ log(`#${done.number}: agent died (infrastructure)${done.error ? ` — ${done.error}` : ''}`)
885
+ if (deadStreak >= 3) {
886
+ stopReason = 'three agents in a row died — infrastructure, not tickets'
887
+ break
557
888
  }
889
+ } else {
890
+ deadStreak = 0
891
+ }
892
+ if (done.ok) {
893
+ landedCount += 1
894
+ log(`#${done.number} landed on ${pre.base} at ${s.mergeCommit} (verified) — ${landedCount} landed`)
895
+ for (const other of state.values()) other.waiting = false // a land is the state change deferred tickets wait for
896
+ } else if (done.doneBefore) {
897
+ log(`#${done.number} was already closed — settled without a land`)
898
+ for (const other of state.values()) other.waiting = false
899
+ } else if (done.deferred) {
900
+ s.waiting = true
901
+ log(`#${done.number} deferred (blocker not landed yet): ${done.why}`)
902
+ } else if (done.held) {
903
+ log(`#${done.number} built and pushed on ${s.branch} but NOT landed — base red; a relaunch adopts it`)
904
+ }
905
+ if (s.status === 'pending' && s.attempts >= POLICY.attempts) {
906
+ s.status = 'parked'
907
+ log(`#${done.number} parked after ${s.attempts} attempts: ${s.failures[s.failures.length - 1]}`)
558
908
  }
559
909
  }
910
+ log(`stopping: ${stopReason}`)
560
911
 
561
- const merged = [...state.values()].filter((s) => s.status === 'merged')
912
+ const landed = [...state.values()].filter((s) => s.status === 'landed')
562
913
  const parked = [...state.values()].filter((s) => s.status === 'parked')
914
+ const held = [...state.values()].filter((s) => s.status === 'held')
563
915
  const stuck = tickets.filter((t) => {
564
916
  const s = state.get(t.number)
565
- return !s || s.status === 'pending'
917
+ return (!s || s.status === 'pending') && (POLICY.only.length === 0 || POLICY.only.includes(t.number))
566
918
  })
567
- const settledNumber = (b) => closedBefore.has(b) || state.get(b)?.status === 'merged'
568
919
 
569
920
  phase('Park')
570
921
  if (parked.length > 0) {
571
922
  await agent(
572
- `On the tracker (${pre.trackerAccess}), for each of these parked tickets: post one comment containing its accumulated failure history verbatim, remove the ralph:building label if present, and add the needs-info label. Change nothing else. Fix nothing.
573
- ${parked.map((s) => `#${s.ticket.number} (${s.ticket.title}): ${s.failures.join(' | ')}`).join('\n')}`,
923
+ `On the tracker (${pre.trackerAccess}), for each of these parked tickets: post one comment containing its accumulated failure history verbatim (and the pushed branch that holds its work, when there is one), remove the ralph:building label if present, and add the needs-info label. Change nothing else. Fix nothing.
924
+ ${parked.map((s) => `#${s.ticket.number} (${s.ticket.title})${s.branch ? ` — branch ${s.branch}` : ''}: ${s.failures.join(' | ')}`).join('\n')}`,
574
925
  { label: 'park', phase: 'Park', schema: { type: 'object', properties: { done: { type: 'boolean' } }, required: ['done'], additionalProperties: false }, model: 'haiku', effort: 'low' },
575
926
  )
576
927
  }
577
928
 
578
929
  // The completion contract: the loop cannot declare success while required
579
- // verification fails on the final, post-merge base.
930
+ // verification fails on the final, post-land base. The last green checkpoint counts
931
+ // when the base has not moved since; otherwise the full gate runs once more here.
580
932
  phase('Release')
581
933
  const release = await agent(
582
- `Release verification for a finished Ralph loop run. Fix nothing.
583
- 1. Sync a fresh ${pre.base} and record its head sha.
584
- 2. Run the verification gate: npx @wemuda/launchrail verify. Report the actual exit code.
585
- ${
586
- pre.browserTesting && merged.length > 0
587
- ? `3. The browser-testing module is enabled: start the app (node scripts/dev.mjs --background), scaffold an evidence bundle (npx @wemuda/launchrail smoke), and drive the smoke journeys from docs/testing/smoke-journeys.md per the launch-browser-smoke skill. Report the bundle path. A journey you could not complete is a failure, never a pass.`
588
- : ''
589
- }
590
- verified means: the verification gate exited 0${pre.browserTesting && merged.length > 0 ? ' AND no smoke journey failed' : ''}.`,
934
+ releasePrompt(pre, {
935
+ baseRed,
936
+ baseRedReason,
937
+ needsGate: baseTip !== lastGreenSha,
938
+ greenSha: lastGreenSha,
939
+ prune: landed.map((s) => s.branch).filter(Boolean),
940
+ }),
591
941
  { label: 'release-verification', phase: 'Release', schema: RELEASE_SCHEMA },
592
942
  )
593
943
 
@@ -595,21 +945,30 @@ verified means: the verification gate exited 0${pre.browserTesting && merged.len
595
945
  // next step, as data — the supervisor relays it, never reconstructs it.
596
946
  const mode = POLICY.target ? 'consolidation' : 'trunk'
597
947
  return {
598
- rounds,
599
- verified: release?.verified ?? false,
948
+ builds,
949
+ stopReason,
950
+ verified: !baseRed && (release?.verified ?? false),
600
951
  maxReached,
601
- target: { mode, base: pre.base, defaultBranch: pre.defaultBranch ?? '', headSha: release?.headSha ?? '' },
952
+ target: { mode, base: pre.base, defaultBranch: pre.defaultBranch ?? '', headSha: release?.headSha ?? baseTip },
953
+ baseRed: baseRed ? baseRedReason : null,
954
+ checkpoints,
602
955
  nextStep:
603
- mode === 'consolidation'
604
- ? `All campaign work is on ${pre.base}; ${pre.defaultBranch || 'the default branch'} is untouched. Release it with one PR ${pre.base} -> ${pre.defaultBranch || 'the default branch'} — offer it, and open it only when the user says so.`
605
- : `Every merged ticket is live on ${pre.base}; nothing is left to integrate.`,
956
+ (mode === 'consolidation'
957
+ ? `All campaign work is on ${pre.base}; ${pre.defaultBranch || 'the default branch'} is untouched. Release it with one PR ${pre.base} -> ${pre.defaultBranch || 'the default branch'} — cloud CI runs there, once — offer it, and open it only when the user says so.`
958
+ : `Every landed ticket is live on ${pre.base}; nothing is left to integrate.`) +
959
+ (baseRed
960
+ ? ` The base is RED (${baseRedReason}): fix it by hand, then relaunch the loop with knownGreen set to the sha you verified — it adopts the held branches.`
961
+ : held.length > 0
962
+ ? ' Relaunch the loop (knownGreen: the last green sha) to land the held tickets.'
963
+ : ''),
606
964
  release,
607
- merged: merged.map((s) => ({ ticket: s.ticket.number, title: s.ticket.title, pr: s.pr, mergeCommit: s.mergeCommit })),
608
- parked: parked.map((s) => ({ ticket: s.ticket.number, title: s.ticket.title, failures: s.failures })),
965
+ landed: landed.map((s) => ({ ticket: s.ticket.number, title: s.ticket.title, mergeCommit: s.mergeCommit, branch: s.branch })),
966
+ held: held.map((s) => ({ ticket: s.ticket.number, title: s.ticket.title, branch: s.branch })),
967
+ parked: parked.map((s) => ({ ticket: s.ticket.number, title: s.ticket.title, branch: s.branch, failures: s.failures })),
609
968
  stuck: stuck.map((t) => ({
610
969
  ticket: t.number,
611
970
  title: t.title,
612
- blockedBy: t.blockedBy.filter((b) => !settledNumber(b)),
971
+ blockedBy: t.blockedBy.filter((b) => !settled(b)),
613
972
  })),
614
973
  followUps: [...state.values()].flatMap((s) => s.punted),
615
974
  }