@limina-labs/momentum 0.36.0 → 0.38.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.
@@ -553,6 +553,13 @@ worktree) bound to one phase or ad-hoc record.
553
553
  - The Active Phase table holds **one row per active lane**
554
554
  (Phase | Branch | Status | Progress). Add your row at phase start; update
555
555
  only your row; mark it at completion.
556
+ - **Across machines (Team Mode, v0.37.0+):** this convention is now backed by a
557
+ *mechanism*, not only discipline — per-actor coordination fragments
558
+ (`.momentum/team/`, collision-free by own-prefix construction) + `refs/momentum/*`
559
+ compare-and-swap for allocation (`momentum claim <phase|id|version>`).
560
+ `momentum team sync`/`board` compile the shared view. This makes **N humans on
561
+ N clones** trivially mergeable, not just N sessions on one disk; the social
562
+ discipline above is the fallback. See ADR-0012/0013/0014.
556
563
 
557
564
  #### Landing
558
565
 
@@ -607,6 +607,13 @@ worktree) bound to one phase or ad-hoc record.
607
607
  - The Active Phase table holds **one row per active lane**
608
608
  (Phase | Branch | Status | Progress). Add your row at phase start; update
609
609
  only your row; mark it at completion.
610
+ - **Across machines (Team Mode, v0.37.0+):** this convention is now backed by a
611
+ *mechanism*, not only discipline — per-actor coordination fragments
612
+ (`.momentum/team/`, collision-free by own-prefix construction) + `refs/momentum/*`
613
+ compare-and-swap for allocation (`momentum claim <phase|id|version>`).
614
+ `momentum team sync`/`board` compile the shared view. This makes **N humans on
615
+ N clones** trivially mergeable, not just N sessions on one disk; the social
616
+ discipline above is the fallback. See ADR-0012/0013/0014.
610
617
 
611
618
  #### Landing
612
619
 
@@ -520,6 +520,13 @@ worktree) bound to one phase or ad-hoc record.
520
520
  - The Active Phase table holds **one row per active lane**
521
521
  (Phase | Branch | Status | Progress). Add your row at phase start; update
522
522
  only your row; mark it at completion.
523
+ - **Across machines (Team Mode, v0.37.0+):** this convention is now backed by a
524
+ *mechanism*, not only discipline — per-actor coordination fragments
525
+ (`.momentum/team/`, collision-free by own-prefix construction) + `refs/momentum/*`
526
+ compare-and-swap for allocation (`momentum claim <phase|id|version>`).
527
+ `momentum team sync`/`board` compile the shared view. This makes **N humans on
528
+ N clones** trivially mergeable, not just N sessions on one disk; the social
529
+ discipline above is the fallback. See ADR-0012/0013/0014.
523
530
 
524
531
  #### Landing
