@limina-labs/momentum 0.30.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +214 -0
  3. package/adapters/antigravity/adapter.js +196 -0
  4. package/adapters/antigravity/hooks.json +39 -0
  5. package/adapters/antigravity/instructions/AGENTS.md +638 -0
  6. package/adapters/antigravity/instructions/header.md +3 -0
  7. package/adapters/antigravity/instructions/surfaces.md +153 -0
  8. package/adapters/antigravity/instructions/vars.json +1 -0
  9. package/adapters/antigravity/scripts/antigravity-hook-adapter.sh +147 -0
  10. package/adapters/antigravity/skills/momentum-orient/SKILL.md +29 -0
  11. package/adapters/antigravity/skills/momentum-reviewer-architecture/SKILL.md +39 -0
  12. package/adapters/antigravity/skills/momentum-reviewer-qa/SKILL.md +38 -0
  13. package/adapters/antigravity/skills/momentum-reviewer-security/SKILL.md +42 -0
  14. package/adapters/antigravity/skills/swarm-supervisor/SKILL.md +75 -0
  15. package/adapters/antigravity/workflows/continue.md +39 -0
  16. package/adapters/antigravity/workflows/dispatch.md +49 -0
  17. package/adapters/antigravity/workflows/handoff.md +38 -0
  18. package/adapters/antigravity/workflows/review-code.md +84 -0
  19. package/adapters/antigravity/workflows/scout.md +42 -0
  20. package/adapters/antigravity/workflows/swarm.md +203 -0
  21. package/adapters/claude-code/adapter.js +148 -0
  22. package/adapters/claude-code/commands/continue.md +82 -0
  23. package/adapters/claude-code/commands/dispatch.md +154 -0
  24. package/adapters/claude-code/commands/handoff.md +85 -0
  25. package/adapters/claude-code/commands/review-code.md +194 -0
  26. package/adapters/claude-code/commands/scout.md +119 -0
  27. package/adapters/claude-code/commands/swarm.md +242 -0
  28. package/adapters/claude-code/instructions/header.md +3 -0
  29. package/adapters/claude-code/instructions/vars.json +1 -0
  30. package/adapters/claude-code/settings.json +36 -0
  31. package/adapters/codex/adapter.js +225 -0
  32. package/adapters/codex/agents/momentum-reviewer-architecture.toml +57 -0
  33. package/adapters/codex/agents/momentum-reviewer-qa.toml +52 -0
  34. package/adapters/codex/agents/momentum-reviewer-security.toml +57 -0
  35. package/adapters/codex/agents/swarm-supervisor.toml +76 -0
  36. package/adapters/codex/commands/continue.md +40 -0
  37. package/adapters/codex/commands/dispatch.md +111 -0
  38. package/adapters/codex/commands/handoff.md +57 -0
  39. package/adapters/codex/commands/scout.md +90 -0
  40. package/adapters/codex/commands/swarm.md +334 -0
  41. package/adapters/codex/hooks.json +36 -0
  42. package/adapters/codex/instructions/AGENTS.md +691 -0
  43. package/adapters/codex/instructions/header.md +3 -0
  44. package/adapters/codex/instructions/surfaces.md +206 -0
  45. package/adapters/codex/instructions/vars.json +1 -0
  46. package/adapters/codex/skills/momentum-orient/SKILL.md +29 -0
  47. package/adapters/opencode/adapter.js +224 -0
  48. package/adapters/opencode/agents/momentum-reviewer-architecture.md +54 -0
  49. package/adapters/opencode/agents/momentum-reviewer-qa.md +48 -0
  50. package/adapters/opencode/agents/momentum-reviewer-security.md +48 -0
  51. package/adapters/opencode/agents/swarm-supervisor.md +51 -0
  52. package/adapters/opencode/commands/continue.md +40 -0
  53. package/adapters/opencode/commands/dispatch.md +111 -0
  54. package/adapters/opencode/commands/handoff.md +57 -0
  55. package/adapters/opencode/commands/review-code.md +194 -0
  56. package/adapters/opencode/commands/scout.md +90 -0
  57. package/adapters/opencode/commands/swarm.md +334 -0
  58. package/adapters/opencode/instructions/AGENTS.md +604 -0
  59. package/adapters/opencode/instructions/header.md +3 -0
  60. package/adapters/opencode/instructions/surfaces.md +119 -0
  61. package/adapters/opencode/instructions/vars.json +1 -0
  62. package/adapters/opencode/plugins/momentum.js +219 -0
  63. package/adapters/opencode/skills/momentum-lanes/SKILL.md +30 -0
  64. package/adapters/opencode/skills/momentum-orient/SKILL.md +29 -0
  65. package/adapters/opencode/skills/momentum-track/SKILL.md +28 -0
  66. package/adapters/opencode/skills/momentum-validate/SKILL.md +31 -0
  67. package/bin/antigravity.js +112 -0
  68. package/bin/ecosystem.js +1302 -0
  69. package/bin/lanes.js +300 -0
  70. package/bin/momentum.js +1857 -0
  71. package/bin/okf.js +72 -0
  72. package/bin/orchestration-commands.js +305 -0
  73. package/bin/state-commands.js +218 -0
  74. package/bin/swarm.js +1104 -0
  75. package/bin/waves.js +147 -0
  76. package/core/adapter-capabilities.md +112 -0
  77. package/core/adapter-parity-matrix.md +170 -0
  78. package/core/commands/brainstorm-idea.md +98 -0
  79. package/core/commands/brainstorm-phase.md +129 -0
  80. package/core/commands/complete-phase.md +130 -0
  81. package/core/commands/ecosystem.md +161 -0
  82. package/core/commands/hotfix.md +64 -0
  83. package/core/commands/initiative.md +119 -0
  84. package/core/commands/lanes.md +94 -0
  85. package/core/commands/log.md +45 -0
  86. package/core/commands/migrate.md +70 -0
  87. package/core/commands/review.md +31 -0
  88. package/core/commands/session.md +79 -0
  89. package/core/commands/start-phase.md +161 -0
  90. package/core/commands/start-project.md +142 -0
  91. package/core/commands/sync-docs.md +81 -0
  92. package/core/commands/systematic-debug.md +46 -0
  93. package/core/commands/track.md +54 -0
  94. package/core/commands/validate.md +96 -0
  95. package/core/ecosystem/layout.md +128 -0
  96. package/core/ecosystem/lib/index.js +245 -0
  97. package/core/ecosystem/lib/initiative.js +258 -0
  98. package/core/ecosystem/lib/pointer.js +198 -0
  99. package/core/ecosystem/lib/state.js +295 -0
  100. package/core/ecosystem/schema/ecosystem.schema.json +85 -0
  101. package/core/ecosystem/schema/initiative.schema.json +61 -0
  102. package/core/ecosystem/scripts/session-append.sh +167 -0
  103. package/core/ecosystem/templates/ecosystem-agents.md +79 -0
  104. package/core/ecosystem/templates/ecosystem-claude.md +79 -0
  105. package/core/ecosystem/templates/ecosystem-settings-claude.json +14 -0
  106. package/core/ecosystem/templates/initiative-template.md +50 -0
  107. package/core/engines/subagent-dispatch.md +80 -0
  108. package/core/git-hooks/commit-msg +14 -0
  109. package/core/git-hooks/contract.js +176 -0
  110. package/core/git-hooks/pre-push +15 -0
  111. package/core/git-hooks/run-check.js +160 -0
  112. package/core/instructions/legacy-project-md-hashes.json +12 -0
  113. package/core/instructions/navigation.md +14 -0
  114. package/core/instructions/rules-body.md +454 -0
  115. package/core/lanes/lib/board.js +161 -0
  116. package/core/lanes/lib/land.js +301 -0
  117. package/core/lanes/lib/signals.js +207 -0
  118. package/core/lanes/lib/state.js +325 -0
  119. package/core/lib/frontmatter.js +163 -0
  120. package/core/lib/okf-migrate.js +374 -0
  121. package/core/lib/okf-types.js +61 -0
  122. package/core/lifecycle-contract.md +59 -0
  123. package/core/orchestration/capability-routing.js +120 -0
  124. package/core/orchestration/continue.js +102 -0
  125. package/core/orchestration/dispatch.js +366 -0
  126. package/core/orchestration/events.js +197 -0
  127. package/core/orchestration/handoff.js +317 -0
  128. package/core/orchestration/index.js +50 -0
  129. package/core/orchestration/run-artifact.js +93 -0
  130. package/core/orchestration/scout.js +327 -0
  131. package/core/orchestration/session-log.js +123 -0
  132. package/core/orchestration/tracking.js +180 -0
  133. package/core/orchestration/types.js +131 -0
  134. package/core/scripts/brainstorm-gate.sh +157 -0
  135. package/core/scripts/check-history-reminder.sh +110 -0
  136. package/core/scripts/sessionstart-handoff.sh +180 -0
  137. package/core/specs-templates/CLAUDE.md +484 -0
  138. package/core/specs-templates/README.md +32 -0
  139. package/core/specs-templates/specs/README.md +30 -0
  140. package/core/specs-templates/specs/adhoc/README.md +31 -0
  141. package/core/specs-templates/specs/adhoc/_TEMPLATE.md +29 -0
  142. package/core/specs-templates/specs/architecture/ecosystem.md +214 -0
  143. package/core/specs-templates/specs/backlog/backlog.md +46 -0
  144. package/core/specs-templates/specs/backlog/details/.gitkeep +0 -0
  145. package/core/specs-templates/specs/changelog/.gitkeep +0 -0
  146. package/core/specs-templates/specs/decisions/0000-template.md +33 -0
  147. package/core/specs-templates/specs/decisions/README.md +29 -0
  148. package/core/specs-templates/specs/decisions/impact-map.md +14 -0
  149. package/core/specs-templates/specs/decisions/index.md +4 -0
  150. package/core/specs-templates/specs/index.md +16 -0
  151. package/core/specs-templates/specs/phases/README.md +41 -0
  152. package/core/specs-templates/specs/phases/index.md +6 -0
  153. package/core/specs-templates/specs/planning/roadmap.md +23 -0
  154. package/core/specs-templates/specs/status.md +70 -0
  155. package/core/specs-templates/specs/vision/principles.md +16 -0
  156. package/core/specs-templates/specs/vision/project-charter.md +35 -0
  157. package/core/specs-templates/specs/vision/success-criteria.md +19 -0
  158. package/core/swarm/absorb.js +294 -0
  159. package/core/swarm/conductor.js +499 -0
  160. package/core/swarm/focus.js +151 -0
  161. package/core/swarm/inbox.js +222 -0
  162. package/core/swarm/join.js +138 -0
  163. package/core/swarm/lib/board.js +178 -0
  164. package/core/swarm/lib/brief.js +157 -0
  165. package/core/swarm/lib/git-sha-cache.js +71 -0
  166. package/core/swarm/lib/incremental-log.js +117 -0
  167. package/core/swarm/lib/manifest.js +578 -0
  168. package/core/swarm/lib/pre-merge.js +112 -0
  169. package/core/swarm/lib/saga.js +146 -0
  170. package/core/swarm/lib/sessions.js +83 -0
  171. package/core/swarm/lib/tokens.js +255 -0
  172. package/core/swarm/lib/wave-ordering.js +55 -0
  173. package/core/swarm/schema/board.schema.json +54 -0
  174. package/core/swarm/schema/contract.schema.json +56 -0
  175. package/core/swarm/schema/dispatch-run.schema.json +70 -0
  176. package/core/swarm/schema/manifest.schema.json +234 -0
  177. package/core/swarm/schema/signal.schema.json +61 -0
  178. package/core/swarm/signals.js +296 -0
  179. package/core/swarm/supervise.md +81 -0
  180. package/core/waves/lib/plan-graph.js +175 -0
  181. package/core/waves/lib/waves.js +83 -0
  182. package/package.json +46 -0
