@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.
- package/adapters/antigravity/instructions/AGENTS.md +7 -0
- package/adapters/codex/instructions/AGENTS.md +7 -0
- package/adapters/opencode/instructions/AGENTS.md +7 -0
- package/bin/momentum.js +16 -0
- package/bin/team.js +235 -0
- package/core/commands/brainstorm-phase.md +5 -0
- package/core/commands/complete-phase.md +5 -0
- package/core/commands/hotfix.md +3 -0
- package/core/config-templates.js +4 -0
- package/core/config.js +6 -0
- package/core/git-hooks/run-check.js +10 -0
- package/core/identity/index.js +66 -0
- package/core/instructions/rules-body.md +7 -0
- package/core/lanes/lib/land.js +19 -0
- package/core/lanes/lib/signals.js +2 -0
- package/core/specs-templates/CLAUDE.md +7 -0
- package/core/swarm/lib/manifest.js +35 -0
- package/core/team/contract/index.js +41 -0
- package/core/team/lib/approvals.js +47 -0
- package/core/team/lib/claim.js +33 -0
- package/core/team/lib/compile.js +106 -0
- package/core/team/lib/fragments.js +109 -0
- package/core/team/lib/lease.js +43 -0
- package/core/team/lib/presence.js +54 -0
- package/core/team/lib/queue.js +51 -0
- package/core/team/lib/refcas.js +112 -0
- package/core/team/relay/client.js +51 -0
- package/core/team/relay/server.js +68 -0
- package/package.json +1 -1
|
@@ -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)
|
package/core/commands/hotfix.md
CHANGED
|
@@ -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.
|
package/core/config-templates.js
CHANGED
|
@@ -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
|
|
package/core/lanes/lib/land.js
CHANGED
|
@@ -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 };
|