@workflow-manager/runner 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +123 -12
  2. package/dist/adapters.d.ts +4 -0
  3. package/dist/adapters.js +11 -0
  4. package/dist/claudeCodeExecutor.d.ts +4 -0
  5. package/dist/claudeCodeExecutor.js +173 -0
  6. package/dist/cliRunRenderer.d.ts +36 -0
  7. package/dist/cliRunRenderer.js +286 -0
  8. package/dist/engine.d.ts +4 -2
  9. package/dist/engine.js +622 -44
  10. package/dist/events.d.ts +1 -1
  11. package/dist/events.js +4 -2
  12. package/dist/index.js +371 -41
  13. package/dist/manPage.d.ts +1 -0
  14. package/dist/manPage.js +147 -0
  15. package/dist/mockExecutor.d.ts +2 -2
  16. package/dist/mockExecutor.js +62 -13
  17. package/dist/opencodeExecutor.d.ts +2 -2
  18. package/dist/opencodeExecutor.js +101 -138
  19. package/dist/parser.js +98 -2
  20. package/dist/piAgentExecutor.d.ts +4 -0
  21. package/dist/piAgentExecutor.js +298 -0
  22. package/dist/remote/api.d.ts +1 -0
  23. package/dist/remote/commands.d.ts +2 -0
  24. package/dist/remote/commands.js +76 -4
  25. package/dist/runnerApi.d.ts +7 -0
  26. package/dist/runnerApi.js +221 -0
  27. package/dist/runnerSession.d.ts +62 -0
  28. package/dist/runnerSession.js +260 -0
  29. package/dist/runtimePreflight.d.ts +16 -0
  30. package/dist/runtimePreflight.js +189 -0
  31. package/dist/skillResolver.d.ts +6 -0
  32. package/dist/skillResolver.js +75 -0
  33. package/dist/types.d.ts +148 -2
  34. package/man/wfm.1 +54 -4
  35. package/package.json +28 -4
  36. package/skills/commit-discipline/SKILL.md +109 -0
  37. package/skills/doc-sync/SKILL.md +83 -0
  38. package/skills/repo-hygiene/SKILL.md +70 -0
  39. package/skills/spec-driven-development/SKILL.md +33 -0
  40. package/skills/workflow-manager-cli/SKILL.md +14 -9
