@limina-labs/momentum 0.33.0 → 0.34.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.
@@ -167,6 +167,10 @@ function transformCommandsIntoSkills(targetDir, recordManaged) {
167
167
 
168
168
  let count = 0;
169
169
  for (const file of fs.readdirSync(commandsDir)) {
170
+ // Skip macOS AppleDouble sidecars FIRST: `._complete-phase.md` ends with
171
+ // `.md`, so an endsWith check alone would mint a `._complete-phase/SKILL.md`
172
+ // junk skill dir (Phase 27 G4 — the source of the ._* skill-dir litter).
173
+ if (file.startsWith('._') || file === '.DS_Store') continue;
170
174
  if (!file.endsWith('.md')) continue;
171
175
  const name = file.replace(/\.md$/, '');
172
176
  const body = fs.readFileSync(path.join(commandsDir, file), 'utf8');
@@ -223,3 +227,6 @@ function renderSkillMarkdown(name, description, recipeBody) {
223
227
  recipeBody.endsWith('\n') ? recipeBody : recipeBody + '\n',
224
228
  ].join('\n');
225
229
  }
230
+
231
+ // Phase 27 G4 — exported for the AppleDouble-filter regression test.
232
+ module.exports.transformCommandsIntoSkills = transformCommandsIntoSkills;
@@ -173,6 +173,8 @@ function ensureCommandFrontmatter(targetDir) {
173
173
  }
174
174
  let count = 0;
175
175
  for (const file of fs.readdirSync(commandsDir)) {
176
+ // Skip AppleDouble sidecars first — `._x.md` ends with `.md` (Phase 27 G4).
177
+ if (file.startsWith('._') || file === '.DS_Store') continue;
176
178
  if (!file.endsWith('.md')) continue;
177
179
  const filePath = path.join(commandsDir, file);
178
180
  const body = fs.readFileSync(filePath, 'utf8');
@@ -29,6 +29,7 @@ const PLUGIN_NAME = 'momentum';
29
29
  function copyDirRecursive(src, dest, dryRun, log) {
30
30
  const entries = fs.readdirSync(src, { withFileTypes: true });
31
31
  for (const entry of entries) {
32
+ if (entry.name.startsWith('._') || entry.name === '.DS_Store') continue; // Phase 27 G4 — no AppleDouble litter
32
33
  const s = path.join(src, entry.name);
33
34
  const d = path.join(dest, entry.name);
34
35
  if (entry.isDirectory()) {
package/bin/lanes.js CHANGED
@@ -18,6 +18,7 @@ const { spawnSync } = require('child_process');
18
18
 
19
19
  const MOMENTUM_ROOT = path.resolve(__dirname, '..');
20
20
  const state = require(path.join(MOMENTUM_ROOT, 'core', 'lanes', 'lib', 'state'));
21
+ const cleanup = require(path.join(MOMENTUM_ROOT, 'core', 'lanes', 'lib', 'cleanup'));
21
22
 
22
23
  // ─── small helpers ───────────────────────────────────────────────────────
23
24
 
@@ -39,7 +40,7 @@ function parseFlags(argv) {
39
40
  for (let i = 0; i < argv.length; i++) {
40
41
  const a = argv[i];
41
42
  if (a === '--no-worktree' || a === '--rm-worktree' || a === '--execute' ||
42
- a === '--force' || a === '--json' || a === '--ack-all') {
43
+ a === '--force' || a === '--json' || a === '--ack-all' || a === '--no-remote') {
43
44
  flags[a.slice(2)] = true;
44
45
  } else if (a.startsWith('--')) {
45
46
  flags[a.slice(2)] = argv[++i];
@@ -197,7 +198,7 @@ function cmdDone(cwd, argv) {
197
198
  function cmdClose(cwd, argv) {
198
199
  const { flags, positional } = parseFlags(argv);
199
200
  const id = positional[0];
200
- if (!id) return fail('usage: momentum lanes close <lane-id> [--rm-worktree]');
201
+ if (!id) return fail('usage: momentum lanes close <lane-id> [--rm-worktree] [--force] [--no-remote]');
201
202
  const anchor = requireAnchor(cwd);
202
203
  if (!anchor) return;
203
204
  let lane;
@@ -206,10 +207,21 @@ function cmdClose(cwd, argv) {
206
207
  } catch (err) {
207
208
  return fail(err.message);
208
209
  }
209
- if (flags['rm-worktree'] && lane.worktree) {
210
- const res = git(cwd, 'worktree', 'remove', lane.worktree, '--force');
211
- if (res.status === 0) console.log(`✓ worktree removed: ${lane.worktree}`);
212
- else console.log(`⚠ could not remove worktree (${res.stderr.trim()}) — remove manually`);
210
+ // ENH-063: close does FULL cleanup — local + remote branch (default-branch-
211
+ // safe) + lane state, plus the worktree with --rm-worktree. Best-effort: an
212
+ // unmerged branch is refused (never force-deleted) but that does not fail close.
213
+ const res = cleanup.cleanupTarget({
214
+ cwd,
215
+ branch: lane.branch,
216
+ worktree: flags['rm-worktree'] ? lane.worktree : null,
217
+ laneId: id,
218
+ deleteRemote: !flags['no-remote'],
219
+ force: Boolean(flags.force),
220
+ });
221
+ const glyph = { removed: '✓', deleted: '✓', cleared: '✓', skipped: 'ℹ', blocked: '⚠' };
222
+ for (const a of res.actions) console.log(` ${glyph[a.status] || '·'} ${a.step}: ${a.detail}`);
223
+ if (!res.ok) {
224
+ console.log(`⚠ some cleanup refused (${res.blocked.join(', ')}) — e.g. an unmerged branch; rerun with --force only if intended`);
213
225
  }
214
226
  console.log(`✓ lane '${id}' closed`);
215
227
  }
@@ -243,6 +255,13 @@ Usage:
243
255
  momentum lanes land <id> [--into <ref>] [--execute] [--force] [--mark-landed]
244
256
  --mark-landed bookkeeping only: record an out-of-band merge that
245
257
  already happened (done + merged lane → landed)
258
+ momentum lanes cleanup <branch> [--worktree P] [--lane ID] [flags]
259
+ Remove a spent branch's worktree + branch + lane state, default-branch-safe
260
+ --no-remote skip origin delete --force allow -D / dirty worktree
261
+ --dry-run report only --keep-state leave lane dir untouched
262
+ momentum lanes reconcile [--execute] [--into <ref>] [--json]
263
+ Sweep landed/merged lanes whose terminal merge happened out-of-band
264
+ (human/forge) and clean them once verified contained in <ref>
246
265
 
247
266
  State lives at <git-common-dir>/momentum/lanes — shared by all worktrees,
248
267
  untracked, no daemon. Substrate: plain git worktrees by default; treehouse
@@ -287,6 +306,10 @@ function dispatch(argv, cwd) {
287
306
  return delegate('signals', 'cmdInbox', rest);
288
307
  case 'land':
289
308
  return delegate('land', 'cmdLand', rest);
309
+ case 'cleanup':
310
+ return delegate('cleanup', 'cmdCleanup', rest);
311
+ case 'reconcile':
312
+ return delegate('reconcile', 'cmdReconcile', rest);
290
313
  case '--help':
291
314
  case '-h':
292
315
  case 'help':
package/bin/momentum.js CHANGED
@@ -1620,6 +1620,7 @@ Ecosystem use — entry/exit commands (run from any repo):
1620
1620
  existing ecosystem
1621
1621
  momentum leave Detach THIS repo from its ecosystem
1622
1622
  momentum doctor Diagnose state + list available transitions
1623
+ momentum doctor --clean [--execute] Sweep stray artifacts (AppleDouble/.bak/orphan worktrees)
1623
1624
  momentum antigravity plugin-pack Pack momentum skills as a native Antigravity plugin ([--global])
1624
1625
 
1625
1626
  Ecosystem use — operator toolkit (advanced):
@@ -129,8 +129,103 @@ function cmdLeave(args) {
129
129
  console.log(`✓ Left "${reg.ecosystemName}". Now standalone.`);
130
130
  }
131
131
 
132
+ // ── Phase 27 G4 — stray-artifact sweep (`momentum doctor --clean`) ──────────
133
+
134
+ const BAK_ROOTS = ['.claude', '.agents', '.opencode', '.codex', '.githooks', 'scripts'];
135
+
136
+ /** Bounded walk collecting AppleDouble sidecars anywhere (always junk). */
137
+ function walkAppleDouble(dir, acc, depth) {
138
+ if (depth > 8) return;
139
+ let entries;
140
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
141
+ for (const e of entries) {
142
+ if (e.name === '.git' || e.name === 'node_modules') continue;
143
+ const abs = path.join(dir, e.name);
144
+ if (e.name.startsWith('._') || e.name === '.DS_Store') { acc.appleDouble.push(abs); continue; }
145
+ if (e.isDirectory()) walkAppleDouble(abs, acc, depth + 1);
146
+ }
147
+ }
148
+
149
+ /** `.bak` files under a momentum-managed root only (never user backups). */
150
+ function collectBaks(dir, acc, depth) {
151
+ if (depth > 8) return;
152
+ let entries;
153
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
154
+ for (const e of entries) {
155
+ const abs = path.join(dir, e.name);
156
+ if (e.isDirectory()) collectBaks(abs, acc, depth + 1);
157
+ else if (e.name.endsWith('.bak')) acc.baks.push(abs);
158
+ }
159
+ }
160
+
161
+ /** Find stray artifacts: AppleDouble, momentum `.bak` litter, orphan worktrees. */
162
+ function findStrayArtifacts(root) {
163
+ const { spawnSync } = require('child_process');
164
+ const acc = { appleDouble: [], baks: [], prunableWorktrees: [], orphanWorktreeDirs: [] };
165
+ walkAppleDouble(root, acc, 0);
166
+ for (const sub of BAK_ROOTS) collectBaks(path.join(root, sub), acc, 0);
167
+ for (const f of ['CLAUDE.md.bak', 'AGENTS.md.bak']) {
168
+ const p = path.join(root, f);
169
+ if (fs.existsSync(p)) acc.baks.push(p);
170
+ }
171
+ // registered worktrees whose directory is gone → prunable
172
+ const registered = new Set();
173
+ const wl = spawnSync('git', ['worktree', 'list', '--porcelain'], { cwd: root, encoding: 'utf8' });
174
+ if (wl.status === 0) {
175
+ for (const line of wl.stdout.split('\n')) {
176
+ if (line.startsWith('worktree ')) {
177
+ const p = line.slice('worktree '.length);
178
+ registered.add(realpathOf(p));
179
+ if (p !== root && !fs.existsSync(p)) acc.prunableWorktrees.push(p);
180
+ }
181
+ }
182
+ }
183
+ // .claude/worktrees/* leftover dirs not registered as git worktrees
184
+ const cw = path.join(root, '.claude', 'worktrees');
185
+ try {
186
+ for (const name of fs.readdirSync(cw)) {
187
+ const abs = path.join(cw, name);
188
+ if (fs.statSync(abs).isDirectory() && !registered.has(realpathOf(abs))) acc.orphanWorktreeDirs.push(abs);
189
+ }
190
+ } catch { /* no .claude/worktrees */ }
191
+ return acc;
192
+ }
193
+
194
+ function sweepStrayArtifacts(root, execute) {
195
+ const { spawnSync } = require('child_process');
196
+ const stray = findStrayArtifacts(root);
197
+ const total = stray.appleDouble.length + stray.baks.length
198
+ + stray.prunableWorktrees.length + stray.orphanWorktreeDirs.length;
199
+ const rel = (p) => path.relative(root, p) || p;
200
+
201
+ console.log(execute ? 'Cleaning stray artifacts:' : 'Stray artifacts (dry-run — pass --execute to remove):');
202
+ if (total === 0) { console.log(' ✓ none — tree is clean'); return 0; }
203
+
204
+ for (const p of stray.appleDouble) {
205
+ if (execute) { try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* ignore */ } }
206
+ console.log(` ${execute ? '✓ removed' : '·'} AppleDouble: ${rel(p)}`);
207
+ }
208
+ for (const p of stray.baks) {
209
+ if (execute) { try { fs.rmSync(p, { force: true }); } catch { /* ignore */ } }
210
+ console.log(` ${execute ? '✓ removed' : '·'} .bak: ${rel(p)}`);
211
+ }
212
+ for (const p of stray.orphanWorktreeDirs) {
213
+ if (execute) { try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* ignore */ } }
214
+ console.log(` ${execute ? '✓ removed' : '·'} orphan worktree dir: ${rel(p)}`);
215
+ }
216
+ if (stray.prunableWorktrees.length) {
217
+ if (execute) spawnSync('git', ['worktree', 'prune'], { cwd: root });
218
+ for (const p of stray.prunableWorktrees) console.log(` ${execute ? '✓ pruned' : '·'} stale worktree ref: ${rel(p)}`);
219
+ }
220
+ console.log(execute ? `✓ cleaned ${total} stray artifact(s)` : `${total} stray artifact(s) — rerun with --execute to remove`);
221
+ return 0;
222
+ }
223
+
132
224
  function cmdDoctor(args) {
133
- void args;
225
+ // Phase 27 G4 — `momentum doctor --clean [--execute]` sweeps stray artifacts.
226
+ if (args && args.includes('--clean')) {
227
+ return sweepStrayArtifacts(realpathOf(process.cwd()), args.includes('--execute'));
228
+ }
134
229
  const cwd = realpathOf(process.cwd());
135
230
  lib._clearRootCache();
136
231
  const state = stateLib.detectState(cwd);
@@ -176,6 +271,17 @@ function cmdDoctor(args) {
176
271
  }
177
272
  } catch (_err) { /* advisory is best-effort — never block doctor */ }
178
273
 
274
+ // Phase 27 G4 — stray-artifact advisory (best-effort, non-blocking).
275
+ try {
276
+ const stray = findStrayArtifacts(cwd);
277
+ const n = stray.appleDouble.length + stray.baks.length
278
+ + stray.prunableWorktrees.length + stray.orphanWorktreeDirs.length;
279
+ if (n > 0) {
280
+ console.log(`Advisory: ${n} stray artifact(s) (AppleDouble / .bak / orphan worktrees) — run \`momentum doctor --clean\` to review, \`--clean --execute\` to remove.`);
281
+ console.log('');
282
+ }
283
+ } catch { /* best-effort */ }
284
+
179
285
  const transitions = stateLib.availableTransitions(state, reg || {});
180
286
  if (transitions.length === 0) {
181
287
  console.log('No suggested next steps for this state.');
@@ -215,4 +321,6 @@ module.exports = {
215
321
  cmdLeave,
216
322
  cmdDoctor,
217
323
  formatStateLabel,
324
+ findStrayArtifacts,
325
+ sweepStrayArtifacts,
218
326
  };
@@ -53,22 +53,34 @@ Verify, finalize, and release a completed phase.
53
53
  - Format as fenced code blocks with the command as the heading
54
54
  - This is the durable record that Rule 12 was honored — it must exist before the release section runs
55
55
 
56
- 7. Update all tracking:
56
+ 7. Update all tracking (this happens BEFORE the release — see the Release
57
+ gate below; the release must never precede tracking):
57
58
  - `specs/phases/README.md` → mark `Complete`
58
- - `specs/status.md` → remove this lane's row from the Active Phase table
59
- (leave other lanes' rows untouched Rule 15), update current phase,
60
- add release version
61
- - `specs/planning/roadmap.md` update phase status
59
+ - `specs/status.md` → (a) header **`Latest Release`** = this version + name;
60
+ (b) add the phase's row to the **Completed Phases** table; (c) remove this
61
+ lane's row from the Active Phase table (leave other lanes' rows untouched —
62
+ Rule 15); (d) update Current Phase
63
+ - `specs/planning/roadmap.md` → update phase status → Complete (+ version)
62
64
  - `specs/changelog/YYYY-MM.md` → add release entry
63
65
 
64
66
  ### Release
65
67
 
66
- > **Gate (Rule 12):** Do NOT enter Release if `retrospective.md` lacks a `## Verification Evidence` section. If evidence is missing, return to Step 3 and capture it.
67
-
68
- 8. Commit all remaining changes and push:
68
+ > **Gate A (Rule 12):** Do NOT enter Release if `retrospective.md` lacks a `## Verification Evidence` section. If evidence is missing, return to Step 3 and capture it.
69
+ >
70
+ > **Gate B (tracking-before-release):** Do NOT merge, tag, or release until ALL
71
+ > tracking that reflects this release (Step 7 — `status.md` `Latest Release` +
72
+ > Completed Phases row, `specs/phases/README.md`, `roadmap.md`, the changelog)
73
+ > is written AND committed on the phase branch (Step 8). The release must never
74
+ > precede tracking — otherwise `status.md` says "in progress" while the tag is
75
+ > already public. Confirm the working tree is clean of tracking edits (Step 8
76
+ > committed them) before Step 9's approval and Step 10's merge/tag.
77
+
78
+ 8. Commit all remaining changes (retrospective + ALL Step-7 tracking) and push.
79
+ This commit MUST land before the merge/tag — it is Gate B:
69
80
  ```bash
70
81
  git add -A
71
82
  git commit -m "docs: complete Phase N - {phase name}"
83
+ git status --porcelain # expect empty — tracking is committed (Gate B satisfied)
72
84
  git push origin phase-N-shortname
73
85
  ```
74
86
 
@@ -118,11 +130,26 @@ Verify, finalize, and release a completed phase.
118
130
  git tag -a vX.Y.Z -m "Phase N: {phase name}"
119
131
  git push origin vX.Y.Z
120
132
  ```
121
- For `end_state: feature-branch-only` (config), SKIP the merge
122
- sequence — the branch is already pushed; only tag (if the project tags
123
- releases on the feature branch) per `## Project Extensions`. For
124
- `end_state: staging-promotion`, merge to `branch_flow[0]` only and stop;
125
- tag + release happen after the user promotes to `main`.
133
+ **`end_state` variants** (config; the sequence above is the
134
+ `merge-after-yes` default):
135
+ - `feature-branch-only` — SKIP the merge/tag; the branch is pushed, the
136
+ human owns merge + release.
137
+ - `staging-promotion` merge to `branch_flow[0]` only, then STOP; the human
138
+ promotes to `main` and tags.
139
+ - `open-pr` — do NOT merge; push the branch and open a PR for review
140
+ (config-gated, agent-run — `git_forge=github`: `gh pr create`; GitLab:
141
+ `glab mr create`), then STOP. Merge happens on the forge on human approval.
142
+
143
+ **Human handshake → verify → clean** (every non-`merge-after-yes`
144
+ end_state). Step-13 auto-cleanup only fires when the AGENT did the terminal
145
+ merge. When a human/forge does it, NEVER clean on trust: ask the user to
146
+ confirm once they have merged/promoted, then verify + clean in one step —
147
+ ```bash
148
+ momentum lanes reconcile # is the branch a verified ancestor of the terminal branch yet?
149
+ momentum lanes reconcile --execute # only once it shows 'landed' — cleans worktree + branch + state
150
+ ```
151
+ `reconcile` refuses to clean a lane whose branch is not yet contained in the
152
+ terminal branch — a "yes, I merged it" is never taken on trust (Rule 12).
126
153
 
127
154
  12. Report summary to user:
128
155
  - What was delivered
@@ -130,19 +157,29 @@ Verify, finalize, and release a completed phase.
130
157
  status from `## Project Extensions` if any ran
131
158
  - What's next
132
159
 
133
- 13. Delete the merged phase branch (ENH-042 — now that merge → main + release
134
- are confirmed; do NOT skip this, or stale branches accumulate on origin):
160
+ 13. Clean up the spent phase branch (ENH-042 / BUG-026 — now that merge → main
161
+ + release are confirmed; do NOT skip, or stale branches/worktrees/state
162
+ accumulate). One command removes ALL of it, default-branch-safe:
135
163
  ```bash
136
- git branch -d phase-N-shortname # local `-d` (not `-D`) refuses if unmerged
137
- git push origin --delete phase-N-shortname # remote
164
+ # worktree (if the phase ran in a lane) + local branch + remote branch
165
+ # (skipped when it is the forge default — BUG-025 guard) + lane state.
166
+ momentum lanes cleanup phase-N-shortname
167
+ # --lane <id> if this phase ran as a registered `momentum lanes` lane
168
+ # --force only to override an unmerged-branch refusal AFTER you have
169
+ # verified the branch actually landed
138
170
  ```
171
+ If cleanup reports `blocked` on the remote branch because it is the forge
172
+ DEFAULT, STOP — that is the BUG-025 hijack. Reassign the default to the
173
+ terminal branch first (e.g. `git_forge=github`: `gh repo edit
174
+ --default-branch main`), then rerun. `-d` (not `-D`) refuses an unmerged
175
+ branch by design — investigate rather than force.
139
176
 
140
177
  14. Branch-hygiene self-audit (ENH-042) — confirm no released phase left a
141
- dangling branch:
178
+ dangling branch or worktree:
142
179
  ```bash
143
- # Any origin branch for an already-released phase should be gone.
144
180
  git branch -r | grep -E 'origin/(phase-|chore/|audit/)' || echo "✓ clean — no stale branches"
181
+ git worktree list # no leftover lane worktrees for landed work
145
182
  ```
146
- For each match, verify it is fully merged into `main`
147
- (`git branch -r --merged main`) and delete it per step 13. Leave any
183
+ For each stale match, verify it is fully merged into `main`
184
+ (`git branch -r --merged main`) and clean it per step 13. Leave any
148
185
  *unmerged* branch alone and surface it to the user.
@@ -75,7 +75,7 @@ landing) and ack what you've acted on.
75
75
  momentum lanes done <id> # enter the landing queue
76
76
  momentum lanes land <id> # validate: turn, freshness, graded gate
77
77
  momentum lanes land <id> --execute# merge --no-ff into the CURRENT branch
78
- momentum lanes close <id> [--rm-worktree]
78
+ momentum lanes close <id> [--rm-worktree] # retire: full cleanup (branch + state)
79
79
  ```
80
80
 
81
81
  Landing follows the Rule 6 **Landing Order**: one lane at a time, run the
@@ -87,6 +87,23 @@ with a non-empty `## Verification Evidence` section (Rule 12).
87
87
  `land` never pushes — pushes to protected branches keep their own
88
88
  approval gate.
89
89
 
90
+ ### Cleanup — a landed lane is spent (BUG-026)
91
+
92
+ ```bash
93
+ momentum lanes reconcile # report lanes now merged out-of-band (human/forge)
94
+ momentum lanes reconcile --execute # clean them: worktree + branch + state
95
+ momentum lanes cleanup <branch> [--worktree P] [--dry-run] # one branch, manually
96
+ ```
97
+
98
+ When the AGENT lands on the terminal branch (`branch_flow` last entry),
99
+ `land --execute` **auto-cleans** the worktree + branch + lane state
100
+ (default-branch-safe; `--keep` opts out). When a HUMAN or the FORGE does
101
+ the terminal merge (`end_state` `staging-promotion` / `feature-branch-only`
102
+ / `open-pr`), cleanup is deferred: run `momentum lanes reconcile` — it
103
+ cleans only lanes whose branch is a **verified ancestor** of the terminal
104
+ branch (a "yes, I merged it" is never trusted, Rule 12). Cleanup never
105
+ deletes a branch that is the forge default (the BUG-025 guard).
106
+
90
107
  ## Tracking
91
108
 
92
109
  Opening/closing lanes is not itself phase history; the WORK inside a lane
@@ -87,7 +87,19 @@ After setup, **executes the plan end-to-end autonomously** — no per-group appr
87
87
 
88
88
  9. Create git branch and initial commit:
89
89
  ```bash
90
- git checkout main && git pull origin main
90
+ git checkout main
91
+ # Resilient when 'main' is unborn on the remote (fresh repo — BUG-025):
92
+ git ls-remote --heads origin main | grep -q . && git pull origin main || true
93
+ # BUG-025 safety net: the terminal branch must ALREADY be the remote default
94
+ # before ANY phase branch is pushed (founding normally established it). If it
95
+ # is still not on origin, establish it FIRST so phase-N can't hijack default:
96
+ if git remote get-url origin >/dev/null 2>&1 && ! git ls-remote --heads origin main | grep -q .; then
97
+ touch .momentum/merge-approved && git push -u origin main && git remote set-head origin main 2>/dev/null || true
98
+ fi
99
+ # Optional forge repair (only when git_forge=github AND gh is on PATH;
100
+ # non-fatal; idempotent). Fixes a repo whose default was ALREADY hijacked.
101
+ # Agent-run recipe step — momentum ships no forge code, stays forge-neutral:
102
+ command -v gh >/dev/null 2>&1 && gh repo edit --default-branch main 2>/dev/null || true
91
103
  git checkout -b phase-N-shortname
92
104
  git add specs/
93
105
  git commit -m "docs: start Phase N - {phase name}"
@@ -135,6 +147,10 @@ defaults below) and ask the user the gate question matching the project's
135
147
  - **`feature-branch-only`**: "All groups complete and verified. The feature
136
148
  branch `<phase-branch>` is pushed. Merge, tag, and release are yours to
137
149
  do. Review the branch?"
150
+ - **`open-pr`**: "All groups complete and verified. Ready to push
151
+ `<phase-branch>` and open a PR for review? Merge + release happen when a
152
+ human approves and merges the PR on the forge; then `momentum lanes
153
+ reconcile --execute` cleans up the branch + worktree + state."
138
154
 
139
155
  Then append: "Project-specific release/publish/deploy steps (e.g.
140
156
  `npm publish`, forge release creation) run from `## Project Extensions` in
@@ -96,7 +96,9 @@ If you're still exploring the idea, run `/brainstorm-idea` first.
96
96
  ```
97
97
  (On a brand-new repo this may be the first content commit on the default
98
98
  branch — the bootstrap exception to Rule 6. On a repo with existing code,
99
- normal branch discipline applies.)
99
+ normal branch discipline applies. Founding pushes nothing; `/start-phase`
100
+ establishes the terminal branch as the remote default before it pushes the
101
+ first phase branch — BUG-025.)
100
102
 
101
103
  8. Report to user:
102
104
  - Summary of what was founded
package/core/config.js CHANGED
@@ -52,7 +52,7 @@ const ENUMS = {
52
52
  publish_target: ['npm', 'pypi', 'crates-io', 'nuget', 'none', 'custom'],
53
53
  git_forge: ['github', 'gitlab', 'bitbucket', 'gitea', 'forgejo', 'bare-ssh'],
54
54
  release_flow: ['tag-and-publish', 'tag-and-deploy', 'tag-only', 'custom'],
55
- end_state: ['merge-after-yes', 'staging-promotion', 'feature-branch-only'],
55
+ end_state: ['merge-after-yes', 'staging-promotion', 'feature-branch-only', 'open-pr'],
56
56
  };
57
57
 
58
58
  // Fields the CLI can infer from manifests + git remote. Used by drift detection.
@@ -0,0 +1,349 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared, forge-neutral cleanup action for a "spent" branch/lane
5
+ * (Phase 27 G0 — BUG-026 / ENH-063; see specs/phases/phase-27-lifecycle-cleanup).
6
+ *
7
+ * A phase/lane branch is "spent" once its work has landed on the terminal
8
+ * integration branch. `cleanupTarget()` removes what it leaves behind, in a
9
+ * fixed, idempotent, DEFAULT-BRANCH-SAFE order:
10
+ *
11
+ * 1. worktree git worktree remove (never the caller's own worktree)
12
+ * 2. local branch git branch -d (-D only with force; unmerged → blocked)
13
+ * 3. remote branch git push origin --delete (SKIPPED when it is the forge default)
14
+ * 4. lane state clear inbox/signals; leave a `cleaned` tombstone by default
15
+ *
16
+ * Pure git + node builtins; ships NO forge code — the optional `gh` default-branch
17
+ * assertion lives in the recipes, config-gated (momentum stays forge-neutral).
18
+ * Idempotent: anything already gone is reported `skipped`, never an error. The
19
+ * remote-default guard is load-bearing: it is the reason a hijacked default
20
+ * branch (BUG-025) can never be deleted out from under the repo.
21
+ */
22
+
23
+ const fs = require('fs');
24
+ const path = require('path');
25
+ const { spawnSync } = require('child_process');
26
+
27
+ const state = require('./state');
28
+
29
+ function git(cwd, ...args) {
30
+ return spawnSync('git', args, { cwd, encoding: 'utf8' });
31
+ }
32
+ function ok(res) { return res && res.status === 0; }
33
+ function out(res) { return ((res && res.stdout) || '').trim(); }
34
+ function err(res) { return ((res && res.stderr) || '').trim(); }
35
+
36
+ /** Real path (resolves symlinks like macOS /var → /private/var), best-effort. */
37
+ function realpath(p) {
38
+ try { return fs.realpathSync(p); } catch { return path.resolve(p); }
39
+ }
40
+ function samePath(a, b) {
41
+ return Boolean(a) && Boolean(b) && realpath(a) === realpath(b);
42
+ }
43
+
44
+ // ─── read-only git predicates ────────────────────────────────────────────
45
+
46
+ function hasRemote(cwd, remote = 'origin') {
47
+ return ok(git(cwd, 'remote', 'get-url', remote));
48
+ }
49
+
50
+ function localBranchExists(cwd, branch) {
51
+ return ok(git(cwd, 'rev-parse', '--verify', '--quiet', `refs/heads/${branch}`));
52
+ }
53
+
54
+ function remoteBranchExists(cwd, branch, remote = 'origin') {
55
+ const res = git(cwd, 'ls-remote', '--heads', remote, branch);
56
+ return ok(res) && out(res) !== '';
57
+ }
58
+
59
+ /** True when `ref` is contained in `container` (already merged / landed). */
60
+ function isMerged(cwd, ref, container) {
61
+ return ok(git(cwd, 'merge-base', '--is-ancestor', ref, container));
62
+ }
63
+
64
+ /**
65
+ * The remote's default branch short name (e.g. "main"), or null when unknown.
66
+ * Prefers the locally-tracked symbolic ref; falls back to asking the remote
67
+ * (network) unless { network:false }.
68
+ */
69
+ function remoteDefaultBranch(cwd, { remote = 'origin', network = true } = {}) {
70
+ const local = git(cwd, 'symbolic-ref', '--short', `refs/remotes/${remote}/HEAD`);
71
+ if (ok(local) && out(local)) {
72
+ return out(local).replace(new RegExp(`^${remote}/`), '');
73
+ }
74
+ if (network) {
75
+ const ls = git(cwd, 'ls-remote', '--symref', remote, 'HEAD');
76
+ if (ok(ls)) {
77
+ const m = out(ls).match(/^ref:\s+refs\/heads\/(\S+)\s+HEAD/m);
78
+ if (m) return m[1];
79
+ }
80
+ }
81
+ return null;
82
+ }
83
+
84
+ /** Registered worktrees: [{ path, branch }]. */
85
+ function worktreeList(cwd) {
86
+ const res = git(cwd, 'worktree', 'list', '--porcelain');
87
+ if (!ok(res)) return [];
88
+ const trees = [];
89
+ let cur = null;
90
+ for (const line of out(res).split('\n')) {
91
+ if (line.startsWith('worktree ')) { cur = { path: line.slice('worktree '.length), branch: null }; trees.push(cur); }
92
+ else if (line.startsWith('branch ') && cur) cur.branch = line.slice('branch '.length).replace('refs/heads/', '');
93
+ }
94
+ return trees;
95
+ }
96
+
97
+ // ─── the shared cleanup action ───────────────────────────────────────────
98
+
99
+ /**
100
+ * Remove the artifacts a spent branch/lane leaves behind.
101
+ *
102
+ * @param {object} opts
103
+ * cwd where git runs (must NOT be inside `worktree`)
104
+ * branch branch to delete (local + remote)
105
+ * worktree worktree path to remove (optional)
106
+ * laneId lane-state id to clear (optional)
107
+ * anchor lane-state anchor (optional; derived from cwd)
108
+ * deleteRemote delete origin/<branch> when safe (default true)
109
+ * force allow `git branch -D` / `worktree remove --force` (default false)
110
+ * dryRun report intended actions without performing them
111
+ * keepState keep the full lane dir untouched (default false → tombstone)
112
+ * remote remote name (default 'origin')
113
+ * @returns {{ ok:boolean, actions:Array<{step,status,detail}>, blocked:string[] }}
114
+ */
115
+ function cleanupTarget(opts = {}) {
116
+ const cwd = opts.cwd || process.cwd();
117
+ const {
118
+ branch, worktree, laneId,
119
+ deleteRemote = true, force = false, dryRun = false, keepState = false,
120
+ remote = 'origin',
121
+ } = opts;
122
+ const anchor = opts.anchor || state.resolveAnchor(cwd);
123
+ const actions = [];
124
+ const blocked = [];
125
+ const record = (step, status, detail) => actions.push({ step, status, detail });
126
+
127
+ const selfRoot = out(git(cwd, 'rev-parse', '--show-toplevel'));
128
+
129
+ // 1. worktree — before the branch, since a checked-out branch blocks `-d`
130
+ if (worktree) {
131
+ const registered = worktreeList(cwd).find((w) => samePath(w.path, worktree));
132
+ if (!registered && !fs.existsSync(worktree)) {
133
+ record('worktree', 'skipped', `${worktree} — not present`);
134
+ } else if (samePath(worktree, selfRoot)) {
135
+ record('worktree', 'blocked', `refusing to remove the current worktree (${worktree}) — run cleanup from the integration checkout`);
136
+ blocked.push('worktree');
137
+ } else if (dryRun) {
138
+ record('worktree', 'would', `git worktree remove ${worktree}${force ? ' --force' : ''}`);
139
+ } else {
140
+ const args = ['worktree', 'remove', worktree];
141
+ if (force) args.push('--force');
142
+ const res = git(cwd, ...args);
143
+ if (ok(res)) {
144
+ record('worktree', 'removed', worktree);
145
+ } else {
146
+ record('worktree', 'blocked', `git worktree remove failed (${err(res) || 'dirty or locked'}) — rerun with --force`);
147
+ blocked.push('worktree');
148
+ }
149
+ }
150
+ git(cwd, 'worktree', 'prune');
151
+ }
152
+
153
+ // 2. local branch — skip if its worktree is still present
154
+ if (branch && localBranchExists(cwd, branch)) {
155
+ if (blocked.includes('worktree')) {
156
+ record('local-branch', 'skipped', `'${branch}' — worktree still present`);
157
+ } else if (dryRun) {
158
+ record('local-branch', 'would', `git branch -${force ? 'D' : 'd'} ${branch}`);
159
+ } else {
160
+ const res = git(cwd, 'branch', force ? '-D' : '-d', branch);
161
+ if (ok(res)) {
162
+ record('local-branch', 'deleted', branch);
163
+ } else {
164
+ record('local-branch', 'blocked', `'${branch}' not fully merged (git branch -d refused) — verify it landed, then rerun with --force`);
165
+ blocked.push('local-branch');
166
+ }
167
+ }
168
+ } else if (branch) {
169
+ record('local-branch', 'skipped', `'${branch}' — no local branch`);
170
+ }
171
+
172
+ // 3. remote branch — NEVER the forge default (BUG-025 guard)
173
+ if (branch && deleteRemote) {
174
+ if (!hasRemote(cwd, remote)) {
175
+ record('remote-branch', 'skipped', `no '${remote}' remote`);
176
+ } else if (!remoteBranchExists(cwd, branch, remote)) {
177
+ record('remote-branch', 'skipped', `${remote}/${branch} — not on the remote`);
178
+ } else {
179
+ const def = remoteDefaultBranch(cwd, { remote });
180
+ if (def && def === branch) {
181
+ record('remote-branch', 'blocked', `${remote}/${branch} is the forge DEFAULT branch — refusing to delete (BUG-025 guard); reassign the default first`);
182
+ blocked.push('remote-branch');
183
+ } else if (dryRun) {
184
+ record('remote-branch', 'would', `git push ${remote} --delete ${branch}`);
185
+ } else {
186
+ const res = git(cwd, 'push', remote, '--delete', branch);
187
+ if (ok(res)) {
188
+ record('remote-branch', 'deleted', `${remote}/${branch}`);
189
+ } else {
190
+ record('remote-branch', 'blocked', `git push --delete failed (${err(res)})`);
191
+ blocked.push('remote-branch');
192
+ }
193
+ }
194
+ }
195
+ }
196
+
197
+ // 4. lane state — clear heavy state, leave a `cleaned` tombstone by default
198
+ if (laneId && anchor) {
199
+ const dir = state.laneDir(anchor, laneId);
200
+ const manifest = state.readManifest(anchor, laneId);
201
+ if (!manifest && !fs.existsSync(dir)) {
202
+ record('lane-state', 'skipped', `no lane '${laneId}'`);
203
+ } else if (keepState) {
204
+ record('lane-state', 'skipped', `'${laneId}' — keepState`);
205
+ } else if (dryRun) {
206
+ record('lane-state', 'would', `clear '${laneId}' inbox + tombstone the manifest`);
207
+ } else {
208
+ try { fs.rmSync(state.inboxDir(anchor, laneId), { recursive: true, force: true }); } catch { /* absent */ }
209
+ if (manifest) {
210
+ try {
211
+ state.updateLane(anchor, laneId, { worktree: null, cleaned: true, cleanedAt: new Date().toISOString() });
212
+ } catch { /* ENOLANE race — nothing to tombstone */ }
213
+ }
214
+ record('lane-state', 'cleared', `'${laneId}' inbox removed; manifest tombstoned (cleaned)`);
215
+ }
216
+ }
217
+
218
+ return { ok: blocked.length === 0, actions, blocked };
219
+ }
220
+
221
+ // ─── default-branch establishment (BUG-025) ──────────────────────────────
222
+
223
+ /**
224
+ * Ensure the terminal integration branch (e.g. main) is the branch a fresh
225
+ * forge repo adopts as its default — by making sure it is on the remote FIRST,
226
+ * before any phase branch is ever pushed. Pure git; the forge-side default
227
+ * assertion (`gh repo edit --default-branch`) is a recipe step, config-gated.
228
+ *
229
+ * Read-only by default (returns a plan the recipe acts on). With { push:true }
230
+ * it performs the founding bootstrap push when the remote lacks the branch.
231
+ *
232
+ * @returns {{ status:string, detail:string, pushed:boolean, defaultBranch?:string }}
233
+ * status ∈ no-remote | no-local-branch | needs-push | push-failed | ok | default-mismatch
234
+ */
235
+ function ensureTerminalBranchIsRemoteDefault(cwd, terminalBranch, opts = {}) {
236
+ const { push = false, remote = 'origin', setLocalHead = true } = opts;
237
+ if (!hasRemote(cwd, remote)) {
238
+ return { status: 'no-remote', detail: `no '${remote}' remote — nothing to establish`, pushed: false };
239
+ }
240
+ if (!localBranchExists(cwd, terminalBranch)) {
241
+ return { status: 'no-local-branch', detail: `local '${terminalBranch}' does not exist yet`, pushed: false };
242
+ }
243
+ let pushed = false;
244
+ if (!remoteBranchExists(cwd, terminalBranch, remote)) {
245
+ if (!push) {
246
+ return {
247
+ status: 'needs-push',
248
+ detail: `'${terminalBranch}' is not on ${remote} — push it FIRST (before any phase branch) so the forge adopts it as default`,
249
+ pushed: false,
250
+ };
251
+ }
252
+ const res = git(cwd, 'push', '-u', remote, terminalBranch);
253
+ if (!ok(res)) return { status: 'push-failed', detail: err(res), pushed: false };
254
+ pushed = true;
255
+ }
256
+ if (setLocalHead) git(cwd, 'remote', 'set-head', remote, terminalBranch);
257
+ const def = remoteDefaultBranch(cwd, { remote });
258
+ const isDefault = def === terminalBranch;
259
+ return {
260
+ status: isDefault || pushed ? 'ok' : 'default-mismatch',
261
+ detail: isDefault
262
+ ? `${remote} default is '${terminalBranch}'`
263
+ : `${remote} default is '${def || 'unknown'}' — run the forge assertion (e.g. gh repo edit --default-branch ${terminalBranch}) to repair`,
264
+ pushed,
265
+ defaultBranch: def,
266
+ };
267
+ }
268
+
269
+ // ─── CLI: `momentum lanes cleanup` ───────────────────────────────────────
270
+
271
+ function parseCleanupArgs(argv) {
272
+ const flags = {};
273
+ const positional = [];
274
+ for (let i = 0; i < argv.length; i++) {
275
+ const a = argv[i];
276
+ if (a === '--force' || a === '--dry-run' || a === '--no-remote' || a === '--keep-state' || a === '--json') {
277
+ flags[a.slice(2)] = true;
278
+ } else if (a.startsWith('--')) {
279
+ flags[a.slice(2)] = argv[++i];
280
+ } else {
281
+ positional.push(a);
282
+ }
283
+ }
284
+ return { flags, positional };
285
+ }
286
+
287
+ /**
288
+ * `momentum lanes cleanup <branch> [--worktree P] [--lane ID] [--no-remote]
289
+ * [--force] [--dry-run] [--keep-state] [--json]`
290
+ *
291
+ * A thin surface over cleanupTarget() for the recipes (complete-phase) and
292
+ * for manual use. When --lane is given but --branch/--worktree are omitted,
293
+ * they are read from the lane manifest.
294
+ */
295
+ function cmdCleanup(cwd, argv) {
296
+ const { flags, positional } = parseCleanupArgs(argv);
297
+ let branch = positional[0];
298
+ const laneId = flags.lane || null;
299
+ let worktree = flags.worktree || null;
300
+
301
+ if (laneId) {
302
+ const anchor = state.resolveAnchor(cwd);
303
+ const lane = anchor && state.readManifest(anchor, laneId);
304
+ if (lane) {
305
+ branch = branch || lane.branch;
306
+ worktree = worktree || lane.worktree || null;
307
+ }
308
+ }
309
+ if (!branch && !worktree && !laneId) {
310
+ console.error('✗ usage: momentum lanes cleanup <branch> [--worktree P] [--lane ID] [--no-remote] [--force] [--dry-run] [--keep-state]');
311
+ return 1;
312
+ }
313
+
314
+ const result = cleanupTarget({
315
+ cwd, branch, worktree, laneId,
316
+ deleteRemote: !flags['no-remote'],
317
+ force: Boolean(flags.force),
318
+ dryRun: Boolean(flags['dry-run']),
319
+ keepState: Boolean(flags['keep-state']),
320
+ });
321
+
322
+ if (flags.json) {
323
+ console.log(JSON.stringify(result, null, 2));
324
+ return result.ok ? 0 : 1;
325
+ }
326
+
327
+ const glyph = { removed: '✓', deleted: '✓', cleared: '✓', skipped: 'ℹ', would: '·', blocked: '✗' };
328
+ for (const a of result.actions) {
329
+ console.log(` ${glyph[a.status] || '·'} ${a.step}: ${a.detail}`);
330
+ }
331
+ if (result.ok) {
332
+ console.log(flags['dry-run'] ? '✓ dry-run — nothing changed' : '✓ cleanup complete');
333
+ return 0;
334
+ }
335
+ console.error(`✗ cleanup incomplete — blocked: ${result.blocked.join(', ')}`);
336
+ return 1;
337
+ }
338
+
339
+ module.exports = {
340
+ cleanupTarget,
341
+ ensureTerminalBranchIsRemoteDefault,
342
+ remoteDefaultBranch,
343
+ remoteBranchExists,
344
+ localBranchExists,
345
+ isMerged,
346
+ hasRemote,
347
+ worktreeList,
348
+ cmdCleanup,
349
+ };
@@ -41,17 +41,29 @@ const path = require('path');
41
41
  const { spawnSync } = require('child_process');
42
42
 
43
43
  const state = require('./state');
44
+ const cleanup = require('./cleanup');
45
+ const config = require('../../config');
44
46
 
45
47
  function git(cwd, ...args) {
46
48
  return spawnSync('git', args, { cwd, encoding: 'utf8' });
47
49
  }
48
50
 
51
+ /** Terminal integration branch = last branch_flow entry (config), default 'main'. */
52
+ function terminalBranch(repoRoot) {
53
+ try {
54
+ const cfg = config.readConfig(path.join(repoRoot, 'specs'));
55
+ const bf = cfg && cfg.branch_flow;
56
+ if (Array.isArray(bf) && bf.length) return bf[bf.length - 1];
57
+ } catch { /* config absent/unreadable — fall through */ }
58
+ return 'main';
59
+ }
60
+
49
61
  function parseFlags(argv) {
50
62
  const flags = {};
51
63
  const positional = [];
52
64
  for (let i = 0; i < argv.length; i++) {
53
65
  const a = argv[i];
54
- if (a === '--execute' || a === '--force' || a === '--json' || a === '--mark-landed') flags[a.slice(2)] = true;
66
+ if (a === '--execute' || a === '--force' || a === '--json' || a === '--mark-landed' || a === '--keep') flags[a.slice(2)] = true;
55
67
  else if (a.startsWith('--')) flags[a.slice(2)] = argv[++i];
56
68
  else positional.push(a);
57
69
  }
@@ -294,6 +306,27 @@ function cmdLand(cwd, argv) {
294
306
  if (others.length) {
295
307
  console.log(`ℹ advisory rebase signal sent to ${others.length} open lane(s): ${others.map((l) => l.id).join(', ')}`);
296
308
  }
309
+
310
+ // Phase 27 (BUG-026): a lane that landed on the TERMINAL integration branch is
311
+ // spent — clean its worktree + branch + lane state now (default-branch-safe),
312
+ // unless --keep. Landing into an intermediate branch (e.g. staging under
313
+ // staging-promotion) is NOT spent — cleanup defers to `lanes reconcile`.
314
+ const terminal = terminalBranch(repoRoot);
315
+ if (flags.keep) {
316
+ console.log('ℹ --keep: worktree, branch, and lane state left in place');
317
+ } else if (into === terminal) {
318
+ const res = cleanup.cleanupTarget({
319
+ cwd, branch: lane.branch, worktree: lane.worktree, laneId: id, deleteRemote: true,
320
+ });
321
+ const glyph = { removed: '✓', deleted: '✓', cleared: '✓', skipped: 'ℹ', blocked: '⚠' };
322
+ for (const a of res.actions) console.log(` ${glyph[a.status] || '·'} cleanup ${a.step}: ${a.detail}`);
323
+ if (!res.ok) {
324
+ console.log(`⚠ cleanup incomplete (${res.blocked.join(', ')}) — resolve, then: momentum lanes cleanup ${lane.branch}`);
325
+ }
326
+ } else {
327
+ console.log(`ℹ landed on '${into}' (not the terminal branch '${terminal}') — cleanup deferred to \`momentum lanes reconcile\` once it reaches '${terminal}'`);
328
+ }
329
+
297
330
  console.log('ℹ run the suite on the updated branch before the next landing (Rule 6 Landing Order)');
298
331
  return 0;
299
332
  }
@@ -0,0 +1,129 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `momentum lanes reconcile` — sweep lanes whose terminal merge happened
5
+ * OUT-OF-BAND (Phase 27 G3, BUG-026). For the `end_state`s where a human or the
6
+ * forge performs the terminal merge (`staging-promotion` after promotion,
7
+ * `feature-branch-only`, `open-pr`), the agent never auto-cleans at land time;
8
+ * this command detects lanes now contained in the terminal branch upstream and
9
+ * runs the same default-branch-safe `cleanupTarget()` on them.
10
+ *
11
+ * Validate-first: without --execute it only reports which lanes are landed
12
+ * (cleanable) vs still pending. `git fetch --prune` runs first (unless
13
+ * --no-fetch) so containment is judged against the freshest `origin/<terminal>`.
14
+ *
15
+ * Verify-before-clean (Rule 12): a lane is cleaned ONLY when its branch is a
16
+ * verified ancestor of the terminal branch — a human's "yes, I merged it" is
17
+ * never taken on trust.
18
+ */
19
+
20
+ const path = require('path');
21
+ const { spawnSync } = require('child_process');
22
+
23
+ const state = require('./state');
24
+ const cleanup = require('./cleanup');
25
+ const config = require('../../config');
26
+
27
+ function git(cwd, ...args) {
28
+ return spawnSync('git', args, { cwd, encoding: 'utf8' });
29
+ }
30
+ function ok(res) { return res && res.status === 0; }
31
+
32
+ function terminalBranch(repoRoot) {
33
+ try {
34
+ const cfg = config.readConfig(path.join(repoRoot, 'specs'));
35
+ const bf = cfg && cfg.branch_flow;
36
+ if (Array.isArray(bf) && bf.length) return bf[bf.length - 1];
37
+ } catch { /* config absent — default below */ }
38
+ return 'main';
39
+ }
40
+
41
+ /** The best ref to test containment against: origin/<terminal> if present, else local. */
42
+ function containerRef(cwd, terminal) {
43
+ if (ok(git(cwd, 'rev-parse', '--verify', '--quiet', `refs/remotes/origin/${terminal}`))) {
44
+ return `origin/${terminal}`;
45
+ }
46
+ return terminal;
47
+ }
48
+
49
+ function parseFlags(argv) {
50
+ const flags = {};
51
+ const positional = [];
52
+ for (let i = 0; i < argv.length; i++) {
53
+ const a = argv[i];
54
+ if (a === '--execute' || a === '--json' || a === '--no-fetch' || a === '--force') flags[a.slice(2)] = true;
55
+ else if (a.startsWith('--')) flags[a.slice(2)] = argv[++i];
56
+ else positional.push(a);
57
+ }
58
+ return { flags, positional };
59
+ }
60
+
61
+ /**
62
+ * Classify a lane: landed (branch contained in the terminal ref, or already
63
+ * gone) vs pending (still not merged). Returns { lane, verdict, reason }.
64
+ */
65
+ function classify(cwd, lane, container) {
66
+ const local = cleanup.localBranchExists(cwd, lane.branch);
67
+ const remote = cleanup.remoteBranchExists(cwd, lane.branch);
68
+ if (!local && !remote) {
69
+ // Branch already deleted everywhere — only residual worktree/state remains.
70
+ return { lane, verdict: 'landed', reason: `branch '${lane.branch}' already gone — residual state only` };
71
+ }
72
+ const ref = local ? lane.branch : `origin/${lane.branch}`;
73
+ if (cleanup.isMerged(cwd, ref, container)) {
74
+ return { lane, verdict: 'landed', reason: `'${lane.branch}' is contained in '${container}'` };
75
+ }
76
+ return { lane, verdict: 'pending', reason: `'${lane.branch}' not yet contained in '${container}'` };
77
+ }
78
+
79
+ function cmdReconcile(cwd, argv) {
80
+ const { flags } = parseFlags(argv);
81
+ const anchor = state.resolveAnchor(cwd);
82
+ if (!anchor) { console.error('✗ not inside a git repository'); return 1; }
83
+ const repoRoot = state.worktreeRoot(cwd);
84
+ const terminal = flags.into || terminalBranch(repoRoot);
85
+
86
+ if (!flags['no-fetch']) git(cwd, 'fetch', '--prune'); // best-effort; offline is fine
87
+ const container = containerRef(cwd, terminal);
88
+
89
+ // Sweep every non-cleaned, non-closed lane.
90
+ const lanes = state.listLanes(anchor).filter((l) => !l.cleaned && l.status !== 'closed');
91
+ const classified = lanes.map((l) => classify(cwd, l, container));
92
+ const landed = classified.filter((c) => c.verdict === 'landed');
93
+ const pending = classified.filter((c) => c.verdict === 'pending');
94
+
95
+ if (flags.json && !flags.execute) {
96
+ console.log(JSON.stringify({ terminal, container, landed, pending }, null, 2));
97
+ return 0;
98
+ }
99
+
100
+ console.log(`reconcile against '${container}' (terminal branch '${terminal}'):`);
101
+ if (!classified.length) console.log(' ℹ no open/landed lanes to reconcile');
102
+ for (const c of pending) console.log(` · pending: ${c.lane.id} — ${c.reason}`);
103
+ for (const c of landed) console.log(` ✓ landed: ${c.lane.id} — ${c.reason}`);
104
+
105
+ if (!flags.execute) {
106
+ if (landed.length) {
107
+ console.log(`✓ ${landed.length} lane(s) cleanable — run with --execute to clean worktree + branch + state`);
108
+ } else {
109
+ console.log('✓ nothing to clean');
110
+ }
111
+ return 0;
112
+ }
113
+
114
+ let failures = 0;
115
+ for (const { lane } of landed) {
116
+ console.log(`cleaning '${lane.id}'…`);
117
+ const res = cleanup.cleanupTarget({
118
+ cwd, branch: lane.branch, worktree: lane.worktree, laneId: lane.id,
119
+ deleteRemote: true, force: Boolean(flags.force),
120
+ });
121
+ const glyph = { removed: '✓', deleted: '✓', cleared: '✓', skipped: 'ℹ', blocked: '⚠' };
122
+ for (const a of res.actions) console.log(` ${glyph[a.status] || '·'} ${a.step}: ${a.detail}`);
123
+ if (!res.ok) { failures++; console.log(` ⚠ '${lane.id}' cleanup incomplete: ${res.blocked.join(', ')}`); }
124
+ }
125
+ console.log(`✓ reconcile complete — cleaned ${landed.length - failures}/${landed.length} landed lane(s); ${pending.length} still pending`);
126
+ return failures ? 1 : 0;
127
+ }
128
+
129
+ module.exports = { cmdReconcile, classify, terminalBranch };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limina-labs/momentum",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "Agent-agnostic, specs-driven development framework — single project or multi-project ecosystem",
5
5
  "homepage": "https://trymomentum.github.io",
6
6
  "bin": {