525
532
 
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,235 @@
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
+
83
+ // Auto-heartbeat (30d): any `momentum team` command in a repo refreshes this
84
+ // actor's presence — "live" liveness with no daemon. Best-effort + gated to
85
+ // team-active repos (a .momentum/team/ dir exists) so solo projects stay quiet.
86
+ try {
87
+ const root = repoRoot(cwd);
88
+ if (root && sub !== 'record' && sub !== 'heartbeat' && sub !== 'whoami' &&
89
+ require('fs').existsSync(path.join(root, '.momentum', 'team'))) {
90
+ presenceLib.heartbeat(root, identity.resolveActor(root).id, { activity: `team ${sub}` });
91
+ }
92
+ } catch { /* presence is best-effort; never fail a command on it */ }
93
+
94
+ if (sub === 'whoami') {
95
+ const a = identity.resolveActor(cwd);
96
+ console.log(`${a.id} (source: ${a.source}${a.email ? `, ${a.email}` : ''})`);
97
+ return 0;
98
+ }
99
+ if (sub === 'sync') {
100
+ if (!insideRepo(cwd)) { console.error('✗ not inside a git repository'); return 1; }
101
+ const remote = argv[1] && !argv[1].startsWith('--') ? argv[1] : 'origin';
102
+ const ok = refcas.fetchAll(cwd, remote);
103
+ const root = repoRoot(cwd);
104
+ const rec = root ? compile.compileStatusFile(root) : { changed: false };
105
+ console.log(ok
106
+ ? `✓ synced coordination refs from ${remote} (refs/momentum/*). Fragments arrive via branch integration.`
107
+ : `⚠ could not fetch from ${remote} — working offline; coordination is eventually consistent`);
108
+ if (rec.changed) console.log('✓ recompiled the Active-Phase table in specs/status.md');
109
+ return 0;
110
+ }
111
+ if (sub === 'compile') {
112
+ const root = repoRoot(cwd);
113
+ if (!root) { console.error('✗ not inside a git repository'); return 1; }
114
+ const rec = compile.compileStatusFile(root);
115
+ console.log(rec.path
116
+ ? (rec.changed ? '✓ recompiled Active-Phase table in specs/status.md' : '✓ specs/status.md already current')
117
+ : 'ℹ no specs/status.md to compile into');
118
+ return 0;
119
+ }
120
+ if (sub === 'board') {
121
+ const root = repoRoot(cwd);
122
+ if (!root) { console.error('✗ not inside a git repository'); return 1; }
123
+ console.log(compile.compileActivePhase(root));
124
+ return 0;
125
+ }
126
+ if (sub === 'record') {
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
+ if (!flags.branch) { console.error('usage: momentum team record --branch B [--phase P] [--status S] [--progress "..."]'); return 1; }
131
+ const actor = flags.actor || identity.resolveActor(root).id;
132
+ compile.recordActivePhase(root, actor, {
133
+ branch: flags.branch, phase: flags.phase || '', status: flags.status || '', progress: flags.progress || '',
134
+ });
135
+ console.log(`✓ recorded active-phase row for '${flags.branch}' as '${actor}'`);
136
+ return 0;
137
+ }
138
+ if (sub === 'heartbeat') {
139
+ const root = repoRoot(cwd);
140
+ if (!root) { console.error('✗ not inside a git repository'); return 1; }
141
+ const { flags } = parseFlags(argv.slice(1));
142
+ const actor = flags.actor || identity.resolveActor(root).id;
143
+ presenceLib.heartbeat(root, actor, { branch: flags.branch, lane: flags.lane, activity: flags.activity });
144
+ console.log(`✓ heartbeat for '${actor}'`);
145
+ return 0;
146
+ }
147
+ if (sub === 'presence') {
148
+ const root = repoRoot(cwd);
149
+ if (!root) { console.error('✗ not inside a git repository'); return 1; }
150
+ const now = Date.now();
151
+ const list = presenceLib.presence(root, now);
152
+ if (!list.length) { console.log('(no presence recorded)'); return 0; }
153
+ for (const p of list) {
154
+ console.log(`${p.liveness === 'active' ? '●' : p.liveness === 'idle' ? '◐' : '○'} ${p.actor} ${p.liveness} ${p.branch || ''}${p.activity ? ' — ' + p.activity : ''}`);
155
+ }
156
+ return 0;
157
+ }
158
+ if (sub === 'approve') {
159
+ const root = repoRoot(cwd);
160
+ if (!root) { console.error('✗ not inside a git repository'); return 1; }
161
+ const { flags, positional } = parseFlags(argv.slice(1));
162
+ const change = positional[0];
163
+ if (!change) { console.error('usage: momentum team approve <change> [--verdict approve|reject]'); return 1; }
164
+ const actor = flags.actor || identity.resolveActor(root).id;
165
+ approvalsLib.approve(root, actor, change, { verdict: flags.verdict || 'approve' });
166
+ console.log(`✓ ${flags.verdict === 'reject' ? 'rejected' : 'approved'} '${change}' as '${actor}'`);
167
+ return 0;
168
+ }
169
+ if (sub === 'check') {
170
+ const root = repoRoot(cwd);
171
+ if (!root) { console.error('✗ not inside a git repository'); return 1; }
172
+ const { flags, positional } = parseFlags(argv.slice(1));
173
+ const change = positional[0];
174
+ if (!change || !flags.author) { console.error('usage: momentum team check <change> --author <a> [--threshold N] [--allow-self]'); return 1; }
175
+ const threshold = flags.threshold ? parseInt(flags.threshold, 10) : 1;
176
+ const allowSelf = argv.includes('--allow-self');
177
+ const approvers = approvalsLib.approversFor(root, change, flags.author, allowSelf);
178
+ if (approvers.length >= threshold) {
179
+ console.log(`✓ '${change}' satisfied — ${approvers.length}/${threshold} approval(s): ${approvers.join(', ')}`);
180
+ return 0;
181
+ }
182
+ console.error(`✗ '${change}' not satisfied — ${approvers.length}/${threshold} peer approval(s)${allowSelf ? '' : ' (author self-approval does not count)'}`);
183
+ return 2;
184
+ }
185
+ if (sub === 'turn') {
186
+ const root = repoRoot(cwd);
187
+ if (!root) { console.error('✗ not inside a git repository'); return 1; }
188
+ const action = argv[1];
189
+ const runway = argv[2] || 'main';
190
+ const actor = identity.resolveActor(root).id;
191
+ if (action === 'take') {
192
+ const r = queueLib.takeTurn(root, runway, actor);
193
+ if (r.held) { console.log(`✓ took the '${runway}' landing turn as '${actor}'`); return 0; }
194
+ console.error(`✗ '${runway}' turn held by '${r.holder}' — wait or coordinate`); return 2;
195
+ }
196
+ if (action === 'release') { queueLib.releaseTurn(root, runway); console.log(`✓ released the '${runway}' turn`); return 0; }
197
+ if (action === 'holder') { const h = queueLib.turnHolder(root, runway); console.log(h ? `${runway}: held by '${h}'` : `${runway}: free`); return 0; }
198
+ console.error('usage: momentum team turn <take|release|holder> [runway]');
199
+ return 1;
200
+ }
201
+ if (sub === 'lease') {
202
+ const root = repoRoot(cwd);
203
+ if (!root) { console.error('✗ not inside a git repository'); return 1; }
204
+ const action = argv[1];
205
+ const resource = argv[2];
206
+ if (!action || !resource) { console.error('usage: momentum team lease <acquire|release|owner> <resource>'); return 1; }
207
+ const actor = identity.resolveActor(root).id;
208
+ if (action === 'acquire') {
209
+ const r = leaseLib.acquireLease(root, resource, actor);
210
+ if (r.held) { console.log(`✓ leased '${resource}' as '${actor}'`); return 0; }
211
+ console.error(`✗ '${resource}' leased by '${r.owner}'`); return 2;
212
+ }
213
+ if (action === 'release') { leaseLib.releaseLease(root, resource); console.log(`✓ released lease '${resource}'`); return 0; }
214
+ if (action === 'owner') { const o = leaseLib.leaseOwner(root, resource); console.log(o ? `${resource}: owned by '${o}'` : `${resource}: free`); return 0; }
215
+ console.error('usage: momentum team lease <acquire|release|owner> <resource>');
216
+ return 1;
217
+ }
218
+ if (sub === 'contract') {
219
+ console.log(JSON.stringify(CONTRACT, null, 2));
220
+ return 0;
221
+ }
222
+ if (sub === 'relay') {
223
+ const { flags } = parseFlags(argv.slice(1));
224
+ const relay = createRelay({ port: flags.port ? parseInt(flags.port, 10) : 0 });
225
+ relay.listen((port) => {
226
+ console.log(`✓ momentum coordination relay (authority-free) on http://127.0.0.1:${port}`);
227
+ console.log(` set relay_url=http://127.0.0.1:${port} for teammates; Ctrl-C to stop.`);
228
+ });
229
+ return 0; // server keeps the process alive
230
+ }
231
+ console.error('usage: momentum team <whoami|sync|board|record|compile|heartbeat|presence|approve|check|turn|lease|contract|relay>');
232
+ return 1;
233
+ }
234
+
235
+ module.exports = { runTeam, runClaim };
@@ -73,6 +73,11 @@ The brainstorm output IS the phase files — there is no intermediate design doc
73
73
  ```bash
74
74
  rm .momentum/brainstorm-active
75
75
  ```
