@limina-labs/momentum 0.32.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.
package/README.md CHANGED
@@ -182,6 +182,7 @@ and is idempotent. (ADR-0005.)
182
182
  | ๐Ÿงญ **Phases** | Plan โ†’ execute โ†’ verify โ†’ release. Every phase has a brainstorm, plan, tasks, history. |
183
183
  | ๐Ÿ“‹ **Backlog** | Bugs / features / tech debt / enhancements with priorities and per-item context. |
184
184
  | ๐Ÿ“œ **History** | Append-only log of decisions, discoveries, scope changes. The *why* outlives any session. |
185
+ | โš™๏ธ **Config** | `specs/config.md` โ€” forge, publish target, branch flow, test/build commands. Recipes adapt to your project, not npm+GitHub defaults. |
185
186
  | โš–๏ธ **Rules** | 13 autonomous agent rules โ€” orient first, verify before claim, log every decision. |
186
187
  | ๐Ÿ› ๏ธ **Skills** | ~15 slash commands your agent runs โ€” start, complete, sync, review, validate. |
187
188
  | ๐ŸŒ **Multi-project** | Ecosystem mode (state layer) + Orchestration primitives (action layer) for cross-project work. |
@@ -15,6 +15,7 @@
15
15
  | Phase tasks/progress? | `specs/phases/phase-N-*/tasks.md` |
16
16
  | Why was X chosen? | `specs/decisions/NNNN-*.md` |
17
17
  | Roadmap / timeline? | `specs/planning/roadmap.md` (authored at founding โ€” `/start-project`) |
18
+ | Project config (forge, publish, branch flow)? | `specs/config.md` (inferred by `momentum init`, authored at `/start-project`) |
18
19
  | How to contribute? | `docs/developer-guide.md` |
19
20
 
20
21
  > **First file to read: ALWAYS `specs/status.md`.**
@@ -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;
@@ -15,6 +15,7 @@
15
15
  | Phase tasks/progress? | `specs/phases/phase-N-*/tasks.md` |
16
16
  | Why was X chosen? | `specs/decisions/NNNN-*.md` |
17
17
  | Roadmap / timeline? | `specs/planning/roadmap.md` (authored at founding โ€” `/start-project`) |
18
+ | Project config (forge, publish, branch flow)? | `specs/config.md` (inferred by `momentum init`, authored at `/start-project`) |
18
19
  | How to contribute? | `docs/developer-guide.md` |
19
20
 
20
21
  > **First file to read: ALWAYS `specs/status.md`.**
