@limina-labs/momentum 0.40.0 → 0.41.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.
@@ -35,5 +35,19 @@
35
35
  "timeout": 15
36
36
  }
37
37
  ]
38
+ },
39
+ "momentum-cross-repo-gate": {
40
+ "PreToolUse": [
41
+ {
42
+ "matcher": "write_to_file|.*write.*|.*edit.*|.*replace.*",
43
+ "hooks": [
44
+ {
45
+ "type": "command",
46
+ "command": "bash ../scripts/cross-repo-gate.sh",
47
+ "timeout": 15
48
+ }
49
+ ]
50
+ }
51
+ ]
38
52
  }
39
53
  }
@@ -9,6 +9,15 @@
9
9
  "command": "bash scripts/brainstorm-gate.sh"
10
10
  }
11
11
  ]
12
+ },
13
+ {
14
+ "matcher": "Write|Edit|MultiEdit",
15
+ "hooks": [
16
+ {
17
+ "type": "command",
18
+ "command": "bash scripts/cross-repo-gate.sh"
19
+ }
20
+ ]
12
21
  }
13
22
  ],
14
23
  "PostToolUse": [
@@ -9,6 +9,15 @@
9
9
  "command": "bash scripts/brainstorm-gate.sh"
10
10
  }
11
11
  ]
12
+ },
13
+ {
14
+ "matcher": "apply_patch|Bash",
15
+ "hooks": [
16
+ {
17
+ "type": "command",
18
+ "command": "bash scripts/cross-repo-gate.sh"
19
+ }
20
+ ]
12
21
  }
13
22
  ],
