@worca/app 1.2.0-rc.1 → 1.2.0-rc.3

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.
@@ -0,0 +1,1169 @@
1
+ // src/core/workflow-export.mjs
2
+ // Thin core for "Export to Claude Code": turn a saved Composer workflow into a
3
+ // self-contained, runnable Claude Code skill tree under <dest>/.claude/. The
4
+ // generator is deterministic; only the generated skill's runtime is interpretive
5
+ // ("faithful simulation, not determinism"). See the v4 plan for the full design.
6
+ //
7
+ // Public surface:
8
+ // planExport(opts) -> resolve + classify; writes NOTHING (== dry-run)
9
+ // applyExport(opts) -> resolve + classify + apply resolutions + write
10
+ // exportWorkflow(opts) -> dispatcher: dryRun -> planExport else applyExport
11
+ // opts = { workflowId, destination:'global'|'project', projectDir?, slug?,
12
+ // includeAgents=true, dryRun?, onConflict?, resolutions?, repoRoot? }
13
+
14
+ import { createHash, randomBytes } from 'node:crypto';
15
+ import { existsSync } from 'node:fs';
16
+ import { readFile, writeFile, mkdir, cp, readdir, rename } from 'node:fs/promises';
17
+ import { join, resolve, dirname, sep, basename } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { readWorkflow, resolveGraph } from './workflows.mjs';
20
+ import { exportGraphJson, workflowFileSlug, summarizeUnknownAgents } from './workflow-share.mjs';
21
+ import { validatePluginDir, PLUGIN_NAME_RE } from './plugin-manifest.mjs';
22
+ import { registryPortsFn } from './graph/registry-ports.mjs';
23
+ import { validateGraph, formatIssue } from '../shared/graph/validate.mjs';
24
+ import { EFFORTS as EFFORT_LIST } from './model-env.mjs';
25
+ import { loadAgentRegistry } from './agent-registry.mjs';
26
+ import { slugify } from './artifacts.mjs';
27
+ import { isValidSkillName, collectRequiredSkills, resolveSkill, pluginSkillDirs } from './skills.mjs';
28
+ import { normalizeProjectPath } from './projects.mjs';
29
+ import { defaultRoot } from './settings.mjs';
30
+ import { buildGraphManifest } from '../shared/graph/manifest.mjs';
31
+ import { portsOf, findPort } from '../shared/graph/ports.mjs';
32
+
33
+ // ── v1-compat shim (Node-graph v2 rebase) ────────────────────────────────────
34
+ // The v2 refactor deleted src/core/channels.mjs (named-channel bus) and stopped
35
+ // exporting FRONTMATTER_RE from workflows.mjs. These helpers are consumed ONLY by
36
+ // this exporter to keep an exported skill's artifact paths byte-identical to what
37
+ // Worca writes at runtime; v2 runtime derives the same basenames from agent
38
+ // sidecar port templates, so the values below still match. Inlined here (rather
39
+ // than resurrecting channels.mjs) since the exporter is the sole consumer.
40
+
41
+ /** Canonical leading-`---` YAML frontmatter matcher (was workflows.mjs FRONTMATTER_RE).
42
+ * Group 1 is the inner YAML; the whole match (m[0]) is the fence block incl. its
43
+ * trailing newline when present. */
44
+ const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---\n?/;
45
+
46
+ /**
47
+ * The review-JSON basename for a producing role key (e.g. 'reviewer' -> 'impl-review').
48
+ * A custom key gets its own `<key>-review` (diverted to `<key>-agent-review` if that
49
+ * would collide with a built-in basename). The full file is `${base}-cycle${N}.json`.
50
+ */
51
+ function reviewJsonBasename(key) {
52
+ const k = key || 'reviewer';
53
+ const BESPOKE_BASE = {
54
+ reviewer: 'impl-review',
55
+ refiner: 'refine-review',
56
+ manualWebUiTesting: 'webui-review',
57
+ planReviewer: 'plan-review',
58
+ workspaceReviewer: 'ws-review',
59
+ };
60
+ let base = BESPOKE_BASE[k] || `${k}-review`;
61
+ if (!BESPOKE_BASE[k] && Object.values(BESPOKE_BASE).includes(base)) base = `${k}-agent-review`;
62
+ return base;
63
+ }
64
+
65
+ /** Basenames for the fixed single-file channels (still what v2 writes into the run dir). */
66
+ const CHANNEL_FILE_BASENAMES = {
67
+ checklist: 'manual-tests-checklist.md',
68
+ decomposition: 'decomposition.json',
69
+ clarify: 'clarify.json',
70
+ };
71
+
72
+ /** Basename for an open-vocabulary (custom) channel. A channelDef overrides kind
73
+ * (md|json) and filename; absent a def the channel defaults to `<id>.md`. */
74
+ function customChannelBasename(channel, { cycle = 1, channelDefs } = {}) {
75
+ const def = (channelDefs && channelDefs[channel]) || null;
76
+ const ext = def?.kind === 'json' ? 'json' : 'md';
77
+ const stem = String(def?.filename || `${channel}.${ext}`).replace(/\.(md|json)$/i, '');
78
+ return Number(cycle) > 1 ? `${stem}-cycle${cycle}.${ext}` : `${stem}.${ext}`;
79
+ }
80
+
81
+ /** v2 shim for the removed agent-registry collectChannelDefs(): v2 has no v1 channel
82
+ * defs (custom channels are ports now), so there is no legacy def map to collect. The
83
+ * v2 plan-walk port (see buildExportSet) will source custom-channel filenames from
84
+ * port templates instead. */
85
+ function collectChannelDefs() { return {}; }
86
+
87
+ // Inlined from ui/public/composer-core.mjs (DOM-free) to keep core free of a ui import.
88
+ // The browser module cannot import from src/core (no build step) and core avoids a ui import,
89
+ // so this is a deliberate copy — kept byte-equivalent to composer-core's distinctAgents and
90
+ // guarded against drift by test/workflow-export-generate.test.mjs. Exported for that guard.
91
+ export function distinctAgents(steps) {
92
+ const seen = [];
93
+ for (const col of steps) for (const node of col) if (!seen.includes(node.key)) seen.push(node.key);
94
+ return seen;
95
+ }
96
+
97
+ /** Coded error so surfaces can map to HTTP/exit codes. */
98
+ function err(message, code) { return Object.assign(new Error(message), { code }); }
99
+ // codes: BAD_REQUEST | NOT_FOUND | UNSUPPORTED | MISSING_SKILL | CONFLICT | CANCELLED
100
+
101
+ /**
102
+ * Emit a YAML single-quoted flow scalar for a frontmatter value. A single-quoted
103
+ * scalar treats every character literally except `'` (escaped by doubling), so a
104
+ * description containing `: `, ` #`, or `"` can never break the `---` fence — the
105
+ * class of bug an unquoted `description: ${raw}` interpolation invites. Newlines are
106
+ * pre-collapsed by callers so the value stays on one line.
107
+ */
108
+ function yamlScalar(s) { return `'${String(s == null ? '' : s).replace(/'/g, "''")}'`; }
109
+
110
+ /**
111
+ * Durable write: write to a temp sibling, then atomically rename over the target so a
112
+ * crash or concurrent read never observes a half-written file. A partial write would
113
+ * corrupt the content-hash stamp and make the NEXT export mis-classify the file as
114
+ * "locally modified" (a spurious conflict). Mirrors settings.mjs's persist pattern.
115
+ */
116
+ async function writeFileAtomic(path, text) {
117
+ const tmp = `${path}.${randomBytes(4).toString('hex')}.tmp`;
118
+ await writeFile(tmp, text, 'utf8');
119
+ await rename(tmp, path);
120
+ }
121
+
122
+ /** Blanket conflict policies accepted by applyExport's `onConflict` (CLI + API share these). */
123
+ export const ON_CONFLICT_MODES = ['skip', 'overwrite', 'namespace'];
124
+ /** Per-path resolution choices accepted in `resolutions` (see classify's `options`). */
125
+ export const RESOLUTION_CHOICES = ['keep', 'overwrite', 'namespace', 'cancel'];
126
+
127
+ // ── Step 1: destination + slug resolution & path-safety ──────────────────────
128
+
129
+ const STRIPPED_TOOLS = new Set([
130
+ 'AskUserQuestion', 'EnterPlanMode', 'ExitPlanMode', 'Workflow',
131
+ 'ScheduleWakeup', 'TaskOutput', 'WaitForMcpServers', 'EndConversation',
132
+ ]);
133
+ // Advisory efforts an exported node may carry — model-env's canonical list ('low' is
134
+ // intentionally absent there: Worca couples it to the model). Env-bound nodes are
135
+ // identified by the registry's scope (below), never a hardcoded key list.
136
+ const EFFORTS = new Set(EFFORT_LIST);
137
+
138
+ /** Resolve the destination ROOT (`.claude` is written beneath it). */
139
+ function resolveDest({ destination, projectDir }) {
140
+ if (destination === 'global') return resolve(defaultRoot());
141
+ if (destination === 'project') {
142
+ const p = normalizeProjectPath(projectDir);
143
+ if (!p) throw err('projectDir is required for a project export', 'BAD_REQUEST');
144
+ return p;
145
+ }
146
+ throw err(`destination must be 'global' or 'project' (got ${JSON.stringify(destination)})`, 'BAD_REQUEST');
147
+ }
148
+
149
+ /** Every write path MUST resolve inside dest/.claude — belt-and-suspenders over slug validation. */
150
+ function safeJoin(dest, ...parts) {
151
+ const full = resolve(dest, '.claude', ...parts);
152
+ const root = resolve(dest, '.claude') + sep; // containment is against dest/.claude, per the doc
153
+ if (!full.startsWith(root)) throw err(`refusing to write outside destination: ${full}`, 'BAD_REQUEST');
154
+ return full;
155
+ }
156
+
157
+ function assertName(name, what) {
158
+ if (!isValidSkillName(name)) throw err(`invalid ${what} "${name}" (allowed: letters, digits, . _ - ; not . or ..)`, 'BAD_REQUEST');
159
+ return name;
160
+ }
161
+
162
+ // ── Step 1: metadata stamp + content hash ────────────────────────────────────
163
+
164
+ // FRONTMATTER_RE is defined above (v1-compat shim). Group 1 is the inner YAML; the
165
+ // whole match (m[0]) is the fence block incl. its trailing newline.
166
+ // The trailing `\n\n` is part of the stamp region so the strip is the EXACT inverse of the insert.
167
+ const STAMP_RE = /<!--\s*worca-cc-export:\n([\s\S]*?)\n-->\n\n/; // the inert block + its blank line
168
+
169
+ function sha256(text) { return 'sha256:' + createHash('sha256').update(text, 'utf8').digest('hex'); }
170
+
171
+ /** stamp: { key, workflow, version, updatedAt, contentHash } -> comment block string */
172
+ function stampBlock(s) {
173
+ return `<!-- worca-cc-export:\nkey: ${s.key}\nworkflow: ${s.workflow}\nversion: ${s.version}\n` +
174
+ `updatedAt: ${s.updatedAt || ''}\ncontentHash: ${s.contentHash}\n-->`;
175
+ }
176
+ function parseStampBlock(text) {
177
+ const m = text.match(STAMP_RE);
178
+ if (!m) return null;
179
+ const out = {};
180
+ for (const line of m[1].split('\n')) {
181
+ const i = line.indexOf(':'); if (i < 0) continue;
182
+ out[line.slice(0, i).trim()] = line.slice(i + 1).trim();
183
+ }
184
+ return out;
185
+ }
186
+
187
+ /** Produce final .md bytes: hash the stampless body, then insert the stamp after frontmatter. */
188
+ function stampMarkdown(body, ident) { // body has NO stamp yet
189
+ const contentHash = sha256(body);
190
+ const block = stampBlock({ ...ident, contentHash }) + '\n\n'; // the '\n\n' is owned by STAMP_RE
191
+ const fm = body.match(FRONTMATTER_RE);
192
+ // Function replacer (NOT a string) so `$`-sequences in the frontmatter — e.g. a workflow
193
+ // name/description containing `$'`, `$&`, `$1` — are inserted literally instead of being
194
+ // interpreted by String.prototype.replace. fm[0] is the whole fence block.
195
+ const withStamp = fm ? body.replace(FRONTMATTER_RE, () => fm[0] + block) : block + body;
196
+ return { text: withStamp, contentHash };
197
+ }
198
+ /** Read an existing .md: recover its stamp + the hash of its current stampless body. */
199
+ function readMarkdownStamp(text) {
200
+ const stripped = text.replace(STAMP_RE, ''); // removes block AND the '\n\n' it added → exact inverse
201
+ return { stamp: parseStampBlock(text), currentHash: sha256(stripped) };
202
+ }
203
+
204
+ /** workflow.json: stamp lives in `_worca`; hash covers the semantic payload only. */
205
+ function stampJson(payload, ident) { // payload = {name,domain,steps,feedbacks}
206
+ const contentHash = sha256(JSON.stringify(payload));
207
+ const obj = { ...payload, _worca: { ...ident, contentHash } };
208
+ return { text: JSON.stringify(obj, null, 2) + '\n', contentHash };
209
+ }
210
+ function readJsonStamp(text) {
211
+ let obj; try { obj = JSON.parse(text); } catch { return { stamp: null, currentHash: null }; }
212
+ const { _worca, ...payload } = obj || {};
213
+ return { stamp: _worca || null, currentHash: sha256(JSON.stringify(payload)) };
214
+ }
215
+
216
+ // ── Step 1: classification ───────────────────────────────────────────────────
217
+
218
+ /**
219
+ * Compare an intended file (incoming) against what is on disk (existing).
220
+ * Returns { action:'create'|'noop'|'update'|'conflict', reason?, options? }.
221
+ * version is the primary tiebreak, updatedAt secondary, contentHash the idempotency key.
222
+ */
223
+ function classify(existing, incoming, { namespaceable }) {
224
+ if (!existing.exists) return { action: 'create' };
225
+ if (incoming.contentHash === existing.currentHash) return { action: 'noop' }; // idempotent
226
+ // Per-conflict options are derived from RESOLUTION_CHOICES (the single source the CLI and
227
+ // server validate against), never re-listed inline — so a new choice can't silently diverge
228
+ // from what applyExport accepts. 'cancel' is intentionally NOT offered per-conflict: it aborts
229
+ // the WHOLE export (see applyExport), which a per-file radio misrepresents as a per-file skip;
230
+ // a whole-export cancel is the modal's Cancel button. 'namespace' is offered only when the
231
+ // target can actually be renamed.
232
+ const CONFLICT = (reason) => ({
233
+ action: 'conflict', reason,
234
+ options: RESOLUTION_CHOICES.filter((c) => c !== 'cancel' && (namespaceable || c !== 'namespace')),
235
+ });
236
+ if (!existing.stamp) return CONFLICT('unmanaged file (no worca-cc-export metadata)');
237
+ if (existing.currentHash !== existing.stamp.contentHash) return CONFLICT('locally modified since last export');
238
+ if (existing.stamp.workflow !== incoming.workflow) return CONFLICT('different workflow lineage, content differs');
239
+ // Same lineage, and the on-disk file is byte-for-byte our last export (verified just
240
+ // above), so nothing the user authored is at risk. Order by version, then updatedAt.
241
+ const vi = Number(incoming.version) || 0, ve = Number(existing.stamp.version) || 0;
242
+ if (vi < ve) return CONFLICT('the exported copy is a newer version');
243
+ if (vi > ve) return { action: 'update' };
244
+ const ui = incoming.updatedAt || '', ue = existing.stamp.updatedAt || '';
245
+ if (ui && ue && ui < ue) return CONFLICT('the exported copy has a newer updatedAt');
246
+ // Equal identity (incl. frozen constants like wf_default: version 1, epoch updatedAt) but
247
+ // differing content ⇒ only the GENERATOR changed. Regenerate cleanly instead of forcing a
248
+ // CONFLICT on every legitimate refresh.
249
+ return { action: 'update' };
250
+ }
251
+
252
+ async function inspect(path, kind) { // kind: 'md' | 'json'
253
+ if (!existsSync(path)) return { exists: false, stamp: null, currentHash: null };
254
+ const text = await readFile(path, 'utf8');
255
+ const r = kind === 'json' ? readJsonStamp(text) : readMarkdownStamp(text);
256
+ return { exists: true, ...r };
257
+ }
258
+
259
+ // ── Step 2: capability layer ─────────────────────────────────────────────────
260
+
261
+ function applyCapabilityLayer(node) {
262
+ const consoleTools = new Set((Array.isArray(node.tools) ? node.tools : [])
263
+ .filter((t) => !STRIPPED_TOOLS.has(t))); // drop hard-stripped (incl. AskUserQuestion)
264
+ // Fan-out (A). The registry already resolves this per node (e.g. decomposer.meta.json
265
+ // sets fanOut:true), so trust node.fanOut rather than re-hardcoding keys here.
266
+ const fanOut = !!node.fanOut;
267
+ if (fanOut) { consoleTools.add('Agent'); consoleTools.add('Task'); }
268
+ // Effort (A) — advisory prose, and ONLY when a model is present (Worca couples them).
269
+ const effort = node.model && node.effort && EFFORTS.has(node.effort) ? node.effort : null;
270
+ return {
271
+ ...node,
272
+ consoleTools: [...consoleTools],
273
+ fanOut,
274
+ effort, // may be null
275
+ model: node.model || null, // omit from frontmatter when null
276
+ askQuestions: !!node.askQuestions, // (B) hoisted to the SKILL body
277
+ };
278
+ }
279
+
280
+ // ── Step 2: v2 ports/wires → console filenames ───────────────────────────────
281
+ // The exported skill keeps its own simplified `$RUN_DIR`-relative filenames (the
282
+ // "faithful simulation, not determinism" contract). v2 gives us the authoritative
283
+ // wiring via typed ports + wires; we translate each port to the SAME console name a
284
+ // v1 export used, so the exported-skill layout is unchanged. Producer/consumer paths
285
+ // are derived from the wires (no more `connectsTo` review-source guessing).
286
+
287
+ /** The artifact kind a port names — explicit `artifactKind`, else the `{base}-<kind>.md`
288
+ * capture (impl-review/plan-review/ws-review), else the port id (mirrors executor.mjs). */
289
+ function portArtifactKind(port) {
290
+ if (port?.artifactKind) return port.artifactKind;
291
+ const m = /^\{base\}-(.+)\.md$/.exec(port?.filename || '');
292
+ return m ? m[1] : (port?.id || '');
293
+ }
294
+
295
+ /**
296
+ * Console `$RUN_DIR`-relative filename an OUTPUT port writes, or:
297
+ * undefined → a pure control signal (void port; nothing on disk),
298
+ * null → the working tree (a code side-effect, no artifact file).
299
+ * A `when:'blocking'` output is a loop GATE — the runner reads the producer's verdict
300
+ * JSON (`<reviewJsonBasename(key)>-cycleN.json`), exactly as a real run writes it.
301
+ */
302
+ function outputConsoleFile(nodeKey, port, cycle = 1, channelDefs) {
303
+ if (!port || port.type === 'void') return undefined; // done/pass: control-flow only
304
+ if (port.when === 'blocking') return `${reviewJsonBasename(nodeKey)}-cycle${cycle}.json`;
305
+ switch (portArtifactKind(port)) {
306
+ case 'clarify': return CHANNEL_FILE_BASENAMES.clarify; // clarify.json
307
+ case 'plan': return cycle > 1 ? `plan-cycle${cycle}.md` : 'plan.md';
308
+ case 'decomposition': return CHANNEL_FILE_BASENAMES.decomposition;
309
+ case 'checklist': return CHANNEL_FILE_BASENAMES.checklist;
310
+ default:
311
+ // Open vocabulary (custom agent output). Prefer the port's own filename; else the
312
+ // shared basename builder — so an exported custom path matches what a run mints.
313
+ return port.filename
314
+ ? String(port.filename).replace(/\{base\}-?|\{vsuffix\}/g, '').replace(/\{cycle\}/g, String(cycle))
315
+ : customChannelBasename(portArtifactKind(port), { cycle, channelDefs });
316
+ }
317
+ }
318
+
319
+ /**
320
+ * The console file a consumer reads for a given INPUT port, tracing its inbound wire
321
+ * back to the producing OUTPUT port. Returns undefined (skip: unwired optional / signal),
322
+ * null (the working tree), or a `$RUN_DIR`-relative filename.
323
+ */
324
+ function consumedConsoleFile(graph, node, inPort, cycle, channelDefs) {
325
+ if (!inPort || inPort.synthetic) return undefined; // the synthetic `await` port
326
+ if (inPort.type === 'void' || inPort.as === 'worktree') return null; // the working tree / a signal
327
+ // The runner hoists AskUserQuestion: a clarify `answers` consumer reads the ANSWERS file.
328
+ if (inPort.id === 'answers' || inPort.as === 'answers') return 'clarify-answers.json';
329
+ const wire = (graph.template.wires || []).find((w) => w?.to?.node === node.id && w?.to?.port === inPort.id);
330
+ if (!wire) return undefined; // unwired optional input
331
+ return producerFileFor(graph, wire.from.node, wire.from.port, cycle, channelDefs, new Set());
332
+ }
333
+
334
+ /** Console file the producer at (srcNodeId, srcPortId) writes. Follows flow cards
335
+ * (task→prompt.md; and/or/combine→their first resolvable inbound) up to the source agent. */
336
+ function producerFileFor(graph, srcNodeId, srcPortId, cycle, channelDefs, seen) {
337
+ if (seen.has(srcNodeId)) return undefined; // cycle guard (loop wires)
338
+ seen.add(srcNodeId);
339
+ const tnode = (graph.template.nodes || []).find((n) => n.id === srcNodeId);
340
+ if (!tnode) return undefined;
341
+ if (tnode.kind === 'task') return 'prompt.md'; // the user prompt
342
+ if (tnode.kind === 'end') return undefined;
343
+ if (tnode.kind === 'agent') {
344
+ const key = graph.nodes[srcNodeId]?.key || tnode.key;
345
+ return outputConsoleFile(key, findPort(portsOf(graph.ports, tnode), srcPortId, 'out'), cycle, channelDefs);
346
+ }
347
+ // and/or/combine: a pass-through merge — resolve to the first inbound producer file.
348
+ for (const w of (graph.template.wires || []).filter((x) => x?.to?.node === srcNodeId)) {
349
+ const f = producerFileFor(graph, w.from.node, w.from.port, cycle, channelDefs, seen);
350
+ if (f !== undefined) return f;
351
+ }
352
+ return undefined;
353
+ }
354
+
355
+ /** Derive an agent node's input/output console files as ordered, de-duped lists (each
356
+ * entry is a `$RUN_DIR`-relative filename, or null for "the working tree"). Named
357
+ * `reads`/`writes` — the v1 `consumes`/`produces` vocabulary is retired and banned. */
358
+ function deriveNodeIo(graph, tnode, nodeKey, { cycle = 1, channelDefs } = {}) {
359
+ const ports = portsOf(graph.ports, tnode);
360
+ const uniq = (arr) => {
361
+ const seen = new Set(); const out = [];
362
+ for (const f of arr) { const k = f === null ? '\0worktree' : f; if (!seen.has(k)) { seen.add(k); out.push(f); } }
363
+ return out;
364
+ };
365
+ const reads = uniq(ports.inputs
366
+ .map((p) => consumedConsoleFile(graph, tnode, p, cycle, channelDefs))
367
+ .filter((f) => f !== undefined));
368
+ let writes = uniq(ports.outputs
369
+ .map((p) => outputConsoleFile(nodeKey, p, cycle, channelDefs))
370
+ .filter((f) => f !== undefined));
371
+ // A code side-effect node (implementer) has only a void `done` output but writes the
372
+ // working tree — preserve the v1 rendering rather than showing "(none)".
373
+ if (!writes.length && graph.nodes[tnode.id]?.meta?.sideEffect === 'code') writes = [null];
374
+ return { reads, writes };
375
+ }
376
+
377
+ // ── Step 2: buildExportSet (shared deterministic core) ───────────────────────
378
+
379
+ async function buildExportSet({ workflowId, destination, projectDir, slug, includeAgents = true, repoRoot }) {
380
+ const tpl = await readWorkflow(workflowId);
381
+ if (!tpl) throw err(`workflow not found: ${workflowId}`, 'NOT_FOUND');
382
+
383
+ const dest = resolveDest({ destination, projectDir });
384
+ const finalSlug = assertName(slug || slugify(tpl.name).slice(0, 48) || 'workflow', 'slug');
385
+
386
+ const registry = loadAgentRegistry();
387
+ const channelDefs = collectChannelDefs(registry); // custom channel kind/filename map (mirrors allocate())
388
+ // Global export = defaults-only (projectDir null, enabled by the §4.2 guard); project export bakes config.
389
+
390
+ // ── Resolve the v2 node/wire graph, then rebuild the v1-shaped { steps, feedbacks }
391
+ // plan the generator below reads. buildGraphManifest gives the topological rank
392
+ // LEVELS (its `{kind:'agents'}` cells) and the feedback loops; each cell is joined
393
+ // with resolveGraph's full node (tools/fanOut/askQuestions/model/effort/meta) and
394
+ // its consumes/produces (derived from ports + wires, §2). Flow cards (task/end/
395
+ // and/or/combine) are NOT dispatched — only agent nodes become steps.
396
+ const graph = await resolveGraph(destination === 'project' ? projectDir : null, workflowId, registry);
397
+ const manifest = buildGraphManifest(graph.template, graph.agentsByKey,
398
+ { overlays: { nodes: graph.nodes, wires: graph.wires } });
399
+ const tnodeById = new Map((graph.template.nodes || []).map((n) => [n.id, n]));
400
+ const enrich = (cell) => {
401
+ const rn = graph.nodes[cell.id] || {};
402
+ const io = deriveNodeIo(graph, tnodeById.get(cell.id), rn.key, { channelDefs });
403
+ return {
404
+ nodeId: cell.id, key: rn.key, uiPhase: cell.uiPhase,
405
+ tools: Array.isArray(rn.tools) ? rn.tools : [],
406
+ model: rn.model || null, effort: rn.effort || null,
407
+ fanOut: !!rn.fanOut, askQuestions: !!rn.askQuestions,
408
+ reads: io.reads, writes: io.writes,
409
+ };
410
+ };
411
+ const steps = manifest.steps
412
+ .filter((c) => c.kind === 'agents')
413
+ .map((c) => c.nodes.filter((cell) => cell.key).map(enrich)) // agent cells only (key !== null)
414
+ .filter((group) => group.length); // drop levels that were all flow cards
415
+ const resolved = { id: tpl.id, name: tpl.name, steps, feedbacks: manifest.feedbacks };
416
+
417
+ const warnings = [];
418
+
419
+ // ── Refuse environment-bound nodes (specific message: which node, why) ──
420
+ for (const group of resolved.steps) {
421
+ for (const node of group) {
422
+ if (registry[node.key]?.scope === 'workspace-only') {
423
+ throw err(
424
+ `node "${node.key}" (${node.nodeId}) requires a Worca multi-repo workspace ` +
425
+ `(member checkouts + per-member diffs) and cannot run as a single-repo console skill.`,
426
+ 'UNSUPPORTED',
427
+ );
428
+ }
429
+ const declared = Array.isArray(node.tools) ? node.tools : [];
430
+ const stripped = declared.filter((t) => STRIPPED_TOOLS.has(t) && t !== 'AskUserQuestion');
431
+ if (stripped.length) warnings.push(`node "${node.key}": dropped subagent-incompatible tool(s): ${stripped.join(', ')}`);
432
+ }
433
+ }
434
+
435
+ // ── Per-node capability layer (deterministic; produces console tools + body clauses) ──
436
+ const nodes = resolved.steps.map((group) => group.map((node) => applyCapabilityLayer(node)));
437
+
438
+ // ── Feedback loops: resolve each `from`/`to` instance-id to node keys -> gate basename ──
439
+ const nodeById = new Map();
440
+ for (const g of resolved.steps) for (const n of g) nodeById.set(n.nodeId, n);
441
+ const loops = resolved.feedbacks.map((fb) => {
442
+ const fromNode = nodeById.get(fb.from);
443
+ const toNode = nodeById.get(fb.to);
444
+ if (!fromNode) warnings.push(`feedback ${fb.id}: unknown 'from' node ${fb.from}; defaulting gate to impl-review`);
445
+ if (!toNode) warnings.push(`feedback ${fb.id}: unknown 'to' node ${fb.to}; the loop's fix target defaults to 'reviewer'`);
446
+ const fromKey = fromNode ? fromNode.key : 'reviewer';
447
+ return {
448
+ ...fb, fromKey, toKey: toNode ? toNode.key : null,
449
+ gateBasename: reviewJsonBasename(fromKey), selfLoop: fb.from === fb.to,
450
+ };
451
+ });
452
+
453
+ // ── Select distinct agents ──
454
+ const keys = distinctAgents(resolved.steps); // ordered unique node.key
455
+ const agents = keys.map((key) => {
456
+ const meta = registry[key] || {};
457
+ const stem = assertName(String(meta.agentFile || `${key}.md`).replace(/\.md$/, ''), 'agent name');
458
+ return { key, meta, stem };
459
+ });
460
+
461
+ // ── Resolve skill deps (resolve-and-fill; loud fail on unresolvable) ──
462
+ const depSkills = includeAgents ? resolveDepSkills(registry, graph.agentKeys, dest, destination, projectDir, repoRoot) : [];
463
+
464
+ // The SKILL.md always dispatches the pipeline's agents by subagent_type. With includeAgents
465
+ // off, none of those .md files are written — the skill only runs if the agents already exist at
466
+ // the destination. Warn loudly so a caller who exports agent-less by mistake isn't left with a
467
+ // skill that fails at its first dispatch, with no signal until run time.
468
+ if (!includeAgents && agents.length) {
469
+ warnings.push(
470
+ `agents NOT exported (includeAgents=false): the skill dispatches ${agents.map((a) => a.stem).join(', ')} — ` +
471
+ `those agents must already exist under the destination's .claude/agents or the skill will fail at dispatch.`,
472
+ );
473
+ }
474
+
475
+ // ── Stamp lineage identity shared by every emitted file ──
476
+ const ident = { workflow: tpl.id, version: tpl.version || 1, updatedAt: tpl.updatedAt || '' };
477
+
478
+ return { tpl, template: graph.template, dest, slug: finalSlug, resolved, nodes, loops, agents, depSkills, ident, warnings, includeAgents, agentSrcCache: new Map() };
479
+ }
480
+
481
+ // ── Step 3: SKILL.md generation ──────────────────────────────────────────────
482
+
483
+ /** Map a feedback instance id (e.g. 's2_0') to its node key using the resolved topology. */
484
+ function instanceKey(resolved, instanceId) {
485
+ for (const g of resolved.steps) for (const n of g) if (n.nodeId === instanceId) return n.key;
486
+ return 'reviewer';
487
+ }
488
+
489
+ function makeSkillMd(set, nameFor) {
490
+ const { tpl, slug, resolved, nodes, loops } = set;
491
+ const desc = (
492
+ `Run the "${tpl.name}" workflow (exported from Worca Composer) end-to-end in this repo: ` +
493
+ `${resolved.steps.map((g) => g.map((n) => n.uiPhase).join('+')).join(' → ')}. ` +
494
+ `Clarify asks you real questions; feedback loops iterate on critical/major issues.`
495
+ ).replace(/\n+/g, ' '); // collapse any newline (e.g. from tpl.name) so the quoted scalar stays one line
496
+
497
+ const L = [];
498
+ // description is YAML-quoted: tpl.name may contain ':', '#', or quotes that would otherwise
499
+ // break the frontmatter fence. slug is already validated to [A-Za-z0-9._-], so it needs none.
500
+ L.push('---', `name: ${slug}`, `description: ${yamlScalar(desc)}`, '---', '');
501
+ L.push(`# ${tpl.name} — exported pipeline`, '');
502
+ L.push('Run this pipeline yourself (you are the runner). Recommended: launch Claude Code with',
503
+ '`--permission-mode acceptEdits` so subagent edits do not block on prompts.', '');
504
+
505
+ // Invariants — hardening so a step/loop is never dropped.
506
+ L.push('## Invariants (never violate)', '',
507
+ '1. Run every step below in the given order. Never skip, merge, or reorder a step.',
508
+ '2. Nodes under the same step are dispatched IN PARALLEL — one message, multiple Task calls.',
509
+ '3. One node = one subagent (`subagent_type`). Never combine two nodes into one dispatch.',
510
+ '4. A loop gate is blocking ONLY for severity `critical` or `major`; any other/unknown severity is non-blocking.',
511
+ '5. The current cycle for a gate is the highest N present on disk (`ls`); start at 1.',
512
+ '6. Never exceed a loop\'s max cycles. At the cap with blocking issues open, STOP and ask the user.',
513
+ '7. Pass each subagent the EXACT absolute paths given here; artifacts flow only through those files.',
514
+ '8. Do all work on the run branch created in Setup; never switch back to or edit the user\'s original branch.',
515
+ '9. Never `git add`, `git commit`, or `git push`. The run leaves working-tree edits and `$RUN_DIR` artifacts on disk, uncommitted, for the user to review.', '');
516
+
517
+ // Runtime setup: isolate on a branch, keep every change uncommitted.
518
+ L.push('## Setup', '',
519
+ 'Isolate this run on its own git branch and keep every change **uncommitted** — this',
520
+ 'pipeline writes files (working-tree edits + `$RUN_DIR` artifacts) but never runs `git add`,',
521
+ '`git commit`, or `git push`. You review and commit yourself afterward.', '',
522
+ '```bash',
523
+ '# 1. Branch: reuse the branch the user named for this run, else cut a fresh one.',
524
+ '# Set BRANCH to the user-specified branch; leave it empty to auto-create.',
525
+ '# (Skips cleanly when the destination is not a git repository.)',
526
+ 'BRANCH=""',
527
+ 'if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then',
528
+ ' [ -n "$BRANCH" ] || BRANCH="worca/' + slug + '-$(date +%Y%m%d-%H%M%S)"',
529
+ ' git switch "$BRANCH" 2>/dev/null || git switch -c "$BRANCH"',
530
+ ' # keep run artifacts local-only (never staged/committed) via the repo-local exclude file',
531
+ ' grep -qxF "ai-artifacts/" .git/info/exclude 2>/dev/null || echo "ai-artifacts/" >> .git/info/exclude',
532
+ 'fi',
533
+ '',
534
+ '# 2. Run-local artifacts: saved on disk, never committed.',
535
+ 'RUN_DIR="ai-artifacts/pipelines/$(date +%Y-%m-%d)-' + slug + '"',
536
+ 'mkdir -p "$RUN_DIR"',
537
+ '```',
538
+ 'All `$RUN_DIR/...` paths below are absolute once `$RUN_DIR` is resolved. Write the user prompt to `$RUN_DIR/prompt.md` first.', '');
539
+
540
+ // Steps.
541
+ resolved.steps.forEach((group, i) => {
542
+ const flat = nodes[i];
543
+ L.push(`## Step ${i + 1}${flat.length > 1 ? ' (dispatch all nodes in parallel)' : ''}`, '');
544
+ flat.forEach((n) => {
545
+ const dispatch = nameFor(n.key);
546
+ // n.reads / n.writes are pre-derived console files (a `$RUN_DIR`-relative name,
547
+ // or null for "the working tree"), computed from v2 ports+wires in deriveNodeIo.
548
+ const render = (f) => (f ? `\`$RUN_DIR/${f}\`` : 'the working tree');
549
+ const readsLine = (n.reads || []).map(render).join(', ') || '(the user prompt)';
550
+ const writesLine = (n.writes || []).map(render).join(', ') || '(none)';
551
+ L.push(`### Dispatch \`${dispatch}\``);
552
+ L.push(`- Consumes: ${readsLine}`);
553
+ L.push(`- Produces: ${writesLine} (review/plan filenames shown at cycle 1; use the current cycle N at runtime)`);
554
+ if (n.model) L.push(`- Model: dispatch with \`model: ${n.model}\`.`);
555
+ if (n.effort) L.push(`- Effort: instruct the subagent to work at **${n.effort}** effort.`);
556
+ if (n.fanOut) L.push('- Fan-out: this subagent MAY dispatch parallel READ-ONLY research subagents (it has `Agent`). Skip for trivial single-file work.');
557
+ if (n.askQuestions) {
558
+ L.push('- Ask-user (hoisted): this subagent CANNOT ask you directly. It writes its questions as JSON',
559
+ ` to \`$RUN_DIR/${CHANNEL_FILE_BASENAMES.clarify}\` in AskUserQuestion's schema`,
560
+ ' `{"questions":[{"question","header","options","multiSelect"}]}`. After it returns, **YOU** call',
561
+ ' `AskUserQuestion` with those questions, then write the answers to `$RUN_DIR/clarify-answers.json`',
562
+ ' and pass that path into every later node that consumes `clarify`.');
563
+ }
564
+ L.push('');
565
+ });
566
+ });
567
+
568
+ // Feedback loops.
569
+ loops.forEach((fb) => {
570
+ const gate = `$RUN_DIR/${fb.gateBasename}-cycle<N>.json`;
571
+ const fromName = nameFor(fb.fromKey); // the DISPATCH name, not the raw node key
572
+ const target = fb.selfLoop ? 'itself' : nameFor(instanceKey(resolved, fb.to));
573
+ L.push(`## Feedback loop: ${fromName} → ${fb.selfLoop ? 'itself' : target} (max ${fb.maxCycles} cycles)`, '',
574
+ `1. After \`${fromName}\` writes \`${gate}\`, read that file (highest N on disk).`,
575
+ '2. Parse `{issues:[{severity,...}],summary}`. It is **blocking** if any issue severity (lower-cased) is `critical` or `major`.',
576
+ fb.selfLoop
577
+ ? `3. If blocking and N < ${fb.maxCycles}: re-dispatch \`${fromName}\` at cycle N+1 (it writes a fresh gate + updated artifact). Repeat.`
578
+ : `3. If blocking and N < ${fb.maxCycles}: dispatch \`${target}\` in FIX mode with the review path, then re-dispatch \`${fromName}\` at cycle N+1. Repeat.`,
579
+ '4. If NOT blocking: the loop is satisfied; continue.',
580
+ `5. If N reaches ${fb.maxCycles} with blocking issues still open: STOP, show the open critical/major issues, and ask the user whether to continue or accept.`, '');
581
+ });
582
+
583
+ return L.join('\n');
584
+ }
585
+
586
+ // ── Step 4: agent .md emission ───────────────────────────────────────────────
587
+
588
+ async function readAgentSource(meta, cache) {
589
+ const path = meta.agentPath; // absolute; computed by the registry for every origin
590
+ if (!path || !existsSync(path)) throw err(`agent source not found for "${meta.key}"`, 'NOT_FOUND');
591
+ // Optional per-export cache: a blanket --on-conflict=namespace classifies agents TWICE
592
+ // (a plain-stem discovery pass + the final pass). The raw source is identical across both,
593
+ // so cache it to avoid reading every agent file from disk a second time.
594
+ if (cache && cache.has(path)) return cache.get(path);
595
+ const src = await readFile(path, 'utf8');
596
+ if (cache) cache.set(path, src);
597
+ return src;
598
+ }
599
+ function bodyAfterFrontmatter(src) {
600
+ const m = src.match(FRONTMATTER_RE);
601
+ return m ? src.slice(m[0].length) : src;
602
+ }
603
+
604
+ async function makeAgentMd(agent, node, finalName, srcCache) {
605
+ const src = await readAgentSource(agent.meta, srcCache);
606
+ let body = bodyAfterFrontmatter(src)
607
+ .replace(/^MOCK_[A-Z_]+:.*$/gm, '') // drop stray mock markers
608
+ .trimStart();
609
+
610
+ // `tools:` frontmatter. An empty (or omitted) list makes a Claude Code subagent INHERIT
611
+ // ALL TOOLS. That's acceptable for an agent that declared none (it never had a restricted
612
+ // set) — we omit the line so it inherits cleanly. But if the agent DECLARED tools and every
613
+ // one was stripped as subagent-incompatible, emitting an empty list would silently broaden a
614
+ // restricted node to all tools — refuse instead. AskUserQuestion is compatible-via-hoist
615
+ // (buildExportSet treats it as compatible, NOT a dropped incompatible tool), so — exactly
616
+ // like an ask-only node (declares only AskUserQuestion) — a node that ALSO declares it may
617
+ // inherit all tools even when its other declared tools were all stripped. Refuse only when
618
+ // the node declared real tools, every one was stripped, AND it has no ask-hoist to fall back
619
+ // on; otherwise `[AskUserQuestion, Workflow]` would abort while `[AskUserQuestion]` succeeds.
620
+ const declared = Array.isArray(node.tools) ? node.tools : [];
621
+ const hasAskHoist = declared.includes('AskUserQuestion');
622
+ const declaredCount = declared.filter((t) => t !== 'AskUserQuestion').length;
623
+ if (!node.consoleTools.length && declaredCount && !hasAskHoist) {
624
+ throw err(`node "${node.key}" declared only subagent-incompatible tool(s) (all stripped); ` +
625
+ `an empty "tools:" list would make the exported agent inherit ALL tools. ` +
626
+ `Give it at least one console-compatible tool.`, 'BAD_REQUEST');
627
+ }
628
+ // YAML-quote the description: agent.meta.description is user-authored (via createAgent) and may
629
+ // contain ':', '#', or quotes that would otherwise break the frontmatter fence.
630
+ const agentDesc = (agent.meta.description || node.uiPhase + ' node exported from Worca').replace(/\n+/g, ' ');
631
+ const fm = ['---', `name: ${finalName}`, `description: ${yamlScalar(agentDesc)}`];
632
+ if (node.consoleTools.length) fm.push(`tools: ${node.consoleTools.join(', ')}`); // omit → inherit (declared none)
633
+ if (node.model) fm.push(`model: ${node.model}`); // omit entirely when unset
634
+ fm.push('---');
635
+
636
+ const preamble = [
637
+ '', '## Console adaptation (read first)',
638
+ 'You run as a dispatched Claude Code subagent — there is NO Worca orchestrator, SQLite store, or run channel.',
639
+ 'Ignore any text below that names a Worca orchestrator, database, MOCK markers, or specific minted filenames.',
640
+ '**The absolute file paths in your dispatch prompt are authoritative** — read your inputs from and write your',
641
+ 'outputs to exactly those paths, and nothing else.',
642
+ ];
643
+ if (node.fanOut) {
644
+ preamble.push('', 'You MAY dispatch parallel READ-ONLY research subagents (you have the `Agent` tool) to investigate',
645
+ 'multiple areas at once; synthesize their findings yourself. Skip this for trivial single-file work.');
646
+ }
647
+ if (node.askQuestions) {
648
+ preamble.push('', 'You CANNOT ask the user directly (no `AskUserQuestion`). When you need a decision, write your',
649
+ 'questions as JSON to the clarify path in your dispatch prompt, shaped',
650
+ '`{"questions":[{"question","header","options","multiSelect"}]}`; the runner asks and returns the answers.');
651
+ }
652
+ if (node.effort) preamble.push('', `Work at **${node.effort}** effort.`);
653
+
654
+ return `${fm.join('\n')}\n${preamble.join('\n')}\n\n${body}`.trimEnd() + '\n';
655
+ }
656
+
657
+ // ── Step 5: workflow.json snapshot ───────────────────────────────────────────
658
+
659
+ function makeWorkflowJson(set) {
660
+ const { tpl, template, slug, ident } = set;
661
+ // v2 lineage snapshot: the node/wire graph (was v1 steps/feedbacks). `template` is
662
+ // resolveGraph's resolved clone (agent keys post workspace-substitution); fall back
663
+ // to the stored tpl fields defensively.
664
+ const payload = {
665
+ name: tpl.name, domain: tpl.domain || 'general', version: 2,
666
+ nodes: template?.nodes ?? tpl.nodes ?? [], wires: template?.wires ?? tpl.wires ?? [],
667
+ };
668
+ return stampJson(payload, { // key marks skill lineage; carry version/updatedAt from ident
669
+ key: `skill:${slug}`, workflow: ident.workflow, version: ident.version, updatedAt: ident.updatedAt,
670
+ });
671
+ }
672
+
673
+ // ── Step 6: skill-dependency resolution — resolve-and-fill ───────────────────
674
+
675
+ /**
676
+ * Resolve-and-fill for the skills the given agents require. Exported for the
677
+ * plugin exporter (#421), which passes `layout:'plugin'` (the "already at the
678
+ * destination" probe becomes `<dest>/skills/<name>/SKILL.md`) and
679
+ * `skipSources:['bundle']` (a Worca-shipped skill is present on every host, so
680
+ * it is reported as skipped rather than copied — the agent analogue is "built-ins
681
+ * are never bundled"). Default options reproduce the Claude Code export exactly.
682
+ * @returns {Array<{skill, requiredBy, resolvedFrom, srcDir, fill:boolean, skipped?:true}>}
683
+ */
684
+ export function resolveDepSkills(registry, agentKeys, dest, destination, projectDir, repoRootOverride, opts = {}) {
685
+ const layout = opts.layout === 'plugin' ? 'plugin' : 'claude';
686
+ const skipSources = new Set(Array.isArray(opts.skipSources) ? opts.skipSources : []);
687
+ const required = collectRequiredSkills(registry, agentKeys); // [{skill, requiredBy, origin?}]
688
+ // fileURLToPath (not URL.pathname) — correct on Windows and for paths with spaces.
689
+ const repoRoot = repoRootOverride || resolve(fileURLToPath(new URL('../../', import.meta.url)));
690
+ const ctx = {
691
+ repoRoot,
692
+ projectDir: destination === 'project' ? projectDir : dest,
693
+ homeDir: defaultRoot(),
694
+ // Mirror the orchestrator's skillCtx (orchestrator.mjs) so a plugin-imported
695
+ // workflow that RUNS also EXPORTS: without pluginDirs/origin, resolveSkill's chain
696
+ // never probes the owning plugin and a plugin-bundled dep throws MISSING_SKILL.
697
+ pluginDirs: pluginSkillDirs(),
698
+ };
699
+ const out = [];
700
+ const missing = [];
701
+ for (const r of required) {
702
+ if (!isValidSkillName(r.skill)) { missing.push({ ...r, searched: [] }); continue; }
703
+ // resolve-and-fill idempotency: if the skill is ALREADY at the destination
704
+ // (dest/.claude/skills/<name>), leave it untouched. This must be checked directly
705
+ // rather than via resolveSkill.source, because resolveSkill's chain probes the
706
+ // source-repo `bundle` (repoRoot/skills) BEFORE the destination scopes — a bundle
707
+ // hit is a SOURCE to copy from, not proof the dep is present at the destination.
708
+ const destSkillMd = layout === 'plugin'
709
+ ? join(dest, 'skills', r.skill, 'SKILL.md')
710
+ : join(dest, '.claude', 'skills', r.skill, 'SKILL.md');
711
+ if (existsSync(destSkillMd)) {
712
+ out.push({ ...r, resolvedFrom: 'destination', srcDir: dirname(destSkillMd), fill: false });
713
+ continue;
714
+ }
715
+ // Not at the destination → locate a source (bundle/global/plugin/owner) to fill from.
716
+ const hit = resolveSkill(r.skill, { ...ctx, origin: r.origin ?? null }); // {source, path, searched}
717
+ if (hit.source && skipSources.has(hit.source)) {
718
+ out.push({ ...r, resolvedFrom: hit.source, srcDir: hit.path, fill: false, skipped: true });
719
+ continue;
720
+ }
721
+ if (hit.source) { out.push({ ...r, resolvedFrom: hit.source, srcDir: hit.path, fill: true }); continue; }
722
+ missing.push({ ...r, searched: hit.searched });
723
+ }
724
+ if (missing.length) { // mirror validateSkills posture (list searched paths)
725
+ const lines = missing.map((m) =>
726
+ ` - skill "${m.skill}" (required by ${m.requiredBy.join(', ')}) not found. Searched:\n` +
727
+ (m.searched || []).map((p) => ` ${p}`).join('\n'));
728
+ throw err(`Export failed: ${missing.length} required skill(s) unavailable:\n${lines.join('\n')}`, 'MISSING_SKILL');
729
+ }
730
+ return out; // only entries with fill:true get written (via cp of srcDir)
731
+ }
732
+
733
+ // ── Step 7: Plan & Apply ─────────────────────────────────────────────────────
734
+
735
+ /**
736
+ * Dispatch-name resolver. `nsKeys` is the EXPLICIT set of agent keys to namespace;
737
+ * a namespaced key maps to `<slug>-<stem>`, everything else to its plain `<stem>`.
738
+ */
739
+ function makeNameResolver(set, nsKeys = new Set()) {
740
+ const stemByKey = new Map(set.agents.map((a) => [a.key, a.stem]));
741
+ return (key) => {
742
+ const stem = stemByKey.get(key) || key;
743
+ return nsKeys.has(key) ? `${set.slug}-${stem}` : stem;
744
+ };
745
+ }
746
+
747
+ /**
748
+ * Which agent keys must be namespaced: any whose DEFAULT agent path carries an explicit
749
+ * `namespace` resolution, plus — when a blanket `--on-conflict=namespace` is in effect —
750
+ * every agent whose default-path target is currently a `conflict` (discovered by a plain-stem
751
+ * first pass in applyExport). Non-conflicting agents are never namespaced.
752
+ */
753
+ function namespacedKeys(set, resolutions, blanket, conflictedAgentKeys = new Set()) {
754
+ const ns = new Set();
755
+ for (const a of set.agents) {
756
+ const defPath = safeJoin(set.dest, 'agents', `${a.stem}.md`);
757
+ if (resolutions[defPath] === 'namespace') ns.add(a.key);
758
+ }
759
+ if (blanket === 'namespace') for (const k of conflictedAgentKeys) ns.add(k);
760
+ return ns;
761
+ }
762
+
763
+ /**
764
+ * Classify every intended file. `agentsOnly` skips the SKILL.md/workflow.json/dep-skill work
765
+ * and emits ONLY the agent targets — used by applyExport's blanket-namespace discovery pass,
766
+ * which only needs to know which AGENT default-paths conflict (regenerating the full skill body
767
+ * + workflow.json + deps a second time is pure waste). SKILL.md is FIRST when included so
768
+ * callers can locate it as targets[0].
769
+ */
770
+ async function classifyTargets(set, nameFor, { agentsOnly = false } = {}) {
771
+ const targets = [];
772
+ if (!agentsOnly) {
773
+ // SKILL.md (slug collision is a non-namespaceable conflict → guidance: choose a different --slug)
774
+ {
775
+ const body = makeSkillMd(set, nameFor);
776
+ const { text, contentHash } = stampMarkdown(body, { ...set.ident, key: `skill:${set.slug}` });
777
+ const path = safeJoin(set.dest, 'skills', set.slug, 'SKILL.md');
778
+ const action = classify(await inspect(path, 'md'), { contentHash, ...set.ident, workflow: set.ident.workflow }, { namespaceable: false });
779
+ targets.push({ path, text, kind: 'md', role: 'skill', ...action });
780
+ }
781
+ // workflow.json
782
+ {
783
+ const { text, contentHash } = makeWorkflowJson(set);
784
+ const path = safeJoin(set.dest, 'skills', set.slug, 'workflow.json');
785
+ const action = classify(await inspect(path, 'json'), { contentHash, ...set.ident }, { namespaceable: false });
786
+ targets.push({ path, text, kind: 'json', ...action });
787
+ }
788
+ }
789
+ // agents (namespaceable)
790
+ if (set.includeAgents) {
791
+ // map key -> its resolved node for capability flags
792
+ const nodeByKey = new Map();
793
+ for (const g of set.nodes) for (const n of g) if (!nodeByKey.has(n.key)) nodeByKey.set(n.key, n);
794
+ for (const a of set.agents) {
795
+ const finalName = nameFor(a.key);
796
+ const body = await makeAgentMd(a, nodeByKey.get(a.key), finalName, set.agentSrcCache);
797
+ const { text, contentHash } = stampMarkdown(body, { ...set.ident, key: a.stem });
798
+ const path = safeJoin(set.dest, 'agents', `${finalName}.md`);
799
+ const action = classify(await inspect(path, 'md'), { contentHash, ...set.ident, key: a.stem }, { namespaceable: true });
800
+ targets.push({ path, text, kind: 'md', agentKey: a.key, ...action }); // agentKey → blanket-namespace discovery
801
+ }
802
+ // dep skills (fill:true only) — not needed by the agentsOnly discovery pass
803
+ if (!agentsOnly) for (const d of set.depSkills.filter((x) => x.fill)) {
804
+ targets.push({ path: safeJoin(set.dest, 'skills', d.skill, 'SKILL.md'), copyFrom: join(d.srcDir, 'SKILL.md'),
805
+ kind: 'md', action: 'create', dep: d });
806
+ }
807
+ }
808
+ return targets;
809
+ }
810
+
811
+ export async function planExport(opts) {
812
+ const set = await buildExportSet(opts);
813
+ const targets = await classifyTargets(set, makeNameResolver(set)); // plain stems → conflicts at default paths
814
+ const bucket = { created: [], noop: [], updated: [], conflicts: [], warnings: set.warnings };
815
+ for (const t of targets) {
816
+ if (t.action === 'create') bucket.created.push(t.path);
817
+ else if (t.action === 'noop') bucket.noop.push(t.path);
818
+ else if (t.action === 'update') bucket.updated.push(t.path);
819
+ else bucket.conflicts.push({ path: t.path, reason: t.reason, options: t.options });
820
+ }
821
+ bucket.orphans = await findOrphans(set, makeNameResolver(set)); // reported, never auto-deleted (v1)
822
+ return bucket;
823
+ }
824
+
825
+ export async function applyExport(opts) {
826
+ const set = await buildExportSet(opts);
827
+ const resolutions = opts.resolutions || {}; // { [defaultPath]: 'keep'|'overwrite'|'namespace'|'cancel' }
828
+ const blanket = opts.onConflict || 'skip'; // CLI default 'skip' + report
829
+
830
+ // 'cancel' aborts the WHOLE export — it is a user's "stop, don't touch anything", not a
831
+ // per-file skip. Refuse before classifying or writing anything, naming the cancelled path(s).
832
+ const cancelled = Object.keys(resolutions).filter((p) => resolutions[p] === 'cancel');
833
+ if (cancelled.length) {
834
+ throw err(`export cancelled at ${cancelled.length} conflict(s); nothing was written:\n` +
835
+ cancelled.map((p) => ` - ${p}`).join('\n'), 'CANCELLED');
836
+ }
837
+
838
+ // A blanket --on-conflict=namespace needs a plain-stem FIRST pass to discover which agent
839
+ // targets conflict, so it namespaces EXACTLY those. No other policy uses it, so skip the
840
+ // extra classify for skip/overwrite. `agentsOnly` keeps the pass cheap — it needs agent
841
+ // conflicts only, not a full SKILL.md/workflow.json/dep regeneration.
842
+ let conflictedAgentKeys = new Set();
843
+ if (blanket === 'namespace') {
844
+ const firstPass = await classifyTargets(set, makeNameResolver(set), { agentsOnly: true });
845
+ conflictedAgentKeys = new Set(
846
+ firstPass.filter((t) => t.agentKey && t.action === 'conflict').map((t) => t.agentKey),
847
+ );
848
+ }
849
+
850
+ // Decide per-agent final dispatch name (namespacing) so SKILL.md dispatches the final name
851
+ // and the agent `name:` matches — byte-identical on both sides.
852
+ const nsKeys = namespacedKeys(set, resolutions, blanket, conflictedAgentKeys);
853
+ const nameFor = makeNameResolver(set, nsKeys);
854
+
855
+ const targets = await classifyTargets(set, nameFor);
856
+
857
+ // Reject a KNOWN choice applied to a target that never OFFERED it — e.g. 'namespace' on the
858
+ // non-namespaceable SKILL.md/workflow.json (classify returns options WITHOUT 'namespace' for
859
+ // those). Left unchecked, decisionFor would fall to writes()=false and SILENTLY skip the file:
860
+ // applyExport would report success while leaving a stale, non-runnable export on disk. (The
861
+ // server validates only against the global choice list, so this per-target check is the real
862
+ // gate.) We gate on RESOLUTION_CHOICES so an UNKNOWN value (e.g. the typo "Overwrite") still
863
+ // falls through to the fail-closed skip below, not a hard error. A namespaced agent's default
864
+ // path is renamed out of `targets`, so its 'namespace' resolution is never seen here.
865
+ for (const t of targets) {
866
+ const choice = resolutions[t.path];
867
+ if (choice && RESOLUTION_CHOICES.includes(choice) && t.action === 'conflict' &&
868
+ Array.isArray(t.options) && !t.options.includes(choice)) {
869
+ throw err(`resolution '${choice}' is not valid for ${t.path} (offered: ${t.options.join(', ')}); ` +
870
+ `a non-namespaceable file cannot be namespaced — choose 'overwrite', or a different --slug.`, 'BAD_REQUEST');
871
+ }
872
+ }
873
+
874
+ // Final per-target decision, computed ONCE (reused by the consistency guard AND the write
875
+ // loop). A conflict resolves to its per-path choice, else the blanket policy; anything not in
876
+ // {create,update,overwrite} leaves the file as-is (fail closed — an unrecognized resolution
877
+ // like the typo "Overwrite" must never clobber a user's file).
878
+ const decisionFor = (t) => t.action === 'conflict'
879
+ ? (resolutions[t.path] || (blanket === 'overwrite' ? 'overwrite' : 'keep'))
880
+ : t.action;
881
+ const writes = (d) => d === 'create' || d === 'update' || d === 'overwrite';
882
+
883
+ // Under blanket namespace, a conflict that renaming did NOT turn into a fresh 'create'
884
+ // (SKILL.md / workflow.json are non-namespaceable; a slug collision) cannot be resolved by
885
+ // namespacing. Refuse BEFORE writing anything — otherwise we would keep a stale SKILL.md
886
+ // that dispatches old plain names while emitting orphan <slug>-<stem> agents (a non-runnable
887
+ // export). A different --slug (or an explicit per-path resolution) is the fix.
888
+ if (blanket === 'namespace') {
889
+ const unresolved = targets.filter((t) => t.action === 'conflict' && !resolutions[t.path]);
890
+ if (unresolved.length) {
891
+ throw err(`--on-conflict=namespace cannot resolve ${unresolved.length} conflict(s) by renaming ` +
892
+ `(a slug/skill collision is not fixable by namespacing agents). Choose a different --slug, or ` +
893
+ `resolve per-path:\n` + unresolved.map((t) => ` - ${t.path} (${t.reason})`).join('\n'), 'CONFLICT');
894
+ }
895
+ }
896
+
897
+ // Namespace/SKILL consistency (applies to per-path resolutions too, not just blanket
898
+ // namespace). SKILL.md is regenerated to dispatch the <slug>-<stem> names of every namespaced
899
+ // agent. If SKILL.md itself is a conflict the user is NOT (re)writing (e.g. resolved 'keep',
900
+ // or skipped), the on-disk skill would keep dispatching the OLD plain names while we emit the
901
+ // renamed agents — an orphaned agent + a non-runnable skill. Refuse before any write.
902
+ if (nsKeys.size) {
903
+ const skillTarget = targets.find((t) => t.role === 'skill');
904
+ if (skillTarget) {
905
+ const d = decisionFor(skillTarget);
906
+ if (d !== 'noop' && !writes(d)) { // noop = on-disk already == our namespaced SKILL.md
907
+ throw err(`refusing a broken export: ${nsKeys.size} agent(s) would be namespaced (dispatched as ` +
908
+ `<slug>-<stem>), but SKILL.md (${skillTarget.path}) would be ${d === 'keep' ? 'kept' : 'skipped'} — ` +
909
+ `the on-disk skill would still dispatch the old plain names, orphaning the namespaced agents. ` +
910
+ `Resolve the SKILL.md conflict with 'overwrite', or choose a different --slug.`, 'CONFLICT');
911
+ }
912
+ }
913
+ }
914
+
915
+ const written = [], skipped = [];
916
+ for (const t of targets) {
917
+ // A namespaced agent already appears here as a fresh 'create' at the `<slug>-<stem>` path;
918
+ // its original default-path file is simply NOT in `targets` (nameFor renamed it) → left as-is.
919
+ const decision = decisionFor(t);
920
+ if (!writes(decision)) { skipped.push(t.path); continue; }
921
+ await mkdir(dirname(t.path), { recursive: true });
922
+ // dep skill: copy the whole source dir, but NEVER clobber a file the user already has there
923
+ // (a dep is only filled when its SKILL.md is absent, yet the dir may hold unrelated user
924
+ // files — force:false + errorOnExist:false leaves those untouched).
925
+ if (t.copyFrom) await cp(dirname(t.copyFrom), dirname(t.path), { recursive: true, force: false, errorOnExist: false });
926
+ else await writeFileAtomic(t.path, t.text);
927
+ written.push(t.path);
928
+ }
929
+ // Classification buckets from the SAME targets used for writing, so a caller (e.g. the CLI)
930
+ // can show the plan AND apply from a single pass instead of running the whole resolve+classify
931
+ // pipeline twice.
932
+ const plan = { created: [], updated: [], noop: [], conflicts: [] };
933
+ for (const t of targets) {
934
+ if (t.action === 'create') plan.created.push(t.path);
935
+ else if (t.action === 'update') plan.updated.push(t.path);
936
+ else if (t.action === 'noop') plan.noop.push(t.path);
937
+ else if (t.action === 'conflict') {
938
+ // Only report a conflict back as OUTSTANDING if the caller did not resolve it. A conflict
939
+ // resolved per-path (keep/overwrite/namespace) or by a blanket overwrite is done — echoing
940
+ // it would make a UI that treats every returned conflict as "still needs resolving" loop
941
+ // forever on a 'keep' that was honored by leaving the file untouched. Genuinely unresolved
942
+ // conflicts (blanket skip default, an invalid choice that fell to fail-closed skip, or a
943
+ // TOCTOU conflict that appeared after Plan) still surface so the caller can act.
944
+ const choice = resolutions[t.path];
945
+ const resolved = choice === 'keep' || choice === 'overwrite' || choice === 'namespace' || blanket === 'overwrite';
946
+ if (!resolved) plan.conflicts.push({ path: t.path, reason: t.reason, options: t.options });
947
+ }
948
+ }
949
+ // findOrphans with the REAL (namespacing-aware) resolver so a stale plain file left behind
950
+ // when a stem was namespaced is reported (see findOrphans).
951
+ return { ...plan, written, skipped, warnings: set.warnings, orphans: await findOrphans(set, nameFor) };
952
+ }
953
+
954
+ /** Dispatcher: a dry run classifies (writes nothing); anything else applies. Conflict
955
+ * handling defaults to blanket 'skip' inside applyExport when onConflict/resolutions are
956
+ * absent — a plain apply must NOT silently degrade to a no-op plan. */
957
+ export function exportWorkflow(opts) {
958
+ return opts && opts.dryRun ? planExport(opts) : applyExport(opts);
959
+ }
960
+
961
+ /**
962
+ * Scan <dest>/.claude/agents/*.md for files this workflow emitted before (stamp.workflow ===
963
+ * tpl.id) that it no longer emits. `nameFor` is the SAME dispatch-name resolver used to write
964
+ * this run, so orphans are judged by the FILENAME actually emitted for each stem — not the bare
965
+ * stem. That catches two cases with one rule:
966
+ * - a node removed from the workflow (its stem has no current file at all), and
967
+ * - a stale PLAIN file left behind after its stem was namespaced (the stem is still current,
968
+ * but under a `<slug>-<stem>.md` filename now, so the old `<stem>.md` is orphaned).
969
+ * A namespaced sibling is NOT falsely reported: its filename equals nameFor's output for its
970
+ * stem. (Note: planExport passes the plain resolver — it cannot know per-path namespacing
971
+ * intent — so a pre-existing namespaced file may surface as an orphan in a dry-run plan; orphans
972
+ * are informational and never auto-deleted, so this is a report-only over-list, not a hazard.)
973
+ */
974
+ async function findOrphans(set, nameFor) {
975
+ const dir = safeJoin(set.dest, 'agents');
976
+ if (!existsSync(dir)) return [];
977
+ // stem -> the filename this run emits for it (namespacing-aware).
978
+ const currentFileByStem = new Map(set.agents.map((a) => [a.stem, `${nameFor(a.key)}.md`]));
979
+ const orphans = [];
980
+ for (const name of await readdir(dir)) {
981
+ if (!name.endsWith('.md')) continue;
982
+ const { stamp } = readMarkdownStamp(await readFile(join(dir, name), 'utf8'));
983
+ if (!stamp || stamp.workflow !== set.ident.workflow) continue; // not ours (or unstamped)
984
+ const current = currentFileByStem.get(stamp.key);
985
+ if (!current || current !== name) {
986
+ orphans.push(join(dir, name)); // this workflow emitted it before; no longer referenced
987
+ }
988
+ }
989
+ return orphans;
990
+ }
991
+
992
+ // ── Export to plugin (#421) ──────────────────────────────────────────────────
993
+ // "Share this workflow with another Worca user", the durable way: a plugin
994
+ // folder the recipient `worca plugin link`s (or publishes) and updates with
995
+ // `worca plugin reimport`. Bundles the workflow (unstamped v2 graph), every USER
996
+ // agent it references (built-ins are on every host; another plugin's agent is
997
+ // refused — bundling it would create two owners) and every non-bundle skill
998
+ // those agents require. Deterministic: a re-export of an unchanged workflow with
999
+ // --keep-version is an all-no-op.
1000
+
1001
+ const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
1002
+
1003
+ /** Patch-bump a plain x.y.z; anything else yields null (caller warns). */
1004
+ function bumpPatch(version) {
1005
+ const m = SEMVER_RE.exec(String(version || ''));
1006
+ return m ? `${m[1]}.${m[2]}.${Number(m[3]) + 1}` : null;
1007
+ }
1008
+
1009
+ async function readTextOrNull(path) {
1010
+ try { return await readFile(path, 'utf8'); } catch { return null; }
1011
+ }
1012
+
1013
+ /**
1014
+ * Plan (dryRun) or write a plugin folder for one saved v2 workflow.
1015
+ * @param {{workflowId:string, targetDir:string, pluginName?:string, keepVersion?:boolean,
1016
+ * dryRun?:boolean, repoRoot?:string}} opts
1017
+ * @returns {Promise<{dir, name, slug, version, created:string[], updated:string[], noop:string[],
1018
+ * skipped:Array<{path:string, reason:string}>, conflicts:[], warnings:string[], orphans:[],
1019
+ * written:string[], validation:{ok:boolean, problems:Array}|null}>}
1020
+ * Error codes: BAD_REQUEST | NOT_FOUND | UNSUPPORTED | INVALID_GRAPH | CONFLICT | MISSING_SKILL
1021
+ */
1022
+ export async function exportWorkflowPlugin({ workflowId, targetDir, pluginName, keepVersion = false, dryRun = false, repoRoot } = {}) {
1023
+ if (!workflowId || typeof workflowId !== 'string') throw err('workflowId is required', 'BAD_REQUEST');
1024
+ if (!targetDir || typeof targetDir !== 'string' || !targetDir.trim()) throw err('a target plugin folder is required', 'BAD_REQUEST');
1025
+ const dir = resolve(targetDir);
1026
+ const tpl = await readWorkflow(workflowId);
1027
+ if (!tpl) throw err(`workflow not found: ${workflowId}`, 'NOT_FOUND');
1028
+ const payload = await exportGraphJson(workflowId); // UNSUPPORTED for a v1 row
1029
+ const slug = workflowFileSlug(tpl.id);
1030
+ const warnings = [];
1031
+
1032
+ // ── Manifest identity: an explicit --name must match an existing manifest; absent
1033
+ // a --name, an existing manifest's name is adopted, else the folder's basename.
1034
+ const manifestPath = join(dir, 'worca-cc-plugin.json');
1035
+ let existing = null;
1036
+ const existingText = await readTextOrNull(manifestPath);
1037
+ if (existingText !== null) {
1038
+ try { existing = JSON.parse(existingText); } catch (e) { throw err(`${manifestPath}: invalid JSON (${e.message})`, 'BAD_REQUEST'); }
1039
+ if (!existing || typeof existing !== 'object' || Array.isArray(existing)) throw err(`${manifestPath}: not a JSON object`, 'BAD_REQUEST');
1040
+ }
1041
+ const askedName = typeof pluginName === 'string' && pluginName.trim() ? pluginName.trim() : '';
1042
+ const name = askedName || (existing && typeof existing.name === 'string' && existing.name.trim()) || basename(dir);
1043
+ if (!PLUGIN_NAME_RE.test(name) || name.length > 64) {
1044
+ throw err(`plugin name must be kebab-case — lowercase letters, digits and hyphens (got "${name}")`, 'BAD_REQUEST');
1045
+ }
1046
+ if (existing && askedName && existing.name !== askedName) {
1047
+ throw err(`${dir} is plugin "${existing.name}" — pass --name ${existing.name} to update it, or choose another folder`, 'CONFLICT');
1048
+ }
1049
+
1050
+ // ── The stored graph must be runnable HERE before it is shared: a stranded
1051
+ // key (deleted agent) would only surface at the recipient's link.
1052
+ const registry = loadAgentRegistry();
1053
+ const { errors } = validateGraph({ ...payload, id: tpl.id }, registryPortsFn(registry));
1054
+ if (errors.length) {
1055
+ const summary = summarizeUnknownAgents(errors);
1056
+ throw Object.assign(
1057
+ err(`workflow ${tpl.id} cannot be exported: ${summary || errors.map(formatIssue).join('; ')}`, 'INVALID_GRAPH'),
1058
+ { errors, summary });
1059
+ }
1060
+
1061
+ // ── Agents: user-owned are bundled, built-ins never, another plugin's refused.
1062
+ const agentNodes = payload.nodes.filter((n) => n && n.kind === 'agent' && n.key);
1063
+ const keys = distinctAgents([agentNodes]);
1064
+ const userAgents = [];
1065
+ const foreign = [];
1066
+ const builtins = [];
1067
+ for (const key of keys) {
1068
+ const meta = registry[key];
1069
+ if (!meta) continue; // validateGraph refused above
1070
+ const origin = String(meta.origin || '');
1071
+ if (origin === 'builtin') builtins.push(key);
1072
+ else if (origin.startsWith('plugin:')) foreign.push({ key, plugin: origin.slice('plugin:'.length) });
1073
+ else userAgents.push({ key, meta });
1074
+ }
1075
+ if (foreign.length) {
1076
+ const list = foreign.map((f) => `"${f.key}" (owned by plugin "${f.plugin}")`).join(', ');
1077
+ throw err(`cannot bundle ${list} — a shared workflow bundles only your own agents; ` +
1078
+ 'the recipient installs that plugin alongside, or you duplicate the agent under your own name', 'UNSUPPORTED');
1079
+ }
1080
+ if (builtins.length) warnings.push(`built-in agent(s) not bundled (present on every Worca host): ${builtins.join(', ')}`);
1081
+
1082
+ // ── Skills the bundled agents require: filled from global/project/other-plugin
1083
+ // sources; a Worca-shipped (bundle) skill is skipped, like a built-in agent.
1084
+ const depSkills = resolveDepSkills(registry, userAgents.map((a) => a.key), dir, 'plugin', null, repoRoot,
1085
+ { layout: 'plugin', skipSources: ['bundle'] });
1086
+
1087
+ // ── Intended files ──
1088
+ const targets = []; // {path, text?, copyFrom?}
1089
+ const skipped = [];
1090
+ const noop = [];
1091
+ targets.push({ path: join(dir, 'workflows', `${slug}.json`), text: JSON.stringify(payload, null, 2) + '\n' });
1092
+ for (const { key, meta } of userAgents) {
1093
+ const mdPath = meta.agentPath || null;
1094
+ if (!mdPath || !existsSync(mdPath)) throw err(`agent source not found for "${key}"`, 'NOT_FOUND');
1095
+ const sidecarPath = join(dirname(mdPath), `${key}.meta.json`);
1096
+ if (!existsSync(sidecarPath)) throw err(`agent sidecar not found for "${key}" (${sidecarPath})`, 'NOT_FOUND');
1097
+ // Byte-identical copies: the plugin ships exactly what the exporter runs.
1098
+ targets.push({ path: join(dir, 'agents', `${key}.md`), text: await readFile(mdPath, 'utf8') });
1099
+ targets.push({ path: join(dir, 'agents', `${key}.meta.json`), text: await readFile(sidecarPath, 'utf8') });
1100
+ }
1101
+ for (const s of depSkills) {
1102
+ const skillMd = join(dir, 'skills', s.skill, 'SKILL.md');
1103
+ if (s.skipped) skipped.push({ path: skillMd, reason: `skill "${s.skill}" ships with Worca (${s.resolvedFrom}) — not bundled` });
1104
+ else if (!s.fill) noop.push(skillMd); // already in the folder: never touched
1105
+ else targets.push({ path: skillMd, copyFrom: join(s.srcDir, 'SKILL.md') });
1106
+ }
1107
+
1108
+ // ── Classify everything but the manifest (its version bump depends on this).
1109
+ const created = [];
1110
+ const updated = [];
1111
+ for (const t of targets) {
1112
+ if (t.copyFrom) { t.action = 'create'; created.push(t.path); continue; } // fill:true == absent
1113
+ const cur = await readTextOrNull(t.path);
1114
+ if (cur === null) { t.action = 'create'; created.push(t.path); }
1115
+ else if (cur === t.text) { t.action = 'noop'; noop.push(t.path); }
1116
+ else { t.action = 'update'; updated.push(t.path); }
1117
+ }
1118
+
1119
+ // ── Manifest: adopt what is there, record the export, bump the patch version
1120
+ // when this export changes a file (unless keepVersion).
1121
+ const changed = created.length + updated.length > 0;
1122
+ const manifest = existing ? { ...existing } : {
1123
+ name, version: '0.1.0',
1124
+ description: `Workflows shared from Worca — ${tpl.name}`,
1125
+ engines: { 'worca-cc-api': '>=3 <4' },
1126
+ };
1127
+ if (!manifest.name) manifest.name = name;
1128
+ let version = typeof manifest.version === 'string' ? manifest.version : '';
1129
+ if (existing && changed && !keepVersion) {
1130
+ const next = bumpPatch(version);
1131
+ if (next) version = next;
1132
+ else warnings.push(`manifest version "${version}" is not plain x.y.z — left unchanged (bump it yourself before publishing)`);
1133
+ }
1134
+ if (version) manifest.version = version;
1135
+ const prevWorca = existing && existing.worca && typeof existing.worca === 'object' && !Array.isArray(existing.worca) ? existing.worca : {};
1136
+ const prevExports = prevWorca.exports && typeof prevWorca.exports === 'object' && !Array.isArray(prevWorca.exports) ? prevWorca.exports : {};
1137
+ manifest.worca = {
1138
+ ...prevWorca,
1139
+ exports: { ...prevExports, [slug]: { workflowId: tpl.id, name: tpl.name, updatedAt: tpl.updatedAt || null } },
1140
+ };
1141
+ const manifestText = JSON.stringify(manifest, null, 2) + '\n';
1142
+ const manifestTarget = { path: manifestPath, text: manifestText };
1143
+ if (existingText === null) { manifestTarget.action = 'create'; created.push(manifestPath); }
1144
+ else if (existingText === manifestText) { manifestTarget.action = 'noop'; noop.push(manifestPath); }
1145
+ else { manifestTarget.action = 'update'; updated.push(manifestPath); }
1146
+ targets.push(manifestTarget);
1147
+
1148
+ const plan = {
1149
+ dir, name, slug, version: manifest.version || null,
1150
+ created, updated, noop, skipped, conflicts: [], warnings, orphans: [], written: [], validation: null,
1151
+ };
1152
+ if (dryRun) return plan;
1153
+
1154
+ // ── Apply ──
1155
+ for (const t of targets) {
1156
+ if (t.action === 'noop') continue;
1157
+ await mkdir(dirname(t.path), { recursive: true });
1158
+ // dep skill: copy the whole source dir; SKILL.md is absent by construction (fill:true), but
1159
+ // the dir may hold unrelated user files — force:false leaves those untouched.
1160
+ if (t.copyFrom) await cp(dirname(t.copyFrom), dirname(t.path), { recursive: true, force: false, errorOnExist: false });
1161
+ else await writeFileAtomic(t.path, t.text);
1162
+ plan.written.push(t.path);
1163
+ }
1164
+ // The folder must lint clean — the recipient's `worca plugin link` runs this same gate.
1165
+ const v = validatePluginDir(dir);
1166
+ plan.validation = { ok: v.ok, problems: v.problems };
1167
+ for (const p of v.problems) warnings.push(`plugin validation ${p.level}: ${p.message}`);
1168
+ return plan;
1169
+ }