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