@limina-labs/momentum 0.33.0 → 0.35.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.
@@ -638,6 +638,9 @@ Use `infra:` for CI, build, deploy, tooling, and release-pipeline changes that d
638
638
 
639
639
  ## Project Extensions
640
640
 
641
- > Everything below this heading is preserved across `momentum upgrade`.
642
- > Add project-specific navigation, rules, cross-repo references, etc. here.
643
- > Anything above this heading is managed by momentum and may be replaced on upgrade.
641
+ <!-- momentum:project-rules-pointer -->
642
+ > **Project-specific rules live in `specs/project-rules.md`** — read it now.
643
+ > Session-start self-audits, project constraints, and any project-specific
644
+ > guidance are there, shared identically by every agent (ADR-0010). This
645
+ > section is a momentum-managed pointer; edit `specs/project-rules.md`, not
646
+ > this file.
@@ -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;
@@ -48,7 +48,7 @@ The shipped recipe set (one skill per recipe, all under
48
48
  | log | Append a manual narrative entry to the history of the phase bound to your branch (fallback: status.md) |
49
49
  | migrate | Onboard an existing project into momentum |
50
50
  | validate | Check spec structure health |
51
- | sync-config | Re-infer project shape, show config drift, apply on approval (ENH-062)
51
+ | sync-config | Re-infer project shape, show config drift, apply on approval (ENH-062) |
52
52
  | ecosystem | Cross-repo ecosystem coordination |
53
53
  | initiative | Manage cross-repo initiatives |
54
54
  | session | Append a manual narrative entry to today's ecosystem session log |
@@ -692,6 +692,9 @@ Use `infra:` for CI, build, deploy, tooling, and release-pipeline changes that d
692
692
 
693
693
  ## Project Extensions
694
694
 
695
- > Everything below this heading is preserved across `momentum upgrade`.
696
- > Add project-specific navigation, rules, cross-repo references, etc. here.
697
- > Anything above this heading is managed by momentum and may be replaced on upgrade.
695
+ <!-- momentum:project-rules-pointer -->
696
+ > **Project-specific rules live in `specs/project-rules.md`** — read it now.
697
+ > Session-start self-audits, project constraints, and any project-specific
698
+ > guidance are there, shared identically by every agent (ADR-0010). This
699
+ > section is a momentum-managed pointer; edit `specs/project-rules.md`, not
700
+ > this file.
@@ -24,7 +24,7 @@ The shipped recipe set (one skill per recipe, all under
24
24
  | log | Append a manual narrative entry to the history of the phase bound to your branch (fallback: status.md) |
25
25
  | migrate | Onboard an existing project into momentum |
26
26
  | validate | Check spec structure health |
27
- | sync-config | Re-infer project shape, show config drift, apply on approval (ENH-062)
27
+ | sync-config | Re-infer project shape, show config drift, apply on approval (ENH-062) |
28
28
  | ecosystem | Cross-repo ecosystem coordination |
29
29
  | initiative | Manage cross-repo initiatives |
30
30
  | session | Append a manual narrative entry to today's ecosystem session log |
@@ -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');
@@ -48,7 +48,7 @@ The shipped recipe set (one command per recipe):
48
48
  | lanes | Work with lanes — concurrent workstreams in one repo (Rule 15) |
49
49
  | migrate | Onboard an existing project into momentum |
50
50
  | validate | Check spec structure health |
51
- | sync-config | Re-infer project shape, show config drift, apply on approval (ENH-062)
51
+ | sync-config | Re-infer project shape, show config drift, apply on approval (ENH-062) |
52
52
  | ecosystem | Cross-repo ecosystem coordination |
53
53
  | initiative | Manage cross-repo initiatives |
54
54
  | session | Append a manual narrative entry to today's ecosystem session log |
@@ -605,6 +605,9 @@ Use `infra:` for CI, build, deploy, tooling, and release-pipeline changes that d
605
605
 
606
606
  ## Project Extensions
607
607
 
608
- > Everything below this heading is preserved across `momentum upgrade`.
609
- > Add project-specific navigation, rules, cross-repo references, etc. here.
610
- > Anything above this heading is managed by momentum and may be replaced on upgrade.
608
+ <!-- momentum:project-rules-pointer -->
609
+ > **Project-specific rules live in `specs/project-rules.md`** — read it now.
610
+ > Session-start self-audits, project constraints, and any project-specific
611
+ > guidance are there, shared identically by every agent (ADR-0010). This
612
+ > section is a momentum-managed pointer; edit `specs/project-rules.md`, not
613
+ > this file.
@@ -24,7 +24,7 @@ The shipped recipe set (one command per recipe):
24
24
  | lanes | Work with lanes — concurrent workstreams in one repo (Rule 15) |
25
25
  | migrate | Onboard an existing project into momentum |
26
26
  | validate | Check spec structure health |
27
- | sync-config | Re-infer project shape, show config drift, apply on approval (ENH-062)
27
+ | sync-config | Re-infer project shape, show config drift, apply on approval (ENH-062) |
28
28
  | ecosystem | Cross-repo ecosystem coordination |
29
29
  | initiative | Manage cross-repo initiatives |
30
30
  | session | Append a manual narrative entry to today's ecosystem session log |
@@ -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/ecosystem.js CHANGED
@@ -24,8 +24,11 @@ const {
24
24
  POINTER_END,
25
25
  PRIMARY_INSTRUCTION_CANDIDATES,
26
26
  findPrimaryInstructionFile,
27
+ findAllInstructionFiles,
27
28
  ensurePointerInjected,
29
+ ensurePointerInjectedAll,
28
30
  stripPointer,
31
+ stripPointerAll,
29
32
  } = require('../core/ecosystem/lib/pointer');
30
33
 
31
34
  // ─────────────────────────────────────────────────────────────────────────────
@@ -616,7 +619,7 @@ function cmdAdd(args) {
616
619
  // Idempotent: if the path matches, do nothing. If it differs, fail loud.
617
620
  if (existing.path === relPath) {
618
621
  console.log(`add: "${id}" is already registered (path ${relPath}). No changes.`);
619
- ensurePointerInjected(absRepo, primary, root, manifest.name);
622
+ ensurePointerInjectedAll(absRepo, root, manifest.name);
620
623
  return;
621
624
  }
622
625
  throw new Error(
@@ -641,11 +644,12 @@ function cmdAdd(args) {
641
644
  'utf8',
642
645
  );
643
646
 
644
- // Inject the fenced pointer into the target's primary instruction.
645
- ensurePointerInjected(absRepo, primary, root, manifest.name);
647
+ // Inject the fenced pointer into EVERY instruction file (Phase 28 — every
648
+ // agent, not just the preferred one).
649
+ const injected = ensurePointerInjectedAll(absRepo, root, manifest.name);
646
650
 
647
651
  console.log(`Added member "${id}" (${role}) at ${relPath}.`);
648
- console.log(`Pointer injected into ${path.relative(root, path.join(absRepo, primary))}.`);
652
+ console.log(`Pointer injected into ${injected.map((f) => path.relative(root, path.join(absRepo, f))).join(', ')}.`);
649
653
  }
650
654
 
651
655
  // ─────────────────────────────────────────────────────────────────────────────
@@ -686,10 +690,7 @@ function cmdRemove(args) {
686
690
  // remove must succeed even when the member repo is gone.
687
691
  const absRepo = path.resolve(root, member.path);
688
692
  if (fs.existsSync(absRepo)) {
689
- const primary = findPrimaryInstructionFile(absRepo);
690
- if (primary) {
691
- stripPointer(path.join(absRepo, primary));
692
- }
693
+ stripPointerAll(absRepo); // Phase 28 — strip from every instruction file
693
694
  }
694
695
 
695
696
  console.log(`Removed member "${id}".`);
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
@@ -1348,11 +1348,32 @@ function upgrade(targetDir, agent, opts = {}) {
1348
1348
  // would dirty files mid-upgrade and break autostash restores).
1349
1349
  const pointerLib = require('../core/ecosystem/lib/pointer');
1350
1350
  const primaryRel = adapter.primaryInstruction.destination.join('/');
1351
+
1352
+ // Phase 28 (ADR-0010): migrate any authored `## Project Extensions` prose into
1353
+ // specs/project-rules.md and rewrite that section as the managed pointer,
1354
+ // BEFORE the marker-aware rewrite preserves it. Migrate-never-drop, idempotent.
1355
+ if (!_dryRun) {
1356
+ const projectRules = require('../core/lib/project-rules');
1357
+ const migRes = projectRules.migrateProjectExtensions(
1358
+ path.join(target, primaryRel),
1359
+ path.join(target, 'specs'),
1360
+ { projectName: getProjectName(target) }
1361
+ );
1362
+ if (migRes.appended) {
1363
+ console.log(` ↳ migrated ${primaryRel} Project Extensions → specs/project-rules.md (ADR-0010)`);
1364
+ }
1365
+ }
1366
+
1367
+ // Snapshot whether THIS agent's instruction file carries the pointer BEFORE
1368
+ // the managed rewrite — we only ever PRESERVE it (BUG-022), never add one the
1369
+ // file didn't have (adding would dirty files mid-upgrade and break autostash
1370
+ // restores). Phase 28: since `upgrade` now runs per installed agent (G1), this
1371
+ // preserves every agent's pointer; ADDING a missing pointer (cause #2) is
1372
+ // `momentum ecosystem add`'s job, which now injects into all instruction files.
1351
1373
  let hadPointerBlock = false;
1352
1374
  try {
1353
- hadPointerBlock = pointerLib.POINTER_BEGIN_RE.test(
1354
- fs.readFileSync(path.join(target, primaryRel), 'utf8')
1355
- );
1375
+ hadPointerBlock = fs.readFileSync(path.join(target, primaryRel), 'utf8')
1376
+ .includes(pointerLib.POINTER_BEGIN_PREFIX);
1356
1377
  } catch (_e) { /* absent file → nothing to preserve */ }
1357
1378
 
1358
1379
  // Upgrade adapter-owned root instruction file
@@ -1620,6 +1641,7 @@ Ecosystem use — entry/exit commands (run from any repo):
1620
1641
  existing ecosystem
1621
1642
  momentum leave Detach THIS repo from its ecosystem
1622
1643
  momentum doctor Diagnose state + list available transitions
1644
+ momentum doctor --clean [--execute] Sweep stray artifacts (AppleDouble/.bak/orphan worktrees)
1623
1645
  momentum antigravity plugin-pack Pack momentum skills as a native Antigravity plugin ([--global])
1624
1646
 
1625
1647
  Ecosystem use — operator toolkit (advanced):
@@ -1982,12 +2004,27 @@ async function main() {
1982
2004
  // upgrade can only ever install files as new as the CLI itself.
1983
2005
  const staleWarning = formatStaleCliWarning(pkg.version, await updateCheckPromise);
1984
2006
  if (staleWarning) console.log(staleWarning);
1985
- // --autostash: stash a dirty tree, upgrade, restore. Dry-run writes
1986
- // nothing, so it never needs to stash.
2007
+ // Phase 28 (ADR-0010): with no explicit --agent, refresh EVERY installed
2008
+ // agent so no agent's instruction file silently drifts (cause #1 of the
2009
+ // CLAUDE.md/AGENTS.md divergence). Explicit --agent targets that one.
2010
+ let agentsToUpgrade;
2011
+ if (agentExplicit) {
2012
+ agentsToUpgrade = [agent];
2013
+ } else {
2014
+ const installed = loadInstalledState(path.resolve(targetDir));
2015
+ const names = Object.keys(installed.agents || {});
2016
+ agentsToUpgrade = names.length ? names : [agent];
2017
+ }
2018
+ if (agentsToUpgrade.length > 1) {
2019
+ console.log(`Upgrading all installed agents: ${agentsToUpgrade.join(', ')}\n`);
2020
+ }
2021
+ // --autostash: stash a dirty tree, upgrade, restore — once around all
2022
+ // agents. Dry-run writes nothing, so it never needs to stash.
2023
+ const runAll = () => { for (const a of agentsToUpgrade) upgrade(targetDir, a, { dryRun }); };
1987
2024
  if (autostash && !dryRun) {
1988
- withAutostash(path.resolve(targetDir), () => upgrade(targetDir, agent, { dryRun }));
2025
+ withAutostash(path.resolve(targetDir), runAll);
1989
2026
  } else {
1990
- upgrade(targetDir, agent, { dryRun });
2027
+ runAll();
1991
2028
  }
1992
2029
  } catch (err) {
1993
2030
  console.error(`\nError: ${err.message}`);
@@ -103,10 +103,9 @@ function cmdLeave(args) {
103
103
  }
104
104
 
105
105
  if (state === 'broken-pointer') {
106
- // Pointer present but no registration. Just strip pointer to recover.
107
- const primary = pointerLib.findPrimaryInstructionFile(cwd);
108
- if (primary) {
109
- pointerLib.stripPointer(path.join(cwd, primary));
106
+ // Pointer present but no registration. Strip from every instruction file.
107
+ const stripped = pointerLib.stripPointerAll(cwd);
108
+ if (stripped.length) {
110
109
  console.log('Stripped dangling pointer. Now standalone.');
111
110
  } else {
112
111
  console.log('No pointer block found. No changes.');
@@ -129,8 +128,103 @@ function cmdLeave(args) {
129
128
  console.log(`✓ Left "${reg.ecosystemName}". Now standalone.`);
130
129
  }
131
130
 
131
+ // ── Phase 27 G4 — stray-artifact sweep (`momentum doctor --clean`) ──────────
132
+
133
+ const BAK_ROOTS = ['.claude', '.agents', '.opencode', '.codex', '.githooks', 'scripts'];
134
+
135
+ /** Bounded walk collecting AppleDouble sidecars anywhere (always junk). */
136
+ function walkAppleDouble(dir, acc, depth) {
137
+ if (depth > 8) return;
138
+ let entries;
139
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
140
+ for (const e of entries) {
141
+ if (e.name === '.git' || e.name === 'node_modules') continue;
142
+ const abs = path.join(dir, e.name);
143
+ if (e.name.startsWith('._') || e.name === '.DS_Store') { acc.appleDouble.push(abs); continue; }
144
+ if (e.isDirectory()) walkAppleDouble(abs, acc, depth + 1);
145
+ }
146
+ }
147
+
148
+ /** `.bak` files under a momentum-managed root only (never user backups). */
149
+ function collectBaks(dir, acc, depth) {
150
+ if (depth > 8) return;
151
+ let entries;
152
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
153
+ for (const e of entries) {
154
+ const abs = path.join(dir, e.name);
155
+ if (e.isDirectory()) collectBaks(abs, acc, depth + 1);
156
+ else if (e.name.endsWith('.bak')) acc.baks.push(abs);
157
+ }
158
+ }
159
+
160
+ /** Find stray artifacts: AppleDouble, momentum `.bak` litter, orphan worktrees. */
161
+ function findStrayArtifacts(root) {
162
+ const { spawnSync } = require('child_process');
163
+ const acc = { appleDouble: [], baks: [], prunableWorktrees: [], orphanWorktreeDirs: [] };
164
+ walkAppleDouble(root, acc, 0);
165
+ for (const sub of BAK_ROOTS) collectBaks(path.join(root, sub), acc, 0);
166
+ for (const f of ['CLAUDE.md.bak', 'AGENTS.md.bak']) {
167
+ const p = path.join(root, f);
168
+ if (fs.existsSync(p)) acc.baks.push(p);
169
+ }
170
+ // registered worktrees whose directory is gone → prunable
171
+ const registered = new Set();
172
+ const wl = spawnSync('git', ['worktree', 'list', '--porcelain'], { cwd: root, encoding: 'utf8' });
173
+ if (wl.status === 0) {
174
+ for (const line of wl.stdout.split('\n')) {
175
+ if (line.startsWith('worktree ')) {
176
+ const p = line.slice('worktree '.length);
177
+ registered.add(realpathOf(p));
178
+ if (p !== root && !fs.existsSync(p)) acc.prunableWorktrees.push(p);
179
+ }
180
+ }
181
+ }
182
+ // .claude/worktrees/* leftover dirs not registered as git worktrees
183
+ const cw = path.join(root, '.claude', 'worktrees');
184
+ try {
185
+ for (const name of fs.readdirSync(cw)) {
186
+ const abs = path.join(cw, name);
187
+ if (fs.statSync(abs).isDirectory() && !registered.has(realpathOf(abs))) acc.orphanWorktreeDirs.push(abs);
188
+ }
189
+ } catch { /* no .claude/worktrees */ }
190
+ return acc;
191
+ }
192
+
193
+ function sweepStrayArtifacts(root, execute) {
194
+ const { spawnSync } = require('child_process');
195
+ const stray = findStrayArtifacts(root);
196
+ const total = stray.appleDouble.length + stray.baks.length
197
+ + stray.prunableWorktrees.length + stray.orphanWorktreeDirs.length;
198
+ const rel = (p) => path.relative(root, p) || p;
199
+
200
+ console.log(execute ? 'Cleaning stray artifacts:' : 'Stray artifacts (dry-run — pass --execute to remove):');
201
+ if (total === 0) { console.log(' ✓ none — tree is clean'); return 0; }
202
+
203
+ for (const p of stray.appleDouble) {
204
+ if (execute) { try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* ignore */ } }
205
+ console.log(` ${execute ? '✓ removed' : '·'} AppleDouble: ${rel(p)}`);
206
+ }
207
+ for (const p of stray.baks) {
208
+ if (execute) { try { fs.rmSync(p, { force: true }); } catch { /* ignore */ } }
209
+ console.log(` ${execute ? '✓ removed' : '·'} .bak: ${rel(p)}`);
210
+ }
211
+ for (const p of stray.orphanWorktreeDirs) {
212
+ if (execute) { try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* ignore */ } }
213
+ console.log(` ${execute ? '✓ removed' : '·'} orphan worktree dir: ${rel(p)}`);
214
+ }
215
+ if (stray.prunableWorktrees.length) {
216
+ if (execute) spawnSync('git', ['worktree', 'prune'], { cwd: root });
217
+ for (const p of stray.prunableWorktrees) console.log(` ${execute ? '✓ pruned' : '·'} stale worktree ref: ${rel(p)}`);
218
+ }
219
+ console.log(execute ? `✓ cleaned ${total} stray artifact(s)` : `${total} stray artifact(s) — rerun with --execute to remove`);
220
+ return 0;
221
+ }
222
+
132
223
  function cmdDoctor(args) {
133
- void args;
224
+ // Phase 27 G4 — `momentum doctor --clean [--execute]` sweeps stray artifacts.
225
+ if (args && args.includes('--clean')) {
226
+ return sweepStrayArtifacts(realpathOf(process.cwd()), args.includes('--execute'));
227
+ }
134
228
  const cwd = realpathOf(process.cwd());
135
229
  lib._clearRootCache();
136
230
  const state = stateLib.detectState(cwd);
@@ -176,6 +270,17 @@ function cmdDoctor(args) {
176
270
  }
177
271
  } catch (_err) { /* advisory is best-effort — never block doctor */ }
178
272
 
273
+ // Phase 27 G4 — stray-artifact advisory (best-effort, non-blocking).
274
+ try {
275
+ const stray = findStrayArtifacts(cwd);
276
+ const n = stray.appleDouble.length + stray.baks.length
277
+ + stray.prunableWorktrees.length + stray.orphanWorktreeDirs.length;
278
+ if (n > 0) {
279
+ console.log(`Advisory: ${n} stray artifact(s) (AppleDouble / .bak / orphan worktrees) — run \`momentum doctor --clean\` to review, \`--clean --execute\` to remove.`);
280
+ console.log('');
281
+ }
282
+ } catch { /* best-effort */ }
283
+
179
284
  const transitions = stateLib.availableTransitions(state, reg || {});
180
285
  if (transitions.length === 0) {
181
286
  console.log('No suggested next steps for this state.');
@@ -215,4 +320,6 @@ module.exports = {
215
320
  cmdLeave,
216
321
  cmdDoctor,
217
322
  formatStateLabel,
323
+ findStrayArtifacts,
324
+ sweepStrayArtifacts,
218
325
  };
@@ -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
 
@@ -77,7 +89,7 @@ Verify, finalize, and release a completed phase.
77
89
  `[staging, main]`) in order. The plan stops at the universal git primitives
78
90
  (`git merge` + `git tag -a` + `git push origin <tag>`); forge-specific
79
91
  release creation and registry publishes are NOT listed here — they run
80
- from `## Project Extensions` + `specs/config.md` (`release_command`,
92
+ from `specs/project-rules.md` + `specs/config.md` (`release_command`,
81
93
  `publish_target`, `release_flow`) after the tag is pushed.
82
94
  ```
83
95
  Ready to release vX.Y.Z. This will:
@@ -86,7 +98,7 @@ Verify, finalize, and release a completed phase.
86
98
  3. Tag vX.Y.Z and push the tag
87
99
 
88
100
  Project-specific release/publish/deploy (forge release, npm publish, …)
89
- run from ## Project Extensions + specs/config.md after the tag lands.
101
+ run from specs/project-rules.md + specs/config.md after the tag lands.
90
102
 
91
103
  Proceed?
92
104
  ```
@@ -114,35 +126,60 @@ Verify, finalize, and release a completed phase.
114
126
  git push origin <branch_flow[1]>
115
127
 
116
128
  # final step — tag + push the tag (universal git; forge release + publish
117
- # are project-specific — run them from ## Project Extensions now)
129
+ # are project-specific — run them from specs/project-rules.md now)
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
129
156
  - Tag URL (`git push origin vX.Y.Z` output) — forge release/publish
130
- status from `## Project Extensions` if any ran
157
+ status from `specs/project-rules.md` 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