@limina-labs/momentum 0.30.2

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.
Files changed (182) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +214 -0
  3. package/adapters/antigravity/adapter.js +196 -0
  4. package/adapters/antigravity/hooks.json +39 -0
  5. package/adapters/antigravity/instructions/AGENTS.md +638 -0
  6. package/adapters/antigravity/instructions/header.md +3 -0
  7. package/adapters/antigravity/instructions/surfaces.md +153 -0
  8. package/adapters/antigravity/instructions/vars.json +1 -0
  9. package/adapters/antigravity/scripts/antigravity-hook-adapter.sh +147 -0
  10. package/adapters/antigravity/skills/momentum-orient/SKILL.md +29 -0
  11. package/adapters/antigravity/skills/momentum-reviewer-architecture/SKILL.md +39 -0
  12. package/adapters/antigravity/skills/momentum-reviewer-qa/SKILL.md +38 -0
  13. package/adapters/antigravity/skills/momentum-reviewer-security/SKILL.md +42 -0
  14. package/adapters/antigravity/skills/swarm-supervisor/SKILL.md +75 -0
  15. package/adapters/antigravity/workflows/continue.md +39 -0
  16. package/adapters/antigravity/workflows/dispatch.md +49 -0
  17. package/adapters/antigravity/workflows/handoff.md +38 -0
  18. package/adapters/antigravity/workflows/review-code.md +84 -0
  19. package/adapters/antigravity/workflows/scout.md +42 -0
  20. package/adapters/antigravity/workflows/swarm.md +203 -0
  21. package/adapters/claude-code/adapter.js +148 -0
  22. package/adapters/claude-code/commands/continue.md +82 -0
  23. package/adapters/claude-code/commands/dispatch.md +154 -0
  24. package/adapters/claude-code/commands/handoff.md +85 -0
  25. package/adapters/claude-code/commands/review-code.md +194 -0
  26. package/adapters/claude-code/commands/scout.md +119 -0
  27. package/adapters/claude-code/commands/swarm.md +242 -0
  28. package/adapters/claude-code/instructions/header.md +3 -0
  29. package/adapters/claude-code/instructions/vars.json +1 -0
  30. package/adapters/claude-code/settings.json +36 -0
  31. package/adapters/codex/adapter.js +225 -0
  32. package/adapters/codex/agents/momentum-reviewer-architecture.toml +57 -0
  33. package/adapters/codex/agents/momentum-reviewer-qa.toml +52 -0
  34. package/adapters/codex/agents/momentum-reviewer-security.toml +57 -0
  35. package/adapters/codex/agents/swarm-supervisor.toml +76 -0
  36. package/adapters/codex/commands/continue.md +40 -0
  37. package/adapters/codex/commands/dispatch.md +111 -0
  38. package/adapters/codex/commands/handoff.md +57 -0
  39. package/adapters/codex/commands/scout.md +90 -0
  40. package/adapters/codex/commands/swarm.md +334 -0
  41. package/adapters/codex/hooks.json +36 -0
  42. package/adapters/codex/instructions/AGENTS.md +691 -0
  43. package/adapters/codex/instructions/header.md +3 -0
  44. package/adapters/codex/instructions/surfaces.md +206 -0
  45. package/adapters/codex/instructions/vars.json +1 -0
  46. package/adapters/codex/skills/momentum-orient/SKILL.md +29 -0
  47. package/adapters/opencode/adapter.js +224 -0
  48. package/adapters/opencode/agents/momentum-reviewer-architecture.md +54 -0
  49. package/adapters/opencode/agents/momentum-reviewer-qa.md +48 -0
  50. package/adapters/opencode/agents/momentum-reviewer-security.md +48 -0
  51. package/adapters/opencode/agents/swarm-supervisor.md +51 -0
  52. package/adapters/opencode/commands/continue.md +40 -0
  53. package/adapters/opencode/commands/dispatch.md +111 -0
  54. package/adapters/opencode/commands/handoff.md +57 -0
  55. package/adapters/opencode/commands/review-code.md +194 -0
  56. package/adapters/opencode/commands/scout.md +90 -0
  57. package/adapters/opencode/commands/swarm.md +334 -0
  58. package/adapters/opencode/instructions/AGENTS.md +604 -0
  59. package/adapters/opencode/instructions/header.md +3 -0
  60. package/adapters/opencode/instructions/surfaces.md +119 -0
  61. package/adapters/opencode/instructions/vars.json +1 -0
  62. package/adapters/opencode/plugins/momentum.js +219 -0
  63. package/adapters/opencode/skills/momentum-lanes/SKILL.md +30 -0
  64. package/adapters/opencode/skills/momentum-orient/SKILL.md +29 -0
  65. package/adapters/opencode/skills/momentum-track/SKILL.md +28 -0
  66. package/adapters/opencode/skills/momentum-validate/SKILL.md +31 -0
  67. package/bin/antigravity.js +112 -0
  68. package/bin/ecosystem.js +1302 -0
  69. package/bin/lanes.js +300 -0
  70. package/bin/momentum.js +1857 -0
  71. package/bin/okf.js +72 -0
  72. package/bin/orchestration-commands.js +305 -0
  73. package/bin/state-commands.js +218 -0
  74. package/bin/swarm.js +1104 -0
  75. package/bin/waves.js +147 -0
  76. package/core/adapter-capabilities.md +112 -0
  77. package/core/adapter-parity-matrix.md +170 -0
  78. package/core/commands/brainstorm-idea.md +98 -0
  79. package/core/commands/brainstorm-phase.md +129 -0
  80. package/core/commands/complete-phase.md +130 -0
  81. package/core/commands/ecosystem.md +161 -0
  82. package/core/commands/hotfix.md +64 -0
  83. package/core/commands/initiative.md +119 -0
  84. package/core/commands/lanes.md +94 -0
  85. package/core/commands/log.md +45 -0
  86. package/core/commands/migrate.md +70 -0
  87. package/core/commands/review.md +31 -0
  88. package/core/commands/session.md +79 -0
  89. package/core/commands/start-phase.md +161 -0
  90. package/core/commands/start-project.md +142 -0
  91. package/core/commands/sync-docs.md +81 -0
  92. package/core/commands/systematic-debug.md +46 -0
  93. package/core/commands/track.md +54 -0
  94. package/core/commands/validate.md +96 -0
  95. package/core/ecosystem/layout.md +128 -0
  96. package/core/ecosystem/lib/index.js +245 -0
  97. package/core/ecosystem/lib/initiative.js +258 -0
  98. package/core/ecosystem/lib/pointer.js +198 -0
  99. package/core/ecosystem/lib/state.js +295 -0
  100. package/core/ecosystem/schema/ecosystem.schema.json +85 -0
  101. package/core/ecosystem/schema/initiative.schema.json +61 -0
  102. package/core/ecosystem/scripts/session-append.sh +167 -0
  103. package/core/ecosystem/templates/ecosystem-agents.md +79 -0
  104. package/core/ecosystem/templates/ecosystem-claude.md +79 -0
  105. package/core/ecosystem/templates/ecosystem-settings-claude.json +14 -0
  106. package/core/ecosystem/templates/initiative-template.md +50 -0
  107. package/core/engines/subagent-dispatch.md +80 -0
  108. package/core/git-hooks/commit-msg +14 -0
  109. package/core/git-hooks/contract.js +176 -0
  110. package/core/git-hooks/pre-push +15 -0
  111. package/core/git-hooks/run-check.js +160 -0
  112. package/core/instructions/legacy-project-md-hashes.json +12 -0
  113. package/core/instructions/navigation.md +14 -0
  114. package/core/instructions/rules-body.md +454 -0
  115. package/core/lanes/lib/board.js +161 -0
  116. package/core/lanes/lib/land.js +301 -0
  117. package/core/lanes/lib/signals.js +207 -0
  118. package/core/lanes/lib/state.js +325 -0
  119. package/core/lib/frontmatter.js +163 -0
  120. package/core/lib/okf-migrate.js +374 -0
  121. package/core/lib/okf-types.js +61 -0
  122. package/core/lifecycle-contract.md +59 -0
  123. package/core/orchestration/capability-routing.js +120 -0
  124. package/core/orchestration/continue.js +102 -0
  125. package/core/orchestration/dispatch.js +366 -0
  126. package/core/orchestration/events.js +197 -0
  127. package/core/orchestration/handoff.js +317 -0
  128. package/core/orchestration/index.js +50 -0
  129. package/core/orchestration/run-artifact.js +93 -0
  130. package/core/orchestration/scout.js +327 -0
  131. package/core/orchestration/session-log.js +123 -0
  132. package/core/orchestration/tracking.js +180 -0
  133. package/core/orchestration/types.js +131 -0
  134. package/core/scripts/brainstorm-gate.sh +157 -0
  135. package/core/scripts/check-history-reminder.sh +110 -0
  136. package/core/scripts/sessionstart-handoff.sh +180 -0
  137. package/core/specs-templates/CLAUDE.md +484 -0
  138. package/core/specs-templates/README.md +32 -0
  139. package/core/specs-templates/specs/README.md +30 -0
  140. package/core/specs-templates/specs/adhoc/README.md +31 -0
  141. package/core/specs-templates/specs/adhoc/_TEMPLATE.md +29 -0
  142. package/core/specs-templates/specs/architecture/ecosystem.md +214 -0
  143. package/core/specs-templates/specs/backlog/backlog.md +46 -0
  144. package/core/specs-templates/specs/backlog/details/.gitkeep +0 -0
  145. package/core/specs-templates/specs/changelog/.gitkeep +0 -0
  146. package/core/specs-templates/specs/decisions/0000-template.md +33 -0
  147. package/core/specs-templates/specs/decisions/README.md +29 -0
  148. package/core/specs-templates/specs/decisions/impact-map.md +14 -0
  149. package/core/specs-templates/specs/decisions/index.md +4 -0
  150. package/core/specs-templates/specs/index.md +16 -0
  151. package/core/specs-templates/specs/phases/README.md +41 -0
  152. package/core/specs-templates/specs/phases/index.md +6 -0
  153. package/core/specs-templates/specs/planning/roadmap.md +23 -0
  154. package/core/specs-templates/specs/status.md +70 -0
  155. package/core/specs-templates/specs/vision/principles.md +16 -0
  156. package/core/specs-templates/specs/vision/project-charter.md +35 -0
  157. package/core/specs-templates/specs/vision/success-criteria.md +19 -0
  158. package/core/swarm/absorb.js +294 -0
  159. package/core/swarm/conductor.js +499 -0
  160. package/core/swarm/focus.js +151 -0
  161. package/core/swarm/inbox.js +222 -0
  162. package/core/swarm/join.js +138 -0
  163. package/core/swarm/lib/board.js +178 -0
  164. package/core/swarm/lib/brief.js +157 -0
  165. package/core/swarm/lib/git-sha-cache.js +71 -0
  166. package/core/swarm/lib/incremental-log.js +117 -0
  167. package/core/swarm/lib/manifest.js +578 -0
  168. package/core/swarm/lib/pre-merge.js +112 -0
  169. package/core/swarm/lib/saga.js +146 -0
  170. package/core/swarm/lib/sessions.js +83 -0
  171. package/core/swarm/lib/tokens.js +255 -0
  172. package/core/swarm/lib/wave-ordering.js +55 -0
  173. package/core/swarm/schema/board.schema.json +54 -0
  174. package/core/swarm/schema/contract.schema.json +56 -0
  175. package/core/swarm/schema/dispatch-run.schema.json +70 -0
  176. package/core/swarm/schema/manifest.schema.json +234 -0
  177. package/core/swarm/schema/signal.schema.json +61 -0
  178. package/core/swarm/signals.js +296 -0
  179. package/core/swarm/supervise.md +81 -0
  180. package/core/waves/lib/plan-graph.js +175 -0
  181. package/core/waves/lib/waves.js +83 -0
  182. package/package.json +46 -0
