@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,222 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Swarm inbox protocol.
5
+ *
6
+ * Supervisors write `<eco>/swarms/<id>/inbox/NNNN-<slug>.md` when they
7
+ * need a user decision they can't make alone. The conductor surfaces
8
+ * these per turn and resolves them interactively. After resolution the
9
+ * item is moved to `inbox/resolved/` (preserved for audit) and the
10
+ * INDEX is regenerated.
11
+ *
12
+ * Numbering: monotonic across the swarm's lifetime, including
13
+ * resolved. Filename: `NNNN-<slug>.md` matching the regex
14
+ * `/^\d{4}-[a-z][a-z0-9-]*\.md$/`.
15
+ *
16
+ * Concurrency: writes use the same mkdir-lock as manifest.js.
17
+ */
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+
22
+ const manifestLib = require('./lib/manifest');
23
+
24
+ const INBOX_DIR = 'inbox';
25
+ const RESOLVED_DIR = 'resolved';
26
+ const INDEX_FILENAME = 'INDEX.md';
27
+
28
+ const FILENAME = /^(\d{4})-([a-z][a-z0-9-]*)\.md$/;
29
+
30
+ function inboxDir(ecosystemRoot, swarmId) {
31
+ return path.join(manifestLib.swarmDir(ecosystemRoot, swarmId), INBOX_DIR);
32
+ }
33
+
34
+ function resolvedDir(ecosystemRoot, swarmId) {
35
+ return path.join(inboxDir(ecosystemRoot, swarmId), RESOLVED_DIR);
36
+ }
37
+
38
+ function indexPath(ecosystemRoot, swarmId) {
39
+ return path.join(inboxDir(ecosystemRoot, swarmId), INDEX_FILENAME);
40
+ }
41
+
42
+ function ensureLayout(ecosystemRoot, swarmId) {
43
+ manifestLib.ensureSwarmLayout(ecosystemRoot, swarmId);
44
+ fs.mkdirSync(resolvedDir(ecosystemRoot, swarmId), { recursive: true });
45
+ }
46
+
47
+ function nextInboxId(ecosystemRoot, swarmId) {
48
+ ensureLayout(ecosystemRoot, swarmId);
49
+ let max = 0;
50
+ for (const name of fs.readdirSync(inboxDir(ecosystemRoot, swarmId))) {
51
+ const m = name.match(FILENAME);
52
+ if (m) {
53
+ const n = parseInt(m[1], 10);
54
+ if (n > max) max = n;
55
+ }
56
+ }
57
+ for (const name of fs.readdirSync(resolvedDir(ecosystemRoot, swarmId))) {
58
+ const m = name.match(FILENAME);
59
+ if (m) {
60
+ const n = parseInt(m[1], 10);
61
+ if (n > max) max = n;
62
+ }
63
+ }
64
+ return String(max + 1).padStart(4, '0');
65
+ }
66
+
67
+ /**
68
+ * Supervisor-side write. Returns { id, slug, filePath }.
69
+ *
70
+ * @param {object} args
71
+ * @param {string} args.ecosystemRoot
72
+ * @param {string} args.swarmId
73
+ * @param {string} args.repo ecosystem member id
74
+ * @param {string} args.slug kebab slug summarizing the question
75
+ * @param {string} args.question markdown body (full question)
76
+ * @param {string[]} [args.options] optional structured choices (each one line)
77
+ * @param {string} args.nowIso
78
+ */
79
+ function writeInboxItem(args) {
80
+ const { ecosystemRoot, swarmId, repo, slug, question, options = [], nowIso } = args;
81
+ if (typeof slug !== 'string' || !/^[a-z][a-z0-9-]*$/.test(slug)) {
82
+ throw new TypeError(`writeInboxItem: invalid slug ${JSON.stringify(slug)}`);
83
+ }
84
+ if (typeof question !== 'string' || question.length === 0) {
85
+ throw new TypeError('writeInboxItem: question required');
86
+ }
87
+ if (typeof repo !== 'string' || !/^[a-z][a-z0-9-]*$/.test(repo)) {
88
+ throw new TypeError(`writeInboxItem: invalid repo ${JSON.stringify(repo)}`);
89
+ }
90
+ ensureLayout(ecosystemRoot, swarmId);
91
+ const id = nextInboxId(ecosystemRoot, swarmId);
92
+ const filePath = path.join(inboxDir(ecosystemRoot, swarmId), `${id}-${slug}.md`);
93
+ const lines = [
94
+ `# ${id} — ${slug}`,
95
+ '',
96
+ `- Repo: \`${repo}\``,
97
+ `- Asked at: ${nowIso}`,
98
+ `- Status: pending`,
99
+ '',
100
+ '## Question',
101
+ '',
102
+ question.trim(),
103
+ '',
104
+ ];
105
+ if (options.length) {
106
+ lines.push('## Options');
107
+ lines.push('');
108
+ for (const o of options) lines.push(`- ${o}`);
109
+ lines.push('');
110
+ }
111
+ manifestLib.withLock(filePath, () => {
112
+ fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
113
+ });
114
+
115
+ rebuildIndex(ecosystemRoot, swarmId);
116
+ return { id, slug, filePath };
117
+ }
118
+
119
+ /**
120
+ * Conductor-side: list pending items (excluding resolved/).
121
+ */
122
+ function listPendingInboxItems(ecosystemRoot, swarmId) {
123
+ const dir = inboxDir(ecosystemRoot, swarmId);
124
+ if (!fs.existsSync(dir)) return [];
125
+ const items = [];
126
+ for (const name of fs.readdirSync(dir)) {
127
+ const m = name.match(FILENAME);
128
+ if (!m) continue;
129
+ const filePath = path.join(dir, name);
130
+ items.push(parseItemHeader(filePath, m[1], m[2]));
131
+ }
132
+ items.sort((a, b) => a.id.localeCompare(b.id));
133
+ return items;
134
+ }
135
+
136
+ function parseItemHeader(filePath, id, slug) {
137
+ const raw = fs.readFileSync(filePath, 'utf8');
138
+ const lines = raw.split('\n');
139
+ let repo = '';
140
+ let asked = '';
141
+ let status = 'pending';
142
+ for (const line of lines) {
143
+ let m;
144
+ if ((m = line.match(/^- Repo:\s*`([^`]+)`/))) repo = m[1];
145
+ else if ((m = line.match(/^- Asked at:\s*(.+)$/))) asked = m[1].trim();
146
+ else if ((m = line.match(/^- Status:\s*(.+)$/))) status = m[1].trim();
147
+ }
148
+ return { id, slug, repo, asked, status, filePath };
149
+ }
150
+
151
+ /**
152
+ * Conductor-side: resolve an item with the user's answer. Moves the
153
+ * file into resolved/ with the answer + ts appended.
154
+ */
155
+ function resolveInboxItem(args) {
156
+ const { ecosystemRoot, swarmId, id, answer, nowIso } = args;
157
+ if (typeof id !== 'string' || !/^\d{4}$/.test(id)) {
158
+ throw new TypeError(`resolveInboxItem: invalid id ${JSON.stringify(id)}`);
159
+ }
160
+ if (typeof answer !== 'string' || answer.length === 0) {
161
+ throw new TypeError('resolveInboxItem: answer required');
162
+ }
163
+ ensureLayout(ecosystemRoot, swarmId);
164
+ const dir = inboxDir(ecosystemRoot, swarmId);
165
+ const match = fs.readdirSync(dir).find((n) => n.startsWith(`${id}-`) && n.endsWith('.md'));
166
+ if (!match) throw new Error(`resolveInboxItem: no pending inbox item ${id}`);
167
+ const fromPath = path.join(dir, match);
168
+ const toPath = path.join(resolvedDir(ecosystemRoot, swarmId), match);
169
+
170
+ const raw = fs.readFileSync(fromPath, 'utf8');
171
+ const updated =
172
+ raw.replace(/^- Status: pending\b/m, '- Status: resolved') +
173
+ `\n## Answer (resolved at ${nowIso})\n\n${answer.trim()}\n`;
174
+
175
+ manifestLib.withLock(fromPath, () => {
176
+ fs.writeFileSync(toPath, updated, 'utf8');
177
+ fs.unlinkSync(fromPath);
178
+ });
179
+ rebuildIndex(ecosystemRoot, swarmId);
180
+
181
+ manifestLib.appendAudit(ecosystemRoot, swarmId, {
182
+ ts: nowIso, actor: 'conductor', event: 'inbox-resolved',
183
+ detail: `${id} — ${answer.slice(0, 200)}`,
184
+ });
185
+ return { id, resolvedPath: toPath };
186
+ }
187
+
188
+ /**
189
+ * Regenerate inbox/INDEX.md from pending items. Cheap — runs on every
190
+ * write/resolve.
191
+ */
192
+ function rebuildIndex(ecosystemRoot, swarmId) {
193
+ ensureLayout(ecosystemRoot, swarmId);
194
+ const items = listPendingInboxItems(ecosystemRoot, swarmId);
195
+ const lines = ['# Inbox — pending items', ''];
196
+ if (items.length === 0) {
197
+ lines.push('_(no pending items)_');
198
+ } else {
199
+ lines.push('| ID | Repo | Slug | Asked at |');
200
+ lines.push('|----|------|------|----------|');
201
+ for (const it of items) {
202
+ lines.push(`| ${it.id} | ${it.repo} | ${it.slug} | ${it.asked} |`);
203
+ }
204
+ }
205
+ lines.push('');
206
+ fs.writeFileSync(indexPath(ecosystemRoot, swarmId), lines.join('\n'), 'utf8');
207
+ }
208
+
209
+ module.exports = {
210
+ INBOX_DIR,
211
+ RESOLVED_DIR,
212
+ INDEX_FILENAME,
213
+ inboxDir,
214
+ resolvedDir,
215
+ indexPath,
216
+ ensureLayout,
217
+ nextInboxId,
218
+ writeInboxItem,
219
+ listPendingInboxItems,
220
+ resolveInboxItem,
221
+ rebuildIndex,
222
+ };
@@ -0,0 +1,138 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * /swarm join <swarm-id> — register a session with an existing swarm.
5
+ *
6
+ * Phase 17.5 portability primitive. Three call shapes:
7
+ *
8
+ * join(swarmId, sessionId)
9
+ * Registration only. Adds to sessions[] (idempotent — touch on
10
+ * re-join), auto-renews any repos this session already owns.
11
+ *
12
+ * join(swarmId, sessionId, { token })
13
+ * Consume an opaque transfer token. If kind=focus, claim ONLY the
14
+ * token's target_repo (FOCUSING sentinel allows takeover). If
15
+ * kind=join, no automatic claim — equivalent to plain registration.
16
+ *
17
+ * join(swarmId, sessionId, { claim: <repo> })
18
+ * Explicit claim shortcut. Composes `manifestLib.updateManifestAsOwner`
19
+ * on the named repo — bound by the same lease rules as `/swarm claim`.
20
+ *
21
+ * Returns: {
22
+ * swarmId,
23
+ * sessionId,
24
+ * sessions: <new sessions[] array>,
25
+ * claimed?: { repo, owner, lease_expires_at },
26
+ * token?: <consumed token record>,
27
+ * }
28
+ *
29
+ * Throws on:
30
+ * - swarm missing
31
+ * - token missing / expired
32
+ * - claim rejected (EOWNERSHIP propagates)
33
+ */
34
+
35
+ const manifestLib = require('./lib/manifest');
36
+ const tokensLib = require('./lib/tokens');
37
+ const sessionsLib = require('./lib/sessions');
38
+ const boardLib = require('./lib/board');
39
+
40
+ const DEFAULT_LEASE_MS = 24 * 60 * 60 * 1000;
41
+
42
+ function join(args) {
43
+ const {
44
+ ecosystemRoot, swarmId, sessionId, nowIso,
45
+ token: tokenStr, claim: claimRepo,
46
+ leaseMs = DEFAULT_LEASE_MS,
47
+ } = args;
48
+
49
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
50
+ throw new TypeError('join: sessionId required');
51
+ }
52
+ if (typeof nowIso !== 'string' || nowIso.length === 0) {
53
+ throw new TypeError('join: nowIso required');
54
+ }
55
+
56
+ const manifest = manifestLib.loadManifest(ecosystemRoot, swarmId);
57
+ if (!manifest) throw new Error(`join: swarm ${swarmId} not found at ${ecosystemRoot}`);
58
+
59
+ // 1. Register / touch session
60
+ sessionsLib.touchSession(ecosystemRoot, swarmId, sessionId, nowIso);
61
+
62
+ let claimed = null;
63
+ let consumedToken = null;
64
+ let claimTargetRepo = claimRepo || null;
65
+
66
+ // 2. Optional token consumption — may set claimTargetRepo
67
+ if (tokenStr) {
68
+ consumedToken = tokensLib.consumeToken(ecosystemRoot, swarmId, tokenStr, nowIso);
69
+ if (consumedToken.kind === 'focus') {
70
+ // Focus token narrows the claim to its target_repo
71
+ claimTargetRepo = consumedToken.target_repo;
72
+ }
73
+ // kind=join: no implicit claim, registration is enough
74
+ }
75
+
76
+ // 3. Optional / token-implied repo claim
77
+ if (claimTargetRepo) {
78
+ const expiresAt = isoFromMs(Date.parse(nowIso) + leaseMs);
79
+ manifestLib.updateManifestAsOwner({
80
+ ecosystemRoot, swarmId, sessionId, repo: claimTargetRepo, nowIso,
81
+ mutate: (m, decision) => {
82
+ const previousOwner = m.repos[claimTargetRepo].owner;
83
+ m.repos[claimTargetRepo].owner = sessionId;
84
+ m.repos[claimTargetRepo].claimed_by_session = sessionId;
85
+ m.repos[claimTargetRepo].lease_renewed_at = nowIso;
86
+ m.repos[claimTargetRepo].lease_expires_at = expiresAt;
87
+ if (!Array.isArray(m.audit)) m.audit = [];
88
+ m.audit.push({
89
+ ts: nowIso, actor: sessionId, event: 'claim', repo: claimTargetRepo,
90
+ detail: `${previousOwner} → ${sessionId} (via join${consumedToken ? ` --token kind=${consumedToken.kind}` : ''})`,
91
+ });
92
+ if (decision.expired) {
93
+ m.audit.push({
94
+ ts: nowIso, actor: sessionId, event: 'lease-takeover', repo: claimTargetRepo,
95
+ detail: `previous owner ${previousOwner} lease expired`,
96
+ });
97
+ }
98
+ },
99
+ });
100
+ claimed = { repo: claimTargetRepo, owner: sessionId, lease_expires_at: expiresAt };
101
+ }
102
+
103
+ // 4. Audit-log the join itself
104
+ manifestLib.appendAudit(ecosystemRoot, swarmId, {
105
+ ts: nowIso, actor: sessionId, event: 'join',
106
+ detail: tokenStr
107
+ ? `via token kind=${consumedToken.kind}${claimTargetRepo ? ` (claimed ${claimTargetRepo})` : ''}`
108
+ : (claimRepo ? `with --claim ${claimRepo}` : 'registration only'),
109
+ });
110
+
111
+ // 5. Auto-renew owned-repo leases. Pulls conductor.renewLeases without
112
+ // requiring it as a hard dep — the conductor module does require
113
+ // this file's siblings, so we lazy-require here to avoid cycles.
114
+ const conductor = require('./conductor');
115
+ conductor.renewLeases(ecosystemRoot, swarmId, sessionId, nowIso);
116
+
117
+ // 6. Refresh board
118
+ boardLib.refreshBoard(ecosystemRoot, swarmId, nowIso);
119
+
120
+ return {
121
+ swarmId,
122
+ sessionId,
123
+ sessions: sessionsLib.listSessions(ecosystemRoot, swarmId),
124
+ claimed,
125
+ token: consumedToken,
126
+ };
127
+ }
128
+
129
+ function isoFromMs(ms) {
130
+ // Match the same no-millis format `bin/swarm.js::nowIso()` produces so
131
+ // joined manifest timestamps look consistent across writers.
132
+ return new Date(ms).toISOString().replace(/\.\d+Z$/, 'Z');
133
+ }
134
+
135
+ module.exports = {
136
+ join,
137
+ DEFAULT_LEASE_MS,
138
+ };
@@ -0,0 +1,178 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Materialized board cache for swarms.
5
+ *
6
+ * Strategy A from the indexing design: the conductor reads ONLY
7
+ * board.json on most turns. Inputs: manifest.json + per-repo
8
+ * dispatch-run-NNN.json records. Output: board.json (≈1–3KB).
9
+ *
10
+ * Pre-rendered display strings keep the conductor stateless — no
11
+ * computation per turn. The materializer is invoked from any code path
12
+ * that mutates manifest or supervisor saga records.
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ const manifestLib = require('./manifest');
19
+
20
+ const BOARD_FILENAME = 'board.json';
21
+
22
+ function boardPath(ecosystemRoot, swarmId) {
23
+ return path.join(manifestLib.swarmDir(ecosystemRoot, swarmId), BOARD_FILENAME);
24
+ }
25
+
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+ // Render helpers
28
+ // ─────────────────────────────────────────────────────────────────────────────
29
+
30
+ function tasksDisplay(done, total) {
31
+ if (done == null && total == null) return '';
32
+ return `${done || 0}/${total || 0}`;
33
+ }
34
+
35
+ function tokensDisplay(used, budget) {
36
+ const k = (n) => `${Math.round((n || 0) / 1000)}k`;
37
+ if (used == null && budget == null) return '';
38
+ return `${k(used)}/${k(budget)}`;
39
+ }
40
+
41
+ function shortTs(iso) {
42
+ // "2026-06-12T17:42:11Z" → "17:42"
43
+ if (typeof iso !== 'string') return '';
44
+ const m = iso.match(/T(\d{2}):(\d{2})/);
45
+ return m ? `${m[1]}:${m[2]}` : '';
46
+ }
47
+
48
+ function deriveOverallStatus(manifest) {
49
+ const repos = Object.values(manifest.repos || {});
50
+ if (repos.length === 0) return 'running';
51
+ if (repos.every((r) => r.status === 'complete')) return 'complete';
52
+ if (repos.some((r) => r.status === 'failed')) return 'failed';
53
+ if (repos.every((r) => r.status === 'cancelled' || r.status === 'complete')
54
+ && repos.some((r) => r.status === 'cancelled')) {
55
+ return 'cancelled';
56
+ }
57
+ return manifest.status || 'running';
58
+ }
59
+
60
+ function activeWave(manifest) {
61
+ const waves = manifest.waves || [];
62
+ for (const w of waves) {
63
+ if (w.status === 'running') return w.index;
64
+ }
65
+ for (const w of waves) {
66
+ if (w.status === 'queued' || w.status === undefined) return w.index;
67
+ }
68
+ // All complete — return last wave's index
69
+ return waves.length ? waves[waves.length - 1].index : 1;
70
+ }
71
+
72
+ function inboxCount(ecosystemRoot, swarmId) {
73
+ const inboxDir = path.join(manifestLib.swarmDir(ecosystemRoot, swarmId), 'inbox');
74
+ if (!fs.existsSync(inboxDir)) return 0;
75
+ return fs.readdirSync(inboxDir)
76
+ .filter((n) => /^\d{4}-[a-z][a-z0-9-]*\.md$/.test(n))
77
+ .length;
78
+ }
79
+
80
+ // ─────────────────────────────────────────────────────────────────────────────
81
+ // Materialization
82
+ // ─────────────────────────────────────────────────────────────────────────────
83
+
84
+ /**
85
+ * Derive a board cache from manifest state. Pure function over manifest
86
+ * — disk reads are limited to the inbox dir for the count. `nowIso` is
87
+ * injected for determinism (the conductor passes current time; tests
88
+ * pass a fixed value).
89
+ */
90
+ function materializeBoard(ecosystemRoot, swarmId, manifest, nowIso) {
91
+ if (!manifest || typeof manifest !== 'object') {
92
+ throw new TypeError('materializeBoard: manifest must be an object');
93
+ }
94
+ const board = {
95
+ swarm_id: manifest.swarm_id,
96
+ version: 1,
97
+ saga_id: manifest.saga_id,
98
+ mode: manifest.mode,
99
+ rendered_at: nowIso,
100
+ status: deriveOverallStatus(manifest),
101
+ active_wave: activeWave(manifest),
102
+ repos: [],
103
+ inbox_count: inboxCount(ecosystemRoot, swarmId),
104
+ recent_activity: [],
105
+ };
106
+
107
+ for (const [name, r] of Object.entries(manifest.repos || {})) {
108
+ const row = {
109
+ name,
110
+ wave: r.wave,
111
+ status: r.status,
112
+ };
113
+ if (r.tasks_done != null || r.tasks_total != null) {
114
+ row.tasks = tasksDisplay(r.tasks_done, r.tasks_total);
115
+ }
116
+ if (r.tokens_used != null || r.tokens_budget != null) {
117
+ row.tokens = tokensDisplay(r.tokens_used, r.tokens_budget);
118
+ }
119
+ if (r.commits != null) row.commits = r.commits;
120
+ if (r.current_task) row.current = r.current_task;
121
+ if (r.inbox_count != null) row.inbox_count = r.inbox_count;
122
+ if (Array.isArray(r.waiting_on) && r.waiting_on.length) {
123
+ row.waiting_on = r.waiting_on.join(', ');
124
+ }
125
+ board.repos.push(row);
126
+ }
127
+
128
+ if (Array.isArray(manifest.audit)) {
129
+ const tail = manifest.audit.slice(-10).reverse();
130
+ for (const a of tail) {
131
+ const msg = a.detail || a.event;
132
+ const entry = { ts: shortTs(a.ts), msg };
133
+ if (a.repo) entry.repo = a.repo;
134
+ board.recent_activity.push(entry);
135
+ }
136
+ }
137
+
138
+ return board;
139
+ }
140
+
141
+ function writeBoard(ecosystemRoot, swarmId, board) {
142
+ const file = boardPath(ecosystemRoot, swarmId);
143
+ manifestLib.ensureSwarmLayout(ecosystemRoot, swarmId);
144
+ return manifestLib.withLock(file, () => {
145
+ fs.writeFileSync(file, JSON.stringify(board, null, 2) + '\n', 'utf8');
146
+ return file;
147
+ });
148
+ }
149
+
150
+ function loadBoard(ecosystemRoot, swarmId) {
151
+ const file = boardPath(ecosystemRoot, swarmId);
152
+ if (!fs.existsSync(file)) return null;
153
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
154
+ }
155
+
156
+ /**
157
+ * Convenience: materialize from current manifest + write atomically.
158
+ */
159
+ function refreshBoard(ecosystemRoot, swarmId, nowIso) {
160
+ const manifest = manifestLib.loadManifest(ecosystemRoot, swarmId);
161
+ if (!manifest) throw new Error(`refreshBoard: no manifest for swarm ${swarmId}`);
162
+ const board = materializeBoard(ecosystemRoot, swarmId, manifest, nowIso);
163
+ writeBoard(ecosystemRoot, swarmId, board);
164
+ return board;
165
+ }
166
+
167
+ module.exports = {
168
+ BOARD_FILENAME,
169
+ boardPath,
170
+ materializeBoard,
171
+ writeBoard,
172
+ loadBoard,
173
+ refreshBoard,
174
+ deriveOverallStatus,
175
+ activeWave,
176
+ tasksDisplay,
177
+ tokensDisplay,
178
+ };
@@ -0,0 +1,157 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Phase-brief frontmatter — swarm-context extension.
5
+ *
6
+ * A solo (non-swarm) phase brief has NO frontmatter — it's plain
7
+ * markdown beginning with `# Phase N — ...`. A swarm-member phase
8
+ * brief MAY have an optional YAML frontmatter block declaring its
9
+ * swarm context. The fields are:
10
+ *
11
+ * swarm: NNNN-<slug> # swarm directory name
12
+ * wave: <integer> # 1-based wave index
13
+ * initiative: <slug> # /eco/initiatives/<NNNN>-<slug>.md
14
+ * claimed_by_session: <uuid> # current owning session
15
+ *
16
+ * All four are optional individually — a brief with `swarm:` but no
17
+ * `wave:` is malformed and validation fails. Solo briefs (no
18
+ * frontmatter at all) are byte-shape-compatible with v0.19.0.
19
+ *
20
+ * This module is intentionally narrow: parseSwarmFrontmatter,
21
+ * serializeSwarmFrontmatter, validateSwarmFrontmatter,
22
+ * injectSwarmFrontmatter (rewrites an existing brief atomically). It
23
+ * does NOT duplicate the broader markdown parsing already in
24
+ * ecosystem/lib/initiative.js — it's a focused helper for the
25
+ * supervisor recipe + /validate.
26
+ */
27
+
28
+ const fs = require('fs');
29
+
30
+ const FENCE = '---';
31
+
32
+ const SLUG = /^[a-z][a-z0-9-]*$/;
33
+ const SWARM_ID = /^[0-9]{4}-[a-z][a-z0-9-]*$/;
34
+
35
+ const SWARM_FIELDS = Object.freeze([
36
+ 'swarm',
37
+ 'wave',
38
+ 'initiative',
39
+ 'claimed_by_session',
40
+ ]);
41
+
42
+ function parseSwarmFrontmatter(content) {
43
+ if (typeof content !== 'string') {
44
+ return { frontmatter: null, body: content == null ? '' : String(content) };
45
+ }
46
+ const trimmed = content.replace(/^/, '');
47
+ if (!trimmed.startsWith(FENCE)) {
48
+ return { frontmatter: null, body: content };
49
+ }
50
+ const lines = trimmed.split('\n');
51
+ let end = -1;
52
+ for (let i = 1; i < lines.length; i++) {
53
+ if (lines[i].trim() === FENCE) { end = i; break; }
54
+ }
55
+ if (end === -1) return { frontmatter: null, body: content };
56
+ const fm = {};
57
+ for (const raw of lines.slice(1, end)) {
58
+ const line = raw.trim();
59
+ if (!line || line.startsWith('#')) continue;
60
+ const colon = line.indexOf(':');
61
+ if (colon === -1) continue;
62
+ const key = line.slice(0, colon).trim();
63
+ let value = line.slice(colon + 1).trim();
64
+ if (value.startsWith('"') && value.endsWith('"')) {
65
+ value = value.slice(1, -1);
66
+ } else if (/^-?\d+$/.test(value)) {
67
+ value = parseInt(value, 10);
68
+ }
69
+ fm[key] = value;
70
+ }
71
+ const body = lines.slice(end + 1).join('\n').replace(/^\n+/, '');
72
+ return { frontmatter: fm, body };
73
+ }
74
+
75
+ function serializeSwarmFrontmatter(fm) {
76
+ const lines = [FENCE];
77
+ for (const key of SWARM_FIELDS) {
78
+ if (fm[key] === undefined || fm[key] === null) continue;
79
+ const v = fm[key];
80
+ if (typeof v === 'number') {
81
+ lines.push(`${key}: ${v}`);
82
+ } else {
83
+ const s = String(v);
84
+ lines.push(`${key}: ${s}`);
85
+ }
86
+ }
87
+ lines.push(FENCE);
88
+ return lines.join('\n') + '\n';
89
+ }
90
+
91
+ function validateSwarmFrontmatter(fm) {
92
+ const errors = [];
93
+ if (fm == null) return { ok: true }; // solo brief — no frontmatter required
94
+ if (typeof fm !== 'object' || Array.isArray(fm)) {
95
+ return { ok: false, errors: [{ path: '$', message: 'frontmatter must be an object' }] };
96
+ }
97
+
98
+ // If ANY swarm field is present, swarm + wave are both required (the
99
+ // pair locks the brief into a swarm context).
100
+ const present = SWARM_FIELDS.filter((k) => fm[k] !== undefined);
101
+ const anyPresent = present.length > 0;
102
+ if (anyPresent) {
103
+ if (typeof fm.swarm !== 'string' || !SWARM_ID.test(fm.swarm)) {
104
+ errors.push({ path: '$.swarm', message: 'required when any swarm field is set; must match NNNN-<slug>' });
105
+ }
106
+ if (!Number.isInteger(fm.wave) || fm.wave < 1) {
107
+ errors.push({ path: '$.wave', message: 'required when any swarm field is set; positive integer' });
108
+ }
109
+ }
110
+
111
+ if (fm.initiative !== undefined) {
112
+ if (typeof fm.initiative !== 'string' || !SLUG.test(fm.initiative)) {
113
+ errors.push({ path: '$.initiative', message: 'must be a slug' });
114
+ }
115
+ }
116
+ if (fm.claimed_by_session !== undefined) {
117
+ if (typeof fm.claimed_by_session !== 'string' || fm.claimed_by_session.length === 0) {
118
+ errors.push({ path: '$.claimed_by_session', message: 'must be a non-empty string' });
119
+ }
120
+ }
121
+
122
+ return errors.length === 0 ? { ok: true } : { ok: false, errors };
123
+ }
124
+
125
+ /**
126
+ * Atomically inject (or update) swarm frontmatter in an existing brief
127
+ * file. Preserves the body verbatim. Validates the merged frontmatter
128
+ * before writing.
129
+ *
130
+ * If the brief has no frontmatter, prepends a new block. If it already
131
+ * has frontmatter, merges (new keys override existing).
132
+ */
133
+ function injectSwarmFrontmatter(filePath, swarmFields) {
134
+ if (typeof filePath !== 'string' || filePath.length === 0) {
135
+ throw new TypeError('injectSwarmFrontmatter: filePath required');
136
+ }
137
+ const exists = fs.existsSync(filePath);
138
+ const raw = exists ? fs.readFileSync(filePath, 'utf8') : '';
139
+ const { frontmatter, body } = parseSwarmFrontmatter(raw);
140
+ const merged = Object.assign({}, frontmatter || {}, swarmFields);
141
+ const v = validateSwarmFrontmatter(merged);
142
+ if (!v.ok) {
143
+ const summary = v.errors.map((e) => ` ${e.path}: ${e.message}`).join('\n');
144
+ throw new Error(`injectSwarmFrontmatter: validation failed:\n${summary}`);
145
+ }
146
+ const fmBlock = serializeSwarmFrontmatter(merged);
147
+ fs.writeFileSync(filePath, fmBlock + body, 'utf8');
148
+ return merged;
149
+ }
150
+
151
+ module.exports = {
152
+ SWARM_FIELDS,
153
+ parseSwarmFrontmatter,
154
+ serializeSwarmFrontmatter,
155
+ validateSwarmFrontmatter,
156
+ injectSwarmFrontmatter,
157
+ };