@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.
- package/README.md +36 -2
- package/package.json +1 -1
- package/src/cli/worca-cc.mjs +358 -19
- package/src/core/ask/models.mjs +7 -1
- package/src/core/ask/turn.mjs +5 -0
- package/src/core/config.mjs +33 -4
- package/src/core/folder-dialog.mjs +21 -12
- package/src/core/marketplaces.mjs +13 -0
- package/src/core/model-env.mjs +41 -0
- package/src/core/model-test.mjs +2 -1
- package/src/core/plugin-manifest.mjs +45 -8
- package/src/core/run-harness.mjs +7 -0
- package/src/core/settings.mjs +61 -0
- package/src/core/title.mjs +72 -9
- package/src/core/ui-instance.mjs +262 -0
- package/src/core/workflow-export.mjs +1169 -0
- package/src/core/workflow-share.mjs +191 -0
- package/src/core/workflows.mjs +35 -4
- package/src/shared/graph/validate.mjs +5 -3
- package/ui/public/app.js +653 -22
- package/ui/public/ask-panel.mjs +4 -2
- package/ui/public/export-slug.mjs +30 -0
- package/ui/public/graph/composer.mjs +24 -3
- package/ui/public/graph/inspector.mjs +4 -1
- package/ui/public/index.html +127 -1
- package/ui/public/models-view.mjs +34 -3
- package/ui/public/style.css +65 -5
- package/ui/server.mjs +305 -90
|
@@ -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
|
+
}
|