@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.
Files changed (35) hide show
  1. package/adapters/antigravity/instructions/AGENTS.md +6 -2
  2. package/adapters/antigravity/instructions/manifest.json +8 -0
  3. package/adapters/claude-code/instructions/manifest.json +8 -0
  4. package/adapters/codex/instructions/AGENTS.md +6 -2
  5. package/adapters/codex/instructions/manifest.json +8 -0
  6. package/adapters/opencode/instructions/AGENTS.md +6 -2
  7. package/adapters/opencode/instructions/manifest.json +8 -0
  8. package/bin/ecosystem.js +11 -1
  9. package/bin/momentum.js +72 -10
  10. package/bin/team.js +223 -0
  11. package/core/identity/index.js +66 -0
  12. package/core/instructions/README.md +69 -0
  13. package/core/instructions/rules-body.md +2 -2
  14. package/core/lib/instruction-compose.js +175 -0
  15. package/core/specs-templates/CLAUDE.md +7 -3
  16. package/core/team/contract/index.js +41 -0
  17. package/core/team/lib/approvals.js +47 -0
  18. package/core/team/lib/claim.js +33 -0
  19. package/core/team/lib/compile.js +106 -0
  20. package/core/team/lib/fragments.js +109 -0
  21. package/core/team/lib/lease.js +43 -0
  22. package/core/team/lib/presence.js +54 -0
  23. package/core/team/lib/queue.js +51 -0
  24. package/core/team/lib/refcas.js +112 -0
  25. package/core/team/relay/client.js +51 -0
  26. package/core/team/relay/server.js +68 -0
  27. package/package.json +1 -1
  28. package/adapters/antigravity/instructions/header.md +0 -3
  29. package/adapters/antigravity/instructions/vars.json +0 -1
  30. package/adapters/claude-code/instructions/header.md +0 -3
  31. package/adapters/claude-code/instructions/vars.json +0 -1
  32. package/adapters/codex/instructions/header.md +0 -3
  33. package/adapters/codex/instructions/vars.json +0 -1
  34. package/adapters/opencode/instructions/header.md +0 -3
  35. 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 the native **task list** artifact (task.md) to track in-session task progress. Do NOT wait for the user to ask you to update tracking.
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
- - "The task-list artifact is enough" — The task-list artifact is in-session only; `tasks.md` is the durable record.
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
@@ -0,0 +1,8 @@
1
+ {
2
+ "id": "antigravity",
3
+ "displayName": "Antigravity",
4
+ "surface": "agents-md",
5
+ "taskTool": "the native **task list** artifact (task.md)",
6
+ "taskToolName": "The task-list artifact",
7
+ "hasSurfaceDelta": true
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "id": "claude-code",
3
+ "displayName": "Claude Code",
4
+ "surface": "claude-md",
5
+ "taskTool": "the built-in **TodoWrite** tool",
6
+ "taskToolName": "TodoWrite",
7
+ "hasSurfaceDelta": false
8
+ }
@@ -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 the built-in **plan** tool to track in-session task progress. Do NOT wait for the user to ask you to update tracking.
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
- - "The plan tool is enough" — The plan tool is in-session only; `tasks.md` is the durable record.
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
@@ -0,0 +1,8 @@
1
+ {
2
+ "id": "codex",
3
+ "displayName": "Codex",
4
+ "surface": "agents-md",
5
+ "taskTool": "the built-in **plan** tool",
6
+ "taskToolName": "The plan tool",
7
+ "hasSurfaceDelta": true
8
+ }
@@ -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 the built-in **task** tool (subagent fan-out) to track in-session task progress. Do NOT wait for the user to ask you to update tracking.
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
- - "The task tool is enough" — The task tool is in-session only; `tasks.md` is the durable record.
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
@@ -0,0 +1,8 @@
1
+ {
2
+ "id": "opencode",
3
+ "displayName": "opencode",
4
+ "surface": "agents-md",
5
+ "taskTool": "the built-in **task** tool (subagent fan-out)",
6
+ "taskToolName": "The task tool",
7
+ "hasSurfaceDelta": true
8
+ }
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
- if (m && m.agent) return m.agent;
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
- function installPrimaryInstruction(srcRoot, targetDir, adapterDir, primaryInstruction) {
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
- fs.readFileSync(srcPath, 'utf8'),
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(fs.readFileSync(srcPath, 'utf8'), projectName);
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(fs.readFileSync(srcPath, 'utf8')));
955
+ fs.writeFileSync(destPath, render(rawSrc));
911
956
  console.log(` + added: ${rel}`);
912
957
  return 'added';
913
958
  }
914
959
 
915
- const srcContent = render(fs.readFileSync(srcPath, 'utf8'));
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 };