14
23
  "PostToolUse": [
package/bin/ecosystem.js CHANGED
@@ -82,7 +82,7 @@ Usage:
82
82
  momentum ecosystem init [name]
83
83
  momentum ecosystem add <repo-path> [--role <role>] [--id <id>] [--ecosystem <path>]
84
84
  momentum ecosystem remove <member-id> [--ecosystem <path>]
85
- momentum ecosystem status [--no-git] [--ecosystem <path>]
85
+ momentum ecosystem status [--no-git] [--brief] [--ecosystem <path>]
86
86
  momentum ecosystem upgrade [--dry-run] [--force|--autostash] [--agent <name>] [--ecosystem <path>]
87
87
  momentum ecosystem initiative create <slug> [--why "<text>"] [--repos r1,r2] [--owner <name>] [--ecosystem <path>]
88
88
  momentum ecosystem initiative start <slug> [--contribute <member>:<kind>:<ref>]... [--edge <from>:<to>:<kind>]...
@@ -778,11 +778,21 @@ function cmdRemove(args) {
778
778
  function cmdStatus(args) {
779
779
  const opts = parseFlags(args, {
780
780
  'no-git': 'boolean',
781
+ brief: 'boolean',
781
782
  ecosystem: 'string',
782
783
  });
783
784
  const root = resolveEcosystemRoot(opts.ecosystem, 'status');
784
785
  const manifest = lib.loadManifest(root);
785
786
 
787
+ // Fleet orient (Phase 31b, ENH-067). `--brief` preserves the pre-31b output
788
+ // for scripts that parse this. Read once up front so the per-member loop can
789
+ // render each summary inline.
790
+ const orientLib = require('../core/ecosystem/lib/orient');
791
+ const orient = opts.brief ? null : (() => {
792
+ try { return new Map(orientLib.orientFleet(root, manifest).map((s) => [s.id, s])); }
793
+ catch (_e) { return null; } // orient is additive — never fail status on it
794
+ })();
795
+
786
796
  // Auto-heartbeat (ADR-0015 G2): mark this actor present when the ecosystem is
787
797
  // team-active (a .momentum/team/ dir exists). Best-effort, gated so solo
788
798
  // ecosystems stay quiet — mirrors bin/team.js.
@@ -818,6 +828,28 @@ function cmdStatus(args) {
818
828
  // path was declared but the checkout is absent on this machine.
819
829
  console.log(` ${m.id} [${m.role}] ${loc.path || '(no path)'} (MISSING)`);
820
830
  }
831
+
832
+ // Fleet orient (ENH-067) — each member's own tracking state, so a session
833
+ // reaching into a sibling sees its phase and open P0/P1 BEFORE editing it.
834
+ const s = orient && orient.get(m.id);
835
+ if (s && s.reachable && s.managed) {
836
+ for (const p of s.phases) {
837
+ console.log(` phase: ${p.phase} — ${p.status} (${p.branch})`);
838
+ }
839
+ for (const b of s.blockers.slice(0, 5)) {
840
+ console.log(` ${b.priority}: ${b.id} — ${b.title}`);
841
+ }
842
+ const extra = s.blockers.length - 5;
843
+ if (extra > 0) console.log(` …+${extra} more open P0/P1`);
844
+ if (s.lanes.length) {
845
+ console.log(` lanes: ${s.lanes.map((l) => `${l.id} (${l.status})`).join(', ')}`);
846
+ }
847
+ if (!s.phases.length && !s.blockers.length && !s.lanes.length) {
848
+ console.log(' (no active phase, no open P0/P1, no lanes)');
849
+ }
850
+ } else if (s && s.reachable && s.managed === false) {
851
+ console.log(' (not momentum-managed — no specs/)');
852
+ }
821
853
  }
822
854
 
823
855
  // Active initiative — shared, attributed fragment (ADR-0015 G2) with a
package/bin/momentum.js CHANGED
@@ -1226,6 +1226,16 @@ 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.
1233
+ const orientSrc = path.join(src, 'core', 'ecosystem', 'lib', 'orient.js');
1234
+ if (fs.existsSync(orientSrc)) {
1235
+ const orientDest = path.join(target, ...dests.scripts, 'orient.js');
1236
+ copyFile(orientSrc, orientDest);
1237
+ }
1238
+
1229
1239
  // .githooks/ — git-lifecycle enforcement hooks (Phase 19)
1230
1240
  console.log('→ Installing git-lifecycle hooks...');
1231
1241
  installGitHooks(src, target);
@@ -1357,6 +1367,11 @@ function upgrade(targetDir, agent, opts = {}) {
1357
1367
  const sessionUpgradeDest = path.join(target, ...dests.scripts, 'session-append.sh');
1358
1368
  copyFile(sessionUpgradeSrc, sessionUpgradeDest);
1359
1369
  }
1370
+ // Phase 31b — fleet-orient helper (see the install path for why it ships).
1371
+ const orientUpgradeSrc = path.join(src, 'core', 'ecosystem', 'lib', 'orient.js');
1372
+ if (fs.existsSync(orientUpgradeSrc)) {
1373
+ copyFile(orientUpgradeSrc, path.join(target, ...dests.scripts, 'orient.js'));
1374
+ }
1360
1375
  // Re-apply executable bit to all .sh scripts
1361
1376
  const scriptsDir = path.join(target, ...dests.scripts);
1362
1377
  if (!_dryRun && fs.existsSync(scriptsDir)) {
@@ -39,11 +39,40 @@ Present to user:
39
39
  - [list of files]
40
40
  Proceed? (yes/no)"
41
41
 
42
- **If any cross-repo entries were partitioned out in Step 2:** also show them
43
- under a "Cross-repo impact (NOT touching sync the other repo manually)"
44
- heading, listing each `Affects-specs: ../...` path. Tell the user which repo
45
- they need to edit and which entries pointed there. Do NOT prompt for approval
46
- on cross-repo paths — they're informational only.
42
+ **If any cross-repo entries were partitioned out in Step 2:** show them under a
43
+ "Cross-repo impact" heading, listing each `Affects-specs: ../...` path and which
44
+ member repo owns it.
45
+
46
+ **Then DELIVER them, don't just mention them (Phase 31b, ADR-0017 E6).** A chat
47
+ message dies with the session — that is why one reviewed multi-repo session's
48
+ glossary propagation never happened *despite this rule working exactly as
49
+ designed*. For each target member repo, write a structured handoff into its
50
+ inbox:
51
+
52
+ ```js
53
+ const orchestration = require('<momentum-root>/core/orchestration');
54
+ await orchestration.handoff.handoff({
55
+ fromRepo: '<this repo absolute path>',
56
+ toRepo: '<target member absolute path>',
57
+ summary: 'Doc sync needed: <N> entries from <this repo> phase <phase> affect your specs',
58
+ decisions: [/* the history entries that pointed here, verbatim */],
59
+ filesTouched: [/* the ../ paths, rewritten relative to the TARGET repo */],
60
+ verificationCommands: [],
61
+ openQuestions: ['Do these entries still apply after your latest changes?'],
62
+ ecosystem: { rootPath: '<ecosystem root>', memberId: '<this repo member id>' },
63
+ });
64
+ ```
65
+
66
+ The receiving session surfaces it at SessionStart and picks it up with
67
+ `/continue`.
68
+
69
+ **The ownership rule is unchanged and absolute:** you still NEVER edit a file in
70
+ another repo. You are handing the target repo's own agent a note; that agent
71
+ decides what to change. Delivery is not ownership.
72
+
73
+ Do NOT prompt for approval on cross-repo paths — writing a handoff into an
74
+ inbox is additive and reversible, and the receiving session gates the actual
75
+ edits.
47
76
 
48
77
  If user says no → stop.
49
78
 
@@ -76,6 +105,6 @@ Ready to run /complete-phase."
76
105
  ## Safeguards
77
106
  - NEVER update files not in the targeted list
78
107
  - NEVER update `specs/architecture/` (monorepo only — constitution is read-only)
79
- - NEVER update files in another repo (paths starting with `../`) — you only own this repo's docs. Flag cross-repo entries to the user instead.
108
+ - NEVER update files in another repo (paths starting with `../`) — you only own this repo's docs. **Deliver** cross-repo entries as a handoff into the target member's inbox (ADR-0017 E6); the receiving repo's own agent decides what to change. Delivery is not ownership.
80
109
  - ALWAYS show the plan (Step 3) before making any edits
81
110
  - History entries are NEVER modified — only read
@@ -0,0 +1,181 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * momentum git-hook helper — cross-repo routing detection (Phase 31b G2, ADR-0017).
5
+ *
6
+ * Produces the routing message shown by BOTH enforcement layers (E1):
7
+ * - the `post-commit` banner (git-native, agent-independent)
8
+ * - the `cross-repo-gate.sh` PreToolUse nudge (fires before the edit)
9
+ *
10
+ * WHY THIS IS ANOTHER SELF-CONTAINED FILE
11
+ * ---------------------------------------
12
+ * Third time in this arc, so worth stating plainly rather than rediscovering:
13
+ * an installed project receives NO copy of momentum's `core/`. Anything a hook
14
+ * needs must travel with the hook. `core/git-hooks/eco-event.js` (31a) and
15
+ * `core/ecosystem/lib/orient.js` (31b G1) hit the same wall.
16
+ *
17
+ * This file is therefore node-builtins-only, ships into `.githooks/` beside
18
+ * `eco-event.js`, and resolves `orient.js` LAZILY through a candidate list so
19
+ * it degrades to a detail-free message rather than throwing when orient is not
20
+ * installed. TD-012 tracks consolidating this shipped-runtime story.
21
+ *
22
+ * The coverage logic here is a deliberate minimal mirror of
23
+ * `core/ecosystem/lib/detect.js`, fenced by a parity test — the same discipline
24
+ * applied to the eco-event.js/fragments duplication in 31a.
25
+ */
26
+
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+
30
+ const EVENTS_VIEW = 'eco-events';
31
+ const DEFAULT_WINDOW_HOURS = 24;
32
+
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 */ }
47
+ }
48
+ return null;
49
+ }
50
+
51
+ function readJson(file) {
52
+ try {
53
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
54
+ } catch (_e) {
55
+ return null;
56
+ }
57
+ }
58
+
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
+ }
85
+
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;
125
+ }
126
+
127
+ /**
128
+ * The routing question. Returns
129
+ * `{ crossRepo, covered, shouldRoute, members, initiative }`.
130
+ */
131
+ 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 };
143
+ }
144
+
145
+ /**
146
+ * The routing message. `focus` is the member being entered right now (the
147
+ * PreToolUse case) — its open P0/P1 items are surfaced, which is what turns
148
+ * "this is cross-repo work" into something worth reading (AC-4).
149
+ */
150
+ function routingMessage(ecosystemRoot, result, focus) {
151
+ const lines = [];
152
+ lines.push(`⚠ Cross-repo work with no initiative: ${result.members.join(' + ')}`);
153
+
154
+ if (focus) {
155
+ const orient = loadOrient();
156
+ if (orient) {
157
+ try {
158
+ const manifest = readJson(path.join(ecosystemRoot, 'ecosystem.json')) || {};
159
+ const member = (manifest.members || []).find((m) => m && m.id === focus);
160
+ if (member) {
161
+ const summary = orient.orientMember(ecosystemRoot, member);
162
+ for (const l of orient.memberBrief(summary)) lines.push(` ${l}`);
163
+ }
164
+ } catch (_e) { /* detail is a bonus, never a requirement */ }
165
+ }
166
+ }
167
+
168
+ lines.push(' → Run /brainstorm-initiative to open one before going further.');
169
+ lines.push(' (Cross-repo work belongs to an initiative — see ADR-0016.)');
170
+ return lines;
171
+ }
172
+
173
+ module.exports = {
174
+ EVENTS_VIEW,
175
+ DEFAULT_WINDOW_HOURS,
176
+ loadOrient,
177
+ touchedMembers,
178
+ openInitiativeMembers,
179
+ detect,
180
+ routingMessage,
181
+ };
@@ -0,0 +1,195 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Cross-repo coverage detection (Phase 31b G0, ADR-0017 E2).
5
+ *
6
+ * Answers one question: **is this actor doing cross-repo work that no initiative
7
+ * covers?**
8
+ *
9
+ * WHY THIS IS A QUERY AND NOT A TRACKER
10
+ * -------------------------------------
11
+ * The obvious implementation is a per-session record of "members touched so
12
+ * far". That is unnecessary — ADR-0016's write path already records
13
+ * `{actor, ts, member}` for every commit, so the answer is a query over data
14
+ * momentum already collects. A parallel tracker would be a second source of
15
+ * truth to keep honest, which is the exact failure mode this arc exists to
16
+ * close (BUG-007/BUG-028, and the hook-side writer's parity fence).
17
+ *
18
+ * Two properties follow from that choice, and both are load-bearing:
19
+ *
20
+ * 1. **No git calls.** Everything here is file reads over the fragment stream
21
+ * and `initiatives/`. That is what makes it cheap enough to run from a
22
+ * PreToolUse hook on every edit.
23
+ * 2. **A CLOSED initiative covers nothing.** Coverage is a question about live
24
+ * state, not history. An initiative that shipped last month does not
25
+ * license today's untracked cross-repo work.
26
+ */
27
+
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+
31
+ const events = require('./events');
32
+ const initiativeLib = require('./initiative');
33
+
34
+ /** Default lookback for "is this the same stretch of work". */
35
+ const DEFAULT_WINDOW_HOURS = 24;
36
+
37
+ /**
38
+ * Resolve the detection window from ecosystem config, falling back to the
39
+ * default. Invalid values fall back rather than throwing — this runs inside
40
+ * hooks, where an exception is worse than an imperfect window.
41
+ */
42
+ function windowHours(manifest) {
43
+ const cfg = (manifest && manifest.config) || {};
44
+ const raw = cfg.detect_window_hours;
45
+ const n = typeof raw === 'number' ? raw : parseInt(raw, 10);
46
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_WINDOW_HOURS;
47
+ }
48
+
49
+ /**
50
+ * Members this actor has recorded events in, within the window.
51
+ *
52
+ * `opts.actor` — restrict to one actor (omit for all actors)
53
+ * `opts.hours` — lookback (defaults to config / 24)
54
+ * `opts.now` — injectable clock for tests
55
+ * `opts.extra` — additional member ids to fold in (used by the PreToolUse
56
+ * nudge, where the member being edited RIGHT NOW has no event
57
+ * yet — the whole point is to fire before the commit).
58
+ */
59
+ function touchedMembers(ecosystemRoot, opts) {
60
+ opts = opts || {};
61
+ const hours = opts.hours || DEFAULT_WINDOW_HOURS;
62
+ const now = opts.now ? new Date(opts.now).getTime() : Date.now();
63
+ const cutoff = now - hours * 3600 * 1000;
64
+
65
+ const seen = new Set(Array.isArray(opts.extra) ? opts.extra.filter(Boolean) : []);
66
+ let stream = [];
67
+ try {
68
+ stream = events.listEvents(ecosystemRoot);
69
+ } catch (_e) {
70
+ stream = [];
71
+ }
72
+
73
+ for (const f of stream) {
74
+ if (!f || !f.payload || !f.payload.member) continue;
75
+ if (opts.actor && f.actor !== opts.actor) continue;
76
+ const ts = new Date(f.ts).getTime();
77
+ if (!Number.isFinite(ts) || ts < cutoff) continue;
78
+ seen.add(f.payload.member);
79
+ }
80
+ return [...seen].sort();
81
+ }
82
+
83
+ /** Every in-progress initiative, parsed. Closed/abandoned are excluded (E2). */
84
+ function openInitiatives(ecosystemRoot) {
85
+ const dir = path.join(ecosystemRoot, initiativeLib.INITIATIVES_DIR);
86
+ let names = [];
87
+ try {
88
+ names = fs.readdirSync(dir);
89
+ } catch (_e) {
90
+ return [];
91
+ }
92
+
93
+ const out = [];
94
+ for (const name of names.sort()) {
95
+ if (!/^\d{4}-.*\.md$/.test(name)) continue;
96
+ let raw = '';
97
+ try {
98
+ raw = fs.readFileSync(path.join(dir, name), 'utf8');
99
+ } catch (_e) {
100
+ continue;
101
+ }
102
+ const { frontmatter } = initiativeLib.parseFrontmatter(raw);
103
+ if (!frontmatter || frontmatter.status !== 'in-progress') continue;
104
+
105
+ const repos = new Set(Array.isArray(frontmatter.repos) ? frontmatter.repos : []);
106
+ for (const entry of (frontmatter.contributions || [])) {
107
+ const parsed = initiativeLib.parseContribution(entry);
108
+ if (parsed) repos.add(parsed.member);
109
+ }
110
+ out.push({ slug: frontmatter.slug, file: name, members: repos });
111
+ }
112
+ return out;
113
+ }
114
+
115
+ /**
116
+ * Which in-progress initiative covers `members`, if any.
117
+ *
118
+ * "Covers" means every member in the set appears in that initiative's `repos[]`
119
+ * or `contributions[]`. A partial match does NOT cover: an initiative spanning
120
+ * backend+frontend does not license work that also touches infra, because the
121
+ * infra work is precisely the part nobody planned.
122
+ *
123
+ * Returns { initiative, uncovered } — `initiative` is the covering slug or null,
124
+ * and `uncovered` lists the members no open initiative accounts for.
125
+ */
126
+ function coverage(ecosystemRoot, members) {
127
+ const set = [...new Set(members || [])].sort();
128
+ if (set.length === 0) return { initiative: null, uncovered: [] };
129
+
130
+ const open = openInitiatives(ecosystemRoot);
131
+ for (const init of open) {
132
+ if (set.every((m) => init.members.has(m))) {
133
+ return { initiative: init.slug, uncovered: [] };
134
+ }
135
+ }
136
+
137
+ // Nothing covers the whole set. Report which members no OPEN initiative
138
+ // mentions at all — those are the ones a new initiative must account for.
139
+ const mentioned = new Set();
140
+ for (const init of open) for (const m of init.members) mentioned.add(m);
141
+ return { initiative: null, uncovered: set.filter((m) => !mentioned.has(m)) };
142
+ }
143
+
144
+ /**
145
+ * The detection question, answered.
146
+ *
147
+ * Returns:
148
+ * {
149
+ * crossRepo: boolean, // ≥2 members touched in the window
150
+ * covered: boolean, // an open initiative covers the whole set
151
+ * members: string[], // everything touched
152
+ * initiative: string|null,
153
+ * uncovered: string[], // members no open initiative mentions
154
+ * }
155
+ *
156
+ * `shouldRoute` (crossRepo && !covered) is the condition the nudge and the
157
+ * commit banner both fire on.
158
+ */
159
+ function detect(ecosystemRoot, opts) {
160
+ opts = opts || {};
161
+ let manifest = null;
162
+ try {
163
+ manifest = require('./index').loadManifest(ecosystemRoot);
164
+ } catch (_e) {
165
+ manifest = null;
166
+ }
167
+
168
+ const members = touchedMembers(ecosystemRoot, {
169
+ actor: opts.actor,
170
+ hours: opts.hours || windowHours(manifest),
171
+ now: opts.now,
172
+ extra: opts.extra,
173
+ });
174
+
175
+ const crossRepo = members.length >= 2;
176
+ const { initiative, uncovered } = coverage(ecosystemRoot, members);
177
+
178
+ return {
179
+ crossRepo,
180
+ covered: crossRepo && initiative !== null,
181
+ members,
182
+ initiative,
183
+ uncovered,
184
+ shouldRoute: crossRepo && initiative === null,
185
+ };
186
+ }
187
+
188
+ module.exports = {
189
+ DEFAULT_WINDOW_HOURS,
190
+ windowHours,
191
+ touchedMembers,
192
+ openInitiatives,
193
+ coverage,
194
+ detect,
195
+ };
@@ -46,8 +46,16 @@ const teamState = require('./team-state');
46
46
  /** Fragment view holding ecosystem activity events. */
47
47
  const EVENTS_VIEW = 'eco-events';
48
48
 
49
- /** Event kinds the git hooks emit. */
50
- const EVENT_KINDS = ['commit', 'merge', 'tag'];
49
+ /**
50
+ * Event kinds recorded on the stream.
51
+ *
52
+ * `land` (Phase 31b, ADR-0017 E5) is written by `momentum lanes land --execute`
53
+ * on success. It exists because "has member X landed its contribution?" must be
54
+ * answerable from a RECORD rather than inferred from branch or merge state —
55
+ * the asking machine may not have member X checked out at all, the same reason
56
+ * the 31a completion gate blocks on absent members rather than skipping them.
57
+ */
58
+ const EVENT_KINDS = ['commit', 'merge', 'tag', 'land'];
51
59
 
52
60
  function git(dir, ...args) {
53
61
  try {
@@ -87,6 +95,50 @@ function resolveMemberRepoRoot(cwd) {
87
95
  return top ? path.resolve(top) : null;
88
96
  }
89
97
 
98
+ /**
99
+ * Locate the ecosystem root from a member repo — up-walk AND sibling scan.
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.
107
+ *
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.
113
+ */
114
+ 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;
140
+ }
141
+
90
142
  /**
91
143
  * Match a member repo root against the ecosystem manifest.
92
144
  * Returns the member id, or null when this repo is not a registered member.
@@ -144,7 +196,7 @@ function recordEvent(opts) {
144
196
  const repoRoot = resolveMemberRepoRoot(cwd);
145
197
  if (!repoRoot) return { recorded: false, reason: 'not a git repo' };
146
198
 
147
- const ecosystemRoot = lib.findRoot(repoRoot);
199
+ const ecosystemRoot = opts.ecosystemRoot || resolveEcosystemRootFrom(repoRoot);
148
200
  if (!ecosystemRoot) return { recorded: false, reason: 'no ecosystem' };
149
201
 
150
202
  const member = resolveMemberId(ecosystemRoot, repoRoot);
@@ -156,6 +208,12 @@ function recordEvent(opts) {
156
208
  summary: String(opts.summary || '').split('\n')[0].slice(0, 500),
157
209
  context: opts.context ? String(opts.context).slice(0, 200) : '',
158
210
  };
211
+ // `land` events carry which initiative they belong to and whether the
212
+ // landing order was overridden (Phase 31b, ADR-0017 E5). Recorded on the
213
+ // event rather than inferred later, so a forced land stays visible in the
214
+ // stream instead of vanishing the way a `--no-verify` bypass would.
215
+ if (opts.initiative) payload.initiative = String(opts.initiative).slice(0, 64);
216
+ if (opts.forced) payload.forced = true;
159
217
 
160
218
  const frag = fragments.writeFragment(
161
219
  ecosystemRoot, EVENTS_VIEW, actor, kind, payload,
@@ -235,6 +293,7 @@ function writeSessionLog(ecosystemRoot, date) {
235
293
  module.exports = {
236
294
  EVENTS_VIEW,
237
295
  EVENT_KINDS,
296
+ resolveEcosystemRootFrom,
238
297
  resolveMemberRepoRoot,
239
298
  resolveMemberId,
240
299
  recordEvent,