@limina-labs/momentum 0.41.1 → 0.42.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/bin/ecosystem.js CHANGED
@@ -1834,16 +1834,11 @@ function resolveEcosystemRoot(explicitPath, subcommand) {
1834
1834
  }
1835
1835
  return abs;
1836
1836
  }
1837
- // First try parent walk-up succeeds when CWD is inside the ecosystem root.
1837
+ // ONE resolver (ADR-0018 R3): findRoot does up-walk -> sibling scan ->
1838
+ // registration fallback. The local sibling+registration fallback that used to
1839
+ // live here is gone; it existed only because findRoot walked up only.
1838
1840
  const found = lib.findRoot(process.cwd());
1839
1841
  if (found) return found;
1840
- // Then try sibling walk — succeeds when CWD is inside a member repo
1841
- // whose ecosystem root is a sibling directory. This mirrors the
1842
- // session-append.sh hook's discovery pattern and is what makes
1843
- // ENH-021 actually feel location-agnostic from inside a member repo.
1844
- const stateLib = require('../core/ecosystem/lib/state');
1845
- const reg = stateLib.findRegistration(process.cwd());
1846
- if (reg) return reg.rootPath;
1847
1842
  throw new Error(
1848
1843
  `${subcommand}: no ecosystem.json found in this or any parent directory, ` +
1849
1844
  `nor in any reachable sibling. Pass --ecosystem <path>, or cd to an ` +
package/bin/momentum.js CHANGED
@@ -1226,14 +1226,21 @@ function init(targetDir, agent, opts = {}) {
1226
1226
  if (!_dryRun) fs.chmodSync(sessionDest, 0o755);
1227
1227
  }
1228
1228
 
1229
- // Phase 31b — fleet-orient helper, read by sessionstart-handoff.sh for its
1230
- // Fleet banner line. Shipped here for the same reason session-append.sh is:
1231
- // an installed project receives no copy of momentum's core/, so the script
1232
- // must travel with the hooks that use it. Dependency-free by construction.
1229
+ // Phase 31c (ADR-0018 R1) the shipped runtime. Hooks and scripts require
1230
+ // REAL core modules from `.momentum/runtime/` instead of carrying hand-written
1231
+ // mirrors of them, which is what produced BUG-029 and BUG-030. Verbatim
1232
+ // copies, computed from the entry points' actual require graph.
1233
+ console.log('→ Installing shipped runtime (.momentum/runtime/)...');
1234
+ const runtimeFiles = require('../core/runtime/closure').install(target, { dryRun: _dryRun });
1235
+ console.log(` ${runtimeFiles.length} runtime module(s)`);
1236
+
1237
+ // TRANSITIONAL (31c G1→G3): `sessionstart-handoff.sh` and `cross-repo.js`
1238
+ // still resolve `scripts/orient.js`. G1 is deliberately ADDITIVE — the runtime
1239
+ // arrives before its consumers are rewired — so this copy stays until G2/G3
1240
+ // point them at the runtime, then it is deleted.
1233
1241
  const orientSrc = path.join(src, 'core', 'ecosystem', 'lib', 'orient.js');
1234
1242
  if (fs.existsSync(orientSrc)) {
1235
- const orientDest = path.join(target, ...dests.scripts, 'orient.js');
1236
- copyFile(orientSrc, orientDest);
1243
+ copyFile(orientSrc, path.join(target, ...dests.scripts, 'orient.js'));
1237
1244
  }
1238
1245
 
1239
1246
  // .githooks/ — git-lifecycle enforcement hooks (Phase 19)
@@ -1367,7 +1374,10 @@ function upgrade(targetDir, agent, opts = {}) {
1367
1374
  const sessionUpgradeDest = path.join(target, ...dests.scripts, 'session-append.sh');
1368
1375
  copyFile(sessionUpgradeSrc, sessionUpgradeDest);
1369
1376
  }
1370
- // Phase 31b — fleet-orient helper (see the install path for why it ships).
1377
+ // Phase 31c (ADR-0018 R1) refresh the shipped runtime so hooks always run
1378
+ // the same core code as the installed momentum version.
1379
+ require('../core/runtime/closure').install(target, { dryRun: _dryRun });
1380
+ // TRANSITIONAL (31c G1→G3) — see the install path.
1371
1381
  const orientUpgradeSrc = path.join(src, 'core', 'ecosystem', 'lib', 'orient.js');
1372
1382
  if (fs.existsSync(orientUpgradeSrc)) {
1373
1383
  copyFile(orientUpgradeSrc, path.join(target, ...dests.scripts, 'orient.js'));
package/bin/swarm.js CHANGED
@@ -81,10 +81,9 @@ function resolveEcosystemRoot(explicit) {
81
81
  if (explicit && fs.existsSync(path.join(explicit, 'ecosystem.json'))) {
82
82
  return path.resolve(explicit);
83
83
  }
84
+ // ONE resolver (ADR-0018 R3) — findRoot covers sibling + registration itself.
84
85
  const fromCwd = ecosystemLib.findRoot(process.cwd());
85
86
  if (fromCwd) return fromCwd;
86
- const reg = findRegistration(process.cwd());
87
- if (reg && reg.rootPath) return reg.rootPath;
88
87
  throw new Error(
89
88
  'swarm: cannot locate ecosystem root. Run from inside an ecosystem ' +
90
89
  'or pass --ecosystem <path>.'
@@ -28,22 +28,36 @@ const fs = require('fs');
28
28
  const path = require('path');
29
29
 
30
30
  const EVENTS_VIEW = 'eco-events';
31
- const DEFAULT_WINDOW_HOURS = 24;
32
31
 
33
- /** Lazily locate orient.js wherever this file happens to be installed. */
34
- function loadOrient() {
35
- const here = __dirname;
36
- const candidates = [
37
- path.join(here, 'orient.js'), // co-installed
38
- path.join(here, '..', 'scripts', 'orient.js'), // .githooks/ scripts/
39
- path.join(here, 'orient.js'),
40
- path.join(here, '..', 'ecosystem', 'lib', 'orient.js'),
41
- path.join(here, '..', '..', 'ecosystem', 'lib', 'orient.js'),
42
- ];
43
- for (const c of candidates) {
44
- try {
45
- if (fs.existsSync(c)) return require(c);
46
- } catch (_e) { /* try next */ }
32
+ /**
33
+ * Resolve a core module (ADR-0018 R2).
34
+ *
35
+ * ONE depth-independent rule rather than a per-location candidate list: walk up
36
+ * from this file looking for `.momentum/runtime/`, then for a `core/` that
37
+ * contains the module. This file executes from two different depths — as
38
+ * momentum's own `core/ecosystem/lib/cross-repo.js` and as the installed
39
+ * `.githooks/cross-repo.js` and a fixed relative path cannot serve both.
40
+ *
41
+ * The five-entry hardcoded lookup this file used to carry (for `orient.js`) is
42
+ * exactly what R2 objects to: it encoded assumptions about layout per call site
43
+ * and broke silently when either layout moved. A bounded upward search has one
44
+ * rule and one failure mode.
45
+ */
46
+ function resolveModule(rel) {
47
+ let dir = __dirname;
48
+ for (let i = 0; i < 8; i++) {
49
+ for (const base of [path.join(dir, '.momentum', 'runtime'), path.join(dir, 'core')]) {
50
+ const cand = path.join(base, rel);
51
+ try {
52
+ if (fs.existsSync(cand)) {
53
+ // eslint-disable-next-line global-require
54
+ return require(cand);
55
+ }
56
+ } catch (_e) { /* keep searching */ }
57
+ }
58
+ const parent = path.dirname(dir);
59
+ if (parent === dir) break;
60
+ dir = parent;
47
61
  }
48
62
  return null;
49
63
  }
@@ -56,90 +70,28 @@ function readJson(file) {
56
70
  }
57
71
  }
58
72
 
59
- /** Members with recorded events inside the window (mirrors detect.touchedMembers). */
60
- function touchedMembers(ecosystemRoot, opts) {
61
- opts = opts || {};
62
- const manifest = opts.manifest || readJson(path.join(ecosystemRoot, 'ecosystem.json'));
63
- const cfgHours = manifest && manifest.config && manifest.config.detect_window_hours;
64
- const hours = opts.hours
65
- || (typeof cfgHours === 'number' && cfgHours > 0 ? cfgHours : DEFAULT_WINDOW_HOURS);
66
- const now = opts.now ? new Date(opts.now).getTime() : Date.now();
67
- const cutoff = now - hours * 3600 * 1000;
68
-
69
- const seen = new Set(Array.isArray(opts.extra) ? opts.extra.filter(Boolean) : []);
70
- const dir = path.join(ecosystemRoot, '.momentum', 'team', EVENTS_VIEW);
71
- let names = [];
72
- try { names = fs.readdirSync(dir); } catch (_e) { names = []; }
73
-
74
- for (const n of names) {
75
- if (!n.endsWith('.json')) continue;
76
- const frag = readJson(path.join(dir, n));
77
- if (!frag || !frag.payload || !frag.payload.member) continue;
78
- if (opts.actor && frag.actor !== opts.actor) continue;
79
- const ts = new Date(frag.ts).getTime();
80
- if (!Number.isFinite(ts) || ts < cutoff) continue;
81
- seen.add(frag.payload.member);
82
- }
83
- return [...seen].sort();
84
- }
73
+ /** Back-compat alias `sibling` was the 31b-era name. */
74
+ const sibling = resolveModule;
85
75
 
86
- /** Member sets of every in-progress initiative (mirrors detect.openInitiatives). */
87
- function openInitiativeMembers(ecosystemRoot) {
88
- const dir = path.join(ecosystemRoot, 'initiatives');
89
- let names = [];
90
- try { names = fs.readdirSync(dir); } catch (_e) { return []; }
91
-
92
- const out = [];
93
- for (const name of names.sort()) {
94
- if (!/^\d{4}-.*\.md$/.test(name)) continue;
95
- let body = '';
96
- try { body = fs.readFileSync(path.join(dir, name), 'utf8'); } catch (_e) { continue; }
97
- if (!body.startsWith('---')) continue;
98
- const end = body.indexOf('\n---', 3);
99
- if (end === -1) continue;
100
- const fm = body.slice(3, end);
101
-
102
- if (!/^status:\s*in-progress\s*$/m.test(fm)) continue;
103
- const slug = (fm.match(/^slug:\s*"?([a-z][a-z0-9-]*)"?\s*$/m) || [])[1];
104
- if (!slug) continue;
105
-
106
- const members = new Set();
107
- const repos = (fm.match(/^repos:\s*\[([^\]]*)\]/m) || [])[1];
108
- if (repos) {
109
- for (const r of repos.split(',')) {
110
- const v = r.trim().replace(/^["']|["']$/g, '');
111
- if (v) members.add(v);
112
- }
113
- }
114
- const contrib = (fm.match(/^contributions:\s*\[([^\]]*)\]/m) || [])[1];
115
- if (contrib) {
116
- for (const c of contrib.split(',')) {
117
- const v = c.trim().replace(/^["']|["']$/g, '');
118
- const m = v.split(':')[0];
119
- if (m) members.add(m);
120
- }
121
- }
122
- out.push({ slug, members });
123
- }
124
- return out;
76
+ function loadOrient() {
77
+ return sibling('ecosystem/lib/orient.js');
125
78
  }
126
79
 
127
80
  /**
128
- * The routing question. Returns
129
- * `{ crossRepo, covered, shouldRoute, members, initiative }`.
81
+ * The routing question — delegated to `core/ecosystem/lib/detect`, the authority
82
+ * (Phase 31c G2, ADR-0018 R1).
83
+ *
84
+ * This file used to MIRROR detect.js: its own event-stream scan, its own
85
+ * initiative frontmatter parser, its own coverage rule. The mirror existed only
86
+ * because an installed project had no `core/`; the vendored runtime removes that
87
+ * reason, so the mirror is gone and with it the parity fence that guarded it
88
+ * (R7). Returns detect's shape unchanged.
130
89
  */
131
90
  function detect(ecosystemRoot, opts) {
132
- const members = touchedMembers(ecosystemRoot, opts);
133
- const crossRepo = members.length >= 2;
134
- if (!crossRepo) {
135
- return { crossRepo: false, covered: false, shouldRoute: false, members, initiative: null };
136
- }
137
- for (const init of openInitiativeMembers(ecosystemRoot)) {
138
- if (members.every((m) => init.members.has(m))) {
139
- return { crossRepo: true, covered: true, shouldRoute: false, members, initiative: init.slug };
140
- }
141
- }
142
- return { crossRepo: true, covered: false, shouldRoute: true, members, initiative: null };
91
+ const authority = sibling('ecosystem/lib/detect.js');
92
+ if (authority) return authority.detect(ecosystemRoot, opts);
93
+ // Fail-open: no runtime → nothing to route. A hook must never throw.
94
+ return { crossRepo: false, covered: false, shouldRoute: false, members: [], initiative: null };
143
95
  }
144
96
 
145
97
  /**
@@ -170,12 +122,15 @@ function routingMessage(ecosystemRoot, result, focus) {
170
122
  return lines;
171
123
  }
172
124
 
125
+ // `touchedMembers` / `openInitiativeMembers` / `DEFAULT_WINDOW_HOURS` are gone:
126
+ // they were the mirror's internals, and the mirror is deleted (ADR-0018 R1).
127
+ // Callers wanting them should use `core/ecosystem/lib/detect` directly — there
128
+ // is one implementation now, and this file is an ENTRY POINT over it.
173
129
  module.exports = {
174
130
  EVENTS_VIEW,
175
- DEFAULT_WINDOW_HOURS,
131
+ resolveModule,
132
+ sibling,
176
133
  loadOrient,
177
- touchedMembers,
178
- openInitiativeMembers,
179
134
  detect,
180
135
  routingMessage,
181
136
  };
@@ -96,47 +96,18 @@ function resolveMemberRepoRoot(cwd) {
96
96
  }
97
97
 
98
98
  /**
99
- * Locate the ecosystem root from a member repo — up-walk AND sibling scan.
99
+ * Locate the ecosystem root from a member repo.
100
100
  *
101
- * `lib.findRoot` walks UP ONLY, but `core/ecosystem/layout.md` documents the
102
- * ecosystem root as a SIBLING of its member repos, which is what `ecosystem
103
- * init` + `ecosystem add ../<repo>` actually produce. Every other discovery
104
- * path in momentum already scans siblings `session-append.sh`,
105
- * `sessionstart-handoff.sh`, `core/git-hooks/eco-event.js`, and
106
- * `bin/ecosystem.js`'s own resolver. `findRoot` is the outlier.
101
+ * Phase 31b added a local sibling-scanning resolver here because `lib.findRoot`
102
+ * walked UP ONLY and therefore never found the sibling root that
103
+ * `core/ecosystem/layout.md` documents. Phase 31c (ADR-0018 R3) fixed
104
+ * `findRoot` itself, so this is now a thin alias kept for its callers and for
105
+ * back-compatibility with anything importing it.
107
106
  *
108
- * That inconsistency was invisible until Phase 31b called `recordEvent()` from
109
- * library code (the hooks had always used their own sibling-aware resolver), at
110
- * which point every such call silently returned "no ecosystem" in the standard
111
- * layout. Mirrored here rather than changing `findRoot`, whose up-only
112
- * semantics other callers may rely on; TD-013 tracks unifying them.
107
+ * There is exactly ONE discovery implementation. Do not add another.
113
108
  */
114
109
  function resolveEcosystemRootFrom(startDir) {
115
- const max = (() => {
116
- const raw = process.env.MOMENTUM_MAX_PARENT_WALK;
117
- const n = parseInt(raw, 10);
118
- return Number.isInteger(n) && n >= 0 ? n : 5;
119
- })();
120
-
121
- let current = path.resolve(startDir);
122
- for (let depth = 0; depth <= max; depth++) {
123
- try {
124
- if (fs.statSync(path.join(current, 'ecosystem.json')).isFile()) return current;
125
- } catch (_e) { /* keep walking */ }
126
-
127
- const parent = path.dirname(current);
128
- if (parent === current) return null;
129
- let siblings = [];
130
- try { siblings = fs.readdirSync(parent); } catch (_e) { siblings = []; }
131
- for (const name of siblings) {
132
- const cand = path.join(parent, name);
133
- try {
134
- if (fs.statSync(path.join(cand, 'ecosystem.json')).isFile()) return cand;
135
- } catch (_e) { /* not it */ }
136
- }
137
- current = parent;
138
- }
139
- return null;
110
+ return lib.findRoot(startDir);
140
111
  }
141
112
 
142
113
  /**
@@ -202,7 +173,12 @@ function recordEvent(opts) {
202
173
  const member = resolveMemberId(ecosystemRoot, repoRoot);
203
174
  if (!member) return { recorded: false, reason: 'not a registered member' };
204
175
 
205
- const actor = identity.resolveActor(repoRoot);
176
+ // `opts.env` lets a caller inject the environment actor resolution reads.
177
+ // `identity.resolveActor` has always accepted it; recordEvent simply never
178
+ // passed it through, so the pre-31c hook-side mirror carried a test seam
179
+ // core lacked. Added here rather than working around it in the test —
180
+ // production always uses the ambient env, so behaviour is unchanged.
181
+ const actor = identity.resolveActor(repoRoot, opts.env);
206
182
  const payload = {
207
183
  member,
208
184
  summary: String(opts.summary || '').split('\n')[0].slice(0, 500),
@@ -50,6 +50,36 @@ const rootCache = new Map();
50
50
  * Memoized: repeated calls with paths under the same root return
51
51
  * instantly. Cache key is the absolute starting path.
52
52
  */
53
+ /** True when `dir/ecosystem.json` exists and is a file. */
54
+ function hasManifest(dir) {
55
+ try {
56
+ return fs.statSync(path.join(dir, MANIFEST_FILENAME)).isFile();
57
+ } catch (_err) {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * THE ecosystem-root resolver (ADR-0018 R3).
64
+ *
65
+ * Three strategies, in order:
66
+ * 1. **up-walk** — `ecosystem.json` in this dir or an ancestor
67
+ * 2. **sibling scan** at each level — `core/ecosystem/layout.md` documents the
68
+ * ecosystem root as a SIBLING of its members, which is exactly what
69
+ * `ecosystem init` + `ecosystem add ../repo` produce
70
+ * 3. **registration lookup** — the per-machine registry, for members whose
71
+ * root is neither an ancestor nor a sibling
72
+ *
73
+ * Before Phase 31c this function did step 1 ONLY, while six other call sites
74
+ * each hand-rolled step 2 and the two CLIs bolted on step 3. Every ad-hoc copy
75
+ * encoded the documented layout; the sanctioned API contradicted it. The cost
76
+ * was **BUG-030**: `landing.js` had no fallback, so in the standard sibling
77
+ * layout `momentum lanes land` silently skipped the entire cross-repo gate and
78
+ * v0.41.0 shipped ENH-068 non-functional.
79
+ *
80
+ * Bounded by `MOMENTUM_MAX_PARENT_WALK` (default 5) and memoised per absolute
81
+ * start path.
82
+ */
53
83
  function findRoot(startPath) {
54
84
  if (typeof startPath !== 'string' || startPath.length === 0) {
55
85
  return null;
@@ -58,26 +88,44 @@ function findRoot(startPath) {
58
88
  if (rootCache.has(abs)) {
59
89
  return rootCache.get(abs);
60
90
  }
91
+
92
+ const remember = (val) => { rootCache.set(abs, val); return val; };
61
93
  const maxDepth = resolveMaxParentWalk();
62
94
  let current = abs;
95
+
63
96
  for (let i = 0; i <= maxDepth; i++) {
64
- const candidate = path.join(current, MANIFEST_FILENAME);
65
- let stat;
97
+ // 1. this directory
98
+ if (hasManifest(current)) return remember(current);
99
+
100
+ const parent = path.dirname(current);
101
+ if (parent === current) break; // filesystem root
102
+
103
+ // 2. siblings of this directory
104
+ let siblings = [];
66
105
  try {
67
- stat = fs.statSync(candidate);
106
+ siblings = fs.readdirSync(parent, { withFileTypes: true })
107
+ .filter((e) => e.isDirectory())
108
+ .map((e) => path.join(parent, e.name));
68
109
  } catch (_err) {
69
- stat = null;
110
+ siblings = []; // unreadable parent — keep walking
70
111
  }
71
- if (stat && stat.isFile()) {
72
- rootCache.set(abs, current);
73
- return current;
112
+ for (const sib of siblings) {
113
+ if (sib !== current && hasManifest(sib)) return remember(sib);
74
114
  }
75
- const parent = path.dirname(current);
76
- if (parent === current) break; // reached filesystem root
115
+
77
116
  current = parent;
78
117
  }
79
- rootCache.set(abs, null);
80
- return null;
118
+
119
+ // 3. registration fallback — required lazily to avoid a module cycle
120
+ // (state.js reads the manifest through this module).
121
+ try {
122
+ const reg = require('./state').findRegistration(abs);
123
+ if (reg && reg.rootPath && hasManifest(reg.rootPath)) {
124
+ return remember(reg.rootPath);
125
+ }
126
+ } catch (_err) { /* registry absent or unreadable — fall through */ }
127
+
128
+ return remember(null);
81
129
  }
82
130
 
83
131
  /**
@@ -124,77 +124,46 @@ function openBlockers(backlogBody) {
124
124
  const IN_FLIGHT = new Set(['open', 'done']);
125
125
 
126
126
  /**
127
- * Resolve `<git-common-dir>/momentum/lanes` without running git.
127
+ * In-flight lanes for a member — delegated to `core/lanes/lib/state`, the
128
+ * authority (Phase 31c G2, ADR-0018 R1).
128
129
  *
129
- * `.git` is a directory in a normal checkout, but a FILE in a linked worktree
130
- * (`gitdir: /abs/path/.git/worktrees/<name>`), whose sibling `commondir` points
131
- * back at the shared dir. Lane state lives under the COMMON dir, so a worktree
132
- * that did not follow the pointer would see no lanes at all — and Rule 15 lane
133
- * work happens in worktrees, which is exactly where this has to be right.
134
- */
135
- function laneAnchor(repoDir) {
136
- const dotGit = path.join(repoDir, '.git');
137
- let stat;
138
- try { stat = fs.statSync(dotGit); } catch (_e) { return null; }
139
-
140
- if (stat.isDirectory()) return path.join(dotGit, 'momentum', 'lanes');
141
-
142
- const pointer = readIf(dotGit);
143
- if (!pointer) return null;
144
- const m = pointer.match(/^gitdir:\s*(.+)$/m);
145
- if (!m) return null;
146
- const gitdir = path.resolve(repoDir, m[1].trim());
147
- const commondir = readIf(path.join(gitdir, 'commondir'));
148
- const common = commondir
149
- ? path.resolve(gitdir, commondir.trim())
150
- : gitdir;
151
- return path.join(common, 'momentum', 'lanes');
152
- }
153
-
154
- /**
155
- * In-flight lanes for a member.
130
+ * This function used to re-implement lane-registry reading, because orient.js
131
+ * had to stay dependency-free to ship into installs. It got the format wrong:
132
+ * the registry holds lane ID STRINGS, not objects, so every entry became
133
+ * `{status: undefined}` and `undefined !== 'closed'` passed the repo's entire
134
+ * lane history through as open (BUG-029). The vendored runtime removes the
135
+ * reason for the mirror, so the mirror is gone.
156
136
  *
157
- * The registry stores an array of lane **id strings**; per-lane detail lives in
158
- * `<anchor>/<id>/manifest.json`. Reading the ids as objects was BUG-029
159
- * every entry became `{status: undefined}`, and `undefined !== 'closed'` let the
160
- * whole history through.
137
+ * `state` is resolved lazily: orient.js is also loaded from `scripts/orient.js`
138
+ * in installs where the sibling path differs, and a missing authority must
139
+ * degrade to "no lanes" rather than throw a fleet view that dies on one bad
140
+ * member is useless exactly when it matters.
161
141
  */
162
- function openLanes(repoDir) {
163
- const anchors = [laneAnchor(repoDir), path.join(repoDir, '.momentum', 'lanes')]
164
- .filter(Boolean);
165
-
166
- for (const anchor of anchors) {
167
- const body = readIf(path.join(anchor, 'registry.json'));
168
- if (!body) continue;
169
-
170
- let ids;
142
+ function laneState() {
143
+ for (const rel of ['../../lanes/lib/state', './lanes/lib/state', '../lanes/lib/state']) {
171
144
  try {
172
- const reg = JSON.parse(body);
173
- ids = Array.isArray(reg.lanes) ? reg.lanes : [];
174
- } catch (_e) {
175
- return [];
176
- }
177
-
178
- const out = [];
179
- for (const entry of ids) {
180
- // Tolerate both shapes: the id-string form momentum writes today, and an
181
- // inlined object, so a future registry format change degrades instead of
182
- // silently reporting garbage again.
183
- const id = typeof entry === 'string' ? entry : (entry && entry.id);
184
- if (!id) continue;
145
+ // eslint-disable-next-line global-require
146
+ return require(path.join(__dirname, rel));
147
+ } catch (_e) { /* try next */ }
148
+ }
149
+ return null;
150
+ }
185
151
 
186
- let lane = (entry && typeof entry === 'object') ? entry : null;
187
- if (!lane || !lane.status) {
188
- const mf = readIf(path.join(anchor, id, 'manifest.json'));
189
- if (!mf) continue;
190
- try { lane = JSON.parse(mf); } catch (_e) { continue; }
191
- }
192
- if (!IN_FLIGHT.has(lane.status)) continue;
193
- out.push({ id, branch: lane.branch || null, status: lane.status });
194
- }
195
- return out;
152
+ function openLanes(repoDir) {
153
+ const state = laneState();
154
+ if (!state) return [];
155
+ try {
156
+ // anchorFromRepoDir, NOT resolveAnchor: orient is contractually git-free
157
+ // (it runs across every member, and from the SessionStart banner's <100ms
158
+ // budget). state.js owns both resolvers so there is still one implementation.
159
+ const anchor = state.anchorFromRepoDir(repoDir);
160
+ if (!anchor) return [];
161
+ return state.listLanes(anchor)
162
+ .filter((l) => l && IN_FLIGHT.has(l.status))
163
+ .map((l) => ({ id: l.id, branch: l.branch || null, status: l.status }));
164
+ } catch (_e) {
165
+ return [];
196
166
  }
197
- return [];
198
167
  }
199
168
 
200
169
  /**
@@ -289,7 +258,6 @@ function memberBrief(summary) {
289
258
  module.exports = {
290
259
  MAX_ITEMS,
291
260
  IN_FLIGHT,
292
- laneAnchor,
293
261
  MAX_TITLE,
294
262
  condense,
295
263
  activePhases,
@@ -34,71 +34,34 @@ if [ -z "$EVENT_KIND" ] || [ -z "$EVENT_SUMMARY" ]; then
34
34
  exit 0
35
35
  fi
36
36
 
37
- # ── Resolve ecosystem root (bounded walk-up, looking for siblings) ──────────
38
- # The ecosystem is a SIBLING of member repos. From a member repo we walk
39
- # up one level and look at each sibling for ecosystem.json.
40
-
41
- find_ecosystem_root() {
42
- local start="$PWD"
43
- local current="$start"
44
- local depth=0
45
- local max_depth="${MOMENTUM_MAX_PARENT_WALK:-5}"
46
- # Guard against non-numeric / negative env value.
47
- case "$max_depth" in
48
- ''|*[!0-9]*) max_depth=5 ;;
49
- esac
50
- while [ $depth -le $max_depth ]; do
51
- # Same-directory check (caller might already be in ecosystem root)
52
- if [ -f "$current/ecosystem.json" ]; then
53
- echo "$current"
54
- return 0
55
- fi
56
- # Sibling check
57
- local parent
58
- parent=$(dirname "$current")
59
- if [ "$parent" = "$current" ]; then return 1; fi
60
- for sibling in "$parent"/*; do
61
- if [ -d "$sibling" ] && [ -f "$sibling/ecosystem.json" ]; then
62
- echo "$sibling"
63
- return 0
64
- fi
65
- done
66
- current="$parent"
67
- depth=$((depth + 1))
37
+ # ── Ecosystem discovery (Phase 31c G3, ADR-0018 R5) ────────────────────────
38
+ # Delegates to the ONE implementation in core/ecosystem/lib/index.js via the
39
+ # vendored runtime, instead of re-walking the tree in bash. Any future change to
40
+ # discovery rules is then made once, not once per language — which is how the
41
+ # seven-implementation split accumulated in the first place.
42
+ #
43
+ # Fail-open in every branch: no node, no runtime, no ecosystem → the caller
44
+ # simply gets nothing and carries on. A hook must never break a commit or a
45
+ # session start.
46
+ momentum_discover() {
47
+ command -v node >/dev/null 2>&1 || return 1
48
+ _d=$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")
49
+ for _c in "$_d/../.momentum/runtime/discover.js" \
50
+ "$_d/../../.momentum/runtime/discover.js" \
51
+ "$_d/../../runtime/discover.js"; do
52
+ [ -f "$_c" ] || continue
53
+ node "$_c" "${1:-$PWD}" 2>/dev/null && return 0
54
+ return 1
68
55
  done
69
56
  return 1
70
57
  }
71
58
 
72
- ROOT=$(find_ecosystem_root 2>/dev/null) || exit 0
73
- [ -z "$ROOT" ] && exit 0
59
+ _disc=$(momentum_discover "$PWD") || exit 0
60
+ ROOT=$(printf '%s' "$_disc" | cut -f1)
61
+ MEMBER_ID=$(printf '%s' "$_disc" | cut -f2)
62
+ [ -n "$ROOT" ] || exit 0
74
63
  [ -f "$ROOT/ecosystem.json" ] || exit 0
75
-
76
- # ── Resolve member id by matching $PWD against manifest.members[].path ─────
77
-
78
- resolve_member_id() {
79
- python3 - "$ROOT" "$PWD" <<'PY' 2>/dev/null || echo ""
80
- import sys, json, os
81
- root, pwd = sys.argv[1], os.path.realpath(sys.argv[2])
82
- try:
83
- with open(os.path.join(root, "ecosystem.json")) as f:
84
- m = json.load(f)
85
- except Exception:
86
- sys.exit(0)
87
- for member in m.get("members", []):
88
- abs_path = os.path.realpath(os.path.join(root, member.get("path", "")))
89
- # Match pwd that is == abs_path or any descendant
90
- try:
91
- rel = os.path.relpath(pwd, abs_path)
92
- except ValueError:
93
- continue
94
- if rel == "." or (not rel.startswith("..") and not os.path.isabs(rel)):
95
- print(member.get("id", ""))
96
- sys.exit(0)
97
- PY
98
- }
99
-
100
- MEMBER_ID=$(resolve_member_id)
101
- [ -z "$MEMBER_ID" ] && exit 0
64
+ [ -n "$MEMBER_ID" ] || exit 0
102
65
 
103
66
  # ── Write the line ─────────────────────────────────────────────────────────
104
67