@wemuda/launchrail 1.0.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.
- package/LICENSE +21 -0
- package/assets/ralph.workflow.js +476 -0
- package/dist/commands/add.d.ts +13 -0
- package/dist/commands/add.js +189 -0
- package/dist/commands/add.js.map +1 -0
- package/dist/commands/diff.d.ts +13 -0
- package/dist/commands/diff.js +44 -0
- package/dist/commands/diff.js.map +1 -0
- package/dist/commands/doctor.d.ts +12 -0
- package/dist/commands/doctor.js +171 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/eject.d.ts +17 -0
- package/dist/commands/eject.js +80 -0
- package/dist/commands/eject.js.map +1 -0
- package/dist/commands/init.d.ts +13 -0
- package/dist/commands/init.js +173 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/smoke.d.ts +21 -0
- package/dist/commands/smoke.js +171 -0
- package/dist/commands/smoke.js.map +1 -0
- package/dist/commands/status.d.ts +15 -0
- package/dist/commands/status.js +91 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/sync.d.ts +19 -0
- package/dist/commands/sync.js +83 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/commands/verify.d.ts +18 -0
- package/dist/commands/verify.js +50 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +127 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/browser-testing.d.ts +16 -0
- package/dist/lib/browser-testing.js +170 -0
- package/dist/lib/browser-testing.js.map +1 -0
- package/dist/lib/checksum.d.ts +1 -0
- package/dist/lib/checksum.js +5 -0
- package/dist/lib/checksum.js.map +1 -0
- package/dist/lib/claudeSettings.d.ts +21 -0
- package/dist/lib/claudeSettings.js +71 -0
- package/dist/lib/claudeSettings.js.map +1 -0
- package/dist/lib/detect.d.ts +23 -0
- package/dist/lib/detect.js +88 -0
- package/dist/lib/detect.js.map +1 -0
- package/dist/lib/diff.d.ts +12 -0
- package/dist/lib/diff.js +99 -0
- package/dist/lib/diff.js.map +1 -0
- package/dist/lib/lockfile.d.ts +25 -0
- package/dist/lib/lockfile.js +44 -0
- package/dist/lib/lockfile.js.map +1 -0
- package/dist/lib/manifest.d.ts +41 -0
- package/dist/lib/manifest.js +127 -0
- package/dist/lib/manifest.js.map +1 -0
- package/dist/lib/migrations.d.ts +47 -0
- package/dist/lib/migrations.js +76 -0
- package/dist/lib/migrations.js.map +1 -0
- package/dist/lib/project.d.ts +22 -0
- package/dist/lib/project.js +53 -0
- package/dist/lib/project.js.map +1 -0
- package/dist/lib/ralph.d.ts +12 -0
- package/dist/lib/ralph.js +21 -0
- package/dist/lib/ralph.js.map +1 -0
- package/dist/lib/seeds.d.ts +11 -0
- package/dist/lib/seeds.js +120 -0
- package/dist/lib/seeds.js.map +1 -0
- package/dist/lib/upstream.d.ts +21 -0
- package/dist/lib/upstream.js +37 -0
- package/dist/lib/upstream.js.map +1 -0
- package/dist/lib/writer.d.ts +26 -0
- package/dist/lib/writer.js +62 -0
- package/dist/lib/writer.js.map +1 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +4 -0
- package/dist/version.js.map +1 -0
- package/package.json +36 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wemuda
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
// Managed by Launchrail. Do not hand-edit — `launchrail sync` may replace this file.
|
|
2
|
+
// Override policy per run via args instead, e.g. { width: 1, only: [9, 10] }.
|
|
3
|
+
//
|
|
4
|
+
// The Ralph campaign as a deterministic workflow: the plan, the frontier bookkeeping,
|
|
5
|
+
// and every intermediate report live in script variables — not in any context window —
|
|
6
|
+
// so long or wide runs cannot compact away their own state. The watchable, checkpointed
|
|
7
|
+
// variant of the same loop is the launchrail:ralph skill; the two share one policy block,
|
|
8
|
+
// and a policy change belongs in both places (ADR-0005, field-revised by ADR-0010).
|
|
9
|
+
export const meta = {
|
|
10
|
+
name: 'ralph',
|
|
11
|
+
description: 'Autonomous Ralph campaign: implement ready tickets with fresh-context subagents, verification-gated',
|
|
12
|
+
whenToUse:
|
|
13
|
+
'Run a Ralph implementation campaign over the ticket backlog when the dependency graph is wide or the run is long. Scope a run via args: { only: [9, 10], width: 2 } or just [9, 10]. For a watchable, checkpointed run (or when something is already going wrong), use the launchrail:ralph skill instead.',
|
|
14
|
+
phases: [
|
|
15
|
+
{ title: 'Preflight', detail: 'read project config, sync the base, run the verification gate' },
|
|
16
|
+
{ title: 'Graph', detail: 'list ready tickets and their blocking edges, verbatim' },
|
|
17
|
+
{ title: 'Build', detail: 'one fresh-context implementer per ticket, merge included' },
|
|
18
|
+
{ title: 'Verify', detail: 'remote ground truth for every claimed merge' },
|
|
19
|
+
{ title: 'Park', detail: 'comment failure history, label needs-info' },
|
|
20
|
+
{ title: 'Release', detail: 'final verification gate and evidence summary' },
|
|
21
|
+
],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Policy — the launchrail:ralph policy block, as code. Override via args.
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
// args may arrive as an object ({ only, width, ... }), a bare array of ticket numbers,
|
|
29
|
+
// or a JSON string of either — some launch surfaces stringify it. Normalise all three.
|
|
30
|
+
// A provided-but-unparseable args is a caller error, not licence to build the whole tracker.
|
|
31
|
+
function resolveArgs(raw) {
|
|
32
|
+
if (raw == null) return {}
|
|
33
|
+
let value = raw
|
|
34
|
+
if (typeof value === 'string') {
|
|
35
|
+
try {
|
|
36
|
+
value = JSON.parse(value)
|
|
37
|
+
} catch {
|
|
38
|
+
throw new Error(`ralph: args was a string but not valid JSON — refusing to run: ${raw}`)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return Array.isArray(value) ? { only: value } : value
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const A = resolveArgs(args)
|
|
45
|
+
|
|
46
|
+
const POLICY = {
|
|
47
|
+
// Scope the run to specific ticket numbers ([] = the whole ready frontier).
|
|
48
|
+
only: A.only ?? [],
|
|
49
|
+
// Parallel implementers. Width also caps local build concurrency — several implementers
|
|
50
|
+
// share one machine, and fanning out test runs buys backpressure, not speed. Use 1 until
|
|
51
|
+
// a campaign has landed tickets cleanly on this project.
|
|
52
|
+
width: A.width ?? 3,
|
|
53
|
+
// Tries per ticket: 1 attempt + 1 retry with a fresh context, then park. Deferrals
|
|
54
|
+
// (a declared blocker had not landed yet) hand their attempt back, capped separately.
|
|
55
|
+
attempts: A.attempts ?? 2,
|
|
56
|
+
// Backstop against a graph that never drains; deferral rounds spend from this too.
|
|
57
|
+
maxRounds: A.maxRounds ?? 25,
|
|
58
|
+
// Re-read the tracker between rounds so externally closed tickets unblock things.
|
|
59
|
+
refreshGraph: A.refreshGraph ?? true,
|
|
60
|
+
// Stop starting new rounds when the remaining token budget drops below this — a round
|
|
61
|
+
// that starts without enough budget to merge is worse than one that never starts.
|
|
62
|
+
reserve: A.reserve ?? 200_000,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Clauses carried verbatim in every dispatch, retries included.
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
const INTEGRITY = `INTEGRITY: No placeholders, no stubs, no "simplified for now". Never delete,
|
|
69
|
+
skip, or weaken a test to get a green run; if a test is genuinely wrong, fix it deliberately
|
|
70
|
+
and say so in the PR body. Never claim verification passed without having run it.`
|
|
71
|
+
|
|
72
|
+
const IDEMPOTENCY = `IDEMPOTENCY: This step can be replayed after an interruption, so check
|
|
73
|
+
before you act: if the ticket is already closed, report status "already-done" and stop; if a
|
|
74
|
+
ralph/<n>-* branch or an open PR for this ticket already exists, adopt it and continue from
|
|
75
|
+
where it left off — do not start over. Never open a second PR for the same ticket.`
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Schemas — stages return validated structure, never prose the script must parse.
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
const PREFLIGHT_SCHEMA = {
|
|
81
|
+
type: 'object',
|
|
82
|
+
additionalProperties: false,
|
|
83
|
+
required: ['green', 'base', 'trackerAccess', 'verifyCommand', 'localCommands', 'failures'],
|
|
84
|
+
properties: {
|
|
85
|
+
green: { type: 'boolean', description: 'base is synced and the verification gate passed' },
|
|
86
|
+
headSha: { type: 'string', description: 'commit sha the gate ran against' },
|
|
87
|
+
repo: { type: 'string', description: 'owner/name from the git remote, or empty' },
|
|
88
|
+
base: { type: 'string', description: 'default branch name' },
|
|
89
|
+
issueTracker: { type: 'string', description: 'issueTracker from .launchrail.yml (github | linear | none)' },
|
|
90
|
+
trackerAccess: {
|
|
91
|
+
type: 'string',
|
|
92
|
+
description:
|
|
93
|
+
'instruction for reaching the tracker from THIS execution environment (exact CLI or MCP tools available here), for inclusion in dispatch prompts',
|
|
94
|
+
},
|
|
95
|
+
verifyCommand: { type: 'string', description: 'the verification gate command, verbatim' },
|
|
96
|
+
localCommands: {
|
|
97
|
+
type: 'array',
|
|
98
|
+
items: { type: 'string' },
|
|
99
|
+
description: 'other verbatim local commands (install, typecheck, unit tests) implementers should use',
|
|
100
|
+
},
|
|
101
|
+
browserTesting: { type: 'boolean', description: '.launchrail.yml modules.browser-testing' },
|
|
102
|
+
failures: { type: 'array', items: { type: 'string' } },
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const GRAPH_SCHEMA = {
|
|
107
|
+
type: 'object',
|
|
108
|
+
additionalProperties: false,
|
|
109
|
+
required: ['tickets'],
|
|
110
|
+
properties: {
|
|
111
|
+
tickets: {
|
|
112
|
+
type: 'array',
|
|
113
|
+
items: {
|
|
114
|
+
type: 'object',
|
|
115
|
+
additionalProperties: false,
|
|
116
|
+
required: ['number', 'title', 'blockedByLine'],
|
|
117
|
+
properties: {
|
|
118
|
+
number: { type: 'integer' },
|
|
119
|
+
title: { type: 'string' },
|
|
120
|
+
blockedByLine: {
|
|
121
|
+
type: 'string',
|
|
122
|
+
description:
|
|
123
|
+
'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.',
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const BUILD_SCHEMA = {
|
|
132
|
+
type: 'object',
|
|
133
|
+
additionalProperties: false,
|
|
134
|
+
required: ['status', 'summary'],
|
|
135
|
+
properties: {
|
|
136
|
+
status: {
|
|
137
|
+
type: 'string',
|
|
138
|
+
enum: ['merged', 'already-done', 'blocked', 'ci-red', 'ci-timeout', 'conflict', 'verify-failed', 'failed'],
|
|
139
|
+
},
|
|
140
|
+
pr: { type: 'integer', description: 'PR number, when one was opened or adopted' },
|
|
141
|
+
mergeCommit: { type: 'string' },
|
|
142
|
+
summary: { type: 'string', description: 'what happened, short; on failure, enough for a retry to act on' },
|
|
143
|
+
failure: {
|
|
144
|
+
type: 'string',
|
|
145
|
+
description: 'on "blocked": which blocker is still open; on failure: the one fact a fresh retry must know',
|
|
146
|
+
},
|
|
147
|
+
punted: {
|
|
148
|
+
type: 'array',
|
|
149
|
+
items: { type: 'string' },
|
|
150
|
+
description: 'follow-up work discovered but deliberately left out of scope',
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const VERIFY_SCHEMA = {
|
|
156
|
+
type: 'object',
|
|
157
|
+
additionalProperties: false,
|
|
158
|
+
required: ['merged', 'issueClosed', 'evidence'],
|
|
159
|
+
properties: {
|
|
160
|
+
merged: {
|
|
161
|
+
type: 'boolean',
|
|
162
|
+
description: 'true only if the PR is merged AND its merge commit appears in the base branch history',
|
|
163
|
+
},
|
|
164
|
+
issueClosed: { type: 'boolean' },
|
|
165
|
+
mergeCommit: { type: 'string' },
|
|
166
|
+
evidence: { type: 'string', description: 'the API facts that establish the verdict' },
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const RELEASE_SCHEMA = {
|
|
171
|
+
type: 'object',
|
|
172
|
+
additionalProperties: false,
|
|
173
|
+
required: ['verified', 'summary'],
|
|
174
|
+
properties: {
|
|
175
|
+
verified: { type: 'boolean', description: 'verification gate green on the final base' },
|
|
176
|
+
headSha: { type: 'string' },
|
|
177
|
+
smokeBundle: { type: 'string', description: 'path of the smoke evidence bundle, when one was produced' },
|
|
178
|
+
summary: { type: 'string' },
|
|
179
|
+
failures: { type: 'array', items: { type: 'string' } },
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Dispatch prompts
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
function preamble(pre) {
|
|
187
|
+
return `You are working in a Launchrail project${pre.repo ? ` (${pre.repo})` : ''}; base branch: ${pre.base}.
|
|
188
|
+
Binding docs: AGENTS.md and CLAUDE.md, plus the vision, specs, and ADRs under docs/. Read them before touching code.
|
|
189
|
+
Tracker access from this environment: ${pre.trackerAccess}
|
|
190
|
+
Blocking edges live on tickets as "Blocked by: #n" lines.
|
|
191
|
+
Verbatim local commands: ${[...pre.localCommands, pre.verifyCommand].join(' ; ')}
|
|
192
|
+
The verification gate is: ${pre.verifyCommand} — a ticket is not done while it fails.
|
|
193
|
+
Several implementers share this machine — run single test files while iterating and save full runs for the gate.`
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function buildPrompt(pre, ticket, attempt, priorFailure) {
|
|
197
|
+
const retry =
|
|
198
|
+
attempt > 1
|
|
199
|
+
? `\nThis is a RETRY with a fresh context. Prior attempt failed: ${priorFailure}
|
|
200
|
+
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`
|
|
201
|
+
: ''
|
|
202
|
+
return `${preamble(pre)}
|
|
203
|
+
|
|
204
|
+
Implement ticket #${ticket.number} ("${ticket.title}") end to end — merge included. You own it 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.
|
|
205
|
+
${retry}
|
|
206
|
+
Steps, in order:
|
|
207
|
+
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.
|
|
208
|
+
2. Read the ticket and everything it links (spec sections, ADRs, journeys). Report status "already-done" if it is already closed.
|
|
209
|
+
3. Label the ticket ralph:building so a lost session leaves a trace.
|
|
210
|
+
4. Branch from a fresh sync of ${pre.base}: ralph/${ticket.number}-<short-slug>.
|
|
211
|
+
5. Implement by invoking the launchrail: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.
|
|
212
|
+
6. Pre-PR sync: merge the latest ${pre.base} into your branch. Conflicts are ordinary work — resolve them with the launchrail:resolving-merge-conflicts skill and re-run the verification gate if anything changed.
|
|
213
|
+
7. Open a PR 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.
|
|
214
|
+
8. Wait for CI if the repository has it, spacing polls with the Monitor tool or a background sleep — never a foreground sleep, never a busy loop; treat ~20 minutes as the budget and report status "ci-timeout" beyond it. Fix what your branch broke and push. If a failure reproduces on ${pre.base} itself, report "ci-red" and stop — that is systemic, not this ticket's problem.
|
|
215
|
+
9. Immediately before merging, re-sync with ${pre.base} once more (retry up to 3 times if the base keeps moving), then squash-merge. Squash-merge does not reliably fire "Closes" — read the issue back, close it explicitly if it is still open, and remove the ralph:building label. Never push to ${pre.base} directly; the PR is the only door.
|
|
216
|
+
|
|
217
|
+
${INTEGRITY}
|
|
218
|
+
|
|
219
|
+
${IDEMPOTENCY}
|
|
220
|
+
|
|
221
|
+
Report honestly via the schema: "merged" only after the squash-merge API call succeeded; "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); "ci-red" / "ci-timeout" / "failed" otherwise, with a summary a fresh retry can act on. List deliberately-out-of-scope discoveries in "punted".`
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function verifyPrompt(pre, ticket, build) {
|
|
225
|
+
return `Establish ground truth for ticket #${ticket.number} using the tracker API only.
|
|
226
|
+
Tracker access: ${pre.trackerAccess}
|
|
227
|
+
An implementer claims it merged via PR #${build.pr}${build.mergeCommit ? ` (merge commit ${build.mergeCommit})` : ''}.
|
|
228
|
+
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.
|
|
229
|
+
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.`
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const graphPrompt = (pre) => `List the open, ready tickets for a Ralph campaign. Change nothing on the tracker.
|
|
233
|
+
Tracker access: ${pre.trackerAccess}
|
|
234
|
+
Include every open ticket labeled ready-for-agent, excluding any labeled needs-info.
|
|
235
|
+
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.
|
|
236
|
+
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.`
|
|
237
|
+
|
|
238
|
+
// Blocking edges are parsed here, deterministically, from the verbatim line — never by a
|
|
239
|
+
// model. A single misread edge silently builds a ticket on a dependency that hasn't landed.
|
|
240
|
+
function parseGraph(graph) {
|
|
241
|
+
return (graph?.tickets ?? []).map((t) => ({
|
|
242
|
+
number: t.number,
|
|
243
|
+
title: t.title,
|
|
244
|
+
blockedBy: [...(t.blockedByLine ?? '').matchAll(/#(\d+)/g)]
|
|
245
|
+
.map((m) => Number(m[1]))
|
|
246
|
+
.filter((n) => n !== t.number),
|
|
247
|
+
}))
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// Stages
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
const state = new Map() // number -> { ticket, attempts, defers, failures[], status, pr, mergeCommit, punted[] }
|
|
254
|
+
|
|
255
|
+
function entry(ticket) {
|
|
256
|
+
if (!state.has(ticket.number)) {
|
|
257
|
+
state.set(ticket.number, { ticket, attempts: 0, defers: 0, failures: [], status: 'pending', punted: [] })
|
|
258
|
+
}
|
|
259
|
+
return state.get(ticket.number)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function drive(pre, ticket) {
|
|
263
|
+
const s = entry(ticket)
|
|
264
|
+
s.attempts += 1
|
|
265
|
+
const build = await agent(buildPrompt(pre, ticket, s.attempts, s.failures.join(' | ')), {
|
|
266
|
+
label: `build:#${ticket.number}${s.attempts > 1 ? ':retry' : ''}`,
|
|
267
|
+
phase: 'Build',
|
|
268
|
+
schema: BUILD_SCHEMA,
|
|
269
|
+
isolation: 'worktree', // parallel implementers must never fight over one checkout
|
|
270
|
+
})
|
|
271
|
+
if (!build) {
|
|
272
|
+
s.failures.push('implementer died (infrastructure)')
|
|
273
|
+
return { ticket, ok: false, dead: true }
|
|
274
|
+
}
|
|
275
|
+
s.punted.push(...(build.punted ?? []))
|
|
276
|
+
if (build.status === 'already-done') {
|
|
277
|
+
s.status = 'merged'
|
|
278
|
+
return { ticket, ok: true }
|
|
279
|
+
}
|
|
280
|
+
if (build.status === 'blocked') {
|
|
281
|
+
// A declared blocker had not actually landed — the frontier's view was stale, or the
|
|
282
|
+
// blocker is open but outside the ready set. Hand the attempt back: a deferral is not
|
|
283
|
+
// a failure. Capped so a permanently missing dependency still parks eventually.
|
|
284
|
+
s.defers += 1
|
|
285
|
+
if (s.defers <= POLICY.attempts) {
|
|
286
|
+
s.attempts -= 1
|
|
287
|
+
return { ticket, ok: false, deferred: true, why: build.failure ?? build.summary }
|
|
288
|
+
}
|
|
289
|
+
s.failures.push(`still blocked after ${s.defers} deferrals: ${build.failure ?? build.summary}`)
|
|
290
|
+
return { ticket, ok: false }
|
|
291
|
+
}
|
|
292
|
+
if (build.status !== 'merged') {
|
|
293
|
+
s.failures.push(`[attempt ${s.attempts}] ${build.status}: ${build.failure ?? build.summary}`)
|
|
294
|
+
return { ticket, ok: false }
|
|
295
|
+
}
|
|
296
|
+
if (!build.pr) {
|
|
297
|
+
s.failures.push(`[attempt ${s.attempts}] reported merged but returned no PR number`)
|
|
298
|
+
return { ticket, ok: false }
|
|
299
|
+
}
|
|
300
|
+
// Nothing is trusted from a report — a claimed merge is checked against the remote
|
|
301
|
+
// by a separate, cheap agent with tracker access only.
|
|
302
|
+
const verdict = await agent(verifyPrompt(pre, ticket, build), {
|
|
303
|
+
label: `verify:#${ticket.number}`,
|
|
304
|
+
phase: 'Verify',
|
|
305
|
+
schema: VERIFY_SCHEMA,
|
|
306
|
+
model: 'haiku',
|
|
307
|
+
effort: 'low',
|
|
308
|
+
})
|
|
309
|
+
if (verdict?.merged && verdict.issueClosed) {
|
|
310
|
+
s.status = 'merged'
|
|
311
|
+
s.pr = build.pr
|
|
312
|
+
s.mergeCommit = verdict.mergeCommit || build.mergeCommit
|
|
313
|
+
return { ticket, ok: true }
|
|
314
|
+
}
|
|
315
|
+
// Merged-but-issue-open fails verification too: the retry adopts the merged PR (the
|
|
316
|
+
// idempotency clause), closes the issue explicitly, and the ticket settles cleanly.
|
|
317
|
+
s.failures.push(
|
|
318
|
+
`[attempt ${s.attempts}] claimed merged, remote disagrees: ${verdict ? verdict.evidence : 'verifier died'}`,
|
|
319
|
+
)
|
|
320
|
+
return { ticket, ok: false }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// A ticket is ready when it isn't settled, hasn't exhausted its attempts, every blocker
|
|
324
|
+
// is closed-before-the-run or merged-by-us, and no blocker is parked. Pure code — the
|
|
325
|
+
// orchestrator never asks a subagent what's ready. The implementer's dependency gate is
|
|
326
|
+
// the backstop for what this check cannot see (a blocker that is open but never entered
|
|
327
|
+
// the ready set).
|
|
328
|
+
function frontier(tickets, closedBefore) {
|
|
329
|
+
return tickets.filter((t) => {
|
|
330
|
+
const s = entry(t)
|
|
331
|
+
if (s.status !== 'pending') return false
|
|
332
|
+
if (s.attempts >= POLICY.attempts) return false
|
|
333
|
+
if (POLICY.only.length > 0 && !POLICY.only.includes(t.number)) return false
|
|
334
|
+
return t.blockedBy.every((b) => {
|
|
335
|
+
if (closedBefore.has(b)) return true
|
|
336
|
+
const blocker = state.get(b)
|
|
337
|
+
return blocker !== undefined && blocker.status === 'merged'
|
|
338
|
+
})
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
// The run
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
phase('Preflight')
|
|
346
|
+
const pre = await agent(
|
|
347
|
+
`Preflight for a Ralph campaign in this repository. Fix nothing; report actual state.
|
|
348
|
+
1. Read .launchrail.yml (issueTracker, testing commands, modules) and AGENTS.md (verbatim commands).
|
|
349
|
+
2. Identify the repo (git remote) and the default/base branch; sync it fresh (clean tree). If the base branch does not exist on the remote, report not green and say the base is missing — do not guess another branch.
|
|
350
|
+
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.
|
|
351
|
+
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.
|
|
352
|
+
green means: base synced AND the verification gate exited 0.`,
|
|
353
|
+
{ label: 'preflight', phase: 'Preflight', schema: PREFLIGHT_SCHEMA },
|
|
354
|
+
)
|
|
355
|
+
if (!pre) throw new Error('preflight agent died — refusing to start')
|
|
356
|
+
if (!pre.green) {
|
|
357
|
+
// A broken base poisons every implementer after it; a campaign that starts red
|
|
358
|
+
// can only end with unverifiable results.
|
|
359
|
+
return { refused: true, reason: 'preflight not green', failures: pre.failures }
|
|
360
|
+
}
|
|
361
|
+
if ((pre.issueTracker ?? 'none') === 'none') {
|
|
362
|
+
return { refused: true, reason: 'no issue tracker configured (.launchrail.yml issueTracker: none) — Ralph needs tickets' }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
phase('Graph')
|
|
366
|
+
log(
|
|
367
|
+
`Base green at ${pre.headSha ?? pre.base} on ${pre.base}. ` +
|
|
368
|
+
(POLICY.only.length > 0
|
|
369
|
+
? `Scoped to ${POLICY.only.map((n) => `#${n}`).join(', ')}.`
|
|
370
|
+
: 'No scope — building the whole ready frontier.') +
|
|
371
|
+
` Width ${POLICY.width}, ${POLICY.attempts} attempts per ticket.`,
|
|
372
|
+
)
|
|
373
|
+
let graph = await agent(graphPrompt(pre), { label: 'read-graph', phase: 'Graph', schema: GRAPH_SCHEMA, model: 'haiku', effort: 'low' })
|
|
374
|
+
if (!graph) throw new Error('graph agent died — refusing to start')
|
|
375
|
+
let tickets = parseGraph(graph)
|
|
376
|
+
log(`${tickets.length} ready ticket(s) on the tracker`)
|
|
377
|
+
|
|
378
|
+
const closedBefore = new Set() // blockers not in the ready set are treated as settled before the run
|
|
379
|
+
for (const t of tickets) {
|
|
380
|
+
for (const b of t.blockedBy) {
|
|
381
|
+
if (!tickets.some((x) => x.number === b)) closedBefore.add(b)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
let rounds = 0
|
|
386
|
+
while (rounds < POLICY.maxRounds) {
|
|
387
|
+
if (budget.total && budget.remaining() < POLICY.reserve) {
|
|
388
|
+
log(`token budget at reserve (${Math.round(budget.remaining() / 1000)}k left) — stopping before a new round`)
|
|
389
|
+
break
|
|
390
|
+
}
|
|
391
|
+
const ready = frontier(tickets, closedBefore)
|
|
392
|
+
if (ready.length === 0) break
|
|
393
|
+
rounds += 1
|
|
394
|
+
const batch = ready.slice(0, POLICY.width)
|
|
395
|
+
log(`round ${rounds}: dispatching ${batch.map((t) => `#${t.number}`).join(', ')} (${ready.length} unblocked)`)
|
|
396
|
+
const results = await parallel(batch.map((t) => () => drive(pre, t)))
|
|
397
|
+
const landed = results.filter((r) => r?.ok)
|
|
398
|
+
for (const r of results.filter((x) => x?.deferred)) {
|
|
399
|
+
log(`#${r.ticket.number} deferred (blocker not landed yet): ${r.why}`)
|
|
400
|
+
}
|
|
401
|
+
if (results.every((r) => !r || r.dead)) {
|
|
402
|
+
log('every agent in the round died — infrastructure, not tickets; stopping the campaign')
|
|
403
|
+
break
|
|
404
|
+
}
|
|
405
|
+
for (const t of batch) {
|
|
406
|
+
const s = entry(t)
|
|
407
|
+
if (s.status !== 'merged' && s.attempts >= POLICY.attempts) s.status = 'parked'
|
|
408
|
+
}
|
|
409
|
+
log(`round ${rounds}: ${landed.length}/${batch.length} verified merged`)
|
|
410
|
+
if (POLICY.refreshGraph && frontier(tickets, closedBefore).length > 0) {
|
|
411
|
+
graph = await agent(graphPrompt(pre), { label: `read-graph:r${rounds}`, phase: 'Graph', schema: GRAPH_SCHEMA, model: 'haiku', effort: 'low' })
|
|
412
|
+
if (graph) {
|
|
413
|
+
const fresh = parseGraph(graph)
|
|
414
|
+
for (const t of fresh) {
|
|
415
|
+
if (!tickets.some((x) => x.number === t.number)) tickets.push(t)
|
|
416
|
+
}
|
|
417
|
+
for (const t of tickets) {
|
|
418
|
+
const still = fresh.some((x) => x.number === t.number)
|
|
419
|
+
const s = state.get(t.number)
|
|
420
|
+
// Ticket left the ready set without us touching it (closed or re-labeled
|
|
421
|
+
// externally): treat it as settled for blockers and never dispatch it.
|
|
422
|
+
if (!still && (!s || (s.status === 'pending' && s.attempts === 0))) {
|
|
423
|
+
entry(t).status = 'external'
|
|
424
|
+
closedBefore.add(t.number)
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const merged = [...state.values()].filter((s) => s.status === 'merged')
|
|
432
|
+
const parked = [...state.values()].filter((s) => s.status === 'parked')
|
|
433
|
+
const stuck = tickets.filter((t) => {
|
|
434
|
+
const s = state.get(t.number)
|
|
435
|
+
return !s || s.status === 'pending'
|
|
436
|
+
})
|
|
437
|
+
const settledNumber = (b) => closedBefore.has(b) || state.get(b)?.status === 'merged'
|
|
438
|
+
|
|
439
|
+
phase('Park')
|
|
440
|
+
if (parked.length > 0) {
|
|
441
|
+
await agent(
|
|
442
|
+
`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.
|
|
443
|
+
${parked.map((s) => `#${s.ticket.number} (${s.ticket.title}): ${s.failures.join(' | ')}`).join('\n')}`,
|
|
444
|
+
{ label: 'park', phase: 'Park', schema: { type: 'object', properties: { done: { type: 'boolean' } }, required: ['done'], additionalProperties: false }, model: 'haiku', effort: 'low' },
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// The completion contract: the campaign cannot declare success while required
|
|
449
|
+
// verification fails on the final, post-merge base.
|
|
450
|
+
phase('Release')
|
|
451
|
+
const release = await agent(
|
|
452
|
+
`Release verification for a finished Ralph campaign. Fix nothing.
|
|
453
|
+
1. Sync a fresh ${pre.base} and record its head sha.
|
|
454
|
+
2. Run the verification gate: npx @wemuda/launchrail verify. Report the actual exit code.
|
|
455
|
+
${
|
|
456
|
+
pre.browserTesting && merged.length > 0
|
|
457
|
+
? `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 launchrail:browser-smoke skill. Report the bundle path. A journey you could not complete is a failure, never a pass.`
|
|
458
|
+
: ''
|
|
459
|
+
}
|
|
460
|
+
verified means: the verification gate exited 0${pre.browserTesting && merged.length > 0 ? ' AND no smoke journey failed' : ''}.`,
|
|
461
|
+
{ label: 'release-verification', phase: 'Release', schema: RELEASE_SCHEMA },
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
rounds,
|
|
466
|
+
verified: release?.verified ?? false,
|
|
467
|
+
release,
|
|
468
|
+
merged: merged.map((s) => ({ ticket: s.ticket.number, title: s.ticket.title, pr: s.pr, mergeCommit: s.mergeCommit })),
|
|
469
|
+
parked: parked.map((s) => ({ ticket: s.ticket.number, title: s.ticket.title, failures: s.failures })),
|
|
470
|
+
stuck: stuck.map((t) => ({
|
|
471
|
+
ticket: t.number,
|
|
472
|
+
title: t.title,
|
|
473
|
+
blockedBy: t.blockedBy.filter((b) => !settledNumber(b)),
|
|
474
|
+
})),
|
|
475
|
+
followUps: [...state.values()].flatMap((s) => s.punted),
|
|
476
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type PlannedAction } from "../lib/writer.js";
|
|
2
|
+
export interface AddOptions {
|
|
3
|
+
cwd: string;
|
|
4
|
+
module: string;
|
|
5
|
+
dryRun: boolean;
|
|
6
|
+
yes: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface AddOutcome {
|
|
9
|
+
code: number;
|
|
10
|
+
actions: PlannedAction[];
|
|
11
|
+
}
|
|
12
|
+
export declare const AVAILABLE_MODULES: string[];
|
|
13
|
+
export declare function runAdd(opts: AddOptions): Promise<AddOutcome>;
|