@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.
- package/LICENSE +21 -0
- package/README.md +214 -0
- package/adapters/antigravity/adapter.js +196 -0
- package/adapters/antigravity/hooks.json +39 -0
- package/adapters/antigravity/instructions/AGENTS.md +638 -0
- package/adapters/antigravity/instructions/header.md +3 -0
- package/adapters/antigravity/instructions/surfaces.md +153 -0
- package/adapters/antigravity/instructions/vars.json +1 -0
- package/adapters/antigravity/scripts/antigravity-hook-adapter.sh +147 -0
- package/adapters/antigravity/skills/momentum-orient/SKILL.md +29 -0
- package/adapters/antigravity/skills/momentum-reviewer-architecture/SKILL.md +39 -0
- package/adapters/antigravity/skills/momentum-reviewer-qa/SKILL.md +38 -0
- package/adapters/antigravity/skills/momentum-reviewer-security/SKILL.md +42 -0
- package/adapters/antigravity/skills/swarm-supervisor/SKILL.md +75 -0
- package/adapters/antigravity/workflows/continue.md +39 -0
- package/adapters/antigravity/workflows/dispatch.md +49 -0
- package/adapters/antigravity/workflows/handoff.md +38 -0
- package/adapters/antigravity/workflows/review-code.md +84 -0
- package/adapters/antigravity/workflows/scout.md +42 -0
- package/adapters/antigravity/workflows/swarm.md +203 -0
- package/adapters/claude-code/adapter.js +148 -0
- package/adapters/claude-code/commands/continue.md +82 -0
- package/adapters/claude-code/commands/dispatch.md +154 -0
- package/adapters/claude-code/commands/handoff.md +85 -0
- package/adapters/claude-code/commands/review-code.md +194 -0
- package/adapters/claude-code/commands/scout.md +119 -0
- package/adapters/claude-code/commands/swarm.md +242 -0
- package/adapters/claude-code/instructions/header.md +3 -0
- package/adapters/claude-code/instructions/vars.json +1 -0
- package/adapters/claude-code/settings.json +36 -0
- package/adapters/codex/adapter.js +225 -0
- package/adapters/codex/agents/momentum-reviewer-architecture.toml +57 -0
- package/adapters/codex/agents/momentum-reviewer-qa.toml +52 -0
- package/adapters/codex/agents/momentum-reviewer-security.toml +57 -0
- package/adapters/codex/agents/swarm-supervisor.toml +76 -0
- package/adapters/codex/commands/continue.md +40 -0
- package/adapters/codex/commands/dispatch.md +111 -0
- package/adapters/codex/commands/handoff.md +57 -0
- package/adapters/codex/commands/scout.md +90 -0
- package/adapters/codex/commands/swarm.md +334 -0
- package/adapters/codex/hooks.json +36 -0
- package/adapters/codex/instructions/AGENTS.md +691 -0
- package/adapters/codex/instructions/header.md +3 -0
- package/adapters/codex/instructions/surfaces.md +206 -0
- package/adapters/codex/instructions/vars.json +1 -0
- package/adapters/codex/skills/momentum-orient/SKILL.md +29 -0
- package/adapters/opencode/adapter.js +224 -0
- package/adapters/opencode/agents/momentum-reviewer-architecture.md +54 -0
- package/adapters/opencode/agents/momentum-reviewer-qa.md +48 -0
- package/adapters/opencode/agents/momentum-reviewer-security.md +48 -0
- package/adapters/opencode/agents/swarm-supervisor.md +51 -0
- package/adapters/opencode/commands/continue.md +40 -0
- package/adapters/opencode/commands/dispatch.md +111 -0
- package/adapters/opencode/commands/handoff.md +57 -0
- package/adapters/opencode/commands/review-code.md +194 -0
- package/adapters/opencode/commands/scout.md +90 -0
- package/adapters/opencode/commands/swarm.md +334 -0
- package/adapters/opencode/instructions/AGENTS.md +604 -0
- package/adapters/opencode/instructions/header.md +3 -0
- package/adapters/opencode/instructions/surfaces.md +119 -0
- package/adapters/opencode/instructions/vars.json +1 -0
- package/adapters/opencode/plugins/momentum.js +219 -0
- package/adapters/opencode/skills/momentum-lanes/SKILL.md +30 -0
- package/adapters/opencode/skills/momentum-orient/SKILL.md +29 -0
- package/adapters/opencode/skills/momentum-track/SKILL.md +28 -0
- package/adapters/opencode/skills/momentum-validate/SKILL.md +31 -0
- package/bin/antigravity.js +112 -0
- package/bin/ecosystem.js +1302 -0
- package/bin/lanes.js +300 -0
- package/bin/momentum.js +1857 -0
- package/bin/okf.js +72 -0
- package/bin/orchestration-commands.js +305 -0
- package/bin/state-commands.js +218 -0
- package/bin/swarm.js +1104 -0
- package/bin/waves.js +147 -0
- package/core/adapter-capabilities.md +112 -0
- package/core/adapter-parity-matrix.md +170 -0
- package/core/commands/brainstorm-idea.md +98 -0
- package/core/commands/brainstorm-phase.md +129 -0
- package/core/commands/complete-phase.md +130 -0
- package/core/commands/ecosystem.md +161 -0
- package/core/commands/hotfix.md +64 -0
- package/core/commands/initiative.md +119 -0
- package/core/commands/lanes.md +94 -0
- package/core/commands/log.md +45 -0
- package/core/commands/migrate.md +70 -0
- package/core/commands/review.md +31 -0
- package/core/commands/session.md +79 -0
- package/core/commands/start-phase.md +161 -0
- package/core/commands/start-project.md +142 -0
- package/core/commands/sync-docs.md +81 -0
- package/core/commands/systematic-debug.md +46 -0
- package/core/commands/track.md +54 -0
- package/core/commands/validate.md +96 -0
- package/core/ecosystem/layout.md +128 -0
- package/core/ecosystem/lib/index.js +245 -0
- package/core/ecosystem/lib/initiative.js +258 -0
- package/core/ecosystem/lib/pointer.js +198 -0
- package/core/ecosystem/lib/state.js +295 -0
- package/core/ecosystem/schema/ecosystem.schema.json +85 -0
- package/core/ecosystem/schema/initiative.schema.json +61 -0
- package/core/ecosystem/scripts/session-append.sh +167 -0
- package/core/ecosystem/templates/ecosystem-agents.md +79 -0
- package/core/ecosystem/templates/ecosystem-claude.md +79 -0
- package/core/ecosystem/templates/ecosystem-settings-claude.json +14 -0
- package/core/ecosystem/templates/initiative-template.md +50 -0
- package/core/engines/subagent-dispatch.md +80 -0
- package/core/git-hooks/commit-msg +14 -0
- package/core/git-hooks/contract.js +176 -0
- package/core/git-hooks/pre-push +15 -0
- package/core/git-hooks/run-check.js +160 -0
- package/core/instructions/legacy-project-md-hashes.json +12 -0
- package/core/instructions/navigation.md +14 -0
- package/core/instructions/rules-body.md +454 -0
- package/core/lanes/lib/board.js +161 -0
- package/core/lanes/lib/land.js +301 -0
- package/core/lanes/lib/signals.js +207 -0
- package/core/lanes/lib/state.js +325 -0
- package/core/lib/frontmatter.js +163 -0
- package/core/lib/okf-migrate.js +374 -0
- package/core/lib/okf-types.js +61 -0
- package/core/lifecycle-contract.md +59 -0
- package/core/orchestration/capability-routing.js +120 -0
- package/core/orchestration/continue.js +102 -0
- package/core/orchestration/dispatch.js +366 -0
- package/core/orchestration/events.js +197 -0
- package/core/orchestration/handoff.js +317 -0
- package/core/orchestration/index.js +50 -0
- package/core/orchestration/run-artifact.js +93 -0
- package/core/orchestration/scout.js +327 -0
- package/core/orchestration/session-log.js +123 -0
- package/core/orchestration/tracking.js +180 -0
- package/core/orchestration/types.js +131 -0
- package/core/scripts/brainstorm-gate.sh +157 -0
- package/core/scripts/check-history-reminder.sh +110 -0
- package/core/scripts/sessionstart-handoff.sh +180 -0
- package/core/specs-templates/CLAUDE.md +484 -0
- package/core/specs-templates/README.md +32 -0
- package/core/specs-templates/specs/README.md +30 -0
- package/core/specs-templates/specs/adhoc/README.md +31 -0
- package/core/specs-templates/specs/adhoc/_TEMPLATE.md +29 -0
- package/core/specs-templates/specs/architecture/ecosystem.md +214 -0
- package/core/specs-templates/specs/backlog/backlog.md +46 -0
- package/core/specs-templates/specs/backlog/details/.gitkeep +0 -0
- package/core/specs-templates/specs/changelog/.gitkeep +0 -0
- package/core/specs-templates/specs/decisions/0000-template.md +33 -0
- package/core/specs-templates/specs/decisions/README.md +29 -0
- package/core/specs-templates/specs/decisions/impact-map.md +14 -0
- package/core/specs-templates/specs/decisions/index.md +4 -0
- package/core/specs-templates/specs/index.md +16 -0
- package/core/specs-templates/specs/phases/README.md +41 -0
- package/core/specs-templates/specs/phases/index.md +6 -0
- package/core/specs-templates/specs/planning/roadmap.md +23 -0
- package/core/specs-templates/specs/status.md +70 -0
- package/core/specs-templates/specs/vision/principles.md +16 -0
- package/core/specs-templates/specs/vision/project-charter.md +35 -0
- package/core/specs-templates/specs/vision/success-criteria.md +19 -0
- package/core/swarm/absorb.js +294 -0
- package/core/swarm/conductor.js +499 -0
- package/core/swarm/focus.js +151 -0
- package/core/swarm/inbox.js +222 -0
- package/core/swarm/join.js +138 -0
- package/core/swarm/lib/board.js +178 -0
- package/core/swarm/lib/brief.js +157 -0
- package/core/swarm/lib/git-sha-cache.js +71 -0
- package/core/swarm/lib/incremental-log.js +117 -0
- package/core/swarm/lib/manifest.js +578 -0
- package/core/swarm/lib/pre-merge.js +112 -0
- package/core/swarm/lib/saga.js +146 -0
- package/core/swarm/lib/sessions.js +83 -0
- package/core/swarm/lib/tokens.js +255 -0
- package/core/swarm/lib/wave-ordering.js +55 -0
- package/core/swarm/schema/board.schema.json +54 -0
- package/core/swarm/schema/contract.schema.json +56 -0
- package/core/swarm/schema/dispatch-run.schema.json +70 -0
- package/core/swarm/schema/manifest.schema.json +234 -0
- package/core/swarm/schema/signal.schema.json +61 -0
- package/core/swarm/signals.js +296 -0
- package/core/swarm/supervise.md +81 -0
- package/core/waves/lib/plan-graph.js +175 -0
- package/core/waves/lib/waves.js +83 -0
- package/package.json +46 -0
package/bin/momentum.js
ADDED
|
@@ -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
|
+
}
|