@@ -47,6 +48,7 @@ The shipped recipe set (one skill per recipe, all under
47
48
  | log | Append a manual narrative entry to the history of the phase bound to your branch (fallback: status.md) |
48
49
  | migrate | Onboard an existing project into momentum |
49
50
  | validate | Check spec structure health |
51
+ | sync-config | Re-infer project shape, show config drift, apply on approval (ENH-062)
50
52
  | ecosystem | Cross-repo ecosystem coordination |
51
53
  | initiative | Manage cross-repo initiatives |
52
54
  | session | Append a manual narrative entry to today's ecosystem session log |
@@ -24,6 +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
28
  | ecosystem | Cross-repo ecosystem coordination |
28
29
  | initiative | Manage cross-repo initiatives |
29
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');
@@ -15,6 +15,7 @@
15
15
  | Phase tasks/progress? | `specs/phases/phase-N-*/tasks.md` |
16
16
  | Why was X chosen? | `specs/decisions/NNNN-*.md` |
17
17
  | Roadmap / timeline? | `specs/planning/roadmap.md` (authored at founding โ€” `/start-project`) |
18
+ | Project config (forge, publish, branch flow)? | `specs/config.md` (inferred by `momentum init`, authored at `/start-project`) |
18
19
  | How to contribute? | `docs/developer-guide.md` |
19
20
 
20
21
  > **First file to read: ALWAYS `specs/status.md`.**
@@ -47,6 +48,7 @@ The shipped recipe set (one command per recipe):
47
48
  | lanes | Work with lanes โ€” concurrent workstreams in one repo (Rule 15) |
48
49
  | migrate | Onboard an existing project into momentum |
49
50
  | validate | Check spec structure health |
51
+ | sync-config | Re-infer project shape, show config drift, apply on approval (ENH-062)
50
52
  | ecosystem | Cross-repo ecosystem coordination |
51
53
  | initiative | Manage cross-repo initiatives |
52
54
  | session | Append a manual narrative entry to today's ecosystem session log |
@@ -24,6 +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
28
  | ecosystem | Cross-repo ecosystem coordination |
28
29
  | initiative | Manage cross-repo initiatives |
29
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/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
@@ -451,6 +451,172 @@ function migrateFoundationDocs(srcRoot, target) {
451
451
  return result;
452
452
  }
453
453
 
454
+ /**
455
+ * Phase 26 โ€” Project Config (ADR-0009).
456
+ *
457
+ * init: after the specs skeleton, infer + write `specs/config.md` and the
458
+ * derived `.momentum/config-cache.json` (only when the file is absent โ€”
459
+ * re-init leaves an authored file alone).
460
+ *
461
+ * upgrade: write inferred config for FOUNDED projects only (charter +
462
+ * roadmap exist โ€” ADR-0008); unfounded projects author them at /start-project.
463
+ * When the file exists, refresh the derived cache from the content source of
464
+ * truth and report any drift on the machine-inferable fields (never clobber
465
+ * user edits).
466
+ *
467
+ * specs/ is user content โ€” the config file is NOT recorded in the managed
468
+ * manifest (orphan cleanup must never touch it). The cache is gitignored by the
469
+ * existing `.momentum/*` rule.
470
+ */
471
+ function installConfig(target, { dryRun, upgradeMode } = {}) {
472
+ const prefsLib = require('../core/config');
473
+ const specsDir = path.join(target, 'specs');
474
+ if (!fileExists(specsDir)) return; // no specs skeleton โ†’ nothing to do
475
+ const prefsPath = path.join(specsDir, 'config.md');
476
+ const exists = fileExists(prefsPath);
477
+
478
+ if (!exists) {
479
+ // upgrade only writes config for founded projects (migration).
480
+ if (upgradeMode && !prefsLib.isFounded(target)) return;
481
+ const prefs = prefsLib.inferConfig(target);
482
+ if (dryRun) {
483
+ console.log(` โœ‹ would add: specs/config.md (inferred: language=${prefs.language}, forge=${prefs.git_forge})`);
484
+ return;
485
+ }
486
+ prefsLib.writeConfig(specsDir, prefs, { inferred: true });
487
+ prefsLib.writeConfigCache(target, prefs);
488
+ console.log(` + added: specs/config.md (inferred: language=${prefs.language}, forge=${prefs.git_forge})`);
489
+ return;
490
+ }
491
+
492
+ // File exists: refresh the derived cache from the content source of truth.
493
+ // A present-but-garbage file parses to an all-defaults object; detect that
494
+ // (zero known keys present in the raw file) and LEAVE it untouched rather
495
+ // than silently overwriting the user's (broken) content with defaults.
496
+ const raw = (() => {
497
+ try {
498
+ return prefsLib.parseConfigMarkdown(fs.readFileSync(prefsPath, 'utf8'));
499
+ } catch {
500
+ return {};
501
+ }
502
+ })();
503
+ const hasAnyKey = prefsLib.KNOWN_KEYS.some((k) => k in raw);
504
+ if (!hasAnyKey) {
505
+ console.log(` โš ๏ธ specs/config.md exists but is empty/unparseable โ€” left untouched (fix or delete it)`);
506
+ return;
507
+ }
508
+ const stored = prefsLib.readConfig(specsDir);
509
+ if (!dryRun) prefsLib.writeConfigCache(target, stored);
510
+
511
+ // Drift detection (upgrade only, founded only): report changed inferable
512
+ // fields and point at the approval-gated fix. Never clobber user edits โ€”
513
+ // the user applies the change explicitly via `momentum config sync`.
514
+ if (upgradeMode && prefsLib.isFounded(target)) {
515
+ const inferred = prefsLib.inferConfig(target);
516
+ const drifted = prefsLib.INFERABLE_KEYS.filter((k) => !prefsLib.valuesEqual(inferred[k], stored[k]));
517
+ if (drifted.length) {
518
+ console.log(` โš ๏ธ specs/config.md drifted from manifests on: ${drifted.join(', ')} โ€” run 'momentum config sync' to review + apply`);
519
+ }
520
+ }
521
+ }
522
+
523
+ /**
524
+ * Ask the user a single question and resolve with their trimmed answer.
525
+ * Works whether stdin is a TTY or piped (tests pipe input). On EOF without an
526
+ * answer, resolves to the empty string so callers can treat it as "skip".
527
+ * @param {string} query
528
+ * @returns {Promise<string>}
529
+ */
530
+ function askQuestion(query) {
531
+ const readline = require('node:readline');
532
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
533
+ return new Promise((resolve) => {
534
+ let done = false;
535
+ const finish = (v) => {
536
+ if (done) return;
537
+ done = true;
538
+ rl.close();
539
+ resolve(v);
540
+ };
541
+ // EOF (piped stdin, no answer) resolves to '' so callers treat it as skip.
542
+ rl.on('close', () => finish(''));
543
+ rl.question(query, (answer) => finish((answer || '').trim()));
544
+ });
545
+ }
546
+
547
+ /**
548
+ * ENH-062 โ€” proactive, approval-gated config drift sync. Re-infers the project
549
+ * shape and diffs it against specs/config.md. When drift is found, shows the
550
+ * per-field oldโ†’new diff and asks the user which fields to apply (or "all" /
551
+ * "skip"). Nothing is written without explicit approval.
552
+ * @param {string} targetDir
553
+ * @param {object} [opts] { dryRun }
554
+ */
555
+ async function syncConfig(targetDir, opts = {}) {
556
+ const prefsLib = require('../core/config');
557
+ const specsDir = path.join(targetDir, 'specs');
558
+ if (!fileExists(specsDir)) {
559
+ console.log(' โš ๏ธ no specs/ skeleton โ€” run momentum init first');
560
+ return;
561
+ }
562
+ const { exists, drift } = prefsLib.diffConfig(specsDir, targetDir);
563
+
564
+ if (!exists) {
565
+ // Migration: a founded project with no config.md yet โ†’ create it.
566
+ if (prefsLib.isFounded(targetDir)) {
567
+ if (opts.dryRun) {
568
+ const p = prefsLib.inferConfig(targetDir);
569
+ console.log(` โœ‹ would add: specs/config.md (inferred: language=${p.language}, forge=${p.git_forge})`);
570
+ return;
571
+ }
572
+ const prefs = prefsLib.inferConfig(targetDir);
573
+ prefsLib.writeConfig(specsDir, prefs, { inferred: true });
574
+ prefsLib.writeConfigCache(targetDir, prefs);
575
+ console.log(` + added: specs/config.md (inferred: language=${prefs.language}, forge=${prefs.git_forge})`);
576
+ } else {
577
+ console.log(' โš ๏ธ project not founded โ€” run /start-project to author specs/config.md');
578
+ }
579
+ return;
580
+ }
581
+
582
+ if (drift.length === 0) {
583
+ console.log(' โœ“ specs/config.md matches the inferred project shape โ€” nothing to sync');
584
+ return;
585
+ }
586
+
587
+ console.log('\n Project shape changed since specs/config.md was written:\n');
588
+ for (const d of drift) {
589
+ console.log(` โ€ข ${d.key}: ${JSON.stringify(d.old)} โ†’ ${JSON.stringify(d.new)}`);
590
+ }
591
+ console.log('');
592
+
593
+ if (opts.dryRun) {
594
+ console.log(` โœ‹ would sync ${drift.length} field(s) โ€” re-run without --dry-run to apply`);
595
+ return;
596
+ }
597
+
598
+ const answer = await askQuestion(' Apply? [a]ll / comma-separated keys / [s]kip: ');
599
+ const a = answer.toLowerCase();
600
+ if (a === 's' || a === '' || a === 'skip') {
601
+ console.log(' โ†ฉ๏ธ skipped โ€” specs/config.md left unchanged');
602
+ return;
603
+ }
604
+ let keys;
605
+ if (a === 'a' || a === 'all' || a === 'y' || a === 'yes') {
606
+ keys = drift.map((d) => d.key);
607
+ } else {
608
+ const wanted = new Set(a.split(',').map((s) => s.trim()));
609
+ keys = drift.filter((d) => wanted.has(d.key)).map((d) => d.key);
610
+ if (keys.length === 0) {
611
+ console.log(' โ†ฉ๏ธ no matching keys โ€” specs/config.md left unchanged');
612
+ return;
613
+ }
614
+ }
615
+ const updated = prefsLib.mergeConfigDrift(specsDir, targetDir, keys);
616
+ console.log(` โœ“ synced ${keys.join(', ')} โ†’ specs/config.md (cache refreshed)`);
617
+ return updated;
618
+ }
619
+
454
620
  function removeOrphans(targetDir, prevManifest, currentSet, opts = {}) {
455
621
  if (!prevManifest || !Array.isArray(prevManifest.managedFiles)) return [];
456
622
  const currentRel = new Set(
@@ -1038,10 +1204,17 @@ function init(targetDir, agent, opts = {}) {
1038
1204
  const specsSrc = path.join(src, 'core', 'specs-templates');
1039
1205
  copyDir(specsSrc, target, {
1040
1206
  skipIfExists: true,
1041
- skipRelPaths: new Set(['CLAUDE.md']),
1207
+ // CLAUDE.md is adapter-owned (written separately); config.md is
1208
+ // inferred per-project by installConfig (Phase 26) โ€” neither is
1209
+ // copied as a static template.
1210
+ skipRelPaths: new Set(['CLAUDE.md', 'config.md']),
1042
1211
  record: false, // specs are install-once / user-owned โ€” never orphan them
1043
1212
  });
1044
1213
 
1214
+ // Phase 26 โ€” infer + write specs/config.md + the derived hook cache.
1215
+ console.log('โ†’ Inferring project config...');
1216
+ installConfig(target, { dryRun: _dryRun });
1217
+
1045
1218
  installPrimaryInstruction(src, target, adapterDir, adapter.primaryInstruction);
1046
1219
 
1047
1220
  // Adapter overlay โ€” per-agent commands/agent-rules/scripts (additive)
@@ -1243,6 +1416,12 @@ function upgrade(targetDir, agent, opts = {}) {
1243
1416
  console.log(' = no untouched placeholders (foundation docs are authored or absent)');
1244
1417
  }
1245
1418
 
1419
+ // Phase 26 โ€” project config (ADR-0009). Founded projects get an
1420
+ // inferred specs/config.md on first upgrade; existing files get their
1421
+ // derived cache refreshed + drift reported (never clobbered).
1422
+ console.log('โ†’ Inferring project config...');
1423
+ installConfig(target, { dryRun: _dryRun, upgradeMode: true });
1424
+
1246
1425
  // Orphan cleanup โ€” only remove files this agent's prior version installed
1247
1426
  // that this version no longer ships. Other agents' files are untouched.
1248
1427
  orphans = removeOrphans(target, { managedFiles: prevAgentFiles }, _managedCollector, {
@@ -1441,6 +1620,7 @@ Ecosystem use โ€” entry/exit commands (run from any repo):
1441
1620
  existing ecosystem
1442
1621
  momentum leave Detach THIS repo from its ecosystem
1443
1622
  momentum doctor Diagnose state + list available transitions
1623
+ momentum doctor --clean [--execute] Sweep stray artifacts (AppleDouble/.bak/orphan worktrees)
1444
1624
  momentum antigravity plugin-pack Pack momentum skills as a native Antigravity plugin ([--global])
1445
1625
 
1446
1626
  Ecosystem use โ€” operator toolkit (advanced):
@@ -1471,6 +1651,7 @@ Waves โ€” wave plan from dependency annotations (Phase 21c, one engine every sca
1471
1651
  OKF โ€” specs/ as an Open Knowledge Format bundle (Phase 24, ADR-0005):
1472
1652
  momentum okf check [dir] OKF v0.1 conformance report for <dir>/specs
1473
1653
  momentum okf index [dir] Regenerate bundle indexes (root/phases/decisions)
1654
+ momentum config sync [target-dir] Re-infer project shape, show config drift, apply on approval
1474
1655
 
1475
1656
  Options:
1476
1657
  --agent <name> Agent to install for. \`init\` asks when this
@@ -1919,6 +2100,22 @@ async function main() {
1919
2100
  console.error(`\nError: ${err.message}`);
1920
2101
  exitCode = 1;
1921
2102
  }
2103
+ } else if (args[0] === 'config') {
2104
+ try {
2105
+ const sub = args[1];
2106
+ const target = args[2] ? path.resolve(args[2]) : process.cwd();
2107
+ if (sub === 'sync') {
2108
+ const dryRun = args.includes('--dry-run') || _dryRun;
2109
+ await syncConfig(target, { dryRun });
2110
+ } else {
2111
+ console.error(`Unknown config subcommand: ${sub || '(none)'}`);
2112
+ console.error('Usage: momentum config sync [target-dir] [--dry-run]');
2113
+ exitCode = 1;
2114
+ }
2115
+ } catch (err) {
2116
+ console.error(`\nError: ${err.message}`);
2117
+ exitCode = 1;
2118
+ }
1922
2119
  } else {
1923
2120
  console.error(`Unknown command: ${args[0]}`);
1924
2121
  console.error('Run "momentum --help" for usage.');
@@ -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
  };
@@ -44,6 +44,25 @@ When you're ready to turn the result into a project, run `/start-project` โ€” it
44
44
 
45
45
  4. Iterate until the idea is clear.
46
46
 
47
+ 4b. **Config discovery** โ€” once the idea is settled, gather the
48
+ project-shape config that `/start-project` will author into
49
+ `specs/config.md` (ADR-0009). Ask one question at a time; if the
50
+ user is unsure, suggest a default from the idea's context:
51
+ - **Git forge**: "Which forge? GitHub / GitLab / Bitbucket / Gitea /
52
+ Forgejo / bare-ssh?" (default from `git remote get-url origin` if a
53
+ repo exists, else GitHub)
54
+ - **Language + framework**: "What language/framework? Node+Next.js /
55
+ Python+FastAPI / Rust+Actix / Go / โ€ฆ?" (default from manifests if a
56
+ repo exists, else ask)
57
+ - **Publish/deploy target**: "How does this ship? npm publish / pypi /
58
+ crates.io / deploy-only (Vercel/Fly/โ€ฆ) / none?" (sets `publish_target`
59
+ + `release_flow`)
60
+ - **Branch flow**: "How do changes land? feature โ†’ staging โ†’ main /
61
+ feature โ†’ main / feature-branch-only?" (sets `branch_flow` +
62
+ `end_state`; default `feature โ†’ staging โ†’ main`)
63
+ Carry the settled config as context into `/start-project` so it
64
+ authors `specs/config.md` alongside the charter/roadmap in one batch.
65
+
47
66
  5. **Exit the brainstorm gate** before any follow-up command:
48
67
  ```bash
49
68
  rm .momentum/brainstorm-active
@@ -34,6 +34,13 @@ The brainstorm output IS the phase files โ€” there is no intermediate design doc
34
34
  - Read `specs/planning/roadmap.md` โ€” what's the next planned phase?
35
35
  - Is the planned phase still the right next step given what was learned?
36
36
 
37
+ 2b. Read verification defaults from config:
38
+ - Read `specs/config.md` (`core/config.js` โ†’ `readConfig('specs')`)
39
+ for `test_command` and `build_command`. When absent, default to
40
+ `npm test` / no build. Use these as the default verification commands in
41
+ the phase's deliverables table (overview.md) unless the phase needs
42
+ something different โ€” call out any deviation explicitly.
43
+
37
44
  3. Define scope with the user (one question at a time):
38
45
  - What is the goal of this phase?
39
46
  - What are the key deliverables?