@limina-labs/momentum 0.30.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +214 -0
  3. package/adapters/antigravity/adapter.js +196 -0
  4. package/adapters/antigravity/hooks.json +39 -0
  5. package/adapters/antigravity/instructions/AGENTS.md +638 -0
  6. package/adapters/antigravity/instructions/header.md +3 -0
  7. package/adapters/antigravity/instructions/surfaces.md +153 -0
  8. package/adapters/antigravity/instructions/vars.json +1 -0
  9. package/adapters/antigravity/scripts/antigravity-hook-adapter.sh +147 -0
  10. package/adapters/antigravity/skills/momentum-orient/SKILL.md +29 -0
  11. package/adapters/antigravity/skills/momentum-reviewer-architecture/SKILL.md +39 -0
  12. package/adapters/antigravity/skills/momentum-reviewer-qa/SKILL.md +38 -0
  13. package/adapters/antigravity/skills/momentum-reviewer-security/SKILL.md +42 -0
  14. package/adapters/antigravity/skills/swarm-supervisor/SKILL.md +75 -0
  15. package/adapters/antigravity/workflows/continue.md +39 -0
  16. package/adapters/antigravity/workflows/dispatch.md +49 -0
  17. package/adapters/antigravity/workflows/handoff.md +38 -0
  18. package/adapters/antigravity/workflows/review-code.md +84 -0
  19. package/adapters/antigravity/workflows/scout.md +42 -0
  20. package/adapters/antigravity/workflows/swarm.md +203 -0
  21. package/adapters/claude-code/adapter.js +148 -0
  22. package/adapters/claude-code/commands/continue.md +82 -0
  23. package/adapters/claude-code/commands/dispatch.md +154 -0
  24. package/adapters/claude-code/commands/handoff.md +85 -0
  25. package/adapters/claude-code/commands/review-code.md +194 -0
  26. package/adapters/claude-code/commands/scout.md +119 -0
  27. package/adapters/claude-code/commands/swarm.md +242 -0
  28. package/adapters/claude-code/instructions/header.md +3 -0
  29. package/adapters/claude-code/instructions/vars.json +1 -0
  30. package/adapters/claude-code/settings.json +36 -0
  31. package/adapters/codex/adapter.js +225 -0
  32. package/adapters/codex/agents/momentum-reviewer-architecture.toml +57 -0
  33. package/adapters/codex/agents/momentum-reviewer-qa.toml +52 -0
  34. package/adapters/codex/agents/momentum-reviewer-security.toml +57 -0
  35. package/adapters/codex/agents/swarm-supervisor.toml +76 -0
  36. package/adapters/codex/commands/continue.md +40 -0
  37. package/adapters/codex/commands/dispatch.md +111 -0
  38. package/adapters/codex/commands/handoff.md +57 -0
  39. package/adapters/codex/commands/scout.md +90 -0
  40. package/adapters/codex/commands/swarm.md +334 -0
  41. package/adapters/codex/hooks.json +36 -0
  42. package/adapters/codex/instructions/AGENTS.md +691 -0
  43. package/adapters/codex/instructions/header.md +3 -0
  44. package/adapters/codex/instructions/surfaces.md +206 -0
  45. package/adapters/codex/instructions/vars.json +1 -0
  46. package/adapters/codex/skills/momentum-orient/SKILL.md +29 -0
  47. package/adapters/opencode/adapter.js +224 -0
  48. package/adapters/opencode/agents/momentum-reviewer-architecture.md +54 -0
  49. package/adapters/opencode/agents/momentum-reviewer-qa.md +48 -0
  50. package/adapters/opencode/agents/momentum-reviewer-security.md +48 -0
  51. package/adapters/opencode/agents/swarm-supervisor.md +51 -0
  52. package/adapters/opencode/commands/continue.md +40 -0
  53. package/adapters/opencode/commands/dispatch.md +111 -0
  54. package/adapters/opencode/commands/handoff.md +57 -0
  55. package/adapters/opencode/commands/review-code.md +194 -0
  56. package/adapters/opencode/commands/scout.md +90 -0
  57. package/adapters/opencode/commands/swarm.md +334 -0
  58. package/adapters/opencode/instructions/AGENTS.md +604 -0
  59. package/adapters/opencode/instructions/header.md +3 -0
  60. package/adapters/opencode/instructions/surfaces.md +119 -0
  61. package/adapters/opencode/instructions/vars.json +1 -0
  62. package/adapters/opencode/plugins/momentum.js +219 -0
  63. package/adapters/opencode/skills/momentum-lanes/SKILL.md +30 -0
  64. package/adapters/opencode/skills/momentum-orient/SKILL.md +29 -0
  65. package/adapters/opencode/skills/momentum-track/SKILL.md +28 -0
  66. package/adapters/opencode/skills/momentum-validate/SKILL.md +31 -0
  67. package/bin/antigravity.js +112 -0
  68. package/bin/ecosystem.js +1302 -0
  69. package/bin/lanes.js +300 -0
  70. package/bin/momentum.js +1857 -0
  71. package/bin/okf.js +72 -0
  72. package/bin/orchestration-commands.js +305 -0
  73. package/bin/state-commands.js +218 -0
  74. package/bin/swarm.js +1104 -0
  75. package/bin/waves.js +147 -0
  76. package/core/adapter-capabilities.md +112 -0
  77. package/core/adapter-parity-matrix.md +170 -0
  78. package/core/commands/brainstorm-idea.md +98 -0
  79. package/core/commands/brainstorm-phase.md +129 -0
  80. package/core/commands/complete-phase.md +130 -0
  81. package/core/commands/ecosystem.md +161 -0
  82. package/core/commands/hotfix.md +64 -0
  83. package/core/commands/initiative.md +119 -0
  84. package/core/commands/lanes.md +94 -0
  85. package/core/commands/log.md +45 -0
  86. package/core/commands/migrate.md +70 -0
  87. package/core/commands/review.md +31 -0
  88. package/core/commands/session.md +79 -0
  89. package/core/commands/start-phase.md +161 -0
  90. package/core/commands/start-project.md +142 -0
  91. package/core/commands/sync-docs.md +81 -0
  92. package/core/commands/systematic-debug.md +46 -0
  93. package/core/commands/track.md +54 -0
  94. package/core/commands/validate.md +96 -0
  95. package/core/ecosystem/layout.md +128 -0
  96. package/core/ecosystem/lib/index.js +245 -0
  97. package/core/ecosystem/lib/initiative.js +258 -0
  98. package/core/ecosystem/lib/pointer.js +198 -0
  99. package/core/ecosystem/lib/state.js +295 -0
  100. package/core/ecosystem/schema/ecosystem.schema.json +85 -0
  101. package/core/ecosystem/schema/initiative.schema.json +61 -0
  102. package/core/ecosystem/scripts/session-append.sh +167 -0
  103. package/core/ecosystem/templates/ecosystem-agents.md +79 -0
  104. package/core/ecosystem/templates/ecosystem-claude.md +79 -0
  105. package/core/ecosystem/templates/ecosystem-settings-claude.json +14 -0
  106. package/core/ecosystem/templates/initiative-template.md +50 -0
  107. package/core/engines/subagent-dispatch.md +80 -0
  108. package/core/git-hooks/commit-msg +14 -0
  109. package/core/git-hooks/contract.js +176 -0
  110. package/core/git-hooks/pre-push +15 -0
  111. package/core/git-hooks/run-check.js +160 -0
  112. package/core/instructions/legacy-project-md-hashes.json +12 -0
  113. package/core/instructions/navigation.md +14 -0
  114. package/core/instructions/rules-body.md +454 -0
  115. package/core/lanes/lib/board.js +161 -0
  116. package/core/lanes/lib/land.js +301 -0
  117. package/core/lanes/lib/signals.js +207 -0
  118. package/core/lanes/lib/state.js +325 -0
  119. package/core/lib/frontmatter.js +163 -0
  120. package/core/lib/okf-migrate.js +374 -0
  121. package/core/lib/okf-types.js +61 -0
  122. package/core/lifecycle-contract.md +59 -0
  123. package/core/orchestration/capability-routing.js +120 -0
  124. package/core/orchestration/continue.js +102 -0
  125. package/core/orchestration/dispatch.js +366 -0
  126. package/core/orchestration/events.js +197 -0
  127. package/core/orchestration/handoff.js +317 -0
  128. package/core/orchestration/index.js +50 -0
  129. package/core/orchestration/run-artifact.js +93 -0
  130. package/core/orchestration/scout.js +327 -0
  131. package/core/orchestration/session-log.js +123 -0
  132. package/core/orchestration/tracking.js +180 -0
  133. package/core/orchestration/types.js +131 -0
  134. package/core/scripts/brainstorm-gate.sh +157 -0
  135. package/core/scripts/check-history-reminder.sh +110 -0
  136. package/core/scripts/sessionstart-handoff.sh +180 -0
  137. package/core/specs-templates/CLAUDE.md +484 -0
  138. package/core/specs-templates/README.md +32 -0
  139. package/core/specs-templates/specs/README.md +30 -0
  140. package/core/specs-templates/specs/adhoc/README.md +31 -0
  141. package/core/specs-templates/specs/adhoc/_TEMPLATE.md +29 -0
  142. package/core/specs-templates/specs/architecture/ecosystem.md +214 -0
  143. package/core/specs-templates/specs/backlog/backlog.md +46 -0
  144. package/core/specs-templates/specs/backlog/details/.gitkeep +0 -0
  145. package/core/specs-templates/specs/changelog/.gitkeep +0 -0
  146. package/core/specs-templates/specs/decisions/0000-template.md +33 -0
  147. package/core/specs-templates/specs/decisions/README.md +29 -0
  148. package/core/specs-templates/specs/decisions/impact-map.md +14 -0
  149. package/core/specs-templates/specs/decisions/index.md +4 -0
  150. package/core/specs-templates/specs/index.md +16 -0
  151. package/core/specs-templates/specs/phases/README.md +41 -0
  152. package/core/specs-templates/specs/phases/index.md +6 -0
  153. package/core/specs-templates/specs/planning/roadmap.md +23 -0
  154. package/core/specs-templates/specs/status.md +70 -0
  155. package/core/specs-templates/specs/vision/principles.md +16 -0
  156. package/core/specs-templates/specs/vision/project-charter.md +35 -0
  157. package/core/specs-templates/specs/vision/success-criteria.md +19 -0
  158. package/core/swarm/absorb.js +294 -0
  159. package/core/swarm/conductor.js +499 -0
  160. package/core/swarm/focus.js +151 -0
  161. package/core/swarm/inbox.js +222 -0
  162. package/core/swarm/join.js +138 -0
  163. package/core/swarm/lib/board.js +178 -0
  164. package/core/swarm/lib/brief.js +157 -0
  165. package/core/swarm/lib/git-sha-cache.js +71 -0
  166. package/core/swarm/lib/incremental-log.js +117 -0
  167. package/core/swarm/lib/manifest.js +578 -0
  168. package/core/swarm/lib/pre-merge.js +112 -0
  169. package/core/swarm/lib/saga.js +146 -0
  170. package/core/swarm/lib/sessions.js +83 -0
  171. package/core/swarm/lib/tokens.js +255 -0
  172. package/core/swarm/lib/wave-ordering.js +55 -0
  173. package/core/swarm/schema/board.schema.json +54 -0
  174. package/core/swarm/schema/contract.schema.json +56 -0
  175. package/core/swarm/schema/dispatch-run.schema.json +70 -0
  176. package/core/swarm/schema/manifest.schema.json +234 -0
  177. package/core/swarm/schema/signal.schema.json +61 -0
  178. package/core/swarm/signals.js +296 -0
  179. package/core/swarm/supervise.md +81 -0
  180. package/core/waves/lib/plan-graph.js +175 -0
  181. package/core/waves/lib/waves.js +83 -0
  182. package/package.json +46 -0