76
+ **Reserve the phase number first (team-safe — multi-session):** run
77
+ `momentum claim phase <N>`. If a concurrent session already grabbed that
78
+ number the claim loses (exit 2) — pick the next free number before writing,
79
+ so two people brainstorming at once can't both create "Phase N".
80
+
76
81
  Then write all four files to `specs/phases/phase-N-shortname/` and commit:
77
82
  ```bash
78
83
  git add specs/phases/phase-N-shortname/
@@ -91,6 +91,11 @@ Verify, finalize, and release a completed phase.
91
91
  release creation and registry publishes are NOT listed here — they run
92
92
  from `specs/project-rules.md` + `specs/config.md` (`release_command`,
93
93
  `publish_target`, `release_flow`) after the tag is pushed.
94
+
95
+ **Reserve the version first (team-safe — ENH-057):** before tagging, run
96
+ `momentum claim version vX.Y.Z`. If another session already reserved that
97
+ version the claim loses (exit 2) — re-read the intended version and re-pick,
98
+ so two concurrent releases can never burn a version number.
94
99
  ```
95
100
  Ready to release vX.Y.Z. This will:
96
101
  1. Merge phase-N-shortname → <branch_flow[0]> (e.g. staging)
@@ -33,6 +33,9 @@ verification gate. For net-new features or cross-cutting work, use
33
33
 
