@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 +3 -8
- package/bin/momentum.js +17 -7
- package/bin/swarm.js +1 -2
- package/core/ecosystem/lib/cross-repo.js +51 -96
- package/core/ecosystem/lib/events.js +14 -38
- package/core/ecosystem/lib/index.js +59 -11
- package/core/ecosystem/lib/orient.js +34 -66
- package/core/ecosystem/scripts/session-append.sh +23 -60
- package/core/git-hooks/cross-repo.js +51 -96
- package/core/git-hooks/eco-event.js +95 -194
- package/core/lanes/lib/state.js +42 -0
- package/core/runtime/closure.js +156 -0
- package/core/runtime/discover.js +91 -0
- package/core/scripts/sessionstart-handoff.sh +19 -28
- package/package.json +1 -1
package/bin/ecosystem.js
CHANGED
|
@@ -1834,16 +1834,11 @@ function resolveEcosystemRoot(explicitPath, subcommand) {
|
|
|
1834
1834
|
}
|
|
1835
1835
|
return abs;
|
|
1836
1836
|
}
|
|
1837
|
-
//
|
|
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
|
|
1230
|
-
//
|
|
1231
|
-
//
|
|
1232
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
/**
|
|
60
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
|
129
|
-
*
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
|
99
|
+
* Locate the ecosystem root from a member repo.
|
|
100
100
|
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
+
siblings = []; // unreadable parent — keep walking
|
|
70
111
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return current;
|
|
112
|
+
for (const sib of siblings) {
|
|
113
|
+
if (sib !== current && hasManifest(sib)) return remember(sib);
|
|
74
114
|
}
|
|
75
|
-
|
|
76
|
-
if (parent === current) break; // reached filesystem root
|
|
115
|
+
|
|
77
116
|
current = parent;
|
|
78
117
|
}
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
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
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
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
|
|
163
|
-
const
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
} catch (_e) {
|
|
175
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
return
|
|
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
|
-
# ──
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
|