package/dist/parser.js CHANGED
@@ -1,7 +1,57 @@
1
+ import { createHash } from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import matter from "gray-matter";
4
- const SUPPORTED_ADAPTERS = ["mock", "opencode", "codex", "claude-code"];
5
+ import { SUPPORTED_ADAPTERS, resolveTaskAdapter } from "./adapters.js";
6
+ const SKILL_NAME_PATTERN = /^[a-zA-Z0-9_.-]+$/;
7
+ function hashContentSha256(content) {
8
+ return createHash("sha256").update(content).digest("hex");
9
+ }
10
+ function isSafeSkillName(name) {
11
+ return !!name && SKILL_NAME_PATTERN.test(name);
12
+ }
13
+ function isAllowedLocalSkillSourcePath(source) {
14
+ if (!source || path.isAbsolute(source) || source.includes("\\") || source.includes(".."))
15
+ return false;
16
+ const normalized = path.posix.normalize(source);
17
+ const withoutDot = normalized.startsWith("./") ? normalized.slice(2) : normalized;
18
+ if (!withoutDot.startsWith("skills/"))
19
+ return false;
20
+ return withoutDot.endsWith("/SKILL.md");
21
+ }
22
+ function validateSkillEntry(name, entry, errors) {
23
+ if (!isSafeSkillName(name)) {
24
+ errors.push(`Invalid skill name: ${name}`);
25
+ }
26
+ if (!entry.content?.trim() && !entry.source?.trim()) {
27
+ errors.push(`Skill "${name}" must define content or source`);
28
+ }
29
+ if (entry.source && !isAllowedLocalSkillSourcePath(entry.source)) {
30
+ errors.push(`Skill "${name}" source must be under ./skills/**/SKILL.md`);
31
+ }
32
+ if (entry.contentSha256) {
33
+ if (!/^[a-f0-9]{64}$/.test(entry.contentSha256)) {
34
+ errors.push(`Skill "${name}" contentSha256 must be a 64-char lowercase hex SHA-256`);
35
+ }
36
+ else if (!entry.content?.trim()) {
37
+ errors.push(`Skill "${name}" defines contentSha256 but has no content`);
38
+ }
39
+ else if (hashContentSha256(entry.content) !== entry.contentSha256) {
40
+ errors.push(`Skill "${name}" contentSha256 does not match content`);
41
+ }
42
+ }
43
+ if (entry.upstream) {
44
+ if (entry.upstream.repo !== undefined && (!entry.upstream.repo || typeof entry.upstream.repo !== "string")) {
45
+ errors.push(`Skill "${name}" upstream.repo must be a non-empty string when present`);
46
+ }
47
+ if (entry.upstream.ref !== undefined && (!entry.upstream.ref || typeof entry.upstream.ref !== "string")) {
48
+ errors.push(`Skill "${name}" upstream.ref must be a non-empty string when present`);
49
+ }
50
+ if (entry.upstream.path !== undefined && (!entry.upstream.path || typeof entry.upstream.path !== "string")) {
51
+ errors.push(`Skill "${name}" upstream.path must be a non-empty string when present`);
52
+ }
53
+ }
54
+ }
5
55
  function normalizeWorkflow(data, source) {
6
56
  if (!data.key || !data.title || !Array.isArray(data.steps)) {
7
57
  throw new Error(`Invalid workflow ${source}: key, title, and steps are required`);
@@ -14,6 +64,7 @@ function normalizeWorkflow(data, source) {
14
64
  inputSchema: data.inputSchema ?? {},
15
65
  outputSchema: data.outputSchema ?? {},
16
66
  defaultRetryPolicy: data.defaultRetryPolicy ?? { maxAttempts: 1 },
67
+ skills: data.skills,
17
68
  steps: data.steps.map((s) => ({
18
69
  ...s,
19
70
  dependsOn: s.dependsOn ?? [],
@@ -22,7 +73,7 @@ function normalizeWorkflow(data, source) {
22
73
  taskSpec: s.taskSpec
23
74
  ? {
24
75
  ...s.taskSpec,
25
- adapterKey: (s.taskSpec.adapterKey ?? "mock"),
76
+ adapterKey: resolveTaskAdapter(s.taskSpec.adapterKey),
26
77
  init: {
27
78
  context: s.taskSpec.init?.context ?? {},
28
79
  skills: s.taskSpec.init?.skills ?? [],
@@ -35,6 +86,44 @@ function normalizeWorkflow(data, source) {
35
86
  })),
36
87
  };
37
88
  }
89
+ function findDependencyCycle(def) {
90
+ const stepsByKey = new Map(def.steps.map((step) => [step.key, step]));
91
+ const visiting = new Set();
92
+ const visited = new Set();
93
+ const path = [];
94
+ const visit = (stepKey) => {
95
+ if (visited.has(stepKey)) {
96
+ return null;
97
+ }
98
+ const pathIndex = path.indexOf(stepKey);
99
+ if (visiting.has(stepKey) && pathIndex >= 0) {
100
+ return [...path.slice(pathIndex), stepKey];
101
+ }
102
+ const step = stepsByKey.get(stepKey);
103
+ if (!step) {
104
+ return null;
105
+ }
106
+ visiting.add(stepKey);
107
+ path.push(stepKey);
108
+ for (const dependency of step.dependsOn ?? []) {
109
+ const cycle = visit(dependency);
110
+ if (cycle) {
111
+ return cycle;
112
+ }
113
+ }
114
+ path.pop();
115
+ visiting.delete(stepKey);
116
+ visited.add(stepKey);
117
+ return null;
118
+ };
119
+ for (const step of def.steps) {
120
+ const cycle = visit(step.key);
121
+ if (cycle) {
122
+ return cycle;
123
+ }
124
+ }
125
+ return null;
126
+ }
38
127
  export function parseWorkflowMarkdown(filePath) {
39
128
  const raw = fs.readFileSync(filePath, "utf-8");
40
129
  const parsed = matter(raw);
@@ -66,6 +155,9 @@ export function validateWorkflow(def) {
66
155
  errors.push("Workflow must define at least one step");
67
156
  return errors;
68
157
  }
158
+ for (const [name, entry] of Object.entries(def.skills ?? {})) {
159
+ validateSkillEntry(name, entry, errors);
160
+ }
69
161
  for (const step of def.steps) {
70
162
  if (!step.key || !step.key.trim()) {
71
163
  errors.push("Each step must define a non-empty key");
@@ -97,5 +189,9 @@ export function validateWorkflow(def) {
97
189
  errors.push(`Invalid validation mode for ${step.key}: ${mode}`);
98
190
  }
99
191
  }
192
+ const cycle = findDependencyCycle(def);
193
+ if (cycle) {
194
+ errors.push(`Circular dependency detected: ${cycle.join(" -> ")}`);
195
+ }
100
196
  return errors;
101
197
  }
@@ -0,0 +1,4 @@
1
+ import type { InputEnvelope, OutputEnvelope, StepDefinition, StepExecutionHooks, WorkflowDefinition } from "./types.js";
2
+ export declare const DEFAULT_PI_COMMAND = "pi";
3
+ export declare function normalizeTimeout(value: unknown, fallbackMs?: number): number;
4
+ export declare function executePiAgentStep(step: StepDefinition, input: InputEnvelope, attempt: number, workflow?: WorkflowDefinition, workflowFilePath?: string, hooks?: StepExecutionHooks): Promise<OutputEnvelope>;
@@ -0,0 +1,298 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { resolveSkill } from "./skillResolver.js";
6
+ export const DEFAULT_PI_COMMAND = "pi";
7
+ function asRecord(value) {
8
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
9
+ }
10
+ export function normalizeTimeout(value, fallbackMs = 600000) {
11
+ const timeout = Number(value ?? fallbackMs);
12
+ if (!Number.isFinite(timeout) || timeout <= 0)
13
+ return fallbackMs;
14
+ return Math.floor(timeout);
15
+ }
16
+ function makeResult(step, input, attempt, startedAt, status, reason, extra = {}, action = "PROCEED") {
17
+ return {
18
+ step_id: step.key,
19
+ execution_status: status,
20
+ qa_routing: { action, feedback_reason: reason },
21
+ mutated_payload: {
22
+ stepKey: step.key,
23
+ attempt,
24
+ adapter: input.priming_configuration.adapter ?? "pi-agent",
25
+ ...extra,
26
+ },
27
+ metadata: {
28
+ execution_time_ms: Date.now() - startedAt,
29
+ external_intervention_required: false,
30
+ },
31
+ };
32
+ }
33
+ function resolveCommand(payload) {
34
+ if (typeof payload.command === "string" && payload.command.trim())
35
+ return payload.command;
36
+ if (process.env.WFM_PI_AGENT_COMMAND?.trim())
37
+ return process.env.WFM_PI_AGENT_COMMAND;
38
+ return DEFAULT_PI_COMMAND;
39
+ }
40
+ function resolveArgs(payload) {
41
+ return Array.isArray(payload.args) ? payload.args.map((arg) => String(arg)) : [];
42
+ }
43
+ function resolveRunDir(payload) {
44
+ if (typeof payload.runDir === "string" && payload.runDir.trim()) {
45
+ fs.mkdirSync(payload.runDir, { recursive: true });
46
+ return payload.runDir;
47
+ }
48
+ return fs.mkdtempSync(path.join(os.tmpdir(), "wfm-pi-agent-"));
49
+ }
50
+ function buildInputFile(step, input, attempt, runDir, workflow, workflowFilePath) {
51
+ const resolvedSkills = [];
52
+ if (workflow && workflowFilePath) {
53
+ for (const name of input.priming_configuration.required_skills) {
54
+ const resolved = resolveSkill(name, workflow, workflowFilePath);
55
+ if (resolved) {
56
+ resolvedSkills.push({ name, origin: resolved.origin, content: resolved.content });
57
+ }
58
+ }
59
+ }
60
+ return {
61
+ input_envelope: input,
62
+ step,
63
+ workflow: workflow
64
+ ? {
65
+ key: workflow.key,
66
+ title: workflow.title,
67
+ description: workflow.description,
68
+ objectives: workflow.objectives,
69
+ inputSchema: workflow.inputSchema,
70
+ outputSchema: workflow.outputSchema,
71
+ }
72
+ : undefined,
73
+ resolved_skills: resolvedSkills,
74
+ run: { attempt, runDir },
75
+ };
76
+ }
77
+ function sanitizeSkillName(name) {
78
+ const safe = name.replace(/[^a-zA-Z0-9_.-]/g, "-");
79
+ return safe || "skill";
80
+ }
81
+ function writeSkillFiles(runDir, skills) {
82
+ const skillPaths = [];
83
+ skills.forEach((skill, index) => {
84
+ const skillDir = path.join(runDir, "skills", `${index}-${sanitizeSkillName(skill.name)}`);
85
+ fs.mkdirSync(skillDir, { recursive: true });
86
+ const skillPath = path.join(skillDir, "SKILL.md");
87
+ fs.writeFileSync(skillPath, skill.content, "utf-8");
88
+ skillPaths.push(skillPath);
89
+ });
90
+ return skillPaths;
91
+ }
92
+ function describeContext(context) {
93
+ if (typeof context === "string") {
94
+ return context.trim() ? context : null;
95
+ }
96
+ if (context && Object.keys(context).length > 0) {
97
+ return JSON.stringify(context, null, 2);
98
+ }
99
+ return null;
100
+ }
101
+ function buildPrompt(step, input, inputPath, outputPath) {
102
+ const lines = ["You are executing a single step of an orchestrated workflow run."];
103
+ if (input.global_context.primary_objective.trim()) {
104
+ lines.push("", `Workflow objective: ${input.global_context.primary_objective}`);
105
+ }
106
+ if (input.global_context.workflow_objectives.length > 0) {
107
+ lines.push(`Workflow goals: ${input.global_context.workflow_objectives.join("; ")}`);
108
+ }
109
+ lines.push("", `Step key: ${step.key}`);
110
+ const objective = step.objective ?? input.step_context.step_objective;
111
+ if (objective.trim()) {
112
+ lines.push(`Step objective: ${objective}`);
113
+ }
114
+ if (Object.keys(input.step_context.previous_output).length > 0) {
115
+ lines.push("", "Previous step output:", JSON.stringify(input.step_context.previous_output, null, 2));
116
+ }
117
+ const context = describeContext(input.priming_configuration.context);
118
+ if (context) {
119
+ lines.push("", "Additional context:", context);
120
+ }
121
+ if (input.priming_configuration.mcp_endpoints.length > 0) {
122
+ lines.push("", `MCP endpoints declared for this step: ${input.priming_configuration.mcp_endpoints.join(", ")}`);
123
+ }
124
+ lines.push("", `The full structured step input is available at ${inputPath}.`, "", "Complete the step objective now.", `When you are done, write a JSON result envelope to ${outputPath} with this shape:`, `{"step_id":"${step.key}","execution_status":"SUCCESS"|"FAILED"|"QA_REJECTED","qa_routing":{"action":"PROCEED"|"RETRY_CURRENT"|"ROLLBACK_PREVIOUS"|"RESTART_ALL","feedback_reason":"<short reason>"},"mutated_payload":{<step results>},"metadata":{"execution_time_ms":0,"external_intervention_required":false}}`, "If you cannot write that file, simply finish with a clear final response; a successful exit is recorded as step success and your response text becomes the step output.");
125
+ return lines.join("\n");
126
+ }
127
+ function buildPiArgs(payload, input, skillPaths, prompt) {
128
+ const args = resolveArgs(payload);
129
+ args.push("--print", "--no-session");
130
+ if (input.priming_configuration.model) {
131
+ args.push("--model", input.priming_configuration.model);
132
+ }
133
+ for (const systemPrompt of input.priming_configuration.system_prompts) {
134
+ args.push("--append-system-prompt", systemPrompt);
135
+ }
136
+ for (const skillPath of skillPaths) {
137
+ args.push("--skill", skillPath);
138
+ }
139
+ args.push(prompt);
140
+ return args;
141
+ }
142
+ function normalizeOutputEnvelope(raw, step, input, attempt, startedAt) {
143
+ const record = asRecord(raw);
144
+ const metadata = asRecord(record.metadata);
145
+ const qaRouting = asRecord(record.qa_routing);
146
+ const status = String(record.execution_status ?? "SUCCESS");
147
+ const action = String(qaRouting.action ?? "PROCEED");
148
+ return {
149
+ step_id: typeof record.step_id === "string" && record.step_id.trim() ? record.step_id : step.key,
150
+ execution_status: status,
151
+ qa_routing: {
152
+ action,
153
+ feedback_reason: typeof qaRouting.feedback_reason === "string" ? qaRouting.feedback_reason : "",
154
+ },
155
+ mutated_payload: {
156
+ stepKey: step.key,
157
+ attempt,
158
+ adapter: input.priming_configuration.adapter ?? "pi-agent",
159
+ ...asRecord(record.mutated_payload),
160
+ },
161
+ metadata: {
162
+ execution_time_ms: typeof metadata.execution_time_ms === "number" ? metadata.execution_time_ms : Date.now() - startedAt,
163
+ external_intervention_required: metadata.external_intervention_required === true,
164
+ intervention_details: asRecord(metadata.intervention_details),
165
+ },
166
+ };
167
+ }
168
+ export function executePiAgentStep(step, input, attempt, workflow, workflowFilePath, hooks) {
169
+ const startedAt = Date.now();
170
+ let payload;
171
+ let timeoutMs;
172
+ let runDir;
173
+ let inputPath;
174
+ let outputPath;
175
+ let command;
176
+ let args;
177
+ try {
178
+ payload = asRecord(step.taskSpec?.payload);
179
+ timeoutMs = normalizeTimeout(payload.timeoutMs);
180
+ runDir = resolveRunDir(payload);
181
+ inputPath = path.join(runDir, "input.json");
182
+ outputPath = path.join(runDir, "output.json");
183
+ // A fixed runDir is reused across attempts; a leftover output.json would be
184
+ // read as this attempt's result if the agent exits 0 without writing one.
185
+ fs.rmSync(outputPath, { force: true });
186
+ command = resolveCommand(payload);
187
+ const inputFile = buildInputFile(step, input, attempt, runDir, workflow, workflowFilePath);
188
+ fs.writeFileSync(inputPath, JSON.stringify(inputFile, null, 2), "utf-8");
189
+ const skillPaths = writeSkillFiles(runDir, inputFile.resolved_skills);
190
+ const prompt = buildPrompt(step, input, inputPath, outputPath);
191
+ fs.writeFileSync(path.join(runDir, "prompt.txt"), prompt, "utf-8");
192
+ args = buildPiArgs(payload, input, skillPaths, prompt);
193
+ }
194
+ catch (err) {
195
+ const result = makeResult(step, input, attempt, startedAt, "FAILED", `Pi setup failed: ${err.message}`);
196
+ hooks?.onFinished?.({ executionStatus: result.execution_status });
197
+ return Promise.resolve(result);
198
+ }
199
+ return new Promise((resolve) => {
200
+ let child;
201
+ try {
202
+ child = spawn(command, args, {
203
+ stdio: ["ignore", "pipe", "pipe"],
204
+ env: { ...process.env, WFM_PI_INPUT_FILE: inputPath, WFM_PI_OUTPUT_FILE: outputPath },
205
+ });
206
+ }
207
+ catch (err) {
208
+ resolve(makeResult(step, input, attempt, startedAt, "FAILED", err.message, {
209
+ command,
210
+ inputPath,
211
+ outputPath,
212
+ timeoutMs,
213
+ }));
214
+ return;
215
+ }
216
+ hooks?.onStarted?.({ command, inputPath, outputPath, timeoutMs });
217
+ const outChunks = [];
218
+ const errChunks = [];
219
+ child.stdout?.setEncoding("utf-8");
220
+ child.stderr?.setEncoding("utf-8");
221
+ child.stdout?.on("data", (chunk) => {
222
+ outChunks.push(chunk);
223
+ hooks?.onStdout?.(chunk);
224
+ });
225
+ child.stderr?.on("data", (chunk) => {
226
+ errChunks.push(chunk);
227
+ hooks?.onStderr?.(chunk);
228
+ });
229
+ let timedOut = false;
230
+ const timer = setTimeout(() => {
231
+ timedOut = true;
232
+ child.kill("SIGTERM");
233
+ const result = makeResult(step, input, attempt, startedAt, "FAILED", `timed out after ${timeoutMs}ms`, { command, inputPath, outputPath, timeoutMs, stdout: outChunks.join(""), stderr: errChunks.join("") });
234
+ hooks?.onFinished?.({ executionStatus: result.execution_status, timedOut: true });
235
+ resolve(result);
236
+ }, timeoutMs);
237
+ child.on("error", (err) => {
238
+ clearTimeout(timer);
239
+ if (timedOut)
240
+ return;
241
+ const result = makeResult(step, input, attempt, startedAt, "FAILED", err.message, {
242
+ command,
243
+ inputPath,
244
+ outputPath,
245
+ timeoutMs,
246
+ stdout: outChunks.join(""),
247
+ stderr: errChunks.join(""),
248
+ });
249
+ hooks?.onFinished?.({ executionStatus: result.execution_status });
250
+ resolve(result);
251
+ });
252
+ child.on("close", (code) => {
253
+ clearTimeout(timer);
254
+ if (timedOut)
255
+ return;
256
+ const stdout = outChunks.join("");
257
+ const stderr = errChunks.join("");
258
+ const exitStatus = code ?? 1;
259
+ if (exitStatus !== 0) {
260
+ const result = makeResult(step, input, attempt, startedAt, "FAILED", `${command} exited with status ${exitStatus}`, {
261
+ command,
262
+ inputPath,
263
+ outputPath,
264
+ exitStatus,
265
+ stdout,
266
+ stderr,
267
+ });
268
+ hooks?.onFinished?.({ executionStatus: result.execution_status, exitStatus });
269
+ resolve(result);
270
+ return;
271
+ }
272
+ if (!fs.existsSync(outputPath)) {
273
+ const result = makeResult(step, input, attempt, startedAt, "SUCCESS", "pi completed without a result envelope; using response text", { command, inputPath, outputPath, exitStatus, response: stdout.trim() });
274
+ hooks?.onFinished?.({ executionStatus: result.execution_status, exitStatus });
275
+ resolve(result);
276
+ return;
277
+ }
278
+ try {
279
+ const rawOutput = JSON.parse(fs.readFileSync(outputPath, "utf-8"));
280
+ const result = normalizeOutputEnvelope(rawOutput, step, input, attempt, startedAt);
281
+ hooks?.onFinished?.({ executionStatus: result.execution_status, exitStatus });
282
+ resolve(result);
283
+ }
284
+ catch (err) {
285
+ const result = makeResult(step, input, attempt, startedAt, "FAILED", `Invalid Pi result envelope: ${err.message}`, {
286
+ command,
287
+ inputPath,
288
+ outputPath,
289
+ exitStatus,
290
+ stdout,
291
+ stderr,
292
+ });
293
+ hooks?.onFinished?.({ executionStatus: result.execution_status, exitStatus });
294
+ resolve(result);
295
+ }
296
+ });
297
+ });
298
+ }
@@ -9,6 +9,7 @@ export interface RemoteSearchResultItem {
9
9
  latestVersion: string | null;
10
10
  sourceFormat: string | null;
11
11
  publishedState: string | null;
12
+ tags: string[];
12
13
  updatedAt: string;
13
14
  createdAt: string;
14
15
  }
@@ -1,3 +1,5 @@
1
+ import type { WorkflowDefinition } from "../types.js";
2
+ export declare function bundleSkills(workflow: WorkflowDefinition, workflowFilePath: string): WorkflowDefinition;
1
3
  export declare function cmdAuth(args: string[]): Promise<number>;
2
4
  export declare function cmdSearch(args: string[]): Promise<number>;
3
5
  export declare function cmdPublish(filePath: string, args: string[]): Promise<number>;
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { parseWorkflowFile, validateWorkflow } from "../parser.js";
@@ -31,6 +32,64 @@ function splitOwnerSlug(value) {
31
32
  function sourceFormatFromPath(filePath) {
32
33
  return path.extname(filePath).toLowerCase() === ".json" ? "json" : "markdown";
33
34
  }
35
+ function hashContentSha256(content) {
36
+ return createHash("sha256").update(content).digest("hex");
37
+ }
38
+ function isAllowedLocalSkillSourcePath(source) {
39
+ if (!source || path.isAbsolute(source) || source.includes("\\") || source.includes(".."))
40
+ return false;
41
+ const normalized = path.posix.normalize(source);
42
+ const withoutDot = normalized.startsWith("./") ? normalized.slice(2) : normalized;
43
+ if (!withoutDot.startsWith("skills/"))
44
+ return false;
45
+ return withoutDot.endsWith("/SKILL.md");
46
+ }
47
+ function resolveAllowedLocalSkillSourcePath(workflowDir, source) {
48
+ if (!isAllowedLocalSkillSourcePath(source)) {
49
+ throw new Error(`Skill source must be under ./skills/**/SKILL.md: ${source}`);
50
+ }
51
+ const sourcePath = path.resolve(workflowDir, source);
52
+ const allowedRoot = path.resolve(workflowDir, "skills");
53
+ if (!sourcePath.startsWith(`${allowedRoot}${path.sep}`)) {
54
+ throw new Error(`Skill source escapes ./skills directory: ${source}`);
55
+ }
56
+ if (path.basename(sourcePath) !== "SKILL.md") {
57
+ throw new Error(`Skill source must point to SKILL.md: ${source}`);
58
+ }
59
+ return sourcePath;
60
+ }
61
+ export function bundleSkills(workflow, workflowFilePath) {
62
+ if (!workflow.skills)
63
+ return workflow;
64
+ const workflowDir = path.dirname(path.resolve(workflowFilePath));
65
+ const bundled = {};
66
+ for (const [name, entry] of Object.entries(workflow.skills)) {
67
+ if (entry.content && entry.content.trim()) {
68
+ const content = entry.content;
69
+ bundled[name] = {
70
+ ...entry,
71
+ content,
72
+ contentSha256: hashContentSha256(content),
73
+ };
74
+ continue;
75
+ }
76
+ if (!entry.source) {
77
+ throw new Error(`Skill "${name}" has neither content nor source`);
78
+ }
79
+ const sourcePath = resolveAllowedLocalSkillSourcePath(workflowDir, entry.source);
80
+ if (!fs.existsSync(sourcePath)) {
81
+ throw new Error(`Skill "${name}" source file not found: ${sourcePath}`);
82
+ }
83
+ const content = fs.readFileSync(sourcePath, "utf-8");
84
+ bundled[name] = {
85
+ source: entry.source,
86
+ upstream: entry.upstream,
87
+ content,
88
+ contentSha256: hashContentSha256(content),
89
+ };
90
+ }
91
+ return { ...workflow, skills: bundled };
92
+ }
34
93
  function normalizeTags(raw) {
35
94
  if (!raw) {
36
95
  return [];
@@ -116,15 +175,19 @@ export async function cmdPublish(filePath, args) {
116
175
  const publishedState = hasFlag(args, "--draft") ? "draft" : "published";
117
176
  const tags = normalizeTags(getFlag(args, "--tag"));
118
177
  const changelog = getFlag(args, "--changelog")?.trim() || null;
178
+ const bundled = bundleSkills(workflow, resolvedPath);
179
+ const hasSkills = Object.keys(bundled.skills ?? {}).length > 0;
180
+ const sourceFormat = hasSkills ? "json" : sourceFormatFromPath(resolvedPath);
181
+ const publishSource = hasSkills ? JSON.stringify(bundled, null, 2) : rawSource;
119
182
  const result = await publishRemoteWorkflow({
120
183
  slug,
121
184
  title,
122
185
  description,
123
186
  visibility,
124
187
  versionLabel,
125
- sourceFormat: sourceFormatFromPath(resolvedPath),
126
- rawSource,
127
- definition: workflow,
188
+ sourceFormat,
189
+ rawSource: publishSource,
190
+ definition: bundled,
128
191
  tags,
129
192
  changelog,
130
193
  publishedState,
@@ -145,12 +208,21 @@ export async function cmdPull(reference, args) {
145
208
  const outputPath = getFlag(args, "--output") ??
146
209
  path.resolve(`${slug}.${pulled.sourceFormat === "json" ? "json" : "md"}`);
147
210
  fs.writeFileSync(outputPath, pulled.rawSource, "utf-8");
148
- const validationErrors = validateWorkflow(parseWorkflowFile(outputPath));
211
+ const parsed = parseWorkflowFile(outputPath);
212
+ const validationErrors = validateWorkflow(parsed);
149
213
  if (validationErrors.length > 0) {
150
214
  fs.rmSync(outputPath);
151
215
  console.error(`Pulled workflow failed local validation: ${validationErrors.join("; ")}`);
152
216
  return 1;
153
217
  }
218
+ const missingEmbeddedSkills = Object.entries(parsed.skills ?? {})
219
+ .filter(([, entry]) => !entry.content?.trim())
220
+ .map(([name]) => name);
221
+ if (missingEmbeddedSkills.length > 0) {
222
+ fs.rmSync(outputPath);
223
+ console.error(`Pulled workflow is missing embedded content for skills: ${missingEmbeddedSkills.join(", ")}`);
224
+ return 1;
225
+ }
154
226
  console.log(`Pulled ${owner}/${slug}@${pulled.version} -> ${path.resolve(outputPath)}`);
155
227
  return 0;
156
228
  }
@@ -0,0 +1,7 @@
1
+ import type { RunnerSessionStore } from "./runnerSession.js";
2
+ interface RunnerApiServer {
3
+ port: number;
4
+ close: () => Promise<void>;
5
+ }
6
+ export declare function startRunnerApiServer(store: RunnerSessionStore, requestedPort: number): Promise<RunnerApiServer>;
7
+ export {};