34
34
  5. **Track it** (Rule 3): ensure the backlog has a row (`/track` if not). A
35
35
  pure chore with no backlog id is fine — note "Backlog: none" in the record.
36
+ **Team-safe (multi-session):** when assigning a NEW backlog id
37
+ (`BUG`/`FEAT`/`TD`/`ENH-NNN`), reserve it first with `momentum claim id <ID>`;
38
+ if it loses (exit 2) another session already filed that number — pick the next.
36
39
 
37
40
  6. **Do the work.** Commit with Conventional Commits (the `commit-msg` hook
38
41
  enforces this). Keep commits atomic.
@@ -25,6 +25,10 @@ const KNOWN_KEYS = [
25
25
  'end_state',
26
26
  'branch_flow',
27
27
  'protected_branches',
28
+ 'review_min_approvals',
29
+ 'review_self_approval',
30
+ 'presence_idle_seconds',
31
+ 'presence_offline_seconds',
28
32
  ];
29
33
 
30
34
  const LIST_KEYS = new Set(['branch_flow', 'protected_branches']);
package/core/config.js CHANGED
@@ -44,6 +44,12 @@ const DEFAULTS = Object.freeze({
44
44
  end_state: 'merge-after-yes',
45
45
  branch_flow: ['staging', 'main'],
46
46
  protected_branches: ['staging', 'main'],
47
+ // Team-mode (30d, ENH-064) — off by default so single-operator behavior is
48
+ // unchanged. review_min_approvals ≥ 1 turns on the reviewer≠author land gate.
49
+ review_min_approvals: '0',
50
+ review_self_approval: 'false',
51
+ presence_idle_seconds: '600',
52
+ presence_offline_seconds: '3600',
47
53
  });
48
54
 
49
55
  // Enumerations — a value outside the set resolves to the default + warning.
@@ -128,6 +128,16 @@ function prePush() {
128
128
  } catch {
129
129
  /* ignore */
130
130
  }
