@limina-labs/momentum 0.37.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/team.js +12 -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/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/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/team.js
CHANGED
|
@@ -79,6 +79,18 @@ function runClaim(argv) {
|
|
|
79
79
|
function runTeam(argv) {
|
|
80
80
|
const sub = argv[0];
|
|
81
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
|
+
|
|
82
94
|
if (sub === 'whoami') {
|
|
83
95
|
const a = identity.resolveActor(cwd);
|
|
84
96
|
console.log(`${a.id} (source: ${a.source}${a.email ? `, ${a.email}` : ''})`);
|
|
@@ -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
|
);
|
|
@@ -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) {
|
package/package.json
CHANGED