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