131
+ // Team-mode (Phase 30d): record WHO authorized this protected push
132
+ // (attributed audit). Best-effort — an audit-log failure must NEVER
133
+ // block a push, so the whole thing is wrapped and swallowed.
134
+ try {
135
+ const who = (require('child_process')
136
+ .execSync('git config user.email', { cwd: root }).toString().trim()) || 'unknown';
137
+ const line = `${new Date().toISOString()} ${who} authorized push to ${branch} (${parsed.localSha.slice(0, 7)})\n`;
138
+ fs.mkdirSync(path.join(root, '.momentum', 'team'), { recursive: true });
139
+ fs.appendFileSync(path.join(root, '.momentum', 'team', 'merge-approvals.log'), line);
140
+ } catch { /* never block a push on an audit-log failure */ }
131
141
  process.stderr.write(
132
142
  ` momentum: '${C.CONTRACT.mergeApprovedSentinel}' consumed — push to '${branch}' authorized.\n`
133
143
  );
@@ -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 };
@@ -371,6 +371,13 @@ worktree) bound to one phase or ad-hoc record.
371
371
  - The Active Phase table holds **one row per active lane**
372
372
  (Phase | Branch | Status | Progress). Add your row at phase start; update
373
373
  only your row; mark it at completion.
374
+ - **Across machines (Team Mode, v0.37.0+):** this convention is now backed by a
375
+ *mechanism*, not only discipline — per-actor coordination fragments
376
+ (`.momentum/team/`, collision-free by own-prefix construction) + `refs/momentum/*`
377
+ compare-and-swap for allocation (`momentum claim <phase|id|version>`).
378
+ `momentum team sync`/`board` compile the shared view. This makes **N humans on
379
+ N clones** trivially mergeable, not just N sessions on one disk; the social
380
+ discipline above is the fallback. See ADR-0012/0013/0014.
374
381
 
375
382
  #### Landing
376
383
 
@@ -43,6 +43,8 @@ const { spawnSync } = require('child_process');
43
43
  const state = require('./state');
44
44
  const cleanup = require('./cleanup');
45
45
  const config = require('../../config');
46
+ const approvals = require('../../team/lib/approvals');
47
+ const identity = require('../../identity');
46
48
 
47
49
  function git(cwd, ...args) {
48
50
  return spawnSync('git', args, { cwd, encoding: 'utf8' });
@@ -257,6 +259,23 @@ function cmdLand(cwd, argv) {
257
259
  checks.push(`${gate.ok ? '✓' : '✗'} gate[${lane.grade}]: ${gate.detail}`);
258
260
  if (!gate.ok) ok = false;
259
261
 
262
+ // 4b. reviewer≠author gate (Team-mode, config-gated — ENH-064). Off by default
263
+ // (review_min_approvals=0) so single-operator behavior is unchanged.
264
+ let reviewCfg = {};
265
+ try { reviewCfg = config.readConfig(path.join(repoRoot, 'specs')) || {}; } catch { /* absent */ }
266
+ const minApprovals = parseInt(reviewCfg.review_min_approvals, 10) || 0;
267
+ if (minApprovals >= 1) {
268
+ const lander = identity.resolveActor(cwd).id;
269
+ const allowSelf = String(reviewCfg.review_self_approval).toLowerCase() === 'true';
270
+ const peers = approvals.approversFor(repoRoot, id, lander, allowSelf);
271
+ if (peers.length >= minApprovals) {
272
+ checks.push(`✓ review: ${peers.length}/${minApprovals} approval(s) (${peers.join(', ')})`);
273
+ } else {
274
+ checks.push(`✗ review: ${peers.length}/${minApprovals} peer approval(s) — needs review by someone other than '${lander}' (they run: momentum team approve ${id})`);
275
+ ok = false;
276
+ }
277
+ }
278
+
260
279
  // 5. overlap advisory
261
280
  for (const w of state.overlapWarnings(anchor, id, lane.touches)) {
262
281
  checks.push(`⚠ touch overlap with '${w.laneId}': ${w.mine} ↔ ${w.theirs} (advisory)`);
@@ -25,6 +25,7 @@ const path = require('path');
25
25
  const { spawnSync } = require('child_process');
26
26
 
27
27
  const state = require('./state');
28
+ const identity = require('../../identity');
28
29
 
29
30
  const SEQ_WIDTH = 4;
30
31
 
@@ -120,6 +121,7 @@ function cmdSignal(cwd, argv) {
120
121
  type,
121
122
  text: text || null,
122
123
  from: currentBranch(cwd),
124
+ actor: identity.resolveActor(cwd).id, // Team-mode: durable "who" (ENH-064)
123
125
  at: new Date().toISOString(),
124
126
  });
125
127
  });
