@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,578 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Swarm manifest CRUD + structural validation.
5
+ *
6
+ * Mirrors the shape of `core/ecosystem/lib/index.js` and
7
+ * `core/ecosystem/lib/initiative.js`: zero-dependency, hand-rolled
8
+ * structural validator, mkdir-locked writes for race safety. The JSON
9
+ * Schema at `../schema/manifest.schema.json` is the authoritative
10
+ * contract; this validator is its operational counterpart.
11
+ *
12
+ * Locking: we use the same mkdir-based portable lock as
13
+ * `core/ecosystem/scripts/session-append.sh`. `mkdir` is atomic on
14
+ * POSIX filesystems and works across macOS + Linux without flock.
15
+ *
16
+ * Layout on disk:
17
+ * <ecosystem-root>/swarms/<swarm-id>/
18
+ * manifest.json ← this module
19
+ * board.json ← lib/board.js
20
+ * contracts/*.contract.json
21
+ * inbox/NNNN-<slug>.md + INDEX.md
22
+ * signals/ ← reserved (Phase 17.5)
23
+ * tokens/ ← reserved (Phase 17.5)
24
+ * details/<repo>.json
25
+ * changes/<id>.md
26
+ */
27
+
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+
31
+ const SWARMS_DIR = 'swarms';
32
+ const MANIFEST_FILENAME = 'manifest.json';
33
+ const RESERVED_DIRS = Object.freeze([
34
+ 'contracts',
35
+ 'inbox',
36
+ 'signals',
37
+ 'tokens',
38
+ 'details',
39
+ 'changes',
40
+ ]);
41
+
42
+ // ─────────────────────────────────────────────────────────────────────────────
43
+ // Locking
44
+ // ─────────────────────────────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * Acquire a mkdir-lock for the duration of `fn`. The lock is bound to a
48
+ * filesystem path (typically the manifest path). Returns whatever `fn`
49
+ * returns. Re-throws fn's exception after releasing the lock.
50
+ *
51
+ * @param {string} filePath the file being protected
52
+ * @param {() => T} fn work to do under the lock
53
+ * @returns {T}
54
+ */
55
+ function withLock(filePath, fn) {
56
+ const lockDir = `${filePath}.lock`;
57
+ const deadline = Date.now() + 5000; // 5s budget — same as session-append.sh
58
+ while (Date.now() < deadline) {
59
+ try {
60
+ fs.mkdirSync(lockDir);
61
+ try {
62
+ return fn();
63
+ } finally {
64
+ try { fs.rmdirSync(lockDir); } catch (_e) { /* best-effort */ }
65
+ }
66
+ } catch (err) {
67
+ if (err && err.code === 'EEXIST') {
68
+ // contention — short sleep
69
+ const wait = 50;
70
+ const end = Date.now() + wait;
71
+ // busy-wait — keep dependency-free; lock contention is rare
72
+ while (Date.now() < end) { /* spin */ }
73
+ continue;
74
+ }
75
+ throw err;
76
+ }
77
+ }
78
+ throw new Error(`swarm/manifest: could not acquire lock at ${lockDir} within budget`);
79
+ }
80
+
81
+ // ─────────────────────────────────────────────────────────────────────────────
82
+ // Path helpers
83
+ // ─────────────────────────────────────────────────────────────────────────────
84
+
85
+ function swarmDir(ecosystemRoot, swarmId) {
86
+ return path.join(ecosystemRoot, SWARMS_DIR, swarmId);
87
+ }
88
+
89
+ function manifestPath(ecosystemRoot, swarmId) {
90
+ return path.join(swarmDir(ecosystemRoot, swarmId), MANIFEST_FILENAME);
91
+ }
92
+
93
+ function ensureSwarmLayout(ecosystemRoot, swarmId) {
94
+ const root = swarmDir(ecosystemRoot, swarmId);
95
+ fs.mkdirSync(root, { recursive: true });
96
+ for (const d of RESERVED_DIRS) {
97
+ fs.mkdirSync(path.join(root, d), { recursive: true });
98
+ }
99
+ return root;
100
+ }
101
+
102
+ // ─────────────────────────────────────────────────────────────────────────────
103
+ // ID generators
104
+ // ─────────────────────────────────────────────────────────────────────────────
105
+
106
+ /**
107
+ * Next zero-padded swarm id under `<ecosystem-root>/swarms/`. Scans
108
+ * directory names matching `NNNN-<slug>`; returns `NNNN+1-<newSlug>`.
109
+ */
110
+ function nextSwarmId(ecosystemRoot, slug) {
111
+ if (typeof slug !== 'string' || !/^[a-z][a-z0-9-]*$/.test(slug)) {
112
+ throw new TypeError(`nextSwarmId: slug must match /^[a-z][a-z0-9-]*$/ (got ${JSON.stringify(slug)})`);
113
+ }
114
+ const dir = path.join(ecosystemRoot, SWARMS_DIR);
115
+ let max = 0;
116
+ if (fs.existsSync(dir)) {
117
+ for (const name of fs.readdirSync(dir)) {
118
+ const m = name.match(/^(\d{4})-[a-z][a-z0-9-]*$/);
119
+ if (m) {
120
+ const n = parseInt(m[1], 10);
121
+ if (n > max) max = n;
122
+ }
123
+ }
124
+ }
125
+ const next = String(max + 1).padStart(4, '0');
126
+ return `${next}-${slug}`;
127
+ }
128
+
129
+ /**
130
+ * Generate a saga id. Deterministic given a swarm id + a numeric seed
131
+ * (the seed is the current step counter; tests can pass 0 for
132
+ * reproducibility).
133
+ */
134
+ function makeSagaId(swarmId, seed) {
135
+ const s = `${swarmId}-${seed != null ? seed : 0}`;
136
+ let hash = 0;
137
+ for (let i = 0; i < s.length; i++) {
138
+ hash = (hash * 31 + s.charCodeAt(i)) >>> 0;
139
+ }
140
+ return `sg_${hash.toString(36).padStart(6, '0').slice(0, 8)}`;
141
+ }
142
+
143
+ // ─────────────────────────────────────────────────────────────────────────────
144
+ // Validation — operational counterpart to manifest.schema.json
145
+ // ─────────────────────────────────────────────────────────────────────────────
146
+
147
+ const SLUG = /^[a-z][a-z0-9-]*$/;
148
+ const SWARM_ID = /^[0-9]{4}-[a-z][a-z0-9-]*$/;
149
+ const SAGA_ID = /^sg_[a-z0-9]{4,16}$/;
150
+ const PHASE_SLUG = /^phase-[0-9]+(?:[a-z])?-[a-z][a-z0-9-]*$/;
151
+ const SHA_HEX = /^[0-9a-f]{7,40}$/;
152
+ const ISO_DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/;
153
+
154
+ const VALID_MODES = ['autopilot', 'checkpoint', 'interactive'];
155
+ const VALID_STATUSES = ['running', 'paused', 'complete', 'cancelled', 'failed'];
156
+ const VALID_REPO_STATUSES = ['queued', 'running', 'blocked', 'complete', 'failed', 'cancelled'];
157
+ const VALID_WAVE_STATUSES = ['queued', 'running', 'complete', 'cancelled'];
158
+ const VALID_CONTRACT_KINDS = ['http', 'rpc', 'event', 'library', 'schema', 'other'];
159
+ const VALID_AUDIT_EVENTS = [
160
+ 'start', 'tell', 'broadcast', 'budget', 'cancel', 'verify',
161
+ 'checkpoint', 'complete', 'resume', 'wave-transition',
162
+ 'inbox-resolved', 'contract-bump',
163
+ // Phase 17.5 portability
164
+ 'claim', 'release', 'focus', 'join', 'absorb', 'lease-takeover',
165
+ ];
166
+
167
+ function validateManifest(obj) {
168
+ const errors = [];
169
+ const push = (p, m) => errors.push({ path: p, message: m });
170
+
171
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
172
+ return { ok: false, errors: [{ path: '$', message: 'manifest must be a JSON object' }] };
173
+ }
174
+
175
+ // Top-level required fields
176
+ if (typeof obj.swarm_id !== 'string' || !SWARM_ID.test(obj.swarm_id)) {
177
+ push('$.swarm_id', 'must match /^[0-9]{4}-[a-z][a-z0-9-]*$/');
178
+ }
179
+ if (obj.version !== 1) push('$.version', 'must be the integer 1');
180
+ if (typeof obj.saga_id !== 'string' || !SAGA_ID.test(obj.saga_id)) {
181
+ push('$.saga_id', 'must match /^sg_[a-z0-9]{4,16}$/');
182
+ }
183
+ if (!VALID_MODES.includes(obj.mode)) {
184
+ push('$.mode', `must be one of: ${VALID_MODES.join(', ')}`);
185
+ }
186
+ if (typeof obj.initiative !== 'string' || !SLUG.test(obj.initiative)) {
187
+ push('$.initiative', 'must match /^[a-z][a-z0-9-]*$/');
188
+ }
189
+ if (typeof obj.created !== 'string' || !ISO_DATETIME.test(obj.created)) {
190
+ push('$.created', 'must be ISO-8601 date-time');
191
+ }
192
+ if (typeof obj.ecosystem !== 'string' || !SLUG.test(obj.ecosystem)) {
193
+ push('$.ecosystem', 'must match /^[a-z][a-z0-9-]*$/');
194
+ }
195
+
196
+ // repos
197
+ if (obj.repos === null || typeof obj.repos !== 'object' || Array.isArray(obj.repos)) {
198
+ push('$.repos', 'must be an object keyed by member id');
199
+ } else {
200
+ const repoKeys = Object.keys(obj.repos);
201
+ if (repoKeys.length === 0) push('$.repos', 'must have at least one repo');
202
+ for (const key of repoKeys) {
203
+ if (!SLUG.test(key)) {
204
+ push(`$.repos["${key}"]`, 'key must match /^[a-z][a-z0-9-]*$/');
205
+ continue;
206
+ }
207
+ const r = obj.repos[key];
208
+ const base = `$.repos.${key}`;
209
+ if (r === null || typeof r !== 'object' || Array.isArray(r)) {
210
+ push(base, 'must be an object');
211
+ continue;
212
+ }
213
+ if (!Number.isInteger(r.wave) || r.wave < 1) {
214
+ push(`${base}.wave`, 'must be a positive integer');
215
+ }
216
+ if (!VALID_REPO_STATUSES.includes(r.status)) {
217
+ push(`${base}.status`, `must be one of: ${VALID_REPO_STATUSES.join(', ')}`);
218
+ }
219
+ if (typeof r.phase_slug !== 'string' || !PHASE_SLUG.test(r.phase_slug)) {
220
+ push(`${base}.phase_slug`, 'must match phase slug pattern');
221
+ }
222
+ if (typeof r.branch !== 'string' || r.branch.length === 0) {
223
+ push(`${base}.branch`, 'required non-empty string');
224
+ }
225
+ if (typeof r.owner !== 'string' || r.owner.length === 0) {
226
+ push(`${base}.owner`, 'required non-empty string');
227
+ }
228
+ if (r.last_seen_sha !== undefined && (typeof r.last_seen_sha !== 'string' || !SHA_HEX.test(r.last_seen_sha))) {
229
+ push(`${base}.last_seen_sha`, 'must be a 7–40 char hex SHA when present');
230
+ }
231
+ if (r.lease_expires_at !== undefined && (typeof r.lease_expires_at !== 'string' || !ISO_DATETIME.test(r.lease_expires_at))) {
232
+ push(`${base}.lease_expires_at`, 'must be ISO-8601 date-time when present');
233
+ }
234
+ if (r.lease_renewed_at !== undefined && (typeof r.lease_renewed_at !== 'string' || !ISO_DATETIME.test(r.lease_renewed_at))) {
235
+ push(`${base}.lease_renewed_at`, 'must be ISO-8601 date-time when present');
236
+ }
237
+ if (r.tokens_budget !== undefined && (!Number.isInteger(r.tokens_budget) || r.tokens_budget < 1)) {
238
+ push(`${base}.tokens_budget`, 'must be a positive integer when present');
239
+ }
240
+ }
241
+ }
242
+
243
+ // waves
244
+ if (!Array.isArray(obj.waves) || obj.waves.length === 0) {
245
+ push('$.waves', 'required non-empty array');
246
+ } else {
247
+ const allRepoIds = new Set(
248
+ obj.repos && typeof obj.repos === 'object' && !Array.isArray(obj.repos)
249
+ ? Object.keys(obj.repos) : []
250
+ );
251
+ const seenIndices = new Set();
252
+ obj.waves.forEach((w, i) => {
253
+ const base = `$.waves[${i}]`;
254
+ if (w === null || typeof w !== 'object' || Array.isArray(w)) {
255
+ push(base, 'must be an object');
256
+ return;
257
+ }
258
+ if (!Number.isInteger(w.index) || w.index < 1) {
259
+ push(`${base}.index`, 'must be a positive integer');
260
+ } else {
261
+ if (seenIndices.has(w.index)) push(`${base}.index`, 'duplicate wave index');
262
+ seenIndices.add(w.index);
263
+ }
264
+ if (!Array.isArray(w.repos) || w.repos.length === 0) {
265
+ push(`${base}.repos`, 'required non-empty array');
266
+ } else {
267
+ w.repos.forEach((rid, j) => {
268
+ if (typeof rid !== 'string' || !SLUG.test(rid)) {
269
+ push(`${base}.repos[${j}]`, 'must be a slug');
270
+ } else if (!allRepoIds.has(rid)) {
271
+ push(`${base}.repos[${j}]`, `references unknown repo "${rid}"`);
272
+ }
273
+ });
274
+ }
275
+ if (w.status !== undefined && !VALID_WAVE_STATUSES.includes(w.status)) {
276
+ push(`${base}.status`, `must be one of: ${VALID_WAVE_STATUSES.join(', ')}`);
277
+ }
278
+ });
279
+ }
280
+
281
+ // sessions (optional)
282
+ if (obj.sessions !== undefined) {
283
+ if (!Array.isArray(obj.sessions)) {
284
+ push('$.sessions', 'must be array when present');
285
+ } else {
286
+ obj.sessions.forEach((s, i) => {
287
+ const base = `$.sessions[${i}]`;
288
+ if (s === null || typeof s !== 'object') {
289
+ push(base, 'must be an object');
290
+ return;
291
+ }
292
+ if (typeof s.session_id !== 'string' || s.session_id.length === 0) {
293
+ push(`${base}.session_id`, 'required non-empty string');
294
+ }
295
+ if (typeof s.first_seen !== 'string' || !ISO_DATETIME.test(s.first_seen)) {
296
+ push(`${base}.first_seen`, 'required ISO-8601 date-time');
297
+ }
298
+ });
299
+ }
300
+ }
301
+
302
+ // contracts (optional)
303
+ if (obj.contracts !== undefined) {
304
+ if (!Array.isArray(obj.contracts)) {
305
+ push('$.contracts', 'must be array when present');
306
+ } else {
307
+ obj.contracts.forEach((c, i) => {
308
+ const base = `$.contracts[${i}]`;
309
+ if (c === null || typeof c !== 'object') {
310
+ push(base, 'must be an object');
311
+ return;
312
+ }
313
+ if (typeof c.surface !== 'string' || !SLUG.test(c.surface)) {
314
+ push(`${base}.surface`, 'must be a slug');
315
+ }
316
+ if (typeof c.owner !== 'string' || !SLUG.test(c.owner)) {
317
+ push(`${base}.owner`, 'must be a slug');
318
+ }
319
+ if (!Array.isArray(c.consumers) || c.consumers.some((s) => typeof s !== 'string' || !SLUG.test(s))) {
320
+ push(`${base}.consumers`, 'must be array of slugs');
321
+ }
322
+ if (!Number.isInteger(c.version) || c.version < 1) {
323
+ push(`${base}.version`, 'must be a positive integer');
324
+ }
325
+ });
326
+ }
327
+ }
328
+
329
+ // audit (optional)
330
+ if (obj.audit !== undefined) {
331
+ if (!Array.isArray(obj.audit)) {
332
+ push('$.audit', 'must be array when present');
333
+ } else {
334
+ obj.audit.forEach((a, i) => {
335
+ const base = `$.audit[${i}]`;
336
+ if (a === null || typeof a !== 'object') { push(base, 'must be an object'); return; }
337
+ if (typeof a.ts !== 'string' || !ISO_DATETIME.test(a.ts)) push(`${base}.ts`, 'must be ISO-8601 date-time');
338
+ if (typeof a.actor !== 'string' || a.actor.length === 0) push(`${base}.actor`, 'required non-empty string');
339
+ if (!VALID_AUDIT_EVENTS.includes(a.event)) push(`${base}.event`, `must be one of: ${VALID_AUDIT_EVENTS.join(', ')}`);
340
+ });
341
+ }
342
+ }
343
+
344
+ // status (optional, derived but persisted)
345
+ if (obj.status !== undefined && !VALID_STATUSES.includes(obj.status)) {
346
+ push('$.status', `must be one of: ${VALID_STATUSES.join(', ')}`);
347
+ }
348
+
349
+ return errors.length === 0 ? { ok: true } : { ok: false, errors };
350
+ }
351
+
352
+ // ─────────────────────────────────────────────────────────────────────────────
353
+ // CRUD
354
+ // ─────────────────────────────────────────────────────────────────────────────
355
+
356
+ function loadManifest(ecosystemRoot, swarmId) {
357
+ const file = manifestPath(ecosystemRoot, swarmId);
358
+ if (!fs.existsSync(file)) return null;
359
+ const raw = fs.readFileSync(file, 'utf8');
360
+ let parsed;
361
+ try {
362
+ parsed = JSON.parse(raw);
363
+ } catch (err) {
364
+ throw new SyntaxError(`swarm/manifest: invalid JSON in ${file}: ${err.message}`);
365
+ }
366
+ return parsed;
367
+ }
368
+
369
+ /**
370
+ * Initial write. Validates and writes atomically under a mkdir-lock.
371
+ * Throws on validation failure.
372
+ */
373
+ function writeManifest(ecosystemRoot, swarmId, manifest) {
374
+ const v = validateManifest(manifest);
375
+ if (!v.ok) {
376
+ const summary = v.errors.map((e) => ` ${e.path}: ${e.message}`).join('\n');
377
+ throw new Error(`swarm/manifest: validation failed:\n${summary}`);
378
+ }
379
+ ensureSwarmLayout(ecosystemRoot, swarmId);
380
+ const file = manifestPath(ecosystemRoot, swarmId);
381
+ return withLock(file, () => {
382
+ fs.writeFileSync(file, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
383
+ return file;
384
+ });
385
+ }
386
+
387
+ /**
388
+ * Read-modify-write under a mkdir-lock. `mutate` receives the current
389
+ * manifest object and may mutate it in-place or return a fresh object.
390
+ * Re-validates before persisting.
391
+ */
392
+ function updateManifest(ecosystemRoot, swarmId, mutate) {
393
+ const file = manifestPath(ecosystemRoot, swarmId);
394
+ if (!fs.existsSync(file)) {
395
+ throw new Error(`swarm/manifest: no manifest at ${file}`);
396
+ }
397
+ return withLock(file, () => {
398
+ const current = JSON.parse(fs.readFileSync(file, 'utf8'));
399
+ const result = mutate(current);
400
+ const next = result === undefined ? current : result;
401
+ const v = validateManifest(next);
402
+ if (!v.ok) {
403
+ const summary = v.errors.map((e) => ` ${e.path}: ${e.message}`).join('\n');
404
+ throw new Error(`swarm/manifest: post-mutate validation failed:\n${summary}`);
405
+ }
406
+ fs.writeFileSync(file, JSON.stringify(next, null, 2) + '\n', 'utf8');
407
+ return next;
408
+ });
409
+ }
410
+
411
+ /**
412
+ * Append an entry to the audit array atomically.
413
+ */
414
+ function appendAudit(ecosystemRoot, swarmId, entry) {
415
+ return updateManifest(ecosystemRoot, swarmId, (m) => {
416
+ if (!Array.isArray(m.audit)) m.audit = [];
417
+ m.audit.push(entry);
418
+ });
419
+ }
420
+
421
+ // ─────────────────────────────────────────────────────────────────────────────
422
+ // Lease enforcement (Phase 17.5)
423
+ // ─────────────────────────────────────────────────────────────────────────────
424
+
425
+ /**
426
+ * Special owner sentinel meaning "ready to be claimed by the next
427
+ * caller". Set by `/swarm release` and as the transitional state during
428
+ * `/swarm focus` before the receiver consumes the transfer token.
429
+ */
430
+ const UNCLAIMED = '_unclaimed';
431
+
432
+ /**
433
+ * Special owner sentinel meaning "the previous owner has issued a
434
+ * focus token and is waiting for the receiver to claim". Treated the
435
+ * same as UNCLAIMED for ownership purposes (any session with a valid
436
+ * token can take it).
437
+ */
438
+ const FOCUSING = '_focusing';
439
+
440
+ const TAKEOVER_OWNERS = new Set([UNCLAIMED, FOCUSING]);
441
+
442
+ /**
443
+ * Pure helper. Decides whether `sessionId` may write to `repo` per
444
+ * the lease rules. Returns `{ allowed: bool, reason: string, expired: bool }`.
445
+ *
446
+ * - allowed when:
447
+ * (a) sessionId === repos[repo].owner (current owner writing)
448
+ * (b) repos[repo].owner is the UNCLAIMED or FOCUSING sentinel
449
+ * (c) lease_expires_at is set AND nowIso > lease_expires_at (takeover)
450
+ * - rejected otherwise.
451
+ *
452
+ * The takeover case sets `expired: true` so callers can decide whether
453
+ * to emit a `lease-expired` signal.
454
+ *
455
+ * @param {object} manifest a loaded manifest
456
+ * @param {string} repo repo key
457
+ * @param {string} sessionId caller session id
458
+ * @param {string} nowIso current time
459
+ */
460
+ function assertOwnership(manifest, repo, sessionId, nowIso) {
461
+ if (!manifest || !manifest.repos || !manifest.repos[repo]) {
462
+ return { allowed: false, reason: `repo "${repo}" not in manifest`, expired: false };
463
+ }
464
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
465
+ return { allowed: false, reason: 'sessionId required', expired: false };
466
+ }
467
+ const r = manifest.repos[repo];
468
+ if (r.owner === sessionId) {
469
+ return { allowed: true, reason: 'owner', expired: false };
470
+ }
471
+ if (TAKEOVER_OWNERS.has(r.owner)) {
472
+ return { allowed: true, reason: `unclaimed (${r.owner})`, expired: false };
473
+ }
474
+ if (typeof r.lease_expires_at === 'string') {
475
+ if (typeof nowIso !== 'string') {
476
+ return { allowed: false, reason: 'nowIso required to evaluate lease', expired: false };
477
+ }
478
+ const now = Date.parse(nowIso);
479
+ const exp = Date.parse(r.lease_expires_at);
480
+ if (!Number.isNaN(now) && !Number.isNaN(exp) && now > exp) {
481
+ return { allowed: true, reason: `lease expired at ${r.lease_expires_at}`, expired: true };
482
+ }
483
+ }
484
+ return {
485
+ allowed: false,
486
+ reason: `repo "${repo}" is owned by ${r.owner} (lease valid)`,
487
+ expired: false,
488
+ };
489
+ }
490
+
491
+ /**
492
+ * Read-modify-write that enforces ownership of a specific repo BEFORE
493
+ * applying the mutation. Throws on rejection.
494
+ *
495
+ * Use this for any mutation that flips `repos[repo].owner`, advances
496
+ * the repo's status, or renews its lease — i.e. anything where the
497
+ * caller is claiming authority over the repo.
498
+ *
499
+ * The `mutate` callback receives the manifest AFTER the ownership
500
+ * check passes; it may modify `repos[repo]` freely (the usual move is
501
+ * to set `owner = sessionId` and refresh the lease).
502
+ *
503
+ * Returns the resulting manifest. The mutate function may also return
504
+ * an object `{ then: fn }` — `then` runs AFTER the locked write
505
+ * commits, useful for emitting a signal that depends on the new state.
506
+ */
507
+ function updateManifestAsOwner(args) {
508
+ const { ecosystemRoot, swarmId, sessionId, repo, nowIso, mutate } = args;
509
+ const file = manifestPath(ecosystemRoot, swarmId);
510
+ if (!fs.existsSync(file)) {
511
+ throw new Error(`swarm/manifest: no manifest at ${file}`);
512
+ }
513
+ let postCommit = null;
514
+ const next = withLock(file, () => {
515
+ const current = JSON.parse(fs.readFileSync(file, 'utf8'));
516
+ const decision = assertOwnership(current, repo, sessionId, nowIso);
517
+ if (!decision.allowed) {
518
+ const err = new Error(`updateManifestAsOwner: rejected — ${decision.reason}`);
519
+ err.code = 'EOWNERSHIP';
520
+ err.decision = decision;
521
+ throw err;
522
+ }
523
+ const result = mutate(current, decision);
524
+ let mutated = current;
525
+ if (result && typeof result === 'object' && 'manifest' in result) {
526
+ mutated = result.manifest;
527
+ postCommit = result.then || null;
528
+ } else if (result !== undefined) {
529
+ mutated = result;
530
+ }
531
+ const v = validateManifest(mutated);
532
+ if (!v.ok) {
533
+ const summary = v.errors.map((e) => ` ${e.path}: ${e.message}`).join('\n');
534
+ throw new Error(`swarm/manifest: post-mutate validation failed:\n${summary}`);
535
+ }
536
+ fs.writeFileSync(file, JSON.stringify(mutated, null, 2) + '\n', 'utf8');
537
+ return mutated;
538
+ });
539
+ if (typeof postCommit === 'function') {
540
+ postCommit(next);
541
+ }
542
+ return next;
543
+ }
544
+
545
+ // ─────────────────────────────────────────────────────────────────────────────
546
+ // Listing
547
+ // ─────────────────────────────────────────────────────────────────────────────
548
+
549
+ function listSwarms(ecosystemRoot) {
550
+ const dir = path.join(ecosystemRoot, SWARMS_DIR);
551
+ if (!fs.existsSync(dir)) return [];
552
+ return fs.readdirSync(dir, { withFileTypes: true })
553
+ .filter((e) => e.isDirectory() && /^[0-9]{4}-[a-z][a-z0-9-]*$/.test(e.name))
554
+ .map((e) => e.name)
555
+ .sort();
556
+ }
557
+
558
+ module.exports = {
559
+ SWARMS_DIR,
560
+ MANIFEST_FILENAME,
561
+ RESERVED_DIRS,
562
+ UNCLAIMED,
563
+ FOCUSING,
564
+ withLock,
565
+ swarmDir,
566
+ manifestPath,
567
+ ensureSwarmLayout,
568
+ nextSwarmId,
569
+ makeSagaId,
570
+ validateManifest,
571
+ loadManifest,
572
+ writeManifest,
573
+ updateManifest,
574
+ updateManifestAsOwner,
575
+ assertOwnership,
576
+ appendAudit,
577
+ listSwarms,
578
+ };
@@ -0,0 +1,112 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Pre-merge preview at fan-in.
5
+ *
6
+ * At swarm completion (or at a wave checkpoint where two sibling
7
+ * branches would merge into a common base), preview the merge to
8
+ * surface conflicts BEFORE the destructive commit. The conductor
9
+ * never actually merges — it surfaces conflicts as inbox items the
10
+ * user resolves before authorizing the real merge.
11
+ *
12
+ * The preview shells out:
13
+ *
14
+ * git merge --no-commit --no-ff <sibling-branch>
15
+ *
16
+ * If there are conflicts, capture the marker file list. Always run
17
+ * `git merge --abort` to leave the working tree clean.
18
+ *
19
+ * This module never writes to disk outside the repo's own git state
20
+ * (which `merge --abort` cleans up). It returns a structured result
21
+ * the caller can render into an inbox item.
22
+ */
23
+
24
+ const { spawnSync } = require('child_process');
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+
28
+ /**
29
+ * @param {object} args
30
+ * @param {string} args.baseRepoPath absolute path to repo on the base/merge-target branch
31
+ * @param {string} args.sourceBranch branch to preview-merge
32
+ * @param {string} [args.targetBranch] branch baseRepoPath should already be on (default: current HEAD)
33
+ * @returns {{ ok: boolean, conflictedFiles: string[], summary: string, error?: string }}
34
+ */
35
+ function previewMerge(args) {
36
+ const { baseRepoPath, sourceBranch } = args;
37
+ if (typeof baseRepoPath !== 'string' || !fs.existsSync(path.join(baseRepoPath, '.git'))) {
38
+ return { ok: false, conflictedFiles: [], summary: 'not a git repo', error: `${baseRepoPath} is not a git repo` };
39
+ }
40
+ if (typeof sourceBranch !== 'string' || sourceBranch.length === 0) {
41
+ return { ok: false, conflictedFiles: [], summary: 'sourceBranch required', error: 'sourceBranch required' };
42
+ }
43
+
44
+ // Ensure the source branch exists locally
45
+ const exists = spawnSync('git', ['rev-parse', '--verify', `refs/heads/${sourceBranch}`], {
46
+ cwd: baseRepoPath, encoding: 'utf8',
47
+ });
48
+ if (exists.status !== 0) {
49
+ return {
50
+ ok: false, conflictedFiles: [],
51
+ summary: `source branch ${sourceBranch} not found`,
52
+ error: (exists.stderr || '').trim(),
53
+ };
54
+ }
55
+
56
+ const merge = spawnSync('git', ['merge', '--no-commit', '--no-ff', sourceBranch], {
57
+ cwd: baseRepoPath, encoding: 'utf8', timeout: 10000,
58
+ });
59
+
60
+ // Capture conflicted file list — always, then always abort
61
+ const status = spawnSync('git', ['diff', '--name-only', '--diff-filter=U'], {
62
+ cwd: baseRepoPath, encoding: 'utf8',
63
+ });
64
+ const conflictedFiles = (status.stdout || '')
65
+ .split('\n').map((s) => s.trim()).filter(Boolean);
66
+
67
+ // Abort to clean working tree
68
+ spawnSync('git', ['merge', '--abort'], { cwd: baseRepoPath });
69
+
70
+ if (merge.status === 0 && conflictedFiles.length === 0) {
71
+ return {
72
+ ok: true, conflictedFiles: [],
73
+ summary: `clean merge — ${sourceBranch} merges into HEAD without conflicts`,
74
+ };
75
+ }
76
+
77
+ return {
78
+ ok: false,
79
+ conflictedFiles,
80
+ summary: conflictedFiles.length
81
+ ? `${conflictedFiles.length} file(s) conflict on merge of ${sourceBranch}`
82
+ : 'merge failed (no conflict markers — see error)',
83
+ error: (merge.stderr || '').trim(),
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Run a preview for every (repo, branch) pair the swarm is about to
89
+ * merge. The caller passes a list:
90
+ * [{ repo, repoPath, branch, intoBranch }, ...]
91
+ *
92
+ * Each entry: if intoBranch differs from the repo's current HEAD,
93
+ * checkout intoBranch first (the caller is responsible — this helper
94
+ * just runs the preview against the working state).
95
+ *
96
+ * @returns Array<{ repo, branch, result }>
97
+ */
98
+ function previewMergeBatch(entries) {
99
+ const out = [];
100
+ for (const e of entries) {
101
+ const result = previewMerge({
102
+ baseRepoPath: e.repoPath, sourceBranch: e.branch,
103
+ });
104
+ out.push({ repo: e.repo, branch: e.branch, intoBranch: e.intoBranch, result });
105
+ }
106
+ return out;
107
+ }
108
+
109
+ module.exports = {
110
+ previewMerge,
111
+ previewMergeBatch,
112
+ };