@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,499 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Swarm conductor — orchestration helpers used by the CLI floor
5
+ * (`bin/swarm.js`) and the Claude Code `/swarm` recipes.
6
+ *
7
+ * Pure, no-spawn: this module never executes the supervisor agent
8
+ * itself. Instead it (a) builds + persists swarm state on disk and
9
+ * (b) emits a structured "spawn directive" that the platform-adapter
10
+ * layer (bin/swarm.js wrapping `claude --bg --cwd <repo>` for Claude
11
+ * Code) actually launches.
12
+ *
13
+ * Separation rationale: keeping the spawn out of core means the
14
+ * library is platform-agnostic — Codex / Antigravity in Phase 18 will
15
+ * reuse this without changes; only their adapter spawn wrappers need
16
+ * to be written.
17
+ */
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+
22
+ const manifestLib = require('./lib/manifest');
23
+ const boardLib = require('./lib/board');
24
+ const waveLib = require('./lib/wave-ordering');
25
+ const briefLib = require('./lib/brief');
26
+ const gitSha = require('./lib/git-sha-cache');
27
+ const sagaLib = require('./lib/saga');
28
+
29
+ const ecosystemLib = require('../ecosystem/lib/index');
30
+
31
+ const DEFAULT_TOKEN_BUDGET = 300000;
32
+ const DEFAULT_LEASE_HOURS = 24;
33
+ const DEFAULT_MODE = 'checkpoint';
34
+
35
+ // ─────────────────────────────────────────────────────────────────────────────
36
+ // Spawn directive (what the platform adapter consumes)
37
+ // ─────────────────────────────────────────────────────────────────────────────
38
+
39
+ /**
40
+ * @typedef {Object} SpawnDirective
41
+ * @property {string} platform — 'claude-code' | 'codex' | 'antigravity'
42
+ * @property {string} swarmId
43
+ * @property {number} wave
44
+ * @property {string} repoId
45
+ * @property {string} repoPath — absolute path to the repo working tree
46
+ * @property {string} phaseSlug
47
+ * @property {string} branch
48
+ * @property {string} sessionId
49
+ * @property {string} recipePath — absolute path to supervise.md (or adapter override)
50
+ * @property {string} contextPath — absolute path to per-supervisor working brief
51
+ * @property {Record<string,string>} env — env vars to inject in the spawn
52
+ */
53
+
54
+ // ─────────────────────────────────────────────────────────────────────────────
55
+ // Plan a swarm — pure: take inputs, return manifest seed
56
+ // ─────────────────────────────────────────────────────────────────────────────
57
+
58
+ /**
59
+ * Build the initial manifest object for a new swarm. Does not write —
60
+ * the caller persists with manifestLib.writeManifest after final
61
+ * review.
62
+ *
63
+ * @param {object} args
64
+ * @param {string} args.ecosystemRoot absolute path to ecosystem root
65
+ * @param {string} args.swarmId result of nextSwarmId(root, slug)
66
+ * @param {string} args.initiative initiative slug
67
+ * @param {string[]} args.impactedRepos ecosystem member ids
68
+ * @param {string} args.phaseSlug phase slug each supervisor will drive (one slug — all repos share it for simplicity)
69
+ * @param {string} args.sessionId conductor session uuid
70
+ * @param {string} [args.mode] default DEFAULT_MODE
71
+ * @param {string} [args.nowIso] timestamp (default required by caller for determinism)
72
+ * @param {object} [args.ecosystemManifest] for wave ordering — if omitted we'll loadManifest
73
+ * @returns {object} manifest
74
+ */
75
+ function planSwarm(args) {
76
+ const {
77
+ ecosystemRoot, swarmId, initiative, impactedRepos, phaseSlug,
78
+ sessionId, mode = DEFAULT_MODE, nowIso,
79
+ ecosystemManifest,
80
+ } = args;
81
+
82
+ if (typeof ecosystemRoot !== 'string' || ecosystemRoot.length === 0) {
83
+ throw new TypeError('planSwarm: ecosystemRoot required');
84
+ }
85
+ if (!/^[0-9]{4}-[a-z][a-z0-9-]*$/.test(swarmId)) {
86
+ throw new TypeError(`planSwarm: invalid swarmId ${JSON.stringify(swarmId)}`);
87
+ }
88
+ if (typeof initiative !== 'string' || !/^[a-z][a-z0-9-]*$/.test(initiative)) {
89
+ throw new TypeError(`planSwarm: invalid initiative ${JSON.stringify(initiative)}`);
90
+ }
91
+ if (!Array.isArray(impactedRepos) || impactedRepos.length === 0) {
92
+ throw new TypeError('planSwarm: impactedRepos must be non-empty');
93
+ }
94
+ if (typeof phaseSlug !== 'string' || !/^phase-[0-9]+(?:[a-z])?-[a-z][a-z0-9-]*$/.test(phaseSlug)) {
95
+ throw new TypeError(`planSwarm: invalid phaseSlug ${JSON.stringify(phaseSlug)}`);
96
+ }
97
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
98
+ throw new TypeError('planSwarm: sessionId required');
99
+ }
100
+ if (typeof nowIso !== 'string' || nowIso.length === 0) {
101
+ throw new TypeError('planSwarm: nowIso required');
102
+ }
103
+ if (!['autopilot', 'checkpoint', 'interactive'].includes(mode)) {
104
+ throw new TypeError(`planSwarm: invalid mode ${JSON.stringify(mode)}`);
105
+ }
106
+
107
+ const ecoMfst = ecosystemManifest || ecosystemLib.loadManifest(ecosystemRoot);
108
+ const deps = (ecoMfst && Array.isArray(ecoMfst.dependencies)) ? ecoMfst.dependencies : [];
109
+
110
+ const waves = waveLib.computeWaves(impactedRepos, deps);
111
+ const repoWaveIndex = {};
112
+ for (const w of waves) {
113
+ for (const r of w.repos) repoWaveIndex[r] = w.index;
114
+ }
115
+
116
+ const leaseExpires = new Date(Date.parse(nowIso) + DEFAULT_LEASE_HOURS * 3600 * 1000).toISOString();
117
+
118
+ const repos = {};
119
+ for (const repoId of impactedRepos) {
120
+ repos[repoId] = {
121
+ wave: repoWaveIndex[repoId],
122
+ status: 'queued',
123
+ phase_slug: phaseSlug,
124
+ branch: phaseSlug,
125
+ owner: sessionId,
126
+ lease_expires_at: leaseExpires,
127
+ lease_renewed_at: nowIso,
128
+ claimed_by_session: sessionId,
129
+ tasks_done: 0,
130
+ tasks_total: 0,
131
+ tokens_used: 0,
132
+ tokens_budget: DEFAULT_TOKEN_BUDGET,
133
+ inbox_count: 0,
134
+ commits: 0,
135
+ };
136
+ }
137
+
138
+ return {
139
+ swarm_id: swarmId,
140
+ version: 1,
141
+ saga_id: manifestLib.makeSagaId(swarmId, 0),
142
+ mode,
143
+ initiative,
144
+ created: nowIso,
145
+ ecosystem: (ecoMfst && ecoMfst.name) || 'unknown',
146
+ repos,
147
+ waves: waves.map((w) => Object.assign({}, w, { status: w.index === 1 ? 'queued' : 'queued' })),
148
+ sessions: [{ session_id: sessionId, first_seen: nowIso, last_seen: nowIso }],
149
+ audit: [{
150
+ ts: nowIso, actor: sessionId, event: 'start',
151
+ detail: `Swarm ${swarmId} planned (${impactedRepos.length} repos, ${waves.length} waves)`,
152
+ }],
153
+ status: 'running',
154
+ };
155
+ }
156
+
157
+ // ─────────────────────────────────────────────────────────────────────────────
158
+ // Resolve member repo path from ecosystem manifest
159
+ // ─────────────────────────────────────────────────────────────────────────────
160
+
161
+ function resolveMemberPath(ecosystemRoot, memberId, ecosystemManifest) {
162
+ const m = ecosystemManifest || ecosystemLib.loadManifest(ecosystemRoot);
163
+ for (const member of ecosystemLib.listMembers(m)) {
164
+ if (member && member.id === memberId) {
165
+ return path.resolve(ecosystemRoot, member.path);
166
+ }
167
+ }
168
+ return null;
169
+ }
170
+
171
+ // ─────────────────────────────────────────────────────────────────────────────
172
+ // Build spawn directives for an active wave
173
+ // ─────────────────────────────────────────────────────────────────────────────
174
+
175
+ /**
176
+ * Return an array of SpawnDirective for every repo in the wave. The
177
+ * caller (platform adapter wrapper in bin/swarm.js) iterates these and
178
+ * actually launches sessions.
179
+ */
180
+ function buildSpawnDirectives(args) {
181
+ const { ecosystemRoot, swarmId, waveIndex, platform = 'claude-code' } = args;
182
+ const manifest = manifestLib.loadManifest(ecosystemRoot, swarmId);
183
+ if (!manifest) throw new Error(`buildSpawnDirectives: no manifest for ${swarmId}`);
184
+ const wave = (manifest.waves || []).find((w) => w.index === waveIndex);
185
+ if (!wave) throw new Error(`buildSpawnDirectives: no wave ${waveIndex} in ${swarmId}`);
186
+
187
+ const ecoMfst = ecosystemLib.loadManifest(ecosystemRoot);
188
+ const recipePath = path.resolve(__dirname, 'supervise.md');
189
+
190
+ const directives = [];
191
+ for (const repoId of wave.repos) {
192
+ const repo = manifest.repos[repoId];
193
+ if (!repo) continue;
194
+ const repoPath = resolveMemberPath(ecosystemRoot, repoId, ecoMfst);
195
+ if (!repoPath) {
196
+ throw new Error(`buildSpawnDirectives: cannot resolve path for member "${repoId}"`);
197
+ }
198
+ const contextPath = path.join(
199
+ repoPath, 'specs', 'phases', repo.phase_slug, 'swarm-context.md'
200
+ );
201
+ directives.push({
202
+ platform,
203
+ swarmId,
204
+ wave: waveIndex,
205
+ repoId,
206
+ repoPath,
207
+ phaseSlug: repo.phase_slug,
208
+ branch: repo.branch,
209
+ sessionId: repo.owner,
210
+ recipePath,
211
+ contextPath,
212
+ env: {
213
+ MOMENTUM_SWARM_ID: swarmId,
214
+ MOMENTUM_SWARM_WAVE: String(waveIndex),
215
+ MOMENTUM_SWARM_INITIATIVE: manifest.initiative,
216
+ MOMENTUM_SWARM_SESSION: repo.owner,
217
+ MOMENTUM_ECOSYSTEM_ROOT: ecosystemRoot,
218
+ MOMENTUM_SWARM_RECIPE: recipePath,
219
+ MOMENTUM_SWARM_CONTEXT: contextPath,
220
+ },
221
+ });
222
+ }
223
+ return directives;
224
+ }
225
+
226
+ // ─────────────────────────────────────────────────────────────────────────────
227
+ // Per-repo supervisor brief injection — called from /start-phase
228
+ // ─────────────────────────────────────────────────────────────────────────────
229
+
230
+ /**
231
+ * Inject swarm frontmatter into a supervisor's phase brief. Called
232
+ * from /start-phase when the env vars MOMENTUM_SWARM_* are set.
233
+ *
234
+ * Idempotent — re-running with the same values is a no-op.
235
+ */
236
+ function injectBriefFrontmatter(repoPath, phaseSlug, swarmFields) {
237
+ const briefPath = path.join(repoPath, 'specs', 'phases', phaseSlug, 'overview.md');
238
+ return briefLib.injectSwarmFrontmatter(briefPath, swarmFields);
239
+ }
240
+
241
+ /**
242
+ * Read from env (compatible with the supervisor recipe) and return
243
+ * { swarmId, wave, initiative, claimedBySession } or null when not in
244
+ * a swarm context.
245
+ */
246
+ function readEnvSwarmContext(env = process.env) {
247
+ if (!env.MOMENTUM_SWARM_ID) return null;
248
+ return {
249
+ swarm: env.MOMENTUM_SWARM_ID,
250
+ wave: parseInt(env.MOMENTUM_SWARM_WAVE, 10),
251
+ initiative: env.MOMENTUM_SWARM_INITIATIVE,
252
+ claimed_by_session: env.MOMENTUM_SWARM_SESSION,
253
+ };
254
+ }
255
+
256
+ // ─────────────────────────────────────────────────────────────────────────────
257
+ // Wave lifecycle — mark started / complete
258
+ // ─────────────────────────────────────────────────────────────────────────────
259
+
260
+ function markWaveStatus(ecosystemRoot, swarmId, waveIndex, status, nowIso) {
261
+ return manifestLib.updateManifest(ecosystemRoot, swarmId, (m) => {
262
+ const w = (m.waves || []).find((x) => x.index === waveIndex);
263
+ if (w) {
264
+ w.status = status;
265
+ if (status === 'running') {
266
+ for (const r of w.repos) {
267
+ if (m.repos[r] && m.repos[r].status === 'queued') m.repos[r].status = 'running';
268
+ }
269
+ }
270
+ if (status === 'complete' && nowIso) {
271
+ w.checkpoint_resolved_at = nowIso;
272
+ }
273
+ }
274
+ });
275
+ }
276
+
277
+ function recordRepoComplete(ecosystemRoot, swarmId, repoId, opts = {}) {
278
+ return manifestLib.updateManifest(ecosystemRoot, swarmId, (m) => {
279
+ if (!m.repos[repoId]) return;
280
+ m.repos[repoId].status = 'complete';
281
+ if (opts.tasksDone != null) m.repos[repoId].tasks_done = opts.tasksDone;
282
+ if (opts.tasksTotal != null) m.repos[repoId].tasks_total = opts.tasksTotal;
283
+ if (opts.commits != null) m.repos[repoId].commits = opts.commits;
284
+ if (opts.lastSeenSha) m.repos[repoId].last_seen_sha = opts.lastSeenSha;
285
+ });
286
+ }
287
+
288
+ // ─────────────────────────────────────────────────────────────────────────────
289
+ // Per-turn poll — Strategies A + B + C in one call
290
+ // ─────────────────────────────────────────────────────────────────────────────
291
+
292
+ /**
293
+ * Run one conductor poll. Cheap by design — ~3KB of file reads when
294
+ * nothing changed. The conductor recipe calls this on every turn.
295
+ *
296
+ * 1. For each active-wave repo, read git HEAD SHA — Strategy B.
297
+ * 2. For repos whose SHA changed since last_seen_sha, refresh their
298
+ * saga record into the manifest. Repos unchanged → cache fresh.
299
+ * 3. If every repo in the current running wave is done, advance the
300
+ * wave (queued → running) and emit a transition audit event.
301
+ * 4. Re-materialize board.json so the next read is fresh.
302
+ *
303
+ * Returns a summary: { changedRepos, completedWave?, advancedToWave? }.
304
+ */
305
+ function pollTurn(args) {
306
+ const { ecosystemRoot, swarmId, nowIso } = args;
307
+ if (typeof ecosystemRoot !== 'string' || ecosystemRoot.length === 0) {
308
+ throw new TypeError('pollTurn: ecosystemRoot required');
309
+ }
310
+ if (typeof nowIso !== 'string' || nowIso.length === 0) {
311
+ throw new TypeError('pollTurn: nowIso required');
312
+ }
313
+ const manifest = manifestLib.loadManifest(ecosystemRoot, swarmId);
314
+ if (!manifest) throw new Error(`pollTurn: no manifest for ${swarmId}`);
315
+
316
+ const ecoMfst = ecosystemLib.loadManifest(ecosystemRoot);
317
+ const runningWave = (manifest.waves || []).find((w) => w.status === 'running');
318
+ const activeRepoIds = runningWave ? runningWave.repos : [];
319
+
320
+ // Build (repoId → lastSeenSha) for the active wave
321
+ const lastSeen = {};
322
+ for (const id of activeRepoIds) {
323
+ lastSeen[id] = (manifest.repos[id] && manifest.repos[id].last_seen_sha) || '';
324
+ }
325
+ const repoIdToPath = {};
326
+ for (const id of activeRepoIds) {
327
+ repoIdToPath[id] = resolveMemberPath(ecosystemRoot, id, ecoMfst);
328
+ }
329
+ const diff = gitSha.diffSinceLastSeen(lastSeen, (id) => repoIdToPath[id]);
330
+
331
+ // Pull updated saga records for changed repos
332
+ manifestLib.updateManifest(ecosystemRoot, swarmId, (m) => {
333
+ for (const id of diff.changed) {
334
+ const repoPath = repoIdToPath[id];
335
+ if (!repoPath) continue;
336
+ const saga = runningWave
337
+ ? sagaLib.findActiveByWave(repoPath, swarmId, runningWave.index)
338
+ : null;
339
+ if (saga) {
340
+ const r = m.repos[id];
341
+ r.tasks_done = saga.tasks_done || r.tasks_done;
342
+ r.tasks_total = saga.tasks_total || r.tasks_total;
343
+ r.tokens_used = saga.tokens_used || r.tokens_used;
344
+ if (saga.head_sha) r.last_seen_sha = saga.head_sha;
345
+ if (saga.done && r.status !== 'complete') r.status = 'complete';
346
+ if (saga.exit_status === 'failed') r.status = 'failed';
347
+ }
348
+ if (diff.shas[id]) m.repos[id].last_seen_sha = diff.shas[id];
349
+ }
350
+ });
351
+
352
+ // Refresh state via re-load
353
+ const after = manifestLib.loadManifest(ecosystemRoot, swarmId);
354
+
355
+ // Wave advancement
356
+ let completedWave = null;
357
+ let advancedToWave = null;
358
+ if (runningWave) {
359
+ const wave = (after.waves || []).find((w) => w.index === runningWave.index);
360
+ const allDone = wave.repos.every((id) =>
361
+ after.repos[id] && (after.repos[id].status === 'complete' || after.repos[id].status === 'cancelled'));
362
+ if (allDone) {
363
+ completedWave = wave.index;
364
+ // For autopilot mode we auto-advance; for checkpoint we mark the
365
+ // wave complete but the recipe is responsible for the user
366
+ // approval prompt before advancing.
367
+ manifestLib.updateManifest(ecosystemRoot, swarmId, (m) => {
368
+ const w = m.waves.find((x) => x.index === completedWave);
369
+ w.status = 'complete';
370
+ w.checkpoint_resolved_at = w.checkpoint_resolved_at || nowIso;
371
+ if (!Array.isArray(m.audit)) m.audit = [];
372
+ m.audit.push({
373
+ ts: nowIso, actor: 'conductor', event: 'wave-transition',
374
+ detail: `Wave ${completedWave} complete`,
375
+ });
376
+ if (m.mode === 'autopilot') {
377
+ const next = m.waves.find((x) => x.index === completedWave + 1);
378
+ if (next && next.status === 'queued') {
379
+ next.status = 'running';
380
+ for (const id of next.repos) {
381
+ if (m.repos[id] && m.repos[id].status === 'queued') m.repos[id].status = 'running';
382
+ }
383
+ advancedToWave = next.index;
384
+ } else if (!next) {
385
+ m.status = 'complete';
386
+ }
387
+ }
388
+ });
389
+ }
390
+ } else {
391
+ // No running wave — try to start wave 1 if everything is queued
392
+ const w1 = (after.waves || []).find((w) => w.index === 1);
393
+ if (w1 && (w1.status === 'queued' || w1.status === undefined)) {
394
+ manifestLib.updateManifest(ecosystemRoot, swarmId, (m) => {
395
+ const w = m.waves.find((x) => x.index === 1);
396
+ w.status = 'running';
397
+ for (const id of w.repos) {
398
+ if (m.repos[id] && m.repos[id].status === 'queued') m.repos[id].status = 'running';
399
+ }
400
+ });
401
+ advancedToWave = 1;
402
+ }
403
+ }
404
+
405
+ // Always refresh board after a turn
406
+ boardLib.refreshBoard(ecosystemRoot, swarmId, nowIso);
407
+
408
+ return {
409
+ changedRepos: diff.changed,
410
+ unchangedRepos: diff.unchanged,
411
+ completedWave,
412
+ advancedToWave,
413
+ };
414
+ }
415
+
416
+ // ─────────────────────────────────────────────────────────────────────────────
417
+ // Cancel — graceful halt
418
+ // ─────────────────────────────────────────────────────────────────────────────
419
+
420
+ function cancelSwarm(ecosystemRoot, swarmId, reason, nowIso) {
421
+ manifestLib.updateManifest(ecosystemRoot, swarmId, (m) => {
422
+ for (const r of Object.values(m.repos || {})) {
423
+ if (r.status === 'queued' || r.status === 'running' || r.status === 'blocked') {
424
+ r.status = 'cancelled';
425
+ }
426
+ }
427
+ for (const w of m.waves || []) {
428
+ if (w.status === 'queued' || w.status === 'running') w.status = 'cancelled';
429
+ }
430
+ m.status = 'cancelled';
431
+ if (!Array.isArray(m.audit)) m.audit = [];
432
+ m.audit.push({
433
+ ts: nowIso, actor: 'conductor', event: 'cancel',
434
+ detail: reason || 'swarm cancelled',
435
+ });
436
+ });
437
+ boardLib.refreshBoard(ecosystemRoot, swarmId, nowIso);
438
+ }
439
+
440
+ // ─────────────────────────────────────────────────────────────────────────────
441
+ // Resume — disk-only reconstitution
442
+ // ─────────────────────────────────────────────────────────────────────────────
443
+
444
+ function resumeSwarm(ecosystemRoot, swarmId, sessionId, nowIso) {
445
+ const manifest = manifestLib.loadManifest(ecosystemRoot, swarmId);
446
+ if (!manifest) throw new Error(`resumeSwarm: no manifest for ${swarmId}`);
447
+
448
+ manifestLib.updateManifest(ecosystemRoot, swarmId, (m) => {
449
+ if (!Array.isArray(m.sessions)) m.sessions = [];
450
+ const existing = m.sessions.find((s) => s.session_id === sessionId);
451
+ if (existing) {
452
+ existing.last_seen = nowIso;
453
+ } else {
454
+ m.sessions.push({ session_id: sessionId, first_seen: nowIso, last_seen: nowIso });
455
+ }
456
+ if (!Array.isArray(m.audit)) m.audit = [];
457
+ m.audit.push({
458
+ ts: nowIso, actor: sessionId, event: 'resume',
459
+ detail: `session ${sessionId} attached to swarm`,
460
+ });
461
+ });
462
+
463
+ // Refresh board so the next conductor turn sees a fresh snapshot.
464
+ boardLib.refreshBoard(ecosystemRoot, swarmId, nowIso);
465
+ return manifestLib.loadManifest(ecosystemRoot, swarmId);
466
+ }
467
+
468
+ // ─────────────────────────────────────────────────────────────────────────────
469
+ // Lease renewal — Phase 17.5 hooks
470
+ // ─────────────────────────────────────────────────────────────────────────────
471
+
472
+ function renewLeases(ecosystemRoot, swarmId, sessionId, nowIso) {
473
+ const leaseExpires = new Date(Date.parse(nowIso) + DEFAULT_LEASE_HOURS * 3600 * 1000).toISOString();
474
+ manifestLib.updateManifest(ecosystemRoot, swarmId, (m) => {
475
+ for (const r of Object.values(m.repos || {})) {
476
+ if (r.owner === sessionId) {
477
+ r.lease_renewed_at = nowIso;
478
+ r.lease_expires_at = leaseExpires;
479
+ }
480
+ }
481
+ });
482
+ }
483
+
484
+ module.exports = {
485
+ DEFAULT_TOKEN_BUDGET,
486
+ DEFAULT_LEASE_HOURS,
487
+ DEFAULT_MODE,
488
+ planSwarm,
489
+ resolveMemberPath,
490
+ buildSpawnDirectives,
491
+ injectBriefFrontmatter,
492
+ readEnvSwarmContext,
493
+ markWaveStatus,
494
+ recordRepoComplete,
495
+ pollTurn,
496
+ cancelSwarm,
497
+ resumeSwarm,
498
+ renewLeases,
499
+ };
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * /swarm focus <repo> — split one repo off a swarm into a focused side
5
+ * session.
6
+ *
7
+ * Phase 17.5 portability primitive. The current owner of `<repo>` runs
8
+ * `focus(swarmId, repo, ...)` to:
9
+ *
10
+ * 1. Validate the repo is part of the swarm.
11
+ * 2. Assert the caller currently owns the repo (rejects otherwise).
12
+ * 3. Issue an opaque single-use focus token (kind=focus, target_repo,
13
+ * 1-hour expiry by default) at `<eco>/swarms/<id>/tokens/<token>.json`.
14
+ * 4. Flip `repos[<repo>].owner` to the FOCUSING sentinel. The lease
15
+ * fields are cleared so the receiver's claim sets a fresh lease.
16
+ * 5. Write a `focus-request` signal carrying the token + repo so other
17
+ * conductors / observers see the request next poll.
18
+ * 6. Audit-log `focus`.
19
+ * 7. Return a SpawnDirective object the caller renders to the user —
20
+ * typically `claude --bg --cwd <ecoRoot>` with the join command as
21
+ * the seed prompt.
22
+ *
23
+ * The receiving session consumes the token via `cmdJoin --token <token>`
24
+ * (Group 3), which calls `manifestLib.updateManifestAsOwner` against the
25
+ * FOCUSING sentinel (any caller may take over) and sets owner =
26
+ * receiverSession + a fresh lease.
27
+ */
28
+
29
+ const path = require('path');
30
+
31
+ const manifestLib = require('./lib/manifest');
32
+ const tokensLib = require('./lib/tokens');
33
+ const signalsLib = require('./signals');
34
+ const boardLib = require('./lib/board');
35
+
36
+ /**
37
+ * Execute a focus request. Throws on rejection.
38
+ *
39
+ * @param {object} args
40
+ * @param {string} args.ecosystemRoot
41
+ * @param {string} args.swarmId
42
+ * @param {string} args.repo repo key in the swarm
43
+ * @param {string} args.sessionId caller session id (must currently own the repo)
44
+ * @param {string} args.nowIso ISO timestamp
45
+ * @param {number} [args.expiresInMs] token expiry (default 1 hour)
46
+ * @param {string} [args.detail] short note surfaced on the signal
47
+ *
48
+ * Returns: {
49
+ * token: <record>,
50
+ * signal: { signal_id, filePath },
51
+ * directive: { command, args, env, prompt },
52
+ * }
53
+ */
54
+ function focus(args) {
55
+ const {
56
+ ecosystemRoot, swarmId, repo, sessionId, nowIso,
57
+ expiresInMs, detail,
58
+ } = args;
59
+ if (typeof repo !== 'string' || repo.length === 0) {
60
+ throw new TypeError('focus: repo required');
61
+ }
62
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
63
+ throw new TypeError('focus: sessionId required');
64
+ }
65
+ if (typeof nowIso !== 'string' || nowIso.length === 0) {
66
+ throw new TypeError('focus: nowIso required');
67
+ }
68
+
69
+ // Pre-validate the repo exists so we surface a useful error before we
70
+ // touch anything mutable.
71
+ const manifest = manifestLib.loadManifest(ecosystemRoot, swarmId);
72
+ if (!manifest) throw new Error(`focus: no manifest for ${swarmId}`);
73
+ if (!manifest.repos[repo]) throw new Error(`focus: ${repo} not in swarm ${swarmId}`);
74
+
75
+ // 1. Issue token (does not mutate manifest)
76
+ const token = tokensLib.writeToken({
77
+ ecosystemRoot, swarmId,
78
+ kind: 'focus', issuedBy: sessionId, targetRepo: repo,
79
+ nowIso, expiresInMs,
80
+ });
81
+
82
+ // 2. Mutate manifest under ownership assertion. If the caller doesn't
83
+ // own the repo, this throws EOWNERSHIP and we clean up the token.
84
+ try {
85
+ manifestLib.updateManifestAsOwner({
86
+ ecosystemRoot, swarmId, sessionId, repo, nowIso,
87
+ mutate: (m) => {
88
+ const previousOwner = m.repos[repo].owner;
89
+ m.repos[repo].owner = manifestLib.FOCUSING;
90
+ delete m.repos[repo].lease_expires_at;
91
+ delete m.repos[repo].lease_renewed_at;
92
+ // claimed_by_session reflects the latest session that holds the claim
93
+ delete m.repos[repo].claimed_by_session;
94
+ if (!Array.isArray(m.audit)) m.audit = [];
95
+ m.audit.push({
96
+ ts: nowIso, actor: sessionId, event: 'focus', repo,
97
+ detail: `${previousOwner} → ${manifestLib.FOCUSING} (token issued)`,
98
+ });
99
+ },
100
+ });
101
+ } catch (err) {
102
+ // Roll back the token if ownership was rejected — keeps the
103
+ // tokens/ directory clean.
104
+ try {
105
+ const fs = require('fs');
106
+ const file = tokensLib.tokenPath(ecosystemRoot, swarmId, token.token);
107
+ if (fs.existsSync(file)) fs.unlinkSync(file);
108
+ } catch (_e) { /* best-effort cleanup */ }
109
+ throw err;
110
+ }
111
+
112
+ // 3. Write the focus-request signal so any other conductor on this
113
+ // swarm sees the request and can pick up the token.
114
+ const signal = signalsLib.writeSignal({
115
+ ecosystemRoot, swarmId,
116
+ type: 'focus-request', slug: `${repo}-by-${sessionSlug(sessionId)}`,
117
+ fromSession: sessionId, repo, token: token.token,
118
+ nowIso, detail,
119
+ });
120
+
121
+ // 4. Refresh the materialized board so the next conductor read sees
122
+ // the FOCUSING state immediately.
123
+ boardLib.refreshBoard(ecosystemRoot, swarmId, nowIso);
124
+
125
+ // 5. Build the spawn directive the CLI renders to the user.
126
+ const ecoName = path.basename(ecosystemRoot);
127
+ const directive = {
128
+ command: 'claude',
129
+ args: ['--bg', '--cwd', ecosystemRoot],
130
+ env: {
131
+ MOMENTUM_SWARM_ID: swarmId,
132
+ MOMENTUM_FOCUS_TOKEN: token.token,
133
+ MOMENTUM_FOCUS_REPO: repo,
134
+ },
135
+ prompt: [
136
+ `You are a focused swarm side-session for repo "${repo}" in ecosystem "${ecoName}".`,
137
+ `Run: momentum swarm join ${swarmId} --token ${token.token}`,
138
+ `Then drive the repo's phase to completion. The original conductor still owns the rest of the swarm.`,
139
+ ].join('\n'),
140
+ };
141
+
142
+ return { token, signal, directive };
143
+ }
144
+
145
+ function sessionSlug(sessionId) {
146
+ return String(sessionId).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32) || 'unknown';
147
+ }
148
+
149
+ module.exports = {
150
+ focus,
151
+ };