@@ -399,6 +399,13 @@ worktree) bound to one phase or ad-hoc record.
399
399
  - The Active Phase table holds **one row per active lane**
400
400
  (Phase | Branch | Status | Progress). Add your row at phase start; update
401
401
  only your row; mark it at completion.
402
+ - **Across machines (Team Mode, v0.37.0+):** this convention is now backed by a
403
+ *mechanism*, not only discipline — per-actor coordination fragments
404
+ (`.momentum/team/`, collision-free by own-prefix construction) + `refs/momentum/*`
405
+ compare-and-swap for allocation (`momentum claim <phase|id|version>`).
406
+ `momentum team sync`/`board` compile the shared view. This makes **N humans on
407
+ N clones** trivially mergeable, not just N sessions on one disk; the social
408
+ discipline above is the fallback. See ADR-0012/0013/0014.
402
409
 
403
410
  #### Landing
404
411
 
@@ -488,6 +488,31 @@ function assertOwnership(manifest, repo, sessionId, nowIso) {
488
488
  };
489
489
  }
490
490
 
491
+ /**
492
+ * Cross-machine lease fence (Phase 30d, ENH-064). OPT-IN via
493
+ * MOMENTUM_SWARM_LEASE_CAS=1: when active AND the ecosystem root is a git repo
494
+ * with a remote, taking over an EXPIRED lease must also win a
495
+ * refs/momentum/leases/* compare-and-swap — so clock skew can't let two machines
496
+ * both take over. Fail-open: only BLOCKS on a positively-confirmed different
497
+ * owner; never blocks on absence/network (the wall-clock lease then governs, as
498
+ * before). Default OFF → single-machine swarm behavior is unchanged.
499
+ */
500
+ function leaseFence(ecosystemRoot, key, holder) {
501
+ if (process.env.MOMENTUM_SWARM_LEASE_CAS !== '1') return { fenced: true, active: false };
502
+ try {
503
+ const { spawnSync } = require('child_process');
504
+ const remotes = spawnSync('git', ['remote'], { cwd: ecosystemRoot, encoding: 'utf8' });
505
+ if (remotes.status !== 0 || !remotes.stdout.trim()) return { fenced: true, active: false };
506
+ const lease = require('../../team/lib/lease');
507
+ const res = lease.acquireLease(ecosystemRoot, key, holder);
508
+ if (res.held) return { fenced: true, active: true };
509
+ if (res.owner && res.owner !== holder) return { fenced: false, active: true, owner: res.owner };
510
+ return { fenced: true, active: false }; // couldn't positively confirm → fail-open
511
+ } catch {
512
+ return { fenced: true, active: false };
513
+ }
514
+ }
515
+
491
516
  /**
492
517
  * Read-modify-write that enforces ownership of a specific repo BEFORE
493
518
  * applying the mutation. Throws on rejection.
@@ -520,6 +545,16 @@ function updateManifestAsOwner(args) {
520
545
  err.decision = decision;
521
546
  throw err;
522
547
  }
548
+ // Cross-machine fence: a wall-clock takeover must also win the ref-CAS
549
+ // lease (opt-in; no-op by default). Closes the clock-skew double-own hazard.
550
+ if (decision.expired) {
551
+ const fence = leaseFence(ecosystemRoot, `swarm-${swarmId}-${repo}`, sessionId);
552
+ if (!fence.fenced) {
553
+ const err = new Error(`updateManifestAsOwner: takeover fenced — "${repo}" lease held by ${fence.owner} (refs/momentum/leases)`);
554
+ err.code = 'EOWNERSHIP';
555
+ throw err;
556
+ }
557
+ }
523
558
  const result = mutate(current, decision);
524
559
  let mutated = current;
525
560
  if (result && typeof result === 'object' && 'manifest' in result) {
@@ -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 };