@codyswann/lisa 2.153.0 → 2.154.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.
- package/dist/cli/apply.d.ts.map +1 -1
- package/dist/cli/apply.js +7 -0
- package/dist/cli/apply.js.map +1 -1
- package/dist/cli/cross-pollinate-cmd.d.ts +12 -0
- package/dist/cli/cross-pollinate-cmd.d.ts.map +1 -0
- package/dist/cli/cross-pollinate-cmd.js +48 -0
- package/dist/cli/cross-pollinate-cmd.js.map +1 -0
- package/dist/cli/cross-pollinate-nudge.d.ts +10 -0
- package/dist/cli/cross-pollinate-nudge.d.ts.map +1 -0
- package/dist/cli/cross-pollinate-nudge.js +55 -0
- package/dist/cli/cross-pollinate-nudge.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +10 -0
- package/dist/cli/index.js.map +1 -1
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/commands/cross-pollinate.md +15 -0
- package/plugins/lisa/scripts/cross-pollinate.mjs +727 -0
- package/plugins/lisa/skills/cross-pollinate/SKILL.md +175 -0
- package/plugins/lisa/skills/cross-pollinate/agents/openai.yaml +4 -0
- package/plugins/lisa-agy/commands/cross-pollinate.md +15 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/scripts/cross-pollinate.mjs +727 -0
- package/plugins/lisa-agy/skills/cross-pollinate/SKILL.md +175 -0
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/commands/cross-pollinate.md +15 -0
- package/plugins/lisa-copilot/scripts/cross-pollinate.mjs +727 -0
- package/plugins/lisa-copilot/skills/cross-pollinate/SKILL.md +175 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/commands/cross-pollinate.md +15 -0
- package/plugins/lisa-cursor/scripts/cross-pollinate.mjs +727 -0
- package/plugins/lisa-cursor/skills/cross-pollinate/SKILL.md +175 -0
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/commands/cross-pollinate.md +15 -0
- package/plugins/src/base/scripts/cross-pollinate.mjs +727 -0
- package/plugins/src/base/skills/cross-pollinate/SKILL.md +175 -0
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Cross-pollinate a host project's locally-authored coding-agent definitions
|
|
4
|
+
* across every agent the project supports.
|
|
5
|
+
*
|
|
6
|
+
* A host project that installs Lisa may hand-author a skill, subagent, rule,
|
|
7
|
+
* command, hook, or MCP entry for ONE coding agent (e.g. a `.claude/skills/foo`
|
|
8
|
+
* that only Claude sees). This engine detects those locally-authored
|
|
9
|
+
* definitions, normalizes each to a canonical intermediate representation (IR),
|
|
10
|
+
* and fans it out to the formats of the OTHER agents declared in the project's
|
|
11
|
+
* `.lisa.config.json` `harness`.
|
|
12
|
+
*
|
|
13
|
+
* Architecture (see the cross-pollinate SKILL.md for the full spec):
|
|
14
|
+
* any agent's format -> Claude-format IR -> every other configured agent
|
|
15
|
+
*
|
|
16
|
+
* Claude format is the IR because every existing Lisa generator already sources
|
|
17
|
+
* from it; emitting IR -> {cursor, codex, agy, copilot, opencode} reuses those
|
|
18
|
+
* battle-tested transforms instead of an N x N translator matrix.
|
|
19
|
+
*
|
|
20
|
+
* PROVENANCE is authoritative via a committed lockfile,
|
|
21
|
+
* `.lisa/cross-pollination.lock.json`. Path/location heuristics alone CANNOT
|
|
22
|
+
* distinguish a generated translation from a hand-authored original (both live
|
|
23
|
+
* in the same per-agent directory), so the lockfile is the only reliable way to:
|
|
24
|
+
* 1. prevent loops — a recorded target is never treated as a source
|
|
25
|
+
* 2. garbage-collect — an orphaned target (source deleted) is removed
|
|
26
|
+
* 3. protect edits — a target whose on-disk hash drifts from the recorded
|
|
27
|
+
* generated hash was hand-edited; never clobber it
|
|
28
|
+
* 4. stay idempotent — unchanged source + intact targets => no-op
|
|
29
|
+
*
|
|
30
|
+
* This engine is the deterministic core. It is invoked standalone
|
|
31
|
+
* (`node .../cross-pollinate.mjs <path> [--dry-run] [--json] [--write]`) and by
|
|
32
|
+
* the cross-pollinate skill, which layers in the judgment-heavy translations the
|
|
33
|
+
* engine reports as `pending`.
|
|
34
|
+
*
|
|
35
|
+
* @module cross-pollinate
|
|
36
|
+
*/
|
|
37
|
+
import { createHash } from "node:crypto";
|
|
38
|
+
import fs from "node:fs";
|
|
39
|
+
import path from "node:path";
|
|
40
|
+
|
|
41
|
+
const LOCKFILE_REL = path.join(".lisa", "cross-pollination.lock.json");
|
|
42
|
+
const LOCK_VERSION = 1;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Canonical coding agents Lisa can fan out to. Mirrors `EmitAgent` in
|
|
46
|
+
* src/core/config.ts; kept inline because this engine ships inside the plugin
|
|
47
|
+
* and runs in host projects without the Lisa TypeScript build on the path.
|
|
48
|
+
*/
|
|
49
|
+
const ALL_AGENTS = ["claude", "codex", "cursor", "agy", "copilot", "opencode"];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the agent set a `harness` value targets. Mirrors
|
|
53
|
+
* `harnessIncludesAgent` in src/core/config.ts (with the `all` -> `fleet`
|
|
54
|
+
* alias already normalized away on the config read path).
|
|
55
|
+
*
|
|
56
|
+
* @param {string} harness Canonical harness string from `.lisa.config.json`.
|
|
57
|
+
* @returns {string[]} Agents the harness includes.
|
|
58
|
+
*/
|
|
59
|
+
function agentsForHarness(harness) {
|
|
60
|
+
if (harness === "fleet") return [...ALL_AGENTS];
|
|
61
|
+
if (harness === "both") return ["claude", "codex"];
|
|
62
|
+
if (harness === "all") return [...ALL_AGENTS]; // defensive: pre-normalized alias
|
|
63
|
+
return ALL_AGENTS.includes(harness) ? [harness] : ["claude"];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Per-(agent, primitive) host-project location + format registry.
|
|
68
|
+
*
|
|
69
|
+
* Only entries we can place with confidence are marked `supported: true` and
|
|
70
|
+
* carry a `dir`/`ext`. Entries marked `supported: false` are intentionally NOT
|
|
71
|
+
* guessed — the engine reports detected definitions of that kind as `pending`
|
|
72
|
+
* so the skill (which reads the real on-disk layout) translates them with
|
|
73
|
+
* judgment rather than the engine writing a wrong path. This is deliberate:
|
|
74
|
+
* silently emitting to an unverified location is worse than reporting it.
|
|
75
|
+
*
|
|
76
|
+
* `kind` legend: skill | agent | rule | command | hook | mcp
|
|
77
|
+
*/
|
|
78
|
+
const HOST_LOCATIONS = {
|
|
79
|
+
skill: {
|
|
80
|
+
// Most agents consume the Claude SKILL.md format directly (OpenCode reads
|
|
81
|
+
// .claude/skills natively; agy/copilot ship SKILL.md verbatim). Codex is
|
|
82
|
+
// the one that needs a derived interface sidecar (agents/openai.yaml).
|
|
83
|
+
claude: { supported: true, dir: ".claude/skills", layout: "skill-dir" },
|
|
84
|
+
codex: { supported: true, dir: ".claude/skills", layout: "codex-sidecar" },
|
|
85
|
+
opencode: {
|
|
86
|
+
supported: true,
|
|
87
|
+
dir: ".claude/skills",
|
|
88
|
+
layout: "native-claude",
|
|
89
|
+
},
|
|
90
|
+
cursor: { supported: false },
|
|
91
|
+
agy: { supported: false },
|
|
92
|
+
copilot: { supported: false },
|
|
93
|
+
},
|
|
94
|
+
mcp: {
|
|
95
|
+
claude: { supported: true, file: ".mcp.json", layout: "json" },
|
|
96
|
+
cursor: { supported: true, file: ".cursor/mcp.json", layout: "json" },
|
|
97
|
+
codex: { supported: false },
|
|
98
|
+
agy: { supported: false },
|
|
99
|
+
copilot: { supported: false },
|
|
100
|
+
opencode: { supported: false },
|
|
101
|
+
},
|
|
102
|
+
rule: {
|
|
103
|
+
// Claude <-> Cursor is the clean, verified pair: a flat per-rule file in
|
|
104
|
+
// both, only the frontmatter + extension differ. codex/agy/opencode deliver
|
|
105
|
+
// project rules by merging into a shared AGENTS.md (section-marker management
|
|
106
|
+
// the engine does not fake) and copilot into .github/copilot-instructions.md
|
|
107
|
+
// — those stay `pending` for the skill to merge with judgment.
|
|
108
|
+
claude: {
|
|
109
|
+
supported: true,
|
|
110
|
+
dir: ".claude/rules",
|
|
111
|
+
ext: ".md",
|
|
112
|
+
layout: "rule-md",
|
|
113
|
+
},
|
|
114
|
+
cursor: {
|
|
115
|
+
supported: true,
|
|
116
|
+
dir: ".cursor/rules",
|
|
117
|
+
ext: ".mdc",
|
|
118
|
+
layout: "rule-mdc",
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
// Subagents, commands, hooks: detected by the scanner and reported as
|
|
122
|
+
// `pending` for the skill to translate. Their host locations differ per agent
|
|
123
|
+
// (and some are lossy/unsupported), so the engine does not hardcode them.
|
|
124
|
+
agent: {},
|
|
125
|
+
command: {},
|
|
126
|
+
hook: {},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Source-detection patterns: where a HUMAN authors each primitive, keyed by the
|
|
131
|
+
* agent whose format it is. The scanner walks these; the lockfile then filters
|
|
132
|
+
* out anything that is actually a generated target (loop prevention).
|
|
133
|
+
*
|
|
134
|
+
* @type {Array<{ kind: string, agent: string, glob: (root: string) => string[] }>}
|
|
135
|
+
*/
|
|
136
|
+
const SOURCE_SCANNERS = [
|
|
137
|
+
{
|
|
138
|
+
kind: "skill",
|
|
139
|
+
agent: "claude",
|
|
140
|
+
glob: root => listSkillDirs(path.join(root, ".claude/skills")),
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
kind: "agent",
|
|
144
|
+
agent: "claude",
|
|
145
|
+
glob: root => listFiles(path.join(root, ".claude/agents"), ".md"),
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
kind: "command",
|
|
149
|
+
agent: "claude",
|
|
150
|
+
glob: root => listFiles(path.join(root, ".claude/commands"), ".md", true),
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
kind: "rule",
|
|
154
|
+
agent: "claude",
|
|
155
|
+
glob: root => listFiles(path.join(root, ".claude/rules"), ".md", true),
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
kind: "rule",
|
|
159
|
+
agent: "cursor",
|
|
160
|
+
glob: root => listFiles(path.join(root, ".cursor/rules"), ".mdc"),
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
kind: "mcp",
|
|
164
|
+
agent: "claude",
|
|
165
|
+
glob: root => existing(path.join(root, ".mcp.json")),
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
kind: "mcp",
|
|
169
|
+
agent: "cursor",
|
|
170
|
+
glob: root => existing(path.join(root, ".cursor/mcp.json")),
|
|
171
|
+
},
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
/** @returns {string[]} Absolute paths to skill directories (those holding SKILL.md). */
|
|
175
|
+
function listSkillDirs(dir) {
|
|
176
|
+
if (!isDir(dir)) return [];
|
|
177
|
+
return fs
|
|
178
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
179
|
+
.filter(
|
|
180
|
+
e => e.isDirectory() && fs.existsSync(path.join(dir, e.name, "SKILL.md"))
|
|
181
|
+
)
|
|
182
|
+
.map(e => path.join(dir, e.name));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** @returns {string[]} Absolute file paths under `dir` with extension `ext`. */
|
|
186
|
+
function listFiles(dir, ext, recursive = false) {
|
|
187
|
+
if (!isDir(dir)) return [];
|
|
188
|
+
const out = [];
|
|
189
|
+
const walk = current => {
|
|
190
|
+
for (const e of fs.readdirSync(current, { withFileTypes: true })) {
|
|
191
|
+
const p = path.join(current, e.name);
|
|
192
|
+
if (e.isDirectory() && recursive) walk(p);
|
|
193
|
+
else if (e.isFile() && e.name.endsWith(ext)) out.push(p);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
walk(dir);
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** @returns {string[]} `[file]` if it exists, else `[]`. */
|
|
201
|
+
function existing(file) {
|
|
202
|
+
return fs.existsSync(file) ? [file] : [];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** @returns {boolean} */
|
|
206
|
+
function isDir(p) {
|
|
207
|
+
return fs.existsSync(p) && fs.statSync(p).isDirectory();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Stable content hash for a definition. For a skill DIRECTORY we hash the
|
|
212
|
+
* sorted (relpath, content) pairs of every file so a change anywhere in the
|
|
213
|
+
* skill invalidates it; for a single file we hash its bytes.
|
|
214
|
+
*
|
|
215
|
+
* @param {string} target Absolute path to a file or skill directory.
|
|
216
|
+
* @returns {string} Hex sha256.
|
|
217
|
+
*/
|
|
218
|
+
function hashDefinition(target) {
|
|
219
|
+
const h = createHash("sha256");
|
|
220
|
+
if (isDir(target)) {
|
|
221
|
+
const files = listFiles(target, "", true).sort();
|
|
222
|
+
for (const f of files) {
|
|
223
|
+
h.update(path.relative(target, f));
|
|
224
|
+
h.update("\0");
|
|
225
|
+
h.update(fs.readFileSync(f));
|
|
226
|
+
h.update("\0");
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
h.update(fs.readFileSync(target));
|
|
230
|
+
}
|
|
231
|
+
return h.digest("hex");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Logical identity of a definition, stable across agent formats. Two
|
|
236
|
+
* definitions sharing a logicalId are "the same thing" in different formats —
|
|
237
|
+
* the basis for both linking a source to its targets and detecting a
|
|
238
|
+
* human-authored collision.
|
|
239
|
+
*
|
|
240
|
+
* @param {string} kind
|
|
241
|
+
* @param {string} sourcePath Absolute path to the source definition.
|
|
242
|
+
* @returns {string} e.g. "skill:security-review"
|
|
243
|
+
*/
|
|
244
|
+
function logicalId(kind, sourcePath) {
|
|
245
|
+
// MCP config is a per-project singleton: `.mcp.json` (Claude) and
|
|
246
|
+
// `.cursor/mcp.json` (Cursor) are the SAME logical thing in different
|
|
247
|
+
// formats, so its identity must not derive from the (differing) filename —
|
|
248
|
+
// otherwise a genuine cross-agent collision reads as two unrelated configs.
|
|
249
|
+
if (kind === "mcp") return "mcp:project";
|
|
250
|
+
const base = isDir(sourcePath)
|
|
251
|
+
? path.basename(sourcePath)
|
|
252
|
+
: path.basename(sourcePath).replace(/\.[^.]+$/, "");
|
|
253
|
+
return `${kind}:${base}`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Read `.lisa.config.json` harness; default "claude". */
|
|
257
|
+
function readHarness(root) {
|
|
258
|
+
const p = path.join(root, ".lisa.config.json");
|
|
259
|
+
if (!fs.existsSync(p)) return "claude";
|
|
260
|
+
try {
|
|
261
|
+
const cfg = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
262
|
+
return typeof cfg.harness === "string" ? cfg.harness : "claude";
|
|
263
|
+
} catch {
|
|
264
|
+
return "claude";
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Read the provenance lockfile; returns an empty lock when absent. */
|
|
269
|
+
function readLock(root) {
|
|
270
|
+
const p = path.join(root, LOCKFILE_REL);
|
|
271
|
+
if (!fs.existsSync(p)) return { version: LOCK_VERSION, entries: {} };
|
|
272
|
+
try {
|
|
273
|
+
const parsed = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
274
|
+
return parsed && typeof parsed === "object" && parsed.entries
|
|
275
|
+
? parsed
|
|
276
|
+
: { version: LOCK_VERSION, entries: {} };
|
|
277
|
+
} catch {
|
|
278
|
+
return { version: LOCK_VERSION, entries: {} };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Persist the provenance lockfile (deterministic key ordering). */
|
|
283
|
+
function writeLock(root, lock) {
|
|
284
|
+
const p = path.join(root, LOCKFILE_REL);
|
|
285
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
286
|
+
const ordered = { version: LOCK_VERSION, entries: {} };
|
|
287
|
+
for (const key of Object.keys(lock.entries).sort()) {
|
|
288
|
+
ordered.entries[key] = lock.entries[key];
|
|
289
|
+
}
|
|
290
|
+
fs.writeFileSync(p, JSON.stringify(ordered, null, 2) + "\n");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The set of absolute paths the lockfile records as GENERATED targets. A path
|
|
295
|
+
* in this set is never treated as a source (loop prevention).
|
|
296
|
+
*
|
|
297
|
+
* @param {object} lock
|
|
298
|
+
* @param {string} root
|
|
299
|
+
* @returns {Set<string>} Absolute target paths.
|
|
300
|
+
*/
|
|
301
|
+
function generatedTargetPaths(lock, root) {
|
|
302
|
+
const set = new Set();
|
|
303
|
+
for (const entry of Object.values(lock.entries)) {
|
|
304
|
+
for (const t of entry.targets ?? []) {
|
|
305
|
+
set.add(path.resolve(root, t.path));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return set;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Plan cross-pollination: scan sources, classify against the lockfile, and
|
|
313
|
+
* compute the actions needed. Pure with respect to the filesystem (reads only).
|
|
314
|
+
*
|
|
315
|
+
* @param {string} root Absolute host-project root.
|
|
316
|
+
* @returns {object} A structured plan (see fields below).
|
|
317
|
+
*/
|
|
318
|
+
export function plan(root) {
|
|
319
|
+
const harness = readHarness(root);
|
|
320
|
+
const targetAgents = agentsForHarness(harness);
|
|
321
|
+
const lock = readLock(root);
|
|
322
|
+
const generated = generatedTargetPaths(lock, root);
|
|
323
|
+
|
|
324
|
+
// Scan every source location, skipping anything the lock says is generated.
|
|
325
|
+
const sources = [];
|
|
326
|
+
const byLogicalId = new Map();
|
|
327
|
+
for (const scanner of SOURCE_SCANNERS) {
|
|
328
|
+
for (const abs of scanner.glob(root)) {
|
|
329
|
+
if (generated.has(path.resolve(abs))) continue; // loop prevention
|
|
330
|
+
const id = logicalId(scanner.kind, abs);
|
|
331
|
+
const src = {
|
|
332
|
+
logicalId: id,
|
|
333
|
+
kind: scanner.kind,
|
|
334
|
+
agent: scanner.agent,
|
|
335
|
+
path: path.relative(root, abs),
|
|
336
|
+
abs,
|
|
337
|
+
hash: hashDefinition(abs),
|
|
338
|
+
};
|
|
339
|
+
sources.push(src);
|
|
340
|
+
const bucket = byLogicalId.get(id) ?? [];
|
|
341
|
+
bucket.push(src);
|
|
342
|
+
byLogicalId.set(id, bucket);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Conflicts: same logicalId authored independently in >1 agent (neither is a
|
|
347
|
+
// generated target). Never auto-translate over either — report for a human.
|
|
348
|
+
const conflicts = [];
|
|
349
|
+
for (const [id, bucket] of byLogicalId) {
|
|
350
|
+
if (bucket.length > 1) {
|
|
351
|
+
conflicts.push({
|
|
352
|
+
logicalId: id,
|
|
353
|
+
authoredIn: bucket.map(s => ({ agent: s.agent, path: s.path })),
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const conflictIds = new Set(conflicts.map(c => c.logicalId));
|
|
358
|
+
|
|
359
|
+
// For each non-conflicting source, decide which target agents need an emit,
|
|
360
|
+
// and which kinds are pending (no confident host location).
|
|
361
|
+
const emits = [];
|
|
362
|
+
const pending = [];
|
|
363
|
+
for (const src of sources) {
|
|
364
|
+
if (conflictIds.has(src.logicalId)) continue;
|
|
365
|
+
const entry = lock.entries[src.logicalId];
|
|
366
|
+
const sourceChanged = !entry || entry.source?.hash !== src.hash;
|
|
367
|
+
for (const agent of targetAgents) {
|
|
368
|
+
if (agent === src.agent) continue;
|
|
369
|
+
const loc = HOST_LOCATIONS[src.kind]?.[agent];
|
|
370
|
+
if (!loc || !loc.supported) {
|
|
371
|
+
pending.push({
|
|
372
|
+
logicalId: src.logicalId,
|
|
373
|
+
kind: src.kind,
|
|
374
|
+
from: src.agent,
|
|
375
|
+
to: agent,
|
|
376
|
+
});
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
// The agent consumes the source format natively (e.g. OpenCode reads
|
|
380
|
+
// .claude/skills directly) — there is nothing to emit, and it must NOT be
|
|
381
|
+
// reported as a perpetual "missing" emit. It is inherently satisfied.
|
|
382
|
+
if (loc.layout === "native-claude") continue;
|
|
383
|
+
const recordedTarget = entry?.targets?.find(t => t.agent === agent);
|
|
384
|
+
const targetAbs = recordedTarget
|
|
385
|
+
? path.resolve(root, recordedTarget.path)
|
|
386
|
+
: null;
|
|
387
|
+
const targetExists = targetAbs ? fs.existsSync(targetAbs) : false;
|
|
388
|
+
const targetDrifted =
|
|
389
|
+
targetExists &&
|
|
390
|
+
recordedTarget &&
|
|
391
|
+
hashDefinition(targetAbs) !== recordedTarget.generatedHash;
|
|
392
|
+
emits.push({
|
|
393
|
+
logicalId: src.logicalId,
|
|
394
|
+
kind: src.kind,
|
|
395
|
+
from: src.agent,
|
|
396
|
+
to: agent,
|
|
397
|
+
sourceAbs: src.abs,
|
|
398
|
+
reason: !targetExists
|
|
399
|
+
? "missing"
|
|
400
|
+
: sourceChanged
|
|
401
|
+
? "source-changed"
|
|
402
|
+
: targetDrifted
|
|
403
|
+
? "drift"
|
|
404
|
+
: "up-to-date",
|
|
405
|
+
drifted: Boolean(targetDrifted),
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Orphans: lockfile entries whose source no longer exists -> GC their targets.
|
|
411
|
+
const liveIds = new Set(sources.map(s => s.logicalId));
|
|
412
|
+
const orphans = [];
|
|
413
|
+
for (const [id, entry] of Object.entries(lock.entries)) {
|
|
414
|
+
if (!liveIds.has(id)) {
|
|
415
|
+
orphans.push({
|
|
416
|
+
logicalId: id,
|
|
417
|
+
targets: (entry.targets ?? []).map(t => t.path),
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
root,
|
|
424
|
+
harness,
|
|
425
|
+
targetAgents,
|
|
426
|
+
sources,
|
|
427
|
+
emits,
|
|
428
|
+
pending,
|
|
429
|
+
conflicts,
|
|
430
|
+
orphans,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Execute the deterministic emits a plan calls for (skills + mcp today),
|
|
436
|
+
* skipping `drift` emits (never clobber hand-edited targets), and update the
|
|
437
|
+
* lockfile. Judgment-heavy `pending` kinds are left for the skill.
|
|
438
|
+
*
|
|
439
|
+
* @param {object} p A plan from {@link plan}.
|
|
440
|
+
* @param {{ dryRun?: boolean }} [opts]
|
|
441
|
+
* @returns {{ written: object[], skippedDrift: object[], gc: string[] }}
|
|
442
|
+
*/
|
|
443
|
+
export function apply(p, opts = {}) {
|
|
444
|
+
const { root } = p;
|
|
445
|
+
const lock = readLock(root);
|
|
446
|
+
const written = [];
|
|
447
|
+
const skippedDrift = [];
|
|
448
|
+
const gc = [];
|
|
449
|
+
|
|
450
|
+
for (const emit of p.emits) {
|
|
451
|
+
if (emit.reason === "up-to-date") continue;
|
|
452
|
+
if (emit.drifted) {
|
|
453
|
+
skippedDrift.push(emit);
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
const result = emitOne(root, emit, opts);
|
|
457
|
+
if (!result) continue;
|
|
458
|
+
written.push({ ...emit, target: path.relative(root, result.abs) });
|
|
459
|
+
if (!opts.dryRun) recordTarget(lock, root, emit, result);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// GC orphaned targets.
|
|
463
|
+
for (const orphan of p.orphans) {
|
|
464
|
+
for (const rel of orphan.targets) {
|
|
465
|
+
const abs = path.resolve(root, rel);
|
|
466
|
+
if (fs.existsSync(abs)) {
|
|
467
|
+
gc.push(rel);
|
|
468
|
+
if (!opts.dryRun) fs.rmSync(abs, { recursive: true, force: true });
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (!opts.dryRun) delete lock.entries[orphan.logicalId];
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (!opts.dryRun) writeLock(root, lock);
|
|
475
|
+
return { written, skippedDrift, gc };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Emit a single target. Returns the written target's {abs} or null when the
|
|
480
|
+
* kind has no deterministic emitter (left to the skill).
|
|
481
|
+
*
|
|
482
|
+
* @param {string} root
|
|
483
|
+
* @param {object} emit
|
|
484
|
+
* @param {{ dryRun?: boolean }} opts
|
|
485
|
+
* @returns {{ abs: string } | null}
|
|
486
|
+
*/
|
|
487
|
+
function emitOne(root, emit, opts) {
|
|
488
|
+
const loc = HOST_LOCATIONS[emit.kind]?.[emit.to];
|
|
489
|
+
if (!loc || !loc.supported) return null;
|
|
490
|
+
|
|
491
|
+
if (emit.kind === "skill") return emitSkill(root, emit, loc, opts);
|
|
492
|
+
if (emit.kind === "mcp") return emitMcp(root, emit, loc, opts);
|
|
493
|
+
if (emit.kind === "rule") return emitRule(root, emit, loc, opts);
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Emit a rule to a target agent, translating between the Claude `.md` and Cursor
|
|
499
|
+
* `.mdc` flat-file formats.
|
|
500
|
+
*
|
|
501
|
+
* Cursor requires YAML frontmatter (`description` from the first H1, `alwaysApply`)
|
|
502
|
+
* and discovers only `.mdc`; Claude reads plain `.md`. The transform mirrors
|
|
503
|
+
* scripts/generate-cursor-plugin-artifacts.mjs (reimplemented inline because that
|
|
504
|
+
* generator operates on the nested plugin layout and is not importable from the
|
|
505
|
+
* plugin at host runtime).
|
|
506
|
+
*
|
|
507
|
+
* @param {string} root
|
|
508
|
+
* @param {object} emit
|
|
509
|
+
* @param {{ dir: string, ext: string, layout: string }} loc
|
|
510
|
+
* @param {{ dryRun?: boolean }} opts
|
|
511
|
+
* @returns {{ abs: string } | null}
|
|
512
|
+
*/
|
|
513
|
+
function emitRule(root, emit, loc, opts) {
|
|
514
|
+
const name = path.basename(emit.sourceAbs).replace(/\.(md|mdc)$/, "");
|
|
515
|
+
const outAbs = path.join(root, loc.dir, `${name}${loc.ext}`);
|
|
516
|
+
if (path.resolve(outAbs) === path.resolve(emit.sourceAbs)) return null;
|
|
517
|
+
|
|
518
|
+
const raw = fs.readFileSync(emit.sourceAbs, "utf8");
|
|
519
|
+
const body = stripFrontmatter(raw);
|
|
520
|
+
let contents;
|
|
521
|
+
if (loc.layout === "rule-mdc") {
|
|
522
|
+
const fm = `---\ndescription: ${yamlQuote(deriveRuleDescription(body, name))}\nalwaysApply: true\n---\n\n`;
|
|
523
|
+
contents = fm + rewriteRuleLinks(body, ".mdc");
|
|
524
|
+
} else {
|
|
525
|
+
contents = rewriteRuleLinks(body, ".md");
|
|
526
|
+
}
|
|
527
|
+
if (!opts.dryRun) {
|
|
528
|
+
fs.mkdirSync(path.dirname(outAbs), { recursive: true });
|
|
529
|
+
fs.writeFileSync(outAbs, contents);
|
|
530
|
+
}
|
|
531
|
+
return { abs: outAbs };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Emit a skill to a target agent. For native-Claude consumers (opencode) and
|
|
536
|
+
* the canonical Claude dir this is a no-op (they already read the source). For
|
|
537
|
+
* Codex, derive the `agents/openai.yaml` interface sidecar next to the SKILL.md.
|
|
538
|
+
*/
|
|
539
|
+
function emitSkill(_root, emit, loc, opts) {
|
|
540
|
+
if (loc.layout === "native-claude") return null; // agent reads source as-is
|
|
541
|
+
|
|
542
|
+
if (loc.layout === "codex-sidecar") {
|
|
543
|
+
const skillName = path.basename(emit.sourceAbs);
|
|
544
|
+
const skillMd = path.join(emit.sourceAbs, "SKILL.md");
|
|
545
|
+
if (!fs.existsSync(skillMd)) return null;
|
|
546
|
+
const fm = parseFrontmatter(fs.readFileSync(skillMd, "utf8"));
|
|
547
|
+
const outAbs = path.join(emit.sourceAbs, "agents", "openai.yaml");
|
|
548
|
+
const yaml = renderOpenAiInterface(
|
|
549
|
+
fm.name ?? skillName,
|
|
550
|
+
fm.description ?? ""
|
|
551
|
+
);
|
|
552
|
+
if (!opts.dryRun) {
|
|
553
|
+
fs.mkdirSync(path.dirname(outAbs), { recursive: true });
|
|
554
|
+
fs.writeFileSync(outAbs, yaml);
|
|
555
|
+
}
|
|
556
|
+
return { abs: outAbs };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Emit an MCP config by re-shaping the source JSON to the target agent's file. */
|
|
563
|
+
function emitMcp(root, emit, loc, opts) {
|
|
564
|
+
const raw = fs.readFileSync(emit.sourceAbs, "utf8");
|
|
565
|
+
let parsed;
|
|
566
|
+
try {
|
|
567
|
+
parsed = JSON.parse(raw);
|
|
568
|
+
} catch {
|
|
569
|
+
return null; // malformed source; leave to the skill to surface
|
|
570
|
+
}
|
|
571
|
+
const outAbs = path.join(root, loc.file);
|
|
572
|
+
if (path.resolve(outAbs) === path.resolve(emit.sourceAbs)) return null;
|
|
573
|
+
if (!opts.dryRun) {
|
|
574
|
+
fs.mkdirSync(path.dirname(outAbs), { recursive: true });
|
|
575
|
+
fs.writeFileSync(outAbs, JSON.stringify(parsed, null, 2) + "\n");
|
|
576
|
+
}
|
|
577
|
+
return { abs: outAbs };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Record/refresh a target in the lockfile after a successful emit. */
|
|
581
|
+
function recordTarget(lock, root, emit, result) {
|
|
582
|
+
const src = {
|
|
583
|
+
agent: emit.from,
|
|
584
|
+
path: path.relative(root, emit.sourceAbs),
|
|
585
|
+
hash: hashDefinition(emit.sourceAbs),
|
|
586
|
+
};
|
|
587
|
+
const entry = lock.entries[emit.logicalId] ?? { source: src, targets: [] };
|
|
588
|
+
entry.source = src;
|
|
589
|
+
const rel = path.relative(root, result.abs);
|
|
590
|
+
const generatedHash = hashDefinition(result.abs);
|
|
591
|
+
const existingIdx = entry.targets.findIndex(t => t.agent === emit.to);
|
|
592
|
+
const record = { agent: emit.to, path: rel, generatedHash };
|
|
593
|
+
if (existingIdx >= 0) entry.targets[existingIdx] = record;
|
|
594
|
+
else entry.targets.push(record);
|
|
595
|
+
entry.targets.sort((a, b) => a.agent.localeCompare(b.agent));
|
|
596
|
+
lock.entries[emit.logicalId] = entry;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Strip a leading `---`-fenced YAML frontmatter block, returning the body. */
|
|
600
|
+
function stripFrontmatter(text) {
|
|
601
|
+
return text.replace(/^---\n[\s\S]*?\n---\n?/, "");
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** YAML-quote a description value for `.mdc` frontmatter. */
|
|
605
|
+
function yamlQuote(value) {
|
|
606
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Derive a single-line rule description: the first H1, else a titleized slug.
|
|
611
|
+
* Mirrors generate-cursor-plugin-artifacts.mjs.
|
|
612
|
+
*
|
|
613
|
+
* @param {string} body Rule markdown body (frontmatter already stripped).
|
|
614
|
+
* @param {string} name Rule slug.
|
|
615
|
+
* @returns {string}
|
|
616
|
+
*/
|
|
617
|
+
function deriveRuleDescription(body, name) {
|
|
618
|
+
const withoutFences = body.replace(/^(```|~~~).*$[\s\S]*?^\1.*$/gm, "");
|
|
619
|
+
const h1 = /^#\s+(.+?)\s*$/m.exec(withoutFences);
|
|
620
|
+
const text = (h1 ? h1[1] : "").replace(/\s+/g, " ").trim();
|
|
621
|
+
if (text) return text;
|
|
622
|
+
return name
|
|
623
|
+
.split("-")
|
|
624
|
+
.map(part => (part ? part[0].toUpperCase() + part.slice(1) : part))
|
|
625
|
+
.join(" ")
|
|
626
|
+
.trim();
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Rewrite intra-rule markdown link extensions to the target rule format so links
|
|
631
|
+
* still resolve after translation (e.g. `](foo.md)` -> `](foo.mdc)`). Only the
|
|
632
|
+
* URL is touched; the link text is preserved. External/`http` links are left as-is.
|
|
633
|
+
*
|
|
634
|
+
* @param {string} body
|
|
635
|
+
* @param {".md"|".mdc"} targetExt
|
|
636
|
+
* @returns {string}
|
|
637
|
+
*/
|
|
638
|
+
function rewriteRuleLinks(body, targetExt) {
|
|
639
|
+
const otherExt = targetExt === ".mdc" ? ".md" : ".mdc";
|
|
640
|
+
const re = new RegExp(`\\]\\(([^)]+?)\\${otherExt}(#[^)]*)?\\)`, "g");
|
|
641
|
+
return body.replace(re, (match, base, fragment = "") =>
|
|
642
|
+
/^https?:/.test(base) ? match : `](${base}${targetExt}${fragment})`
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** Minimal YAML/Markdown frontmatter parser for `name`/`description`. */
|
|
647
|
+
function parseFrontmatter(text) {
|
|
648
|
+
const m = /^---\n([\s\S]*?)\n---/.exec(text);
|
|
649
|
+
const out = {};
|
|
650
|
+
if (!m) return out;
|
|
651
|
+
for (const line of m[1].split("\n")) {
|
|
652
|
+
const kv = /^(\w[\w-]*):\s*(.*)$/.exec(line);
|
|
653
|
+
if (kv) out[kv[1]] = kv[2].replace(/^["']|["']$/g, "").trim();
|
|
654
|
+
}
|
|
655
|
+
return out;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** Render a Codex `openai.yaml` interface from a skill's name/description. */
|
|
659
|
+
function renderOpenAiInterface(name, description) {
|
|
660
|
+
const display = name
|
|
661
|
+
.split("-")
|
|
662
|
+
.map(p => (p ? p[0].toUpperCase() + p.slice(1) : p))
|
|
663
|
+
.join(" ");
|
|
664
|
+
const short = description.split(/[.!?]/)[0].trim() || display;
|
|
665
|
+
const esc = s => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
666
|
+
return (
|
|
667
|
+
`display_name: ${esc(display)}\n` +
|
|
668
|
+
`short_description: ${esc(short)}\n` +
|
|
669
|
+
`default_prompt:\n` +
|
|
670
|
+
` - ${esc(`Use $${name}: ${short}.`)}\n`
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Render a human-readable report of a plan + apply result. */
|
|
675
|
+
export function renderReport(p, result) {
|
|
676
|
+
const lines = [];
|
|
677
|
+
lines.push(
|
|
678
|
+
`Cross-pollination — harness: ${p.harness} -> [${p.targetAgents.join(", ")}]`
|
|
679
|
+
);
|
|
680
|
+
lines.push(` sources detected: ${p.sources.length}`);
|
|
681
|
+
if (result) {
|
|
682
|
+
lines.push(` written: ${result.written.length}`);
|
|
683
|
+
if (result.skippedDrift.length)
|
|
684
|
+
lines.push(` skipped (edited): ${result.skippedDrift.length}`);
|
|
685
|
+
if (result.gc.length) lines.push(` garbage-collected:${result.gc.length}`);
|
|
686
|
+
}
|
|
687
|
+
if (p.conflicts.length) {
|
|
688
|
+
lines.push(` CONFLICTS (authored in >1 agent — not translated):`);
|
|
689
|
+
for (const c of p.conflicts)
|
|
690
|
+
lines.push(
|
|
691
|
+
` - ${c.logicalId}: ${c.authoredIn.map(a => a.agent).join(" + ")}`
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
if (p.pending.length) {
|
|
695
|
+
const byKind = {};
|
|
696
|
+
for (const x of p.pending) byKind[x.kind] = (byKind[x.kind] ?? 0) + 1;
|
|
697
|
+
lines.push(
|
|
698
|
+
` PENDING (need skill-driven translation): ${Object.entries(byKind)
|
|
699
|
+
.map(([k, n]) => `${k}×${n}`)
|
|
700
|
+
.join(", ")}`
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
return lines.join("\n");
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// CLI entrypoint.
|
|
707
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
708
|
+
const args = process.argv.slice(2);
|
|
709
|
+
const root = path.resolve(args.find(a => !a.startsWith("-")) ?? ".");
|
|
710
|
+
const dryRun = args.includes("--dry-run") || !args.includes("--write");
|
|
711
|
+
const asJson = args.includes("--json");
|
|
712
|
+
const p = plan(root);
|
|
713
|
+
const result = apply(p, { dryRun });
|
|
714
|
+
if (asJson) {
|
|
715
|
+
console.log(
|
|
716
|
+
JSON.stringify(
|
|
717
|
+
{ plan: p, result, dryRun },
|
|
718
|
+
(k, v) => (k === "abs" ? undefined : v),
|
|
719
|
+
2
|
|
720
|
+
)
|
|
721
|
+
);
|
|
722
|
+
} else {
|
|
723
|
+
console.log(renderReport(p, result));
|
|
724
|
+
if (dryRun)
|
|
725
|
+
console.log("\n(dry-run — pass --write to apply; default is dry-run)");
|
|
726
|
+
}
|
|
727
|
+
}
|