@mmerterden/multi-agent-pipeline 10.6.0 → 10.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +10 -39
- package/install/index.mjs +9 -101
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/refs/phases/phase-4-review.md +1 -11
- package/pipeline/commands/multi-agent/setup.md +0 -1
- package/pipeline/commands/multi-agent/sync.md +4 -73
- package/pipeline/commands/multi-agent/update.md +9 -0
- package/pipeline/scripts/smoke-cross-cli-behavior.sh +0 -7
- package/pipeline/scripts/smoke-install-layout.sh +1 -2
- package/install/_adapters.mjs +0 -73
- package/pipeline/adapters/_base.mjs +0 -640
- package/pipeline/adapters/antigravity.mjs +0 -140
- package/pipeline/adapters/codex.mjs +0 -159
- package/pipeline/adapters/copilot-chat-orchestration.mjs +0 -148
- package/pipeline/adapters/copilot-chat.mjs +0 -124
- package/pipeline/adapters/cursor-orchestration.mjs +0 -152
- package/pipeline/adapters/cursor.mjs +0 -146
- package/pipeline/scripts/smoke-adapters.sh +0 -276
- package/pipeline/scripts/smoke-shared-runtime.sh +0 -108
- package/pipeline/scripts/smoke-sync-adapters.sh +0 -113
- package/pipeline/scripts/sync-adapters.mjs +0 -183
|
@@ -1,640 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Adapter base - shared helpers for third-party AI tool adapters.
|
|
3
|
-
*
|
|
4
|
-
* Each adapter (cursor.mjs, copilot-chat.mjs, antigravity.mjs, codex.mjs)
|
|
5
|
-
* consumes the same source tree under `pipeline/skills/` and `pipeline/rules/`
|
|
6
|
-
* and emits its own format. This module owns the shared mechanics: SKILL.md
|
|
7
|
-
* parsing, frontmatter mapping, platform glob inference, the install/uninstall
|
|
8
|
-
* file-loop factories, MCP config merging, and the
|
|
9
|
-
* `<!-- multi-agent-pipeline:begin/end -->` marker pair used to scope
|
|
10
|
-
* pipeline-managed content inside user-owned files.
|
|
11
|
-
*
|
|
12
|
-
* Zero runtime deps (ADR-4). Pure ES module.
|
|
13
|
-
*
|
|
14
|
-
* @module pipeline/adapters/_base
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import {
|
|
18
|
-
cpSync,
|
|
19
|
-
existsSync,
|
|
20
|
-
mkdirSync,
|
|
21
|
-
readFileSync,
|
|
22
|
-
readdirSync,
|
|
23
|
-
rmSync,
|
|
24
|
-
writeFileSync,
|
|
25
|
-
} from "fs";
|
|
26
|
-
import { homedir } from "os";
|
|
27
|
-
import { join, relative } from "path";
|
|
28
|
-
import { DEV_ONLY_SCRIPTS } from "../../install/_dev-only-files.mjs";
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Pipeline-managed block markers. Anything between BEGIN and END inside a
|
|
32
|
-
* user-owned file is owned by this installer and may be replaced/removed.
|
|
33
|
-
* Content outside the markers is preserved on uninstall.
|
|
34
|
-
*/
|
|
35
|
-
export const MARKER_BEGIN = "<!-- multi-agent-pipeline:begin -->";
|
|
36
|
-
export const MARKER_END = "<!-- multi-agent-pipeline:end -->";
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Parse a YAML-ish frontmatter block from a SKILL.md file. The pipeline's
|
|
40
|
-
* frontmatter is intentionally narrow (no nested mappings inside list values,
|
|
41
|
-
* no anchors), so a regex parser is sufficient and avoids a YAML dep.
|
|
42
|
-
*
|
|
43
|
-
* @param {string} raw - file contents
|
|
44
|
-
* @returns {{ frontmatter: Record<string, string>, body: string }}
|
|
45
|
-
*/
|
|
46
|
-
export function parseFrontmatter(raw) {
|
|
47
|
-
const match = /^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/.exec(raw);
|
|
48
|
-
if (!match) return { frontmatter: {}, body: raw };
|
|
49
|
-
const [, fmBlock, body] = match;
|
|
50
|
-
const fm = {};
|
|
51
|
-
for (const line of fmBlock.split("\n")) {
|
|
52
|
-
const kv = /^([a-zA-Z][\w-]*)\s*:\s*(.*)$/.exec(line);
|
|
53
|
-
if (!kv) continue;
|
|
54
|
-
let val = kv[2].trim();
|
|
55
|
-
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
56
|
-
val = val.slice(1, -1);
|
|
57
|
-
}
|
|
58
|
-
fm[kv[1]] = val;
|
|
59
|
-
}
|
|
60
|
-
return { frontmatter: fm, body: body.trimStart() };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Default skill source dirs (relative to `pipeline/skills/`) consumed by the
|
|
65
|
-
* adapters. Order matters: it drives per-file write order and the section
|
|
66
|
-
* order inside concatenated digests.
|
|
67
|
-
*/
|
|
68
|
-
export const DEFAULT_SKILL_SOURCES = Object.freeze([
|
|
69
|
-
"shared/core",
|
|
70
|
-
"shared/external",
|
|
71
|
-
"figma-ios",
|
|
72
|
-
"figma-android",
|
|
73
|
-
"figma-common",
|
|
74
|
-
]);
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Walk a skills root and yield every SKILL.md found one directory deep. The
|
|
78
|
-
* pipeline tree is shallow on purpose (one dir per skill, body inside), so a
|
|
79
|
-
* one-level scan covers shared/core, shared/external, figma-{ios,android,common}.
|
|
80
|
-
*
|
|
81
|
-
* @param {string} skillsRoot
|
|
82
|
-
* @returns {Array<{ name: string, srcPath: string, frontmatter: Record<string, string>, body: string }>}
|
|
83
|
-
*/
|
|
84
|
-
export function walkSkills(skillsRoot) {
|
|
85
|
-
const out = [];
|
|
86
|
-
if (!existsSync(skillsRoot)) return out;
|
|
87
|
-
for (const entry of readdirSync(skillsRoot, { withFileTypes: true })) {
|
|
88
|
-
if (!entry.isDirectory()) continue;
|
|
89
|
-
const skillFile = join(skillsRoot, entry.name, "SKILL.md");
|
|
90
|
-
if (!existsSync(skillFile)) continue;
|
|
91
|
-
const raw = readFileSync(skillFile, "utf-8");
|
|
92
|
-
const { frontmatter, body } = parseFrontmatter(raw);
|
|
93
|
-
out.push({
|
|
94
|
-
name: frontmatter.name || entry.name,
|
|
95
|
-
srcPath: skillFile,
|
|
96
|
-
frontmatter,
|
|
97
|
-
body,
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
return out;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Infer a Cursor-style globs string from a skill name. Mirrors the prefix
|
|
105
|
-
* heuristic in `install.js#classifyExternalSkill` so the two stay in sync,
|
|
106
|
-
* but returns globs instead of a coarse platform tag.
|
|
107
|
-
*
|
|
108
|
-
* Skills that don't match a known prefix get null (= no globs, = always-on
|
|
109
|
-
* eligible). The adapter decides whether null means alwaysApply or just
|
|
110
|
-
* description-only.
|
|
111
|
-
*
|
|
112
|
-
* @param {string} skillName
|
|
113
|
-
* @returns {string | null}
|
|
114
|
-
*/
|
|
115
|
-
export function inferGlobs(skillName) {
|
|
116
|
-
const lower = skillName.toLowerCase();
|
|
117
|
-
if (lower.startsWith("multi-agent")) return null;
|
|
118
|
-
if (
|
|
119
|
-
lower.startsWith("swiftui") ||
|
|
120
|
-
lower.startsWith("swift") ||
|
|
121
|
-
lower.startsWith("ios") ||
|
|
122
|
-
lower.startsWith("apple-") ||
|
|
123
|
-
lower === "alarmkit" ||
|
|
124
|
-
lower === "callkit-voip" ||
|
|
125
|
-
lower === "cloudkit-sync" ||
|
|
126
|
-
lower === "core-bluetooth" ||
|
|
127
|
-
lower === "core-motion" ||
|
|
128
|
-
lower === "core-nfc" ||
|
|
129
|
-
lower === "coreml" ||
|
|
130
|
-
lower === "eventkit-calendar" ||
|
|
131
|
-
lower === "healthkit" ||
|
|
132
|
-
lower === "homekit-matter" ||
|
|
133
|
-
lower === "live-activities" ||
|
|
134
|
-
lower === "mapkit-location" ||
|
|
135
|
-
lower === "musickit-audio" ||
|
|
136
|
-
lower === "passkit-wallet" ||
|
|
137
|
-
lower === "pencilkit-drawing" ||
|
|
138
|
-
lower === "permissionkit" ||
|
|
139
|
-
lower === "realitykit-ar" ||
|
|
140
|
-
lower === "speech-recognition" ||
|
|
141
|
-
lower === "storekit" ||
|
|
142
|
-
lower === "tipkit" ||
|
|
143
|
-
lower === "vision-framework" ||
|
|
144
|
-
lower === "weatherkit" ||
|
|
145
|
-
lower === "widgetkit" ||
|
|
146
|
-
lower === "natural-language" ||
|
|
147
|
-
lower === "authentication" ||
|
|
148
|
-
lower === "background-processing" ||
|
|
149
|
-
lower === "contacts-framework" ||
|
|
150
|
-
lower === "device-integrity" ||
|
|
151
|
-
lower === "debugging-instruments" ||
|
|
152
|
-
lower === "energykit" ||
|
|
153
|
-
lower === "metrickit-diagnostics" ||
|
|
154
|
-
lower === "shareplay-activities" ||
|
|
155
|
-
lower === "photos-camera-media" ||
|
|
156
|
-
lower === "push-notifications" ||
|
|
157
|
-
lower === "swiftdata" ||
|
|
158
|
-
lower === "swiftdata-pro" ||
|
|
159
|
-
lower === "app-clips" ||
|
|
160
|
-
lower === "app-intents" ||
|
|
161
|
-
lower === "app-store-changelog" ||
|
|
162
|
-
lower === "app-store-review" ||
|
|
163
|
-
lower === "app-store-optimization" ||
|
|
164
|
-
lower.startsWith("hig-") ||
|
|
165
|
-
lower.startsWith("macos-") ||
|
|
166
|
-
lower.startsWith("apple-")
|
|
167
|
-
) {
|
|
168
|
-
return "**/*.swift";
|
|
169
|
-
}
|
|
170
|
-
if (
|
|
171
|
-
lower.startsWith("android") ||
|
|
172
|
-
lower.startsWith("kotlin") ||
|
|
173
|
-
lower.startsWith("jetpack") ||
|
|
174
|
-
lower.startsWith("compose") ||
|
|
175
|
-
lower === "room-database" ||
|
|
176
|
-
lower === "retrofit-networking" ||
|
|
177
|
-
lower === "gradle-kotlin-dsl" ||
|
|
178
|
-
lower === "play-store-review" ||
|
|
179
|
-
lower === "google-play-compliance"
|
|
180
|
-
) {
|
|
181
|
-
return "**/*.{kt,kts}";
|
|
182
|
-
}
|
|
183
|
-
if (lower === "react-best-practices" || lower === "nextjs-app-router") return "**/*.{tsx,ts,jsx,js}";
|
|
184
|
-
if (lower === "vue-composition") return "**/*.{vue,ts,js}";
|
|
185
|
-
if (lower === "tailwind-css" || lower === "css-modern") return "**/*.{css,scss,tsx,jsx,vue,html}";
|
|
186
|
-
if (lower === "html-semantic") return "**/*.html";
|
|
187
|
-
if (lower === "typescript-patterns") return "**/*.{ts,tsx}";
|
|
188
|
-
if (lower === "python-patterns" || lower === "fastapi-pro") return "**/*.py";
|
|
189
|
-
if (lower === "nodejs-backend-patterns") return "**/*.{js,mjs,ts}";
|
|
190
|
-
return null;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Replace (or insert) a pipeline-managed block inside a user-owned file.
|
|
195
|
-
* If the file does not exist, write it with just the block. If it exists
|
|
196
|
-
* and contains a marker pair, replace between them. Otherwise, append.
|
|
197
|
-
*
|
|
198
|
-
* @param {string} filePath
|
|
199
|
-
* @param {string} blockBody - content WITHOUT the marker lines
|
|
200
|
-
* @param {object} [opts]
|
|
201
|
-
* @param {string} [opts.beforeBlockText] - content prepended ABOVE the markers when creating new file
|
|
202
|
-
* @param {{ begin: string, end: string }} [opts.markers] - alternative marker pair
|
|
203
|
-
* for formats that can't host HTML comments (e.g. `#`-comment markers in TOML)
|
|
204
|
-
*/
|
|
205
|
-
export function replaceManagedBlock(filePath, blockBody, opts = {}) {
|
|
206
|
-
const begin = opts.markers ? opts.markers.begin : MARKER_BEGIN;
|
|
207
|
-
const end = opts.markers ? opts.markers.end : MARKER_END;
|
|
208
|
-
const wrapped = `${begin}\n${blockBody.trim()}\n${end}`;
|
|
209
|
-
if (!existsSync(filePath)) {
|
|
210
|
-
const header = opts.beforeBlockText ? `${opts.beforeBlockText.trimEnd()}\n\n` : "";
|
|
211
|
-
writeFileSync(filePath, `${header}${wrapped}\n`);
|
|
212
|
-
return "created";
|
|
213
|
-
}
|
|
214
|
-
const existing = readFileSync(filePath, "utf-8");
|
|
215
|
-
const re = new RegExp(`${escapeRegex(begin)}[\\s\\S]*?${escapeRegex(end)}`, "m");
|
|
216
|
-
if (re.test(existing)) {
|
|
217
|
-
writeFileSync(filePath, existing.replace(re, wrapped));
|
|
218
|
-
return "replaced";
|
|
219
|
-
}
|
|
220
|
-
writeFileSync(filePath, `${existing.trimEnd()}\n\n${wrapped}\n`);
|
|
221
|
-
return "appended";
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* Remove a pipeline-managed block from a user-owned file. If the file becomes
|
|
226
|
-
* empty (or whitespace-only) after removal, the file is deleted entirely.
|
|
227
|
-
*
|
|
228
|
-
* @param {string} filePath
|
|
229
|
-
* @param {object} [opts]
|
|
230
|
-
* @param {{ begin: string, end: string }} [opts.markers] - alternative marker pair
|
|
231
|
-
* @returns {"removed" | "removed-and-deleted-empty-file" | "missing" | "no-marker"}
|
|
232
|
-
*/
|
|
233
|
-
export function removeManagedBlock(filePath, opts = {}) {
|
|
234
|
-
const begin = opts.markers ? opts.markers.begin : MARKER_BEGIN;
|
|
235
|
-
const end = opts.markers ? opts.markers.end : MARKER_END;
|
|
236
|
-
if (!existsSync(filePath)) return "missing";
|
|
237
|
-
const existing = readFileSync(filePath, "utf-8");
|
|
238
|
-
const re = new RegExp(
|
|
239
|
-
`\\s*${escapeRegex(begin)}[\\s\\S]*?${escapeRegex(end)}\\s*`,
|
|
240
|
-
"m",
|
|
241
|
-
);
|
|
242
|
-
if (!re.test(existing)) return "no-marker";
|
|
243
|
-
const cleaned = existing.replace(re, "\n").trim();
|
|
244
|
-
if (cleaned.length === 0) {
|
|
245
|
-
rmSync(filePath, { force: true });
|
|
246
|
-
return "removed-and-deleted-empty-file";
|
|
247
|
-
}
|
|
248
|
-
writeFileSync(filePath, cleaned + "\n");
|
|
249
|
-
return "removed";
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* Concatenate a list of skill bodies into a single document, with each skill
|
|
254
|
-
* preceded by a heading line. Used by adapters that emit a single rules file
|
|
255
|
-
* (windsurf) instead of one-file-per-skill (cursor / cline).
|
|
256
|
-
*
|
|
257
|
-
* @param {Array<{ name: string, frontmatter: Record<string, string>, body: string }>} skills
|
|
258
|
-
* @returns {string}
|
|
259
|
-
*/
|
|
260
|
-
export function concatSkills(skills) {
|
|
261
|
-
const parts = [];
|
|
262
|
-
for (const skill of skills) {
|
|
263
|
-
const desc = skill.frontmatter.description || "";
|
|
264
|
-
parts.push(`## ${skill.name}`);
|
|
265
|
-
if (desc) parts.push(`> ${desc}`);
|
|
266
|
-
parts.push("");
|
|
267
|
-
parts.push(skill.body.trim());
|
|
268
|
-
parts.push("");
|
|
269
|
-
}
|
|
270
|
-
return parts.join("\n");
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* Filter the multi-agent-* core skills out of a skill list. Used by tools
|
|
275
|
-
* that don't have slash-command infrastructure (cursor / windsurf / cline) -
|
|
276
|
-
* the orchestration commands aren't invocable there, so shipping them as
|
|
277
|
-
* rules just bloats context. Tools get the knowledge layer (rules + external
|
|
278
|
-
* skills) but not the dispatch layer.
|
|
279
|
-
*
|
|
280
|
-
* @template {{ name: string }} T
|
|
281
|
-
* @param {Array<T>} skills
|
|
282
|
-
* @returns {Array<T>}
|
|
283
|
-
*/
|
|
284
|
-
export function withoutOrchestrationSkills(skills) {
|
|
285
|
-
return skills.filter((s) => !s.name.startsWith("multi-agent"));
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* Collect every skill under `pipeline/skills/` and narrow it to the requested
|
|
290
|
-
* platform. The glob heuristic from `inferGlobs` decides platform membership:
|
|
291
|
-
* skills with no inferred globs are generic and always included.
|
|
292
|
-
*
|
|
293
|
-
* @param {string} pipelineSrc
|
|
294
|
-
* @param {"all"|"ios"|"android"} [platformFilter]
|
|
295
|
-
* @param {object} [opts]
|
|
296
|
-
* @param {string[]} [opts.sources] - source dirs relative to `pipeline/skills/`;
|
|
297
|
-
* order drives output order (defaults to DEFAULT_SKILL_SOURCES)
|
|
298
|
-
* @returns {Array<{ name: string, srcPath: string, frontmatter: Record<string, string>, body: string }>}
|
|
299
|
-
*/
|
|
300
|
-
export function collectSkills(pipelineSrc, platformFilter = "all", opts = {}) {
|
|
301
|
-
const skillsRoot = join(pipelineSrc, "skills");
|
|
302
|
-
const sources = opts.sources || DEFAULT_SKILL_SOURCES;
|
|
303
|
-
const all = [];
|
|
304
|
-
for (const src of sources) all.push(...walkSkills(join(skillsRoot, ...src.split("/"))));
|
|
305
|
-
if (platformFilter === "all") return all;
|
|
306
|
-
return all.filter((s) => {
|
|
307
|
-
const globs = inferGlobs(s.name);
|
|
308
|
-
if (globs == null) return true; // generic / orchestration → always include
|
|
309
|
-
if (platformFilter === "ios") return globs.includes("swift");
|
|
310
|
-
if (platformFilter === "android") return globs.includes("kt");
|
|
311
|
-
return true;
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* Walk the pipeline rules tree (`pipeline/rules/*.md`) and yield each rule's
|
|
317
|
-
* name (filename without extension) and raw body.
|
|
318
|
-
*
|
|
319
|
-
* @param {string} pipelineSrc
|
|
320
|
-
* @returns {Array<{ name: string, body: string }>}
|
|
321
|
-
*/
|
|
322
|
-
export function walkRules(pipelineSrc) {
|
|
323
|
-
const rulesSrc = join(pipelineSrc, "rules");
|
|
324
|
-
const out = [];
|
|
325
|
-
if (!existsSync(rulesSrc)) return out;
|
|
326
|
-
for (const entry of readdirSync(rulesSrc, { withFileTypes: true })) {
|
|
327
|
-
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
|
328
|
-
out.push({
|
|
329
|
-
name: entry.name.replace(/\.md$/, ""),
|
|
330
|
-
body: readFileSync(join(rulesSrc, entry.name), "utf-8"),
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
return out;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
/**
|
|
337
|
-
* Render the AGENTS.md-style skill index block: a title, an intro quote, and
|
|
338
|
-
* one bullet per skill. Used by the adapters that ship a context file instead
|
|
339
|
-
* of per-skill rule files (antigravity, codex).
|
|
340
|
-
*
|
|
341
|
-
* @param {{ title: string, intro: string[], skills: Array<{ name: string, frontmatter: Record<string, string> }> }} opts
|
|
342
|
-
* @returns {string}
|
|
343
|
-
*/
|
|
344
|
-
export function renderSkillIndex({ title, intro, skills }) {
|
|
345
|
-
const parts = [title, "", ...intro, "", "## Skills (knowledge index)", ""];
|
|
346
|
-
for (const s of skills) {
|
|
347
|
-
const desc = (s.frontmatter.description || "").replace(/\s+/g, " ").trim();
|
|
348
|
-
parts.push(`- **${s.name}**${desc ? ` - ${desc}` : ""}`);
|
|
349
|
-
}
|
|
350
|
-
parts.push("");
|
|
351
|
-
return parts.join("\n");
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* Write one rendered file per item into `outDir` (created if missing).
|
|
356
|
-
* The shared install loop behind every per-skill / per-rule emit.
|
|
357
|
-
*
|
|
358
|
-
* @template T
|
|
359
|
-
* @param {{ outDir: string, items: T[], fileFor: (item: T) => string, render: (item: T) => string }} opts
|
|
360
|
-
* @returns {number} files written
|
|
361
|
-
*/
|
|
362
|
-
export function installAdapterFiles({ outDir, items, fileFor, render }) {
|
|
363
|
-
ensureDir(outDir);
|
|
364
|
-
let written = 0;
|
|
365
|
-
for (const item of items) {
|
|
366
|
-
writeFileSync(join(outDir, fileFor(item)), render(item));
|
|
367
|
-
written++;
|
|
368
|
-
}
|
|
369
|
-
return written;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
/**
|
|
373
|
-
* Transform every pipeline persona (`pipeline/agents/*.md`) into a
|
|
374
|
-
* platform-specific agent file, plus the cross-vendor second reviewer next to
|
|
375
|
-
* `code-reviewer` (the platforms support distinct per-agent models, so a
|
|
376
|
-
* second reviewer pinned to a different vendor restores 2-model review).
|
|
377
|
-
*
|
|
378
|
-
* @param {{
|
|
379
|
-
* pipelineSrc: string,
|
|
380
|
-
* outDir: string,
|
|
381
|
-
* prefix: string,
|
|
382
|
-
* fileFor: (name: string) => string,
|
|
383
|
-
* render: (persona: string, frontmatter: Record<string, string>, body: string, opts: object) => string,
|
|
384
|
-
* primaryOpts?: (persona: string) => object,
|
|
385
|
-
* crossReviewer?: object,
|
|
386
|
-
* }} opts
|
|
387
|
-
* @returns {{ agents: number, names: string[] }}
|
|
388
|
-
*/
|
|
389
|
-
export function installPersonaAgents({ pipelineSrc, outDir, prefix, fileFor, render, primaryOpts, crossReviewer }) {
|
|
390
|
-
ensureDir(outDir);
|
|
391
|
-
const personaDir = join(pipelineSrc, "agents");
|
|
392
|
-
const names = [];
|
|
393
|
-
if (!existsSync(personaDir)) return { agents: 0, names };
|
|
394
|
-
for (const entry of readdirSync(personaDir, { withFileTypes: true })) {
|
|
395
|
-
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
|
396
|
-
const persona = entry.name.replace(/\.md$/, "");
|
|
397
|
-
const { frontmatter, body } = parseFrontmatter(readFileSync(join(personaDir, entry.name), "utf-8"));
|
|
398
|
-
writeFileSync(
|
|
399
|
-
join(outDir, fileFor(persona)),
|
|
400
|
-
render(persona, frontmatter, body, primaryOpts ? primaryOpts(persona) : {}),
|
|
401
|
-
);
|
|
402
|
-
names.push(`${prefix}${persona}`);
|
|
403
|
-
if (persona === "code-reviewer" && crossReviewer) {
|
|
404
|
-
writeFileSync(join(outDir, fileFor("code-reviewer-x")), render(persona, frontmatter, body, crossReviewer));
|
|
405
|
-
names.push(`${prefix}code-reviewer-x`);
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
return { agents: names.length, names };
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
/**
|
|
412
|
-
* Remove a dir if (and only if) it is empty. Never touches user content.
|
|
413
|
-
* @param {string} dir
|
|
414
|
-
*/
|
|
415
|
-
export function removeDirIfEmpty(dir) {
|
|
416
|
-
try {
|
|
417
|
-
if (existsSync(dir) && readdirSync(dir).length === 0) rmSync(dir, { recursive: true, force: true });
|
|
418
|
-
} catch {
|
|
419
|
-
/* non-fatal */
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
/**
|
|
424
|
-
* Remove every `<prefix>*<suffix>` file in `dir`, then drop the dir itself if
|
|
425
|
-
* it ended up empty. The shared uninstall loop behind every per-skill /
|
|
426
|
-
* per-rule / per-agent emit.
|
|
427
|
-
*
|
|
428
|
-
* @param {string} dir
|
|
429
|
-
* @param {string} prefix
|
|
430
|
-
* @param {string} suffix
|
|
431
|
-
* @returns {number} files removed
|
|
432
|
-
*/
|
|
433
|
-
export function removePrefixedFiles(dir, prefix, suffix) {
|
|
434
|
-
let removed = 0;
|
|
435
|
-
if (!existsSync(dir)) return removed;
|
|
436
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
437
|
-
if (!entry.isFile()) continue;
|
|
438
|
-
if (entry.name.startsWith(prefix) && entry.name.endsWith(suffix)) {
|
|
439
|
-
rmSync(join(dir, entry.name), { force: true });
|
|
440
|
-
removed++;
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
removeDirIfEmpty(dir);
|
|
444
|
-
return removed;
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
/**
|
|
448
|
-
* Remove a single pipeline-owned file, then optionally prune its parent dir
|
|
449
|
-
* when empty.
|
|
450
|
-
*
|
|
451
|
-
* @param {string} filePath
|
|
452
|
-
* @param {string} [pruneDir]
|
|
453
|
-
* @returns {number} 1 if the file existed and was removed, else 0
|
|
454
|
-
*/
|
|
455
|
-
export function removeFileAndPrune(filePath, pruneDir) {
|
|
456
|
-
let removed = 0;
|
|
457
|
-
if (existsSync(filePath)) {
|
|
458
|
-
rmSync(filePath, { force: true });
|
|
459
|
-
removed = 1;
|
|
460
|
-
}
|
|
461
|
-
if (pruneDir) removeDirIfEmpty(pruneDir);
|
|
462
|
-
return removed;
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
/**
|
|
466
|
-
* Register the dev-toolkit MCP server inside a JSON MCP config, merging into
|
|
467
|
-
* the user's existing servers (never clobbering them). `rootKey` follows the
|
|
468
|
-
* platform schema: `mcpServers` (Cursor, Antigravity) or `servers` (VS Code).
|
|
469
|
-
*
|
|
470
|
-
* @param {string} mcpPath
|
|
471
|
-
* @param {object} [opts]
|
|
472
|
-
* @param {string} [opts.rootKey]
|
|
473
|
-
* @returns {"created" | "merged" | "skipped"}
|
|
474
|
-
*/
|
|
475
|
-
export function mergeMcpJson(mcpPath, opts = {}) {
|
|
476
|
-
const rootKey = opts.rootKey || "mcpServers";
|
|
477
|
-
const entry = { command: "npx", args: ["-y", "@mmerterden/dev-toolkit-mcp"] };
|
|
478
|
-
ensureDir(join(mcpPath, ".."));
|
|
479
|
-
if (!existsSync(mcpPath)) {
|
|
480
|
-
writeFileSync(mcpPath, JSON.stringify({ [rootKey]: { "dev-toolkit": entry } }, null, 2) + "\n");
|
|
481
|
-
return "created";
|
|
482
|
-
}
|
|
483
|
-
let cfg;
|
|
484
|
-
try {
|
|
485
|
-
cfg = JSON.parse(readFileSync(mcpPath, "utf-8"));
|
|
486
|
-
} catch {
|
|
487
|
-
return "skipped";
|
|
488
|
-
}
|
|
489
|
-
cfg[rootKey] = cfg[rootKey] || {};
|
|
490
|
-
cfg[rootKey]["dev-toolkit"] = entry;
|
|
491
|
-
writeFileSync(mcpPath, JSON.stringify(cfg, null, 2) + "\n");
|
|
492
|
-
return "merged";
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
/**
|
|
496
|
-
* Remove only OUR dev-toolkit entry from a JSON MCP config; the user's other
|
|
497
|
-
* servers (and the file) survive. The file is deleted only when it held
|
|
498
|
-
* nothing but our entry.
|
|
499
|
-
*
|
|
500
|
-
* @param {string} mcpPath
|
|
501
|
-
* @param {object} [opts]
|
|
502
|
-
* @param {string} [opts.rootKey]
|
|
503
|
-
*/
|
|
504
|
-
export function unmergeMcpJson(mcpPath, opts = {}) {
|
|
505
|
-
const rootKey = opts.rootKey || "mcpServers";
|
|
506
|
-
if (!existsSync(mcpPath)) return;
|
|
507
|
-
let cfg;
|
|
508
|
-
try {
|
|
509
|
-
cfg = JSON.parse(readFileSync(mcpPath, "utf-8"));
|
|
510
|
-
} catch {
|
|
511
|
-
return;
|
|
512
|
-
}
|
|
513
|
-
if (cfg[rootKey] && cfg[rootKey]["dev-toolkit"]) {
|
|
514
|
-
delete cfg[rootKey]["dev-toolkit"];
|
|
515
|
-
if (Object.keys(cfg[rootKey]).length === 0) {
|
|
516
|
-
rmSync(mcpPath, { force: true });
|
|
517
|
-
return;
|
|
518
|
-
}
|
|
519
|
-
writeFileSync(mcpPath, JSON.stringify(cfg, null, 2) + "\n");
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
/**
|
|
524
|
-
* JSON-quote a frontmatter value (descriptions may contain `:` or quotes).
|
|
525
|
-
* @param {string} s
|
|
526
|
-
*/
|
|
527
|
-
export function jsonString(s) {
|
|
528
|
-
return JSON.stringify(s);
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
/**
|
|
532
|
-
* Ensure a directory exists. Recursive.
|
|
533
|
-
* @param {string} dir
|
|
534
|
-
*/
|
|
535
|
-
export function ensureDir(dir) {
|
|
536
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
/**
|
|
540
|
-
* Return the relative path of `child` against `parent` for log output.
|
|
541
|
-
* @param {string} parent
|
|
542
|
-
* @param {string} child
|
|
543
|
-
*/
|
|
544
|
-
export function rel(parent, child) {
|
|
545
|
-
try {
|
|
546
|
-
return relative(parent, child) || child;
|
|
547
|
-
} catch {
|
|
548
|
-
return child;
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
function escapeRegex(s) {
|
|
553
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
/**
|
|
557
|
-
* Cross-vendor reviewer model lineup per adapter platform. The pipeline's value
|
|
558
|
-
* is multi-model review (Claude Code = Opus + Sonnet; Copilot CLI adds GPT-5.4).
|
|
559
|
-
* The adapter platforms support distinct per-agent models, so we emit a SECOND
|
|
560
|
-
* reviewer agent pinned to a different vendor to restore cross-model diversity.
|
|
561
|
-
*
|
|
562
|
-
* Model strings drift as platforms ship new versions - treat this like
|
|
563
|
-
* cost-table.json and update it when the platforms' pickers change. Verified
|
|
564
|
-
* mid-2026 against each platform's model docs.
|
|
565
|
-
*
|
|
566
|
-
* Cursor: per-agent `model:` takes a model ID; the exact Claude slug is not
|
|
567
|
-
* published (docs only give `gpt-5.5` / `composer-2`), so the PRIMARY reviewer
|
|
568
|
-
* stays `inherit` (the user's selected model, usually Claude) and the SECONDARY
|
|
569
|
-
* pins `gpt-5.5` - that yields two vendors without guessing the Claude slug.
|
|
570
|
-
* Copilot Chat: frontmatter `model:` takes the picker LABEL verbatim (confirmed).
|
|
571
|
-
* Antigravity: model is chosen in the side-panel UI, not a file - so its
|
|
572
|
-
* workflow only NAMES the recommended diverse pair; it cannot pin them.
|
|
573
|
-
*/
|
|
574
|
-
export const REVIEWER_MODELS = Object.freeze({
|
|
575
|
-
cursor: { primary: "inherit", crossModel: "gpt-5.5" },
|
|
576
|
-
copilotChat: { primary: "Claude Opus 4.8", crossModel: "GPT-5.5" },
|
|
577
|
-
antigravity: { primary: "Gemini 3 Pro", crossModel: "Claude Opus 4.6" },
|
|
578
|
-
codex: { primary: "GPT-5.5-Codex", crossModel: "Claude Opus 4.8" },
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
/**
|
|
582
|
-
* Shared cross-platform runtime root. The non-native adapters (Cursor,
|
|
583
|
-
* Antigravity, VS Code Copilot Chat) have no user-global skills dir like
|
|
584
|
-
* `~/.claude` / `~/.copilot`, so the pipeline's deterministic-gate scripts are
|
|
585
|
-
* installed once here and the emitted agents/commands reference them by
|
|
586
|
-
* absolute path. This is what lets the gates actually EXECUTE on those
|
|
587
|
-
* platforms instead of degrading to advisory.
|
|
588
|
-
*/
|
|
589
|
-
export function sharedRuntimeRoot() {
|
|
590
|
-
return join(homedir(), ".multi-agent");
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
/**
|
|
594
|
-
* Copy scripts/ + lib/ + schemas/ into the shared runtime root, excluding
|
|
595
|
-
* dev-only files (the figma scrub map, the personal-data scanners). Idempotent.
|
|
596
|
-
* @param {string} pipelineSrc
|
|
597
|
-
* @returns {{ root: string, trees: string[] }}
|
|
598
|
-
*/
|
|
599
|
-
export function installSharedRuntime(pipelineSrc) {
|
|
600
|
-
const root = sharedRuntimeRoot();
|
|
601
|
-
const trees = [];
|
|
602
|
-
for (const sub of ["scripts", "lib", "schemas"]) {
|
|
603
|
-
const src = join(pipelineSrc, sub);
|
|
604
|
-
if (!existsSync(src)) continue;
|
|
605
|
-
const dest = join(root, sub);
|
|
606
|
-
ensureDir(dest);
|
|
607
|
-
cpSync(src, dest, {
|
|
608
|
-
recursive: true,
|
|
609
|
-
// Never copy a dev-only / PII-bearing file into the shared runtime.
|
|
610
|
-
filter: (s) => !DEV_ONLY_SCRIPTS.includes(s.split(/[\\/]/).pop()),
|
|
611
|
-
});
|
|
612
|
-
trees.push(sub);
|
|
613
|
-
}
|
|
614
|
-
return { root, trees };
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
/**
|
|
618
|
-
* Remove the shared runtime root. The tree is 100% pipeline-owned, so a full
|
|
619
|
-
* recursive remove is safe.
|
|
620
|
-
* @returns {boolean} true if anything was removed
|
|
621
|
-
*/
|
|
622
|
-
export function uninstallSharedRuntime() {
|
|
623
|
-
const root = sharedRuntimeRoot();
|
|
624
|
-
if (!existsSync(root)) return false;
|
|
625
|
-
rmSync(root, { recursive: true, force: true });
|
|
626
|
-
return true;
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
/**
|
|
630
|
-
* Rewrite logical `pipeline/scripts/...` and `pipeline/lib/...` references in an
|
|
631
|
-
* emitted agent/command/workflow body to the absolute shared-runtime path, so
|
|
632
|
-
* they resolve from any working directory on the adapter platforms.
|
|
633
|
-
* @param {string} body
|
|
634
|
-
* @returns {string}
|
|
635
|
-
*/
|
|
636
|
-
export function rewriteScriptRefs(body) {
|
|
637
|
-
return String(body)
|
|
638
|
-
.replace(/pipeline\/scripts\//g, "$HOME/.multi-agent/scripts/")
|
|
639
|
-
.replace(/pipeline\/lib\//g, "$HOME/.multi-agent/lib/");
|
|
640
|
-
}
|