@limina-labs/momentum 0.36.0 → 0.37.0

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/bin/momentum.js CHANGED
@@ -2198,6 +2198,22 @@ async function main() {
2198
2198
  console.error(`\nError: ${err.message}`);
2199
2199
  exitCode = 1;
2200
2200
  }
2201
+ } else if (args[0] === 'claim') {
2202
+ try {
2203
+ const { runClaim } = require('./team');
2204
+ exitCode = runClaim(args.slice(1));
2205
+ } catch (err) {
2206
+ console.error(`\nError: ${err.message}`);
2207
+ exitCode = 1;
2208
+ }
2209
+ } else if (args[0] === 'team') {
2210
+ try {
2211
+ const { runTeam } = require('./team');
2212
+ exitCode = runTeam(args.slice(1));
2213
+ } catch (err) {
2214
+ console.error(`\nError: ${err.message}`);
2215
+ exitCode = 1;
2216
+ }
2201
2217
  } else {
2202
2218
  console.error(`Unknown command: ${args[0]}`);
2203
2219
  console.error('Run "momentum --help" for usage.');
package/bin/team.js ADDED
@@ -0,0 +1,223 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `momentum team` + `momentum claim` — git-native team coordination (Phase 30a
5
+ * Team-Walk, ADR-0012). No daemon; every command computes from git + files.
6
+ *
7
+ * momentum claim <namespace> <key> atomic cross-machine allocation (ref-CAS)
8
+ * momentum team whoami show the resolved durable actor
9
+ * momentum team sync fetch coordination refs + recompile views
10
+ */
11
+
12
+ const path = require('path');
13
+ const { spawnSync } = require('child_process');
14
+
15
+ const MOMENTUM_ROOT = path.resolve(__dirname, '..');
16
+ const claimLib = require(path.join(MOMENTUM_ROOT, 'core', 'team', 'lib', 'claim'));
17
+ const refcas = require(path.join(MOMENTUM_ROOT, 'core', 'team', 'lib', 'refcas'));
18
+ const compile = require(path.join(MOMENTUM_ROOT, 'core', 'team', 'lib', 'compile'));
19
+ const presenceLib = require(path.join(MOMENTUM_ROOT, 'core', 'team', 'lib', 'presence'));
20
+ const approvalsLib = require(path.join(MOMENTUM_ROOT, 'core', 'team', 'lib', 'approvals'));
21
+ const queueLib = require(path.join(MOMENTUM_ROOT, 'core', 'team', 'lib', 'queue'));
22
+ const leaseLib = require(path.join(MOMENTUM_ROOT, 'core', 'team', 'lib', 'lease'));
23
+ const { createRelay } = require(path.join(MOMENTUM_ROOT, 'core', 'team', 'relay', 'server'));
24
+ const { CONTRACT } = require(path.join(MOMENTUM_ROOT, 'core', 'team', 'contract'));
25
+ const identity = require(path.join(MOMENTUM_ROOT, 'core', 'identity'));
26
+
27
+ function repoRoot(cwd) {
28
+ const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
29
+ return r.status === 0 ? r.stdout.trim() : null;
30
+ }
31
+
32
+ function parseFlags(argv) {
33
+ const flags = {};
34
+ const positional = [];
35
+ for (let i = 0; i < argv.length; i++) {
36
+ const a = argv[i];
37
+ if (a === '--json') flags.json = true;
38
+ else if (a.startsWith('--')) flags[a.slice(2)] = argv[++i];
39
+ else positional.push(a);
40
+ }
41
+ return { flags, positional };
42
+ }
43
+
44
+ function insideRepo(cwd) {
45
+ return spawnSync('git', ['rev-parse', '--git-dir'], { cwd, encoding: 'utf8' }).status === 0;
46
+ }
47
+
48
+ // ─── momentum claim ────────────────────────────────────────────────────────
49
+
50
+ function runClaim(argv) {
51
+ const { flags, positional } = parseFlags(argv);
52
+ const [namespace, key] = positional;
53
+ if (!namespace || !key) {
54
+ console.error('usage: momentum claim <namespace> <key> [--remote origin] [--actor id]');
55
+ console.error(' e.g. momentum claim phase 30a | momentum claim version 0.37.0 | momentum claim id BUG-028');
56
+ return 1;
57
+ }
58
+ const cwd = process.cwd();
59
+ if (!insideRepo(cwd)) { console.error('✗ not inside a git repository'); return 1; }
60
+ const actor = flags.actor || identity.resolveActor(cwd).id;
61
+ let res;
62
+ try {
63
+ res = claimLib.claimKey(cwd, namespace, key, { remote: flags.remote, actor });
64
+ } catch (err) {
65
+ console.error(`✗ claim failed: ${err.message}`);
66
+ return 1;
67
+ }
68
+ if (res.won) {
69
+ console.log(`✓ claimed ${namespace}/${key} as '${actor}' (${res.ref})`);
70
+ return 0;
71
+ }
72
+ const who = res.winner && res.winner.actor ? res.winner.actor : 'someone else';
73
+ console.error(`✗ ${namespace}/${key} already claimed by '${who}' — pick another`);
74
+ return 2; // distinct from usage(1) so recipes can gate on "lost"
75
+ }
76
+
77
+ // ─── momentum team ─────────────────────────────────────────────────────────
78
+
79
+ function runTeam(argv) {
80
+ const sub = argv[0];
81
+ const cwd = process.cwd();
82
+ if (sub === 'whoami') {
83
+ const a = identity.resolveActor(cwd);
84
+ console.log(`${a.id} (source: ${a.source}${a.email ? `, ${a.email}` : ''})`);
85
+ return 0;
86
+ }
87
+ if (sub === 'sync') {
88
+ if (!insideRepo(cwd)) { console.error('✗ not inside a git repository'); return 1; }
89
+ const remote = argv[1] && !argv[1].startsWith('--') ? argv[1] : 'origin';
90
+ const ok = refcas.fetchAll(cwd, remote);
91
+ const root = repoRoot(cwd);
92
+ const rec = root ? compile.compileStatusFile(root) : { changed: false };
93
+ console.log(ok
94
+ ? `✓ synced coordination refs from ${remote} (refs/momentum/*). Fragments arrive via branch integration.`
95
+ : `⚠ could not fetch from ${remote} — working offline; coordination is eventually consistent`);
96
+ if (rec.changed) console.log('✓ recompiled the Active-Phase table in specs/status.md');
97
+ return 0;
98
+ }
99
+ if (sub === 'compile') {
100
+ const root = repoRoot(cwd);
101
+ if (!root) { console.error('✗ not inside a git repository'); return 1; }
102
+ const rec = compile.compileStatusFile(root);
103
+ console.log(rec.path
104
+ ? (rec.changed ? '✓ recompiled Active-Phase table in specs/status.md' : '✓ specs/status.md already current')
105
+ : 'ℹ no specs/status.md to compile into');
106
+ return 0;
107
+ }
108
+ if (sub === 'board') {
109
+ const root = repoRoot(cwd);
110
+ if (!root) { console.error('✗ not inside a git repository'); return 1; }
111
+ console.log(compile.compileActivePhase(root));
112
+ return 0;
113
+ }
114
+ if (sub === 'record') {
115
+ const root = repoRoot(cwd);
116
+ if (!root) { console.error('✗ not inside a git repository'); return 1; }
117
+ const { flags } = parseFlags(argv.slice(1));
118
+ if (!flags.branch) { console.error('usage: momentum team record --branch B [--phase P] [--status S] [--progress "..."]'); return 1; }
119
+ const actor = flags.actor || identity.resolveActor(root).id;
120
+ compile.recordActivePhase(root, actor, {
121
+ branch: flags.branch, phase: flags.phase || '', status: flags.status || '', progress: flags.progress || '',
122
+ });
123
+ console.log(`✓ recorded active-phase row for '${flags.branch}' as '${actor}'`);
124
+ return 0;
125
+ }
126
+ if (sub === 'heartbeat') {
127
+ const root = repoRoot(cwd);
128
+ if (!root) { console.error('✗ not inside a git repository'); return 1; }
129
+ const { flags } = parseFlags(argv.slice(1));
130
+ const actor = flags.actor || identity.resolveActor(root).id;
131
+ presenceLib.heartbeat(root, actor, { branch: flags.branch, lane: flags.lane, activity: flags.activity });
132
+ console.log(`✓ heartbeat for '${actor}'`);
133
+ return 0;
134
+ }
135
+ if (sub === 'presence') {
136
+ const root = repoRoot(cwd);
137
+ if (!root) { console.error('✗ not inside a git repository'); return 1; }
138
+ const now = Date.now();
139
+ const list = presenceLib.presence(root, now);
140
+ if (!list.length) { console.log('(no presence recorded)'); return 0; }
141
+ for (const p of list) {
142
+ console.log(`${p.liveness === 'active' ? '●' : p.liveness === 'idle' ? '◐' : '○'} ${p.actor} ${p.liveness} ${p.branch || ''}${p.activity ? ' — ' + p.activity : ''}`);
143
+ }
144
+ return 0;
145
+ }
146
+ if (sub === 'approve') {
147
+ const root = repoRoot(cwd);
148
+ if (!root) { console.error('✗ not inside a git repository'); return 1; }
149
+ const { flags, positional } = parseFlags(argv.slice(1));
150
+ const change = positional[0];
151
+ if (!change) { console.error('usage: momentum team approve <change> [--verdict approve|reject]'); return 1; }
152
+ const actor = flags.actor || identity.resolveActor(root).id;
153
+ approvalsLib.approve(root, actor, change, { verdict: flags.verdict || 'approve' });
154
+ console.log(`✓ ${flags.verdict === 'reject' ? 'rejected' : 'approved'} '${change}' as '${actor}'`);
155
+ return 0;
156
+ }
157
+ if (sub === 'check') {
158
+ const root = repoRoot(cwd);
159
+ if (!root) { console.error('✗ not inside a git repository'); return 1; }
160
+ const { flags, positional } = parseFlags(argv.slice(1));
161
+ const change = positional[0];
162
+ if (!change || !flags.author) { console.error('usage: momentum team check <change> --author <a> [--threshold N] [--allow-self]'); return 1; }
163
+ const threshold = flags.threshold ? parseInt(flags.threshold, 10) : 1;
164
+ const allowSelf = argv.includes('--allow-self');
165
+ const approvers = approvalsLib.approversFor(root, change, flags.author, allowSelf);
166
+ if (approvers.length >= threshold) {
167
+ console.log(`✓ '${change}' satisfied — ${approvers.length}/${threshold} approval(s): ${approvers.join(', ')}`);
168
+ return 0;
169
+ }
170
+ console.error(`✗ '${change}' not satisfied — ${approvers.length}/${threshold} peer approval(s)${allowSelf ? '' : ' (author self-approval does not count)'}`);
171
+ return 2;
172
+ }
173
+ if (sub === 'turn') {
174
+ const root = repoRoot(cwd);
175
+ if (!root) { console.error('✗ not inside a git repository'); return 1; }
176
+ const action = argv[1];
177
+ const runway = argv[2] || 'main';
178
+ const actor = identity.resolveActor(root).id;
179
+ if (action === 'take') {
180
+ const r = queueLib.takeTurn(root, runway, actor);
181
+ if (r.held) { console.log(`✓ took the '${runway}' landing turn as '${actor}'`); return 0; }
182
+ console.error(`✗ '${runway}' turn held by '${r.holder}' — wait or coordinate`); return 2;
183
+ }
184
+ if (action === 'release') { queueLib.releaseTurn(root, runway); console.log(`✓ released the '${runway}' turn`); return 0; }
185
+ if (action === 'holder') { const h = queueLib.turnHolder(root, runway); console.log(h ? `${runway}: held by '${h}'` : `${runway}: free`); return 0; }
186
+ console.error('usage: momentum team turn <take|release|holder> [runway]');
187
+ return 1;
188
+ }
189
+ if (sub === 'lease') {
190
+ const root = repoRoot(cwd);
191
+ if (!root) { console.error('✗ not inside a git repository'); return 1; }
192
+ const action = argv[1];
193
+ const resource = argv[2];
194
+ if (!action || !resource) { console.error('usage: momentum team lease <acquire|release|owner> <resource>'); return 1; }
195
+ const actor = identity.resolveActor(root).id;
196
+ if (action === 'acquire') {
197
+ const r = leaseLib.acquireLease(root, resource, actor);
198
+ if (r.held) { console.log(`✓ leased '${resource}' as '${actor}'`); return 0; }
199
+ console.error(`✗ '${resource}' leased by '${r.owner}'`); return 2;
200
+ }
201
+ if (action === 'release') { leaseLib.releaseLease(root, resource); console.log(`✓ released lease '${resource}'`); return 0; }
202
+ if (action === 'owner') { const o = leaseLib.leaseOwner(root, resource); console.log(o ? `${resource}: owned by '${o}'` : `${resource}: free`); return 0; }
203
+ console.error('usage: momentum team lease <acquire|release|owner> <resource>');
204
+ return 1;
205
+ }
206
+ if (sub === 'contract') {
207
+ console.log(JSON.stringify(CONTRACT, null, 2));
208
+ return 0;
209
+ }
210
+ if (sub === 'relay') {
211
+ const { flags } = parseFlags(argv.slice(1));
212
+ const relay = createRelay({ port: flags.port ? parseInt(flags.port, 10) : 0 });
213
+ relay.listen((port) => {
214
+ console.log(`✓ momentum coordination relay (authority-free) on http://127.0.0.1:${port}`);
215
+ console.log(` set relay_url=http://127.0.0.1:${port} for teammates; Ctrl-C to stop.`);
216
+ });
217
+ return 0; // server keeps the process alive
218
+ }
219
+ console.error('usage: momentum team <whoami|sync|board|record|compile|heartbeat|presence|approve|check|turn|lease|contract|relay>');
220
+ return 1;
221
+ }
222
+
223
+ module.exports = { runTeam, runClaim };
@@ -0,0 +1,66 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Actor identity (Phase 30a Team-Walk, ADR-0012).
5
+ *
6
+ * momentum was identity-blind — "actors" were random per-process UUIDs
7
+ * (bin/swarm.js) and branch names (core/lanes/lib/signals.js). Team mode needs
8
+ * a DURABLE actor so coordination writes, claims, and approvals can say WHO.
9
+ *
10
+ * The zero-config source is git's own identity (`git config user.email` /
11
+ * `user.name`), overridable by $MOMENTUM_ACTOR, with a deterministic fallback
12
+ * (stable per repo+user) when git identity is unset. No accounts, no server.
13
+ *
14
+ * Zero dependencies — node builtins only.
15
+ */
16
+
17
+ const crypto = require('crypto');
18
+ const { spawnSync } = require('child_process');
19
+
20
+ function git(cwd, ...args) {
21
+ const res = spawnSync('git', args, { cwd, encoding: 'utf8' });
22
+ if (res.status !== 0) return null;
23
+ return res.stdout.trim();
24
+ }
25
+
26
+ /** Slugify an email/name into a stable, path-safe actor id. */
27
+ function slug(s) {
28
+ const out = String(s)
29
+ .toLowerCase()
30
+ .trim()
31
+ .replace(/@.*$/, '') // email local-part
32
+ .replace(/[^a-z0-9]+/g, '-')
33
+ .replace(/^-+|-+$/g, '')
34
+ .slice(0, 40);
35
+ return out || 'anon';
36
+ }
37
+
38
+ /**
39
+ * Resolve the durable actor for `cwd`.
40
+ * Precedence: $MOMENTUM_ACTOR → git config user.email → deterministic fallback.
41
+ * `env` is injectable for testing (defaults to process.env).
42
+ * Returns { id, name, email, source }.
43
+ */
44
+ function resolveActor(cwd, env) {
45
+ env = env || process.env;
46
+ const dir = cwd || process.cwd();
47
+
48
+ const override = env.MOMENTUM_ACTOR && env.MOMENTUM_ACTOR.trim();
49
+ if (override) {
50
+ return { id: slug(override), name: override, email: null, source: 'env' };
51
+ }
52
+
53
+ const email = git(dir, 'config', 'user.email');
54
+ const name = git(dir, 'config', 'user.name');
55
+ if (email) {
56
+ return { id: slug(email), name: name || email, email, source: 'git' };
57
+ }
58
+
59
+ // Deterministic fallback — stable per repo+user without a git identity.
60
+ const root = git(dir, 'rev-parse', '--show-toplevel') || dir;
61
+ const user = env.USER || env.USERNAME || 'unknown';
62
+ const h = crypto.createHash('sha256').update(`${user}::${root}`).digest('hex').slice(0, 8);
63
+ return { id: `anon-${h}`, name: `anon-${h}`, email: null, source: 'fallback' };
64
+ }
65
+
66
+ module.exports = { resolveActor, slug };
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Published coordination contract (Phase 30c Team-Fly, ADR-0014).
5
+ *
6
+ * The versioned, third-party-consumable description of momentum's git-native
7
+ * team-coordination state — the fragment layout and the `refs/momentum/*`
8
+ * namespace — so dashboards, CI, or other tools can read team state without
9
+ * momentum. Realizes the Lanes-arc "publish the lane-state contract" decision
10
+ * (ADR-0003 §5). Breaking changes bump CONTRACT_VERSION (pinned by a test).
11
+ *
12
+ * Zero dependencies.
13
+ */
14
+
15
+ const CONTRACT_VERSION = '1.0.0';
16
+
17
+ const CONTRACT = {
18
+ version: CONTRACT_VERSION,
19
+ fragments: {
20
+ location: '.momentum/team/<view>/<actor>-<seq>-<kind>.json (committed)',
21
+ views: ['active-phase', 'presence', 'approvals'],
22
+ shape: { actor: 'string', seq: 'number', ts: 'iso8601', kind: 'string', payload: 'object' },
23
+ invariant: 'an actor writes only files prefixed with its own <actor>- → conflict-free by construction',
24
+ },
25
+ refs: {
26
+ namespace: 'refs/momentum/* (fetched via +refs/momentum/*:refs/momentum/*)',
27
+ claims: 'refs/momentum/{claims,version,phase,id}/<key> first-push-wins allocation',
28
+ queue: 'refs/momentum/queue/<runway>-holder single-holder landing turn',
29
+ leases: 'refs/momentum/leases/<resource> single-owner cross-machine lease',
30
+ payload: 'empty-tree commit whose message is JSON { actor, key, ts, ... }',
31
+ },
32
+ relay: {
33
+ optional: true,
34
+ authorityFree: true,
35
+ protocol: 1,
36
+ endpoints: ['POST /publish', 'GET /events?since=N', 'GET /health'],
37
+ absence: 'no relay_url → git-native fallback; nothing depends on it',
38
+ },
39
+ };
40
+
41
+ module.exports = { CONTRACT, CONTRACT_VERSION };
@@ -0,0 +1,47 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Reviewer≠author approvals ledger (Phase 30b Team-Run, ADR-0013).
5
+ *
6
+ * The trust gate finally distinguishes self-approval from peer review. Approvals
7
+ * are attributed fragments; a change is `satisfied` when it has >= threshold
8
+ * approvals by actors DIFFERENT from the author. Deliberately CLIENT-SIDE HONEST
9
+ * — ADR-0009's trust invariant is unchanged and true server-side enforcement
10
+ * stays an optional forge-adapter concern. `allowSelf` restores solo behavior
11
+ * (single-operator N=1 compatibility).
12
+ *
13
+ * Zero dependencies — node builtins only.
14
+ */
15
+
16
+ const fragments = require('./fragments');
17
+
18
+ const APPROVALS_VIEW = 'approvals';
19
+
20
+ /** Record an attributed approval (or a `reject` verdict) for `change`. */
21
+ function approve(repoRoot, approver, change, opts) {
22
+ opts = opts || {};
23
+ return fragments.writeFragment(repoRoot, APPROVALS_VIEW, approver, 'approval', {
24
+ change: String(change),
25
+ verdict: opts.verdict || 'approve',
26
+ }, opts);
27
+ }
28
+
29
+ /** Distinct approvers (latest verdict = approve) for a change, honoring reviewer≠author. */
30
+ function approversFor(repoRoot, change, author, allowSelf) {
31
+ const forChange = fragments
32
+ .readFragments(repoRoot, APPROVALS_VIEW)
33
+ .filter((f) => f.payload.change === String(change));
34
+ const latest = fragments.foldLatest(forChange, (f) => f.actor);
35
+ return [...latest.values()]
36
+ .filter((f) => f.payload.verdict === 'approve')
37
+ .filter((f) => allowSelf || f.actor !== author)
38
+ .map((f) => f.actor);
39
+ }
40
+
41
+ /** True when `change` has >= threshold qualifying approvals. */
42
+ function satisfied(repoRoot, change, opts) {
43
+ const { author, threshold = 1, allowSelf = false } = opts || {};
44
+ return approversFor(repoRoot, change, author, allowSelf).length >= threshold;
45
+ }
46
+
47
+ module.exports = { approve, approversFor, satisfied, APPROVALS_VIEW };
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Collision-free allocation claims (Phase 30a Team-Walk G1, ADR-0012).
5
+ *
6
+ * Thin policy layer over refcas: ensure the coordination refspec is installed
7
+ * (so the claim travels on ordinary `git fetch`), resolve the durable actor,
8
+ * then attempt the ref compare-and-swap. Used by `momentum claim` and, via the
9
+ * recipes, by /brainstorm-phase, /start-phase, /hotfix, and /complete-phase.
10
+ *
11
+ * Folds ENH-057: a release reserves its version with `claim version <X>` before
12
+ * tagging — a stale bump loses the CAS and is refused, so two sessions can never
13
+ * burn a version again.
14
+ *
15
+ * Zero dependencies — node builtins only.
16
+ */
17
+
18
+ const refcas = require('./refcas');
19
+ const identity = require('../../identity');
20
+
21
+ /**
22
+ * Claim `key` in `namespace`. Returns refcas result:
23
+ * { won, ref, oid, winner, error }.
24
+ */
25
+ function claimKey(cwd, namespace, key, opts) {
26
+ opts = opts || {};
27
+ const remote = opts.remote || 'origin';
28
+ const actor = opts.actor || identity.resolveActor(cwd).id;
29
+ refcas.installRefspec(cwd, remote); // idempotent — makes the ref travel on fetch
30
+ return refcas.claim(cwd, { namespace, key, actor, remote, extra: opts.extra || {} });
31
+ }
32
+
33
+ module.exports = { claimKey };
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Compile per-actor fragments into rendered coordination views (Phase 30a
5
+ * Team-Walk G2, ADR-0012).
6
+ *
7
+ * Fragments are collision-free but unreadable on their own; `compile` folds them
8
+ * (last-writer-per-key) into a human view. The Active-Phase table is the first
9
+ * view — each lane writes its OWN row fragment, so Rule 15's "one row per active
10
+ * lane, own-row-touch only" stops being a social convention and becomes
11
+ * mechanical (you can only write files under your own actor prefix).
12
+ *
13
+ * `applyManaged` splices a rendered view between managed markers in a doc
14
+ * (e.g. status.md), inserting the block if the markers are absent — the same
15
+ * marked-region pattern momentum already uses for the ecosystem pointer.
16
+ *
17
+ * Zero dependencies — node builtins only.
18
+ */
19
+
20
+ const fragments = require('./fragments');
21
+
22
+ const ACTIVE_PHASE_VIEW = 'active-phase';
23
+
24
+ function escapeRe(s) {
25
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
26
+ }
27
+
28
+ /** Record this lane's Active-Phase row as a fragment (own-row, last-writer-wins). */
29
+ function recordActivePhase(repoRoot, actor, payload, opts) {
30
+ return fragments.writeFragment(repoRoot, ACTIVE_PHASE_VIEW, actor, 'row', payload, opts);
31
+ }
32
+
33
+ /** Read + fold the Active-Phase rows (one per lane branch, latest wins). */
34
+ function activePhaseRows(repoRoot) {
35
+ const folded = fragments.foldLatest(
36
+ fragments.readFragments(repoRoot, ACTIVE_PHASE_VIEW),
37
+ (f) => f.payload.branch
38
+ );
39
+ return [...folded.values()].sort((a, b) =>
40
+ String(a.payload.phase || '').localeCompare(String(b.payload.phase || '')) ||
41
+ String(a.payload.branch).localeCompare(String(b.payload.branch)));
42
+ }
43
+
44
+ /** Render the Active-Phase rows as a markdown table. */
45
+ function renderActivePhaseTable(rows) {
46
+ const header =
47
+ '| Phase | Branch | Actor | Status | Progress |\n' +
48
+ '|-------|--------|-------|--------|----------|';
49
+ if (!rows.length) return `${header}\n| _(no active lanes)_ | | | | |`;
50
+ const body = rows
51
+ .map((f) => {
52
+ const p = f.payload;
53
+ return `| ${p.phase || ''} | ${p.branch} | ${f.actor} | ${p.status || ''} | ${p.progress || ''} |`;
54
+ })
55
+ .join('\n');
56
+ return `${header}\n${body}`;
57
+ }
58
+
59
+ /** Compile the Active-Phase table for a repo. */
60
+ function compileActivePhase(repoRoot) {
61
+ return renderActivePhaseTable(activePhaseRows(repoRoot));
62
+ }
63
+
64
+ /**
65
+ * Splice `rendered` between `<!-- momentum:team:<name>:begin/end -->` markers in
66
+ * `content`, inserting the block at the end if the markers are absent.
67
+ * Idempotent: re-applying with the same rendered text is a no-op.
68
+ */
69
+ function applyManaged(content, name, rendered) {
70
+ const begin = `<!-- momentum:team:${name}:begin -->`;
71
+ const end = `<!-- momentum:team:${name}:end -->`;
72
+ const block = `${begin}\n${rendered}\n${end}`;
73
+ const re = new RegExp(`${escapeRe(begin)}[\\s\\S]*?${escapeRe(end)}`);
74
+ if (re.test(content)) return content.replace(re, block);
75
+ return `${content.trimEnd()}\n\n${block}\n`;
76
+ }
77
+
78
+ const fs = require('fs');
79
+ const path = require('path');
80
+
81
+ /**
82
+ * Render the Active-Phase table from fragments into `specs/status.md`, spliced
83
+ * between managed markers (inserted if absent). Returns { changed, path } or
84
+ * { changed:false } when there is no status file. This is what makes the shared
85
+ * board real: N actors' fragments compile into one conflict-free table.
86
+ */
87
+ function compileStatusFile(repoRoot) {
88
+ const statusPath = path.join(repoRoot, 'specs', 'status.md');
89
+ let content;
90
+ try { content = fs.readFileSync(statusPath, 'utf8'); } catch { return { changed: false }; }
91
+ const rendered = compileActivePhase(repoRoot);
92
+ const next = applyManaged(content, ACTIVE_PHASE_VIEW, rendered);
93
+ if (next === content) return { changed: false, path: statusPath };
94
+ fs.writeFileSync(statusPath, next);
95
+ return { changed: true, path: statusPath };
96
+ }
97
+
98
+ module.exports = {
99
+ ACTIVE_PHASE_VIEW,
100
+ recordActivePhase,
101
+ activePhaseRows,
102
+ renderActivePhaseTable,
103
+ compileActivePhase,
104
+ applyManaged,
105
+ compileStatusFile,
106
+ };
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Per-actor append-only coordination fragments (Phase 30a Team-Walk, ADR-0012).
5
+ *
6
+ * Bulk coordination state (the status Active-Phase table, changelog, later the
7
+ * backlog) is the ONLY thing that crosses machines today (committed specs) — and
8
+ * it conflicts on every concurrent edit. The fix is the towncrier/reno pattern:
9
+ * each actor writes its own append-only fragment files; a compile step folds
10
+ * them into the rendered view. Because an actor only ever writes paths beginning
11
+ * with its own `<actor>-` prefix, two actors NEVER touch the same file — the
12
+ * merge is conflict-free BY CONSTRUCTION.
13
+ *
14
+ * Fragments live under <repo>/.momentum/team/<view>/ and are COMMITTED (the one
15
+ * exception to the gitignored .momentum/ — see the `!.momentum/team/` rule).
16
+ *
17
+ * Zero dependencies — node builtins only.
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const os = require('os');
22
+ const path = require('path');
23
+
24
+ const SEQ_WIDTH = 6;
25
+
26
+ function teamRoot(repoRoot) {
27
+ return path.join(repoRoot, '.momentum', 'team');
28
+ }
29
+ function viewDir(repoRoot, view) {
30
+ return path.join(teamRoot(repoRoot), String(view));
31
+ }
32
+
33
+ function pad(seq) {
34
+ return String(seq).padStart(SEQ_WIDTH, '0');
35
+ }
36
+ function escapeRe(s) {
37
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
38
+ }
39
+
40
+ /** Highest existing seq for `actorId` in `view` (0 if none). */
41
+ function maxSeq(repoRoot, view, actorId) {
42
+ const dir = viewDir(repoRoot, view);
43
+ let names;
44
+ try { names = fs.readdirSync(dir); } catch { return 0; }
45
+ const re = new RegExp(`^${escapeRe(actorId)}-(\\d+)-`);
46
+ let max = 0;
47
+ for (const n of names) {
48
+ const m = n.match(re);
49
+ if (m) max = Math.max(max, parseInt(m[1], 10));
50
+ }
51
+ return max;
52
+ }
53
+
54
+ /**
55
+ * Append a fragment. Filename `<actor>-<seq>-<kind>.json` — own-prefix only,
56
+ * so concurrent writers never collide. `actor` may be an id string or an
57
+ * identity object ({ id }). `opts.ts` / `opts.seq` are injectable for testing.
58
+ * Returns the written fragment (with its `file` path).
59
+ */
60
+ function writeFragment(repoRoot, view, actor, kind, payload, opts) {
61
+ opts = opts || {};
62
+ const dir = viewDir(repoRoot, view);
63
+ fs.mkdirSync(dir, { recursive: true });
64
+ const actorId = typeof actor === 'string' ? actor : actor.id;
65
+ const seq = opts.seq != null ? opts.seq : maxSeq(repoRoot, view, actorId) + 1;
66
+ const safeKind = String(kind).replace(/[^A-Za-z0-9._-]+/g, '-');
67
+ const frag = {
68
+ actor: actorId,
69
+ seq,
70
+ ts: opts.ts || new Date().toISOString(),
71
+ kind: safeKind,
72
+ payload,
73
+ };
74
+ const file = path.join(dir, `${actorId}-${pad(seq)}-${safeKind}.json`);
75
+ fs.writeFileSync(file, JSON.stringify(frag, null, 2) + os.EOL);
76
+ return Object.assign({ file }, frag);
77
+ }
78
+
79
+ /** All fragments in a view, stable-sorted by (ts, actor, seq). */
80
+ function readFragments(repoRoot, view) {
81
+ const dir = viewDir(repoRoot, view);
82
+ let names;
83
+ try { names = fs.readdirSync(dir); } catch { return []; }
84
+ const frags = [];
85
+ for (const n of names) {
86
+ if (!n.endsWith('.json')) continue;
87
+ try {
88
+ frags.push(JSON.parse(fs.readFileSync(path.join(dir, n), 'utf8')));
89
+ } catch { /* skip malformed */ }
90
+ }
91
+ frags.sort((a, b) =>
92
+ (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0) ||
93
+ (a.actor < b.actor ? -1 : a.actor > b.actor ? 1 : 0) ||
94
+ (a.seq - b.seq));
95
+ return frags;
96
+ }
97
+
98
+ /**
99
+ * Fold fragments to the latest per key (own-row / last-writer-wins semantics).
100
+ * `keyFn(frag)` derives the key; because input is stable-sorted, later
101
+ * fragments win. Returns a Map(key → fragment).
102
+ */
103
+ function foldLatest(fragments, keyFn) {
104
+ const map = new Map();
105
+ for (const f of fragments) map.set(keyFn(f), f);
106
+ return map;
107
+ }
108
+
109
+ module.exports = { teamRoot, viewDir, writeFragment, readFragments, foldLatest, maxSeq };
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Cross-machine leases via ref-CAS (Phase 30c Team-Fly, ADR-0014).
5
+ *
6
+ * Swarm's leases are WALL-CLOCK with no fencing (core/swarm/lib/manifest.js) —
7
+ * clock skew alone can make two machines both believe they own a repo. Fly moves
8
+ * lease acquisition to `refs/momentum/leases/<resource>` ref-CAS: exactly one
9
+ * owner at a time ACROSS machines; the remote arbitrates, no fencing service.
10
+ * Same first-push-wins primitive as claims and queue turns.
11
+ *
12
+ * Zero dependencies — node builtins only.
13
+ */
14
+
15
+ const { spawnSync } = require('child_process');
16
+ const refcas = require('./refcas');
17
+
18
+ const LEASE_NS = 'leases';
19
+
20
+ function gitRes(cwd, args) { return spawnSync('git', args, { cwd, encoding: 'utf8' }); }
21
+
22
+ /** Acquire an exclusive lease on `resource`. Returns { held, owner }. */
23
+ function acquireLease(cwd, resource, actor, remote) {
24
+ const res = refcas.claim(cwd, { namespace: LEASE_NS, key: resource, actor, remote: remote || 'origin' });
25
+ if (res.won) return { held: true, owner: typeof actor === 'string' ? actor : actor.id };
26
+ return { held: false, owner: res.winner && res.winner.actor };
27
+ }
28
+
29
+ /** Release a lease (delete the ref locally + on remote). */
30
+ function releaseLease(cwd, resource, remote) {
31
+ const ref = refcas.refPath(LEASE_NS, resource);
32
+ gitRes(cwd, ['push', remote || 'origin', `:${ref}`]);
33
+ gitRes(cwd, ['update-ref', '-d', ref]);
34
+ return true;
35
+ }
36
+
37
+ /** Current owner of a lease (best-effort remote read), or null. */
38
+ function leaseOwner(cwd, resource, remote) {
39
+ const w = refcas.readClaim(cwd, { namespace: LEASE_NS, key: resource, remote: remote || 'origin' });
40
+ return w && w.actor ? w.actor : null;
41
+ }
42
+
43
+ module.exports = { acquireLease, releaseLease, leaseOwner, LEASE_NS };
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Team presence — heartbeat-on-invocation (Phase 30b Team-Run, ADR-0013).
5
+ *
6
+ * No daemon: any `momentum` command refreshes a short-TTL presence fragment;
7
+ * liveness (active/idle/offline) is DERIVED from `last_seen` age against config
8
+ * thresholds. Presence rides the same collision-free fragment substrate as the
9
+ * Active-Phase table (own-actor prefix → no write contention).
10
+ *
11
+ * Zero dependencies — node builtins only.
12
+ */
13
+
14
+ const fragments = require('./fragments');
15
+
16
+ const PRESENCE_VIEW = 'presence';
17
+ const DEFAULT_IDLE = 600; // seconds → "idle" after 10 min
18
+ const DEFAULT_OFFLINE = 3600; // seconds → "offline" after 1 hr
19
+
20
+ /** Refresh this actor's presence. `ctx` = { branch, lane, activity }. */
21
+ function heartbeat(repoRoot, actor, ctx, opts) {
22
+ ctx = ctx || {};
23
+ opts = opts || {};
24
+ return fragments.writeFragment(repoRoot, PRESENCE_VIEW, actor, 'beat', {
25
+ branch: ctx.branch || null,
26
+ lane: ctx.lane || null,
27
+ activity: ctx.activity || null,
28
+ last_seen: opts.ts || new Date().toISOString(),
29
+ }, opts);
30
+ }
31
+
32
+ /** Derive liveness from a presence fragment given `now` (ms epoch). */
33
+ function liveness(fragment, now, thresholds) {
34
+ const idle = (thresholds && thresholds.idle) || DEFAULT_IDLE;
35
+ const offline = (thresholds && thresholds.offline) || DEFAULT_OFFLINE;
36
+ const seen = new Date(fragment.payload.last_seen || fragment.ts).getTime();
37
+ const age = (now - seen) / 1000;
38
+ if (age <= idle) return 'active';
39
+ if (age <= offline) return 'idle';
40
+ return 'offline';
41
+ }
42
+
43
+ /** Latest presence per actor + derived liveness, sorted by actor. */
44
+ function presence(repoRoot, now, thresholds) {
45
+ const folded = fragments.foldLatest(
46
+ fragments.readFragments(repoRoot, PRESENCE_VIEW),
47
+ (f) => f.actor
48
+ );
49
+ return [...folded.values()]
50
+ .map((f) => Object.assign({ actor: f.actor, liveness: liveness(f, now, thresholds) }, f.payload))
51
+ .sort((a, b) => a.actor.localeCompare(b.actor));
52
+ }
53
+
54
+ module.exports = { heartbeat, liveness, presence, PRESENCE_VIEW, DEFAULT_IDLE, DEFAULT_OFFLINE };
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared merge-queue turn across contributors (Phase 30b Team-Run, ADR-0013).
5
+ *
6
+ * Walk's `lanes land` FIFO turn was per-clone; Run makes it TEAM-WIDE. The
7
+ * runway is a single-holder ref-CAS lock (`refs/momentum/queue/<runway>-holder`):
8
+ * exactly one contributor holds the landing turn at a time, ACROSS machines, via
9
+ * the same first-push-wins primitive as claims. Extends Rule 6 Landing Order to
10
+ * N humans — the remote arbitrates the turn, no server.
11
+ *
12
+ * Zero dependencies — node builtins only.
13
+ */
14
+
15
+ const { spawnSync } = require('child_process');
16
+ const refcas = require('./refcas');
17
+
18
+ const QUEUE_NS = 'queue';
19
+
20
+ function gitRes(cwd, args) { return spawnSync('git', args, { cwd, encoding: 'utf8' }); }
21
+
22
+ function turnKey(runway) { return `${runway}-holder`; }
23
+ function turnRef(runway) { return refcas.refPath(QUEUE_NS, turnKey(runway)); }
24
+
25
+ /**
26
+ * Try to take the runway's landing turn. Returns { held, holder }.
27
+ * If another contributor holds it, `held:false` and `holder` names them.
28
+ */
29
+ function takeTurn(cwd, runway, actor, remote) {
30
+ remote = remote || 'origin';
31
+ const res = refcas.claim(cwd, { namespace: QUEUE_NS, key: turnKey(runway), actor, remote });
32
+ if (res.won) return { held: true, holder: typeof actor === 'string' ? actor : actor.id };
33
+ return { held: false, holder: res.winner && res.winner.actor };
34
+ }
35
+
36
+ /** Release the turn (delete the holder ref locally + on the remote). */
37
+ function releaseTurn(cwd, runway, remote) {
38
+ remote = remote || 'origin';
39
+ const ref = turnRef(runway);
40
+ gitRes(cwd, ['push', remote, `:${ref}`]); // delete on remote (idempotent)
41
+ gitRes(cwd, ['update-ref', '-d', ref]); // delete locally
42
+ return true;
43
+ }
44
+
45
+ /** Who currently holds the runway (best-effort remote read), or null if free. */
46
+ function turnHolder(cwd, runway, remote) {
47
+ const w = refcas.readClaim(cwd, { namespace: QUEUE_NS, key: turnKey(runway), remote: remote || 'origin' });
48
+ return w && w.actor ? w.actor : null;
49
+ }
50
+
51
+ module.exports = { takeTurn, releaseTurn, turnHolder, turnRef, QUEUE_NS };
@@ -0,0 +1,112 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Git-ref compare-and-swap for atomic, cross-machine claims (Phase 30a, ADR-0012).
5
+ *
6
+ * Allocation collisions (two people both picking "the next phase" / version /
7
+ * backlog id) CANNOT be prevented by committed fragments — fragments merge too
8
+ * LATE (you only see the other write after a merge, by which point you've both
9
+ * already picked). A claim needs a pre-merge, atomic, single-winner channel.
10
+ * Git gives us one for free: pushing a NEW ref to the remote is atomic — the
11
+ * first push creates refs/momentum/<ns>/<key>; a second push of a different
12
+ * object is a non-fast-forward and is REJECTED. The remote arbitrates; no
13
+ * server exists.
14
+ *
15
+ * The claim payload rides in the commit message of an empty-tree commit the
16
+ * ref points at, so the winner (and when) is auditable via ordinary git.
17
+ *
18
+ * Zero dependencies — node builtins only.
19
+ */
20
+
21
+ const { spawnSync } = require('child_process');
22
+
23
+ const REFNS = 'refs/momentum';
24
+
25
+ function gitRes(cwd, args, input) {
26
+ return spawnSync('git', args, { cwd, encoding: 'utf8', input });
27
+ }
28
+ function git(cwd, ...args) {
29
+ const r = gitRes(cwd, args);
30
+ return r.status === 0 ? r.stdout.trim() : null;
31
+ }
32
+
33
+ function refPath(namespace, key) {
34
+ const safe = String(key).replace(/[^A-Za-z0-9._-]+/g, '-');
35
+ return `${REFNS}/${namespace}/${safe}`;
36
+ }
37
+
38
+ /** Empty tree oid for this repo's hash algorithm. */
39
+ function emptyTree(cwd) {
40
+ const r = gitRes(cwd, ['mktree'], '');
41
+ return r.status === 0 ? r.stdout.trim() : null;
42
+ }
43
+
44
+ /** Build a claim commit (empty tree, message = claim payload). Returns oid or null. */
45
+ function claimCommit(cwd, payload) {
46
+ const tree = emptyTree(cwd);
47
+ if (!tree) return null;
48
+ return git(cwd, 'commit-tree', tree, '-m', JSON.stringify(payload));
49
+ }
50
+
51
+ /**
52
+ * Attempt to atomically claim `key` in `namespace` against `remote`.
53
+ * Returns { won, ref, oid, winner, error }. On loss, `winner` is the payload
54
+ * that currently holds the ref (best-effort read).
55
+ */
56
+ function claim(cwd, opts) {
57
+ const { namespace, key, actor, remote = 'origin', extra = {} } = opts;
58
+ const ref = refPath(namespace, key);
59
+ const payload = Object.assign(
60
+ { actor: typeof actor === 'string' ? actor : actor && actor.id, key: String(key), ts: new Date().toISOString() },
61
+ extra
62
+ );
63
+ const oid = claimCommit(cwd, payload);
64
+ if (!oid) {
65
+ const e = new Error('claim: could not create claim object (not a git repo?)');
66
+ e.code = 'ENOGIT';
67
+ throw e;
68
+ }
69
+ // create-only push: NO --force. First writer wins; others are non-ff-rejected.
70
+ const push = gitRes(cwd, ['push', remote, `${oid}:${ref}`]);
71
+ if (push.status === 0) {
72
+ return { won: true, ref, oid, winner: payload };
73
+ }
74
+ const winner = readClaim(cwd, { namespace, key, remote });
75
+ return { won: false, ref, oid, winner, error: (push.stderr || '').trim() };
76
+ }
77
+
78
+ /**
79
+ * Read the payload currently holding a claim ref (fetches it first).
80
+ * Returns the parsed payload, or null if unclaimed / unreachable.
81
+ */
82
+ function readClaim(cwd, { namespace, key, remote = 'origin' }) {
83
+ const ref = refPath(namespace, key);
84
+ gitRes(cwd, ['fetch', remote, `${ref}:${ref}`]); // best-effort; ignore failure
85
+ const msg = git(cwd, 'log', '-1', '--format=%B', ref);
86
+ if (!msg) return null;
87
+ try { return JSON.parse(msg.trim()); } catch { return { raw: msg.trim() }; }
88
+ }
89
+
90
+ /** Claimed keys in a namespace (local refs; call fetchAll first for freshness). */
91
+ function listClaims(cwd, namespace) {
92
+ const out = git(cwd, 'for-each-ref', '--format=%(refname)', `${REFNS}/${namespace}`);
93
+ if (!out) return [];
94
+ const prefix = `${REFNS}/${namespace}/`;
95
+ return out.split('\n').filter(Boolean).map((r) => r.slice(prefix.length));
96
+ }
97
+
98
+ /** Fetch all coordination refs from remote into local refs/momentum/*. */
99
+ function fetchAll(cwd, remote = 'origin') {
100
+ return gitRes(cwd, ['fetch', remote, `+${REFNS}/*:${REFNS}/*`]).status === 0;
101
+ }
102
+
103
+ /** Ensure `git fetch` carries refs/momentum/*. Idempotent — true if it added the spec. */
104
+ function installRefspec(cwd, remote = 'origin') {
105
+ const spec = `+${REFNS}/*:${REFNS}/*`;
106
+ const existing = git(cwd, 'config', '--get-all', `remote.${remote}.fetch`) || '';
107
+ if (existing.split('\n').includes(spec)) return false;
108
+ gitRes(cwd, ['config', '--add', `remote.${remote}.fetch`, spec]);
109
+ return true;
110
+ }
111
+
112
+ module.exports = { claim, readClaim, listClaims, fetchAll, installRefspec, refPath, REFNS };
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Relay client (Phase 30c Team-Fly, ADR-0014).
5
+ *
6
+ * GRACEFUL ABSENCE is the whole point: with no relayUrl (or an unreachable one),
7
+ * publish/poll are no-ops that report `skipped` — the caller simply falls back to
8
+ * git-native `team sync`. Nothing depends on the relay; it carries no authority.
9
+ *
10
+ * Zero dependencies — node http only.
11
+ */
12
+
13
+ const http = require('http');
14
+
15
+ function request(method, url, body) {
16
+ return new Promise((resolve) => {
17
+ let u;
18
+ try { u = new URL(url); } catch { return resolve({ ok: false, skipped: true, reason: 'no relay' }); }
19
+ const data = body != null ? JSON.stringify(body) : null;
20
+ const req = http.request(
21
+ {
22
+ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method,
23
+ headers: data ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {},
24
+ },
25
+ (res) => {
26
+ let out = '';
27
+ res.on('data', (c) => { out += c; });
28
+ res.on('end', () => { try { resolve(JSON.parse(out || '{}')); } catch { resolve({ ok: false }); } });
29
+ }
30
+ );
31
+ req.on('error', () => resolve({ ok: false, skipped: true, reason: 'unreachable' }));
32
+ req.setTimeout(1500, () => { req.destroy(); resolve({ ok: false, skipped: true, reason: 'timeout' }); });
33
+ if (data) req.write(data);
34
+ req.end();
35
+ });
36
+ }
37
+
38
+ /** Publish a coordination event. No-op (skipped) when relayUrl is absent. */
39
+ async function publish(relayUrl, event) {
40
+ if (!relayUrl) return { ok: false, skipped: true, reason: 'no relay' };
41
+ return request('POST', relayUrl.replace(/\/$/, '') + '/publish', event);
42
+ }
43
+
44
+ /** Poll events since `since`. Returns { events } (possibly empty), never throws. */
45
+ async function poll(relayUrl, since) {
46
+ if (!relayUrl) return { ok: false, skipped: true, events: [] };
47
+ const r = await request('GET', relayUrl.replace(/\/$/, '') + `/events?since=${since || 0}`);
48
+ return Array.isArray(r.events) ? r : { ok: false, skipped: true, events: [] };
49
+ }
50
+
51
+ module.exports = { publish, poll };
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Optional self-hostable coordination relay (Phase 30c Team-Fly, ADR-0014).
5
+ *
6
+ * AUTHORITY-FREE by design: it mirrors coordination EVENTS (fragment/claim
7
+ * notifications) so teams get near-real-time visibility WITHOUT waiting for a git
8
+ * sync. It never gates, approves, or lands — remove it and nothing about
9
+ * correctness changes; the plane falls straight back to git-native (Walk/Run).
10
+ * momentum ships this code; it does NOT run a hosted service — operators self-host.
11
+ *
12
+ * Minimal store-and-poll protocol (no external deps):
13
+ * POST /publish { ...event } → { ok, seq, protocol }
14
+ * GET /events?since=N → { protocol, events: [...] }
15
+ * GET /health → { ok, protocol }
16
+ *
17
+ * Zero dependencies — node http only.
18
+ */
19
+
20
+ const http = require('http');
21
+
22
+ const PROTOCOL_VERSION = 1;
23
+
24
+ function createRelay(opts) {
25
+ opts = opts || {};
26
+ const events = [];
27
+
28
+ const server = http.createServer((req, res) => {
29
+ if (req.method === 'POST' && req.url === '/publish') {
30
+ let body = '';
31
+ req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); });
32
+ req.on('end', () => {
33
+ try {
34
+ const ev = JSON.parse(body || '{}');
35
+ const stored = Object.assign({}, ev, { seq: events.length + 1 });
36
+ events.push(stored);
37
+ res.writeHead(200, { 'content-type': 'application/json' });
38
+ res.end(JSON.stringify({ ok: true, seq: stored.seq, protocol: PROTOCOL_VERSION }));
39
+ } catch {
40
+ res.writeHead(400, { 'content-type': 'application/json' });
41
+ res.end(JSON.stringify({ ok: false, error: 'bad json' }));
42
+ }
43
+ });
44
+ return;
45
+ }
46
+ if (req.method === 'GET' && req.url.startsWith('/events')) {
47
+ const since = Number(new URL(req.url, 'http://x').searchParams.get('since') || 0);
48
+ res.writeHead(200, { 'content-type': 'application/json' });
49
+ res.end(JSON.stringify({ protocol: PROTOCOL_VERSION, events: events.filter((e) => e.seq > since) }));
50
+ return;
51
+ }
52
+ if (req.method === 'GET' && req.url === '/health') {
53
+ res.writeHead(200, { 'content-type': 'application/json' });
54
+ res.end(JSON.stringify({ ok: true, protocol: PROTOCOL_VERSION }));
55
+ return;
56
+ }
57
+ res.writeHead(404); res.end();
58
+ });
59
+
60
+ return {
61
+ listen(cb) { server.listen(opts.port || 0, '127.0.0.1', () => cb(server.address().port)); },
62
+ close(cb) { server.close(cb); },
63
+ events,
64
+ server,
65
+ };
66
+ }
67
+
68
+ module.exports = { createRelay, PROTOCOL_VERSION };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limina-labs/momentum",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "description": "Agent-agnostic, specs-driven development framework — single project or multi-project ecosystem",
5
5
  "homepage": "https://trymomentum.github.io",
6
6
  "bin": {