@limina-labs/momentum 0.35.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/adapters/antigravity/instructions/AGENTS.md +6 -2
- package/adapters/antigravity/instructions/manifest.json +8 -0
- package/adapters/claude-code/instructions/manifest.json +8 -0
- package/adapters/codex/instructions/AGENTS.md +6 -2
- package/adapters/codex/instructions/manifest.json +8 -0
- package/adapters/opencode/instructions/AGENTS.md +6 -2
- package/adapters/opencode/instructions/manifest.json +8 -0
- package/bin/ecosystem.js +11 -1
- package/bin/momentum.js +72 -10
- package/bin/team.js +223 -0
- package/core/identity/index.js +66 -0
- package/core/instructions/README.md +69 -0
- package/core/instructions/rules-body.md +2 -2
- package/core/lib/instruction-compose.js +175 -0
- package/core/specs-templates/CLAUDE.md +7 -3
- 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
- package/adapters/antigravity/instructions/header.md +0 -3
- package/adapters/antigravity/instructions/vars.json +0 -1
- package/adapters/claude-code/instructions/header.md +0 -3
- package/adapters/claude-code/instructions/vars.json +0 -1
- package/adapters/codex/instructions/header.md +0 -3
- package/adapters/codex/instructions/vars.json +0 -1
- package/adapters/opencode/instructions/header.md +0 -3
- package/adapters/opencode/instructions/vars.json +0 -1
|
@@ -176,6 +176,10 @@ lives in this AGENTS.md primary-instruction text as a belt-and-braces
|
|
|
176
176
|
fallback: if a `.momentum/inbox/handoff-NNN.md` file exists at session
|
|
177
177
|
start, read it and acknowledge before continuing.
|
|
178
178
|
|
|
179
|
+
## In-Session Task Tool
|
|
180
|
+
|
|
181
|
+
When a rule mentions tracking in-session progress (Rule 2), Antigravity's task tool is the native **task list** artifact (task.md) — in-session only; the durable record is `specs/phases/<phase>/tasks.md`.
|
|
182
|
+
|
|
179
183
|
## Autonomous Behaviors (Always-On Rules)
|
|
180
184
|
|
|
181
185
|
### Rule 1: Always Orient First
|
|
@@ -197,7 +201,7 @@ After completing ANY meaningful work, automatically update:
|
|
|
197
201
|
2. **`specs/status.md`** — if phase progress, blockers, or P0 items changed (touch only your own lane's row — Rule 15)
|
|
198
202
|
3. **`specs/changelog/YYYY-MM.md`** — log what changed (one line per change, append-only)
|
|
199
203
|
|
|
200
|
-
Use
|
|
204
|
+
Use your in-session task-tracking tool (named in the "In-Session Task Tool" note above) to track in-session task progress. Do NOT wait for the user to ask you to update tracking.
|
|
201
205
|
|
|
202
206
|
#### Why
|
|
203
207
|
Tracking debt compounds invisibly. A task list one day stale is recoverable; one week stale is fiction. Status drift is how phases silently lose direction.
|
|
@@ -215,7 +219,7 @@ Tracking debt compounds invisibly. A task list one day stale is recoverable; one
|
|
|
215
219
|
#### Anti-Rationalization Counters
|
|
216
220
|
|
|
217
221
|
- "It's faster to do the work first and track at the end" — wrong: reconstruction takes 2-3× longer than real-time logging.
|
|
218
|
-
- "
|
|
222
|
+
- "Your in-session task-tracking tool is enough" — it is in-session only; `tasks.md` is the durable record.
|
|
219
223
|
- "Mid-task tracking interrupts flow" — a one-line update costs <30s; reconstructing a day later costs 30 minutes.
|
|
220
224
|
|
|
221
225
|
### Rule 3: Auto-Track Discoveries
|
|
@@ -230,6 +230,10 @@ This is the documented escalation point referenced in Phase 18 G1
|
|
|
230
230
|
plan.md; if the MCP shim is widely broken, momentum will ship a
|
|
231
231
|
minimal `core/swarm/mcp-cwd-server.js` in a follow-up.
|
|
232
232
|
|
|
233
|
+
## In-Session Task Tool
|
|
234
|
+
|
|
235
|
+
When a rule mentions tracking in-session progress (Rule 2), Codex's task tool is the built-in **plan** tool — in-session only; the durable record is `specs/phases/<phase>/tasks.md`.
|
|
236
|
+
|
|
233
237
|
## Autonomous Behaviors (Always-On Rules)
|
|
234
238
|
|
|
235
239
|
### Rule 1: Always Orient First
|
|
@@ -251,7 +255,7 @@ After completing ANY meaningful work, automatically update:
|
|
|
251
255
|
2. **`specs/status.md`** — if phase progress, blockers, or P0 items changed (touch only your own lane's row — Rule 15)
|
|
252
256
|
3. **`specs/changelog/YYYY-MM.md`** — log what changed (one line per change, append-only)
|
|
253
257
|
|
|
254
|
-
Use
|
|
258
|
+
Use your in-session task-tracking tool (named in the "In-Session Task Tool" note above) to track in-session task progress. Do NOT wait for the user to ask you to update tracking.
|
|
255
259
|
|
|
256
260
|
#### Why
|
|
257
261
|
Tracking debt compounds invisibly. A task list one day stale is recoverable; one week stale is fiction. Status drift is how phases silently lose direction.
|
|
@@ -269,7 +273,7 @@ Tracking debt compounds invisibly. A task list one day stale is recoverable; one
|
|
|
269
273
|
#### Anti-Rationalization Counters
|
|
270
274
|
|
|
271
275
|
- "It's faster to do the work first and track at the end" — wrong: reconstruction takes 2-3× longer than real-time logging.
|
|
272
|
-
- "
|
|
276
|
+
- "Your in-session task-tracking tool is enough" — it is in-session only; `tasks.md` is the durable record.
|
|
273
277
|
- "Mid-task tracking interrupts flow" — a one-line update costs <30s; reconstructing a day later costs 30 minutes.
|
|
274
278
|
|
|
275
279
|
### Rule 3: Auto-Track Discoveries
|
|
@@ -143,6 +143,10 @@ skill paths (`.claude/skills/`, `.agents/skills/`); momentum's opencode
|
|
|
143
143
|
surfaces live under `.opencode/` so multi-adapter installs stay
|
|
144
144
|
side-by-side without collisions.
|
|
145
145
|
|
|
146
|
+
## In-Session Task Tool
|
|
147
|
+
|
|
148
|
+
When a rule mentions tracking in-session progress (Rule 2), opencode's task tool is the built-in **task** tool (subagent fan-out) — in-session only; the durable record is `specs/phases/<phase>/tasks.md`.
|
|
149
|
+
|
|
146
150
|
## Autonomous Behaviors (Always-On Rules)
|
|
147
151
|
|
|
148
152
|
### Rule 1: Always Orient First
|
|
@@ -164,7 +168,7 @@ After completing ANY meaningful work, automatically update:
|
|
|
164
168
|
2. **`specs/status.md`** — if phase progress, blockers, or P0 items changed (touch only your own lane's row — Rule 15)
|
|
165
169
|
3. **`specs/changelog/YYYY-MM.md`** — log what changed (one line per change, append-only)
|
|
166
170
|
|
|
167
|
-
Use
|
|
171
|
+
Use your in-session task-tracking tool (named in the "In-Session Task Tool" note above) to track in-session task progress. Do NOT wait for the user to ask you to update tracking.
|
|
168
172
|
|
|
169
173
|
#### Why
|
|
170
174
|
Tracking debt compounds invisibly. A task list one day stale is recoverable; one week stale is fiction. Status drift is how phases silently lose direction.
|
|
@@ -182,7 +186,7 @@ Tracking debt compounds invisibly. A task list one day stale is recoverable; one
|
|
|
182
186
|
#### Anti-Rationalization Counters
|
|
183
187
|
|
|
184
188
|
- "It's faster to do the work first and track at the end" — wrong: reconstruction takes 2-3× longer than real-time logging.
|
|
185
|
-
- "
|
|
189
|
+
- "Your in-session task-tracking tool is enough" — it is in-session only; `tasks.md` is the durable record.
|
|
186
190
|
- "Mid-task tracking interrupts flow" — a one-line update costs <30s; reconstructing a day later costs 30 minutes.
|
|
187
191
|
|
|
188
192
|
### Rule 3: Auto-Track Discoveries
|
package/bin/ecosystem.js
CHANGED
|
@@ -777,9 +777,18 @@ function detectMemberAgent(repoPath) {
|
|
|
777
777
|
const m = JSON.parse(
|
|
778
778
|
fs.readFileSync(path.join(repoPath, '.momentum', 'installed.json'), 'utf8'),
|
|
779
779
|
);
|
|
780
|
-
|
|
780
|
+
// ADR-0007: the lock is `{ agents: { <id>: … } }`; the pre-0007 lock was
|
|
781
|
+
// `{ agent }`. Read the map first (else every AGENTS.md agent — opencode,
|
|
782
|
+
// Antigravity, Codex — falls through to the `AGENTS.md ⇒ codex` heuristic
|
|
783
|
+
// below and is misidentified). ADR-0011.
|
|
784
|
+
if (m && m.agents && typeof m.agents === 'object') {
|
|
785
|
+
const ids = Object.keys(m.agents);
|
|
786
|
+
if (ids.length) return ids.includes('claude-code') ? 'claude-code' : ids.sort()[0];
|
|
787
|
+
}
|
|
788
|
+
if (m && m.agent) return m.agent; // legacy single-agent lock
|
|
781
789
|
} catch (_e) { /* fall through to heuristic */ }
|
|
782
790
|
if (fs.existsSync(path.join(repoPath, '.codex'))) return 'codex';
|
|
791
|
+
if (fs.existsSync(path.join(repoPath, '.opencode'))) return 'opencode';
|
|
783
792
|
if (fs.existsSync(path.join(repoPath, '.claude'))) return 'claude-code';
|
|
784
793
|
if (
|
|
785
794
|
fs.existsSync(path.join(repoPath, '.agents', 'hooks.json')) ||
|
|
@@ -1297,6 +1306,7 @@ module.exports = {
|
|
|
1297
1306
|
ensurePointerInjected,
|
|
1298
1307
|
stripPointer,
|
|
1299
1308
|
findPrimaryInstructionFile,
|
|
1309
|
+
detectMemberAgent,
|
|
1300
1310
|
sanitizeId,
|
|
1301
1311
|
POINTER_BEGIN,
|
|
1302
1312
|
POINTER_END,
|
package/bin/momentum.js
CHANGED
|
@@ -719,7 +719,46 @@ function resolveAdapterSource(srcRoot, adapterDir, fileSpec) {
|
|
|
719
719
|
return path.join(base, ...fileSpec.source);
|
|
720
720
|
}
|
|
721
721
|
|
|
722
|
-
|
|
722
|
+
/**
|
|
723
|
+
* ADR-0011 — the raw primary-instruction template content for `agent`.
|
|
724
|
+
* A claude-md agent (and a lone installed agents-md agent) gets the static
|
|
725
|
+
* committed template. When MORE THAN ONE agents-md agent is installed, AGENTS.md
|
|
726
|
+
* is COMPOSED — the neutral spine once + one integration section per installed
|
|
727
|
+
* AGENTS.md agent — so switching or running several agents never drops an
|
|
728
|
+
* integration (fixes the same-path collision). Project name is not yet rendered.
|
|
729
|
+
*/
|
|
730
|
+
function resolvePrimaryInstructionContent(srcRoot, targetDir, agent, srcPath) {
|
|
731
|
+
const readStatic = () => fs.readFileSync(srcPath, 'utf8');
|
|
732
|
+
let compose;
|
|
733
|
+
try {
|
|
734
|
+
compose = require('../core/lib/instruction-compose');
|
|
735
|
+
} catch (_e) {
|
|
736
|
+
return readStatic();
|
|
737
|
+
}
|
|
738
|
+
let manifest;
|
|
739
|
+
try {
|
|
740
|
+
manifest = compose.readManifest(srcRoot, agent);
|
|
741
|
+
} catch (_e) {
|
|
742
|
+
return readStatic(); // unknown / manifest-less agent → static template
|
|
743
|
+
}
|
|
744
|
+
if (manifest.surface !== 'agents-md') return readStatic(); // claude-md → static
|
|
745
|
+
|
|
746
|
+
// The set of installed AGENTS.md agents ∪ the one being (re)installed.
|
|
747
|
+
const valid = new Set(compose.discoverAgents(srcRoot));
|
|
748
|
+
const ids = new Set(valid.has(agent) ? [agent] : []);
|
|
749
|
+
const state = loadInstalledState(targetDir);
|
|
750
|
+
for (const id of Object.keys(state.agents || {})) {
|
|
751
|
+
if (!valid.has(id)) continue;
|
|
752
|
+
try {
|
|
753
|
+
if (compose.readManifest(srcRoot, id).surface === 'agents-md') ids.add(id);
|
|
754
|
+
} catch (_e) { /* skip an agent we can't read */ }
|
|
755
|
+
}
|
|
756
|
+
const sorted = [...ids].sort();
|
|
757
|
+
if (sorted.length <= 1) return readStatic(); // N=1 → static committed template
|
|
758
|
+
return compose.composeInstruction(srcRoot, sorted); // N>1 → composed
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function installPrimaryInstruction(srcRoot, targetDir, adapterDir, primaryInstruction, agent) {
|
|
723
762
|
if (!primaryInstruction) return null;
|
|
724
763
|
const srcPath = resolveAdapterSource(srcRoot, adapterDir, primaryInstruction);
|
|
725
764
|
const destPath = path.join(targetDir, ...primaryInstruction.destination);
|
|
@@ -733,7 +772,7 @@ function installPrimaryInstruction(srcRoot, targetDir, adapterDir, primaryInstru
|
|
|
733
772
|
return 'added';
|
|
734
773
|
}
|
|
735
774
|
const rendered = renderProjectName(
|
|
736
|
-
|
|
775
|
+
resolvePrimaryInstructionContent(srcRoot, targetDir, agent, srcPath),
|
|
737
776
|
getProjectName(targetDir)
|
|
738
777
|
);
|
|
739
778
|
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
@@ -745,7 +784,7 @@ function installPrimaryInstruction(srcRoot, targetDir, adapterDir, primaryInstru
|
|
|
745
784
|
return 'skipped';
|
|
746
785
|
}
|
|
747
786
|
|
|
748
|
-
function upgradePrimaryInstruction(srcRoot, targetDir, adapterDir, primaryInstruction) {
|
|
787
|
+
function upgradePrimaryInstruction(srcRoot, targetDir, adapterDir, primaryInstruction, agent) {
|
|
749
788
|
if (!primaryInstruction) return null;
|
|
750
789
|
const srcPath = resolveAdapterSource(srcRoot, adapterDir, primaryInstruction);
|
|
751
790
|
const destPath = path.join(targetDir, ...primaryInstruction.destination);
|
|
@@ -753,12 +792,15 @@ function upgradePrimaryInstruction(srcRoot, targetDir, adapterDir, primaryInstru
|
|
|
753
792
|
const projectName = getProjectName(targetDir);
|
|
754
793
|
recordManaged(destPath); // managed (marker-owned)
|
|
755
794
|
|
|
795
|
+
// ADR-0011: compose AGENTS.md across all installed AGENTS.md agents.
|
|
796
|
+
const rawContent = resolvePrimaryInstructionContent(srcRoot, targetDir, agent, srcPath);
|
|
797
|
+
|
|
756
798
|
console.log(`→ Upgrading ${label}...`);
|
|
757
799
|
if (primaryInstruction.markerAware) {
|
|
758
|
-
return upgradeMarkedFile(srcPath, destPath, label, targetDir, projectName);
|
|
800
|
+
return upgradeMarkedFile(srcPath, destPath, label, targetDir, projectName, rawContent);
|
|
759
801
|
}
|
|
760
802
|
|
|
761
|
-
const srcContent = renderProjectName(
|
|
803
|
+
const srcContent = renderProjectName(rawContent, projectName);
|
|
762
804
|
const relDest = path.relative(targetDir, destPath);
|
|
763
805
|
if (!fileExists(destPath)) {
|
|
764
806
|
if (_dryRun) {
|
|
@@ -895,11 +937,14 @@ function partitionByMarker(content) {
|
|
|
895
937
|
*
|
|
896
938
|
* Returns one of: 'added', 'updated', 'unchanged', 'migrated'.
|
|
897
939
|
*/
|
|
898
|
-
function upgradeMarkedFile(srcPath, destPath, label, root, projectName) {
|
|
940
|
+
function upgradeMarkedFile(srcPath, destPath, label, root, projectName, srcContentRaw) {
|
|
899
941
|
const rel = path.relative(root || process.cwd(), destPath);
|
|
900
942
|
recordManaged(destPath); // managed (marker-owned) in all branches
|
|
901
943
|
const render = (content) =>
|
|
902
944
|
projectName ? renderProjectName(content, projectName) : content;
|
|
945
|
+
// ADR-0011: callers may pass composed content (multi-agent AGENTS.md); else
|
|
946
|
+
// read the static committed template.
|
|
947
|
+
const rawSrc = srcContentRaw != null ? srcContentRaw : fs.readFileSync(srcPath, 'utf8');
|
|
903
948
|
|
|
904
949
|
if (!fileExists(destPath)) {
|
|
905
950
|
if (_dryRun) {
|
|
@@ -907,12 +952,12 @@ function upgradeMarkedFile(srcPath, destPath, label, root, projectName) {
|
|
|
907
952
|
return 'added';
|
|
908
953
|
}
|
|
909
954
|
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
910
|
-
fs.writeFileSync(destPath, render(
|
|
955
|
+
fs.writeFileSync(destPath, render(rawSrc));
|
|
911
956
|
console.log(` + added: ${rel}`);
|
|
912
957
|
return 'added';
|
|
913
958
|
}
|
|
914
959
|
|
|
915
|
-
const srcContent = render(
|
|
960
|
+
const srcContent = render(rawSrc);
|
|
916
961
|
const destContent = fs.readFileSync(destPath, 'utf8');
|
|
917
962
|
const destParts = partitionByMarker(destContent);
|
|
918
963
|
|
|
@@ -1215,7 +1260,7 @@ function init(targetDir, agent, opts = {}) {
|
|
|
1215
1260
|
console.log('→ Inferring project config...');
|
|
1216
1261
|
installConfig(target, { dryRun: _dryRun });
|
|
1217
1262
|
|
|
1218
|
-
installPrimaryInstruction(src, target, adapterDir, adapter.primaryInstruction);
|
|
1263
|
+
installPrimaryInstruction(src, target, adapterDir, adapter.primaryInstruction, agent);
|
|
1219
1264
|
|
|
1220
1265
|
// Adapter overlay — per-agent commands/agent-rules/scripts (additive)
|
|
1221
1266
|
applyOverlay(adapterDir, target, dests, { backup: true, root: target });
|
|
@@ -1381,7 +1426,8 @@ function upgrade(targetDir, agent, opts = {}) {
|
|
|
1381
1426
|
src,
|
|
1382
1427
|
target,
|
|
1383
1428
|
adapterDir,
|
|
1384
|
-
adapter.primaryInstruction
|
|
1429
|
+
adapter.primaryInstruction,
|
|
1430
|
+
agent
|
|
1385
1431
|
);
|
|
1386
1432
|
|
|
1387
1433
|
if (hadPointerBlock && !_dryRun) {
|
|
@@ -2152,6 +2198,22 @@ async function main() {
|
|
|
2152
2198
|
console.error(`\nError: ${err.message}`);
|
|
2153
2199
|
exitCode = 1;
|
|
2154
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
|
+
}
|
|
2155
2217
|
} else {
|
|
2156
2218
|
console.error(`Unknown command: ${args[0]}`);
|
|
2157
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 };
|