@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.
- package/LICENSE +21 -0
- package/README.md +214 -0
- package/adapters/antigravity/adapter.js +196 -0
- package/adapters/antigravity/hooks.json +39 -0
- package/adapters/antigravity/instructions/AGENTS.md +638 -0
- package/adapters/antigravity/instructions/header.md +3 -0
- package/adapters/antigravity/instructions/surfaces.md +153 -0
- package/adapters/antigravity/instructions/vars.json +1 -0
- package/adapters/antigravity/scripts/antigravity-hook-adapter.sh +147 -0
- package/adapters/antigravity/skills/momentum-orient/SKILL.md +29 -0
- package/adapters/antigravity/skills/momentum-reviewer-architecture/SKILL.md +39 -0
- package/adapters/antigravity/skills/momentum-reviewer-qa/SKILL.md +38 -0
- package/adapters/antigravity/skills/momentum-reviewer-security/SKILL.md +42 -0
- package/adapters/antigravity/skills/swarm-supervisor/SKILL.md +75 -0
- package/adapters/antigravity/workflows/continue.md +39 -0
- package/adapters/antigravity/workflows/dispatch.md +49 -0
- package/adapters/antigravity/workflows/handoff.md +38 -0
- package/adapters/antigravity/workflows/review-code.md +84 -0
- package/adapters/antigravity/workflows/scout.md +42 -0
- package/adapters/antigravity/workflows/swarm.md +203 -0
- package/adapters/claude-code/adapter.js +148 -0
- package/adapters/claude-code/commands/continue.md +82 -0
- package/adapters/claude-code/commands/dispatch.md +154 -0
- package/adapters/claude-code/commands/handoff.md +85 -0
- package/adapters/claude-code/commands/review-code.md +194 -0
- package/adapters/claude-code/commands/scout.md +119 -0
- package/adapters/claude-code/commands/swarm.md +242 -0
- package/adapters/claude-code/instructions/header.md +3 -0
- package/adapters/claude-code/instructions/vars.json +1 -0
- package/adapters/claude-code/settings.json +36 -0
- package/adapters/codex/adapter.js +225 -0
- package/adapters/codex/agents/momentum-reviewer-architecture.toml +57 -0
- package/adapters/codex/agents/momentum-reviewer-qa.toml +52 -0
- package/adapters/codex/agents/momentum-reviewer-security.toml +57 -0
- package/adapters/codex/agents/swarm-supervisor.toml +76 -0
- package/adapters/codex/commands/continue.md +40 -0
- package/adapters/codex/commands/dispatch.md +111 -0
- package/adapters/codex/commands/handoff.md +57 -0
- package/adapters/codex/commands/scout.md +90 -0
- package/adapters/codex/commands/swarm.md +334 -0
- package/adapters/codex/hooks.json +36 -0
- package/adapters/codex/instructions/AGENTS.md +691 -0
- package/adapters/codex/instructions/header.md +3 -0
- package/adapters/codex/instructions/surfaces.md +206 -0
- package/adapters/codex/instructions/vars.json +1 -0
- package/adapters/codex/skills/momentum-orient/SKILL.md +29 -0
- package/adapters/opencode/adapter.js +224 -0
- package/adapters/opencode/agents/momentum-reviewer-architecture.md +54 -0
- package/adapters/opencode/agents/momentum-reviewer-qa.md +48 -0
- package/adapters/opencode/agents/momentum-reviewer-security.md +48 -0
- package/adapters/opencode/agents/swarm-supervisor.md +51 -0
- package/adapters/opencode/commands/continue.md +40 -0
- package/adapters/opencode/commands/dispatch.md +111 -0
- package/adapters/opencode/commands/handoff.md +57 -0
- package/adapters/opencode/commands/review-code.md +194 -0
- package/adapters/opencode/commands/scout.md +90 -0
- package/adapters/opencode/commands/swarm.md +334 -0
- package/adapters/opencode/instructions/AGENTS.md +604 -0
- package/adapters/opencode/instructions/header.md +3 -0
- package/adapters/opencode/instructions/surfaces.md +119 -0
- package/adapters/opencode/instructions/vars.json +1 -0
- package/adapters/opencode/plugins/momentum.js +219 -0
- package/adapters/opencode/skills/momentum-lanes/SKILL.md +30 -0
- package/adapters/opencode/skills/momentum-orient/SKILL.md +29 -0
- package/adapters/opencode/skills/momentum-track/SKILL.md +28 -0
- package/adapters/opencode/skills/momentum-validate/SKILL.md +31 -0
- package/bin/antigravity.js +112 -0
- package/bin/ecosystem.js +1302 -0
- package/bin/lanes.js +300 -0
- package/bin/momentum.js +1857 -0
- package/bin/okf.js +72 -0
- package/bin/orchestration-commands.js +305 -0
- package/bin/state-commands.js +218 -0
- package/bin/swarm.js +1104 -0
- package/bin/waves.js +147 -0
- package/core/adapter-capabilities.md +112 -0
- package/core/adapter-parity-matrix.md +170 -0
- package/core/commands/brainstorm-idea.md +98 -0
- package/core/commands/brainstorm-phase.md +129 -0
- package/core/commands/complete-phase.md +130 -0
- package/core/commands/ecosystem.md +161 -0
- package/core/commands/hotfix.md +64 -0
- package/core/commands/initiative.md +119 -0
- package/core/commands/lanes.md +94 -0
- package/core/commands/log.md +45 -0
- package/core/commands/migrate.md +70 -0
- package/core/commands/review.md +31 -0
- package/core/commands/session.md +79 -0
- package/core/commands/start-phase.md +161 -0
- package/core/commands/start-project.md +142 -0
- package/core/commands/sync-docs.md +81 -0
- package/core/commands/systematic-debug.md +46 -0
- package/core/commands/track.md +54 -0
- package/core/commands/validate.md +96 -0
- package/core/ecosystem/layout.md +128 -0
- package/core/ecosystem/lib/index.js +245 -0
- package/core/ecosystem/lib/initiative.js +258 -0
- package/core/ecosystem/lib/pointer.js +198 -0
- package/core/ecosystem/lib/state.js +295 -0
- package/core/ecosystem/schema/ecosystem.schema.json +85 -0
- package/core/ecosystem/schema/initiative.schema.json +61 -0
- package/core/ecosystem/scripts/session-append.sh +167 -0
- package/core/ecosystem/templates/ecosystem-agents.md +79 -0
- package/core/ecosystem/templates/ecosystem-claude.md +79 -0
- package/core/ecosystem/templates/ecosystem-settings-claude.json +14 -0
- package/core/ecosystem/templates/initiative-template.md +50 -0
- package/core/engines/subagent-dispatch.md +80 -0
- package/core/git-hooks/commit-msg +14 -0
- package/core/git-hooks/contract.js +176 -0
- package/core/git-hooks/pre-push +15 -0
- package/core/git-hooks/run-check.js +160 -0
- package/core/instructions/legacy-project-md-hashes.json +12 -0
- package/core/instructions/navigation.md +14 -0
- package/core/instructions/rules-body.md +454 -0
- package/core/lanes/lib/board.js +161 -0
- package/core/lanes/lib/land.js +301 -0
- package/core/lanes/lib/signals.js +207 -0
- package/core/lanes/lib/state.js +325 -0
- package/core/lib/frontmatter.js +163 -0
- package/core/lib/okf-migrate.js +374 -0
- package/core/lib/okf-types.js +61 -0
- package/core/lifecycle-contract.md +59 -0
- package/core/orchestration/capability-routing.js +120 -0
- package/core/orchestration/continue.js +102 -0
- package/core/orchestration/dispatch.js +366 -0
- package/core/orchestration/events.js +197 -0
- package/core/orchestration/handoff.js +317 -0
- package/core/orchestration/index.js +50 -0
- package/core/orchestration/run-artifact.js +93 -0
- package/core/orchestration/scout.js +327 -0
- package/core/orchestration/session-log.js +123 -0
- package/core/orchestration/tracking.js +180 -0
- package/core/orchestration/types.js +131 -0
- package/core/scripts/brainstorm-gate.sh +157 -0
- package/core/scripts/check-history-reminder.sh +110 -0
- package/core/scripts/sessionstart-handoff.sh +180 -0
- package/core/specs-templates/CLAUDE.md +484 -0
- package/core/specs-templates/README.md +32 -0
- package/core/specs-templates/specs/README.md +30 -0
- package/core/specs-templates/specs/adhoc/README.md +31 -0
- package/core/specs-templates/specs/adhoc/_TEMPLATE.md +29 -0
- package/core/specs-templates/specs/architecture/ecosystem.md +214 -0
- package/core/specs-templates/specs/backlog/backlog.md +46 -0
- package/core/specs-templates/specs/backlog/details/.gitkeep +0 -0
- package/core/specs-templates/specs/changelog/.gitkeep +0 -0
- package/core/specs-templates/specs/decisions/0000-template.md +33 -0
- package/core/specs-templates/specs/decisions/README.md +29 -0
- package/core/specs-templates/specs/decisions/impact-map.md +14 -0
- package/core/specs-templates/specs/decisions/index.md +4 -0
- package/core/specs-templates/specs/index.md +16 -0
- package/core/specs-templates/specs/phases/README.md +41 -0
- package/core/specs-templates/specs/phases/index.md +6 -0
- package/core/specs-templates/specs/planning/roadmap.md +23 -0
- package/core/specs-templates/specs/status.md +70 -0
- package/core/specs-templates/specs/vision/principles.md +16 -0
- package/core/specs-templates/specs/vision/project-charter.md +35 -0
- package/core/specs-templates/specs/vision/success-criteria.md +19 -0
- package/core/swarm/absorb.js +294 -0
- package/core/swarm/conductor.js +499 -0
- package/core/swarm/focus.js +151 -0
- package/core/swarm/inbox.js +222 -0
- package/core/swarm/join.js +138 -0
- package/core/swarm/lib/board.js +178 -0
- package/core/swarm/lib/brief.js +157 -0
- package/core/swarm/lib/git-sha-cache.js +71 -0
- package/core/swarm/lib/incremental-log.js +117 -0
- package/core/swarm/lib/manifest.js +578 -0
- package/core/swarm/lib/pre-merge.js +112 -0
- package/core/swarm/lib/saga.js +146 -0
- package/core/swarm/lib/sessions.js +83 -0
- package/core/swarm/lib/tokens.js +255 -0
- package/core/swarm/lib/wave-ordering.js +55 -0
- package/core/swarm/schema/board.schema.json +54 -0
- package/core/swarm/schema/contract.schema.json +56 -0
- package/core/swarm/schema/dispatch-run.schema.json +70 -0
- package/core/swarm/schema/manifest.schema.json +234 -0
- package/core/swarm/schema/signal.schema.json +61 -0
- package/core/swarm/signals.js +296 -0
- package/core/swarm/supervise.md +81 -0
- package/core/waves/lib/plan-graph.js +175 -0
- package/core/waves/lib/waves.js +83 -0
- package/package.json +46 -0
package/bin/ecosystem.js
ADDED
|
@@ -0,0 +1,1302 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `momentum ecosystem` subcommand handler.
|
|
5
|
+
*
|
|
6
|
+
* Subcommands:
|
|
7
|
+
* init [name] — scaffold a new ecosystem root in the CWD
|
|
8
|
+
* add <repo-path> — register a momentum-installed repo as a member
|
|
9
|
+
* remove <id> — strip a member by id
|
|
10
|
+
* status — print manifest + per-member git state
|
|
11
|
+
*
|
|
12
|
+
* The ecosystem layer is strictly additive to single-repo momentum.
|
|
13
|
+
* Member repos receive exactly one fenced line in their CLAUDE.md /
|
|
14
|
+
* AGENTS.md pointing back at the ecosystem; nothing else is touched.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const { execSync } = require('child_process');
|
|
20
|
+
|
|
21
|
+
const lib = require('../core/ecosystem/lib');
|
|
22
|
+
const {
|
|
23
|
+
POINTER_BEGIN,
|
|
24
|
+
POINTER_END,
|
|
25
|
+
PRIMARY_INSTRUCTION_CANDIDATES,
|
|
26
|
+
findPrimaryInstructionFile,
|
|
27
|
+
ensurePointerInjected,
|
|
28
|
+
stripPointer,
|
|
29
|
+
} = require('../core/ecosystem/lib/pointer');
|
|
30
|
+
|
|
31
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
// Public entrypoint
|
|
33
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Dispatch a `momentum ecosystem <subcommand> [...]` invocation.
|
|
37
|
+
*
|
|
38
|
+
* `args` is the full argv tail starting at the subcommand
|
|
39
|
+
* (i.e. caller has already shifted off `momentum` and `ecosystem`).
|
|
40
|
+
*
|
|
41
|
+
* Returns void; throws Error on failure with a human-readable message.
|
|
42
|
+
*/
|
|
43
|
+
function runEcosystem(args) {
|
|
44
|
+
if (!args || args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
45
|
+
return printUsage();
|
|
46
|
+
}
|
|
47
|
+
const sub = args[0];
|
|
48
|
+
const rest = args.slice(1);
|
|
49
|
+
|
|
50
|
+
switch (sub) {
|
|
51
|
+
case 'init':
|
|
52
|
+
return cmdInit(rest);
|
|
53
|
+
case 'add':
|
|
54
|
+
return cmdAdd(rest);
|
|
55
|
+
case 'remove':
|
|
56
|
+
case 'rm':
|
|
57
|
+
return cmdRemove(rest);
|
|
58
|
+
case 'status':
|
|
59
|
+
return cmdStatus(rest);
|
|
60
|
+
case 'upgrade':
|
|
61
|
+
return cmdUpgrade(rest);
|
|
62
|
+
case 'initiative':
|
|
63
|
+
return cmdInitiative(rest);
|
|
64
|
+
default:
|
|
65
|
+
throw new Error(
|
|
66
|
+
`unknown ecosystem subcommand "${sub}". ` +
|
|
67
|
+
`Try: init | add | remove | status | upgrade | initiative`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function printUsage() {
|
|
73
|
+
console.log(`
|
|
74
|
+
momentum ecosystem — Cross-repo coordination layer (Tier 1).
|
|
75
|
+
|
|
76
|
+
Usage:
|
|
77
|
+
momentum ecosystem init [name]
|
|
78
|
+
momentum ecosystem add <repo-path> [--role <role>] [--id <id>] [--ecosystem <path>]
|
|
79
|
+
momentum ecosystem remove <member-id> [--ecosystem <path>]
|
|
80
|
+
momentum ecosystem status [--no-git] [--ecosystem <path>]
|
|
81
|
+
momentum ecosystem upgrade [--dry-run] [--force|--autostash] [--agent <name>] [--ecosystem <path>]
|
|
82
|
+
momentum ecosystem initiative create <slug> [--why "<text>"] [--repos r1,r2] [--owner <name>] [--ecosystem <path>]
|
|
83
|
+
|
|
84
|
+
Location:
|
|
85
|
+
add / remove / status / initiative auto-locate the ecosystem root by
|
|
86
|
+
walking up from CWD (bounded by MOMENTUM_MAX_PARENT_WALK, default 5)
|
|
87
|
+
AND scanning siblings (so member-repo CWDs work). Use --ecosystem <path>
|
|
88
|
+
to override explicitly.
|
|
89
|
+
|
|
90
|
+
Subcommands:
|
|
91
|
+
init Scaffold a new ecosystem root in the CWD (or under [name]/).
|
|
92
|
+
Writes ecosystem.json, initiatives/, sessions/, .state/,
|
|
93
|
+
.gitignore, README.md, CLAUDE.md, AGENTS.md, plus the
|
|
94
|
+
coordination command surface those instructions advertise
|
|
95
|
+
(/scout /dispatch /handoff /continue /initiative /session
|
|
96
|
+
/ecosystem /swarm + session scripts + hook wiring) for
|
|
97
|
+
--agent <name> (default: claude-code). Runs \`git init\`
|
|
98
|
+
and an initial commit.
|
|
99
|
+
|
|
100
|
+
add Register a momentum-installed repo as a member. Writes the
|
|
101
|
+
member into ecosystem.json AND injects an action-bearing
|
|
102
|
+
pointer block into the target's CLAUDE.md / AGENTS.md.
|
|
103
|
+
Idempotent — re-running is a no-op when the state already
|
|
104
|
+
matches.
|
|
105
|
+
|
|
106
|
+
remove Inverse of \`add\`. Strips the member from ecosystem.json and
|
|
107
|
+
removes the pointer block from the target's primary
|
|
108
|
+
instruction file.
|
|
109
|
+
|
|
110
|
+
status Print the manifest summary plus \`git status --short\` and
|
|
111
|
+
the most recent commit for each member.
|
|
112
|
+
|
|
113
|
+
upgrade Sweep \`momentum upgrade\` across every member (PULL model).
|
|
114
|
+
Per repo: skips a dirty working tree, detects the adapter from
|
|
115
|
+
its lock file, runs the upgrade, and reports the momentum version
|
|
116
|
+
before → after. Tolerates partial failure — one bad repo never
|
|
117
|
+
aborts the fleet. Dirty repos:
|
|
118
|
+
--autostash stash the in-flight work, upgrade, restore it
|
|
119
|
+
(your work comes back exactly as it was)
|
|
120
|
+
--force upgrade in place without stashing
|
|
121
|
+
Use --dry-run to preview the whole sweep without writing anything.
|
|
122
|
+
Note: the CLI's OWN version bounds the result — update the CLI
|
|
123
|
+
first (\`npm i -g @limina-labs/momentum@latest\`).
|
|
124
|
+
|
|
125
|
+
initiative Manage cross-repo initiatives. Currently only \`create\` is
|
|
126
|
+
wired as a CLI subcommand (other operations stay slash-only
|
|
127
|
+
for now). Non-interactive — takes flags so it works from
|
|
128
|
+
any agent context. Defaults: --repos all members; --owner
|
|
129
|
+
git user.name (or \$USER); --why "" (write later).
|
|
130
|
+
|
|
131
|
+
Roles:
|
|
132
|
+
platform | client | library | infra | bench | other
|
|
133
|
+
|
|
134
|
+
This command must be run from inside an ecosystem root (or, for
|
|
135
|
+
\`init\`, from where you want the root created).
|
|
136
|
+
`.trim());
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
140
|
+
// init
|
|
141
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
function cmdInit(args) {
|
|
144
|
+
const opts = parseFlags(args, { agent: 'string' });
|
|
145
|
+
// Positionals = args minus flags and their values (parseFlags doesn't strip).
|
|
146
|
+
const positionals = [];
|
|
147
|
+
for (let i = 0; i < args.length; i++) {
|
|
148
|
+
if (args[i].startsWith('--')) {
|
|
149
|
+
if (args[i] === '--agent') i++;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
positionals.push(args[i]);
|
|
153
|
+
}
|
|
154
|
+
const agent = opts.agent || 'claude-code';
|
|
155
|
+
rootCommandDest(agent); // validates the agent name before any writes
|
|
156
|
+
|
|
157
|
+
const name = positionals[0] || path.basename(process.cwd());
|
|
158
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`init: ecosystem name "${name}" must match /^[a-z][a-z0-9-]*$/ ` +
|
|
161
|
+
`(lowercase letters, digits, hyphens; start with a letter).`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// If [name] is given, scaffold under ./<name>/. Otherwise scaffold in CWD.
|
|
166
|
+
const cliGaveName = positionals[0] !== undefined;
|
|
167
|
+
const root = cliGaveName ? path.resolve(process.cwd(), name) : process.cwd();
|
|
168
|
+
|
|
169
|
+
if (fs.existsSync(path.join(root, lib.MANIFEST_FILENAME))) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`init: ${path.join(root, lib.MANIFEST_FILENAME)} already exists. ` +
|
|
172
|
+
`Refusing to overwrite.`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// BUG-021: an ecosystem root is a COORDINATION directory, not a code repo.
|
|
177
|
+
// Scaffolding into an existing project overwrites its README.md and
|
|
178
|
+
// .gitignore with ecosystem templates (observed live — destructive).
|
|
179
|
+
// Refuse when the target looks like an existing project; --force overrides.
|
|
180
|
+
if (!args.includes('--force')) {
|
|
181
|
+
const projectMarkers = [
|
|
182
|
+
['.momentum', 'a momentum project install (.momentum/)'],
|
|
183
|
+
['package.json', 'a package.json'],
|
|
184
|
+
['specs', 'a specs/ tree'],
|
|
185
|
+
['.git', 'a git repository'],
|
|
186
|
+
['README.md', 'an existing README.md'],
|
|
187
|
+
].filter(([marker]) => fs.existsSync(path.join(root, marker)));
|
|
188
|
+
if (projectMarkers.length > 0) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`init: ${root} looks like an existing project — it contains ` +
|
|
191
|
+
`${projectMarkers.map(([, label]) => label).join(', ')}.\n` +
|
|
192
|
+
` An ecosystem root must be its own directory (a SIBLING of member repos).\n` +
|
|
193
|
+
` Use: momentum ecosystem init <name> (creates ./<name>/)\n` +
|
|
194
|
+
` or: momentum init --ecosystem <name> (creates ../<name>/ and registers this repo)\n` +
|
|
195
|
+
` Pass --force only if you really want an ecosystem root here.`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
fs.mkdirSync(root, { recursive: true });
|
|
201
|
+
fs.mkdirSync(path.join(root, 'initiatives'), { recursive: true });
|
|
202
|
+
fs.mkdirSync(path.join(root, 'sessions'), { recursive: true });
|
|
203
|
+
fs.mkdirSync(path.join(root, '.state'), { recursive: true });
|
|
204
|
+
|
|
205
|
+
// ecosystem.json
|
|
206
|
+
const manifest = {
|
|
207
|
+
name,
|
|
208
|
+
version: 1,
|
|
209
|
+
created: today(),
|
|
210
|
+
members: [],
|
|
211
|
+
dependencies: [],
|
|
212
|
+
};
|
|
213
|
+
fs.writeFileSync(
|
|
214
|
+
path.join(root, lib.MANIFEST_FILENAME),
|
|
215
|
+
JSON.stringify(manifest, null, 2) + '\n',
|
|
216
|
+
'utf8',
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
// README
|
|
220
|
+
fs.writeFileSync(
|
|
221
|
+
path.join(root, 'README.md'),
|
|
222
|
+
readmeTemplate(name),
|
|
223
|
+
'utf8',
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
// initiatives/README
|
|
227
|
+
fs.writeFileSync(
|
|
228
|
+
path.join(root, 'initiatives', 'README.md'),
|
|
229
|
+
initiativesReadme(),
|
|
230
|
+
'utf8',
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
// sessions/.gitkeep + .state/.gitkeep
|
|
234
|
+
fs.writeFileSync(path.join(root, 'sessions', '.gitkeep'), '', 'utf8');
|
|
235
|
+
fs.writeFileSync(path.join(root, '.state', '.gitkeep'), '', 'utf8');
|
|
236
|
+
|
|
237
|
+
// .gitignore
|
|
238
|
+
fs.writeFileSync(
|
|
239
|
+
path.join(root, '.gitignore'),
|
|
240
|
+
gitignoreTemplate(),
|
|
241
|
+
'utf8',
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
// ENH-025: managed CLAUDE.md + AGENTS.md so any agent opening this
|
|
245
|
+
// directory immediately learns it is a coordination layer (NOT a
|
|
246
|
+
// project) and discovers the orchestration primitives. We write
|
|
247
|
+
// both because at ecosystem-init time we don't know which adapter
|
|
248
|
+
// the consuming agents will use; both surfaces are cheap. Idempotent:
|
|
249
|
+
// never overwrite if either file already exists.
|
|
250
|
+
writeManagedInstructionFile(
|
|
251
|
+
path.join(root, 'CLAUDE.md'),
|
|
252
|
+
renderEcosystemInstruction('ecosystem-claude.md', name),
|
|
253
|
+
);
|
|
254
|
+
writeManagedInstructionFile(
|
|
255
|
+
path.join(root, 'AGENTS.md'),
|
|
256
|
+
renderEcosystemInstruction('ecosystem-agents.md', name),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
// ENH-049: the command surface those instruction files advertise.
|
|
260
|
+
ensureRootCommandSurface(root, agent, false);
|
|
261
|
+
|
|
262
|
+
// git init + initial commit (best-effort; if git is unavailable the
|
|
263
|
+
// user can do it themselves).
|
|
264
|
+
try {
|
|
265
|
+
execSync('git init -q', { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
266
|
+
execSync('git add .', { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
267
|
+
// Allow commit to fail silently if user.email / user.name not set —
|
|
268
|
+
// the scaffold is still valid.
|
|
269
|
+
try {
|
|
270
|
+
execSync(
|
|
271
|
+
'git commit -q -m "chore(ecosystem): initial scaffold"',
|
|
272
|
+
{ cwd: root, stdio: ['ignore', 'pipe', 'pipe'] },
|
|
273
|
+
);
|
|
274
|
+
} catch (_e) {
|
|
275
|
+
// Likely missing git identity — user can commit manually.
|
|
276
|
+
}
|
|
277
|
+
} catch (_e) {
|
|
278
|
+
// git not available — scaffold is still usable.
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
console.log(`Initialized ecosystem "${name}" at ${root}`);
|
|
282
|
+
console.log(` + CLAUDE.md (managed; tells agents this is a coordination layer, not a project)`);
|
|
283
|
+
console.log(` + AGENTS.md (same content, for AGENTS.md-aware agents)`);
|
|
284
|
+
console.log(` + ${rootCommandDest(agent).join('/')}/ (coordination commands, agent: ${agent})`);
|
|
285
|
+
console.log(`Next steps:`);
|
|
286
|
+
console.log(` cd ${cliGaveName ? name : '.'}`);
|
|
287
|
+
console.log(` momentum ecosystem add ../<repo-name>`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Render an ecosystem CLAUDE.md / AGENTS.md template by substituting
|
|
292
|
+
* {{NAME}} for the ecosystem name. Templates live under
|
|
293
|
+
* `core/ecosystem/templates/`. Substitution is intentionally tiny —
|
|
294
|
+
* the templates are markdown content, not a programmable surface.
|
|
295
|
+
*/
|
|
296
|
+
function renderEcosystemInstruction(filename, ecosystemName) {
|
|
297
|
+
const src = path.join(
|
|
298
|
+
__dirname,
|
|
299
|
+
'..',
|
|
300
|
+
'core',
|
|
301
|
+
'ecosystem',
|
|
302
|
+
'templates',
|
|
303
|
+
filename,
|
|
304
|
+
);
|
|
305
|
+
const raw = fs.readFileSync(src, 'utf8');
|
|
306
|
+
return raw.replace(/\{\{NAME\}\}/g, ecosystemName);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Write a managed agent-instruction file. Skip if the file already
|
|
311
|
+
* exists — agents may have customised it (or another tool may own
|
|
312
|
+
* the surface). Idempotent: re-running init in a directory that already
|
|
313
|
+
* has CLAUDE.md / AGENTS.md leaves them untouched.
|
|
314
|
+
*/
|
|
315
|
+
function writeManagedInstructionFile(destPath, content) {
|
|
316
|
+
if (fs.existsSync(destPath)) return;
|
|
317
|
+
fs.writeFileSync(destPath, content, 'utf8');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Ensure the coordination root's own CLAUDE.md / AGENTS.md exist and carry
|
|
322
|
+
* the ecosystem instructions (BUG-016 self-heal; also retrofits pre-Phase-15
|
|
323
|
+
* roots that were scaffolded before ENH-025 wrote these files). Per file:
|
|
324
|
+
* - missing → write the rendered template
|
|
325
|
+
* - momentum-managed (ecosystem template, or the project template — the
|
|
326
|
+
* BUG-016 signature) → replace the managed section above the
|
|
327
|
+
* `## Project Extensions` marker, preserving user extensions; `.bak`
|
|
328
|
+
* saved first. A boilerplate-only extensions tail (heading + blockquote,
|
|
329
|
+
* no user lines) is swapped for the template's own tail.
|
|
330
|
+
* - anything else (user-owned surface) → leave untouched, warn
|
|
331
|
+
*/
|
|
332
|
+
function refreshRootInstructions(root, ecosystemName, dryRun) {
|
|
333
|
+
// Lazy require avoids a load-time cycle (momentum.js requires this module).
|
|
334
|
+
const { partitionByMarker } = require('./momentum');
|
|
335
|
+
const surfaces = [
|
|
336
|
+
{ file: 'CLAUDE.md', template: 'ecosystem-claude.md' },
|
|
337
|
+
{ file: 'AGENTS.md', template: 'ecosystem-agents.md' },
|
|
338
|
+
];
|
|
339
|
+
console.log('Coordination-root instructions:');
|
|
340
|
+
for (const s of surfaces) {
|
|
341
|
+
const dest = path.join(root, s.file);
|
|
342
|
+
const rendered = renderEcosystemInstruction(s.template, ecosystemName);
|
|
343
|
+
if (!fs.existsSync(dest)) {
|
|
344
|
+
if (dryRun) {
|
|
345
|
+
console.log(` ✋ would add: ${s.file} (ecosystem instructions)`);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
fs.writeFileSync(dest, rendered, 'utf8');
|
|
349
|
+
console.log(` + added: ${s.file} (ecosystem instructions)`);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
const existing = fs.readFileSync(dest, 'utf8');
|
|
353
|
+
if (existing === rendered) {
|
|
354
|
+
console.log(` = ${s.file} up to date`);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
const firstLine = existing.split('\n', 1)[0];
|
|
358
|
+
const isEcosystemManaged = /— Ecosystem \(Coordination Layer\)\s*$/.test(firstLine);
|
|
359
|
+
const isProjectTemplate = /^# Project Rules:/.test(firstLine);
|
|
360
|
+
if (!isEcosystemManaged && !isProjectTemplate) {
|
|
361
|
+
console.log(` ⚠️ ${s.file} exists but is not momentum-managed — left untouched.`);
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
const renderedParts = partitionByMarker(rendered);
|
|
365
|
+
const existingParts = partitionByMarker(existing);
|
|
366
|
+
const tail = hasExtensionContent(existingParts.extensions)
|
|
367
|
+
? existingParts.extensions
|
|
368
|
+
: renderedParts.extensions;
|
|
369
|
+
const next = renderedParts.managed + (tail || '');
|
|
370
|
+
if (next === existing) {
|
|
371
|
+
console.log(` = ${s.file} up to date`);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const isRepair = isProjectTemplate;
|
|
375
|
+
const why = isRepair
|
|
376
|
+
? ' — project template found in a coordination root (BUG-016)'
|
|
377
|
+
: '';
|
|
378
|
+
if (dryRun) {
|
|
379
|
+
console.log(` ✋ would ${isRepair ? 'repair' : 'refresh'}: ${s.file}${why}`);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
fs.copyFileSync(dest, dest + '.bak');
|
|
383
|
+
fs.writeFileSync(dest, next, 'utf8');
|
|
384
|
+
console.log(
|
|
385
|
+
` ↻ ${isRepair ? 'repaired' : 'refreshed'}: ${s.file}${why} (original saved as .bak)`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* True when an extensions tail (as returned by partitionByMarker) carries
|
|
392
|
+
* user content — any non-blank line that isn't the marker heading itself,
|
|
393
|
+
* a `>` blockquote line, or a horizontal rule.
|
|
394
|
+
*/
|
|
395
|
+
function hasExtensionContent(extensions) {
|
|
396
|
+
if (!extensions) return false;
|
|
397
|
+
return extensions.split('\n').some((line) => {
|
|
398
|
+
const t = line.trim();
|
|
399
|
+
return (
|
|
400
|
+
t !== '' && !t.startsWith('>') && !t.startsWith('## Project Extensions') && t !== '---'
|
|
401
|
+
);
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ── Coordination-root command surface (ENH-049) ──────────────────────────────
|
|
406
|
+
//
|
|
407
|
+
// The ecosystem CLAUDE.md/AGENTS.md advertise slash-command primitives
|
|
408
|
+
// (/scout /dispatch /handoff /continue /initiative /session plus the
|
|
409
|
+
// /ecosystem and /swarm operator doors). Before ENH-049 nothing shipped
|
|
410
|
+
// them: a fresh coordination root promised commands that did not resolve
|
|
411
|
+
// (the cerebrio root only had a working surface by accident of the BUG-016
|
|
412
|
+
// project-mode mis-install). `ecosystem init` installs the curated fileset
|
|
413
|
+
// below; `ecosystem upgrade` retrofits/refreshes it. Strictly the
|
|
414
|
+
// coordination layer — project/phase commands are the BUG-016 anti-pattern:
|
|
415
|
+
// warned about, never shipped, never deleted.
|
|
416
|
+
|
|
417
|
+
const ROOT_SURFACE_COMMANDS = [
|
|
418
|
+
// adapter-overlay recipes (per-agent copies under adapters/<agent>/)
|
|
419
|
+
'scout.md', 'dispatch.md', 'handoff.md', 'continue.md', 'swarm.md',
|
|
420
|
+
// agent-neutral recipes (core/commands/)
|
|
421
|
+
'ecosystem.md', 'initiative.md', 'session.md',
|
|
422
|
+
];
|
|
423
|
+
|
|
424
|
+
const ROOT_SURFACE_SCRIPTS = [
|
|
425
|
+
['core', 'scripts', 'sessionstart-handoff.sh'],
|
|
426
|
+
['core', 'ecosystem', 'scripts', 'session-append.sh'],
|
|
427
|
+
];
|
|
428
|
+
|
|
429
|
+
// Project-layer commands that must not live in a coordination root.
|
|
430
|
+
const PROJECT_LAYER_COMMANDS = new Set([
|
|
431
|
+
'brainstorm-idea.md', 'brainstorm-phase.md', 'start-project.md',
|
|
432
|
+
'start-phase.md', 'complete-phase.md', 'sync-docs.md', 'hotfix.md',
|
|
433
|
+
'track.md', 'validate.md', 'log.md', 'migrate.md', 'review.md',
|
|
434
|
+
'review-code.md', 'systematic-debug.md', 'lanes.md',
|
|
435
|
+
]);
|
|
436
|
+
|
|
437
|
+
function listRootAgents() {
|
|
438
|
+
const dir = path.join(__dirname, '..', 'adapters');
|
|
439
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
440
|
+
.filter((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'adapter.js')))
|
|
441
|
+
.map((e) => e.name);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Commands destination for an agent, e.g. ['.claude','commands']. */
|
|
445
|
+
function rootCommandDest(agent) {
|
|
446
|
+
const adapterPath = path.join(__dirname, '..', 'adapters', agent, 'adapter.js');
|
|
447
|
+
if (!fs.existsSync(adapterPath)) {
|
|
448
|
+
throw new Error(
|
|
449
|
+
`unknown agent "${agent}" (available: ${listRootAgents().join(', ')})`,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
const adapter = require(adapterPath);
|
|
453
|
+
return (adapter.destinations && adapter.destinations.commands) || ['.claude', 'commands'];
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Recipe source: adapter overlay first (commands/ or workflows/), then core. */
|
|
457
|
+
function resolveRootCommandSource(agent, file) {
|
|
458
|
+
const src = path.join(__dirname, '..');
|
|
459
|
+
const candidates = [
|
|
460
|
+
path.join(src, 'adapters', agent, 'commands', file),
|
|
461
|
+
path.join(src, 'adapters', agent, 'workflows', file), // Antigravity ships recipes as workflows
|
|
462
|
+
path.join(src, 'core', 'commands', file),
|
|
463
|
+
];
|
|
464
|
+
for (const c of candidates) if (fs.existsSync(c)) return c;
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Agents whose command surface already exists in the root (for upgrade). */
|
|
469
|
+
function detectRootAgents(root) {
|
|
470
|
+
return listRootAgents().filter((agent) =>
|
|
471
|
+
fs.existsSync(path.join(root, ...rootCommandDest(agent))),
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Install/refresh the coordination-root command surface for one agent:
|
|
477
|
+
* add when missing; refresh differing momentum-owned files with a `.bak`
|
|
478
|
+
* (house upgrade semantics); count identical files as up to date. The
|
|
479
|
+
* Claude Code hook wiring (`.claude/settings.json`) is written only when
|
|
480
|
+
* absent — settings are user-owned once they exist.
|
|
481
|
+
*/
|
|
482
|
+
function ensureRootCommandSurface(root, agent, dryRun) {
|
|
483
|
+
const destRel = rootCommandDest(agent);
|
|
484
|
+
const destDir = path.join(root, ...destRel);
|
|
485
|
+
let upToDate = 0;
|
|
486
|
+
|
|
487
|
+
const place = (srcContent, destPath, { exec = false } = {}) => {
|
|
488
|
+
const rel = path.relative(root, destPath);
|
|
489
|
+
if (!fs.existsSync(destPath)) {
|
|
490
|
+
if (dryRun) {
|
|
491
|
+
console.log(` ✋ would add: ${rel}`);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
495
|
+
fs.writeFileSync(destPath, srcContent);
|
|
496
|
+
if (exec) fs.chmodSync(destPath, 0o755);
|
|
497
|
+
console.log(` + added: ${rel}`);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (fs.readFileSync(destPath, 'utf8') === srcContent) {
|
|
501
|
+
if (exec) {
|
|
502
|
+
try { fs.chmodSync(destPath, 0o755); } catch (_e) { /* best-effort */ }
|
|
503
|
+
}
|
|
504
|
+
upToDate++;
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (dryRun) {
|
|
508
|
+
console.log(` ✋ would refresh: ${rel}`);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
fs.copyFileSync(destPath, destPath + '.bak');
|
|
512
|
+
fs.writeFileSync(destPath, srcContent);
|
|
513
|
+
if (exec) fs.chmodSync(destPath, 0o755);
|
|
514
|
+
console.log(` ↑ refreshed: ${rel} (original saved as .bak)`);
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
for (const file of ROOT_SURFACE_COMMANDS) {
|
|
518
|
+
const srcPath = resolveRootCommandSource(agent, file);
|
|
519
|
+
if (!srcPath) {
|
|
520
|
+
console.log(` ⚠️ no source for ${file} (agent: ${agent}) — skipped.`);
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
place(fs.readFileSync(srcPath, 'utf8'), path.join(destDir, file));
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
for (const relParts of ROOT_SURFACE_SCRIPTS) {
|
|
527
|
+
const srcPath = path.join(__dirname, '..', ...relParts);
|
|
528
|
+
place(fs.readFileSync(srcPath, 'utf8'), path.join(root, 'scripts', relParts[relParts.length - 1]), { exec: true });
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (agent === 'claude-code') {
|
|
532
|
+
const settingsPath = path.join(root, '.claude', 'settings.json');
|
|
533
|
+
if (!fs.existsSync(settingsPath)) {
|
|
534
|
+
if (dryRun) {
|
|
535
|
+
console.log(` ✋ would add: .claude/settings.json (SessionStart hook wiring)`);
|
|
536
|
+
} else {
|
|
537
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
538
|
+
fs.writeFileSync(
|
|
539
|
+
settingsPath,
|
|
540
|
+
renderEcosystemInstruction('ecosystem-settings-claude.json', ''),
|
|
541
|
+
);
|
|
542
|
+
console.log(` + added: .claude/settings.json (SessionStart hook wiring)`);
|
|
543
|
+
}
|
|
544
|
+
} else {
|
|
545
|
+
upToDate++; // user-owned once present — never rewritten
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (upToDate > 0) {
|
|
550
|
+
console.log(` = ${destRel.join('/')} [${agent}]: ${upToDate} file(s) up to date`);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const offenders = fs.existsSync(destDir)
|
|
554
|
+
? fs.readdirSync(destDir).filter((f) => PROJECT_LAYER_COMMANDS.has(f))
|
|
555
|
+
: [];
|
|
556
|
+
if (offenders.length) {
|
|
557
|
+
console.log(
|
|
558
|
+
` ⚠️ project-layer commands in the coordination root (BUG-016 anti-pattern — ` +
|
|
559
|
+
`remove them; they mis-orient agents): ${offenders.join(', ')}`,
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
565
|
+
// add
|
|
566
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
567
|
+
|
|
568
|
+
function cmdAdd(args) {
|
|
569
|
+
if (args.length === 0) {
|
|
570
|
+
throw new Error('add: missing <repo-path> argument.');
|
|
571
|
+
}
|
|
572
|
+
const repoPath = args[0];
|
|
573
|
+
const opts = parseFlags(args.slice(1), {
|
|
574
|
+
role: 'string',
|
|
575
|
+
id: 'string',
|
|
576
|
+
ecosystem: 'string',
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
const root = resolveEcosystemRoot(opts.ecosystem, 'add');
|
|
580
|
+
|
|
581
|
+
const absRepo = path.resolve(root, repoPath);
|
|
582
|
+
if (!fs.existsSync(absRepo)) {
|
|
583
|
+
throw new Error(`add: repo path does not exist: ${absRepo}`);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Pre-flight: target must look momentum-installed.
|
|
587
|
+
const primary = findPrimaryInstructionFile(absRepo);
|
|
588
|
+
if (!primary) {
|
|
589
|
+
throw new Error(
|
|
590
|
+
`add: ${absRepo} doesn't look momentum-installed ` +
|
|
591
|
+
`(no CLAUDE.md or AGENTS.md found). Run \`momentum init\` there first.`,
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const id = opts.id || sanitizeId(path.basename(absRepo));
|
|
596
|
+
if (!/^[a-z][a-z0-9-]*$/.test(id)) {
|
|
597
|
+
throw new Error(
|
|
598
|
+
`add: derived/explicit id "${id}" must match /^[a-z][a-z0-9-]*$/. ` +
|
|
599
|
+
`Use --id to override.`,
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
const role = opts.role || 'other';
|
|
603
|
+
const validRoles = ['platform', 'client', 'library', 'infra', 'bench', 'other'];
|
|
604
|
+
if (!validRoles.includes(role)) {
|
|
605
|
+
throw new Error(
|
|
606
|
+
`add: invalid --role "${role}". Must be one of: ${validRoles.join(', ')}`,
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const relPath = path.relative(root, absRepo) || '.';
|
|
611
|
+
|
|
612
|
+
// Mutate manifest.
|
|
613
|
+
const manifest = lib.loadManifest(root);
|
|
614
|
+
const existing = lib.findMember(manifest, id);
|
|
615
|
+
if (existing) {
|
|
616
|
+
// Idempotent: if the path matches, do nothing. If it differs, fail loud.
|
|
617
|
+
if (existing.path === relPath) {
|
|
618
|
+
console.log(`add: "${id}" is already registered (path ${relPath}). No changes.`);
|
|
619
|
+
ensurePointerInjected(absRepo, primary, root, manifest.name);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
throw new Error(
|
|
623
|
+
`add: member id "${id}" already registered with a different path ` +
|
|
624
|
+
`(${existing.path}). Use --id to register under a different id, ` +
|
|
625
|
+
`or \`remove ${id}\` first.`,
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
manifest.members.push({ id, path: relPath, role });
|
|
629
|
+
|
|
630
|
+
const v = lib.validateManifest(manifest);
|
|
631
|
+
if (!v.ok) {
|
|
632
|
+
throw new Error(
|
|
633
|
+
`add: resulting manifest fails validation:\n` +
|
|
634
|
+
v.errors.map((e) => ` ${e.path}: ${e.message}`).join('\n'),
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
fs.writeFileSync(
|
|
639
|
+
path.join(root, lib.MANIFEST_FILENAME),
|
|
640
|
+
JSON.stringify(manifest, null, 2) + '\n',
|
|
641
|
+
'utf8',
|
|
642
|
+
);
|
|
643
|
+
|
|
644
|
+
// Inject the fenced pointer into the target's primary instruction.
|
|
645
|
+
ensurePointerInjected(absRepo, primary, root, manifest.name);
|
|
646
|
+
|
|
647
|
+
console.log(`Added member "${id}" (${role}) at ${relPath}.`);
|
|
648
|
+
console.log(`Pointer injected into ${path.relative(root, path.join(absRepo, primary))}.`);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
652
|
+
// remove
|
|
653
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
654
|
+
|
|
655
|
+
function cmdRemove(args) {
|
|
656
|
+
if (args.length === 0) {
|
|
657
|
+
throw new Error('remove: missing <member-id> argument.');
|
|
658
|
+
}
|
|
659
|
+
const opts = parseFlags(args.slice(1), { ecosystem: 'string' });
|
|
660
|
+
const id = args[0];
|
|
661
|
+
|
|
662
|
+
const root = resolveEcosystemRoot(opts.ecosystem, 'remove');
|
|
663
|
+
|
|
664
|
+
const manifest = lib.loadManifest(root);
|
|
665
|
+
const member = lib.findMember(manifest, id);
|
|
666
|
+
if (!member) {
|
|
667
|
+
throw new Error(`remove: no member with id "${id}".`);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Strip from manifest.
|
|
671
|
+
manifest.members = manifest.members.filter((m) => m.id !== id);
|
|
672
|
+
// Also strip any dependency edges referencing the removed member.
|
|
673
|
+
if (Array.isArray(manifest.dependencies)) {
|
|
674
|
+
manifest.dependencies = manifest.dependencies.filter(
|
|
675
|
+
(d) => d.from !== id && d.to !== id,
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
fs.writeFileSync(
|
|
679
|
+
path.join(root, lib.MANIFEST_FILENAME),
|
|
680
|
+
JSON.stringify(manifest, null, 2) + '\n',
|
|
681
|
+
'utf8',
|
|
682
|
+
);
|
|
683
|
+
|
|
684
|
+
// Best-effort: strip the fenced pointer from the target's CLAUDE.md /
|
|
685
|
+
// AGENTS.md. If the target no longer exists on disk we skip silently —
|
|
686
|
+
// remove must succeed even when the member repo is gone.
|
|
687
|
+
const absRepo = path.resolve(root, member.path);
|
|
688
|
+
if (fs.existsSync(absRepo)) {
|
|
689
|
+
const primary = findPrimaryInstructionFile(absRepo);
|
|
690
|
+
if (primary) {
|
|
691
|
+
stripPointer(path.join(absRepo, primary));
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
console.log(`Removed member "${id}".`);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
699
|
+
// status
|
|
700
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
701
|
+
|
|
702
|
+
function cmdStatus(args) {
|
|
703
|
+
const opts = parseFlags(args, {
|
|
704
|
+
'no-git': 'boolean',
|
|
705
|
+
ecosystem: 'string',
|
|
706
|
+
});
|
|
707
|
+
const root = resolveEcosystemRoot(opts.ecosystem, 'status');
|
|
708
|
+
const manifest = lib.loadManifest(root);
|
|
709
|
+
|
|
710
|
+
console.log(`Ecosystem: ${manifest.name} (root: ${root})`);
|
|
711
|
+
console.log(`Members: ${manifest.members.length}`);
|
|
712
|
+
if (manifest.members.length === 0) {
|
|
713
|
+
console.log(' (none registered yet — try `momentum ecosystem add ../<repo>`)');
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
for (const m of manifest.members) {
|
|
718
|
+
const absRepo = path.resolve(root, m.path);
|
|
719
|
+
const exists = fs.existsSync(absRepo);
|
|
720
|
+
console.log('');
|
|
721
|
+
console.log(` ${m.id} [${m.role}] ${m.path}${exists ? '' : ' (MISSING)'}`);
|
|
722
|
+
if (exists && !opts['no-git']) {
|
|
723
|
+
printGitState(absRepo);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Print active initiative if any.
|
|
728
|
+
const activeFile = path.join(root, '.state', 'active-initiative');
|
|
729
|
+
if (fs.existsSync(activeFile)) {
|
|
730
|
+
const slug = fs.readFileSync(activeFile, 'utf8').trim();
|
|
731
|
+
if (slug) {
|
|
732
|
+
console.log('');
|
|
733
|
+
console.log(`Active initiative: ${slug}`);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
739
|
+
// upgrade — PULL-model fleet sweep (Phase 20)
|
|
740
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
741
|
+
//
|
|
742
|
+
// Runs `momentum upgrade` across every ecosystem member in one pass. Chosen
|
|
743
|
+
// over a PUSH-model bot (Renovate/Dependabot) because momentum is forge-neutral
|
|
744
|
+
// (DIP) and operates on local git checkouts. Fleet-safety: clean-tree gate per
|
|
745
|
+
// repo, partial-failure tolerance, per-repo version reporting, dry-run.
|
|
746
|
+
|
|
747
|
+
/** True when the repo has uncommitted changes. Non-git repos count as clean. */
|
|
748
|
+
function isWorkingTreeDirty(repoPath) {
|
|
749
|
+
try {
|
|
750
|
+
const out = execSync('git status --porcelain', {
|
|
751
|
+
cwd: repoPath,
|
|
752
|
+
encoding: 'utf8',
|
|
753
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
754
|
+
}).trim();
|
|
755
|
+
return out.length > 0;
|
|
756
|
+
} catch (_e) {
|
|
757
|
+
return false; // not a git repo — nothing to protect, let the upgrade proceed
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** Read the recorded momentum version from a member's lock file. */
|
|
762
|
+
function readMemberVersion(repoPath) {
|
|
763
|
+
try {
|
|
764
|
+
const m = JSON.parse(
|
|
765
|
+
fs.readFileSync(path.join(repoPath, '.momentum', 'installed.json'), 'utf8'),
|
|
766
|
+
);
|
|
767
|
+
return (m && (m.momentumVersion || m.version)) || 'unknown'; // ADR-0007: new schema uses `version`; momentumVersion kept as compat mirror
|
|
768
|
+
} catch (_e) {
|
|
769
|
+
return 'none'; // pre-Phase-20 install (no lock file)
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** Detect which adapter a member uses. Lock file is authoritative; else heuristic. */
|
|
774
|
+
function detectMemberAgent(repoPath) {
|
|
775
|
+
try {
|
|
776
|
+
const m = JSON.parse(
|
|
777
|
+
fs.readFileSync(path.join(repoPath, '.momentum', 'installed.json'), 'utf8'),
|
|
778
|
+
);
|
|
779
|
+
if (m && m.agent) return m.agent;
|
|
780
|
+
} catch (_e) { /* fall through to heuristic */ }
|
|
781
|
+
if (fs.existsSync(path.join(repoPath, '.codex'))) return 'codex';
|
|
782
|
+
if (fs.existsSync(path.join(repoPath, '.claude'))) return 'claude-code';
|
|
783
|
+
if (
|
|
784
|
+
fs.existsSync(path.join(repoPath, '.agents', 'hooks.json')) ||
|
|
785
|
+
fs.existsSync(path.join(repoPath, '.agent', 'workflows'))
|
|
786
|
+
) {
|
|
787
|
+
return 'antigravity';
|
|
788
|
+
}
|
|
789
|
+
if (fs.existsSync(path.join(repoPath, 'CLAUDE.md'))) return 'claude-code';
|
|
790
|
+
if (fs.existsSync(path.join(repoPath, 'AGENTS.md'))) return 'codex';
|
|
791
|
+
return null;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function cmdUpgrade(args) {
|
|
795
|
+
const opts = parseFlags(args, {
|
|
796
|
+
ecosystem: 'string',
|
|
797
|
+
'dry-run': 'boolean',
|
|
798
|
+
force: 'boolean',
|
|
799
|
+
autostash: 'boolean',
|
|
800
|
+
agent: 'string',
|
|
801
|
+
});
|
|
802
|
+
const dryRun = !!opts['dry-run'];
|
|
803
|
+
const root = resolveEcosystemRoot(opts.ecosystem, 'upgrade');
|
|
804
|
+
const manifest = lib.loadManifest(root);
|
|
805
|
+
|
|
806
|
+
console.log(`Ecosystem: ${manifest.name} (root: ${root})`);
|
|
807
|
+
|
|
808
|
+
// BUG-016: the root's own instruction surfaces are upgraded here — the
|
|
809
|
+
// project-mode `momentum upgrade` refuses to run in a coordination root,
|
|
810
|
+
// so this is the only refresh/retrofit path they have.
|
|
811
|
+
refreshRootInstructions(root, manifest.name, dryRun);
|
|
812
|
+
|
|
813
|
+
// ENH-049: the command surface those instructions advertise. Manage every
|
|
814
|
+
// agent surface already present in the root; retrofit the default (or
|
|
815
|
+
// --agent) surface when none exists yet (pre-ENH-049 roots).
|
|
816
|
+
const rootAgents = detectRootAgents(root);
|
|
817
|
+
const surfaceAgents = rootAgents.length ? rootAgents : [opts.agent || 'claude-code'];
|
|
818
|
+
console.log('Coordination-root command surface:');
|
|
819
|
+
for (const agent of surfaceAgents) {
|
|
820
|
+
ensureRootCommandSurface(root, agent, dryRun);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
console.log('');
|
|
824
|
+
console.log(`Sweeping \`momentum upgrade\` across ${manifest.members.length} member(s)${dryRun ? ' [dry run — no writes]' : ''}`);
|
|
825
|
+
if (manifest.members.length === 0) {
|
|
826
|
+
console.log(' (none registered yet — try `momentum ecosystem add ../<repo>`)');
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Lazy require avoids a load-time cycle (momentum.js requires this module).
|
|
831
|
+
const { upgrade, withAutostash } = require('./momentum');
|
|
832
|
+
const results = [];
|
|
833
|
+
|
|
834
|
+
for (const m of manifest.members) {
|
|
835
|
+
const absRepo = path.resolve(root, m.path);
|
|
836
|
+
console.log('');
|
|
837
|
+
console.log(`── ${m.id} [${m.role}] ${m.path} ─────────────────────`);
|
|
838
|
+
|
|
839
|
+
if (!fs.existsSync(absRepo)) {
|
|
840
|
+
console.log(' ⚠️ missing on disk — skipped.');
|
|
841
|
+
results.push({ id: m.id, status: 'missing' });
|
|
842
|
+
continue;
|
|
843
|
+
}
|
|
844
|
+
const dirty = isWorkingTreeDirty(absRepo);
|
|
845
|
+
// Dirty repos are skipped UNLESS --force (plow through) or --autostash
|
|
846
|
+
// (stash → upgrade → restore). Both are explicit opt-ins.
|
|
847
|
+
if (dirty && !opts.force && !opts.autostash) {
|
|
848
|
+
console.log(' ⚠️ working tree not clean — skipped. Commit/stash, --force, or --autostash.');
|
|
849
|
+
results.push({ id: m.id, status: 'dirty-skip' });
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
const agent = opts.agent || detectMemberAgent(absRepo);
|
|
853
|
+
if (!agent) {
|
|
854
|
+
console.log(' ⚠️ could not detect adapter — skipped. Re-run with --agent <name>.');
|
|
855
|
+
results.push({ id: m.id, status: 'no-agent' });
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const before = readMemberVersion(absRepo);
|
|
860
|
+
const useAutostash = opts.autostash && dirty && !dryRun;
|
|
861
|
+
try {
|
|
862
|
+
let conflict = false;
|
|
863
|
+
if (useAutostash) {
|
|
864
|
+
const r = withAutostash(absRepo, () => upgrade(absRepo, agent, { dryRun }));
|
|
865
|
+
conflict = r.conflict;
|
|
866
|
+
} else {
|
|
867
|
+
upgrade(absRepo, agent, { dryRun });
|
|
868
|
+
}
|
|
869
|
+
const after = dryRun ? before : readMemberVersion(absRepo);
|
|
870
|
+
results.push({
|
|
871
|
+
id: m.id,
|
|
872
|
+
status: dryRun ? 'would-upgrade' : 'upgraded',
|
|
873
|
+
agent,
|
|
874
|
+
before,
|
|
875
|
+
after,
|
|
876
|
+
autostashed: useAutostash,
|
|
877
|
+
conflict,
|
|
878
|
+
});
|
|
879
|
+
} catch (err) {
|
|
880
|
+
// Partial-failure tolerance — one bad repo never aborts the fleet.
|
|
881
|
+
console.log(` ❌ upgrade failed: ${err.message}`);
|
|
882
|
+
results.push({ id: m.id, status: 'failed', error: err.message });
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
printSweepSummary(results, dryRun);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function printSweepSummary(results, dryRun) {
|
|
890
|
+
const icon = {
|
|
891
|
+
upgraded: '✓', 'would-upgrade': '✋', 'dirty-skip': '⊘', missing: '∅',
|
|
892
|
+
'no-agent': '?', failed: '✗',
|
|
893
|
+
};
|
|
894
|
+
console.log('');
|
|
895
|
+
console.log('── Sweep summary ───────────────────────────────');
|
|
896
|
+
for (const r of results) {
|
|
897
|
+
const ver = r.before ? ` ${r.before} → ${r.after}` : '';
|
|
898
|
+
const ag = r.agent ? ` [${r.agent}]` : '';
|
|
899
|
+
const tag = r.conflict ? ' (autostash CONFLICT — work safe in stash)'
|
|
900
|
+
: r.autostashed ? ' (autostashed)' : '';
|
|
901
|
+
const err = r.status === 'failed' && r.error ? ` — ${r.error}` : '';
|
|
902
|
+
console.log(` ${icon[r.status] || '•'} ${r.id}: ${r.status}${ver}${ag}${tag}${err}`);
|
|
903
|
+
}
|
|
904
|
+
const counts = {};
|
|
905
|
+
for (const r of results) counts[r.status] = (counts[r.status] || 0) + 1;
|
|
906
|
+
console.log('');
|
|
907
|
+
console.log(' ' + Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(', '));
|
|
908
|
+
if (dryRun) console.log(' (dry run — no files were written)');
|
|
909
|
+
const conflicts = results.filter((r) => r.conflict);
|
|
910
|
+
if (conflicts.length) {
|
|
911
|
+
console.log('');
|
|
912
|
+
console.log(` ⚠️ ${conflicts.length} repo(s) had an autostash conflict — your work is`);
|
|
913
|
+
console.log(' preserved in each repo\'s `git stash list`; resolve with `git stash pop`.');
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
918
|
+
// initiative
|
|
919
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
920
|
+
//
|
|
921
|
+
// ENH-035 — promised "coming with Group 2 of Phase 9" and never shipped
|
|
922
|
+
// until Phase 15. Wires the existing core/ecosystem/lib/initiative.js
|
|
923
|
+
// (nextInitiativeId / writeInitiative / setActive) to a CLI subcommand.
|
|
924
|
+
// Non-interactive (flag-driven) so it works from any agent context.
|
|
925
|
+
|
|
926
|
+
function cmdInitiative(args) {
|
|
927
|
+
if (args.length === 0) {
|
|
928
|
+
throw new Error(
|
|
929
|
+
'initiative: missing subsubcommand. Try: ' +
|
|
930
|
+
'`momentum ecosystem initiative create <slug>`',
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
const sub = args[0];
|
|
934
|
+
const rest = args.slice(1);
|
|
935
|
+
switch (sub) {
|
|
936
|
+
case 'create':
|
|
937
|
+
return cmdInitiativeCreate(rest);
|
|
938
|
+
default:
|
|
939
|
+
throw new Error(
|
|
940
|
+
`initiative: unknown subsubcommand "${sub}". ` +
|
|
941
|
+
`Only \`create\` is currently wired as a CLI; ` +
|
|
942
|
+
`\`list\` / \`status\` / \`close\` stay as slash-only for now.`,
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function cmdInitiativeCreate(args) {
|
|
948
|
+
if (args.length === 0 || args[0].startsWith('--')) {
|
|
949
|
+
throw new Error(
|
|
950
|
+
'initiative create: missing <slug> positional argument. ' +
|
|
951
|
+
'Usage: momentum ecosystem initiative create <slug> ' +
|
|
952
|
+
'[--why "<text>"] [--repos r1,r2] [--owner <name>]',
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
const slug = args[0];
|
|
956
|
+
if (!/^[a-z][a-z0-9-]*$/.test(slug)) {
|
|
957
|
+
throw new Error(
|
|
958
|
+
`initiative create: slug "${slug}" must match /^[a-z][a-z0-9-]*$/ ` +
|
|
959
|
+
`(lowercase letters, digits, hyphens; start with a letter).`,
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
const opts = parseFlags(args.slice(1), {
|
|
963
|
+
why: 'string',
|
|
964
|
+
repos: 'string',
|
|
965
|
+
owner: 'string',
|
|
966
|
+
ecosystem: 'string',
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
const root = resolveEcosystemRoot(opts.ecosystem, 'initiative create');
|
|
970
|
+
const manifest = lib.loadManifest(root);
|
|
971
|
+
|
|
972
|
+
// Default repos to all member ids; parse the --repos CSV if given.
|
|
973
|
+
let reposList;
|
|
974
|
+
if (opts.repos) {
|
|
975
|
+
reposList = opts.repos
|
|
976
|
+
.split(',')
|
|
977
|
+
.map((s) => s.trim())
|
|
978
|
+
.filter(Boolean);
|
|
979
|
+
if (reposList.length === 0) {
|
|
980
|
+
throw new Error(
|
|
981
|
+
'initiative create: --repos was provided but empty after parsing.',
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
// Validate every named repo is a registered member.
|
|
985
|
+
const known = new Set(manifest.members.map((m) => m.id));
|
|
986
|
+
const unknown = reposList.filter((r) => !known.has(r));
|
|
987
|
+
if (unknown.length) {
|
|
988
|
+
throw new Error(
|
|
989
|
+
`initiative create: unknown member id(s): ${unknown.join(', ')}. ` +
|
|
990
|
+
`Known: ${[...known].join(', ') || '(none)'}.`,
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
} else {
|
|
994
|
+
reposList = manifest.members.map((m) => m.id);
|
|
995
|
+
if (reposList.length === 0) {
|
|
996
|
+
throw new Error(
|
|
997
|
+
'initiative create: ecosystem has no registered members yet. ' +
|
|
998
|
+
'`--repos` defaults to all members but the manifest is empty. ' +
|
|
999
|
+
'Register members via `momentum ecosystem add <repo>` first, ' +
|
|
1000
|
+
'or pass --repos explicitly.',
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// Default owner: explicit > git user.name > $USER > "(unknown)".
|
|
1006
|
+
let owner = opts.owner;
|
|
1007
|
+
if (!owner) {
|
|
1008
|
+
try {
|
|
1009
|
+
owner = execSync('git config user.name', {
|
|
1010
|
+
cwd: root,
|
|
1011
|
+
encoding: 'utf8',
|
|
1012
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1013
|
+
}).trim();
|
|
1014
|
+
} catch (_e) {
|
|
1015
|
+
// git not configured — fall through.
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
if (!owner) owner = process.env.USER || '(unknown)';
|
|
1019
|
+
|
|
1020
|
+
const why = (opts.why || '').trim();
|
|
1021
|
+
|
|
1022
|
+
// Allocate next id, render the file from template, write it.
|
|
1023
|
+
const initLib = require('../core/ecosystem/lib/initiative');
|
|
1024
|
+
const id = initLib.nextInitiativeId(root);
|
|
1025
|
+
const filePath = initLib.initiativePath(root, id, slug);
|
|
1026
|
+
|
|
1027
|
+
const templateSrc = path.join(
|
|
1028
|
+
__dirname,
|
|
1029
|
+
'..',
|
|
1030
|
+
'core',
|
|
1031
|
+
'ecosystem',
|
|
1032
|
+
'templates',
|
|
1033
|
+
'initiative-template.md',
|
|
1034
|
+
);
|
|
1035
|
+
const rawTemplate = fs.readFileSync(templateSrc, 'utf8');
|
|
1036
|
+
|
|
1037
|
+
// Discard the template's stub frontmatter; we generate our own. Keep
|
|
1038
|
+
// the body (## Why, ## Per-repo contributions, etc).
|
|
1039
|
+
const { content: bodyTemplate } = initLib.parseFrontmatter(rawTemplate);
|
|
1040
|
+
|
|
1041
|
+
// Substitute the few placeholders the template body carries.
|
|
1042
|
+
const title = slugToTitle(slug);
|
|
1043
|
+
let body = bodyTemplate
|
|
1044
|
+
.replace(/\{\{ID\}\}/g, String(id))
|
|
1045
|
+
.replace(/\{\{TITLE\}\}/g, title)
|
|
1046
|
+
.replace(/\{\{REPOS\}\}/g, reposList.join(', '))
|
|
1047
|
+
.replace(/\{\{STARTED\}\}/g, today())
|
|
1048
|
+
.replace(/\{\{OWNER\}\}/g, owner);
|
|
1049
|
+
|
|
1050
|
+
// Inject the user-provided "Why" if any (replacing the placeholder
|
|
1051
|
+
// paragraph in the template). The template's "Why" section reads
|
|
1052
|
+
// "One short paragraph that captures the motivation…" — when the
|
|
1053
|
+
// user passes --why we replace that placeholder; otherwise we leave
|
|
1054
|
+
// the template's prompt in place so they know what to fill in later.
|
|
1055
|
+
if (why) {
|
|
1056
|
+
body = body.replace(
|
|
1057
|
+
/(## Why\n\n)One short paragraph that captures the motivation for this initiative\.\nWhat problem are we solving\? Why does it span multiple repos\? What\nbecomes possible once it ships\?/,
|
|
1058
|
+
`$1${why}`,
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// Expand the per-repo contributions stubs.
|
|
1063
|
+
body = body.replace(
|
|
1064
|
+
/- \*\*\{\{REPO_1\}\}\*\*: …\n- \*\*\{\{REPO_2\}\}\*\*: …/,
|
|
1065
|
+
reposList.map((r) => `- **${r}**: …`).join('\n'),
|
|
1066
|
+
);
|
|
1067
|
+
|
|
1068
|
+
const frontmatter = {
|
|
1069
|
+
id,
|
|
1070
|
+
slug,
|
|
1071
|
+
title,
|
|
1072
|
+
status: 'in-progress',
|
|
1073
|
+
started: today(),
|
|
1074
|
+
owner,
|
|
1075
|
+
repos: reposList,
|
|
1076
|
+
};
|
|
1077
|
+
|
|
1078
|
+
initLib.writeInitiative(filePath, frontmatter, body);
|
|
1079
|
+
initLib.setActive(root, slug);
|
|
1080
|
+
|
|
1081
|
+
console.log(`Created ${path.relative(root, filePath)} (id ${id}).`);
|
|
1082
|
+
console.log(`Set as active initiative.`);
|
|
1083
|
+
if (!why) {
|
|
1084
|
+
console.log(
|
|
1085
|
+
`Note: --why was not provided — the template "Why" section is a ` +
|
|
1086
|
+
`placeholder. Edit ${path.relative(root, filePath)} to fill it in.`,
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function slugToTitle(slug) {
|
|
1092
|
+
return slug
|
|
1093
|
+
.split('-')
|
|
1094
|
+
.map((s) => (s.length ? s[0].toUpperCase() + s.slice(1) : s))
|
|
1095
|
+
.join(' ');
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1099
|
+
// Helpers
|
|
1100
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1101
|
+
//
|
|
1102
|
+
// findPrimaryInstructionFile / ensurePointerInjected / stripPointer moved
|
|
1103
|
+
// to core/ecosystem/lib/pointer.js in Phase 10 Group 0 for reuse by
|
|
1104
|
+
// `momentum join` and `momentum leave`. Imported above.
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* ENH-021 — resolve the ecosystem root from any directory.
|
|
1108
|
+
*
|
|
1109
|
+
* Resolution order:
|
|
1110
|
+
* 1. explicit `--ecosystem <path>` (highest precedence)
|
|
1111
|
+
* 2. ecosystem.json in CWD
|
|
1112
|
+
* 3. walk up via findRoot() bounded by MOMENTUM_MAX_PARENT_WALK
|
|
1113
|
+
* 4. error with a remediation message naming the subcommand
|
|
1114
|
+
*/
|
|
1115
|
+
function resolveEcosystemRoot(explicitPath, subcommand) {
|
|
1116
|
+
if (explicitPath) {
|
|
1117
|
+
const abs = path.resolve(process.cwd(), explicitPath);
|
|
1118
|
+
if (!fs.existsSync(path.join(abs, lib.MANIFEST_FILENAME))) {
|
|
1119
|
+
throw new Error(
|
|
1120
|
+
`${subcommand}: --ecosystem ${explicitPath} → ${abs} has no ecosystem.json. ` +
|
|
1121
|
+
`Did you mean a different path?`,
|
|
1122
|
+
);
|
|
1123
|
+
}
|
|
1124
|
+
return abs;
|
|
1125
|
+
}
|
|
1126
|
+
// First try parent walk-up — succeeds when CWD is inside the ecosystem root.
|
|
1127
|
+
const found = lib.findRoot(process.cwd());
|
|
1128
|
+
if (found) return found;
|
|
1129
|
+
// Then try sibling walk — succeeds when CWD is inside a member repo
|
|
1130
|
+
// whose ecosystem root is a sibling directory. This mirrors the
|
|
1131
|
+
// session-append.sh hook's discovery pattern and is what makes
|
|
1132
|
+
// ENH-021 actually feel location-agnostic from inside a member repo.
|
|
1133
|
+
const stateLib = require('../core/ecosystem/lib/state');
|
|
1134
|
+
const reg = stateLib.findRegistration(process.cwd());
|
|
1135
|
+
if (reg) return reg.rootPath;
|
|
1136
|
+
throw new Error(
|
|
1137
|
+
`${subcommand}: no ecosystem.json found in this or any parent directory, ` +
|
|
1138
|
+
`nor in any reachable sibling. Pass --ecosystem <path>, or cd to an ` +
|
|
1139
|
+
`ecosystem root before running.`,
|
|
1140
|
+
);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function printGitState(repoPath) {
|
|
1144
|
+
// Suppress stderr from git so "fatal: not a git repository" doesn't
|
|
1145
|
+
// leak through to the user when the member isn't yet under git.
|
|
1146
|
+
const gitOpts = { cwd: repoPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] };
|
|
1147
|
+
let gitOk = false;
|
|
1148
|
+
try {
|
|
1149
|
+
const status = execSync('git status --short', gitOpts).trim();
|
|
1150
|
+
gitOk = true;
|
|
1151
|
+
if (status) {
|
|
1152
|
+
console.log(' git: ' + status.split('\n').slice(0, 5).map((l) => l.trim()).join(' | '));
|
|
1153
|
+
} else {
|
|
1154
|
+
console.log(' git: clean');
|
|
1155
|
+
}
|
|
1156
|
+
} catch (_e) {
|
|
1157
|
+
console.log(' git: (not a git repo)');
|
|
1158
|
+
}
|
|
1159
|
+
if (!gitOk) return;
|
|
1160
|
+
try {
|
|
1161
|
+
const log = execSync('git log -1 --pretty=format:"%h %s"', gitOpts).trim();
|
|
1162
|
+
if (log) console.log(` last: ${log}`);
|
|
1163
|
+
} catch (_e) {
|
|
1164
|
+
// ignore — no commits yet
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function parseFlags(args, spec) {
|
|
1169
|
+
const out = {};
|
|
1170
|
+
for (let i = 0; i < args.length; i++) {
|
|
1171
|
+
const a = args[i];
|
|
1172
|
+
if (!a.startsWith('--')) continue;
|
|
1173
|
+
const key = a.slice(2);
|
|
1174
|
+
if (spec[key] === 'boolean') {
|
|
1175
|
+
out[key] = true;
|
|
1176
|
+
} else if (spec[key] === 'string') {
|
|
1177
|
+
out[key] = args[i + 1];
|
|
1178
|
+
i++;
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
return out;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function sanitizeId(s) {
|
|
1185
|
+
return s
|
|
1186
|
+
.toLowerCase()
|
|
1187
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
1188
|
+
.replace(/^-+|-+$/g, '');
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
function today() {
|
|
1192
|
+
const d = new Date();
|
|
1193
|
+
return d.toISOString().slice(0, 10);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1197
|
+
// Templates
|
|
1198
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1199
|
+
|
|
1200
|
+
function readmeTemplate(name) {
|
|
1201
|
+
return `# ${name} — Ecosystem
|
|
1202
|
+
|
|
1203
|
+
> Coordination layer for the ${name} family of repos.
|
|
1204
|
+
> Created by \`momentum ecosystem init\`.
|
|
1205
|
+
|
|
1206
|
+
## What lives here
|
|
1207
|
+
|
|
1208
|
+
| Path | Purpose |
|
|
1209
|
+
|---|---|
|
|
1210
|
+
| \`ecosystem.json\` | Manifest: members, roles, dependency edges. |
|
|
1211
|
+
| \`initiatives/\` | Cross-repo feature files (\`NNNN-slug.md\`). |
|
|
1212
|
+
| \`sessions/\` | Daily activity log (\`YYYY-MM-DD.md\`), auto-appended by member-repo hooks. |
|
|
1213
|
+
| \`.state/\` | Runtime state (gitignored). |
|
|
1214
|
+
|
|
1215
|
+
## Quick commands
|
|
1216
|
+
|
|
1217
|
+
\`\`\`
|
|
1218
|
+
momentum ecosystem status # show members + git state
|
|
1219
|
+
momentum ecosystem add ../<repo> # register a member
|
|
1220
|
+
momentum ecosystem remove <id> # unregister a member
|
|
1221
|
+
\`\`\`
|
|
1222
|
+
|
|
1223
|
+
## Members
|
|
1224
|
+
|
|
1225
|
+
Run \`momentum ecosystem status\` to see the current registered members.
|
|
1226
|
+
|
|
1227
|
+
## Conventions
|
|
1228
|
+
|
|
1229
|
+
- The ecosystem layer is strictly additive. It never writes into a
|
|
1230
|
+
member's \`specs/\`. The only touch on a member repo is one fenced
|
|
1231
|
+
line in its \`CLAUDE.md\` / \`AGENTS.md\` pointing back here.
|
|
1232
|
+
- Add members via \`momentum ecosystem add\`. Don't hand-edit
|
|
1233
|
+
\`ecosystem.json\` unless you know what you're doing — validation runs
|
|
1234
|
+
on every CLI write.
|
|
1235
|
+
`;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
function initiativesReadme() {
|
|
1239
|
+
return `# Initiatives
|
|
1240
|
+
|
|
1241
|
+
> Cross-repo features that span multiple member repos.
|
|
1242
|
+
|
|
1243
|
+
Each initiative is one markdown file named \`NNNN-<slug>.md\` with YAML
|
|
1244
|
+
frontmatter (id, slug, status, started, owner, repos) and a body of
|
|
1245
|
+
fixed sections: Why · Per-repo contributions · Linked decisions ·
|
|
1246
|
+
Deploy chronology · Close.
|
|
1247
|
+
|
|
1248
|
+
Numbering is monotonically increasing across the ecosystem. The
|
|
1249
|
+
filename slug is for human readability; the \`id\` integer is canonical.
|
|
1250
|
+
|
|
1251
|
+
## Create a new initiative
|
|
1252
|
+
|
|
1253
|
+
\`\`\`
|
|
1254
|
+
momentum ecosystem initiative create <slug> \\
|
|
1255
|
+
--why "<one-paragraph motivation>" \\
|
|
1256
|
+
--repos <member-id-1>,<member-id-2> \\
|
|
1257
|
+
--owner <you>
|
|
1258
|
+
\`\`\`
|
|
1259
|
+
|
|
1260
|
+
Flags are optional:
|
|
1261
|
+
- \`--why\` — defaults to a placeholder you can fill in later
|
|
1262
|
+
- \`--repos\` — defaults to all registered members
|
|
1263
|
+
- \`--owner\` — defaults to \`git config user.name\` (or \`\$USER\`)
|
|
1264
|
+
|
|
1265
|
+
The slash-command door \`/initiative create <slug>\` does the same
|
|
1266
|
+
work via the same code path; both end up calling
|
|
1267
|
+
\`core/ecosystem/lib/initiative.js\`.
|
|
1268
|
+
`;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
function gitignoreTemplate() {
|
|
1272
|
+
return `# Ecosystem runtime state
|
|
1273
|
+
.state/
|
|
1274
|
+
|
|
1275
|
+
# Standard noise
|
|
1276
|
+
.DS_Store
|
|
1277
|
+
._*
|
|
1278
|
+
*.swp
|
|
1279
|
+
Thumbs.db
|
|
1280
|
+
`;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1284
|
+
// Exports
|
|
1285
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1286
|
+
|
|
1287
|
+
module.exports = {
|
|
1288
|
+
runEcosystem,
|
|
1289
|
+
// Exported for tests:
|
|
1290
|
+
cmdInit,
|
|
1291
|
+
cmdAdd,
|
|
1292
|
+
cmdRemove,
|
|
1293
|
+
cmdStatus,
|
|
1294
|
+
cmdInitiative,
|
|
1295
|
+
cmdInitiativeCreate,
|
|
1296
|
+
ensurePointerInjected,
|
|
1297
|
+
stripPointer,
|
|
1298
|
+
findPrimaryInstructionFile,
|
|
1299
|
+
sanitizeId,
|
|
1300
|
+
POINTER_BEGIN,
|
|
1301
|
+
POINTER_END,
|
|
1302
|
+
};
|