@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
package/bin/swarm.js ADDED
@@ -0,0 +1,1104 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `momentum swarm` subcommand dispatcher.
5
+ *
6
+ * Subcommands (v0.20.0):
7
+ * start <slug> --initiative <slug> --repos r1,r2,... --phase <phase-slug> [--mode autopilot|checkpoint]
8
+ * status <swarm-id> — print board.json as JSON or rendered table
9
+ * tell <swarm-id> <repo> "<text>"
10
+ * broadcast <swarm-id> "<text>"
11
+ * verify <swarm-id> — contract verifier + drift check
12
+ * complete <swarm-id> — synthesize retrospective + changes/<id>.md
13
+ * resume <swarm-id> — reattach this session to a swarm
14
+ * cancel <swarm-id> — graceful halt
15
+ * budget <swarm-id> <repo> +N
16
+ *
17
+ * Lower-level CLI floor for the three doors: slash recipes wrap this,
18
+ * NL inference wraps this, direct CLI is this. All three produce the
19
+ * same on-disk artifacts.
20
+ */
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const crypto = require('crypto');
25
+
26
+ const MOMENTUM_ROOT = path.resolve(__dirname, '..');
27
+ const conductor = require(path.join(MOMENTUM_ROOT, 'core', 'swarm', 'conductor'));
28
+ const manifestLib = require(path.join(MOMENTUM_ROOT, 'core', 'swarm', 'lib', 'manifest'));
29
+ const boardLib = require(path.join(MOMENTUM_ROOT, 'core', 'swarm', 'lib', 'board'));
30
+ const briefLib = require(path.join(MOMENTUM_ROOT, 'core', 'swarm', 'lib', 'brief'));
31
+ const inboxLib = require(path.join(MOMENTUM_ROOT, 'core', 'swarm', 'inbox'));
32
+ const signalsLib = require(path.join(MOMENTUM_ROOT, 'core', 'swarm', 'signals'));
33
+ const focusLib = require(path.join(MOMENTUM_ROOT, 'core', 'swarm', 'focus'));
34
+ const joinLib = require(path.join(MOMENTUM_ROOT, 'core', 'swarm', 'join'));
35
+ const absorbLib = require(path.join(MOMENTUM_ROOT, 'core', 'swarm', 'absorb'));
36
+ const preMergeLib = require(path.join(MOMENTUM_ROOT, 'core', 'swarm', 'lib', 'pre-merge'));
37
+ const ecosystemLib = require(path.join(MOMENTUM_ROOT, 'core', 'ecosystem', 'lib', 'index'));
38
+ const { findRegistration } = require(path.join(MOMENTUM_ROOT, 'core', 'ecosystem', 'lib', 'state'));
39
+
40
+ // ─────────────────────────────────────────────────────────────────────────────
41
+ // Argv parsing
42
+ // ─────────────────────────────────────────────────────────────────────────────
43
+
44
+ function parseFlags(args, defs) {
45
+ const out = { positional: [] };
46
+ for (const key of Object.keys(defs)) out[key] = defs[key].default;
47
+ let i = 0;
48
+ while (i < args.length) {
49
+ const a = args[i];
50
+ let consumed = false;
51
+ for (const key of Object.keys(defs)) {
52
+ const def = defs[key];
53
+ if (a === def.flag || (def.aliases && def.aliases.includes(a))) {
54
+ if (def.type === 'bool') {
55
+ out[key] = true;
56
+ i += 1;
57
+ } else if (def.type === 'list') {
58
+ out[key] = (args[i + 1] || '').split(',').map((s) => s.trim()).filter(Boolean);
59
+ i += 2;
60
+ } else {
61
+ out[key] = args[i + 1];
62
+ i += 2;
63
+ }
64
+ consumed = true;
65
+ break;
66
+ }
67
+ }
68
+ if (!consumed) {
69
+ out.positional.push(a);
70
+ i += 1;
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+
76
+ // ─────────────────────────────────────────────────────────────────────────────
77
+ // Ecosystem resolution
78
+ // ─────────────────────────────────────────────────────────────────────────────
79
+
80
+ function resolveEcosystemRoot(explicit) {
81
+ if (explicit && fs.existsSync(path.join(explicit, 'ecosystem.json'))) {
82
+ return path.resolve(explicit);
83
+ }
84
+ const fromCwd = ecosystemLib.findRoot(process.cwd());
85
+ if (fromCwd) return fromCwd;
86
+ const reg = findRegistration(process.cwd());
87
+ if (reg && reg.rootPath) return reg.rootPath;
88
+ throw new Error(
89
+ 'swarm: cannot locate ecosystem root. Run from inside an ecosystem ' +
90
+ 'or pass --ecosystem <path>.'
91
+ );
92
+ }
93
+
94
+ function nowIso() {
95
+ return new Date().toISOString().replace(/\.\d+Z$/, 'Z');
96
+ }
97
+
98
+ function generateSessionId() {
99
+ return 'sess_' + crypto.randomBytes(8).toString('hex');
100
+ }
101
+
102
+ /**
103
+ * Resolve the calling session id. Order:
104
+ * 1. explicit --session flag
105
+ * 2. MOMENTUM_SESSION_ID env (set by conductor recipe)
106
+ * 3. generate a fresh one
107
+ */
108
+ function resolveSessionId(explicit) {
109
+ if (explicit) return explicit;
110
+ if (process.env.MOMENTUM_SESSION_ID) return process.env.MOMENTUM_SESSION_ID;
111
+ return generateSessionId();
112
+ }
113
+
114
+ /**
115
+ * Add `hours` to an ISO timestamp (returns ISO).
116
+ */
117
+ function plusHours(iso, hours) {
118
+ const ms = Date.parse(iso) + Number(hours) * 60 * 60 * 1000;
119
+ return new Date(ms).toISOString().replace(/\.\d+Z$/, 'Z');
120
+ }
121
+
122
+ /**
123
+ * Slugify a session id for use in signal slugs (lowercase, kebab-safe).
124
+ */
125
+ function sessionSlug(sessionId) {
126
+ return String(sessionId).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32) || 'unknown';
127
+ }
128
+
129
+ // ─────────────────────────────────────────────────────────────────────────────
130
+ // Subcommands
131
+ // ─────────────────────────────────────────────────────────────────────────────
132
+
133
+ function cmdStart(args) {
134
+ const opts = parseFlags(args, {
135
+ initiative: { flag: '--initiative' },
136
+ repos: { flag: '--repos', type: 'list', default: [] },
137
+ phase: { flag: '--phase' },
138
+ mode: { flag: '--mode', default: 'checkpoint' },
139
+ sessionId: { flag: '--session', default: null },
140
+ ecosystem: { flag: '--ecosystem', default: null },
141
+ spawn: { flag: '--spawn', type: 'bool', default: false },
142
+ json: { flag: '--json', type: 'bool', default: false },
143
+ });
144
+ const [slug] = opts.positional;
145
+ if (!slug) throw new Error('swarm start: <slug> required');
146
+ if (!opts.initiative) throw new Error('swarm start: --initiative <slug> required');
147
+ if (!opts.repos || opts.repos.length === 0) throw new Error('swarm start: --repos r1,r2,... required');
148
+ if (!opts.phase) throw new Error('swarm start: --phase <phase-slug> required');
149
+
150
+ const ecosystemRoot = resolveEcosystemRoot(opts.ecosystem);
151
+ const swarmId = manifestLib.nextSwarmId(ecosystemRoot, slug);
152
+ const sessionId = opts.sessionId || generateSessionId();
153
+ const ts = nowIso();
154
+
155
+ const manifest = conductor.planSwarm({
156
+ ecosystemRoot,
157
+ swarmId,
158
+ initiative: opts.initiative,
159
+ impactedRepos: opts.repos,
160
+ phaseSlug: opts.phase,
161
+ sessionId,
162
+ mode: opts.mode,
163
+ nowIso: ts,
164
+ });
165
+ manifestLib.writeManifest(ecosystemRoot, swarmId, manifest);
166
+ boardLib.refreshBoard(ecosystemRoot, swarmId, ts);
167
+
168
+ // Build (and optionally execute) Wave 1 spawn directives. Default is
169
+ // dry-run — the platform layer or the conductor recipe handles the
170
+ // actual spawn so the CLI floor stays platform-agnostic.
171
+ const directives = conductor.buildSpawnDirectives({
172
+ ecosystemRoot, swarmId, waveIndex: 1, platform: 'claude-code',
173
+ });
174
+
175
+ let spawnResults = null;
176
+ if (opts.spawn) {
177
+ spawnResults = spawnSupervisors(directives);
178
+ }
179
+
180
+ if (opts.json) {
181
+ process.stdout.write(JSON.stringify({
182
+ swarmId, sessionId, manifestPath: manifestLib.manifestPath(ecosystemRoot, swarmId),
183
+ directives, spawned: spawnResults,
184
+ }, null, 2) + '\n');
185
+ return;
186
+ }
187
+
188
+ console.log(`▸ Swarm started: ${swarmId}`);
189
+ console.log(` Initiative: ${opts.initiative}`);
190
+ console.log(` Mode: ${opts.mode}`);
191
+ console.log(` Session: ${sessionId}`);
192
+ console.log(` Waves: ${manifest.waves.length}`);
193
+ for (const w of manifest.waves) {
194
+ console.log(` Wave ${w.index}: ${w.repos.join(', ')}`);
195
+ }
196
+ console.log('');
197
+ console.log(`▸ Wave 1 spawn directives (${directives.length}):`);
198
+ for (const d of directives) {
199
+ console.log(` - ${d.repoId}: ${d.repoPath}`);
200
+ }
201
+ if (opts.spawn && spawnResults) {
202
+ console.log('');
203
+ console.log('▸ Spawn results:');
204
+ for (const r of spawnResults) {
205
+ console.log(` - ${r.repoId}: ${r.status === 0 ? 'ok' : 'failed'}`);
206
+ }
207
+ } else {
208
+ console.log('');
209
+ console.log(' (dry-run — pass --spawn to launch Claude Code supervisors)');
210
+ }
211
+ }
212
+
213
+ function cmdStatus(args) {
214
+ const opts = parseFlags(args, {
215
+ ecosystem: { flag: '--ecosystem', default: null },
216
+ json: { flag: '--json', type: 'bool', default: false },
217
+ });
218
+ const [swarmId] = opts.positional;
219
+ if (!swarmId) throw new Error('swarm status: <swarm-id> required');
220
+ const ecosystemRoot = resolveEcosystemRoot(opts.ecosystem);
221
+ const board = boardLib.loadBoard(ecosystemRoot, swarmId);
222
+ if (!board) throw new Error(`swarm status: no board for ${swarmId}`);
223
+ if (opts.json) {
224
+ process.stdout.write(JSON.stringify(board, null, 2) + '\n');
225
+ return;
226
+ }
227
+ renderBoardTable(board);
228
+ }
229
+
230
+ function cmdTell(args) {
231
+ const opts = parseFlags(args, {
232
+ ecosystem: { flag: '--ecosystem', default: null },
233
+ });
234
+ const [swarmId, repoId, ...rest] = opts.positional;
235
+ const text = rest.join(' ').trim();
236
+ if (!swarmId || !repoId || !text) {
237
+ throw new Error('swarm tell: usage — momentum swarm tell <swarm-id> <repo> "<text>"');
238
+ }
239
+ const ecosystemRoot = resolveEcosystemRoot(opts.ecosystem);
240
+ const manifest = manifestLib.loadManifest(ecosystemRoot, swarmId);
241
+ if (!manifest) throw new Error(`swarm tell: no manifest for ${swarmId}`);
242
+ const repo = manifest.repos[repoId];
243
+ if (!repo) throw new Error(`swarm tell: ${repoId} is not a member of swarm ${swarmId}`);
244
+
245
+ const repoPath = conductor.resolveMemberPath(ecosystemRoot, repoId);
246
+ if (!repoPath) throw new Error(`swarm tell: cannot locate repo ${repoId}`);
247
+ const contextPath = path.join(repoPath, 'specs', 'phases', repo.phase_slug, 'swarm-context.md');
248
+ fs.mkdirSync(path.dirname(contextPath), { recursive: true });
249
+ const ts = nowIso();
250
+ const block = `\n\n## tell @ ${ts}\n\n${text}\n`;
251
+ fs.appendFileSync(contextPath, block, 'utf8');
252
+
253
+ manifestLib.appendAudit(ecosystemRoot, swarmId, {
254
+ ts, actor: 'conductor', event: 'tell', repo: repoId, detail: text.slice(0, 200),
255
+ });
256
+ boardLib.refreshBoard(ecosystemRoot, swarmId, ts);
257
+ console.log(`▸ tell delivered to ${repoId}: ${path.relative(ecosystemRoot, contextPath)}`);
258
+ }
259
+
260
+ function cmdBroadcast(args) {
261
+ const opts = parseFlags(args, {
262
+ ecosystem: { flag: '--ecosystem', default: null },
263
+ });
264
+ const [swarmId, ...rest] = opts.positional;
265
+ const text = rest.join(' ').trim();
266
+ if (!swarmId || !text) {
267
+ throw new Error('swarm broadcast: usage — momentum swarm broadcast <swarm-id> "<text>"');
268
+ }
269
+ const ecosystemRoot = resolveEcosystemRoot(opts.ecosystem);
270
+ const manifest = manifestLib.loadManifest(ecosystemRoot, swarmId);
271
+ if (!manifest) throw new Error(`swarm broadcast: no manifest for ${swarmId}`);
272
+ const ts = nowIso();
273
+ for (const [repoId, repo] of Object.entries(manifest.repos)) {
274
+ const repoPath = conductor.resolveMemberPath(ecosystemRoot, repoId);
275
+ if (!repoPath) continue;
276
+ const contextPath = path.join(repoPath, 'specs', 'phases', repo.phase_slug, 'swarm-context.md');
277
+ fs.mkdirSync(path.dirname(contextPath), { recursive: true });
278
+ const block = `\n\n## broadcast @ ${ts}\n\n${text}\n`;
279
+ fs.appendFileSync(contextPath, block, 'utf8');
280
+ }
281
+ manifestLib.appendAudit(ecosystemRoot, swarmId, {
282
+ ts, actor: 'conductor', event: 'broadcast', detail: text.slice(0, 200),
283
+ });
284
+ boardLib.refreshBoard(ecosystemRoot, swarmId, ts);
285
+ console.log(`▸ broadcast sent to ${Object.keys(manifest.repos).length} repos`);
286
+ }
287
+
288
+ function cmdVerify(args) {
289
+ const opts = parseFlags(args, {
290
+ ecosystem: { flag: '--ecosystem', default: null },
291
+ json: { flag: '--json', type: 'bool', default: false },
292
+ });
293
+ const [swarmId] = opts.positional;
294
+ if (!swarmId) throw new Error('swarm verify: <swarm-id> required');
295
+ const ecosystemRoot = resolveEcosystemRoot(opts.ecosystem);
296
+ const result = verifySwarm(ecosystemRoot, swarmId);
297
+ if (opts.json) {
298
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
299
+ } else {
300
+ console.log(`▸ Verify ${swarmId}: ${result.ok ? 'OK' : 'FAIL'}`);
301
+ for (const issue of result.issues) {
302
+ console.log(` ✗ ${issue}`);
303
+ }
304
+ for (const note of result.notes) {
305
+ console.log(` · ${note}`);
306
+ }
307
+ }
308
+ if (!result.ok) process.exit(1);
309
+ }
310
+
311
+ function cmdComplete(args) {
312
+ const opts = parseFlags(args, {
313
+ ecosystem: { flag: '--ecosystem', default: null },
314
+ });
315
+ const [swarmId] = opts.positional;
316
+ if (!swarmId) throw new Error('swarm complete: <swarm-id> required');
317
+ const ecosystemRoot = resolveEcosystemRoot(opts.ecosystem);
318
+ const ts = nowIso();
319
+ const result = completeSwarm(ecosystemRoot, swarmId, ts);
320
+ console.log(`▸ Swarm ${swarmId} complete`);
321
+ console.log(` Changeset: ${result.changesetPath}`);
322
+ console.log(` Repos done: ${result.reposComplete}/${result.reposTotal}`);
323
+ }
324
+
325
+ function cmdResume(args) {
326
+ const opts = parseFlags(args, {
327
+ ecosystem: { flag: '--ecosystem', default: null },
328
+ sessionId: { flag: '--session', default: null },
329
+ json: { flag: '--json', type: 'bool', default: false },
330
+ });
331
+ const [swarmId] = opts.positional;
332
+ if (!swarmId) throw new Error('swarm resume: <swarm-id> required');
333
+ const ecosystemRoot = resolveEcosystemRoot(opts.ecosystem);
334
+ const sessionId = opts.sessionId || generateSessionId();
335
+ const ts = nowIso();
336
+ const manifest = conductor.resumeSwarm(ecosystemRoot, swarmId, sessionId, ts);
337
+
338
+ // Disk-only reconstitution: pull the current materialized board so
339
+ // the recipe can render it without recomputing.
340
+ const board = boardLib.loadBoard(ecosystemRoot, swarmId);
341
+
342
+ // For every owned repo, refresh the lease so audit shows we are live.
343
+ conductor.renewLeases(ecosystemRoot, swarmId, sessionId, ts);
344
+
345
+ if (opts.json) {
346
+ process.stdout.write(JSON.stringify({
347
+ swarmId, sessionId, status: manifest.status, board,
348
+ }, null, 2) + '\n');
349
+ return;
350
+ }
351
+ console.log(`▸ Resumed swarm ${swarmId} as session ${sessionId}`);
352
+ console.log(` Status: ${manifest.status}`);
353
+ for (const w of manifest.waves) {
354
+ console.log(` Wave ${w.index}: ${w.status || 'queued'} — ${w.repos.join(', ')}`);
355
+ }
356
+ if (board && board.inbox_count > 0) {
357
+ console.log(` ▸ ${board.inbox_count} pending inbox item(s) — surface with /swarm inbox list ${swarmId}`);
358
+ }
359
+ }
360
+
361
+ function cmdCancel(args) {
362
+ const opts = parseFlags(args, {
363
+ ecosystem: { flag: '--ecosystem', default: null },
364
+ reason: { flag: '--reason', default: 'cancelled by user' },
365
+ });
366
+ const [swarmId] = opts.positional;
367
+ if (!swarmId) throw new Error('swarm cancel: <swarm-id> required');
368
+ const ecosystemRoot = resolveEcosystemRoot(opts.ecosystem);
369
+ const ts = nowIso();
370
+ conductor.cancelSwarm(ecosystemRoot, swarmId, opts.reason, ts);
371
+ console.log(`▸ Swarm ${swarmId} cancelled (${opts.reason})`);
372
+ }
373
+
374
+ // ─────────────────────────────────────────────────────────────────────────────
375
+ // Phase 17.5 — claim / release
376
+ // ─────────────────────────────────────────────────────────────────────────────
377
+
378
+ function cmdClaim(args) {
379
+ const opts = parseFlags(args, {
380
+ sessionId: { flag: '--session', default: null },
381
+ ecosystem: { flag: '--ecosystem', default: null },
382
+ leaseHours: { flag: '--lease-hours', default: '24' },
383
+ json: { flag: '--json', type: 'bool', default: false },
384
+ });
385
+ const [swarmId, repoId] = opts.positional;
386
+ if (!swarmId || !repoId) {
387
+ throw new Error('swarm claim: usage — momentum swarm claim <swarm-id> <repo> [--session <id>]');
388
+ }
389
+ const ecosystemRoot = resolveEcosystemRoot(opts.ecosystem);
390
+ const sessionId = resolveSessionId(opts.sessionId);
391
+ const ts = nowIso();
392
+ const expiresAt = plusHours(ts, opts.leaseHours);
393
+ try {
394
+ manifestLib.updateManifestAsOwner({
395
+ ecosystemRoot, swarmId, sessionId, repo: repoId, nowIso: ts,
396
+ mutate: (m, decision) => {
397
+ const previousOwner = m.repos[repoId].owner;
398
+ m.repos[repoId].owner = sessionId;
399
+ m.repos[repoId].claimed_by_session = sessionId;
400
+ m.repos[repoId].lease_renewed_at = ts;
401
+ m.repos[repoId].lease_expires_at = expiresAt;
402
+ if (!Array.isArray(m.audit)) m.audit = [];
403
+ m.audit.push({
404
+ ts, actor: sessionId, event: 'claim', repo: repoId,
405
+ detail: `${previousOwner} → ${sessionId}${decision.expired ? ' (after lease expiry)' : ''}`,
406
+ });
407
+ if (decision.expired) {
408
+ m.audit.push({
409
+ ts, actor: sessionId, event: 'lease-takeover', repo: repoId,
410
+ detail: `previous owner ${previousOwner} lease expired`,
411
+ });
412
+ }
413
+ },
414
+ });
415
+ boardLib.refreshBoard(ecosystemRoot, swarmId, ts);
416
+ if (opts.json) {
417
+ process.stdout.write(JSON.stringify({
418
+ swarmId, repo: repoId, owner: sessionId, lease_expires_at: expiresAt,
419
+ }, null, 2) + '\n');
420
+ } else {
421
+ console.log(`▸ claim ${swarmId}/${repoId} → ${sessionId}`);
422
+ console.log(` lease until ${expiresAt}`);
423
+ }
424
+ } catch (err) {
425
+ if (err.code === 'EOWNERSHIP') {
426
+ // Write a claim-request signal so the existing owner sees it next poll.
427
+ const sig = signalsLib.writeSignal({
428
+ ecosystemRoot, swarmId,
429
+ type: 'claim-request', slug: `${repoId}-by-${sessionSlug(sessionId)}`,
430
+ fromSession: sessionId, repo: repoId,
431
+ nowIso: ts, detail: `claim rejected: ${err.decision.reason}`,
432
+ });
433
+ console.error(`✗ claim ${swarmId}/${repoId} rejected: ${err.decision.reason}`);
434
+ console.error(` claim-request signal written (${sig.signal_id}); owner sees it next poll`);
435
+ process.exit(1);
436
+ }
437
+ throw err;
438
+ }
439
+ }
440
+
441
+ function cmdAbsorb(args) {
442
+ const opts = parseFlags(args, {
443
+ sessionId: { flag: '--session', default: null },
444
+ ecosystem: { flag: '--ecosystem', default: null },
445
+ yes: { flag: '--yes', type: 'bool', default: false, aliases: ['-y'] },
446
+ json: { flag: '--json', type: 'bool', default: false },
447
+ });
448
+ const [targetSwarmId, sourceSwarmId] = opts.positional;
449
+ if (!targetSwarmId || !sourceSwarmId) {
450
+ throw new Error('swarm absorb: usage — momentum swarm absorb <target-swarm-id> <source-swarm-id> [--session <id>] [--yes]');
451
+ }
452
+ const ecosystemRoot = resolveEcosystemRoot(opts.ecosystem);
453
+ const sessionId = resolveSessionId(opts.sessionId);
454
+ const ts = nowIso();
455
+
456
+ // Confirmation prompt (skipped with --yes)
457
+ if (!opts.yes && !opts.json) {
458
+ const target = manifestLib.loadManifest(ecosystemRoot, targetSwarmId);
459
+ const source = manifestLib.loadManifest(ecosystemRoot, sourceSwarmId);
460
+ if (!target || !source) {
461
+ // Let absorb() surface the canonical error
462
+ } else {
463
+ const conflicts = absorbLib.detectContractConflicts(source.contracts, target.contracts);
464
+ const overlap = Object.keys(source.repos).filter((id) => id in target.repos);
465
+ const newRepos = Object.keys(source.repos).filter((id) => !(id in target.repos));
466
+ console.log(`▸ absorb plan: ${sourceSwarmId} → ${targetSwarmId}`);
467
+ console.log(` Repos to add: ${newRepos.length ? newRepos.join(', ') : '(none)'}`);
468
+ console.log(` Repos overlap: ${overlap.length ? overlap.join(', ') : '(none)'}`);
469
+ console.log(` Contracts diff: ${conflicts.length ? conflicts.length + ' conflict(s)' : 'clean'}`);
470
+ if (conflicts.length) {
471
+ for (const c of conflicts) {
472
+ console.log(` ✗ ${c.surface} — ${c.kind}`);
473
+ }
474
+ console.error('');
475
+ console.error('Aborting — resolve contract conflicts first (bump versions on both sides, or use --yes to force).');
476
+ process.exit(1);
477
+ }
478
+ console.log('');
479
+ console.log(' Pass --yes to skip this confirmation. Re-run with --yes to proceed.');
480
+ process.exit(0);
481
+ }
482
+ }
483
+
484
+ try {
485
+ const r = absorbLib.absorb({
486
+ ecosystemRoot, targetSwarmId, sourceSwarmId, sessionId, nowIso: ts,
487
+ });
488
+ if (opts.json) {
489
+ process.stdout.write(JSON.stringify(r, null, 2) + '\n');
490
+ return;
491
+ }
492
+ console.log(`▸ absorbed ${r.absorbed} → ${r.into}`);
493
+ console.log(` Repos added: ${r.reposAdded.length ? r.reposAdded.join(', ') : '(none)'}`);
494
+ console.log(` Repos overlap: ${r.reposOverlapped.length ? r.reposOverlapped.join(', ') : '(none)'}`);
495
+ console.log(` Inbox moved: ${r.inboxMoved}`);
496
+ console.log(` Archived to: ${path.relative(ecosystemRoot, r.archivedTo)}`);
497
+ } catch (err) {
498
+ if (err.code === 'ECONTRACT') {
499
+ console.error(`✗ absorb ${sourceSwarmId} → ${targetSwarmId}: contract conflict on ${err.conflicts.length} surface(s)`);
500
+ for (const c of err.conflicts) {
501
+ console.error(` ${c.surface} — ${c.kind}`);
502
+ console.error(` source: ${JSON.stringify(c.source)}`);
503
+ console.error(` target: ${JSON.stringify(c.target)}`);
504
+ }
505
+ console.error('');
506
+ console.error('Both swarms untouched. Resolve contract divergence then retry.');
507
+ process.exit(1);
508
+ }
509
+ throw err;
510
+ }
511
+ }
512
+
513
+ function cmdJoin(args) {
514
+ const opts = parseFlags(args, {
515
+ sessionId: { flag: '--session', default: null },
516
+ ecosystem: { flag: '--ecosystem', default: null },
517
+ token: { flag: '--token', default: null },
518
+ claim: { flag: '--claim', default: null },
519
+ leaseHours: { flag: '--lease-hours', default: '24' },
520
+ json: { flag: '--json', type: 'bool', default: false },
521
+ });
522
+ const [swarmId] = opts.positional;
523
+ if (!swarmId) {
524
+ throw new Error('swarm join: usage — momentum swarm join <swarm-id> [--token <token>] [--claim <repo>] [--session <id>]');
525
+ }
526
+ const ecosystemRoot = resolveEcosystemRoot(opts.ecosystem);
527
+ const sessionId = resolveSessionId(opts.sessionId);
528
+ const ts = nowIso();
529
+ const leaseMs = Number(opts.leaseHours) * 60 * 60 * 1000;
530
+ try {
531
+ const r = joinLib.join({
532
+ ecosystemRoot, swarmId, sessionId, nowIso: ts,
533
+ token: opts.token, claim: opts.claim,
534
+ leaseMs,
535
+ });
536
+ if (opts.json) {
537
+ process.stdout.write(JSON.stringify({
538
+ swarmId: r.swarmId,
539
+ sessionId: r.sessionId,
540
+ sessions: r.sessions,
541
+ claimed: r.claimed,
542
+ token: r.token ? { kind: r.token.kind, target_repo: r.token.target_repo, swarm_id: r.token.swarm_id } : null,
543
+ }, null, 2) + '\n');
544
+ return;
545
+ }
546
+ console.log(`▸ join ${swarmId} as session ${sessionId}`);
547
+ console.log(` Sessions in this swarm: ${r.sessions.length}`);
548
+ if (r.token) {
549
+ console.log(` Token consumed: kind=${r.token.kind}${r.token.target_repo ? ` target=${r.token.target_repo}` : ''}`);
550
+ }
551
+ if (r.claimed) {
552
+ console.log(` Claimed: ${r.claimed.repo} → ${r.claimed.owner} (lease until ${r.claimed.lease_expires_at})`);
553
+ } else {
554
+ console.log(` (registration only — use --claim <repo> to take ownership)`);
555
+ }
556
+ } catch (err) {
557
+ if (err.code === 'EOWNERSHIP') {
558
+ console.error(`✗ join ${swarmId} --claim rejected: ${err.decision.reason}`);
559
+ process.exit(1);
560
+ }
561
+ if (/expired/.test(err.message) || /not found/.test(err.message)) {
562
+ console.error(`✗ join ${swarmId}: ${err.message}`);
563
+ process.exit(1);
564
+ }
565
+ throw err;
566
+ }
567
+ }
568
+
569
+ function cmdFocus(args) {
570
+ const opts = parseFlags(args, {
571
+ sessionId: { flag: '--session', default: null },
572
+ ecosystem: { flag: '--ecosystem', default: null },
573
+ expiresMin: { flag: '--expires-min', default: '60' },
574
+ json: { flag: '--json', type: 'bool', default: false },
575
+ });
576
+ const [swarmId, repoId] = opts.positional;
577
+ if (!swarmId || !repoId) {
578
+ throw new Error('swarm focus: usage — momentum swarm focus <swarm-id> <repo> [--session <id>] [--expires-min 60]');
579
+ }
580
+ const ecosystemRoot = resolveEcosystemRoot(opts.ecosystem);
581
+ const sessionId = resolveSessionId(opts.sessionId);
582
+ const ts = nowIso();
583
+ const expiresInMs = Number(opts.expiresMin) * 60 * 1000;
584
+ try {
585
+ const r = focusLib.focus({
586
+ ecosystemRoot, swarmId, repo: repoId, sessionId, nowIso: ts,
587
+ expiresInMs,
588
+ });
589
+ if (opts.json) {
590
+ process.stdout.write(JSON.stringify({
591
+ swarmId, repo: repoId, token: r.token.token,
592
+ expires_at: r.token.expires_at, signal_id: r.signal.signal_id,
593
+ directive: r.directive,
594
+ }, null, 2) + '\n');
595
+ return;
596
+ }
597
+ console.log(`▸ focus ${swarmId}/${repoId} — token issued`);
598
+ console.log(` Token: ${r.token.token}`);
599
+ console.log(` Expires: ${r.token.expires_at}`);
600
+ console.log(` Owner: → ${'_focusing'} (transitional)`);
601
+ console.log(` Signal: ${r.signal.signal_id}`);
602
+ console.log('');
603
+ console.log(`▸ Spawn directive — run in a second terminal:`);
604
+ console.log(` ${r.directive.command} ${r.directive.args.join(' ')}`);
605
+ console.log(` Then issue inside that session:`);
606
+ console.log(` momentum swarm join ${swarmId} --token ${r.token.token}`);
607
+ } catch (err) {
608
+ if (err.code === 'EOWNERSHIP') {
609
+ console.error(`✗ focus ${swarmId}/${repoId} rejected: ${err.decision.reason}`);
610
+ console.error(` Only the current owner may focus a repo. Try /swarm claim first.`);
611
+ process.exit(1);
612
+ }
613
+ throw err;
614
+ }
615
+ }
616
+
617
+ function cmdRelease(args) {
618
+ const opts = parseFlags(args, {
619
+ sessionId: { flag: '--session', default: null },
620
+ ecosystem: { flag: '--ecosystem', default: null },
621
+ json: { flag: '--json', type: 'bool', default: false },
622
+ });
623
+ const [swarmId, repoId] = opts.positional;
624
+ if (!swarmId || !repoId) {
625
+ throw new Error('swarm release: usage — momentum swarm release <swarm-id> <repo> [--session <id>]');
626
+ }
627
+ const ecosystemRoot = resolveEcosystemRoot(opts.ecosystem);
628
+ const sessionId = resolveSessionId(opts.sessionId);
629
+ const ts = nowIso();
630
+ // Idempotent fast-path: if already UNCLAIMED, no-op
631
+ const manifest = manifestLib.loadManifest(ecosystemRoot, swarmId);
632
+ if (!manifest) throw new Error(`swarm release: no manifest for ${swarmId}`);
633
+ if (!manifest.repos[repoId]) throw new Error(`swarm release: ${repoId} not in swarm ${swarmId}`);
634
+ if (manifest.repos[repoId].owner === manifestLib.UNCLAIMED) {
635
+ if (opts.json) {
636
+ process.stdout.write(JSON.stringify({
637
+ swarmId, repo: repoId, owner: manifestLib.UNCLAIMED, noop: true,
638
+ }, null, 2) + '\n');
639
+ } else {
640
+ console.log(`▸ release ${swarmId}/${repoId}: already unclaimed (no-op)`);
641
+ }
642
+ return;
643
+ }
644
+ try {
645
+ manifestLib.updateManifestAsOwner({
646
+ ecosystemRoot, swarmId, sessionId, repo: repoId, nowIso: ts,
647
+ mutate: (m) => {
648
+ const previousOwner = m.repos[repoId].owner;
649
+ m.repos[repoId].owner = manifestLib.UNCLAIMED;
650
+ // Drop the lease — UNCLAIMED owner means anyone may claim
651
+ delete m.repos[repoId].lease_expires_at;
652
+ delete m.repos[repoId].lease_renewed_at;
653
+ delete m.repos[repoId].claimed_by_session;
654
+ if (!Array.isArray(m.audit)) m.audit = [];
655
+ m.audit.push({
656
+ ts, actor: sessionId, event: 'release', repo: repoId,
657
+ detail: `${previousOwner} → ${manifestLib.UNCLAIMED}`,
658
+ });
659
+ },
660
+ });
661
+ boardLib.refreshBoard(ecosystemRoot, swarmId, ts);
662
+ if (opts.json) {
663
+ process.stdout.write(JSON.stringify({
664
+ swarmId, repo: repoId, owner: manifestLib.UNCLAIMED,
665
+ }, null, 2) + '\n');
666
+ } else {
667
+ console.log(`▸ release ${swarmId}/${repoId} → unclaimed`);
668
+ }
669
+ } catch (err) {
670
+ if (err.code === 'EOWNERSHIP') {
671
+ console.error(`✗ release ${swarmId}/${repoId} rejected: ${err.decision.reason}`);
672
+ console.error(` Only the current owner may release a repo.`);
673
+ process.exit(1);
674
+ }
675
+ throw err;
676
+ }
677
+ }
678
+
679
+ function cmdBudget(args) {
680
+ const opts = parseFlags(args, {
681
+ ecosystem: { flag: '--ecosystem', default: null },
682
+ });
683
+ const [swarmId, repoId, deltaStr] = opts.positional;
684
+ if (!swarmId || !repoId || !deltaStr) {
685
+ throw new Error('swarm budget: usage — momentum swarm budget <swarm-id> <repo> +N');
686
+ }
687
+ const sign = deltaStr.startsWith('+') ? 1 : (deltaStr.startsWith('-') ? -1 : 1);
688
+ const delta = sign * parseInt(deltaStr.replace(/^[+-]/, ''), 10);
689
+ if (!Number.isFinite(delta)) throw new Error(`swarm budget: invalid delta ${deltaStr}`);
690
+ const ecosystemRoot = resolveEcosystemRoot(opts.ecosystem);
691
+ const ts = nowIso();
692
+ manifestLib.updateManifest(ecosystemRoot, swarmId, (m) => {
693
+ if (!m.repos[repoId]) throw new Error(`swarm budget: ${repoId} not a member of ${swarmId}`);
694
+ const cur = m.repos[repoId].tokens_budget || conductor.DEFAULT_TOKEN_BUDGET;
695
+ const next = Math.max(1, cur + delta);
696
+ m.repos[repoId].tokens_budget = next;
697
+ if (!Array.isArray(m.audit)) m.audit = [];
698
+ m.audit.push({
699
+ ts, actor: 'conductor', event: 'budget', repo: repoId,
700
+ detail: `${cur} → ${next}`,
701
+ });
702
+ });
703
+ boardLib.refreshBoard(ecosystemRoot, swarmId, ts);
704
+ console.log(`▸ budget(${repoId}) adjusted by ${delta}`);
705
+ }
706
+
707
+ // ─────────────────────────────────────────────────────────────────────────────
708
+ // Verifier + completion
709
+ // ─────────────────────────────────────────────────────────────────────────────
710
+
711
+ function verifySwarm(ecosystemRoot, swarmId) {
712
+ const issues = [];
713
+ const notes = [];
714
+
715
+ const manifest = manifestLib.loadManifest(ecosystemRoot, swarmId);
716
+ if (!manifest) {
717
+ return { ok: false, issues: [`no manifest for ${swarmId}`], notes };
718
+ }
719
+ const mv = manifestLib.validateManifest(manifest);
720
+ if (!mv.ok) {
721
+ for (const e of mv.errors) issues.push(`manifest ${e.path}: ${e.message}`);
722
+ }
723
+
724
+ // Initiative back-reference
725
+ const initiativeDir = path.join(ecosystemRoot, 'initiatives');
726
+ const initiativeOk = fs.existsSync(initiativeDir) &&
727
+ fs.readdirSync(initiativeDir).some((n) => n.endsWith(`-${manifest.initiative}.md`));
728
+ if (!initiativeOk) {
729
+ issues.push(`initiative "${manifest.initiative}" not found at ${initiativeDir}`);
730
+ } else {
731
+ notes.push(`initiative ${manifest.initiative} resolved`);
732
+ }
733
+
734
+ // Brief back-references
735
+ const ecoMfst = ecosystemLib.loadManifest(ecosystemRoot);
736
+ for (const [repoId, repo] of Object.entries(manifest.repos || {})) {
737
+ const repoPath = conductor.resolveMemberPath(ecosystemRoot, repoId, ecoMfst);
738
+ if (!repoPath) {
739
+ issues.push(`repo "${repoId}" not in ecosystem manifest`);
740
+ continue;
741
+ }
742
+ const briefPath = path.join(repoPath, 'specs', 'phases', repo.phase_slug, 'overview.md');
743
+ if (!fs.existsSync(briefPath)) {
744
+ issues.push(`brief missing: ${briefPath}`);
745
+ continue;
746
+ }
747
+ const { frontmatter } = briefLib.parseSwarmFrontmatter(fs.readFileSync(briefPath, 'utf8'));
748
+ if (!frontmatter || !frontmatter.swarm) {
749
+ issues.push(`brief ${briefPath} has no swarm frontmatter`);
750
+ continue;
751
+ }
752
+ if (frontmatter.swarm !== swarmId) {
753
+ issues.push(`brief ${briefPath} swarm=${frontmatter.swarm} mismatches ${swarmId}`);
754
+ }
755
+ if (frontmatter.wave !== repo.wave) {
756
+ issues.push(`brief ${briefPath} wave=${frontmatter.wave} mismatches manifest wave ${repo.wave}`);
757
+ }
758
+ if (frontmatter.initiative !== manifest.initiative) {
759
+ issues.push(`brief ${briefPath} initiative=${frontmatter.initiative} mismatches ${manifest.initiative}`);
760
+ }
761
+ }
762
+
763
+ // Contract shape — every contract artifact must validate against schema basics
764
+ const contractsDir = path.join(manifestLib.swarmDir(ecosystemRoot, swarmId), 'contracts');
765
+ if (fs.existsSync(contractsDir)) {
766
+ for (const file of fs.readdirSync(contractsDir)) {
767
+ if (!file.endsWith('.contract.json')) continue;
768
+ const filePath = path.join(contractsDir, file);
769
+ try {
770
+ const c = JSON.parse(fs.readFileSync(filePath, 'utf8'));
771
+ if (typeof c.surface !== 'string') issues.push(`${file}: missing surface`);
772
+ if (typeof c.owner !== 'string') issues.push(`${file}: missing owner`);
773
+ if (!Array.isArray(c.consumers)) issues.push(`${file}: consumers must be array`);
774
+ if (!Number.isInteger(c.version) || c.version < 1) issues.push(`${file}: invalid version`);
775
+ if (typeof c.content_hash !== 'string') issues.push(`${file}: missing content_hash`);
776
+ } catch (e) {
777
+ issues.push(`${file}: invalid JSON — ${e.message}`);
778
+ }
779
+ }
780
+ }
781
+
782
+ // Inbox — any pending items are advisories, not failures
783
+ const inboxDir = path.join(manifestLib.swarmDir(ecosystemRoot, swarmId), 'inbox');
784
+ if (fs.existsSync(inboxDir)) {
785
+ const pending = fs.readdirSync(inboxDir).filter((n) => /^\d{4}-[a-z][a-z0-9-]*\.md$/.test(n));
786
+ if (pending.length) notes.push(`${pending.length} pending inbox item(s)`);
787
+ }
788
+
789
+ return { ok: issues.length === 0, issues, notes };
790
+ }
791
+
792
+ function completeSwarm(ecosystemRoot, swarmId, ts) {
793
+ const manifest = manifestLib.loadManifest(ecosystemRoot, swarmId);
794
+ if (!manifest) throw new Error(`completeSwarm: no manifest for ${swarmId}`);
795
+
796
+ const repos = Object.entries(manifest.repos || {});
797
+ const reposComplete = repos.filter(([, r]) => r.status === 'complete').length;
798
+ const reposTotal = repos.length;
799
+
800
+ // Write cross-repo changeset to <eco>/changes/<id>.md
801
+ const changesDir = path.join(ecosystemRoot, 'changes');
802
+ fs.mkdirSync(changesDir, { recursive: true });
803
+ const changesetPath = path.join(changesDir, `${swarmId}.md`);
804
+ const lines = [
805
+ `# Swarm ${swarmId} — cross-repo changeset`,
806
+ '',
807
+ `- Initiative: ${manifest.initiative}`,
808
+ `- Mode: ${manifest.mode}`,
809
+ `- Status: ${manifest.status || 'running'}`,
810
+ `- Repos: ${reposComplete}/${reposTotal} complete`,
811
+ `- Completed at: ${ts}`,
812
+ '',
813
+ '## Per-repo contributions',
814
+ '',
815
+ ];
816
+ for (const [repoId, repo] of repos) {
817
+ lines.push(`### ${repoId}`);
818
+ lines.push(`- Wave: ${repo.wave}`);
819
+ lines.push(`- Branch: \`${repo.branch}\``);
820
+ lines.push(`- Phase: \`${repo.phase_slug}\``);
821
+ lines.push(`- Tasks: ${repo.tasks_done || 0}/${repo.tasks_total || 0}`);
822
+ lines.push(`- Commits: ${repo.commits || 0}`);
823
+ lines.push('');
824
+ }
825
+ fs.writeFileSync(changesetPath, lines.join('\n'), 'utf8');
826
+
827
+ manifestLib.updateManifest(ecosystemRoot, swarmId, (m) => {
828
+ m.status = reposComplete === reposTotal ? 'complete' : (m.status || 'running');
829
+ if (!Array.isArray(m.audit)) m.audit = [];
830
+ m.audit.push({
831
+ ts, actor: 'conductor', event: 'complete',
832
+ detail: `${reposComplete}/${reposTotal} repos complete`,
833
+ });
834
+ });
835
+ boardLib.refreshBoard(ecosystemRoot, swarmId, ts);
836
+
837
+ return { changesetPath, reposComplete, reposTotal };
838
+ }
839
+
840
+ // ─────────────────────────────────────────────────────────────────────────────
841
+ // Adapter spawn dispatch (Phase 18)
842
+ // ─────────────────────────────────────────────────────────────────────────────
843
+
844
+ /**
845
+ * Resolve the adapter module for a given platform id. Used by spawn
846
+ * dispatch so the CLI floor stays platform-agnostic — `bin/swarm.js`
847
+ * never imports any adapter directly.
848
+ */
849
+ function loadAdapterForPlatform(platform) {
850
+ const adapterPath = path.join(MOMENTUM_ROOT, 'adapters', platform, 'adapter.js');
851
+ if (!fs.existsSync(adapterPath)) {
852
+ return null;
853
+ }
854
+ // eslint-disable-next-line global-require
855
+ return require(adapterPath);
856
+ }
857
+
858
+ /**
859
+ * Dispatch each directive to `adapter.spawn(directive)`. The directive's
860
+ * `platform` field selects the adapter; an unknown platform or missing
861
+ * `spawn` function yields a canonical `status: -1` entry so the wave
862
+ * stays robust to per-repo dispatch failures. See `bin/momentum.js` for
863
+ * the directive contract.
864
+ */
865
+ function spawnSupervisors(directives) {
866
+ const results = [];
867
+ for (const d of directives) {
868
+ const adapter = loadAdapterForPlatform(d.platform);
869
+ if (!adapter) {
870
+ results.push({
871
+ repoId: d.repoId, status: -1,
872
+ detail: `unknown platform: ${d.platform}`,
873
+ });
874
+ continue;
875
+ }
876
+ if (typeof adapter.spawn !== 'function') {
877
+ results.push({
878
+ repoId: d.repoId, status: -1,
879
+ detail: `adapter ${d.platform} does not export spawn()`,
880
+ });
881
+ continue;
882
+ }
883
+ results.push(adapter.spawn(d));
884
+ }
885
+ return results;
886
+ }
887
+
888
+ // ─────────────────────────────────────────────────────────────────────────────
889
+ // Renderer
890
+ // ─────────────────────────────────────────────────────────────────────────────
891
+
892
+ function renderBoardTable(board) {
893
+ console.log(`▸ Swarm ${board.swarm_id} — mode=${board.mode} status=${board.status} wave=${board.active_wave}`);
894
+ console.log('');
895
+ const header = ['repo', 'wave', 'status', 'tasks', 'tokens', 'commits'];
896
+ const widths = header.map((h) => h.length);
897
+ const rows = [];
898
+ for (const r of board.repos) {
899
+ const row = [
900
+ r.name, String(r.wave), r.status,
901
+ r.tasks || '', r.tokens || '', String(r.commits || 0),
902
+ ];
903
+ rows.push(row);
904
+ for (let i = 0; i < row.length; i++) widths[i] = Math.max(widths[i], row[i].length);
905
+ }
906
+ const fmt = (cells) => cells.map((c, i) => c.padEnd(widths[i])).join(' ');
907
+ console.log(' ' + fmt(header));
908
+ console.log(' ' + widths.map((w) => '-'.repeat(w)).join(' '));
909
+ for (const row of rows) console.log(' ' + fmt(row));
910
+ if (board.recent_activity && board.recent_activity.length) {
911
+ console.log('');
912
+ console.log(' Recent activity:');
913
+ for (const a of board.recent_activity) {
914
+ console.log(` ${a.ts} ${a.repo ? a.repo + ' ' : ''}${a.msg}`);
915
+ }
916
+ }
917
+ if (board.inbox_count > 0) {
918
+ console.log('');
919
+ console.log(` ▸ ${board.inbox_count} pending inbox item(s)`);
920
+ }
921
+ }
922
+
923
+ // ─────────────────────────────────────────────────────────────────────────────
924
+ // Entrypoint
925
+ // ─────────────────────────────────────────────────────────────────────────────
926
+
927
+ function printUsage() {
928
+ console.log(`momentum swarm — cross-repo work units (Phase 17 v0.20.0, Claude Code only)
929
+
930
+ Usage:
931
+ momentum swarm start <slug> --initiative <slug> --repos r1,r2,... --phase <phase-slug> [--mode autopilot|checkpoint] [--spawn]
932
+ momentum swarm status <swarm-id> [--json]
933
+ momentum swarm tell <swarm-id> <repo> "<text>"
934
+ momentum swarm broadcast <swarm-id> "<text>"
935
+ momentum swarm verify <swarm-id> [--json]
936
+ momentum swarm complete <swarm-id>
937
+ momentum swarm resume <swarm-id> [--session <id>]
938
+ momentum swarm cancel <swarm-id> [--reason "<text>"]
939
+ momentum swarm budget <swarm-id> <repo> +N | -N
940
+ momentum swarm claim <swarm-id> <repo> [--session <id>] [--lease-hours 24]
941
+ momentum swarm release <swarm-id> <repo> [--session <id>]
942
+ momentum swarm focus <swarm-id> <repo> [--session <id>] [--expires-min 60]
943
+ momentum swarm join <swarm-id> [--token <token>] [--claim <repo>] [--session <id>]
944
+ momentum swarm absorb <target-swarm-id> <source-swarm-id> [--yes] [--session <id>]
945
+ momentum swarm inbox list <swarm-id>
946
+ momentum swarm inbox write <swarm-id> --repo <r> --slug <s> --question "<text>"
947
+ momentum swarm inbox resolve <swarm-id> <id> --answer "<text>"
948
+ momentum swarm preview-merge <swarm-id> [--json]
949
+
950
+ Global flags:
951
+ --ecosystem <path> Override ecosystem root resolution
952
+ `);
953
+ }
954
+
955
+ function cmdInboxList(args) {
956
+ const opts = parseFlags(args, {
957
+ ecosystem: { flag: '--ecosystem', default: null },
958
+ json: { flag: '--json', type: 'bool', default: false },
959
+ });
960
+ const [swarmId] = opts.positional;
961
+ if (!swarmId) throw new Error('swarm inbox list: <swarm-id> required');
962
+ const ecosystemRoot = resolveEcosystemRoot(opts.ecosystem);
963
+ const items = inboxLib.listPendingInboxItems(ecosystemRoot, swarmId);
964
+ if (opts.json) {
965
+ process.stdout.write(JSON.stringify(items, null, 2) + '\n');
966
+ return;
967
+ }
968
+ if (items.length === 0) {
969
+ console.log(`▸ inbox(${swarmId}): no pending items`);
970
+ return;
971
+ }
972
+ console.log(`▸ inbox(${swarmId}): ${items.length} pending`);
973
+ for (const it of items) {
974
+ console.log(` ${it.id} ${it.repo} ${it.slug} (${it.asked})`);
975
+ }
976
+ }
977
+
978
+ function cmdInboxWrite(args) {
979
+ const opts = parseFlags(args, {
980
+ repo: { flag: '--repo' },
981
+ slug: { flag: '--slug' },
982
+ question: { flag: '--question' },
983
+ ecosystem: { flag: '--ecosystem', default: null },
984
+ });
985
+ const [swarmId] = opts.positional;
986
+ if (!swarmId || !opts.repo || !opts.slug || !opts.question) {
987
+ throw new Error('swarm inbox write: usage — momentum swarm inbox write <swarm-id> --repo <r> --slug <s> --question "<text>"');
988
+ }
989
+ const ecosystemRoot = resolveEcosystemRoot(opts.ecosystem);
990
+ const r = inboxLib.writeInboxItem({
991
+ ecosystemRoot, swarmId,
992
+ repo: opts.repo, slug: opts.slug, question: opts.question,
993
+ nowIso: nowIso(),
994
+ });
995
+ boardLib.refreshBoard(ecosystemRoot, swarmId, nowIso());
996
+ console.log(`▸ inbox(${swarmId}) ${r.id}-${r.slug}.md written`);
997
+ }
998
+
999
+ function cmdInboxResolve(args) {
1000
+ const opts = parseFlags(args, {
1001
+ answer: { flag: '--answer' },
1002
+ ecosystem: { flag: '--ecosystem', default: null },
1003
+ });
1004
+ const [swarmId, id] = opts.positional;
1005
+ if (!swarmId || !id || !opts.answer) {
1006
+ throw new Error('swarm inbox resolve: usage — momentum swarm inbox resolve <swarm-id> <id> --answer "<text>"');
1007
+ }
1008
+ const ecosystemRoot = resolveEcosystemRoot(opts.ecosystem);
1009
+ const r = inboxLib.resolveInboxItem({
1010
+ ecosystemRoot, swarmId, id, answer: opts.answer, nowIso: nowIso(),
1011
+ });
1012
+ boardLib.refreshBoard(ecosystemRoot, swarmId, nowIso());
1013
+ console.log(`▸ inbox(${swarmId}) ${r.id} resolved → ${path.relative(ecosystemRoot, r.resolvedPath)}`);
1014
+ }
1015
+
1016
+ function cmdInbox(args) {
1017
+ if (!args || args.length === 0 || args[0] === '--help') {
1018
+ console.log('momentum swarm inbox — supervisor questions to the conductor');
1019
+ console.log('Usage:');
1020
+ console.log(' momentum swarm inbox list <swarm-id>');
1021
+ console.log(' momentum swarm inbox write <swarm-id> --repo <r> --slug <s> --question "<text>"');
1022
+ console.log(' momentum swarm inbox resolve <swarm-id> <id> --answer "<text>"');
1023
+ return;
1024
+ }
1025
+ const sub = args[0];
1026
+ const rest = args.slice(1);
1027
+ switch (sub) {
1028
+ case 'list': return cmdInboxList(rest);
1029
+ case 'write': return cmdInboxWrite(rest);
1030
+ case 'resolve': return cmdInboxResolve(rest);
1031
+ default:
1032
+ throw new Error(`swarm inbox: unknown subcommand "${sub}". Try --help.`);
1033
+ }
1034
+ }
1035
+
1036
+ function cmdPreviewMerge(args) {
1037
+ const opts = parseFlags(args, {
1038
+ ecosystem: { flag: '--ecosystem', default: null },
1039
+ json: { flag: '--json', type: 'bool', default: false },
1040
+ });
1041
+ const [swarmId] = opts.positional;
1042
+ if (!swarmId) throw new Error('swarm preview-merge: <swarm-id> required');
1043
+ const ecosystemRoot = resolveEcosystemRoot(opts.ecosystem);
1044
+ const manifest = manifestLib.loadManifest(ecosystemRoot, swarmId);
1045
+ if (!manifest) throw new Error(`swarm preview-merge: no manifest for ${swarmId}`);
1046
+
1047
+ const entries = [];
1048
+ for (const [repoId, repo] of Object.entries(manifest.repos || {})) {
1049
+ const repoPath = conductor.resolveMemberPath(ecosystemRoot, repoId);
1050
+ if (!repoPath) continue;
1051
+ entries.push({
1052
+ repo: repoId, repoPath,
1053
+ branch: repo.branch,
1054
+ intoBranch: 'main',
1055
+ });
1056
+ }
1057
+ const results = preMergeLib.previewMergeBatch(entries);
1058
+ if (opts.json) {
1059
+ process.stdout.write(JSON.stringify(results, null, 2) + '\n');
1060
+ return;
1061
+ }
1062
+ for (const r of results) {
1063
+ console.log(` ${r.repo}: ${r.result.ok ? 'CLEAN' : 'CONFLICTS'} — ${r.result.summary}`);
1064
+ for (const f of r.result.conflictedFiles) console.log(` ✗ ${f}`);
1065
+ }
1066
+ }
1067
+
1068
+ function runSwarm(args) {
1069
+ if (!args || args.length === 0 || args[0] === '--help' || args[0] === '-h') {
1070
+ return printUsage();
1071
+ }
1072
+ const sub = args[0];
1073
+ const rest = args.slice(1);
1074
+ switch (sub) {
1075
+ case 'start': return cmdStart(rest);
1076
+ case 'status': return cmdStatus(rest);
1077
+ case 'tell': return cmdTell(rest);
1078
+ case 'broadcast': return cmdBroadcast(rest);
1079
+ case 'verify': return cmdVerify(rest);
1080
+ case 'complete': return cmdComplete(rest);
1081
+ case 'resume': return cmdResume(rest);
1082
+ case 'cancel': return cmdCancel(rest);
1083
+ case 'budget': return cmdBudget(rest);
1084
+ case 'claim': return cmdClaim(rest);
1085
+ case 'release': return cmdRelease(rest);
1086
+ case 'focus': return cmdFocus(rest);
1087
+ case 'join': return cmdJoin(rest);
1088
+ case 'absorb': return cmdAbsorb(rest);
1089
+ case 'inbox': return cmdInbox(rest);
1090
+ case 'preview-merge': return cmdPreviewMerge(rest);
1091
+ default:
1092
+ throw new Error(`swarm: unknown subcommand "${sub}". Try --help.`);
1093
+ }
1094
+ }
1095
+
1096
+ module.exports = {
1097
+ runSwarm,
1098
+ // Exposed for tests
1099
+ verifySwarm,
1100
+ completeSwarm,
1101
+ spawnSupervisors,
1102
+ loadAdapterForPlatform,
1103
+ resolveEcosystemRoot,
1104
+ };