@wemuda/launchrail 1.15.0 → 1.15.1
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/assets/ralph.workflow.js
CHANGED
|
@@ -139,6 +139,15 @@ const PREFLIGHT_SCHEMA = {
|
|
|
139
139
|
base: { type: 'string', description: "the run's integration base: the declared target branch when one is set, else the default branch" },
|
|
140
140
|
defaultBranch: { type: 'string', description: 'the repository default branch name' },
|
|
141
141
|
targetCreated: { type: 'boolean', description: 'true when a declared target branch was missing from the remote and was created from the default branch tip' },
|
|
142
|
+
baseOnRemote: {
|
|
143
|
+
type: 'string',
|
|
144
|
+
description:
|
|
145
|
+
'the base tip as `git ls-remote --heads origin <base>` reports it on the LIVE remote (after `git fetch --prune`), or "" when the base is absent from origin. NEVER inferred from `git branch -r` or a local refs/remotes/origin/<base> tracking ref — those go stale and make a local-only base look present. green requires this non-empty and equal to headSha.',
|
|
146
|
+
},
|
|
147
|
+
basePushed: {
|
|
148
|
+
type: 'boolean',
|
|
149
|
+
description: 'true when the base existed only locally (a session-pinned working branch with unpushed commits) and preflight pushed it to origin so the run can land onto it',
|
|
150
|
+
},
|
|
142
151
|
issueTracker: { type: 'string', description: 'issueTracker from .launchrail.yml (github | linear | none)' },
|
|
143
152
|
trackerAccess: {
|
|
144
153
|
type: 'string',
|
|
@@ -684,13 +693,13 @@ async function drive(pre, ticket, pushed) {
|
|
|
684
693
|
// ---------------------------------------------------------------------------
|
|
685
694
|
phase('Preflight')
|
|
686
695
|
const pre = await agent(
|
|
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
|
|
696
|
+
`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 publishing the declared integration base to origin (step 2: creating it from the default branch tip, or pushing a base that exists only locally) and syncing this checkout onto it.
|
|
688
697
|
1. Read .launchrail.yml (issueTracker, testing commands, modules) and AGENTS.md (verbatim commands).
|
|
689
|
-
2. Identify the repo (git remote) and its default branch; report the default branch name as defaultBranch. ${
|
|
698
|
+
2. Identify the repo (git remote) and its default branch; report the default branch name as defaultBranch. Prune stale remote-tracking refs first: \`git fetch --prune origin\` — a leftover refs/remotes/origin/<base> from an earlier fetch otherwise makes a local-only base look present. Then judge the base's presence ON THE LIVE REMOTE with \`git ls-remote --heads origin <base>\`, NEVER \`git branch -r\` or a local tracking ref: a base that is not actually on origin passes on a stale ref here and then fails every land at \`git fetch origin <base>\`. ${
|
|
690
699
|
POLICY.target
|
|
691
|
-
? `This run
|
|
692
|
-
: `This run lands onto the default branch (trunk) — that branch is the base. If
|
|
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);
|
|
700
|
+
? `This run's integration base is the branch "${POLICY.target}". If \`git ls-remote --heads origin ${POLICY.target}\` returns it, that is the base. If it is ABSENT from origin: when a local "${POLICY.target}" branch holds commits not yet on origin (a session-pinned working branch), those commits ARE the base — push it (\`git push -u origin ${POLICY.target}\`) and report basePushed: true; otherwise mint it from the default branch's tip (\`git branch ${POLICY.target} origin/<default> && git push -u origin ${POLICY.target}\`; no force, the default branch itself is never touched) and report targetCreated: true. If you can neither push nor create it, report not green with "push the base first: ${POLICY.target} exists only locally". A missing DEFAULT branch is still not green — do not guess.`
|
|
701
|
+
: `This run lands onto the default branch (trunk) — that branch is the base. If \`git ls-remote --heads origin <default>\` does not return it, report not green and say the base is missing — do not guess another branch.`
|
|
702
|
+
} 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); with origin now carrying the base, 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, then re-run \`git ls-remote --heads origin <base>\` and report the sha it returns as baseOnRemote — green REQUIRES baseOnRemote non-empty and equal to headSha (origin carries the base at exactly the tip this checkout builds against).
|
|
694
703
|
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.
|
|
695
704
|
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
705
|
5. Run the project's install command (report it verbatim as installCommand). ${
|
|
@@ -699,7 +708,7 @@ const pre = await agent(
|
|
|
699
708
|
: 'Then run the FULL verification gate'
|
|
700
709
|
}: 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
710
|
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).`,
|
|
711
|
+
green means: the base is confirmed on origin (baseOnRemote non-empty and equal to headSha — proven by \`git ls-remote\`, never inferred from a local tracking ref) AND synced in this checkout AND (the full gate exited 0 OR it was skipped as known green).`,
|
|
703
712
|
{ label: 'preflight', phase: 'Preflight', schema: PREFLIGHT_SCHEMA },
|
|
704
713
|
)
|
|
705
714
|
if (!pre) throw new Error('preflight agent died — refusing to start')
|
|
@@ -708,6 +717,18 @@ if (!pre.green) {
|
|
|
708
717
|
// can only end with unverifiable results.
|
|
709
718
|
return { refused: true, reason: 'preflight not green', failures: pre.failures }
|
|
710
719
|
}
|
|
720
|
+
// The green verdict must be grounded in the LIVE remote, not inferred locally: a stale
|
|
721
|
+
// refs/remotes/origin/<base> tracking ref makes a local-only base look present, preflight
|
|
722
|
+
// reports green, and then EVERY land fails at `git fetch origin <base>` (with canary, no
|
|
723
|
+
// land ever succeeds and the whole run is wasted). Refuse unless preflight confirmed the
|
|
724
|
+
// base on origin — via `git ls-remote` — at exactly the tip this checkout builds against.
|
|
725
|
+
if (!pre.baseOnRemote || pre.baseOnRemote !== (pre.headSha ?? '')) {
|
|
726
|
+
return {
|
|
727
|
+
refused: true,
|
|
728
|
+
reason: `preflight reported green but did not confirm the base ${pre.base} on origin at the checkout tip (git ls-remote --heads origin ${pre.base} → ${pre.baseOnRemote || 'absent'}, headSha ${pre.headSha || 'unset'}) — refusing to dispatch; every land would fail at git fetch origin ${pre.base}`,
|
|
729
|
+
failures: pre.failures,
|
|
730
|
+
}
|
|
731
|
+
}
|
|
711
732
|
if ((pre.issueTracker ?? 'none') === 'none') {
|
|
712
733
|
return { refused: true, reason: 'no issue tracker configured (.launchrail.yml issueTracker: none) — Ralph needs tickets' }
|
|
713
734
|
}
|
|
@@ -727,7 +748,7 @@ phase('Graph')
|
|
|
727
748
|
log(
|
|
728
749
|
`Base green at ${pre.headSha ?? pre.base} on ${pre.base}${pre.skippedGate ? ' (known green — gate skipped)' : ''}. ` +
|
|
729
750
|
(POLICY.target
|
|
730
|
-
? `Consolidating onto ${pre.base}${pre.targetCreated ? ' (created from the default branch tip)' : ''}; ${pre.defaultBranch || 'the default branch'} stays untouched. `
|
|
751
|
+
? `Consolidating onto ${pre.base}${pre.targetCreated ? ' (created from the default branch tip)' : pre.basePushed ? ' (pushed to origin from local-only commits)' : ''}; ${pre.defaultBranch || 'the default branch'} stays untouched. `
|
|
731
752
|
: `Trunk mode — each ticket lands on ${pre.base}. `) +
|
|
732
753
|
(POLICY.only.length > 0
|
|
733
754
|
? `Scoped to ${POLICY.only.map((n) => `#${n}`).join(', ')}.`
|
|
@@ -18,7 +18,7 @@ From `.launchrail.yml`: `issueTracker` and the `testing` commands. From the argu
|
|
|
18
18
|
- **a count** — "the next 5", "max 5" → the loop with a merge cap. Don't hand-pick which five: the cap is a stop condition, and the frontier decides the order — the loop stops after that many *verified merges* and leaves the rest ready;
|
|
19
19
|
- **a spec, slice, or epic reference** — "spec #2's tickets", "the rest of slice 1" → resolve it to explicit numbers against the live tracker: the open tickets that belong to it (a "Part of: #n" line, the spec issue's ticket list, or a label), plus any open in-set blockers so the scope stays dependency-closed. Combinations compose: "the next 5 of spec #2" → that spec's tickets *and* a cap of 5.
|
|
20
20
|
|
|
21
|
-
**Resolve the integration target with the scope.** Every multi-ticket run **consolidates by default** (ADR-0022, ADR-0026): its per-ticket branches are landed onto one integration branch by the loop's own local gate, the default branch stays untouched, and the run ends by *offering* one release PR `<target> → <default>` for the user to review and merge — the one place cloud CI runs — never merging to the default branch on its own. You name that branch before anything launches, scope-native: the branch the user named if they gave one ("collect spec #44 on `spec/44-mvp`"); else **the session's designated working branch** when the environment pinned one at start — a hosted session's `claude/...` branch exists to receive exactly this run, so use it rather than minting a `spec/*` twin beside it (ADR-0028); else the scope's own name when it maps to a spec, epic, or slice (`spec/<n>-<slug>`); else a generated fallback for an ad-hoc frontier (`launch/frontier-<today>`, or `launch/tickets-<n>-<m>` for a handful of loose numbers). A named target the remote lacks is created from the default branch's tip
|
|
21
|
+
**Resolve the integration target with the scope.** Every multi-ticket run **consolidates by default** (ADR-0022, ADR-0026): its per-ticket branches are landed onto one integration branch by the loop's own local gate, the default branch stays untouched, and the run ends by *offering* one release PR `<target> → <default>` for the user to review and merge — the one place cloud CI runs — never merging to the default branch on its own. You name that branch before anything launches, scope-native: the branch the user named if they gave one ("collect spec #44 on `spec/44-mvp`"); else **the session's designated working branch** when the environment pinned one at start — a hosted session's `claude/...` branch exists to receive exactly this run, so use it rather than minting a `spec/*` twin beside it (ADR-0028); else the scope's own name when it maps to a spec, epic, or slice (`spec/<n>-<slug>`); else a generated fallback for an ad-hoc frontier (`launch/frontier-<today>`, or `launch/tickets-<n>-<m>` for a handful of loose numbers). A named target the remote lacks is put on origin by the run's preflight — created from the default branch's tip, or pushed as-is when it exists only locally with committed work (a pinned session's branch), the presence check made against the live remote, never a stale tracking ref (ADR-0035). **Trunk** — each ticket merged straight to the default branch, live the moment it lands — is now the explicit opt-in: choose it only when the user asks in so many words ("land each ticket on master as it goes"), and never when the environment forbids pushing to the default branch. A pinned-branch environment ("develop on this branch; no unprompted PRs") constrains the *deliverable*, which consolidation already honors — the default branch stays untouched, the release PR stays offered-only. It never constrains the *engine*: the user typing `/launch-implement` is the explicit go-ahead for the loop's own mechanics — per-ticket `ralph/*` branches, pushed as they grow and landed on the target by the loop, no PR among them — and is never a reason to drop to sequential in-session building. Whichever target, restate it in the echo — a choice the user should recognize, never silent.
|
|
22
22
|
|
|
23
23
|
**Resolve prose to data before anything launches.** The loop's inputs are ticket numbers and policy values (`only`, `max`, `width`, `target`) — the workflow takes them as JSON args and refuses a natural-language string by design. Translating the user's words into that scope, against live tracker state, is *your* job, and it ends with an echo before any dispatch: "Scope: #14, #15, #19 — the remaining slice-1 tickets; #19 builds after #14. Cap: none. Target: consolidate on `spec/2-checkout` (`master` untouched; one release PR offered at the end). Engine: the `ralph` workflow." A misread scope corrected here costs a sentence; corrected after launch it costs a run. That echo is your one pre-launch report — resolve the scope, repair setup (Step 2), and route (Step 3) without narrating the checks in between; surface a step only when it fails or changes the scope.
|
|
24
24
|
|
|
@@ -17,7 +17,7 @@ This skill is the loop's contract and its supervisor. The loop itself runs as th
|
|
|
17
17
|
|
|
18
18
|
## Policies
|
|
19
19
|
|
|
20
|
-
- **Integration target: declared, singular, restated.** Every run lands its per-ticket branches onto exactly one base, named before anything dispatches and again in the close-out. **Consolidation** (the default, ADR-0026): one integration branch (e.g. `spec/44-mvp`) collects the whole campaign and the default branch is never touched; the front door names it scope-native — the session's designated working branch when the environment pinned one at start, else the scope's own spec/epic/slice name when it maps to one, else a generated `launch/*` fallback — and passes it as the `target` arg. A pinned-branch session (ADR-0028) changes only that name: the user's start authorizes the loop's mechanics, per-ticket `ralph/*` branches included, and the pin's real demands — default branch untouched, release PR offered not opened — are exactly what consolidation already does. The run ends by *offering* one release PR `<target> → <default>` — opened only when the user says so; that PR is where cloud CI runs, once. **Trunk** (the explicit opt-in): the repository's default branch — each verified land is immediately on mainline, and when the run ends there is nothing left to integrate; select it only when the user asks for per-ticket lands on the default branch, and never when the environment forbids pushing there.
|
|
20
|
+
- **Integration target: declared, singular, restated.** Every run lands its per-ticket branches onto exactly one base, named before anything dispatches and again in the close-out. **Consolidation** (the default, ADR-0026): one integration branch (e.g. `spec/44-mvp`) collects the whole campaign and the default branch is never touched; the front door names it scope-native — the session's designated working branch when the environment pinned one at start, else the scope's own spec/epic/slice name when it maps to one, else a generated `launch/*` fallback — and passes it as the `target` arg. A pinned-branch session (ADR-0028) changes only that name: the user's start authorizes the loop's mechanics, per-ticket `ralph/*` branches included, and the pin's real demands — default branch untouched, release PR offered not opened — are exactly what consolidation already does. The run ends by *offering* one release PR `<target> → <default>` — opened only when the user says so; that PR is where cloud CI runs, once. **Trunk** (the explicit opt-in): the repository's default branch — each verified land is immediately on mainline, and when the run ends there is nothing left to integrate; select it only when the user asks for per-ticket lands on the default branch, and never when the environment forbids pushing there. Whether the base is on the remote is judged against the live remote (`git ls-remote` after a pruning fetch, never a stale `origin/<base>` tracking ref): a named consolidation target absent there is created from the default branch's tip, a session-pinned base that exists only locally is pushed to origin, and only then does preflight verify — its green verdict asserts the base is on origin, it never infers it from a local ref. A missing *default* branch stays a refusal. `Closes #n` never auto-fires off the default branch, so the explicit post-land close is load-bearing, not belt-and-suspenders.
|
|
21
21
|
- **The loop lands in the checkout it was launched from.** Preflight syncs the base into it (clean tree required — commit or stash first; a local base that has diverged from the remote is a refusal), builders work in their own worktrees, and every land and checkpoint runs there one at a time — the warm caches are what make the gate cheap. At the end the checkout sits on the target at its landed tip.
|
|
22
22
|
- **Width: 3** implementers at once, kept busy by a **work pool** — when one finishes, the next ready ticket dispatches; a slow ticket never holds the others. Width multiplies conflict rate and shared-machine load, not just throughput — use 1 until a run has landed tickets cleanly on this project (the workflow's `canary: true` encodes exactly that). Cut a batch below width when its tickets would obviously collide (same module, same files); when in doubt, narrow. Tickets that add DB migrations are a known collision (two parallel implementers both claim the next migration number): the workflow keeps **one migration-adding ticket in flight at a time** from the graph reader's flag; in skill mode, serialize them yourself.
|
|
23
23
|
- **Ordering: critical path first.** Among ready tickets, the one with the most transitive dependents dispatches first, so a wide graph unblocks quickly — computed from the parsed edges, never by a model.
|
|
@@ -40,7 +40,7 @@ Verify them silently: a green base and a reachable tracker are the expected case
|
|
|
40
40
|
|
|
41
41
|
1. `.launchrail.yml` exists with `issueTracker` not `none`, and the tracker is reachable **from this environment**: check whether the CLI the project docs assume (e.g. `gh`) is installed here; if not, identify the substitute (e.g. GitHub MCP tools) and name it in every dispatch.
|
|
42
42
|
2. Open tickets labeled `ready-for-agent` exist and carry explicit `Blocked by: #n` edges (or the tracker's native blocking relations). No tickets with edges → nothing to orchestrate; point the user at `launch-tickets`. If anything wearing `ready-for-agent` is plainly not an implementable ticket — a published spec, research notes, an epic — stop and have it relabeled (e.g. `spec`) before starting: the frontier is computed from the label alone and cannot tell prose from work.
|
|
43
|
-
3. The integration target is resolved (a consolidation branch by default, or trunk when the user opts in — see Policies) and its branch is green **in this checkout**: the tree is clean, the base is synced (
|
|
43
|
+
3. The integration target is resolved (a consolidation branch by default, or trunk when the user opts in — see Policies) and its branch is green **in this checkout**: the tree is clean, the base is synced and confirmed on origin against the live remote (`git ls-remote` after a pruning fetch — never a stale `origin/<base>` tracking ref, which makes a local-only base look present and then fails every land at `git fetch origin <base>`): a named consolidation branch the remote lacks is created from the default branch's tip, a session-pinned base that exists only locally is pushed, and a local base that has diverged from the remote is a refusal — push or reset it first; the install command has run, and `npx @wemuda/launchrail verify` exited 0 — report actual exit codes, not the reassuring summary line; a broken base poisons every implementer after it. A missing *default* branch is a refusal, not a cue to guess another. An **empty verification contract fails `verify` and is a refusal condition**: a run whose completion nothing can verify must not start. Tell the user to configure `testing` commands in `.launchrail.yml` first. **On a relaunch** whose base still sits at a sha a previous run verified green, pass that sha as `knownGreen`: preflight syncs and installs but skips re-proving the base.
|
|
44
44
|
4. The verbatim local commands are known (from `AGENTS.md` / `.launchrail.yml`): install, the fast gate, the full gate, and which checks belong to the shared local machine. If `testing.checkCommand` is unset the fast gate is the unit command — fine, but a project that names a quicker lint/typecheck/unit gate there makes every land cheaper.
|
|
45
45
|
|
|
46
46
|
## The loop
|
package/package.json
CHANGED