@@ -0,0 +1,1302 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `momentum ecosystem` subcommand handler.
5
+ *
6
+ * Subcommands:
7
+ * init [name] — scaffold a new ecosystem root in the CWD
8
+ * add <repo-path> — register a momentum-installed repo as a member
9
+ * remove <id> — strip a member by id
10
+ * status — print manifest + per-member git state
11
+ *
12
+ * The ecosystem layer is strictly additive to single-repo momentum.
13
+ * Member repos receive exactly one fenced line in their CLAUDE.md /
14
+ * AGENTS.md pointing back at the ecosystem; nothing else is touched.
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const { execSync } = require('child_process');
20
+
21
+ const lib = require('../core/ecosystem/lib');
22
+ const {
23
+ POINTER_BEGIN,
24
+ POINTER_END,
25
+ PRIMARY_INSTRUCTION_CANDIDATES,
26
+ findPrimaryInstructionFile,
27
+ ensurePointerInjected,
28
+ stripPointer,
29
+ } = require('../core/ecosystem/lib/pointer');
30
+
31
+ // ─────────────────────────────────────────────────────────────────────────────
32
+ // Public entrypoint
33
+ // ─────────────────────────────────────────────────────────────────────────────
34
+
35
+ /**
36
+ * Dispatch a `momentum ecosystem <subcommand> [...]` invocation.
37
+ *
38
+ * `args` is the full argv tail starting at the subcommand
39
+ * (i.e. caller has already shifted off `momentum` and `ecosystem`).
40
+ *
41
+ * Returns void; throws Error on failure with a human-readable message.
42
+ */
43
+ function runEcosystem(args) {
44
+ if (!args || args.length === 0 || args[0] === '--help' || args[0] === '-h') {
45
+ return printUsage();
46
+ }
47
+ const sub = args[0];
48
+ const rest = args.slice(1);
49
+
50
+ switch (sub) {
51
+ case 'init':
52
+ return cmdInit(rest);
53
+ case 'add':
54
+ return cmdAdd(rest);
55
+ case 'remove':
56
+ case 'rm':
57
+ return cmdRemove(rest);
58
+ case 'status':
59
+ return cmdStatus(rest);
60
+ case 'upgrade':
61
+ return cmdUpgrade(rest);
62
+ case 'initiative':
63
+ return cmdInitiative(rest);
64
+ default:
65
+ throw new Error(
66
+ `unknown ecosystem subcommand "${sub}". ` +
67
+ `Try: init | add | remove | status | upgrade | initiative`,
68
+ );
69
+ }
70
+ }
71
+
72
+ function printUsage() {
73
+ console.log(`
74
+ momentum ecosystem — Cross-repo coordination layer (Tier 1).
75
+
76
+ Usage:
77
+ momentum ecosystem init [name]
78
+ momentum ecosystem add <repo-path> [--role <role>] [--id <id>] [--ecosystem <path>]
79
+ momentum ecosystem remove <member-id> [--ecosystem <path>]
80
+ momentum ecosystem status [--no-git] [--ecosystem <path>]
81
+ momentum ecosystem upgrade [--dry-run] [--force|--autostash] [--agent <name>] [--ecosystem <path>]
82
+ momentum ecosystem initiative create <slug> [--why "<text>"] [--repos r1,r2] [--owner <name>] [--ecosystem <path>]
83
+
84
+ Location:
85
+ add / remove / status / initiative auto-locate the ecosystem root by
86
+ walking up from CWD (bounded by MOMENTUM_MAX_PARENT_WALK, default 5)
87
+ AND scanning siblings (so member-repo CWDs work). Use --ecosystem <path>
88
+ to override explicitly.
89
+
90
+ Subcommands:
91
+ init Scaffold a new ecosystem root in the CWD (or under [name]/).
92
+ Writes ecosystem.json, initiatives/, sessions/, .state/,
93
+ .gitignore, README.md, CLAUDE.md, AGENTS.md, plus the
94
+ coordination command surface those instructions advertise
95
+ (/scout /dispatch /handoff /continue /initiative /session
96
+ /ecosystem /swarm + session scripts + hook wiring) for
97
+ --agent <name> (default: claude-code). Runs \`git init\`
98
+ and an initial commit.
99
+
100
+ add Register a momentum-installed repo as a member. Writes the
101
+ member into ecosystem.json AND injects an action-bearing
102
+ pointer block into the target's CLAUDE.md / AGENTS.md.
103
+ Idempotent — re-running is a no-op when the state already
104
+ matches.
105
+
106
+ remove Inverse of \`add\`. Strips the member from ecosystem.json and
107
+ removes the pointer block from the target's primary
108
+ instruction file.
109
+
110
+ status Print the manifest summary plus \`git status --short\` and
111
+ the most recent commit for each member.
112
+
113
+ upgrade Sweep \`momentum upgrade\` across every member (PULL model).
114
+ Per repo: skips a dirty working tree, detects the adapter from
115
+ its lock file, runs the upgrade, and reports the momentum version
116
+ before → after. Tolerates partial failure — one bad repo never
117
+ aborts the fleet. Dirty repos:
118
+ --autostash stash the in-flight work, upgrade, restore it
119
+ (your work comes back exactly as it was)
120
+ --force upgrade in place without stashing
121
+ Use --dry-run to preview the whole sweep without writing anything.
122
+ Note: the CLI's OWN version bounds the result — update the CLI
123
+ first (\`npm i -g @limina-labs/momentum@latest\`).
124
+
125
+ initiative Manage cross-repo initiatives. Currently only \`create\` is
126
+ wired as a CLI subcommand (other operations stay slash-only
127
+ for now). Non-interactive — takes flags so it works from
128
+ any agent context. Defaults: --repos all members; --owner
129
+ git user.name (or \$USER); --why "" (write later).
130
+
131
+ Roles:
132
+ platform | client | library | infra | bench | other
133
+
134
+ This command must be run from inside an ecosystem root (or, for
135
+ \`init\`, from where you want the root created).
136
+ `.trim());
137
+ }
138
+
139
+ // ─────────────────────────────────────────────────────────────────────────────
140
+ // init
141
+ // ─────────────────────────────────────────────────────────────────────────────
142
+
143
+ function cmdInit(args) {
144
+ const opts = parseFlags(args, { agent: 'string' });
145
+ // Positionals = args minus flags and their values (parseFlags doesn't strip).
146
+ const positionals = [];
147
+ for (let i = 0; i < args.length; i++) {
148
+ if (args[i].startsWith('--')) {
149
+ if (args[i] === '--agent') i++;
150
+ continue;
151
+ }
152
+ positionals.push(args[i]);
153
+ }
154
+ const agent = opts.agent || 'claude-code';
155
+ rootCommandDest(agent); // validates the agent name before any writes
156
+
157
+ const name = positionals[0] || path.basename(process.cwd());
158
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) {
159
+ throw new Error(
160
+ `init: ecosystem name "${name}" must match /^[a-z][a-z0-9-]*$/ ` +
161
+ `(lowercase letters, digits, hyphens; start with a letter).`,
162
+ );
163
+ }
164
+
165
+ // If [name] is given, scaffold under ./<name>/. Otherwise scaffold in CWD.
166
+ const cliGaveName = positionals[0] !== undefined;
167
+ const root = cliGaveName ? path.resolve(process.cwd(), name) : process.cwd();
168
+
169
+ if (fs.existsSync(path.join(root, lib.MANIFEST_FILENAME))) {
170
+ throw new Error(
171
+ `init: ${path.join(root, lib.MANIFEST_FILENAME)} already exists. ` +
172
+ `Refusing to overwrite.`,
173
+ );
174
+ }
175
+
176
+ // BUG-021: an ecosystem root is a COORDINATION directory, not a code repo.
177
+ // Scaffolding into an existing project overwrites its README.md and
178
+ // .gitignore with ecosystem templates (observed live — destructive).
179
+ // Refuse when the target looks like an existing project; --force overrides.
180
+ if (!args.includes('--force')) {
181
+ const projectMarkers = [
182
+ ['.momentum', 'a momentum project install (.momentum/)'],
183
+ ['package.json', 'a package.json'],
184
+ ['specs', 'a specs/ tree'],
185
+ ['.git', 'a git repository'],
186
+ ['README.md', 'an existing README.md'],
187
+ ].filter(([marker]) => fs.existsSync(path.join(root, marker)));
188
+ if (projectMarkers.length > 0) {
189
+ throw new Error(
190
+ `init: ${root} looks like an existing project — it contains ` +
191
+ `${projectMarkers.map(([, label]) => label).join(', ')}.\n` +
192
+ ` An ecosystem root must be its own directory (a SIBLING of member repos).\n` +
193
+ ` Use: momentum ecosystem init <name> (creates ./<name>/)\n` +
194
+ ` or: momentum init --ecosystem <name> (creates ../<name>/ and registers this repo)\n` +
195
+ ` Pass --force only if you really want an ecosystem root here.`,
196
+ );
197
+ }
198
+ }
199
+
200
+ fs.mkdirSync(root, { recursive: true });
201
+ fs.mkdirSync(path.join(root, 'initiatives'), { recursive: true });
202
+ fs.mkdirSync(path.join(root, 'sessions'), { recursive: true });
203
+ fs.mkdirSync(path.join(root, '.state'), { recursive: true });
204
+
205
+ // ecosystem.json
206
+ const manifest = {
207
+ name,
208
+ version: 1,
209
+ created: today(),
210
+ members: [],
211
+ dependencies: [],
212
+ };
213
+ fs.writeFileSync(
214
+ path.join(root, lib.MANIFEST_FILENAME),
215
+ JSON.stringify(manifest, null, 2) + '\n',
216
+ 'utf8',
217
+ );
218
+
219
+ // README
220
+ fs.writeFileSync(
221
+ path.join(root, 'README.md'),
222
+ readmeTemplate(name),
223
+ 'utf8',
224
+ );
225
+
226
+ // initiatives/README
227
+ fs.writeFileSync(
228
+ path.join(root, 'initiatives', 'README.md'),
229
+ initiativesReadme(),
230
+ 'utf8',
231
+ );
232
+
233
+ // sessions/.gitkeep + .state/.gitkeep
234
+ fs.writeFileSync(path.join(root, 'sessions', '.gitkeep'), '', 'utf8');
235
+ fs.writeFileSync(path.join(root, '.state', '.gitkeep'), '', 'utf8');
236
+
237
+ // .gitignore
238
+ fs.writeFileSync(
239
+ path.join(root, '.gitignore'),
240
+ gitignoreTemplate(),
241
+ 'utf8',
242
+ );
243
+
244
+ // ENH-025: managed CLAUDE.md + AGENTS.md so any agent opening this
245
+ // directory immediately learns it is a coordination layer (NOT a
246
+ // project) and discovers the orchestration primitives. We write
247
+ // both because at ecosystem-init time we don't know which adapter
248
+ // the consuming agents will use; both surfaces are cheap. Idempotent:
249
+ // never overwrite if either file already exists.
250
+ writeManagedInstructionFile(
251
+ path.join(root, 'CLAUDE.md'),
252
+ renderEcosystemInstruction('ecosystem-claude.md', name),
253
+ );
254
+ writeManagedInstructionFile(
255
+ path.join(root, 'AGENTS.md'),
256
+ renderEcosystemInstruction('ecosystem-agents.md', name),
257
+ );
258
+
259
+ // ENH-049: the command surface those instruction files advertise.
260
+ ensureRootCommandSurface(root, agent, false);
261
+
262
+ // git init + initial commit (best-effort; if git is unavailable the
263
+ // user can do it themselves).
264
+ try {
265
+ execSync('git init -q', { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
266
+ execSync('git add .', { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
267
+ // Allow commit to fail silently if user.email / user.name not set —
268
+ // the scaffold is still valid.
269
+ try {
270
+ execSync(
271
+ 'git commit -q -m "chore(ecosystem): initial scaffold"',
272
+ { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] },
273
+ );
274
+ } catch (_e) {
275
+ // Likely missing git identity — user can commit manually.
276
+ }
277
+ } catch (_e) {
278
+ // git not available — scaffold is still usable.
279
+ }
280
+
281
+ console.log(`Initialized ecosystem "${name}" at ${root}`);
282
+ console.log(` + CLAUDE.md (managed; tells agents this is a coordination layer, not a project)`);
283
+ console.log(` + AGENTS.md (same content, for AGENTS.md-aware agents)`);
284
+ console.log(` + ${rootCommandDest(agent).join('/')}/ (coordination commands, agent: ${agent})`);
285
+ console.log(`Next steps:`);
286
+ console.log(` cd ${cliGaveName ? name : '.'}`);
287
+ console.log(` momentum ecosystem add ../<repo-name>`);
288
+ }
289
+
290
+ /**
291
+ * Render an ecosystem CLAUDE.md / AGENTS.md template by substituting
292
+ * {{NAME}} for the ecosystem name. Templates live under
293
+ * `core/ecosystem/templates/`. Substitution is intentionally tiny —
294
+ * the templates are markdown content, not a programmable surface.
295
+ */
296
+ function renderEcosystemInstruction(filename, ecosystemName) {
297
+ const src = path.join(
298
+ __dirname,
299
+ '..',
300
+ 'core',
301
+ 'ecosystem',
302
+ 'templates',
303
+ filename,
304
+ );
305
+ const raw = fs.readFileSync(src, 'utf8');
306
+ return raw.replace(/\{\{NAME\}\}/g, ecosystemName);
307
+ }
308
+
309
+ /**
310
+ * Write a managed agent-instruction file. Skip if the file already
311
+ * exists — agents may have customised it (or another tool may own
312
+ * the surface). Idempotent: re-running init in a directory that already
313
+ * has CLAUDE.md / AGENTS.md leaves them untouched.
314
+ */
315
+ function writeManagedInstructionFile(destPath, content) {
316
+ if (fs.existsSync(destPath)) return;
317
+ fs.writeFileSync(destPath, content, 'utf8');
318
+ }
319
+
320
+ /**
321
+ * Ensure the coordination root's own CLAUDE.md / AGENTS.md exist and carry
322
+ * the ecosystem instructions (BUG-016 self-heal; also retrofits pre-Phase-15
323
+ * roots that were scaffolded before ENH-025 wrote these files). Per file:
324
+ * - missing → write the rendered template
325
+ * - momentum-managed (ecosystem template, or the project template — the
326
+ * BUG-016 signature) → replace the managed section above the
327
+ * `## Project Extensions` marker, preserving user extensions; `.bak`
328
+ * saved first. A boilerplate-only extensions tail (heading + blockquote,
329
+ * no user lines) is swapped for the template's own tail.
330
+ * - anything else (user-owned surface) → leave untouched, warn
331
+ */
332
+ function refreshRootInstructions(root, ecosystemName, dryRun) {
333
+ // Lazy require avoids a load-time cycle (momentum.js requires this module).
334
+ const { partitionByMarker } = require('./momentum');
335
+ const surfaces = [
336
+ { file: 'CLAUDE.md', template: 'ecosystem-claude.md' },
337
+ { file: 'AGENTS.md', template: 'ecosystem-agents.md' },
338
+ ];
339
+ console.log('Coordination-root instructions:');
340
+ for (const s of surfaces) {
341
+ const dest = path.join(root, s.file);
342
+ const rendered = renderEcosystemInstruction(s.template, ecosystemName);
343
+ if (!fs.existsSync(dest)) {
344
+ if (dryRun) {
345
+ console.log(` ✋ would add: ${s.file} (ecosystem instructions)`);
346
+ continue;
347
+ }
348
+ fs.writeFileSync(dest, rendered, 'utf8');
349
+ console.log(` + added: ${s.file} (ecosystem instructions)`);
350
+ continue;
351
+ }
352
+ const existing = fs.readFileSync(dest, 'utf8');
353
+ if (existing === rendered) {
354
+ console.log(` = ${s.file} up to date`);
355
+ continue;
356
+ }
357
+ const firstLine = existing.split('\n', 1)[0];
358
+ const isEcosystemManaged = /— Ecosystem \(Coordination Layer\)\s*$/.test(firstLine);
359
+ const isProjectTemplate = /^# Project Rules:/.test(firstLine);
360
+ if (!isEcosystemManaged && !isProjectTemplate) {
361
+ console.log(` ⚠️ ${s.file} exists but is not momentum-managed — left untouched.`);
362
+ continue;
363
+ }
364
+ const renderedParts = partitionByMarker(rendered);
365
+ const existingParts = partitionByMarker(existing);
366
+ const tail = hasExtensionContent(existingParts.extensions)
367
+ ? existingParts.extensions
368
+ : renderedParts.extensions;
369
+ const next = renderedParts.managed + (tail || '');
370
+ if (next === existing) {
371
+ console.log(` = ${s.file} up to date`);
372
+ continue;
373
+ }
374
+ const isRepair = isProjectTemplate;
375
+ const why = isRepair
376
+ ? ' — project template found in a coordination root (BUG-016)'
377
+ : '';
378
+ if (dryRun) {
379
+ console.log(` ✋ would ${isRepair ? 'repair' : 'refresh'}: ${s.file}${why}`);
380
+ continue;
381
+ }
382
+ fs.copyFileSync(dest, dest + '.bak');
383
+ fs.writeFileSync(dest, next, 'utf8');
384
+ console.log(
385
+ ` ↻ ${isRepair ? 'repaired' : 'refreshed'}: ${s.file}${why} (original saved as .bak)`,
386
+ );
387
+ }
388
+ }
389
+
390
+ /**
391
+ * True when an extensions tail (as returned by partitionByMarker) carries
392
+ * user content — any non-blank line that isn't the marker heading itself,
393
+ * a `>` blockquote line, or a horizontal rule.
394
+ */
395
+ function hasExtensionContent(extensions) {
396
+ if (!extensions) return false;
397
+ return extensions.split('\n').some((line) => {
398
+ const t = line.trim();
399
+ return (
400
+ t !== '' && !t.startsWith('>') && !t.startsWith('## Project Extensions') && t !== '---'
401
+ );
402
+ });
403
+ }
404
+
405
+ // ── Coordination-root command surface (ENH-049) ──────────────────────────────
406
+ //
407
+ // The ecosystem CLAUDE.md/AGENTS.md advertise slash-command primitives
408
+ // (/scout /dispatch /handoff /continue /initiative /session plus the
409
+ // /ecosystem and /swarm operator doors). Before ENH-049 nothing shipped
410
+ // them: a fresh coordination root promised commands that did not resolve
411
+ // (the cerebrio root only had a working surface by accident of the BUG-016
412
+ // project-mode mis-install). `ecosystem init` installs the curated fileset
413
+ // below; `ecosystem upgrade` retrofits/refreshes it. Strictly the
414
+ // coordination layer — project/phase commands are the BUG-016 anti-pattern:
415
+ // warned about, never shipped, never deleted.
416
+
417
+ const ROOT_SURFACE_COMMANDS = [
418
+ // adapter-overlay recipes (per-agent copies under adapters/<agent>/)
419
+ 'scout.md', 'dispatch.md', 'handoff.md', 'continue.md', 'swarm.md',
420
+ // agent-neutral recipes (core/commands/)
421
+ 'ecosystem.md', 'initiative.md', 'session.md',
422
+ ];
423
+
424
+ const ROOT_SURFACE_SCRIPTS = [
425
+ ['core', 'scripts', 'sessionstart-handoff.sh'],
426
+ ['core', 'ecosystem', 'scripts', 'session-append.sh'],
427
+ ];
428
+
429
+ // Project-layer commands that must not live in a coordination root.
430
+ const PROJECT_LAYER_COMMANDS = new Set([
431
+ 'brainstorm-idea.md', 'brainstorm-phase.md', 'start-project.md',
432
+ 'start-phase.md', 'complete-phase.md', 'sync-docs.md', 'hotfix.md',
433
+ 'track.md', 'validate.md', 'log.md', 'migrate.md', 'review.md',
434
+ 'review-code.md', 'systematic-debug.md', 'lanes.md',
435
+ ]);
436
+
437
+ function listRootAgents() {
438
+ const dir = path.join(__dirname, '..', 'adapters');
439
+ return fs.readdirSync(dir, { withFileTypes: true })
440
+ .filter((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'adapter.js')))
441
+ .map((e) => e.name);
442
+ }
443
+
444
+ /** Commands destination for an agent, e.g. ['.claude','commands']. */
445
+ function rootCommandDest(agent) {
446
+ const adapterPath = path.join(__dirname, '..', 'adapters', agent, 'adapter.js');
447
+ if (!fs.existsSync(adapterPath)) {
448
+ throw new Error(
449
+ `unknown agent "${agent}" (available: ${listRootAgents().join(', ')})`,
450
+ );
451
+ }
452
+ const adapter = require(adapterPath);
453
+ return (adapter.destinations && adapter.destinations.commands) || ['.claude', 'commands'];
454
+ }
455
+
456
+ /** Recipe source: adapter overlay first (commands/ or workflows/), then core. */
457
+ function resolveRootCommandSource(agent, file) {
458
+ const src = path.join(__dirname, '..');
459
+ const candidates = [
460
+ path.join(src, 'adapters', agent, 'commands', file),
461
+ path.join(src, 'adapters', agent, 'workflows', file), // Antigravity ships recipes as workflows
462
+ path.join(src, 'core', 'commands', file),
463
+ ];
464
+ for (const c of candidates) if (fs.existsSync(c)) return c;
465
+ return null;
466
+ }
467
+
468
+ /** Agents whose command surface already exists in the root (for upgrade). */
469
+ function detectRootAgents(root) {
470
+ return listRootAgents().filter((agent) =>
471
+ fs.existsSync(path.join(root, ...rootCommandDest(agent))),
472
+ );
473
+ }
474
+
475
+ /**
476
+ * Install/refresh the coordination-root command surface for one agent:
477
+ * add when missing; refresh differing momentum-owned files with a `.bak`
478
+ * (house upgrade semantics); count identical files as up to date. The
479
+ * Claude Code hook wiring (`.claude/settings.json`) is written only when
480
+ * absent — settings are user-owned once they exist.
481
+ */
482
+ function ensureRootCommandSurface(root, agent, dryRun) {
483
+ const destRel = rootCommandDest(agent);
484
+ const destDir = path.join(root, ...destRel);
485
+ let upToDate = 0;
486
+
487
+ const place = (srcContent, destPath, { exec = false } = {}) => {
488
+ const rel = path.relative(root, destPath);
489
+ if (!fs.existsSync(destPath)) {
490
+ if (dryRun) {
491
+ console.log(` ✋ would add: ${rel}`);
492
+ return;
493
+ }
494
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
495
+ fs.writeFileSync(destPath, srcContent);
496
+ if (exec) fs.chmodSync(destPath, 0o755);
497
+ console.log(` + added: ${rel}`);
498
+ return;
499
+ }
500
+ if (fs.readFileSync(destPath, 'utf8') === srcContent) {
501
+ if (exec) {
502
+ try { fs.chmodSync(destPath, 0o755); } catch (_e) { /* best-effort */ }
503
+ }
504
+ upToDate++;
505
+ return;
506
+ }
507
+ if (dryRun) {
508
+ console.log(` ✋ would refresh: ${rel}`);
509
+ return;
510
+ }
511
+ fs.copyFileSync(destPath, destPath + '.bak');
512
+ fs.writeFileSync(destPath, srcContent);
513
+ if (exec) fs.chmodSync(destPath, 0o755);
514
+ console.log(` ↑ refreshed: ${rel} (original saved as .bak)`);
515
+ };
516
+
517
+ for (const file of ROOT_SURFACE_COMMANDS) {
518
+ const srcPath = resolveRootCommandSource(agent, file);
519
+ if (!srcPath) {
520
+ console.log(` ⚠️ no source for ${file} (agent: ${agent}) — skipped.`);
521
+ continue;
522
+ }
523
+ place(fs.readFileSync(srcPath, 'utf8'), path.join(destDir, file));
524
+ }
525
+
526
+ for (const relParts of ROOT_SURFACE_SCRIPTS) {
527
+ const srcPath = path.join(__dirname, '..', ...relParts);
528
+ place(fs.readFileSync(srcPath, 'utf8'), path.join(root, 'scripts', relParts[relParts.length - 1]), { exec: true });
529
+ }
530
+
531
+ if (agent === 'claude-code') {
532
+ const settingsPath = path.join(root, '.claude', 'settings.json');
533
+ if (!fs.existsSync(settingsPath)) {
534
+ if (dryRun) {
535
+ console.log(` ✋ would add: .claude/settings.json (SessionStart hook wiring)`);
536
+ } else {
537
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
538
+ fs.writeFileSync(
539
+ settingsPath,
540
+ renderEcosystemInstruction('ecosystem-settings-claude.json', ''),
541
+ );
542
+ console.log(` + added: .claude/settings.json (SessionStart hook wiring)`);
543
+ }
544
+ } else {
545
+ upToDate++; // user-owned once present — never rewritten
546
+ }
547
+ }
548
+
549
+ if (upToDate > 0) {
550
+ console.log(` = ${destRel.join('/')} [${agent}]: ${upToDate} file(s) up to date`);
551
+ }
552
+
553
+ const offenders = fs.existsSync(destDir)
554
+ ? fs.readdirSync(destDir).filter((f) => PROJECT_LAYER_COMMANDS.has(f))
555
+ : [];
556
+ if (offenders.length) {
557
+ console.log(
558
+ ` ⚠️ project-layer commands in the coordination root (BUG-016 anti-pattern — ` +
559
+ `remove them; they mis-orient agents): ${offenders.join(', ')}`,
560
+ );
561
+ }
562
+ }
563
+
564
+ // ─────────────────────────────────────────────────────────────────────────────
565
+ // add
566
+ // ─────────────────────────────────────────────────────────────────────────────
567
+
568
+ function cmdAdd(args) {
569
+ if (args.length === 0) {
570
+ throw new Error('add: missing <repo-path> argument.');
571
+ }
572
+ const repoPath = args[0];
573
+ const opts = parseFlags(args.slice(1), {
574
+ role: 'string',
575
+ id: 'string',
576
+ ecosystem: 'string',
577
+ });
578
+
579
+ const root = resolveEcosystemRoot(opts.ecosystem, 'add');
580
+
581
+ const absRepo = path.resolve(root, repoPath);
582
+ if (!fs.existsSync(absRepo)) {
583
+ throw new Error(`add: repo path does not exist: ${absRepo}`);
584
+ }
585
+
586
+ // Pre-flight: target must look momentum-installed.
587
+ const primary = findPrimaryInstructionFile(absRepo);
588
+ if (!primary) {
589
+ throw new Error(
590
+ `add: ${absRepo} doesn't look momentum-installed ` +
591
+ `(no CLAUDE.md or AGENTS.md found). Run \`momentum init\` there first.`,
592
+ );
593
+ }
594
+
595
+ const id = opts.id || sanitizeId(path.basename(absRepo));
596
+ if (!/^[a-z][a-z0-9-]*$/.test(id)) {
597
+ throw new Error(
598
+ `add: derived/explicit id "${id}" must match /^[a-z][a-z0-9-]*$/. ` +
599
+ `Use --id to override.`,
600
+ );
601
+ }
602
+ const role = opts.role || 'other';
603
+ const validRoles = ['platform', 'client', 'library', 'infra', 'bench', 'other'];
604
+ if (!validRoles.includes(role)) {
605
+ throw new Error(
606
+ `add: invalid --role "${role}". Must be one of: ${validRoles.join(', ')}`,
607
+ );
608
+ }
609
+
610
+ const relPath = path.relative(root, absRepo) || '.';
611
+
612
+ // Mutate manifest.
613
+ const manifest = lib.loadManifest(root);
614
+ const existing = lib.findMember(manifest, id);
615
+ if (existing) {
616
+ // Idempotent: if the path matches, do nothing. If it differs, fail loud.
617
+ if (existing.path === relPath) {
618
+ console.log(`add: "${id}" is already registered (path ${relPath}). No changes.`);
619
+ ensurePointerInjected(absRepo, primary, root, manifest.name);
620
+ return;
621
+ }
622
+ throw new Error(
623
+ `add: member id "${id}" already registered with a different path ` +
624
+ `(${existing.path}). Use --id to register under a different id, ` +
625
+ `or \`remove ${id}\` first.`,
626
+ );
627
+ }
628
+ manifest.members.push({ id, path: relPath, role });
629
+
630
+ const v = lib.validateManifest(manifest);
631
+ if (!v.ok) {
632
+ throw new Error(
633
+ `add: resulting manifest fails validation:\n` +
634
+ v.errors.map((e) => ` ${e.path}: ${e.message}`).join('\n'),
635
+ );
636
+ }
637
+
638
+ fs.writeFileSync(
639
+ path.join(root, lib.MANIFEST_FILENAME),
640
+ JSON.stringify(manifest, null, 2) + '\n',
641
+ 'utf8',
642
+ );
643
+
644
+ // Inject the fenced pointer into the target's primary instruction.
645
+ ensurePointerInjected(absRepo, primary, root, manifest.name);
646
+
647
+ console.log(`Added member "${id}" (${role}) at ${relPath}.`);
648
+ console.log(`Pointer injected into ${path.relative(root, path.join(absRepo, primary))}.`);
649
+ }
650
+
651
+ // ─────────────────────────────────────────────────────────────────────────────
652
+ // remove
653
+ // ─────────────────────────────────────────────────────────────────────────────
654
+
655
+ function cmdRemove(args) {
656
+ if (args.length === 0) {
657
+ throw new Error('remove: missing <member-id> argument.');
658
+ }
659
+ const opts = parseFlags(args.slice(1), { ecosystem: 'string' });
660
+ const id = args[0];
661
+
662
+ const root = resolveEcosystemRoot(opts.ecosystem, 'remove');
663
+
664
+ const manifest = lib.loadManifest(root);
665
+ const member = lib.findMember(manifest, id);
666
+ if (!member) {
667
+ throw new Error(`remove: no member with id "${id}".`);
668
+ }
669
+
670
+ // Strip from manifest.
671
+ manifest.members = manifest.members.filter((m) => m.id !== id);
672
+ // Also strip any dependency edges referencing the removed member.
673
+ if (Array.isArray(manifest.dependencies)) {
674
+ manifest.dependencies = manifest.dependencies.filter(
675
+ (d) => d.from !== id && d.to !== id,
676
+ );
677
+ }
678
+ fs.writeFileSync(
679
+ path.join(root, lib.MANIFEST_FILENAME),
680
+ JSON.stringify(manifest, null, 2) + '\n',
681
+ 'utf8',
682
+ );
683
+
684
+ // Best-effort: strip the fenced pointer from the target's CLAUDE.md /
685
+ // AGENTS.md. If the target no longer exists on disk we skip silently —
686
+ // remove must succeed even when the member repo is gone.
687
+ const absRepo = path.resolve(root, member.path);
688
+ if (fs.existsSync(absRepo)) {
689
+ const primary = findPrimaryInstructionFile(absRepo);
690
+ if (primary) {
691
+ stripPointer(path.join(absRepo, primary));
692
+ }
693
+ }
694
+
695
+ console.log(`Removed member "${id}".`);
696
+ }
697
+
698
+ // ─────────────────────────────────────────────────────────────────────────────
699
+ // status
700
+ // ─────────────────────────────────────────────────────────────────────────────
701
+
702
+ function cmdStatus(args) {
703
+ const opts = parseFlags(args, {
704
+ 'no-git': 'boolean',
705
+ ecosystem: 'string',
706
+ });
707
+ const root = resolveEcosystemRoot(opts.ecosystem, 'status');
708
+ const manifest = lib.loadManifest(root);
709
+
710
+ console.log(`Ecosystem: ${manifest.name} (root: ${root})`);
711
+ console.log(`Members: ${manifest.members.length}`);
712
+ if (manifest.members.length === 0) {
713
+ console.log(' (none registered yet — try `momentum ecosystem add ../<repo>`)');
714
+ return;
715
+ }
716
+
717
+ for (const m of manifest.members) {
718
+ const absRepo = path.resolve(root, m.path);
719
+ const exists = fs.existsSync(absRepo);
720
+ console.log('');
721
+ console.log(` ${m.id} [${m.role}] ${m.path}${exists ? '' : ' (MISSING)'}`);
722
+ if (exists && !opts['no-git']) {
723
+ printGitState(absRepo);
724
+ }
725
+ }
726
+
727
+ // Print active initiative if any.
728
+ const activeFile = path.join(root, '.state', 'active-initiative');
729
+ if (fs.existsSync(activeFile)) {
730
+ const slug = fs.readFileSync(activeFile, 'utf8').trim();
731
+ if (slug) {
732
+ console.log('');
733
+ console.log(`Active initiative: ${slug}`);
734
+ }
735
+ }
736
+ }
737
+
738
+ // ─────────────────────────────────────────────────────────────────────────────
739
+ // upgrade — PULL-model fleet sweep (Phase 20)
740
+ // ─────────────────────────────────────────────────────────────────────────────
741
+ //
742
+ // Runs `momentum upgrade` across every ecosystem member in one pass. Chosen
743
+ // over a PUSH-model bot (Renovate/Dependabot) because momentum is forge-neutral
744
+ // (DIP) and operates on local git checkouts. Fleet-safety: clean-tree gate per
745
+ // repo, partial-failure tolerance, per-repo version reporting, dry-run.
746
+
747
+ /** True when the repo has uncommitted changes. Non-git repos count as clean. */
748
+ function isWorkingTreeDirty(repoPath) {
749
+ try {
750
+ const out = execSync('git status --porcelain', {
751
+ cwd: repoPath,
752
+ encoding: 'utf8',
753
+ stdio: ['ignore', 'pipe', 'ignore'],
754
+ }).trim();
755
+ return out.length > 0;
756
+ } catch (_e) {
757
+ return false; // not a git repo — nothing to protect, let the upgrade proceed
758
+ }
759
+ }
760
+
761
+ /** Read the recorded momentum version from a member's lock file. */
762
+ function readMemberVersion(repoPath) {
763
+ try {
764
+ const m = JSON.parse(
765
+ fs.readFileSync(path.join(repoPath, '.momentum', 'installed.json'), 'utf8'),
766
+ );
767
+ return (m && (m.momentumVersion || m.version)) || 'unknown'; // ADR-0007: new schema uses `version`; momentumVersion kept as compat mirror
768
+ } catch (_e) {
769
+ return 'none'; // pre-Phase-20 install (no lock file)
770
+ }
771
+ }
772
+
773
+ /** Detect which adapter a member uses. Lock file is authoritative; else heuristic. */
774
+ function detectMemberAgent(repoPath) {
775
+ try {
776
+ const m = JSON.parse(
777
+ fs.readFileSync(path.join(repoPath, '.momentum', 'installed.json'), 'utf8'),
778
+ );
779
+ if (m && m.agent) return m.agent;
780
+ } catch (_e) { /* fall through to heuristic */ }
781
+ if (fs.existsSync(path.join(repoPath, '.codex'))) return 'codex';
782
+ if (fs.existsSync(path.join(repoPath, '.claude'))) return 'claude-code';
783
+ if (
784
+ fs.existsSync(path.join(repoPath, '.agents', 'hooks.json')) ||
785
+ fs.existsSync(path.join(repoPath, '.agent', 'workflows'))
786
+ ) {
787
+ return 'antigravity';
788
+ }
789
+ if (fs.existsSync(path.join(repoPath, 'CLAUDE.md'))) return 'claude-code';
790
+ if (fs.existsSync(path.join(repoPath, 'AGENTS.md'))) return 'codex';
791
+ return null;
792
+ }
793
+
794
+ function cmdUpgrade(args) {
795
+ const opts = parseFlags(args, {
796
+ ecosystem: 'string',
797
+ 'dry-run': 'boolean',
798
+ force: 'boolean',
799
+ autostash: 'boolean',
800
+ agent: 'string',
801
+ });
802
+ const dryRun = !!opts['dry-run'];
803
+ const root = resolveEcosystemRoot(opts.ecosystem, 'upgrade');
804
+ const manifest = lib.loadManifest(root);
805
+
806
+ console.log(`Ecosystem: ${manifest.name} (root: ${root})`);
807
+
808
+ // BUG-016: the root's own instruction surfaces are upgraded here — the
809
+ // project-mode `momentum upgrade` refuses to run in a coordination root,
810
+ // so this is the only refresh/retrofit path they have.
811
+ refreshRootInstructions(root, manifest.name, dryRun);
812
+
813
+ // ENH-049: the command surface those instructions advertise. Manage every
814
+ // agent surface already present in the root; retrofit the default (or
815
+ // --agent) surface when none exists yet (pre-ENH-049 roots).
816
+ const rootAgents = detectRootAgents(root);
817
+ const surfaceAgents = rootAgents.length ? rootAgents : [opts.agent || 'claude-code'];
818
+ console.log('Coordination-root command surface:');
819
+ for (const agent of surfaceAgents) {
820
+ ensureRootCommandSurface(root, agent, dryRun);
821
+ }
822
+
823
+ console.log('');
824
+ console.log(`Sweeping \`momentum upgrade\` across ${manifest.members.length} member(s)${dryRun ? ' [dry run — no writes]' : ''}`);
825
+ if (manifest.members.length === 0) {
826
+ console.log(' (none registered yet — try `momentum ecosystem add ../<repo>`)');
827
+ return;
828
+ }
829
+
830
+ // Lazy require avoids a load-time cycle (momentum.js requires this module).
831
+ const { upgrade, withAutostash } = require('./momentum');
832
+ const results = [];
833
+
834
+ for (const m of manifest.members) {
835
+ const absRepo = path.resolve(root, m.path);
836
+ console.log('');
837
+ console.log(`── ${m.id} [${m.role}] ${m.path} ─────────────────────`);
838
+
839
+ if (!fs.existsSync(absRepo)) {
840
+ console.log(' ⚠️ missing on disk — skipped.');
841
+ results.push({ id: m.id, status: 'missing' });
842
+ continue;
843
+ }
844
+ const dirty = isWorkingTreeDirty(absRepo);
845
+ // Dirty repos are skipped UNLESS --force (plow through) or --autostash
846
+ // (stash → upgrade → restore). Both are explicit opt-ins.
847
+ if (dirty && !opts.force && !opts.autostash) {
848
+ console.log(' ⚠️ working tree not clean — skipped. Commit/stash, --force, or --autostash.');
849
+ results.push({ id: m.id, status: 'dirty-skip' });
850
+ continue;
851
+ }
852
+ const agent = opts.agent || detectMemberAgent(absRepo);
853
+ if (!agent) {
854
+ console.log(' ⚠️ could not detect adapter — skipped. Re-run with --agent <name>.');
855
+ results.push({ id: m.id, status: 'no-agent' });
856
+ continue;
857
+ }
858
+
859
+ const before = readMemberVersion(absRepo);
860
+ const useAutostash = opts.autostash && dirty && !dryRun;
861
+ try {
862
+ let conflict = false;
863
+ if (useAutostash) {
864
+ const r = withAutostash(absRepo, () => upgrade(absRepo, agent, { dryRun }));
865
+ conflict = r.conflict;
866
+ } else {
867
+ upgrade(absRepo, agent, { dryRun });
868
+ }
869
+ const after = dryRun ? before : readMemberVersion(absRepo);
870
+ results.push({
871
+ id: m.id,
872
+ status: dryRun ? 'would-upgrade' : 'upgraded',
873
+ agent,
874
+ before,
875
+ after,
876
+ autostashed: useAutostash,
877
+ conflict,
878
+ });
879
+ } catch (err) {
880
+ // Partial-failure tolerance — one bad repo never aborts the fleet.
881
+ console.log(` ❌ upgrade failed: ${err.message}`);
882
+ results.push({ id: m.id, status: 'failed', error: err.message });
883
+ }
884
+ }
885
+
886
+ printSweepSummary(results, dryRun);
887
+ }
888
+
889
+ function printSweepSummary(results, dryRun) {
890
+ const icon = {
891
+ upgraded: '✓', 'would-upgrade': '✋', 'dirty-skip': '⊘', missing: '∅',
892
+ 'no-agent': '?', failed: '✗',
893
+ };
894
+ console.log('');
895
+ console.log('── Sweep summary ───────────────────────────────');
896
+ for (const r of results) {
897
+ const ver = r.before ? ` ${r.before} → ${r.after}` : '';
898
+ const ag = r.agent ? ` [${r.agent}]` : '';
899
+ const tag = r.conflict ? ' (autostash CONFLICT — work safe in stash)'
900
+ : r.autostashed ? ' (autostashed)' : '';
901
+ const err = r.status === 'failed' && r.error ? ` — ${r.error}` : '';
902
+ console.log(` ${icon[r.status] || '•'} ${r.id}: ${r.status}${ver}${ag}${tag}${err}`);
903
+ }
904
+ const counts = {};
905
+ for (const r of results) counts[r.status] = (counts[r.status] || 0) + 1;
906
+ console.log('');
907
+ console.log(' ' + Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(', '));
908
+ if (dryRun) console.log(' (dry run — no files were written)');
909
+ const conflicts = results.filter((r) => r.conflict);
910
+ if (conflicts.length) {
911
+ console.log('');
912
+ console.log(` ⚠️ ${conflicts.length} repo(s) had an autostash conflict — your work is`);
913
+ console.log(' preserved in each repo\'s `git stash list`; resolve with `git stash pop`.');
914
+ }
915
+ }
916
+
917
+ // ─────────────────────────────────────────────────────────────────────────────
918
+ // initiative
919
+ // ─────────────────────────────────────────────────────────────────────────────
920
+ //
921
+ // ENH-035 — promised "coming with Group 2 of Phase 9" and never shipped
922
+ // until Phase 15. Wires the existing core/ecosystem/lib/initiative.js
923
+ // (nextInitiativeId / writeInitiative / setActive) to a CLI subcommand.
924
+ // Non-interactive (flag-driven) so it works from any agent context.
925
+
926
+ function cmdInitiative(args) {
927
+ if (args.length === 0) {
928
+ throw new Error(
929
+ 'initiative: missing subsubcommand. Try: ' +
930
+ '`momentum ecosystem initiative create <slug>`',
931
+ );
932
+ }
933
+ const sub = args[0];
934
+ const rest = args.slice(1);
935
+ switch (sub) {
936
+ case 'create':
937
+ return cmdInitiativeCreate(rest);
938
+ default:
939
+ throw new Error(
940
+ `initiative: unknown subsubcommand "${sub}". ` +
941
+ `Only \`create\` is currently wired as a CLI; ` +
942
+ `\`list\` / \`status\` / \`close\` stay as slash-only for now.`,
943
+ );
944
+ }
945
+ }
946
+
947
+ function cmdInitiativeCreate(args) {
948
+ if (args.length === 0 || args[0].startsWith('--')) {
949
+ throw new Error(
950
+ 'initiative create: missing <slug> positional argument. ' +
951
+ 'Usage: momentum ecosystem initiative create <slug> ' +
952
+ '[--why "<text>"] [--repos r1,r2] [--owner <name>]',
953
+ );
954
+ }
955
+ const slug = args[0];
956
+ if (!/^[a-z][a-z0-9-]*$/.test(slug)) {
957
+ throw new Error(
958
+ `initiative create: slug "${slug}" must match /^[a-z][a-z0-9-]*$/ ` +
959
+ `(lowercase letters, digits, hyphens; start with a letter).`,
960
+ );
961
+ }
962
+ const opts = parseFlags(args.slice(1), {
963
+ why: 'string',
964
+ repos: 'string',
965
+ owner: 'string',
966
+ ecosystem: 'string',
967
+ });
968
+
969
+ const root = resolveEcosystemRoot(opts.ecosystem, 'initiative create');
970
+ const manifest = lib.loadManifest(root);
971
+
972
+ // Default repos to all member ids; parse the --repos CSV if given.
973
+ let reposList;
974
+ if (opts.repos) {
975
+ reposList = opts.repos
976
+ .split(',')
977
+ .map((s) => s.trim())
978
+ .filter(Boolean);
979
+ if (reposList.length === 0) {
980
+ throw new Error(
981
+ 'initiative create: --repos was provided but empty after parsing.',
982
+ );
983
+ }
984
+ // Validate every named repo is a registered member.
985
+ const known = new Set(manifest.members.map((m) => m.id));
986
+ const unknown = reposList.filter((r) => !known.has(r));
987
+ if (unknown.length) {
988
+ throw new Error(
989
+ `initiative create: unknown member id(s): ${unknown.join(', ')}. ` +
990
+ `Known: ${[...known].join(', ') || '(none)'}.`,
991
+ );
992
+ }
993
+ } else {
994
+ reposList = manifest.members.map((m) => m.id);
995
+ if (reposList.length === 0) {
996
+ throw new Error(
997
+ 'initiative create: ecosystem has no registered members yet. ' +
998
+ '`--repos` defaults to all members but the manifest is empty. ' +
999
+ 'Register members via `momentum ecosystem add <repo>` first, ' +
1000
+ 'or pass --repos explicitly.',
1001
+ );
1002
+ }
1003
+ }
1004
+
1005
+ // Default owner: explicit > git user.name > $USER > "(unknown)".
1006
+ let owner = opts.owner;
1007
+ if (!owner) {
1008
+ try {
1009
+ owner = execSync('git config user.name', {
1010
+ cwd: root,
1011
+ encoding: 'utf8',
1012
+ stdio: ['ignore', 'pipe', 'ignore'],
1013
+ }).trim();
1014
+ } catch (_e) {
1015
+ // git not configured — fall through.
1016
+ }
1017
+ }
1018
+ if (!owner) owner = process.env.USER || '(unknown)';
1019
+
1020
+ const why = (opts.why || '').trim();
1021
+
1022
+ // Allocate next id, render the file from template, write it.
1023
+ const initLib = require('../core/ecosystem/lib/initiative');
1024
+ const id = initLib.nextInitiativeId(root);
1025
+ const filePath = initLib.initiativePath(root, id, slug);
1026
+
1027
+ const templateSrc = path.join(
1028
+ __dirname,
1029
+ '..',
1030
+ 'core',
1031
+ 'ecosystem',
1032
+ 'templates',
1033
+ 'initiative-template.md',
1034
+ );
1035
+ const rawTemplate = fs.readFileSync(templateSrc, 'utf8');
1036
+
1037
+ // Discard the template's stub frontmatter; we generate our own. Keep
1038
+ // the body (## Why, ## Per-repo contributions, etc).
1039
+ const { content: bodyTemplate } = initLib.parseFrontmatter(rawTemplate);
1040
+
1041
+ // Substitute the few placeholders the template body carries.
1042
+ const title = slugToTitle(slug);
1043
+ let body = bodyTemplate
1044
+ .replace(/\{\{ID\}\}/g, String(id))
1045
+ .replace(/\{\{TITLE\}\}/g, title)
1046
+ .replace(/\{\{REPOS\}\}/g, reposList.join(', '))
1047
+ .replace(/\{\{STARTED\}\}/g, today())
1048
+ .replace(/\{\{OWNER\}\}/g, owner);
1049
+
1050
+ // Inject the user-provided "Why" if any (replacing the placeholder
1051
+ // paragraph in the template). The template's "Why" section reads
1052
+ // "One short paragraph that captures the motivation…" — when the
1053
+ // user passes --why we replace that placeholder; otherwise we leave
1054
+ // the template's prompt in place so they know what to fill in later.
1055
+ if (why) {
1056
+ body = body.replace(
1057
+ /(## Why\n\n)One short paragraph that captures the motivation for this initiative\.\nWhat problem are we solving\? Why does it span multiple repos\? What\nbecomes possible once it ships\?/,
1058
+ `$1${why}`,
1059
+ );
1060
+ }
1061
+
1062
+ // Expand the per-repo contributions stubs.
1063
+ body = body.replace(
1064
+ /- \*\*\{\{REPO_1\}\}\*\*: …\n- \*\*\{\{REPO_2\}\}\*\*: …/,
1065
+ reposList.map((r) => `- **${r}**: …`).join('\n'),
1066
+ );
1067
+
1068
+ const frontmatter = {
1069
+ id,
1070
+ slug,
1071
+ title,
1072
+ status: 'in-progress',
1073
+ started: today(),
1074
+ owner,
1075
+ repos: reposList,
1076
+ };
1077
+
1078
+ initLib.writeInitiative(filePath, frontmatter, body);
1079
+ initLib.setActive(root, slug);
1080
+
1081
+ console.log(`Created ${path.relative(root, filePath)} (id ${id}).`);
1082
+ console.log(`Set as active initiative.`);
1083
+ if (!why) {
1084
+ console.log(
1085
+ `Note: --why was not provided — the template "Why" section is a ` +
1086
+ `placeholder. Edit ${path.relative(root, filePath)} to fill it in.`,
1087
+ );
1088
+ }
1089
+ }
1090
+
1091
+ function slugToTitle(slug) {
1092
+ return slug
1093
+ .split('-')
1094
+ .map((s) => (s.length ? s[0].toUpperCase() + s.slice(1) : s))
1095
+ .join(' ');
1096
+ }
1097
+
1098
+ // ─────────────────────────────────────────────────────────────────────────────
1099
+ // Helpers
1100
+ // ─────────────────────────────────────────────────────────────────────────────
1101
+ //
1102
+ // findPrimaryInstructionFile / ensurePointerInjected / stripPointer moved
1103
+ // to core/ecosystem/lib/pointer.js in Phase 10 Group 0 for reuse by
1104
+ // `momentum join` and `momentum leave`. Imported above.
1105
+
1106
+ /**
1107
+ * ENH-021 — resolve the ecosystem root from any directory.
1108
+ *
1109
+ * Resolution order:
1110
+ * 1. explicit `--ecosystem <path>` (highest precedence)
1111
+ * 2. ecosystem.json in CWD
1112
+ * 3. walk up via findRoot() bounded by MOMENTUM_MAX_PARENT_WALK
1113
+ * 4. error with a remediation message naming the subcommand
1114
+ */
1115
+ function resolveEcosystemRoot(explicitPath, subcommand) {
1116
+ if (explicitPath) {
1117
+ const abs = path.resolve(process.cwd(), explicitPath);
1118
+ if (!fs.existsSync(path.join(abs, lib.MANIFEST_FILENAME))) {
1119
+ throw new Error(
1120
+ `${subcommand}: --ecosystem ${explicitPath} → ${abs} has no ecosystem.json. ` +
1121
+ `Did you mean a different path?`,
1122
+ );
1123
+ }
1124
+ return abs;
1125
+ }
1126
+ // First try parent walk-up — succeeds when CWD is inside the ecosystem root.
1127
+ const found = lib.findRoot(process.cwd());
1128
+ if (found) return found;
1129
+ // Then try sibling walk — succeeds when CWD is inside a member repo
1130
+ // whose ecosystem root is a sibling directory. This mirrors the
1131
+ // session-append.sh hook's discovery pattern and is what makes
1132
+ // ENH-021 actually feel location-agnostic from inside a member repo.
1133
+ const stateLib = require('../core/ecosystem/lib/state');
1134
+ const reg = stateLib.findRegistration(process.cwd());
1135
+ if (reg) return reg.rootPath;
1136
+ throw new Error(
1137
+ `${subcommand}: no ecosystem.json found in this or any parent directory, ` +
1138
+ `nor in any reachable sibling. Pass --ecosystem <path>, or cd to an ` +
1139
+ `ecosystem root before running.`,
1140
+ );
1141
+ }
1142
+
1143
+ function printGitState(repoPath) {
1144
+ // Suppress stderr from git so "fatal: not a git repository" doesn't
1145
+ // leak through to the user when the member isn't yet under git.
1146
+ const gitOpts = { cwd: repoPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] };
1147
+ let gitOk = false;
1148
+ try {
1149
+ const status = execSync('git status --short', gitOpts).trim();
1150
+ gitOk = true;
1151
+ if (status) {
1152
+ console.log(' git: ' + status.split('\n').slice(0, 5).map((l) => l.trim()).join(' | '));
1153
+ } else {
1154
+ console.log(' git: clean');
1155
+ }
1156
+ } catch (_e) {
1157
+ console.log(' git: (not a git repo)');
1158
+ }
1159
+ if (!gitOk) return;
1160
+ try {
1161
+ const log = execSync('git log -1 --pretty=format:"%h %s"', gitOpts).trim();
1162
+ if (log) console.log(` last: ${log}`);
1163
+ } catch (_e) {
1164
+ // ignore — no commits yet
1165
+ }
1166
+ }
1167
+
1168
+ function parseFlags(args, spec) {
1169
+ const out = {};
1170
+ for (let i = 0; i < args.length; i++) {
1171
+ const a = args[i];
1172
+ if (!a.startsWith('--')) continue;
1173
+ const key = a.slice(2);
1174
+ if (spec[key] === 'boolean') {
1175
+ out[key] = true;
1176
+ } else if (spec[key] === 'string') {
1177
+ out[key] = args[i + 1];
1178
+ i++;
1179
+ }
1180
+ }
1181
+ return out;
1182
+ }
1183
+
1184
+ function sanitizeId(s) {
1185
+ return s
1186
+ .toLowerCase()
1187
+ .replace(/[^a-z0-9-]+/g, '-')
1188
+ .replace(/^-+|-+$/g, '');
1189
+ }
1190
+
1191
+ function today() {
1192
+ const d = new Date();
1193
+ return d.toISOString().slice(0, 10);
1194
+ }
1195
+
1196
+ // ─────────────────────────────────────────────────────────────────────────────
1197
+ // Templates
1198
+ // ─────────────────────────────────────────────────────────────────────────────
1199
+
1200
+ function readmeTemplate(name) {
1201
+ return `# ${name} — Ecosystem
1202
+
1203
+ > Coordination layer for the ${name} family of repos.
1204
+ > Created by \`momentum ecosystem init\`.
1205
+
1206
+ ## What lives here
1207
+
1208
+ | Path | Purpose |
1209
+ |---|---|
1210
+ | \`ecosystem.json\` | Manifest: members, roles, dependency edges. |
1211
+ | \`initiatives/\` | Cross-repo feature files (\`NNNN-slug.md\`). |
1212
+ | \`sessions/\` | Daily activity log (\`YYYY-MM-DD.md\`), auto-appended by member-repo hooks. |
1213
+ | \`.state/\` | Runtime state (gitignored). |
1214
+
1215
+ ## Quick commands
1216
+
1217
+ \`\`\`
1218
+ momentum ecosystem status # show members + git state
1219
+ momentum ecosystem add ../<repo> # register a member
1220
+ momentum ecosystem remove <id> # unregister a member
1221
+ \`\`\`
1222
+
1223
+ ## Members
1224
+
1225
+ Run \`momentum ecosystem status\` to see the current registered members.
1226
+
1227
+ ## Conventions
1228
+
1229
+ - The ecosystem layer is strictly additive. It never writes into a
1230
+ member's \`specs/\`. The only touch on a member repo is one fenced
1231
+ line in its \`CLAUDE.md\` / \`AGENTS.md\` pointing back here.
1232
+ - Add members via \`momentum ecosystem add\`. Don't hand-edit
1233
+ \`ecosystem.json\` unless you know what you're doing — validation runs
1234
+ on every CLI write.
1235
+ `;
1236
+ }
1237
+
1238
+ function initiativesReadme() {
1239
+ return `# Initiatives
1240
+
1241
+ > Cross-repo features that span multiple member repos.
1242
+
1243
+ Each initiative is one markdown file named \`NNNN-<slug>.md\` with YAML
1244
+ frontmatter (id, slug, status, started, owner, repos) and a body of
1245
+ fixed sections: Why · Per-repo contributions · Linked decisions ·
1246
+ Deploy chronology · Close.
1247
+
1248
+ Numbering is monotonically increasing across the ecosystem. The
1249
+ filename slug is for human readability; the \`id\` integer is canonical.
1250
+
1251
+ ## Create a new initiative
1252
+
1253
+ \`\`\`
1254
+ momentum ecosystem initiative create <slug> \\
1255
+ --why "<one-paragraph motivation>" \\
1256
+ --repos <member-id-1>,<member-id-2> \\
1257
+ --owner <you>
1258
+ \`\`\`
1259
+
1260
+ Flags are optional:
1261
+ - \`--why\` — defaults to a placeholder you can fill in later
1262
+ - \`--repos\` — defaults to all registered members
1263
+ - \`--owner\` — defaults to \`git config user.name\` (or \`\$USER\`)
1264
+
1265
+ The slash-command door \`/initiative create <slug>\` does the same
1266
+ work via the same code path; both end up calling
1267
+ \`core/ecosystem/lib/initiative.js\`.
1268
+ `;
1269
+ }
1270
+
1271
+ function gitignoreTemplate() {
1272
+ return `# Ecosystem runtime state
1273
+ .state/
1274
+
1275
+ # Standard noise
1276
+ .DS_Store
1277
+ ._*
1278
+ *.swp
1279
+ Thumbs.db
1280
+ `;
1281
+ }
1282
+
1283
+ // ─────────────────────────────────────────────────────────────────────────────
1284
+ // Exports
1285
+ // ─────────────────────────────────────────────────────────────────────────────
1286
+
1287
+ module.exports = {
1288
+ runEcosystem,
1289
+ // Exported for tests:
1290
+ cmdInit,
1291
+ cmdAdd,
1292
+ cmdRemove,
1293
+ cmdStatus,
1294
+ cmdInitiative,
1295
+ cmdInitiativeCreate,
1296
+ ensurePointerInjected,
1297
+ stripPointer,
1298
+ findPrimaryInstructionFile,
1299
+ sanitizeId,
1300
+ POINTER_BEGIN,
1301
+ POINTER_END,
1302
+ };