@mmerterden/multi-agent-pipeline 10.6.0 → 10.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +10 -39
  3. package/install/index.mjs +9 -101
  4. package/package.json +1 -1
  5. package/pipeline/commands/multi-agent/delete.md +4 -5
  6. package/pipeline/commands/multi-agent/refs/phases/phase-4-review.md +1 -11
  7. package/pipeline/commands/multi-agent/refs/picker-contract.md +6 -10
  8. package/pipeline/commands/multi-agent/setup.md +0 -1
  9. package/pipeline/commands/multi-agent/sync.md +4 -73
  10. package/pipeline/commands/multi-agent/update.md +9 -0
  11. package/pipeline/lib/ask-choice.sh +1 -1
  12. package/pipeline/scripts/smoke-cross-cli-behavior.sh +0 -7
  13. package/pipeline/scripts/smoke-install-layout.sh +1 -2
  14. package/pipeline/skills/.skills-index.json +65 -20
  15. package/pipeline/skills/shared/README.md +1 -1
  16. package/pipeline/skills/shared/core/multi-agent-delete/SKILL.md +4 -4
  17. package/pipeline/skills/skills-index.md +24 -19
  18. package/install/_adapters.mjs +0 -73
  19. package/pipeline/adapters/_base.mjs +0 -640
  20. package/pipeline/adapters/antigravity.mjs +0 -140
  21. package/pipeline/adapters/codex.mjs +0 -159
  22. package/pipeline/adapters/copilot-chat-orchestration.mjs +0 -148
  23. package/pipeline/adapters/copilot-chat.mjs +0 -124
  24. package/pipeline/adapters/cursor-orchestration.mjs +0 -152
  25. package/pipeline/adapters/cursor.mjs +0 -146
  26. package/pipeline/scripts/smoke-adapters.sh +0 -276
  27. package/pipeline/scripts/smoke-delete-flow.sh +0 -151
  28. package/pipeline/scripts/smoke-shared-runtime.sh +0 -108
  29. package/pipeline/scripts/smoke-sync-adapters.sh +0 -113
  30. 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
- }