@@ -0,0 +1,1857 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { spawnSync } = require('child_process');
7
+
8
+ // Phase 19 — single source of truth for git-hook install constants.
9
+ const { CONTRACT: LIFECYCLE } = require(path.join(__dirname, '..', 'core', 'git-hooks', 'contract'));
10
+
11
+ // ── Version ───────────────────────────────────────────────────────────────────
12
+
13
+ const pkg = JSON.parse(
14
+ fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')
15
+ );
16
+
17
+ // ── Helpers ───────────────────────────────────────────────────────────────────
18
+
19
+ function copyFile(src, dest) {
20
+ recordManaged(dest);
21
+ if (_dryRun) return;
22
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
23
+ fs.copyFileSync(src, dest);
24
+ }
25
+
26
+ function copyDir(srcDir, destDir, opts = {}) {
27
+ fs.mkdirSync(destDir, { recursive: true });
28
+ for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
29
+ if (entry.name.startsWith('._') || entry.name === '.DS_Store') continue;
30
+ const src = path.join(srcDir, entry.name);
31
+ const dest = path.join(destDir, entry.name);
32
+ const rel = path.relative(srcDir, src);
33
+ if (opts.skipRelPaths && opts.skipRelPaths.has(rel)) continue;
34
+ if (entry.isDirectory()) {
35
+ copyDir(src, dest, opts);
36
+ continue;
37
+ }
38
+ // Leaf file. Record it in the managed set (for the lock file + orphan
39
+ // cleanup) whether it is written, identical, or skip-existing — its
40
+ // presence in momentum's managed set is what matters, not whether this
41
+ // run rewrote it. Install-once/user-owned trees opt out via record:false.
42
+ if (opts.record !== false) recordManaged(dest);
43
+ const destRel = path.relative(opts.root || process.cwd(), dest);
44
+ if (opts.upgradeMode) {
45
+ if (fileExists(dest)) {
46
+ const srcContent = fs.readFileSync(src, 'utf8');
47
+ const destContent = fs.readFileSync(dest, 'utf8');
48
+ if (srcContent !== destContent) {
49
+ if (_dryRun) {
50
+ console.log(` ✋ would upgrade: ${destRel}`);
51
+ } else {
52
+ fs.copyFileSync(dest, dest + '.bak');
53
+ fs.copyFileSync(src, dest);
54
+ console.log(` ↑ upgraded: ${destRel} (original saved as .bak)`);
55
+ }
56
+ }
57
+ // identical — silent skip
58
+ } else if (_dryRun) {
59
+ console.log(` ✋ would add: ${destRel}`);
60
+ } else {
61
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
62
+ fs.copyFileSync(src, dest);
63
+ console.log(` + added: ${destRel}`);
64
+ }
65
+ } else {
66
+ if (opts.skipIfExists && fileExists(dest)) {
67
+ console.log(` ⚠️ ${dest} already exists — skipping.`);
68
+ } else {
69
+ // BUG-008: on init, momentum-owned files must not be silently
70
+ // clobbered. With `backup`, an existing file that differs is saved as
71
+ // .bak before overwrite — making re-init idempotent and safe. Fresh
72
+ // adds stay quiet.
73
+ if (opts.backup && fileExists(dest)) {
74
+ const srcBuf = fs.readFileSync(src);
75
+ const destBuf = fs.readFileSync(dest);
76
+ if (!srcBuf.equals(destBuf)) {
77
+ if (_dryRun) {
78
+ console.log(` ✋ would update: ${destRel}`);
79
+ } else {
80
+ fs.copyFileSync(dest, dest + '.bak');
81
+ console.log(` ↑ updated: ${destRel} (original saved as .bak)`);
82
+ }
83
+ }
84
+ }
85
+ if (!_dryRun) {
86
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
87
+ fs.copyFileSync(src, dest);
88
+ }
89
+ }
90
+ }
91
+ }
92
+ }
93
+
94
+ function fileExists(filePath) {
95
+ try {
96
+ fs.accessSync(filePath);
97
+ return true;
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+
103
+ // BUG-006 — substitute <Project Name> placeholder in primary-instruction
104
+ // templates (CLAUDE.md / AGENTS.md) with the real project name on init+upgrade.
105
+ function getProjectName(targetDir) {
106
+ try {
107
+ const pkg = JSON.parse(
108
+ fs.readFileSync(path.join(targetDir, 'package.json'), 'utf8')
109
+ );
110
+ if (pkg.name && typeof pkg.name === 'string') {
111
+ const scoped = pkg.name.match(/^@[^/]+\/(.+)$/);
112
+ return scoped ? scoped[1] : pkg.name;
113
+ }
114
+ } catch {
115
+ // No package.json (or unreadable / unparseable) — fall through to dirname.
116
+ }
117
+ return path.basename(path.resolve(targetDir)) || 'project';
118
+ }
119
+
120
+ function renderProjectName(content, projectName) {
121
+ return content.replaceAll('<Project Name>', projectName);
122
+ }
123
+
124
+ // ── Installed-files lock file (Phase 20 — Upgrade Hardening) ──────────────────
125
+ //
126
+ // `.momentum/installed.json` is momentum's per-repo version-of-record, modelled
127
+ // on Copier's `_commit` / Cruft's `.cruft.json`. It records the momentum
128
+ // version that last wrote the repo, the adapter, and the exact set of
129
+ // tool-managed files (with sha256). It is COMMITTED to the repo (D1) so the
130
+ // version travels and survives fresh clones.
131
+ //
132
+ // The managed-file set powers three things: per-repo version reporting, the
133
+ // ecosystem sweep's agent detection, and orphan cleanup on upgrade
134
+ // (`old.managedFiles − new.managedFiles` = files a newer momentum dropped).
135
+ // We only ever delete files we previously installed — user files are never in
136
+ // the set, so they are never touched.
137
+ //
138
+ // Collection: a module-level Set is active only during init()/upgrade(). The
139
+ // plain-copy helpers (copyFile/copyDir) and the marker writers append the
140
+ // destination they manage. The specs/ skeleton is install-once and user-owned,
141
+ // so it opts out via `record: false`.
142
+
143
+ const MANIFEST_REL = ['.momentum', 'installed.json'];
144
+ const MANIFEST_SCHEMA = 1;
145
+
146
+ let _managedCollector = null;
147
+
148
+ // When true, init()/upgrade() compute and print the planned action set but
149
+ // perform NO filesystem writes (ENH-040). Every fs-mutation in the install
150
+ // path is gated on this; post-copy chmod/readdir loops are skipped too (they
151
+ // would crash on a dry run because nothing was written).
152
+ let _dryRun = false;
153
+
154
+ function recordManaged(destPath) {
155
+ if (_managedCollector) _managedCollector.add(path.resolve(destPath));
156
+ }
157
+
158
+ function sha256File(filePath) {
159
+ const crypto = require('crypto');
160
+ return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
161
+ }
162
+
163
+ /**
164
+ * Phase 22c (ADR-0007): Load installed state with one-time legacy migration.
165
+ *
166
+ * Legacy format (pre-Phase-22c):
167
+ * { agent, version, files|managedFiles, schema?, momentumVersion?, installedAt?, updatedAt? }
168
+ *
169
+ * New format (Phase-22c+):
170
+ * { version, agents: { [name]: { version, files: string[] } } }
171
+ * — files is an array of relative path strings (simpler than old {path,sha256} objects)
172
+ * — top-level version = max of all agent versions (backward compat)
173
+ *
174
+ * Migration: detects legacy format by presence of 'agent' at root and absence of 'agents',
175
+ * converts in-place immediately, returns the migrated state. Idempotent: the migrated
176
+ * file has 'agents', so repeat calls pass through without re-migrating.
177
+ * Returns default state ({ version: null, agents: {} }) when no manifest exists.
178
+ */
179
+ function loadInstalledState(targetDir) {
180
+ try {
181
+ const raw = JSON.parse(fs.readFileSync(path.join(targetDir, ...MANIFEST_REL), 'utf8'));
182
+
183
+ // Already new format
184
+ if (raw.agents && typeof raw.agents === 'object') {
185
+ return { version: raw.version || '0.0.0', agents: raw.agents };
186
+ }
187
+
188
+ // Legacy format detection: has 'agent' at root but no 'agents'
189
+ if ('agent' in raw) {
190
+ const version = raw.version || raw.momentumVersion || '0.0.0';
191
+ const rawFiles = raw.files || raw.managedFiles || [];
192
+ const files = Array.isArray(rawFiles)
193
+ ? rawFiles.map((f) => (typeof f === 'string' ? f : f.path || ''))
194
+ .filter(Boolean)
195
+ .sort()
196
+ : [];
197
+
198
+ const migrated = {
199
+ version,
200
+ agents: {
201
+ [raw.agent]: { version, files },
202
+ },
203
+ };
204
+
205
+ saveInstalledState(targetDir, migrated);
206
+ return migrated;
207
+ }
208
+
209
+ // Unknown format — return default
210
+ return { version: null, agents: {} };
211
+ } catch {
212
+ // Absent or unreadable
213
+ return { version: null, agents: {} };
214
+ }
215
+ }
216
+
217
+ function saveInstalledState(targetDir, state) {
218
+ const dest = path.join(targetDir, ...MANIFEST_REL);
219
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
220
+ // Compat mirror (ADR-0007 review fix): external readers — including
221
+ // bin/ecosystem.js's fleet version lock (readMemberVersion) and any
222
+ // OLDER momentum CLI inspecting a newer project — read `momentumVersion`.
223
+ // Keep it in sync with the new top-level `version` so mixed-version
224
+ // fleets never see 'unknown' after migration.
225
+ const out = { version: state.version, momentumVersion: state.version, agents: state.agents };
226
+ fs.writeFileSync(dest, JSON.stringify(out, null, 2) + '\n');
227
+ }
228
+
229
+ function readInstalledManifest(targetDir) {
230
+ // Backward compat wrapper: returns old format for callers that need prevManagedFiles.
231
+ // Only used by upgrade() for orphan cleanup — returns the CURRENT agent's prior entry.
232
+ // Phase 22c: replaced by loadInstalledState() for new code paths.
233
+ try {
234
+ return JSON.parse(fs.readFileSync(path.join(targetDir, ...MANIFEST_REL), 'utf8'));
235
+ } catch {
236
+ return null;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Phase 22c (ADR-0007): Serialize the managed-file set into `.momentum/installed.json`.
242
+ * Uses the per-agent `agents` map format — only updates the specified agent's entry,
243
+ * preserving all other agents. `files` is the collector Set of absolute paths.
244
+ * In dry-run mode the manifest is computed and returned but not written.
245
+ */
246
+ function writeInstalledManifest(targetDir, { version, agent, files, dryRun }) {
247
+ const state = loadInstalledState(targetDir); // handles legacy migration
248
+ const managedFiles = [...files]
249
+ .filter((abs) => fileExists(abs))
250
+ .map((abs) =>
251
+ path.relative(targetDir, abs).split(path.sep).join('/')
252
+ )
253
+ .sort();
254
+
255
+ state.agents[agent] = { version, files: managedFiles };
256
+
257
+ // Top-level version = max of all agent versions (backward compat)
258
+ state.version = Object.values(state.agents)
259
+ .map((a) => a.version)
260
+ .reduce((max, v) => (!max || isNewerVersion(v, max) ? v : max), null) || version;
261
+
262
+ if (!dryRun) saveInstalledState(targetDir, state);
263
+ return state;
264
+ }
265
+
266
+ /**
267
+ * Remove orphans: files a previous momentum version installed but the current
268
+ * one no longer ships (`prev.managedFiles − currentSet`). This is the piece a
269
+ * copy-only upgrade can never do (Cruft #67). Safety: we only ever consider
270
+ * files that WE previously recorded as managed — user files are never in the
271
+ * manifest, so they are never eligible. Each removal is backed up to `.bak`.
272
+ * Returns the list of removed relative paths.
273
+ */
274
+ /**
275
+ * Additively refresh the target's `.gitignore` with any momentum/OS ignore
276
+ * rules it's missing (Phase 20 follow-up). NEVER removes a user's lines — it
277
+ * only appends rules from `core/specs-templates/.gitignore` that aren't already
278
+ * present, under a marker comment, with a `.bak` backup. Fixes the gap where
279
+ * `upgrade` left an old `.gitignore` untouched, so repos predating the `._*` /
280
+ * `.momentum/*` rules stayed polluted (which on a non-Unix filesystem like
281
+ * exFAT makes git effectively unusable). One designed exception (BUG-014): a
282
+ * bare directory-level `.momentum/` rule is commented out in place, because
283
+ * it defeats the `!.momentum/installed.json` negation — the line's content is
284
+ * preserved, just disabled. `.gitignore` is user-owned, so it is
285
+ * deliberately NOT recorded as a managed file (never orphan-cleaned).
286
+ * Returns 'added' | 'updated' | 'unchanged'.
287
+ */
288
+ function refreshGitignore(srcRoot, targetDir) {
289
+ const templatePath = path.join(srcRoot, 'core', 'specs-templates', '.gitignore');
290
+ const targetPath = path.join(targetDir, '.gitignore');
291
+ if (!fileExists(templatePath)) return 'unchanged';
292
+
293
+ if (!fileExists(targetPath)) {
294
+ if (_dryRun) {
295
+ console.log(' ✋ would add: .gitignore');
296
+ return 'added';
297
+ }
298
+ fs.writeFileSync(targetPath, fs.readFileSync(templatePath, 'utf8'));
299
+ console.log(' + added: .gitignore');
300
+ return 'added';
301
+ }
302
+
303
+ // Append only the rule lines (ignore comments/blanks) the target lacks.
304
+ const wantedRules = fs
305
+ .readFileSync(templatePath, 'utf8')
306
+ .split('\n')
307
+ .map((l) => l.trim())
308
+ .filter((l) => l && !l.startsWith('#'));
309
+ const targetContent = fs.readFileSync(targetPath, 'utf8');
310
+ const have = new Set(targetContent.split('\n').map((l) => l.trim()));
311
+ const missing = wantedRules.filter((r) => !have.has(r));
312
+
313
+ // BUG-014 — a bare directory-level `.momentum/` rule (every pre-Phase-20
314
+ // install has one) defeats the appended `!.momentum/installed.json`: git
315
+ // cannot re-include a file under an ignored DIRECTORY, so the lock file
316
+ // stays silently ignored while upgrade reports success. Neutralize it by
317
+ // commenting it out in place — the designed exception to "never remove a
318
+ // user's lines" (the line's content is preserved, just disabled). Matches
319
+ // ONLY lines that are exactly `.momentum/` or `/.momentum/` modulo
320
+ // whitespace; `.momentum/*`, negations, comments, and unrelated lines are
321
+ // untouched. Runs even when nothing is missing (the pair may already exist
322
+ // from a prior upgrade with the legacy rule still winning above it).
323
+ const isLegacyDirRule = (l) => {
324
+ const t = l.trim();
325
+ return t === '.momentum/' || t === '/.momentum/';
326
+ };
327
+ const legacyCount = targetContent.split('\n').filter(isLegacyDirRule).length;
328
+ if (missing.length === 0 && legacyCount === 0) return 'unchanged';
329
+
330
+ if (_dryRun) {
331
+ if (legacyCount > 0) {
332
+ console.log(` ✋ would comment out ${legacyCount} legacy .momentum/ dir rule(s) in .gitignore (BUG-014)`);
333
+ }
334
+ if (missing.length > 0) {
335
+ console.log(` ✋ would append ${missing.length} rule(s) to .gitignore`);
336
+ }
337
+ return 'updated';
338
+ }
339
+ fs.copyFileSync(targetPath, targetPath + '.bak');
340
+ let next = legacyCount === 0 ? targetContent : targetContent
341
+ .split('\n')
342
+ .map((l) => isLegacyDirRule(l)
343
+ ? `# ${l.trim()} (disabled by momentum upgrade — directory rule defeats !.momentum/installed.json, BUG-014)`
344
+ : l)
345
+ .join('\n');
346
+ if (missing.length > 0) {
347
+ const addition =
348
+ '\n# Added by momentum upgrade — keep momentum + OS noise out of git\n' +
349
+ missing.join('\n') + '\n';
350
+ next = next.replace(/\s*$/, '\n') + addition;
351
+ }
352
+ fs.writeFileSync(targetPath, next);
353
+ const did = [];
354
+ if (legacyCount > 0) did.push(`commented out ${legacyCount} legacy .momentum/ dir rule(s)`);
355
+ if (missing.length > 0) did.push(`appended ${missing.length} missing rule(s)`);
356
+ console.log(` ↑ updated: .gitignore (${did.join('; ')}; original saved as .bak)`);
357
+ return 'updated';
358
+ }
359
+
360
+ /**
361
+ * Migrate the retired `.agent/rules/project.md` (Phase 23 / ADR-0004).
362
+ * Pristine — content sha256 matches a shipped historical revision (see
363
+ * core/instructions/legacy-project-md-hashes.json) — is removed (the rules
364
+ * now ride the primary instruction file). Anything else is user-customized:
365
+ * kept in place with a deprecation warning, and shielded from orphan
366
+ * cleanup by the caller. Manifest-independent, so pre-Phase-20 installs
367
+ * (no lock file) migrate too.
368
+ * Returns 'absent' | 'removed' | 'would-remove' | 'kept-customized'.
369
+ */
370
+ function migrateAgentRules(srcRoot, target, dests) {
371
+ const relParts = [...(dests['agent-rules'] || ['.agent', 'rules']), 'project.md'];
372
+ const rel = relParts.join('/');
373
+ const abs = path.join(target, ...relParts);
374
+ if (!fileExists(abs)) return 'absent';
375
+
376
+ const crypto = require('crypto');
377
+ const manifest = JSON.parse(
378
+ fs.readFileSync(
379
+ path.join(srcRoot, 'core', 'instructions', 'legacy-project-md-hashes.json'),
380
+ 'utf8'
381
+ )
382
+ );
383
+ // Trailing-whitespace-normalized: an editor-added extra final newline is
384
+ // not "customization" (proved by the self-repo dogfood). Every shipped
385
+ // revision ends with exactly one \n, so the manifest hashes are already
386
+ // in normalized form.
387
+ const normalized = fs.readFileSync(abs, 'utf8').replace(/\s+$/, '') + '\n';
388
+ const hash = crypto.createHash('sha256').update(normalized).digest('hex');
389
+
390
+ if (manifest.sha256.includes(hash)) {
391
+ if (_dryRun) {
392
+ console.log(` ✋ would remove: ${rel} (pristine — rules now live in the primary instruction file)`);
393
+ return 'would-remove';
394
+ }
395
+ fs.rmSync(abs);
396
+ try { fs.rmdirSync(path.dirname(abs)); } catch { /* dir not empty — fine */ }
397
+ console.log(` 🗑 removed: ${rel} (pristine — rules now live in the primary instruction file)`);
398
+ return 'removed';
399
+ }
400
+
401
+ console.log(` ⚠️ kept: ${rel} — customized content detected. This file is retired (ADR-0004);`);
402
+ console.log(` no agent auto-loads it. Move your customizations into the '## Project Extensions'`);
403
+ console.log(` section of the primary instruction file, then delete it.`);
404
+ return 'kept-customized';
405
+ }
406
+
407
+ function removeOrphans(targetDir, prevManifest, currentSet, opts = {}) {
408
+ if (!prevManifest || !Array.isArray(prevManifest.managedFiles)) return [];
409
+ const currentRel = new Set(
410
+ [...currentSet].map((abs) =>
411
+ path.relative(targetDir, abs).split(path.sep).join('/')
412
+ )
413
+ );
414
+ const removed = [];
415
+ for (const entry of prevManifest.managedFiles) {
416
+ // Phase 22c: entry can be a string path (new agents-map format) or { path, sha256 } (legacy)
417
+ const relPath = typeof entry === 'string' ? entry : entry.path;
418
+ if (!relPath) continue;
419
+ if (currentRel.has(relPath)) continue;
420
+ if (opts.keepRel && opts.keepRel.has(relPath)) {
421
+ console.log(` ⚠️ kept (not orphan-cleaned): ${relPath} — user-owned now`);
422
+ continue;
423
+ }
424
+ const abs = path.join(targetDir, relPath);
425
+ if (!fileExists(abs)) continue;
426
+ removed.push(relPath);
427
+ if (!opts.dryRun) {
428
+ fs.copyFileSync(abs, abs + '.bak');
429
+ fs.rmSync(abs);
430
+ }
431
+ console.log(
432
+ ` ${opts.dryRun ? '✋ would remove' : '🗑 removed'}: ${relPath}` +
433
+ `${opts.dryRun ? '' : ' (original saved as .bak)'}`
434
+ );
435
+ }
436
+ return removed;
437
+ }
438
+
439
+ function listAvailableAgents(src = path.join(__dirname, '..')) {
440
+ const adaptersDir = path.join(src, 'adapters');
441
+ if (!fs.existsSync(adaptersDir)) return [];
442
+ return fs.readdirSync(adaptersDir, { withFileTypes: true })
443
+ .filter((entry) => entry.isDirectory())
444
+ .map((entry) => entry.name)
445
+ .filter((name) => fs.existsSync(path.join(adaptersDir, name, 'adapter.js')))
446
+ .sort();
447
+ }
448
+
449
+ function formatAvailableAgents(src = path.join(__dirname, '..')) {
450
+ return listAvailableAgents(src).join(', ');
451
+ }
452
+
453
+ function loadAdapter(src, agent) {
454
+ const adapterDir = path.join(src, 'adapters', agent);
455
+ if (!fs.existsSync(adapterDir)) {
456
+ console.error(`Error: Unknown agent '${agent}'.`);
457
+ console.error(`Available: ${formatAvailableAgents(src)}`);
458
+ process.exit(1);
459
+ }
460
+ return {
461
+ adapterDir,
462
+ adapter: require(path.join(adapterDir, 'adapter.js')),
463
+ };
464
+ }
465
+
466
+ // ── Adapter contract — spawn(directive) (Phase 18) ───────────────────────────
467
+ //
468
+ // Every adapter MUST export a `spawn(directive)` function. The conductor
469
+ // (`core/swarm/conductor.js::buildSpawnDirectives`) produces platform-agnostic
470
+ // directives; the adapter dispatches them to its native runtime (Claude Code
471
+ // `claude --bg --cwd ...`, Codex `codex --cwd ...`, Antigravity `agy`, etc).
472
+ //
473
+ // Directive shape (canonical — produced by buildSpawnDirectives, consumed
474
+ // by adapter.spawn):
475
+ //
476
+ // {
477
+ // platform: string, // adapter id ("claude-code" | "codex" | "antigravity")
478
+ // swarmId: string, // e.g. "0007-user-auth"
479
+ // wave: number, // 1-indexed
480
+ // repoId: string, // ecosystem member id
481
+ // repoPath: string, // absolute path; supervisor's pinned cwd
482
+ // phaseSlug: string, // e.g. "phase-3-user-auth"
483
+ // branch: string,
484
+ // sessionId: string, // session id of the spawning conductor
485
+ // recipePath: string, // absolute path to supervise.md
486
+ // contextPath: string, // optional — for tell / broadcast injection
487
+ // env: object, // MOMENTUM_SWARM_* vars to inject
488
+ // }
489
+ //
490
+ // Return contract: an array element of shape
491
+ // { repoId, status, detail }
492
+ // where `status` is the child-process exit code (0 = ok, non-zero = failure,
493
+ // -1 = could not launch). adapter.spawn is invoked once PER directive so the
494
+ // per-repo result is the function's own return value.
495
+ //
496
+ // Pure stubs (adapters that do not yet implement a runtime dispatch) MUST
497
+ // return `{ repoId, status: -1, detail: 'not implemented' }` rather than
498
+ // throwing — the conductor stays robust to per-repo dispatch failures.
499
+ function adapterSupportsSpawn(adapter) {
500
+ return adapter && typeof adapter.spawn === 'function';
501
+ }
502
+
503
+ function resolveAdapterSource(srcRoot, adapterDir, fileSpec) {
504
+ const sourceBase = fileSpec.sourceBase || 'adapter';
505
+ const base = sourceBase === 'package' ? srcRoot : adapterDir;
506
+ return path.join(base, ...fileSpec.source);
507
+ }
508
+
509
+ function installPrimaryInstruction(srcRoot, targetDir, adapterDir, primaryInstruction) {
510
+ if (!primaryInstruction) return null;
511
+ const srcPath = resolveAdapterSource(srcRoot, adapterDir, primaryInstruction);
512
+ const destPath = path.join(targetDir, ...primaryInstruction.destination);
513
+ const label = primaryInstruction.label || primaryInstruction.destination.join('/');
514
+ recordManaged(destPath); // managed (marker-owned) whether added or skipped
515
+
516
+ console.log(`→ Installing ${label}...`);
517
+ if (!fileExists(destPath)) {
518
+ if (_dryRun) {
519
+ console.log(` ✋ would add: ${label}`);
520
+ return 'added';
521
+ }
522
+ const rendered = renderProjectName(
523
+ fs.readFileSync(srcPath, 'utf8'),
524
+ getProjectName(targetDir)
525
+ );
526
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
527
+ fs.writeFileSync(destPath, rendered);
528
+ return 'added';
529
+ }
530
+
531
+ console.log(` ⚠️ ${label} already exists — skipping.`);
532
+ return 'skipped';
533
+ }
534
+
535
+ function upgradePrimaryInstruction(srcRoot, targetDir, adapterDir, primaryInstruction) {
536
+ if (!primaryInstruction) return null;
537
+ const srcPath = resolveAdapterSource(srcRoot, adapterDir, primaryInstruction);
538
+ const destPath = path.join(targetDir, ...primaryInstruction.destination);
539
+ const label = primaryInstruction.label || primaryInstruction.destination.join('/');
540
+ const projectName = getProjectName(targetDir);
541
+ recordManaged(destPath); // managed (marker-owned)
542
+
543
+ console.log(`→ Upgrading ${label}...`);
544
+ if (primaryInstruction.markerAware) {
545
+ return upgradeMarkedFile(srcPath, destPath, label, targetDir, projectName);
546
+ }
547
+
548
+ const srcContent = renderProjectName(fs.readFileSync(srcPath, 'utf8'), projectName);
549
+ const relDest = path.relative(targetDir, destPath);
550
+ if (!fileExists(destPath)) {
551
+ if (_dryRun) {
552
+ console.log(` ✋ would add: ${relDest}`);
553
+ return 'added';
554
+ }
555
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
556
+ fs.writeFileSync(destPath, srcContent);
557
+ console.log(` + added: ${relDest}`);
558
+ return 'added';
559
+ }
560
+
561
+ const destContent = fs.readFileSync(destPath, 'utf8');
562
+ if (srcContent === destContent) return 'unchanged';
563
+
564
+ if (_dryRun) {
565
+ console.log(` ✋ would upgrade: ${relDest}`);
566
+ return 'updated';
567
+ }
568
+ fs.copyFileSync(destPath, destPath + '.bak');
569
+ fs.writeFileSync(destPath, srcContent);
570
+ console.log(` ↑ upgraded: ${relDest} (original saved as .bak)`);
571
+ return 'updated';
572
+ }
573
+
574
+ // ── Adapter overlay (FEAT-012, Phase 6) ──────────────────────────────────────
575
+ //
576
+ // Adapters may ship per-agent commands/agent-rules/scripts under
577
+ // `adapters/<name>/{commands,agent-rules,scripts}/`. These overlay onto the
578
+ // same destinations as the corresponding `core/<sub>/` content.
579
+ //
580
+ // Contract: additive-only. A given filename lives in EITHER `core/` OR exactly
581
+ // one adapter, never both. Duplicates are a hard error caught before any
582
+ // files are written. Generic content goes in `core/`; agent-specific in
583
+ // `adapters/<name>/`.
584
+
585
+ const DEFAULT_OVERLAY_DESTS = {
586
+ commands: ['.claude', 'commands'],
587
+ 'agent-rules': ['.agent', 'rules'],
588
+ scripts: ['scripts'],
589
+ engines: ['.agent', 'engines'],
590
+ };
591
+
592
+ function listFilesRecursive(dir) {
593
+ if (!fs.existsSync(dir)) return [];
594
+ const result = [];
595
+ const walk = (d, prefix) => {
596
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
597
+ if (entry.name.startsWith('._') || entry.name === '.DS_Store') continue;
598
+ const rel = prefix ? path.join(prefix, entry.name) : entry.name;
599
+ if (entry.isDirectory()) walk(path.join(d, entry.name), rel);
600
+ else result.push(rel);
601
+ }
602
+ };
603
+ walk(dir, '');
604
+ return result;
605
+ }
606
+
607
+ function detectOverlayConflicts(coreRoot, adapterRoot, subdirs) {
608
+ const conflicts = [];
609
+ for (const sub of subdirs) {
610
+ const adapterSubdir = path.join(adapterRoot, sub);
611
+ if (!fs.existsSync(adapterSubdir)) continue;
612
+ const coreFiles = new Set(listFilesRecursive(path.join(coreRoot, sub)));
613
+ for (const f of listFilesRecursive(adapterSubdir)) {
614
+ if (coreFiles.has(f)) conflicts.push({ subdir: sub, file: f });
615
+ }
616
+ }
617
+ return conflicts;
618
+ }
619
+
620
+ function failOnOverlayConflicts(coreRoot, adapterRoot, agent, dests) {
621
+ const conflicts = detectOverlayConflicts(
622
+ coreRoot,
623
+ adapterRoot,
624
+ Object.keys(dests)
625
+ );
626
+ if (conflicts.length === 0) return;
627
+ console.error(
628
+ `Error: duplicate overlay files in core/ and adapters/${agent}/.`
629
+ );
630
+ console.error('Each file may live in EITHER core/ OR exactly one adapter, never both.');
631
+ for (const c of conflicts) {
632
+ console.error(` - ${c.subdir}/${c.file}`);
633
+ }
634
+ console.error('');
635
+ console.error(
636
+ 'Resolution: keep the file in core/ if it is generic across agents, ' +
637
+ 'or in adapters/<agent>/ if it exploits an agent-specific capability. ' +
638
+ 'Delete the duplicate from the other location.'
639
+ );
640
+ process.exit(1);
641
+ }
642
+
643
+ function applyOverlay(adapterRoot, targetDir, dests, opts = {}) {
644
+ for (const [sub, destParts] of Object.entries(dests)) {
645
+ const overlaySrc = path.join(adapterRoot, sub);
646
+ if (!fs.existsSync(overlaySrc)) continue;
647
+ const dest = path.join(targetDir, ...destParts);
648
+ const label = opts.upgradeMode ? 'Overlaying (upgrade)' : 'Overlaying';
649
+ console.log(`→ ${label} ${sub} from adapter...`);
650
+ copyDir(overlaySrc, dest, opts);
651
+ if (sub === 'scripts' && fs.existsSync(dest)) {
652
+ for (const f of fs.readdirSync(dest)) {
653
+ if (f.endsWith('.sh')) fs.chmodSync(path.join(dest, f), 0o755);
654
+ }
655
+ }
656
+ }
657
+ }
658
+
659
+ // ── Marker-based upgrade (ENH-010 / FEAT-011) ────────────────────────────────
660
+
661
+ const MARKER = '## Project Extensions';
662
+
663
+ /**
664
+ * Split a file's content at the `## Project Extensions` heading.
665
+ * Lossless: `managed + extensions === content` when marker is present.
666
+ * Returns `{managed, extensions}` where `extensions` is null if no marker.
667
+ */
668
+ function partitionByMarker(content) {
669
+ const idx = content.indexOf('\n' + MARKER);
670
+ if (idx === -1) return { managed: content, extensions: null };
671
+ return {
672
+ managed: content.slice(0, idx),
673
+ extensions: content.slice(idx), // starts with '\n## Project Extensions'
674
+ };
675
+ }
676
+
677
+ /**
678
+ * Upgrade a marker-aware file. Three paths:
679
+ * - target missing → write source as-is
680
+ * - target has marker → replace managed section, preserve extensions
681
+ * - target lacks marker → backup as .bak, write source, append old content under marker
682
+ *
683
+ * Returns one of: 'added', 'updated', 'unchanged', 'migrated'.
684
+ */
685
+ function upgradeMarkedFile(srcPath, destPath, label, root, projectName) {
686
+ const rel = path.relative(root || process.cwd(), destPath);
687
+ recordManaged(destPath); // managed (marker-owned) in all branches
688
+ const render = (content) =>
689
+ projectName ? renderProjectName(content, projectName) : content;
690
+
691
+ if (!fileExists(destPath)) {
692
+ if (_dryRun) {
693
+ console.log(` ✋ would add: ${rel}`);
694
+ return 'added';
695
+ }
696
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
697
+ fs.writeFileSync(destPath, render(fs.readFileSync(srcPath, 'utf8')));
698
+ console.log(` + added: ${rel}`);
699
+ return 'added';
700
+ }
701
+
702
+ const srcContent = render(fs.readFileSync(srcPath, 'utf8'));
703
+ const destContent = fs.readFileSync(destPath, 'utf8');
704
+ const destParts = partitionByMarker(destContent);
705
+
706
+ if (destParts.extensions === null) {
707
+ // Pre-marker file — back up, write fresh template, append old content
708
+ // under the marker so nothing is lost. The user can manually de-dupe.
709
+ if (_dryRun) {
710
+ console.log(` ✋ would migrate: ${rel} (no marker — original would be saved as .bak)`);
711
+ return 'migrated';
712
+ }
713
+ fs.copyFileSync(destPath, destPath + '.bak');
714
+ const today = new Date().toISOString().slice(0, 10);
715
+ const migrated =
716
+ srcContent.replace(/\s+$/, '') +
717
+ `\n\n<!-- migrated from pre-marker version on ${today}` +
718
+ ` — original at ${path.basename(destPath)}.bak -->\n` +
719
+ destContent.replace(/\s+$/, '') +
720
+ '\n';
721
+ fs.writeFileSync(destPath, migrated);
722
+ console.log(` ↻ migrated: ${rel} (no marker — original saved as .bak)`);
723
+ return 'migrated';
724
+ }
725
+
726
+ const srcParts = partitionByMarker(srcContent);
727
+ const newContent = srcParts.managed + destParts.extensions;
728
+
729
+ if (newContent === destContent) {
730
+ return 'unchanged';
731
+ }
732
+
733
+ if (_dryRun) {
734
+ console.log(` ✋ would upgrade: ${rel} (managed section replaced; extensions preserved)`);
735
+ return 'updated';
736
+ }
737
+ fs.copyFileSync(destPath, destPath + '.bak');
738
+ fs.writeFileSync(destPath, newContent);
739
+ console.log(
740
+ ` ↑ upgraded: ${rel} (managed section replaced; extensions preserved; original saved as .bak)`
741
+ );
742
+ return 'updated';
743
+ }
744
+
745
+ // ── Init command ──────────────────────────────────────────────────────────────
746
+
747
+ // ── Git hooks (Phase 19 — Lifecycle Hardening) ─────────────────────────────────
748
+ //
749
+ // Install momentum's vendor-neutral git-lifecycle hooks into the target's
750
+ // `.githooks/` dir and point `core.hooksPath` at it. Zero-dependency: plain
751
+ // shell wrappers + a node dispatcher, no husky/lefthook.
752
+ //
753
+ // Warn-not-clobber (the BUG-008 lesson): if the target already uses a custom
754
+ // `core.hooksPath` or husky, momentum skips rather than overwriting.
755
+ // True if `filePath` is one of momentum's own shipped hook files (vs a foreign
756
+ // hook of the same name). Recognizes the current header signature AND legacy
757
+ // pre-BUG-011 installs, so `upgrade` can self-heal older repos in place.
758
+ function isMomentumHookFile(filePath) {
759
+ try {
760
+ if (!fs.existsSync(filePath)) return false;
761
+ const c = fs.readFileSync(filePath, 'utf8');
762
+ return /momentum[^\n]*hook/i.test(c) || c.includes('Lifecycle Hardening');
763
+ } catch {
764
+ return false;
765
+ }
766
+ }
767
+
768
+ // Install momentum's hook files into `hooksDest` ADDITIVELY (BUG-011):
769
+ // missing → install it
770
+ // existing & ours → upgrade in place (.bak first)
771
+ // existing & foreign → leave untouched (warn-not-clobber)
772
+ // Skips macOS AppleDouble (`._*`) sidecars so exFAT volumes don't leak junk.
773
+ function installHookFiles(hooksSrc, hooksDest) {
774
+ const out = { installed: [], upgraded: [], unchanged: [], skipped: [] };
775
+ if (!_dryRun) fs.mkdirSync(hooksDest, { recursive: true });
776
+ for (const f of fs.readdirSync(hooksSrc)) {
777
+ if (f.startsWith('._') || f === '.DS_Store') continue;
778
+ const srcF = path.join(hooksSrc, f);
779
+ if (!fs.statSync(srcF).isFile()) continue;
780
+ const destF = path.join(hooksDest, f);
781
+ if (fs.existsSync(destF)) {
782
+ if (isMomentumHookFile(destF)) {
783
+ // Identical content → no rewrite and NO .bak. Backing up identical
784
+ // files is how `.githooks/*.bak` litter got committed (BUG-017);
785
+ // the file still counts for recordManaged + the exec-bit re-chmod.
786
+ if (fs.readFileSync(srcF, 'utf8') === fs.readFileSync(destF, 'utf8')) {
787
+ recordManaged(destF);
788
+ out.unchanged.push(f);
789
+ } else {
790
+ if (!_dryRun) {
791
+ fs.copyFileSync(destF, destF + '.bak');
792
+ fs.copyFileSync(srcF, destF);
793
+ }
794
+ recordManaged(destF);
795
+ out.upgraded.push(f);
796
+ }
797
+ } else {
798
+ out.skipped.push(f); // foreign hook — never clobber
799
+ }
800
+ } else {
801
+ if (!_dryRun) fs.copyFileSync(srcF, destF);
802
+ recordManaged(destF);
803
+ out.installed.push(f);
804
+ }
805
+ }
806
+ return out;
807
+ }
808
+
809
+ function installGitHooks(src, target, opts = {}) {
810
+ const hooksSrc = path.join(src, 'core', 'git-hooks');
811
+ if (!fs.existsSync(hooksSrc)) return;
812
+
813
+ const ourPath = LIFECYCLE.hooksPath; // '.githooks'
814
+ const isGitRepo = fs.existsSync(path.join(target, '.git'));
815
+
816
+ // husky is the one genuine skip — momentum won't fight a husky setup.
817
+ if (fs.existsSync(path.join(target, '.husky'))) {
818
+ console.log(` ⚠️ git hooks: target uses husky (.husky/) — skipping momentum git-hook install.`);
819
+ console.log(` To enable manually, copy core/git-hooks/* into your hooks dir.`);
820
+ return;
821
+ }
822
+
823
+ let existingHooksPath = '';
824
+ if (isGitRepo) {
825
+ const cfg = spawnSync('git', ['-C', target, 'config', '--local', '--get', 'core.hooksPath'], {
826
+ encoding: 'utf8',
827
+ });
828
+ existingHooksPath = (cfg.stdout || '').trim();
829
+ }
830
+
831
+ // BUG-011: when a core.hooksPath is already configured (even the default
832
+ // .git/hooks), RESPECT it and install momentum's hooks ADDITIVELY into it.
833
+ // The old guard skipped the install entirely in this case, leaving repos with
834
+ // zero enforcement hooks while reporting success. Only adopt .githooks and
835
+ // set the config ourselves when nothing is configured.
836
+ const usingConfiguredPath = !!(existingHooksPath && existingHooksPath !== ourPath);
837
+ const destRel = usingConfiguredPath ? existingHooksPath : ourPath;
838
+ const hooksDest = path.isAbsolute(destRel) ? destRel : path.join(target, destRel);
839
+
840
+ const res = installHookFiles(hooksSrc, hooksDest);
841
+
842
+ if (_dryRun) {
843
+ console.log(
844
+ ` ✋ would ${usingConfiguredPath ? `install momentum hooks into ${destRel}/` : `set core.hooksPath → ${ourPath}/`}`
845
+ );
846
+ return;
847
+ }
848
+
849
+ // Executable bit on every momentum-managed hook file — including unchanged
850
+ // ones, so upgrade keeps self-healing exec bits on old installs (git only
851
+ // execs the git-named wrappers, but +x is harmless on the node libs).
852
+ for (const f of [...res.installed, ...res.upgraded, ...res.unchanged]) {
853
+ if (f.endsWith('.md')) continue;
854
+ const fp = path.join(hooksDest, f);
855
+ if (fs.existsSync(fp)) fs.chmodSync(fp, 0o755);
856
+ }
857
+
858
+ // Adopt .githooks + point core.hooksPath at it only when nothing was set.
859
+ if (isGitRepo && !usingConfiguredPath) {
860
+ spawnSync('git', ['-C', target, 'config', '--local', 'core.hooksPath', ourPath]);
861
+ }
862
+
863
+ if (res.installed.length + res.upgraded.length > 0) {
864
+ const where = usingConfiguredPath
865
+ ? `${destRel}/ (your configured core.hooksPath)`
866
+ : `.githooks/ (core.hooksPath set)`;
867
+ console.log(` ✓ git hooks installed → ${where}.`);
868
+ } else if (res.skipped.length === 0) {
869
+ console.log(` ✓ git hooks: already current at ${destRel}/.`);
870
+ }
871
+ for (const f of res.skipped) {
872
+ console.log(` ⚠️ git hooks: ${destRel}/${f} exists and is not momentum's — left untouched.`);
873
+ }
874
+ if (!isGitRepo) {
875
+ console.log(` ⚠️ not a git repo — hooks copied to ${destRel}/; after 'git init' run:`);
876
+ console.log(` git config core.hooksPath ${destRel}`);
877
+ }
878
+ }
879
+
880
+ // ── Ecosystem-root guard (BUG-016) ────────────────────────────────────────────
881
+ //
882
+ // The project-mode scaffold must never land in an ecosystem coordination
883
+ // root. A directory whose ecosystem.json carries both `name` and a `members`
884
+ // array is the coordination layer: its CLAUDE.md / AGENTS.md are the
885
+ // ecosystem templates owned by `momentum ecosystem init|upgrade`, and no
886
+ // specs/ tree exists there. Installing the project scaffold over it points
887
+ // every future agent session at specs/status.md — a file that can never
888
+ // exist in a coordination root. Requiring BOTH keys keeps a foreign
889
+ // ecosystem.json (e.g. PM2's) from false-positiving the guard.
890
+ function assertNotEcosystemRoot(target, command) {
891
+ const manifestPath = path.join(target, 'ecosystem.json');
892
+ if (!fileExists(manifestPath)) return;
893
+ let manifest;
894
+ try {
895
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
896
+ } catch {
897
+ return; // non-JSON — not a momentum manifest
898
+ }
899
+ if (
900
+ !manifest || typeof manifest !== 'object' ||
901
+ !manifest.name || !Array.isArray(manifest.members)
902
+ ) {
903
+ return;
904
+ }
905
+ throw new Error(
906
+ `${command}: ${target} is the coordination root of ecosystem "${manifest.name}" ` +
907
+ `(ecosystem.json present) — the project scaffold does not belong here.\n` +
908
+ ` The coordination root's CLAUDE.md / AGENTS.md are managed by the ecosystem commands:\n` +
909
+ ` momentum ecosystem upgrade # refresh root instructions + sweep member repos\n` +
910
+ ` To install momentum into a member repo, run it from (or point it at) that repo:\n` +
911
+ ` momentum ${command} <member-path>`
912
+ );
913
+ }
914
+
915
+ function init(targetDir, agent, opts = {}) {
916
+ const target = path.resolve(targetDir);
917
+ const src = path.join(__dirname, '..');
918
+
919
+ assertNotEcosystemRoot(target, 'init');
920
+
921
+ // Load adapter
922
+ const { adapterDir, adapter } = loadAdapter(src, agent);
923
+ const dests = adapter.destinations || DEFAULT_OVERLAY_DESTS;
924
+ const coreRoot = path.join(src, 'core');
925
+
926
+ // Pre-flight: error before any writes if adapter overlay duplicates a core filename
927
+ failOnOverlayConflicts(coreRoot, adapterDir, agent, dests);
928
+
929
+ console.log(`Installing momentum into: ${target} [agent: ${agent}]`);
930
+ if (opts.dryRun) console.log('(dry run — no files will be written)');
931
+ console.log('');
932
+
933
+ _dryRun = !!opts.dryRun;
934
+ _managedCollector = new Set();
935
+ try {
936
+ // .claude/commands/
937
+ console.log('→ Installing slash commands...');
938
+ copyDir(
939
+ path.join(src, 'core', 'commands'),
940
+ path.join(target, ...dests.commands),
941
+ { backup: true, root: target }
942
+ );
943
+
944
+ // scripts/
945
+ console.log('→ Installing hook scripts...');
946
+ // Phase 16 Rework: copy the entire core/scripts/ tree. Previously only
947
+ // check-history-reminder.sh was copied explicitly; brainstorm-gate.sh
948
+ // lived in adapters/claude-code/scripts/ and reached the target via
949
+ // the overlay. brainstorm-gate.sh now ships from core so Codex and
950
+ // Antigravity also pick it up via the same scripts/ destination.
951
+ copyDir(
952
+ path.join(src, 'core', 'scripts'),
953
+ path.join(target, ...dests.scripts),
954
+ { backup: true, root: target }
955
+ );
956
+ if (!_dryRun) {
957
+ for (const f of fs.readdirSync(path.join(target, ...dests.scripts))) {
958
+ if (f.endsWith('.sh')) {
959
+ fs.chmodSync(path.join(target, ...dests.scripts, f), 0o755);
960
+ }
961
+ }
962
+ }
963
+ // Phase 9 — ecosystem session-append helper, sourced by check-history-reminder.
964
+ const sessionSrc = path.join(src, 'core', 'ecosystem', 'scripts', 'session-append.sh');
965
+ if (fs.existsSync(sessionSrc)) {
966
+ const sessionDest = path.join(target, ...dests.scripts, 'session-append.sh');
967
+ copyFile(sessionSrc, sessionDest);
968
+ if (!_dryRun) fs.chmodSync(sessionDest, 0o755);
969
+ }
970
+
971
+ // .githooks/ — git-lifecycle enforcement hooks (Phase 19)
972
+ console.log('→ Installing git-lifecycle hooks...');
973
+ installGitHooks(src, target);
974
+
975
+ // core/engines/
976
+ console.log('→ Installing execution engines...');
977
+ if (fs.existsSync(path.join(src, 'core', 'engines'))) {
978
+ copyDir(
979
+ path.join(src, 'core', 'engines'),
980
+ path.join(target, ...(dests.engines || ['.agent', 'engines'])),
981
+ { backup: true, root: target }
982
+ );
983
+ }
984
+
985
+ // .agent/rules/project.md is retired (Phase 23 / ADR-0004): the full rules
986
+ // now ride each adapter's auto-loaded primary instruction file. Fresh
987
+ // installs ship nothing here; `upgrade` migrates existing copies.
988
+
989
+ // specs/ skeleton (root instruction file is adapter-owned)
990
+ console.log('→ Scaffolding project specs...');
991
+ const specsSrc = path.join(src, 'core', 'specs-templates');
992
+ copyDir(specsSrc, target, {
993
+ skipIfExists: true,
994
+ skipRelPaths: new Set(['CLAUDE.md']),
995
+ record: false, // specs are install-once / user-owned — never orphan them
996
+ });
997
+
998
+ installPrimaryInstruction(src, target, adapterDir, adapter.primaryInstruction);
999
+
1000
+ // Adapter overlay — per-agent commands/agent-rules/scripts (additive)
1001
+ applyOverlay(adapterDir, target, dests, { backup: true, root: target });
1002
+
1003
+ // Coding-agent-specific steps
1004
+ adapter.runInstall(target, adapterDir, { copyFile, copyDir, fileExists, recordManaged, dryRun: _dryRun });
1005
+
1006
+ // Lock file — record the version-of-record + managed-file set (Phase 20)
1007
+ writeInstalledManifest(target, { version: pkg.version, agent, files: _managedCollector, dryRun: _dryRun });
1008
+ } finally {
1009
+ _managedCollector = null;
1010
+ _dryRun = false;
1011
+ }
1012
+
1013
+ if (opts.dryRun) {
1014
+ console.log('');
1015
+ console.log('✓ Dry run complete — no files were written.');
1016
+ console.log(' Re-run without --dry-run to apply.');
1017
+ return;
1018
+ }
1019
+
1020
+ console.log('');
1021
+ console.log('✓ momentum installed successfully.');
1022
+ console.log('');
1023
+ console.log('Next steps:');
1024
+ console.log('');
1025
+ const displayName = adapter.displayName || agent;
1026
+ console.log(' Explore an idea first:');
1027
+ console.log(` Open ${displayName} and run: /brainstorm-idea`);
1028
+ console.log('');
1029
+ console.log(' Ready to scaffold a project:');
1030
+ console.log(` Open ${displayName} and run: /start-project`);
1031
+ console.log('');
1032
+ console.log(' Existing project — plan your next phase:');
1033
+ console.log(` Open ${displayName} and run: /brainstorm-phase`);
1034
+ console.log('');
1035
+ console.log(' See docs: https://github.com/LiminaLabsAI/momentum');
1036
+ }
1037
+
1038
+ // ── Upgrade command ───────────────────────────────────────────────────────────
1039
+
1040
+ function upgrade(targetDir, agent, opts = {}) {
1041
+ const target = path.resolve(targetDir);
1042
+ const src = path.join(__dirname, '..');
1043
+
1044
+ assertNotEcosystemRoot(target, 'upgrade');
1045
+
1046
+ // Load adapter
1047
+ const { adapterDir, adapter } = loadAdapter(src, agent);
1048
+ const dests = adapter.destinations || DEFAULT_OVERLAY_DESTS;
1049
+ const coreRoot = path.join(src, 'core');
1050
+
1051
+ // Pre-flight: error before any writes if adapter overlay duplicates a core filename
1052
+ failOnOverlayConflicts(coreRoot, adapterDir, agent, dests);
1053
+
1054
+ console.log(`Upgrading momentum in: ${target} [agent: ${agent}]`);
1055
+ if (opts.dryRun) console.log('(dry run — no files will be written)');
1056
+ console.log('');
1057
+
1058
+ const upgradeOpts = { upgradeMode: true, root: target };
1059
+
1060
+ // Phase 22c (ADR-0007): scope orphan cleanup to THIS agent's prior files only.
1061
+ // Load the installed state (handles legacy migration), extract only the agent
1062
+ // being upgraded — other agents' files are never eligible for removal.
1063
+ const prevState = loadInstalledState(target);
1064
+ const prevAgentEntry = prevState.agents[agent];
1065
+ const prevAgentFiles = prevAgentEntry && Array.isArray(prevAgentEntry.files)
1066
+ ? prevAgentEntry.files
1067
+ : [];
1068
+
1069
+ _dryRun = !!opts.dryRun;
1070
+ _managedCollector = new Set();
1071
+ let agentRulesResult, primaryInstructionResult, gitignoreResult, orphans = [];
1072
+ try {
1073
+ // Upgrade slash commands
1074
+ console.log('→ Upgrading slash commands...');
1075
+ copyDir(
1076
+ path.join(src, 'core', 'commands'),
1077
+ path.join(target, ...dests.commands),
1078
+ upgradeOpts
1079
+ );
1080
+
1081
+ // Upgrade hook scripts
1082
+ console.log('→ Upgrading hook scripts...');
1083
+ copyDir(
1084
+ path.join(src, 'core', 'scripts'),
1085
+ path.join(target, ...dests.scripts),
1086
+ upgradeOpts
1087
+ );
1088
+ // Phase 9 — ecosystem session-append helper lives outside core/scripts/
1089
+ // (it belongs to the ecosystem subsystem) but ships alongside the hook.
1090
+ const sessionUpgradeSrc = path.join(src, 'core', 'ecosystem', 'scripts', 'session-append.sh');
1091
+ if (fs.existsSync(sessionUpgradeSrc)) {
1092
+ const sessionUpgradeDest = path.join(target, ...dests.scripts, 'session-append.sh');
1093
+ copyFile(sessionUpgradeSrc, sessionUpgradeDest);
1094
+ }
1095
+ // Re-apply executable bit to all .sh scripts
1096
+ const scriptsDir = path.join(target, ...dests.scripts);
1097
+ if (!_dryRun && fs.existsSync(scriptsDir)) {
1098
+ for (const f of fs.readdirSync(scriptsDir)) {
1099
+ if (f.endsWith('.sh')) fs.chmodSync(path.join(scriptsDir, f), 0o755);
1100
+ }
1101
+ }
1102
+
1103
+ // Upgrade git-lifecycle hooks (Phase 19)
1104
+ console.log('→ Upgrading git-lifecycle hooks...');
1105
+ installGitHooks(src, target, { upgradeMode: true });
1106
+
1107
+ // Upgrade execution engines
1108
+ console.log('→ Upgrading execution engines...');
1109
+ if (fs.existsSync(path.join(src, 'core', 'engines'))) {
1110
+ copyDir(
1111
+ path.join(src, 'core', 'engines'),
1112
+ path.join(target, ...(dests.engines || ['.agent', 'engines'])),
1113
+ upgradeOpts
1114
+ );
1115
+ }
1116
+
1117
+ // Agent rules — retired surface (Phase 23 / ADR-0004). Migrate any
1118
+ // installed copy: pristine (matches a shipped historical revision) →
1119
+ // remove; customized → keep with a deprecation warning.
1120
+ console.log('→ Migrating agent rules (retired surface)...');
1121
+ agentRulesResult = migrateAgentRules(src, target, dests);
1122
+
1123
+ // BUG-022: the marker-aware rewrite below replaces the whole managed
1124
+ // region, which wipes the ecosystem pointer block a registered member's
1125
+ // instruction file carries (init injects it; upgrade used to lose it).
1126
+ // Snapshot whether the block exists BEFORE the rewrite — we only ever
1127
+ // PRESERVE an existing block, never add one the user didn't have (adding
1128
+ // would dirty files mid-upgrade and break autostash restores).
1129
+ const pointerLib = require('../core/ecosystem/lib/pointer');
1130
+ const primaryRel = adapter.primaryInstruction.destination.join('/');
1131
+ let hadPointerBlock = false;
1132
+ try {
1133
+ hadPointerBlock = pointerLib.POINTER_BEGIN_RE.test(
1134
+ fs.readFileSync(path.join(target, primaryRel), 'utf8')
1135
+ );
1136
+ } catch (_e) { /* absent file → nothing to preserve */ }
1137
+
1138
+ // Upgrade adapter-owned root instruction file
1139
+ primaryInstructionResult = upgradePrimaryInstruction(
1140
+ src,
1141
+ target,
1142
+ adapterDir,
1143
+ adapter.primaryInstruction
1144
+ );
1145
+
1146
+ if (hadPointerBlock && !_dryRun) {
1147
+ try {
1148
+ const ecoState = require('../core/ecosystem/lib/state');
1149
+ const reg = ecoState.findRegistration(target);
1150
+ if (reg && fileExists(path.join(target, primaryRel))) {
1151
+ pointerLib.ensurePointerInjected(target, primaryRel, reg.rootPath, reg.ecosystemName);
1152
+ console.log(` ↺ ecosystem pointer block preserved (member of "${reg.ecosystemName}")`);
1153
+ }
1154
+ } catch (_e) {
1155
+ /* pointer restoration is best-effort — never blocks an upgrade */
1156
+ }
1157
+ }
1158
+
1159
+ // Adapter overlay upgrade — per-agent commands/agent-rules/scripts (additive)
1160
+ applyOverlay(adapterDir, target, dests, upgradeOpts);
1161
+
1162
+ // Delegate adapter-specific upgrade
1163
+ adapter.runUpgrade(target, adapterDir, { copyFile, copyDir, fileExists, recordManaged, dryRun: _dryRun });
1164
+
1165
+ // OKF bundle migration (Phase 24, ADR-0005). specs/ is USER content —
1166
+ // these writes are deliberately not recorded in the managed manifest so
1167
+ // orphan cleanup can never touch them.
1168
+ console.log('→ Migrating specs/ to the OKF bundle format...');
1169
+ const okfResult = require('../core/lib/okf-migrate').migrate(target, { dryRun: _dryRun });
1170
+ if (!okfResult.applicable) {
1171
+ console.log(' (no specs/ directory — skipped)');
1172
+ } else {
1173
+ const okfChanges = okfResult.writes.length + okfResult.converted.length
1174
+ + okfResult.injected.length + okfResult.deletes.length + okfResult.indexes.length;
1175
+ if (okfChanges === 0) {
1176
+ console.log(' = specs/ is already an OKF bundle (no changes)');
1177
+ } else {
1178
+ if (okfResult.writes.length) console.log(` ✓ index.json distributed into ${okfResult.writes.length} phase overview.md file(s)`);
1179
+ if (okfResult.converted.length) console.log(' ✓ impact-map.json → decisions/impact-map.md');
1180
+ if (okfResult.injected.length) console.log(` ✓ \`type\` frontmatter added to ${okfResult.injected.length} spec file(s)`);
1181
+ if (okfResult.deletes.length) console.log(` ✓ legacy removed: ${okfResult.deletes.map((d) => d.rel).join(', ')}`);
1182
+ if (okfResult.indexes.length) console.log(` ✓ bundle indexes: ${okfResult.indexes.join(', ')}`);
1183
+ }
1184
+ for (const w of okfResult.warnings) console.log(` ⚠️ ${w}`);
1185
+ }
1186
+
1187
+ // Additively refresh .gitignore (append missing momentum/OS ignore rules).
1188
+ console.log('→ Refreshing .gitignore...');
1189
+ gitignoreResult = refreshGitignore(src, target);
1190
+
1191
+ // Orphan cleanup — only remove files this agent's prior version installed
1192
+ // that this version no longer ships. Other agents' files are untouched.
1193
+ orphans = removeOrphans(target, { managedFiles: prevAgentFiles }, _managedCollector, {
1194
+ dryRun: _dryRun,
1195
+ // A customized project.md is user-owned now — never orphan-clean it.
1196
+ keepRel: agentRulesResult === 'kept-customized'
1197
+ ? new Set([[...dests['agent-rules'], 'project.md'].join('/')])
1198
+ : undefined,
1199
+ });
1200
+
1201
+ // Lock file — rewrite the version-of-record + managed-file set (Phase 20)
1202
+ writeInstalledManifest(target, { version: pkg.version, agent, files: _managedCollector, dryRun: _dryRun });
1203
+ } finally {
1204
+ _managedCollector = null;
1205
+ _dryRun = false;
1206
+ }
1207
+
1208
+ console.log('');
1209
+ if (opts.dryRun) {
1210
+ console.log('✓ Dry run complete — no files were written.');
1211
+ if (orphans.length) {
1212
+ console.log(` ${orphans.length} file(s) would be removed as orphaned (see ✋ lines above).`);
1213
+ }
1214
+ console.log(' Re-run without --dry-run to apply.');
1215
+ console.log('');
1216
+ return;
1217
+ }
1218
+ console.log('✓ Upgrade complete.');
1219
+ if (adapter.primaryInstruction) {
1220
+ const label = adapter.primaryInstruction.label ||
1221
+ adapter.primaryInstruction.destination.join('/');
1222
+ console.log(` ${label}: ${primaryInstructionResult}`);
1223
+ }
1224
+ console.log(` agent-rules: ${agentRulesResult}`);
1225
+ console.log(` .gitignore: ${gitignoreResult}`);
1226
+ if (orphans.length) {
1227
+ console.log(` removed (orphaned): ${orphans.length} file(s) — see 🗑 lines above`);
1228
+ }
1229
+ console.log('');
1230
+ }
1231
+
1232
+ // ── Update check ─────────────────────────────────────────────────────────────
1233
+
1234
+ /** Returns true if version `a` is strictly greater than version `b` (semver). Null-safe — null/non-null comparisons always prefer the non-null value. */
1235
+ function isNewerVersion(a, b) {
1236
+ if (!a && !b) return false;
1237
+ if (!a) return false; // null is never newer than a real version
1238
+ if (!b) return true; // a real version is always newer than null
1239
+ const parse = (v) => v.replace(/^v/, '').split('.').map(Number);
1240
+ const [aMaj, aMin, aPatch] = parse(a);
1241
+ const [bMaj, bMin, bPatch] = parse(b);
1242
+ if (aMaj !== bMaj) return aMaj > bMaj;
1243
+ if (aMin !== bMin) return aMin > bMin;
1244
+ return aPatch > bPatch;
1245
+ }
1246
+
1247
+ /**
1248
+ * Stale-CLI warning for `upgrade` (Phase 20 G2 / D2 — warn, never block).
1249
+ * `momentum upgrade` copies files from the INSTALLED CLI, not from npm — so a
1250
+ * stale global/npx CLI can only ever install stale files. When the published
1251
+ * `latest` is newer than the running CLI, return a prominent warning telling
1252
+ * the user to update the CLI first. Returns null when the CLI is current
1253
+ * (or the version check was unavailable). Pure + unit-testable.
1254
+ */
1255
+ function formatStaleCliWarning(current, latest) {
1256
+ if (!latest || !isNewerVersion(latest, current)) return null;
1257
+ return [
1258
+ '',
1259
+ ' ┌────────────────────────────────────────────────────────────────┐',
1260
+ ` │ ⚠ Installed momentum CLI is ${current}, but ${latest} is published.`,
1261
+ ' │ `upgrade` copies files from the INSTALLED CLI — your project',
1262
+ ' │ files will only ever be as new as the CLI itself.',
1263
+ ' │ Update the CLI first, then re-run upgrade:',
1264
+ ' │ npm i -g @limina-labs/momentum@latest',
1265
+ ' └────────────────────────────────────────────────────────────────┘',
1266
+ '',
1267
+ ].join('\n');
1268
+ }
1269
+
1270
+ // ── Autostash (Phase 20 follow-up) ───────────────────────────────────────────
1271
+ //
1272
+ // Lets a DIRTY repo be upgraded without committing/stashing by hand: stash the
1273
+ // in-flight work (incl. untracked), run the upgrade on the now-clean tree, then
1274
+ // restore the work exactly as it was. Mirrors `git rebase --autostash`.
1275
+ //
1276
+ // Safety invariant: on a pop that can't apply cleanly (the upgrade touched a
1277
+ // file the user had also changed), we NEVER drop the stash — the user's work
1278
+ // stays recoverable in `git stash list`, and we say so loudly.
1279
+
1280
+ /** Returns `git status --porcelain` output, or null if not a git repo. */
1281
+ function gitPorcelain(repoPath) {
1282
+ const r = spawnSync('git', ['-C', repoPath, 'status', '--porcelain'], { encoding: 'utf8' });
1283
+ return r.status === 0 ? (r.stdout || '') : null;
1284
+ }
1285
+
1286
+ /**
1287
+ * Run `fn` (an upgrade) inside a stash/pop so a dirty working tree is preserved.
1288
+ * No-op wrapper when the tree is already clean or not a git repo.
1289
+ * Returns { stashed, restored, conflict, result }.
1290
+ */
1291
+ function withAutostash(repoPath, fn) {
1292
+ const status = gitPorcelain(repoPath);
1293
+ const dirty = status !== null && status.trim().length > 0;
1294
+ if (!dirty) return { stashed: false, restored: true, conflict: false, result: fn() };
1295
+
1296
+ const push = spawnSync(
1297
+ 'git',
1298
+ ['-C', repoPath, 'stash', 'push', '--include-untracked', '-m', 'momentum-autostash'],
1299
+ { encoding: 'utf8' },
1300
+ );
1301
+ if (push.status !== 0) {
1302
+ throw new Error(`autostash: \`git stash\` failed — ${(push.stderr || '').trim()}`);
1303
+ }
1304
+ console.log(' ⎘ autostash: stashed your in-flight work (will restore after upgrade).');
1305
+
1306
+ let result, conflict = false, restored = false;
1307
+ try {
1308
+ result = fn();
1309
+ } finally {
1310
+ const pop = spawnSync('git', ['-C', repoPath, 'stash', 'pop'], { encoding: 'utf8' });
1311
+ if (pop.status === 0) {
1312
+ restored = true;
1313
+ console.log(' ⎗ autostash: restored your in-flight work.');
1314
+ } else {
1315
+ // Pop failed (overlap with what the upgrade wrote). DO NOT drop the stash.
1316
+ conflict = true;
1317
+ console.log(' ⚠️ autostash: could not re-apply your changes cleanly — the upgrade');
1318
+ console.log(' touched a file you had also modified. YOUR WORK IS SAFE in the stash:');
1319
+ console.log(` (cd "${repoPath}" && git stash list) # your changes are stash@{0}`);
1320
+ console.log(' resolve by hand: `git checkout-index -a` is NOT needed — run `git stash pop` and fix conflicts.');
1321
+ }
1322
+ }
1323
+ return { stashed: true, restored, conflict, result };
1324
+ }
1325
+
1326
+ function checkForUpdates() {
1327
+ return new Promise((resolve) => {
1328
+ const https = require('https');
1329
+ const os = require('os');
1330
+ const cacheFile = path.join(os.tmpdir(), '.momentum-version-check');
1331
+
1332
+ // Use cache — skip network if checked within 24 hours
1333
+ try {
1334
+ const cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
1335
+ if (Date.now() - cache.checkedAt < 24 * 60 * 60 * 1000) {
1336
+ resolve(isNewerVersion(cache.latestVersion, pkg.version) ? cache.latestVersion : null);
1337
+ return;
1338
+ }
1339
+ } catch { /* no cache or unreadable — proceed to network check */ }
1340
+
1341
+ const req = https.get(
1342
+ 'https://registry.npmjs.org/@limina-labs/momentum/latest',
1343
+ { timeout: 3000 },
1344
+ (res) => {
1345
+ let data = '';
1346
+ res.on('data', (chunk) => { data += chunk; });
1347
+ res.on('end', () => {
1348
+ try {
1349
+ const latestVersion = JSON.parse(data).version;
1350
+ try {
1351
+ fs.writeFileSync(cacheFile, JSON.stringify({ latestVersion, checkedAt: Date.now() }));
1352
+ } catch { /* ignore cache write failures */ }
1353
+ resolve(isNewerVersion(latestVersion, pkg.version) ? latestVersion : null);
1354
+ } catch {
1355
+ resolve(null);
1356
+ }
1357
+ });
1358
+ }
1359
+ );
1360
+ req.on('error', () => resolve(null));
1361
+ req.on('timeout', () => { req.destroy(); resolve(null); });
1362
+ });
1363
+ }
1364
+
1365
+ // ── Usage ─────────────────────────────────────────────────────────────────────
1366
+
1367
+ function usage() {
1368
+ console.log(`
1369
+ momentum v${pkg.version} — Agent-agnostic, specs-driven development framework
1370
+
1371
+ Use momentum for a single project, or to orchestrate multiple related
1372
+ projects as one ecosystem from a single agent session.
1373
+
1374
+ Single-project use:
1375
+ momentum init [target-dir] Scaffold momentum into one project
1376
+ (defaults to current directory)
1377
+ momentum upgrade [target-dir] Update momentum files in an existing project
1378
+
1379
+ Ecosystem use — entry/exit commands (run from any repo):
1380
+ momentum init --ecosystem <name> Scaffold an ecosystem in a sibling dir
1381
+ and register this repo as the first member
1382
+ momentum join <ecosystem-path> Register THIS repo as a member of an
1383
+ existing ecosystem
1384
+ momentum leave Detach THIS repo from its ecosystem
1385
+ momentum doctor Diagnose state + list available transitions
1386
+ momentum antigravity plugin-pack Pack momentum skills as a native Antigravity plugin ([--global])
1387
+
1388
+ Ecosystem use — operator toolkit (advanced):
1389
+ momentum ecosystem <sub> [...] Subcommands: init | add | remove | status
1390
+
1391
+ Orchestration — work across ecosystem members from one session:
1392
+ momentum scout <repo> "<prompt>" Read-only context fetch from one member
1393
+ momentum dispatch <r1> <r2> ... Parallel multi-repo fan-out + synthesis
1394
+ --prompt "<text>" (require --prompt; --sequential for testing)
1395
+ momentum handoff <repo> Write a context block for <repo> to pick up
1396
+ momentum continue [--handoff <id>] Pick up a pending handoff in this repo
1397
+
1398
+ Swarm — sustained parallel multi-project feature delivery (Phase 17+, Claude Code):
1399
+ momentum swarm <sub> [...] Subcommands: start | status | tell | broadcast |
1400
+ verify | complete | resume | cancel | budget |
1401
+ claim | release | focus | join | absorb
1402
+
1403
+ Lanes — concurrent workstreams in ONE repo (Phase 21b, Rule 15):
1404
+ momentum lanes Board: every active lane + queue pressure
1405
+ momentum lanes <sub> [...] Subcommands: open | done | close | queue |
1406
+ signal | inbox | land (see: momentum lanes help)
1407
+
1408
+ Waves — wave plan from dependency annotations (Phase 21c, one engine every scale):
1409
+ momentum waves Phase-scale waves (overview.md frontmatter "deps")
1410
+ momentum waves --tasks [ref] Task-group waves for a tasks.md
1411
+ (default: the phase bound to your branch)
1412
+
1413
+ OKF — specs/ as an Open Knowledge Format bundle (Phase 24, ADR-0005):
1414
+ momentum okf check [dir] OKF v0.1 conformance report for <dir>/specs
1415
+ momentum okf index [dir] Regenerate bundle indexes (root/phases/decisions)
1416
+
1417
+ Options:
1418
+ --agent <name> Agent to install for (default: claude-code)
1419
+ Available: ${formatAvailableAgents()}
1420
+ --dry-run init/upgrade: preview the action set; write nothing
1421
+ --autostash upgrade: stash a dirty tree, upgrade, then restore it
1422
+ --no-ecosystem Skip the post-init auto-detect prompt
1423
+ -h, --help Show this help message
1424
+ -v, --version Show version number
1425
+
1426
+ Examples:
1427
+ # Single project (unchanged from v0.12.0):
1428
+ npx @limina-labs/momentum init
1429
+ npx @limina-labs/momentum init ./my-project --agent opencode
1430
+
1431
+ # Ecosystem from scratch:
1432
+ npx @limina-labs/momentum init --ecosystem my-eco
1433
+
1434
+ # Add THIS repo to an existing ecosystem:
1435
+ npx @limina-labs/momentum join ../my-eco
1436
+
1437
+ # Where am I?
1438
+ npx @limina-labs/momentum doctor
1439
+ `.trim());
1440
+ }
1441
+
1442
+ // ── Phase 10 init extensions: --ecosystem + auto-detect ──────────────────────
1443
+
1444
+ /**
1445
+ * Parse Phase 10 init-only flags out of argv in place.
1446
+ * Removes the matched tokens; returns the parsed options + remaining target.
1447
+ *
1448
+ * Supported flags:
1449
+ * --ecosystem <name> Scaffold ecosystem in sibling dir, register this repo.
1450
+ * --no-ecosystem Bypass the post-init auto-detect prompt.
1451
+ */
1452
+ function extractInitFlags(args) {
1453
+ // args[0] === 'init' — leave it; consume the rest.
1454
+ const out = { target: undefined, ecosystem: undefined, noEcosystem: false };
1455
+ const filtered = ['init'];
1456
+ for (let i = 1; i < args.length; i++) {
1457
+ const a = args[i];
1458
+ if (a === '--ecosystem') {
1459
+ out.ecosystem = args[i + 1];
1460
+ i++;
1461
+ } else if (a === '--no-ecosystem') {
1462
+ out.noEcosystem = true;
1463
+ } else if (!out.target && !a.startsWith('--')) {
1464
+ out.target = a;
1465
+ } else {
1466
+ filtered.push(a);
1467
+ }
1468
+ }
1469
+ // Splice in place so any downstream consumers see the cleaned args.
1470
+ args.length = 0;
1471
+ for (const a of filtered) args.push(a);
1472
+ return out;
1473
+ }
1474
+
1475
+ /**
1476
+ * After `init` succeeds, optionally:
1477
+ * - scaffold an ecosystem (when --ecosystem <name> was passed)
1478
+ * - prompt the user to register as a member (auto-detect)
1479
+ *
1480
+ * Single-project usage (no flags, no adjacent ecosystem) is a no-op.
1481
+ */
1482
+ async function postInitEcosystem(targetDir, opts) {
1483
+ const target = path.resolve(targetDir);
1484
+ if (opts.ecosystem) {
1485
+ return scaffoldEcosystemAndJoin(target, opts.ecosystem);
1486
+ }
1487
+ if (opts.noEcosystem) return;
1488
+ return maybePromptForAutoEcosystem(target);
1489
+ }
1490
+
1491
+ function scaffoldEcosystemAndJoin(target, ecosystemName) {
1492
+ if (!/^[a-z][a-z0-9-]*$/.test(ecosystemName)) {
1493
+ throw new Error(
1494
+ `init --ecosystem: name "${ecosystemName}" must match /^[a-z][a-z0-9-]*$/.`,
1495
+ );
1496
+ }
1497
+ const parentDir = path.dirname(target);
1498
+ const ecosystemDir = path.join(parentDir, ecosystemName);
1499
+ if (fs.existsSync(ecosystemDir)) {
1500
+ throw new Error(
1501
+ `init --ecosystem: ${ecosystemDir} already exists. Refusing to overwrite.`,
1502
+ );
1503
+ }
1504
+
1505
+ const { runEcosystem } = require('./ecosystem');
1506
+ const pointer = require('../core/ecosystem/lib/pointer');
1507
+
1508
+ let createdEcosystem = false;
1509
+ let injectedPointer = false;
1510
+ const cwd0 = process.cwd();
1511
+ try {
1512
+ // Scaffold the ecosystem root via existing CLI surface, run from parentDir
1513
+ // so cmdInit creates ./<name>/ under it.
1514
+ process.chdir(parentDir);
1515
+ runEcosystem(['init', ecosystemName]);
1516
+ createdEcosystem = true;
1517
+
1518
+ // Now register this repo as a member, using realpathed absolutes so
1519
+ // path.relative produces a clean ../target instead of a cross-symlink
1520
+ // monstrosity (on macOS where /tmp → /private/tmp).
1521
+ process.chdir(target);
1522
+ let targetReal = target;
1523
+ let ecosystemReal = ecosystemDir;
1524
+ try { targetReal = fs.realpathSync(target); } catch (_e) {}
1525
+ try { ecosystemReal = fs.realpathSync(ecosystemDir); } catch (_e) {}
1526
+ runEcosystem(['add', targetReal, '--ecosystem', ecosystemReal]);
1527
+ injectedPointer = true;
1528
+ } catch (err) {
1529
+ // Rollback partial state.
1530
+ if (createdEcosystem && fs.existsSync(ecosystemDir)) {
1531
+ try {
1532
+ fs.rmSync(ecosystemDir, { recursive: true, force: true });
1533
+ } catch (_e) { /* best-effort */ }
1534
+ }
1535
+ if (injectedPointer) {
1536
+ const primary = pointer.findPrimaryInstructionFile(target);
1537
+ if (primary) {
1538
+ try {
1539
+ pointer.stripPointer(path.join(target, primary));
1540
+ } catch (_e) { /* best-effort */ }
1541
+ }
1542
+ }
1543
+ throw err;
1544
+ } finally {
1545
+ process.chdir(cwd0);
1546
+ }
1547
+
1548
+ console.log('');
1549
+ console.log(`✓ Ecosystem "${ecosystemName}" created at ${ecosystemDir}`);
1550
+ console.log(` This project is registered as the first member.`);
1551
+ console.log('');
1552
+ console.log(' Add another repo:');
1553
+ console.log(` cd <other-repo> && momentum join ${path.relative(process.cwd(), ecosystemDir) || ecosystemDir}`);
1554
+ }
1555
+
1556
+ async function maybePromptForAutoEcosystem(target) {
1557
+ const skipFile = path.join(target, '.momentum', 'skip-ecosystem-prompt');
1558
+ if (fs.existsSync(skipFile)) return;
1559
+
1560
+ const parentDir = path.dirname(target);
1561
+ let entries;
1562
+ try {
1563
+ entries = fs.readdirSync(parentDir, { withFileTypes: true });
1564
+ } catch (_e) {
1565
+ return;
1566
+ }
1567
+
1568
+ for (const e of entries) {
1569
+ if (!e.isDirectory()) continue;
1570
+ const candidate = path.join(parentDir, e.name);
1571
+ if (candidate === target) continue;
1572
+ const manifestPath = path.join(candidate, 'ecosystem.json');
1573
+ if (!fs.existsSync(manifestPath)) continue;
1574
+ let manifest;
1575
+ try {
1576
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
1577
+ } catch (_err) {
1578
+ continue;
1579
+ }
1580
+ if (!manifest || !manifest.name) continue;
1581
+
1582
+ // Non-interactive: silently skip (no skip-file written; a future
1583
+ // interactive session can still prompt).
1584
+ if (!process.stdin.isTTY) return;
1585
+
1586
+ const accepted = await promptYesNo(
1587
+ `Detected ecosystem "${manifest.name}" at ${path.relative(target, candidate) || candidate}.\n` +
1588
+ `Register this project as a member? [y/N] `,
1589
+ );
1590
+ if (accepted) {
1591
+ const { runEcosystem } = require('./ecosystem');
1592
+ const cwd0 = process.cwd();
1593
+ try {
1594
+ process.chdir(target);
1595
+ let targetReal = target;
1596
+ let candidateReal = candidate;
1597
+ try { targetReal = fs.realpathSync(target); } catch (_e) {}
1598
+ try { candidateReal = fs.realpathSync(candidate); } catch (_e) {}
1599
+ runEcosystem(['add', targetReal, '--ecosystem', candidateReal]);
1600
+ console.log(`✓ Registered as a member of "${manifest.name}".`);
1601
+ } finally {
1602
+ process.chdir(cwd0);
1603
+ }
1604
+ } else {
1605
+ try {
1606
+ fs.mkdirSync(path.dirname(skipFile), { recursive: true });
1607
+ fs.writeFileSync(skipFile, 'declined at init time\n');
1608
+ } catch (_e) { /* best-effort */ }
1609
+ }
1610
+ return;
1611
+ }
1612
+ }
1613
+
1614
+ function promptYesNo(question) {
1615
+ return new Promise((resolve) => {
1616
+ const readline = require('readline');
1617
+ const rl = readline.createInterface({
1618
+ input: process.stdin,
1619
+ output: process.stdout,
1620
+ });
1621
+ rl.question(question, (answer) => {
1622
+ rl.close();
1623
+ resolve(/^y(es)?$/i.test((answer || '').trim()));
1624
+ });
1625
+ });
1626
+ }
1627
+
1628
+ // ── Entry point ───────────────────────────────────────────────────────────────
1629
+
1630
+ async function main() {
1631
+ const args = process.argv.slice(2);
1632
+
1633
+ // Reject the deprecated --coding-agent flag with a clear rename message.
1634
+ if (args.includes('--coding-agent')) {
1635
+ console.error('Error: --coding-agent has been renamed to --agent in v0.6.0.');
1636
+ console.error(' Re-run with: --agent <name> (e.g., --agent claude-code)');
1637
+ process.exit(1);
1638
+ }
1639
+
1640
+ // Extract --agent flag before command dispatch. Remember whether it was
1641
+ // explicit: the ecosystem subcommands parse --agent themselves (init's
1642
+ // root command surface, upgrade's sweep override — ENH-049), so an
1643
+ // explicit flag is re-injected at that dispatch rather than silently
1644
+ // swallowed here.
1645
+ let agent = 'claude-code';
1646
+ let agentExplicit = false;
1647
+ const agentFlagIdx = args.indexOf('--agent');
1648
+ if (agentFlagIdx !== -1) {
1649
+ agent = args[agentFlagIdx + 1];
1650
+ agentExplicit = true;
1651
+ args.splice(agentFlagIdx, 2);
1652
+ }
1653
+
1654
+ // --dry-run is parsed per-command (init/upgrade here; `ecosystem upgrade`
1655
+ // parses its own) — NOT stripped globally, or the ecosystem sweep would
1656
+ // never see it.
1657
+
1658
+ // Start update check concurrently — runs while command executes
1659
+ const updateCheckPromise = checkForUpdates();
1660
+
1661
+ let exitCode = 0;
1662
+
1663
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
1664
+ usage();
1665
+ exitCode = args.length === 0 ? 1 : 0;
1666
+ } else if (args[0] === '--version' || args[0] === '-v') {
1667
+ console.log(pkg.version);
1668
+ } else if (args[0] === 'init') {
1669
+ const dryRun = args.includes('--dry-run');
1670
+ const initOpts = extractInitFlags(args.filter((a) => a !== '--dry-run'));
1671
+ const targetDir = initOpts.target || process.cwd();
1672
+ try {
1673
+ init(targetDir, agent, { dryRun });
1674
+ // Ecosystem auto-detect/scaffold mutates state — skip it on a dry run.
1675
+ if (!dryRun) await postInitEcosystem(targetDir, initOpts);
1676
+ } catch (err) {
1677
+ console.error(`\nError: ${err.message}`);
1678
+ exitCode = 1;
1679
+ }
1680
+ } else if (args[0] === 'upgrade') {
1681
+ const dryRun = args.includes('--dry-run');
1682
+ const autostash = args.includes('--autostash');
1683
+ const targetDir = args.slice(1).find((a) => !a.startsWith('--')) || process.cwd();
1684
+ try {
1685
+ // Warn (don't block) if the installed CLI is behind the published latest —
1686
+ // upgrade can only ever install files as new as the CLI itself.
1687
+ const staleWarning = formatStaleCliWarning(pkg.version, await updateCheckPromise);
1688
+ if (staleWarning) console.log(staleWarning);
1689
+ // --autostash: stash a dirty tree, upgrade, restore. Dry-run writes
1690
+ // nothing, so it never needs to stash.
1691
+ if (autostash && !dryRun) {
1692
+ withAutostash(path.resolve(targetDir), () => upgrade(targetDir, agent, { dryRun }));
1693
+ } else {
1694
+ upgrade(targetDir, agent, { dryRun });
1695
+ }
1696
+ } catch (err) {
1697
+ console.error(`\nError: ${err.message}`);
1698
+ exitCode = 1;
1699
+ }
1700
+ } else if (args[0] === 'ecosystem') {
1701
+ try {
1702
+ const { runEcosystem } = require('./ecosystem');
1703
+ runEcosystem(
1704
+ agentExplicit ? [...args.slice(1), '--agent', agent] : args.slice(1),
1705
+ );
1706
+ } catch (err) {
1707
+ console.error(`\nError: ${err.message}`);
1708
+ exitCode = 1;
1709
+ }
1710
+ } else if (args[0] === 'join') {
1711
+ try {
1712
+ const { cmdJoin } = require('./state-commands');
1713
+ cmdJoin(args.slice(1));
1714
+ } catch (err) {
1715
+ console.error(`\nError: ${err.message}`);
1716
+ exitCode = 1;
1717
+ }
1718
+ } else if (args[0] === 'leave') {
1719
+ try {
1720
+ const { cmdLeave } = require('./state-commands');
1721
+ cmdLeave(args.slice(1));
1722
+ } catch (err) {
1723
+ console.error(`\nError: ${err.message}`);
1724
+ exitCode = 1;
1725
+ }
1726
+ } else if (args[0] === 'antigravity') {
1727
+ try {
1728
+ const { runAntigravity } = require('./antigravity');
1729
+ runAntigravity(args.slice(1));
1730
+ } catch (err) {
1731
+ console.error(`\nError: ${err.message}`);
1732
+ exitCode = 1;
1733
+ }
1734
+ } else if (args[0] === 'doctor') {
1735
+ try {
1736
+ const { cmdDoctor } = require('./state-commands');
1737
+ cmdDoctor(args.slice(1));
1738
+ } catch (err) {
1739
+ console.error(`\nError: ${err.message}`);
1740
+ exitCode = 1;
1741
+ }
1742
+ } else if (args[0] === 'scout') {
1743
+ try {
1744
+ const { cmdScout } = require('./orchestration-commands');
1745
+ await cmdScout(args.slice(1));
1746
+ } catch (err) {
1747
+ console.error(`\nError: ${err.message}`);
1748
+ exitCode = 1;
1749
+ }
1750
+ } else if (args[0] === 'dispatch') {
1751
+ try {
1752
+ const { cmdDispatch } = require('./orchestration-commands');
1753
+ await cmdDispatch(args.slice(1));
1754
+ } catch (err) {
1755
+ console.error(`\nError: ${err.message}`);
1756
+ exitCode = 1;
1757
+ }
1758
+ } else if (args[0] === 'handoff') {
1759
+ try {
1760
+ const { cmdHandoff } = require('./orchestration-commands');
1761
+ await cmdHandoff(args.slice(1));
1762
+ } catch (err) {
1763
+ console.error(`\nError: ${err.message}`);
1764
+ exitCode = 1;
1765
+ }
1766
+ } else if (args[0] === 'continue') {
1767
+ try {
1768
+ const { cmdContinue } = require('./orchestration-commands');
1769
+ await cmdContinue(args.slice(1));
1770
+ } catch (err) {
1771
+ console.error(`\nError: ${err.message}`);
1772
+ exitCode = 1;
1773
+ }
1774
+ } else if (args[0] === 'swarm') {
1775
+ try {
1776
+ const { runSwarm } = require('./swarm');
1777
+ runSwarm(args.slice(1));
1778
+ } catch (err) {
1779
+ console.error(`\nError: ${err.message}`);
1780
+ exitCode = 1;
1781
+ }
1782
+ } else if (args[0] === 'lanes') {
1783
+ try {
1784
+ const { runLanes } = require('./lanes');
1785
+ exitCode = runLanes(args.slice(1));
1786
+ } catch (err) {
1787
+ console.error(`\nError: ${err.message}`);
1788
+ exitCode = 1;
1789
+ }
1790
+ } else if (args[0] === 'waves') {
1791
+ try {
1792
+ const { runWaves } = require('./waves');
1793
+ exitCode = runWaves(args.slice(1));
1794
+ } catch (err) {
1795
+ console.error(`\nError: ${err.message}`);
1796
+ exitCode = 1;
1797
+ }
1798
+ } else if (args[0] === 'okf') {
1799
+ try {
1800
+ const { runOkf } = require('./okf');
1801
+ exitCode = runOkf(args.slice(1));
1802
+ } catch (err) {
1803
+ console.error(`\nError: ${err.message}`);
1804
+ exitCode = 1;
1805
+ }
1806
+ } else {
1807
+ console.error(`Unknown command: ${args[0]}`);
1808
+ console.error('Run "momentum --help" for usage.');
1809
+ exitCode = 1;
1810
+ }
1811
+
1812
+ // Show update notice after command output (non-blocking — result from concurrent check)
1813
+ try {
1814
+ const latestVersion = await updateCheckPromise;
1815
+ if (latestVersion) {
1816
+ console.log('');
1817
+ console.log(` ┌──────────────────────────────────────────────────────┐`);
1818
+ console.log(` │ Update available: ${pkg.version} → ${latestVersion}`);
1819
+ console.log(` │ Run: npm install -g @limina-labs/momentum@latest`);
1820
+ console.log(` └──────────────────────────────────────────────────────┘`);
1821
+ console.log('');
1822
+ }
1823
+ } catch { /* ignore update check errors */ }
1824
+
1825
+ process.exit(exitCode);
1826
+ }
1827
+
1828
+ module.exports = {
1829
+ // Pure helpers (unit-testable)
1830
+ partitionByMarker,
1831
+ assertNotEcosystemRoot,
1832
+ listFilesRecursive,
1833
+ detectOverlayConflicts,
1834
+ isNewerVersion,
1835
+ formatStaleCliWarning,
1836
+ gitPorcelain,
1837
+ withAutostash,
1838
+ MARKER,
1839
+ DEFAULT_OVERLAY_DESTS,
1840
+ listAvailableAgents,
1841
+ formatAvailableAgents,
1842
+ // Side-effectful but testable with a tmp dir
1843
+ upgradeMarkedFile,
1844
+ copyDir,
1845
+ copyFile,
1846
+ fileExists,
1847
+ init,
1848
+ upgrade,
1849
+ };
1850
+
1851
+ // Run only when invoked as a CLI, not when required by tests.
1852
+ // NOTE: exports MUST be assigned above this line — main() dispatches the
1853
+ // `ecosystem upgrade` sweep synchronously, which require()s this module back;
1854
+ // if exports weren't set yet, `upgrade` would be undefined (circular require).
1855
+ if (require.main === module) {
1856
+ main();
1857
+ }