@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.
- package/adapters/antigravity/hooks.json +14 -0
- package/adapters/claude-code/settings.json +9 -0
- package/adapters/codex/hooks.json +9 -0
- package/bin/ecosystem.js +33 -1
- package/bin/momentum.js +15 -0
- package/core/commands/sync-docs.md +35 -6
- package/core/ecosystem/lib/cross-repo.js +181 -0
- package/core/ecosystem/lib/detect.js +195 -0
- package/core/ecosystem/lib/events.js +62 -3
- package/core/ecosystem/lib/index.js +35 -1
- package/core/ecosystem/lib/landing.js +188 -0
- package/core/ecosystem/lib/orient.js +240 -0
- package/core/ecosystem/lib/pointer.js +1 -1
- package/core/ecosystem/templates/ecosystem-agents.md +9 -5
- package/core/ecosystem/templates/ecosystem-claude.md +9 -5
- package/core/git-hooks/cross-repo.js +181 -0
- package/core/git-hooks/eco-event.js +26 -2
- package/core/git-hooks/post-commit +5 -1
- package/core/git-hooks/post-merge +5 -1
- package/core/lanes/lib/land.js +75 -2
- package/core/scripts/cross-repo-gate.sh +154 -0
- package/core/scripts/sessionstart-handoff.sh +32 -0
- package/package.json +1 -1
|
@@ -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
|
}
|
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:**
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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.
|
|
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
|
-
/**
|
|
50
|
-
|
|
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 =
|
|
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,
|