@@ -0,0 +1,258 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Initiative helpers — pure, dependency-free.
5
+ *
6
+ * An initiative is one markdown file under
7
+ * `<ecosystem-root>/initiatives/NNNN-<slug>.md` with a YAML frontmatter
8
+ * block validated against `../schema/initiative.schema.json`. Body is
9
+ * free-form markdown using the fixed sections from
10
+ * `../templates/initiative-template.md`.
11
+ *
12
+ * Numbering is monotonically increasing across the ecosystem. The
13
+ * filename slug is for human readability; the `id` integer is canonical.
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+
19
+ const INITIATIVES_DIR = 'initiatives';
20
+ const STATE_DIR = '.state';
21
+ const ACTIVE_FILE = 'active-initiative';
22
+
23
+ const FRONTMATTER_FENCE = '---';
24
+
25
+ // ─────────────────────────────────────────────────────────────────────────────
26
+ // Frontmatter parse / serialize (single-level YAML only)
27
+ // ─────────────────────────────────────────────────────────────────────────────
28
+
29
+ /**
30
+ * Minimal YAML reader: supports scalar key:value lines (string / integer)
31
+ * and inline arrays like `repos: [a, b, c]`. Quotes optional on scalars.
32
+ * Anything more complex (nested, multi-line) is not used by initiatives
33
+ * today — keep this strict and dependency-free.
34
+ */
35
+ function parseFrontmatter(body) {
36
+ if (typeof body !== 'string') return { frontmatter: null, content: body };
37
+ const trimmed = body.replace(/^/, '');
38
+ if (!trimmed.startsWith(FRONTMATTER_FENCE)) {
39
+ return { frontmatter: null, content: body };
40
+ }
41
+ const lines = trimmed.split('\n');
42
+ // Find the closing fence after the first.
43
+ let end = -1;
44
+ for (let i = 1; i < lines.length; i++) {
45
+ if (lines[i].trim() === FRONTMATTER_FENCE) { end = i; break; }
46
+ }
47
+ if (end === -1) return { frontmatter: null, content: body };
48
+ const fmLines = lines.slice(1, end);
49
+ const fm = {};
50
+ for (const raw of fmLines) {
51
+ const line = raw.trim();
52
+ if (line.length === 0 || line.startsWith('#')) continue;
53
+ const colon = line.indexOf(':');
54
+ if (colon === -1) continue;
55
+ const key = line.slice(0, colon).trim();
56
+ let value = line.slice(colon + 1).trim();
57
+ if (value.startsWith('[') && value.endsWith(']')) {
58
+ // Inline array
59
+ const inner = value.slice(1, -1).trim();
60
+ fm[key] = inner.length === 0
61
+ ? []
62
+ : inner.split(',').map((s) => stripQuotes(s.trim())).filter(Boolean);
63
+ } else if (/^-?\d+$/.test(value)) {
64
+ fm[key] = parseInt(value, 10);
65
+ } else {
66
+ fm[key] = stripQuotes(value);
67
+ }
68
+ }
69
+ const content = lines.slice(end + 1).join('\n').replace(/^\n+/, '');
70
+ return { frontmatter: fm, content };
71
+ }
72
+
73
+ function stripQuotes(s) {
74
+ if (s.length >= 2) {
75
+ const first = s[0];
76
+ const last = s[s.length - 1];
77
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
78
+ return s.slice(1, -1);
79
+ }
80
+ }
81
+ return s;
82
+ }
83
+
84
+ function serializeFrontmatter(fm) {
85
+ const lines = [FRONTMATTER_FENCE];
86
+ for (const key of Object.keys(fm)) {
87
+ const value = fm[key];
88
+ if (value === undefined || value === null) continue;
89
+ if (Array.isArray(value)) {
90
+ lines.push(`${key}: [${value.map((v) => formatScalar(v)).join(', ')}]`);
91
+ } else {
92
+ lines.push(`${key}: ${formatScalar(value)}`);
93
+ }
94
+ }
95
+ lines.push(FRONTMATTER_FENCE);
96
+ return lines.join('\n') + '\n';
97
+ }
98
+
99
+ function formatScalar(v) {
100
+ if (typeof v === 'number') return String(v);
101
+ const s = String(v);
102
+ // Quote when ambiguous: contains characters that would re-parse oddly.
103
+ if (s.length === 0) return '""';
104
+ if (/[#:,\[\]]/.test(s) || /^\s|\s$/.test(s) || /^-?\d+$/.test(s)) {
105
+ return `"${s.replace(/"/g, '\\"')}"`;
106
+ }
107
+ return s;
108
+ }
109
+
110
+ // ─────────────────────────────────────────────────────────────────────────────
111
+ // Validation
112
+ // ─────────────────────────────────────────────────────────────────────────────
113
+
114
+ const VALID_STATUSES = ['in-progress', 'closed', 'abandoned'];
115
+
116
+ /**
117
+ * Validates an initiative frontmatter object against the schema.
118
+ * Returns { ok: true } or { ok: false, errors: [{ path, message }] }.
119
+ */
120
+ function validateFrontmatter(fm) {
121
+ const errors = [];
122
+ const slug = /^[a-z][a-z0-9-]*$/;
123
+ const date = /^\d{4}-\d{2}-\d{2}$/;
124
+
125
+ if (fm === null || typeof fm !== 'object' || Array.isArray(fm)) {
126
+ return { ok: false, errors: [{ path: '$', message: 'frontmatter must be an object' }] };
127
+ }
128
+ if (!Number.isInteger(fm.id) || fm.id < 1) {
129
+ errors.push({ path: '$.id', message: 'must be a positive integer' });
130
+ }
131
+ if (typeof fm.slug !== 'string' || !slug.test(fm.slug)) {
132
+ errors.push({ path: '$.slug', message: 'must match /^[a-z][a-z0-9-]*$/' });
133
+ }
134
+ if (typeof fm.status !== 'string' || !VALID_STATUSES.includes(fm.status)) {
135
+ errors.push({ path: '$.status', message: `must be one of: ${VALID_STATUSES.join(', ')}` });
136
+ }
137
+ if (typeof fm.started !== 'string' || !date.test(fm.started)) {
138
+ errors.push({ path: '$.started', message: 'must be ISO-8601 date YYYY-MM-DD' });
139
+ }
140
+ if (fm.status === 'closed') {
141
+ if (typeof fm.closed !== 'string' || !date.test(fm.closed)) {
142
+ errors.push({ path: '$.closed', message: 'required when status=closed' });
143
+ }
144
+ }
145
+ if (typeof fm.owner !== 'string' || fm.owner.length === 0) {
146
+ errors.push({ path: '$.owner', message: 'required non-empty string' });
147
+ }
148
+ if (!Array.isArray(fm.repos) || fm.repos.length === 0 || !fm.repos.every((s) => typeof s === 'string')) {
149
+ errors.push({ path: '$.repos', message: 'required non-empty array of strings' });
150
+ }
151
+ return errors.length === 0 ? { ok: true } : { ok: false, errors };
152
+ }
153
+
154
+ // ─────────────────────────────────────────────────────────────────────────────
155
+ // File operations
156
+ // ─────────────────────────────────────────────────────────────────────────────
157
+
158
+ /**
159
+ * Discover the next available initiative id by scanning filenames in
160
+ * <ecosystem-root>/initiatives/. Pure function over directory contents.
161
+ */
162
+ function nextInitiativeId(ecosystemRoot) {
163
+ const dir = path.join(ecosystemRoot, INITIATIVES_DIR);
164
+ if (!fs.existsSync(dir)) return 1;
165
+ const entries = fs.readdirSync(dir);
166
+ let max = 0;
167
+ for (const name of entries) {
168
+ const m = name.match(/^(\d{4})-[a-z][a-z0-9-]*\.md$/);
169
+ if (m) {
170
+ const n = parseInt(m[1], 10);
171
+ if (n > max) max = n;
172
+ }
173
+ }
174
+ return max + 1;
175
+ }
176
+
177
+ function initiativeFilename(id, slug) {
178
+ const padded = String(id).padStart(4, '0');
179
+ return `${padded}-${slug}.md`;
180
+ }
181
+
182
+ function initiativePath(ecosystemRoot, id, slug) {
183
+ return path.join(ecosystemRoot, INITIATIVES_DIR, initiativeFilename(id, slug));
184
+ }
185
+
186
+ /**
187
+ * Read + parse an initiative by slug. Searches by filename suffix.
188
+ * Returns { frontmatter, content, filePath } or null if not found.
189
+ */
190
+ function loadInitiative(ecosystemRoot, slug) {
191
+ const dir = path.join(ecosystemRoot, INITIATIVES_DIR);
192
+ if (!fs.existsSync(dir)) return null;
193
+ const entries = fs.readdirSync(dir);
194
+ const match = entries.find((name) => name.endsWith(`-${slug}.md`));
195
+ if (!match) return null;
196
+ const filePath = path.join(dir, match);
197
+ const raw = fs.readFileSync(filePath, 'utf8');
198
+ const { frontmatter, content } = parseFrontmatter(raw);
199
+ return { frontmatter, content, filePath };
200
+ }
201
+
202
+ /**
203
+ * Write an initiative file. Caller passes already-validated frontmatter
204
+ * + content. The fenced YAML block is regenerated; content is preserved
205
+ * verbatim below the closing fence.
206
+ */
207
+ function writeInitiative(filePath, frontmatter, content) {
208
+ const v = validateFrontmatter(frontmatter);
209
+ if (!v.ok) {
210
+ throw new Error(
211
+ `writeInitiative: frontmatter validation failed:\n` +
212
+ v.errors.map((e) => ` ${e.path}: ${e.message}`).join('\n'),
213
+ );
214
+ }
215
+ const fm = serializeFrontmatter(frontmatter);
216
+ const body = content == null ? '' : content;
217
+ fs.writeFileSync(filePath, fm + body, 'utf8');
218
+ }
219
+
220
+ /**
221
+ * Set the active initiative for the ecosystem. Empty string clears.
222
+ */
223
+ function setActive(ecosystemRoot, slug) {
224
+ fs.mkdirSync(path.join(ecosystemRoot, STATE_DIR), { recursive: true });
225
+ fs.writeFileSync(
226
+ path.join(ecosystemRoot, STATE_DIR, ACTIVE_FILE),
227
+ slug == null ? '' : String(slug),
228
+ 'utf8',
229
+ );
230
+ }
231
+
232
+ function getActive(ecosystemRoot) {
233
+ const file = path.join(ecosystemRoot, STATE_DIR, ACTIVE_FILE);
234
+ if (!fs.existsSync(file)) return null;
235
+ const s = fs.readFileSync(file, 'utf8').trim();
236
+ return s || null;
237
+ }
238
+
239
+ function clearActive(ecosystemRoot) {
240
+ setActive(ecosystemRoot, '');
241
+ }
242
+
243
+ module.exports = {
244
+ INITIATIVES_DIR,
245
+ STATE_DIR,
246
+ ACTIVE_FILE,
247
+ parseFrontmatter,
248
+ serializeFrontmatter,
249
+ validateFrontmatter,
250
+ nextInitiativeId,
251
+ initiativeFilename,
252
+ initiativePath,
253
+ loadInitiative,
254
+ writeInitiative,
255
+ setActive,
256
+ getActive,
257
+ clearActive,
258
+ };
@@ -0,0 +1,198 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Pointer block helpers — extracted from bin/ecosystem.js so that
5
+ * `momentum join` / `momentum leave` (Phase 10 Group 1) can reuse the
6
+ * same primitives without duplicating logic.
7
+ *
8
+ * A pointer block is a sentinel-fenced markdown stanza injected at the
9
+ * top of a member repo's primary instruction file (CLAUDE.md / AGENTS.md)
10
+ * telling agents working *inside* the member that the repo is part of
11
+ * an ecosystem, what to do for cross-repo work, and which orchestration
12
+ * primitives are available. It is the only touch the ecosystem layer
13
+ * makes on a member repo — strictly additive.
14
+ *
15
+ * The block is versioned via the BEGIN sentinel:
16
+ * `<!-- ecosystem:begin v=2 -->` ← what we WRITE today
17
+ * `<!-- ecosystem:begin -->` ← legacy (v1) — auto-migrated on next touch
18
+ *
19
+ * Phase 15 / ENH-032: bumped to v2. The v1 block was information-only
20
+ * ("Member of X ecosystem at ../X."). v2 is action-bearing — it lists
21
+ * the orchestration primitives and the cross-repo routing rule
22
+ * ("write an initiative; never plan cross-repo features here") so an
23
+ * agent reading the member's primary instruction immediately knows
24
+ * how to engage with the ecosystem layer.
25
+ */
26
+
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+
30
+ const POINTER_VERSION = 2;
31
+ const POINTER_BEGIN = `<!-- ecosystem:begin v=${POINTER_VERSION} -->`;
32
+ const POINTER_END = '<!-- ecosystem:end -->';
33
+
34
+ // Loose substring used to DETECT any pointer block (any version).
35
+ // The literal v=2 sentinel above is what we WRITE; for detection we
36
+ // match the version-less prefix so we can find + migrate v1 blocks.
37
+ const POINTER_BEGIN_PREFIX = '<!-- ecosystem:begin';
38
+ // Regex covers both `<!-- ecosystem:begin -->` and `<!-- ecosystem:begin v=N -->`.
39
+ const POINTER_BEGIN_RE = /<!-- ecosystem:begin(?:\s+v=(\d+))?\s*-->/;
40
+ // Match a full block (BEGIN…END) regardless of version. Used by strip
41
+ // + replace. The `[\s\S]*?` middle is non-greedy so multiple blocks
42
+ // on the same file don't bleed.
43
+ const POINTER_BLOCK_RE = /<!-- ecosystem:begin(?:\s+v=\d+)?\s*-->[\s\S]*?<!-- ecosystem:end -->/g;
44
+
45
+ // Order matters: prefer CLAUDE.md over AGENTS.md when both exist.
46
+ // Future adapters can extend this via the adapter contract (Phase 11+).
47
+ const PRIMARY_INSTRUCTION_CANDIDATES = ['CLAUDE.md', 'AGENTS.md'];
48
+
49
+ /**
50
+ * Locate the primary instruction file in a member repo by checking
51
+ * the candidate list in order. Returns the filename (relative) or null.
52
+ */
53
+ function findPrimaryInstructionFile(repoPath) {
54
+ for (const name of PRIMARY_INSTRUCTION_CANDIDATES) {
55
+ if (fs.existsSync(path.join(repoPath, name))) {
56
+ return name;
57
+ }
58
+ }
59
+ return null;
60
+ }
61
+
62
+ /**
63
+ * True iff `repoPath` has a primary instruction file containing a
64
+ * pointer block (any version). Returns false on missing file,
65
+ * unreadable file, or absent sentinel.
66
+ */
67
+ function hasPointerBlock(repoPath) {
68
+ const primary = findPrimaryInstructionFile(repoPath);
69
+ if (!primary) return false;
70
+ try {
71
+ const content = fs.readFileSync(path.join(repoPath, primary), 'utf8');
72
+ return content.includes(POINTER_BEGIN_PREFIX);
73
+ } catch (_err) {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Render the v2 pointer-block contents (no surrounding sentinels).
80
+ * Exported for tests; also used internally by ensurePointerInjected.
81
+ */
82
+ function renderPointerBody(ecosystemName, relFromMember) {
83
+ return [
84
+ `> **Member of \`${ecosystemName}\` ecosystem** at \`${relFromMember}\`.`,
85
+ `>`,
86
+ `> **Cross-repo work?** Write an initiative — never plan cross-repo features in this repo:`,
87
+ `> \`${relFromMember}/initiatives/<NNNN-slug>.md\` (or \`/initiative create <slug>\`)`,
88
+ `>`,
89
+ `> Orchestration primitives (run from this repo or the ecosystem root):`,
90
+ `> - \`/scout <repo>\` — read another member's state`,
91
+ `> - \`/dispatch <r1> <r2> "..."\` — parallel multi-repo investigation (slash command — \`momentum dispatch\` CLI is keyword-only)`,
92
+ `> - \`/handoff <repo>\` — transfer context to another member`,
93
+ `> - \`/continue\` — resume from an inbox handoff`,
94
+ `>`,
95
+ `> See siblings + live state: \`momentum ecosystem status\``,
96
+ ].join('\n');
97
+ }
98
+
99
+ /**
100
+ * Inject the pointer block immediately after the first H1 in the
101
+ * primary instruction file, or at the top of file if no H1 exists.
102
+ *
103
+ * Behaviour by current state:
104
+ * - No pointer block → insert v=POINTER_VERSION block.
105
+ * - Pointer block, version < POINTER_VERSION → migrate in place
106
+ * (replace the entire block contents with the current version).
107
+ * This is what closes ENH-032 for already-registered members:
108
+ * the next time `momentum ecosystem add` runs against them
109
+ * (idempotent re-add, or a fresh add of a different member that
110
+ * touches the file), the old info-only block is silently upgraded
111
+ * to the action-bearing v2 form.
112
+ * - Pointer block, version == POINTER_VERSION → no-op.
113
+ *
114
+ * `absRepo` — absolute path to the member repo.
115
+ * `primaryFile` — filename returned by findPrimaryInstructionFile.
116
+ * `root` — absolute path to the ecosystem root.
117
+ * `ecosystemName` — manifest name (used in the human-readable line).
118
+ */
119
+ function ensurePointerInjected(absRepo, primaryFile, root, ecosystemName) {
120
+ const filePath = path.join(absRepo, primaryFile);
121
+ const original = fs.readFileSync(filePath, 'utf8');
122
+ const relFromMember = path.relative(absRepo, root) || '.';
123
+ const body = renderPointerBody(ecosystemName, relFromMember);
124
+ const fullBlock = `${POINTER_BEGIN}\n${body}\n${POINTER_END}`;
125
+
126
+ const match = original.match(POINTER_BEGIN_RE);
127
+ if (match) {
128
+ // A pointer block exists. Inspect its version.
129
+ const existingVersion = match[1] ? parseInt(match[1], 10) : 1;
130
+ if (existingVersion >= POINTER_VERSION) {
131
+ // Up-to-date — preserve as-is (and respect any user edits inside).
132
+ return;
133
+ }
134
+ // Migrate in place: replace the FIRST block with the new block.
135
+ let replaced = false;
136
+ const next = original.replace(POINTER_BLOCK_RE, (m) => {
137
+ if (replaced) return m;
138
+ replaced = true;
139
+ return fullBlock;
140
+ });
141
+ fs.writeFileSync(filePath, next, 'utf8');
142
+ return;
143
+ }
144
+
145
+ // No pointer block — insert after first H1 (or at top).
146
+ const insertion = `\n${fullBlock}\n`;
147
+ const lines = original.split('\n');
148
+ let insertAt = 0;
149
+ for (let i = 0; i < lines.length; i++) {
150
+ if (/^#\s+/.test(lines[i])) {
151
+ insertAt = i + 1;
152
+ break;
153
+ }
154
+ }
155
+ const next = lines
156
+ .slice(0, insertAt)
157
+ .concat(insertion.split('\n').slice(0, -1), lines.slice(insertAt))
158
+ .join('\n');
159
+ fs.writeFileSync(filePath, next, 'utf8');
160
+ }
161
+
162
+ /**
163
+ * Strip the pointer block (any version) and one surrounding blank
164
+ * line from the given file. Idempotent: silently no-ops if the file
165
+ * is missing or no pointer block is present.
166
+ */
167
+ function stripPointer(filePath) {
168
+ if (!fs.existsSync(filePath)) return;
169
+ const original = fs.readFileSync(filePath, 'utf8');
170
+ if (!original.includes(POINTER_BEGIN_PREFIX)) return;
171
+ // Match leading newline (if any) + the full block + trailing newline
172
+ // (if any). The BLOCK_RE alone doesn't grab the wrapping whitespace,
173
+ // so we wrap it with optional newlines here.
174
+ const re = new RegExp(
175
+ `\\n?${POINTER_BLOCK_RE.source}\\n?`,
176
+ 'g',
177
+ );
178
+ fs.writeFileSync(filePath, original.replace(re, '\n'), 'utf8');
179
+ }
180
+
181
+ function escapeRegExp(s) {
182
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
183
+ }
184
+
185
+ module.exports = {
186
+ POINTER_VERSION,
187
+ POINTER_BEGIN,
188
+ POINTER_END,
189
+ POINTER_BEGIN_PREFIX,
190
+ POINTER_BEGIN_RE,
191
+ POINTER_BLOCK_RE,
192
+ PRIMARY_INSTRUCTION_CANDIDATES,
193
+ findPrimaryInstructionFile,
194
+ hasPointerBlock,
195
+ ensurePointerInjected,
196
+ stripPointer,
197
+ renderPointerBody,
198
+ };