@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,146 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Swarm saga record — per-supervisor durable state.
5
+ *
6
+ * Each supervisor writes one `dispatch-run-<NNNNNN>.json` per wave to
7
+ * its own `<repo>/.momentum/runs/`. Distinct from the existing
8
+ * `dispatch-run-NNN.md` artifacts written by Phase 11's
9
+ * `core/orchestration/run-artifact.js` — those are scout/dispatch/
10
+ * handoff markdown artifacts; these are JSON saga steps for the
11
+ * conductor's polling loop + `/swarm resume`.
12
+ *
13
+ * Allocation is independent of the .md counters: a separate
14
+ * `.counters.json` key `swarm-supervise` tracks the swarm sequence.
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ const RUNS_DIR = '.momentum/runs';
21
+ const COUNTERS_FILENAME = '.counters.json';
22
+ const PRIMITIVE = 'swarm-supervise';
23
+
24
+ function runsDir(repoPath) {
25
+ return path.join(repoPath, RUNS_DIR);
26
+ }
27
+
28
+ function countersPath(repoPath) {
29
+ return path.join(runsDir(repoPath), COUNTERS_FILENAME);
30
+ }
31
+
32
+ function allocateRunId(repoPath) {
33
+ const dir = runsDir(repoPath);
34
+ fs.mkdirSync(dir, { recursive: true });
35
+ const cp = countersPath(repoPath);
36
+ let counters = {};
37
+ if (fs.existsSync(cp)) {
38
+ try { counters = JSON.parse(fs.readFileSync(cp, 'utf8')); } catch { counters = {}; }
39
+ }
40
+ const next = (counters[PRIMITIVE] || 0) + 1;
41
+ counters[PRIMITIVE] = next;
42
+ fs.writeFileSync(cp, JSON.stringify(counters, null, 2) + '\n');
43
+ return String(next).padStart(3, '0');
44
+ }
45
+
46
+ function recordPath(repoPath, runId) {
47
+ return path.join(runsDir(repoPath), `dispatch-run-${runId}.json`);
48
+ }
49
+
50
+ function writeRecord(repoPath, runId, record) {
51
+ const file = recordPath(repoPath, runId);
52
+ fs.mkdirSync(path.dirname(file), { recursive: true });
53
+ fs.writeFileSync(file, JSON.stringify(record, null, 2) + '\n', 'utf8');
54
+ return file;
55
+ }
56
+
57
+ function loadRecord(repoPath, runId) {
58
+ const file = recordPath(repoPath, runId);
59
+ if (!fs.existsSync(file)) return null;
60
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
61
+ }
62
+
63
+ function listRecords(repoPath) {
64
+ const dir = runsDir(repoPath);
65
+ if (!fs.existsSync(dir)) return [];
66
+ return fs.readdirSync(dir)
67
+ .filter((n) => /^dispatch-run-\d{3,6}\.json$/.test(n))
68
+ .map((n) => ({
69
+ runId: n.replace(/^dispatch-run-(\d+)\.json$/, '$1'),
70
+ path: path.join(dir, n),
71
+ }));
72
+ }
73
+
74
+ function findActiveByWave(repoPath, swarmId, wave) {
75
+ for (const r of listRecords(repoPath)) {
76
+ try {
77
+ const rec = JSON.parse(fs.readFileSync(r.path, 'utf8'));
78
+ if (rec.swarm_id === swarmId && rec.wave === wave && rec.primitive === PRIMITIVE) {
79
+ return Object.assign({}, rec, { runId: r.runId, path: r.path });
80
+ }
81
+ } catch {
82
+ // ignore malformed
83
+ }
84
+ }
85
+ return null;
86
+ }
87
+
88
+ /**
89
+ * Convenience: open-or-create record for (repo, swarm, wave). Allocates
90
+ * a new run id only if none exists yet for that triple. `seed` is the
91
+ * default state for a fresh record.
92
+ */
93
+ function openRecord(repoPath, swarmId, wave, seed) {
94
+ const existing = findActiveByWave(repoPath, swarmId, wave);
95
+ if (existing) return existing;
96
+ const runId = allocateRunId(repoPath);
97
+ const fresh = Object.assign({
98
+ run_id: runId,
99
+ primitive: PRIMITIVE,
100
+ swarm_id: swarmId,
101
+ saga_id: seed.saga_id,
102
+ wave,
103
+ repo: seed.repo,
104
+ phase_slug: seed.phase_slug,
105
+ branch: seed.branch,
106
+ started: seed.started,
107
+ step_n: 0,
108
+ done: false,
109
+ exit_status: 'in-progress',
110
+ tasks_done: 0,
111
+ tasks_total: 0,
112
+ tokens_used: 0,
113
+ inbox_items: [],
114
+ history_tail_offset: 0,
115
+ claimed_by_session: seed.claimed_by_session,
116
+ }, seed.overrides || {});
117
+ writeRecord(repoPath, runId, fresh);
118
+ return Object.assign({}, fresh, { path: recordPath(repoPath, runId) });
119
+ }
120
+
121
+ /**
122
+ * Read-modify-write a saga record. Bumps step_n unless `bumpStep:false`.
123
+ */
124
+ function updateRecord(repoPath, runId, mutate, opts = {}) {
125
+ const file = recordPath(repoPath, runId);
126
+ if (!fs.existsSync(file)) throw new Error(`saga: no record at ${file}`);
127
+ const cur = JSON.parse(fs.readFileSync(file, 'utf8'));
128
+ const result = mutate(cur);
129
+ const next = result === undefined ? cur : result;
130
+ if (opts.bumpStep !== false) next.step_n = (next.step_n || 0) + 1;
131
+ fs.writeFileSync(file, JSON.stringify(next, null, 2) + '\n', 'utf8');
132
+ return next;
133
+ }
134
+
135
+ module.exports = {
136
+ PRIMITIVE,
137
+ RUNS_DIR,
138
+ allocateRunId,
139
+ recordPath,
140
+ writeRecord,
141
+ loadRecord,
142
+ listRecords,
143
+ findActiveByWave,
144
+ openRecord,
145
+ updateRecord,
146
+ };
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Swarm sessions[] registry CRUD.
5
+ *
6
+ * Phase 17.5 portability — extracted from manifest.js for cleaner
7
+ * reuse by `/swarm join`. The sessions[] array on the swarm manifest
8
+ * is append-only and de-duped by `session_id`:
9
+ *
10
+ * - registerSession(eco, swarmId, sessionId, nowIso)
11
+ * If sessionId is new → append { session_id, first_seen, last_seen }
12
+ * If sessionId exists → no-op (use touchSession to bump last_seen)
13
+ *
14
+ * - touchSession(eco, swarmId, sessionId, nowIso)
15
+ * Updates last_seen. Inserts if missing (idempotent).
16
+ *
17
+ * - findSession(manifest, sessionId)
18
+ * Pure helper. Returns the entry or null.
19
+ *
20
+ * - listSessions(eco, swarmId)
21
+ * Pure helper. Returns the sessions[] array (or []).
22
+ *
23
+ * All mutating helpers go through `manifestLib.updateManifest` for
24
+ * mkdir-locked, validated writes.
25
+ */
26
+
27
+ const manifestLib = require('./manifest');
28
+
29
+ function findSession(manifest, sessionId) {
30
+ if (!manifest || !Array.isArray(manifest.sessions)) return null;
31
+ return manifest.sessions.find((s) => s && s.session_id === sessionId) || null;
32
+ }
33
+
34
+ function listSessions(ecosystemRoot, swarmId) {
35
+ const m = manifestLib.loadManifest(ecosystemRoot, swarmId);
36
+ if (!m || !Array.isArray(m.sessions)) return [];
37
+ return m.sessions.slice();
38
+ }
39
+
40
+ function registerSession(ecosystemRoot, swarmId, sessionId, nowIso) {
41
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
42
+ throw new TypeError('registerSession: sessionId required');
43
+ }
44
+ return manifestLib.updateManifest(ecosystemRoot, swarmId, (m) => {
45
+ if (!Array.isArray(m.sessions)) m.sessions = [];
46
+ const existing = findSession(m, sessionId);
47
+ if (existing) {
48
+ // No-op: registerSession is for the FIRST time. last_seen bumps via touch.
49
+ return;
50
+ }
51
+ m.sessions.push({
52
+ session_id: sessionId,
53
+ first_seen: nowIso,
54
+ last_seen: nowIso,
55
+ });
56
+ });
57
+ }
58
+
59
+ function touchSession(ecosystemRoot, swarmId, sessionId, nowIso) {
60
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
61
+ throw new TypeError('touchSession: sessionId required');
62
+ }
63
+ return manifestLib.updateManifest(ecosystemRoot, swarmId, (m) => {
64
+ if (!Array.isArray(m.sessions)) m.sessions = [];
65
+ const existing = findSession(m, sessionId);
66
+ if (existing) {
67
+ existing.last_seen = nowIso;
68
+ } else {
69
+ m.sessions.push({
70
+ session_id: sessionId,
71
+ first_seen: nowIso,
72
+ last_seen: nowIso,
73
+ });
74
+ }
75
+ });
76
+ }
77
+
78
+ module.exports = {
79
+ findSession,
80
+ listSessions,
81
+ registerSession,
82
+ touchSession,
83
+ };
@@ -0,0 +1,255 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Swarm transfer tokens.
5
+ *
6
+ * Phase 17.5 portability — opaque tokens for `/swarm focus` and
7
+ * `/swarm join`. Each token is a small JSON file at
8
+ * `<eco>/swarms/<id>/tokens/<token>.json`. Single-use: `consumeToken`
9
+ * deletes on read.
10
+ *
11
+ * Shape:
12
+ * {
13
+ * token: "<16-hex>",
14
+ * version: 1,
15
+ * kind: "focus" | "join",
16
+ * issued_by: "<session-id>",
17
+ * issued_at: "<ISO ts>",
18
+ * expires_at: "<ISO ts>",
19
+ * target_repo?: "<slug>", // required for kind=focus
20
+ * swarm_id?: "<NNNN-slug>" // required for kind=join (= containing swarm)
21
+ * }
22
+ *
23
+ * Tokens are NOT secrets in the cryptographic sense — they're
24
+ * single-attempt short-lived strings on the local filesystem, used to
25
+ * avoid embedding session UUIDs in spawn directives the user copy-pastes.
26
+ */
27
+
28
+ const crypto = require('crypto');
29
+ const fs = require('fs');
30
+ const path = require('path');
31
+
32
+ const manifestLib = require('./manifest');
33
+
34
+ const TOKENS_DIR = 'tokens';
35
+ const VALID_KINDS = Object.freeze(['focus', 'join']);
36
+ const TOKEN_HEX = /^[0-9a-f]{16}$/;
37
+ const SLUG = /^[a-z][a-z0-9-]*$/;
38
+ const SWARM_ID = /^[0-9]{4}-[a-z][a-z0-9-]*$/;
39
+ const ISO_DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/;
40
+
41
+ const DEFAULT_EXPIRY_MS = 60 * 60 * 1000; // 1 hour
42
+
43
+ function tokensDir(ecosystemRoot, swarmId) {
44
+ return path.join(manifestLib.swarmDir(ecosystemRoot, swarmId), TOKENS_DIR);
45
+ }
46
+
47
+ function tokenPath(ecosystemRoot, swarmId, token) {
48
+ return path.join(tokensDir(ecosystemRoot, swarmId), `${token}.json`);
49
+ }
50
+
51
+ function ensureLayout(ecosystemRoot, swarmId) {
52
+ manifestLib.ensureSwarmLayout(ecosystemRoot, swarmId);
53
+ }
54
+
55
+ function newTokenString() {
56
+ return crypto.randomBytes(8).toString('hex');
57
+ }
58
+
59
+ function isoFromMs(ms) {
60
+ return new Date(ms).toISOString();
61
+ }
62
+
63
+ function nowMsFromIso(iso) {
64
+ const t = Date.parse(iso);
65
+ if (Number.isNaN(t)) {
66
+ throw new TypeError(`tokens: invalid ISO timestamp ${JSON.stringify(iso)}`);
67
+ }
68
+ return t;
69
+ }
70
+
71
+ function validateToken(obj) {
72
+ const errors = [];
73
+ const push = (p, m) => errors.push({ path: p, message: m });
74
+
75
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
76
+ return { ok: false, errors: [{ path: '$', message: 'token must be a JSON object' }] };
77
+ }
78
+ if (typeof obj.token !== 'string' || !TOKEN_HEX.test(obj.token)) {
79
+ push('$.token', 'must be 16-hex');
80
+ }
81
+ if (obj.version !== 1) push('$.version', 'must be 1');
82
+ if (!VALID_KINDS.includes(obj.kind)) {
83
+ push('$.kind', `must be one of: ${VALID_KINDS.join(', ')}`);
84
+ }
85
+ if (typeof obj.issued_by !== 'string' || obj.issued_by.length === 0) {
86
+ push('$.issued_by', 'required non-empty string');
87
+ }
88
+ if (typeof obj.issued_at !== 'string' || !ISO_DATETIME.test(obj.issued_at)) {
89
+ push('$.issued_at', 'required ISO-8601 date-time');
90
+ }
91
+ if (typeof obj.expires_at !== 'string' || !ISO_DATETIME.test(obj.expires_at)) {
92
+ push('$.expires_at', 'required ISO-8601 date-time');
93
+ }
94
+ if (obj.kind === 'focus') {
95
+ if (typeof obj.target_repo !== 'string' || !SLUG.test(obj.target_repo)) {
96
+ push('$.target_repo', 'required slug for kind=focus');
97
+ }
98
+ }
99
+ if (obj.kind === 'join') {
100
+ if (typeof obj.swarm_id !== 'string' || !SWARM_ID.test(obj.swarm_id)) {
101
+ push('$.swarm_id', 'required swarm-id for kind=join');
102
+ }
103
+ }
104
+ return errors.length === 0 ? { ok: true } : { ok: false, errors };
105
+ }
106
+
107
+ /**
108
+ * Issue a token and persist it. Returns the full token record.
109
+ *
110
+ * @param {object} args
111
+ * @param {string} args.ecosystemRoot
112
+ * @param {string} args.swarmId
113
+ * @param {'focus'|'join'} args.kind
114
+ * @param {string} args.issuedBy session id
115
+ * @param {string} args.nowIso ISO timestamp
116
+ * @param {string} [args.targetRepo] required for kind=focus
117
+ * @param {string} [args.targetSwarmId] required for kind=join; defaults to swarmId
118
+ * @param {number} [args.expiresInMs] default 1 hour
119
+ */
120
+ function writeToken(args) {
121
+ const {
122
+ ecosystemRoot, swarmId, kind, issuedBy, nowIso,
123
+ targetRepo, targetSwarmId, expiresInMs = DEFAULT_EXPIRY_MS,
124
+ } = args;
125
+ ensureLayout(ecosystemRoot, swarmId);
126
+ const token = newTokenString();
127
+ const issuedMs = nowMsFromIso(nowIso);
128
+ const record = {
129
+ token,
130
+ version: 1,
131
+ kind,
132
+ issued_by: issuedBy,
133
+ issued_at: nowIso,
134
+ expires_at: isoFromMs(issuedMs + expiresInMs),
135
+ };
136
+ if (kind === 'focus') record.target_repo = targetRepo;
137
+ if (kind === 'join') record.swarm_id = targetSwarmId || swarmId;
138
+
139
+ const v = validateToken(record);
140
+ if (!v.ok) {
141
+ const summary = v.errors.map((e) => ` ${e.path}: ${e.message}`).join('\n');
142
+ throw new Error(`writeToken: validation failed:\n${summary}`);
143
+ }
144
+ const file = tokenPath(ecosystemRoot, swarmId, token);
145
+ manifestLib.withLock(file, () => {
146
+ fs.writeFileSync(file, JSON.stringify(record, null, 2) + '\n', 'utf8');
147
+ });
148
+ return record;
149
+ }
150
+
151
+ /**
152
+ * Read a token without consuming it. Returns null if missing.
153
+ * Validates and surfaces expiry.
154
+ */
155
+ function readToken(ecosystemRoot, swarmId, token, nowIso) {
156
+ const file = tokenPath(ecosystemRoot, swarmId, token);
157
+ if (!fs.existsSync(file)) return null;
158
+ const raw = fs.readFileSync(file, 'utf8');
159
+ const record = JSON.parse(raw);
160
+ const v = validateToken(record);
161
+ if (!v.ok) {
162
+ const summary = v.errors.map((e) => ` ${e.path}: ${e.message}`).join('\n');
163
+ throw new Error(`readToken: invalid token at ${file}:\n${summary}`);
164
+ }
165
+ const expired = nowMsFromIso(nowIso) > nowMsFromIso(record.expires_at);
166
+ return { ...record, expired };
167
+ }
168
+
169
+ /**
170
+ * Read and delete a token in one mkdir-locked step. Throws if the
171
+ * token does not exist or is expired.
172
+ */
173
+ function consumeToken(ecosystemRoot, swarmId, token, nowIso) {
174
+ const file = tokenPath(ecosystemRoot, swarmId, token);
175
+ if (!fs.existsSync(file)) {
176
+ throw new Error(`consumeToken: token ${token} not found in swarm ${swarmId}`);
177
+ }
178
+ return manifestLib.withLock(file, () => {
179
+ const raw = fs.readFileSync(file, 'utf8');
180
+ const record = JSON.parse(raw);
181
+ const v = validateToken(record);
182
+ if (!v.ok) {
183
+ const summary = v.errors.map((e) => ` ${e.path}: ${e.message}`).join('\n');
184
+ throw new Error(`consumeToken: invalid token at ${file}:\n${summary}`);
185
+ }
186
+ if (nowMsFromIso(nowIso) > nowMsFromIso(record.expires_at)) {
187
+ throw new Error(`consumeToken: token ${token} expired at ${record.expires_at}`);
188
+ }
189
+ fs.unlinkSync(file);
190
+ return record;
191
+ });
192
+ }
193
+
194
+ /**
195
+ * Sweep expired tokens. Returns the list of removed token strings.
196
+ */
197
+ function purgeExpired(ecosystemRoot, swarmId, nowIso) {
198
+ ensureLayout(ecosystemRoot, swarmId);
199
+ const dir = tokensDir(ecosystemRoot, swarmId);
200
+ const removed = [];
201
+ const nowMs = nowMsFromIso(nowIso);
202
+ for (const name of fs.readdirSync(dir)) {
203
+ if (!name.endsWith('.json')) continue;
204
+ const file = path.join(dir, name);
205
+ try {
206
+ const record = JSON.parse(fs.readFileSync(file, 'utf8'));
207
+ if (nowMs > nowMsFromIso(record.expires_at)) {
208
+ fs.unlinkSync(file);
209
+ removed.push(record.token);
210
+ }
211
+ } catch (_e) {
212
+ // skip malformed files
213
+ }
214
+ }
215
+ return removed;
216
+ }
217
+
218
+ /**
219
+ * List all current (non-expired) tokens. Read-only.
220
+ */
221
+ function listTokens(ecosystemRoot, swarmId, nowIso) {
222
+ ensureLayout(ecosystemRoot, swarmId);
223
+ const dir = tokensDir(ecosystemRoot, swarmId);
224
+ const nowMs = nowMsFromIso(nowIso);
225
+ const out = [];
226
+ for (const name of fs.readdirSync(dir)) {
227
+ if (!name.endsWith('.json')) continue;
228
+ const file = path.join(dir, name);
229
+ try {
230
+ const record = JSON.parse(fs.readFileSync(file, 'utf8'));
231
+ const expired = nowMs > nowMsFromIso(record.expires_at);
232
+ out.push({ ...record, expired });
233
+ } catch (_e) {
234
+ // skip
235
+ }
236
+ }
237
+ out.sort((a, b) => (a.issued_at < b.issued_at ? -1 : 1));
238
+ return out;
239
+ }
240
+
241
+ module.exports = {
242
+ TOKENS_DIR,
243
+ VALID_KINDS,
244
+ DEFAULT_EXPIRY_MS,
245
+ tokensDir,
246
+ tokenPath,
247
+ ensureLayout,
248
+ newTokenString,
249
+ validateToken,
250
+ writeToken,
251
+ readToken,
252
+ consumeToken,
253
+ purgeExpired,
254
+ listTokens,
255
+ };
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Wave ordering — topological sort from ecosystem.json dependency edges.
5
+ *
6
+ * Since Phase 21c this is a thin adapter over the recursive wave engine
7
+ * (core/waves/lib/waves.js, ADR-0003): identical algorithm, identical
8
+ * wave output, identical error strings — swarm is the engine's top-scale
9
+ * consumer. The public surface below is unchanged from Phase 17.
10
+ *
11
+ * Input: a list of impacted member ids + the ecosystem manifest's
12
+ * `dependencies` array ({ from: A, to: B } = A depends on B; B first).
13
+ * Output: an ordered list of waves, each `{ index, repos }` with repos
14
+ * sorted lexicographically for stable repeated runs.
15
+ */
16
+
17
+ const path = require('path');
18
+
19
+ const { computeWaveLayers } = require(path.join(__dirname, '..', '..', 'waves', 'lib', 'waves'));
20
+
21
+ /**
22
+ * @typedef {Object} EcoDependency
23
+ * @property {string} from — the dependent (must produce its work after `to`)
24
+ * @property {string} to — the dependency (must produce its work first)
25
+ * @property {string} [kind] — informational; not used by ordering
26
+ */
27
+
28
+ /**
29
+ * @param {string[]} impactedRepos — non-empty list of member ids
30
+ * @param {EcoDependency[]} dependencies — full ecosystem dependency array
31
+ * @returns {{ index: number, repos: string[] }[]}
32
+ */
33
+ function computeWaves(impactedRepos, dependencies) {
34
+ return computeWaveLayers(impactedRepos, dependencies, { label: 'computeWaves' })
35
+ .map(({ index, nodes }) => ({ index, repos: nodes }));
36
+ }
37
+
38
+ /**
39
+ * Given a list of all members and a dependency list, compute the
40
+ * waves for the entire ecosystem (used by /swarm verify + diagnostic
41
+ * tooling). Equivalent to `computeWaves(member_ids, dependencies)`.
42
+ */
43
+ function computeFullEcosystemWaves(ecosystemManifest) {
44
+ const members = (ecosystemManifest && Array.isArray(ecosystemManifest.members))
45
+ ? ecosystemManifest.members.map((m) => m && m.id).filter(Boolean)
46
+ : [];
47
+ const deps = (ecosystemManifest && Array.isArray(ecosystemManifest.dependencies))
48
+ ? ecosystemManifest.dependencies : [];
49
+ return computeWaves(members, deps);
50
+ }
51
+
52
+ module.exports = {
53
+ computeWaves,
54
+ computeFullEcosystemWaves,
55
+ };
@@ -0,0 +1,54 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/LiminaLabsAI/momentum/schemas/swarm-board-v1.json",
4
+ "title": "Momentum Swarm Board (Materialized Cache)",
5
+ "description": "Slim conductor-turn view derived from manifest.json + per-supervisor dispatch-run records. Regenerated on any write to manifest or a supervisor's saga step. Conductor reads ONLY this on most turns — naive manifest+briefs+history = ~290KB; this = ~3KB. The materializer lives in core/swarm/lib/board.js.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["swarm_id", "version", "saga_id", "mode", "rendered_at", "status", "repos", "inbox_count"],
9
+ "properties": {
10
+ "swarm_id": {"type": "string", "pattern": "^[0-9]{4}-[a-z][a-z0-9-]*$"},
11
+ "version": {"type": "integer", "const": 1},
12
+ "saga_id": {"type": "string", "pattern": "^sg_[a-z0-9]{4,16}$"},
13
+ "mode": {"type": "string", "enum": ["autopilot", "checkpoint", "interactive"]},
14
+ "rendered_at": {"type": "string", "format": "date-time"},
15
+ "status": {"type": "string", "enum": ["running", "paused", "complete", "cancelled", "failed"]},
16
+ "active_wave": {"type": "integer", "minimum": 1},
17
+ "repos": {
18
+ "type": "array",
19
+ "description": "One row per repo. Pre-rendered display strings — conductor doesn't compute anything.",
20
+ "items": {
21
+ "type": "object",
22
+ "additionalProperties": false,
23
+ "required": ["name", "wave", "status"],
24
+ "properties": {
25
+ "name": {"type": "string", "pattern": "^[a-z][a-z0-9-]*$"},
26
+ "wave": {"type": "integer", "minimum": 1},
27
+ "status": {"type": "string", "enum": ["queued", "running", "blocked", "complete", "failed", "cancelled"]},
28
+ "tasks": {"type": "string", "description": "Pre-rendered N/M string, e.g. \"3/9\"."},
29
+ "tokens": {"type": "string", "description": "Pre-rendered NkB/MkB string, e.g. \"42k/300k\"."},
30
+ "commits": {"type": "integer", "minimum": 0},
31
+ "current": {"type": "string"},
32
+ "inbox_count": {"type": "integer", "minimum": 0},
33
+ "waiting_on": {"type": "string", "description": "Pre-rendered comma-separated repo list."}
34
+ }
35
+ }
36
+ },
37
+ "inbox_count": {"type": "integer", "minimum": 0},
38
+ "recent_activity": {
39
+ "type": "array",
40
+ "maxItems": 10,
41
+ "description": "Last N audit events, newest first. Each is a one-line summary for the board UI.",
42
+ "items": {
43
+ "type": "object",
44
+ "additionalProperties": false,
45
+ "required": ["ts", "msg"],
46
+ "properties": {
47
+ "ts": {"type": "string", "description": "Short HH:MM display string, not full timestamp."},
48
+ "repo": {"type": "string", "pattern": "^[a-z][a-z0-9-]*$"},
49
+ "msg": {"type": "string"}
50
+ }
51
+ }
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,56 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/LiminaLabsAI/momentum/schemas/swarm-contract-v1.json",
4
+ "title": "Momentum Swarm Contract (Pact-Style Shape Declaration)",
5
+ "description": "Declares a cross-repo surface (REST endpoint, message type, library export). One file per surface at <eco>/swarms/<id>/contracts/<surface>.contract.json. Content-hashed; version-bumped on approval. Wave checkpoint verifies producer↔consumer shape agreement before letting the next wave start.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["surface", "version", "owner", "consumers", "kind", "shape", "content_hash"],
9
+ "properties": {
10
+ "surface": {
11
+ "type": "string",
12
+ "pattern": "^[a-z][a-z0-9-]*$",
13
+ "description": "Slug naming this surface (e.g. user-auth-token, billing-events). Used in manifest.contracts[].surface back-reference."
14
+ },
15
+ "version": {
16
+ "type": "integer",
17
+ "minimum": 1,
18
+ "description": "Bumped on every approved breaking shape change. v0.20.0 verifier is structural — semantic compatibility is the user's call."
19
+ },
20
+ "owner": {
21
+ "type": "string",
22
+ "pattern": "^[a-z][a-z0-9-]*$",
23
+ "description": "Ecosystem member id that produces this surface."
24
+ },
25
+ "consumers": {
26
+ "type": "array",
27
+ "minItems": 1,
28
+ "items": {"type": "string", "pattern": "^[a-z][a-z0-9-]*$"},
29
+ "description": "Member ids that depend on this surface. Wave ordering ensures owner is in an earlier wave than every consumer."
30
+ },
31
+ "kind": {
32
+ "type": "string",
33
+ "enum": ["http", "rpc", "event", "library", "schema", "other"],
34
+ "description": "Coarse category. \"http\": REST/GraphQL endpoint. \"rpc\": gRPC/etc. \"event\": Kafka/queue/etc. \"library\": exported function. \"schema\": JSON/Protobuf type. \"other\": opaque (the user-defined shape is authoritative)."
35
+ },
36
+ "shape": {
37
+ "description": "Free-form shape declaration. The verifier compares producer-declared shape against consumer-expected shape — structural diff only. For JSON-shaped contracts a nested object literal works; for libraries an exported-symbols list; for opaque \"other\" surfaces a content-hashed blob.",
38
+ "oneOf": [
39
+ {"type": "object"},
40
+ {"type": "array"},
41
+ {"type": "string"}
42
+ ]
43
+ },
44
+ "content_hash": {
45
+ "type": "string",
46
+ "pattern": "^[0-9a-f]{8,64}$",
47
+ "description": "SHA256-prefix of the canonical serialization of `shape`. The verifier compares hashes first; mismatches fall through to a structural diff for human review."
48
+ },
49
+ "created": {"type": "string", "format": "date-time"},
50
+ "approved_by_session": {
51
+ "type": "string",
52
+ "description": "Session id of the conductor that approved this contract version (checkpoint mode). Null in autopilot."
53
+ },
54
+ "note": {"type": "string", "description": "Free-form human note explaining the surface and any non-obvious shape decisions."}
55
+ }
56
+ }