@sweny-ai/core 0.1.77 → 0.1.79

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 (119) hide show
  1. package/dist/browser.d.ts +11 -5
  2. package/dist/browser.js +11 -11
  3. package/dist/claude.d.ts +16 -0
  4. package/dist/claude.js +98 -9
  5. package/dist/cli/config.d.ts +22 -0
  6. package/dist/cli/config.js +56 -9
  7. package/dist/cli/main.d.ts +1 -1
  8. package/dist/cli/main.js +56 -57
  9. package/dist/cli/marketplace.js +35 -9
  10. package/dist/cli/publish.d.ts +10 -0
  11. package/dist/cli/publish.js +22 -14
  12. package/dist/loader.d.ts +40 -0
  13. package/dist/loader.js +73 -0
  14. package/dist/mcp-catalog.d.ts +47 -0
  15. package/dist/mcp-catalog.js +304 -0
  16. package/dist/mcp.d.ts +6 -9
  17. package/dist/mcp.js +24 -314
  18. package/dist/schema.d.ts +183 -151
  19. package/dist/schema.js +49 -7
  20. package/dist/skills/custom-loader.d.ts +40 -0
  21. package/dist/skills/custom-loader.js +107 -32
  22. package/dist/skills/index.d.ts +13 -1
  23. package/dist/skills/index.js +12 -3
  24. package/dist/studio.js +4 -1
  25. package/dist/templates.d.ts +29 -8
  26. package/dist/templates.js +30 -20
  27. package/dist/types.d.ts +12 -0
  28. package/dist/verify.js +96 -7
  29. package/package.json +8 -4
  30. package/dist/__tests__/cascade.test.d.ts +0 -11
  31. package/dist/__tests__/cascade.test.js +0 -452
  32. package/dist/__tests__/claude.test.d.ts +0 -1
  33. package/dist/__tests__/claude.test.js +0 -427
  34. package/dist/__tests__/custom-loader.test.d.ts +0 -1
  35. package/dist/__tests__/custom-loader.test.js +0 -116
  36. package/dist/__tests__/executor.test.d.ts +0 -1
  37. package/dist/__tests__/executor.test.js +0 -1635
  38. package/dist/__tests__/integration/datadog.integration.test.d.ts +0 -1
  39. package/dist/__tests__/integration/datadog.integration.test.js +0 -23
  40. package/dist/__tests__/integration/e2e-workflow.integration.test.d.ts +0 -1
  41. package/dist/__tests__/integration/e2e-workflow.integration.test.js +0 -75
  42. package/dist/__tests__/integration/github.integration.test.d.ts +0 -1
  43. package/dist/__tests__/integration/github.integration.test.js +0 -37
  44. package/dist/__tests__/integration/harness.d.ts +0 -24
  45. package/dist/__tests__/integration/harness.js +0 -34
  46. package/dist/__tests__/integration/linear.integration.test.d.ts +0 -1
  47. package/dist/__tests__/integration/linear.integration.test.js +0 -15
  48. package/dist/__tests__/integration/sentry.integration.test.d.ts +0 -1
  49. package/dist/__tests__/integration/sentry.integration.test.js +0 -20
  50. package/dist/__tests__/integration/slack.integration.test.d.ts +0 -1
  51. package/dist/__tests__/integration/slack.integration.test.js +0 -22
  52. package/dist/__tests__/integration/sources.integration.test.d.ts +0 -1
  53. package/dist/__tests__/integration/sources.integration.test.js +0 -123
  54. package/dist/__tests__/publish.test.d.ts +0 -1
  55. package/dist/__tests__/publish.test.js +0 -180
  56. package/dist/__tests__/requires.test.d.ts +0 -1
  57. package/dist/__tests__/requires.test.js +0 -82
  58. package/dist/__tests__/retry.test.d.ts +0 -1
  59. package/dist/__tests__/retry.test.js +0 -213
  60. package/dist/__tests__/schema.test.d.ts +0 -1
  61. package/dist/__tests__/schema.test.js +0 -600
  62. package/dist/__tests__/skill-config-integration.test.d.ts +0 -1
  63. package/dist/__tests__/skill-config-integration.test.js +0 -204
  64. package/dist/__tests__/skills-index.test.d.ts +0 -1
  65. package/dist/__tests__/skills-index.test.js +0 -119
  66. package/dist/__tests__/skills.test.d.ts +0 -1
  67. package/dist/__tests__/skills.test.js +0 -565
  68. package/dist/__tests__/spec-conformance.test.d.ts +0 -18
  69. package/dist/__tests__/spec-conformance.test.js +0 -1084
  70. package/dist/__tests__/studio.test.d.ts +0 -1
  71. package/dist/__tests__/studio.test.js +0 -403
  72. package/dist/__tests__/templates.test.d.ts +0 -1
  73. package/dist/__tests__/templates.test.js +0 -49
  74. package/dist/__tests__/testing.test.d.ts +0 -1
  75. package/dist/__tests__/testing.test.js +0 -242
  76. package/dist/__tests__/verify.test.d.ts +0 -1
  77. package/dist/__tests__/verify.test.js +0 -517
  78. package/dist/__tests__/version-consistency.test.d.ts +0 -1
  79. package/dist/__tests__/version-consistency.test.js +0 -312
  80. package/dist/__tests__/workflow-builder.test.d.ts +0 -1
  81. package/dist/__tests__/workflow-builder.test.js +0 -110
  82. package/dist/__tests__/workflow-yaml.test.d.ts +0 -1
  83. package/dist/__tests__/workflow-yaml.test.js +0 -201
  84. package/dist/cli/__tests__/diagram.test.d.ts +0 -1
  85. package/dist/cli/__tests__/diagram.test.js +0 -225
  86. package/dist/cli/__tests__/report-cloud.test.d.ts +0 -1
  87. package/dist/cli/__tests__/report-cloud.test.js +0 -165
  88. package/dist/cli/__tests__/upgrade.test.d.ts +0 -1
  89. package/dist/cli/__tests__/upgrade.test.js +0 -317
  90. package/dist/cli/__tests__/version-check.test.d.ts +0 -1
  91. package/dist/cli/__tests__/version-check.test.js +0 -209
  92. package/dist/cli/config.test.d.ts +0 -1
  93. package/dist/cli/config.test.js +0 -200
  94. package/dist/cli/credentials.test.d.ts +0 -1
  95. package/dist/cli/credentials.test.js +0 -172
  96. package/dist/cli/e2e.test.d.ts +0 -1
  97. package/dist/cli/e2e.test.js +0 -690
  98. package/dist/cli/marketplace.test.d.ts +0 -1
  99. package/dist/cli/marketplace.test.js +0 -355
  100. package/dist/cli/new.test.d.ts +0 -1
  101. package/dist/cli/new.test.js +0 -1043
  102. package/dist/cli/output.test.d.ts +0 -1
  103. package/dist/cli/output.test.js +0 -180
  104. package/dist/cli/publish.test.d.ts +0 -1
  105. package/dist/cli/publish.test.js +0 -610
  106. package/dist/cli/renderer.test.d.ts +0 -1
  107. package/dist/cli/renderer.test.js +0 -302
  108. package/dist/executor.test.d.ts +0 -1
  109. package/dist/executor.test.js +0 -618
  110. package/dist/mcp.test.d.ts +0 -1
  111. package/dist/mcp.test.js +0 -735
  112. package/dist/mermaid.test.d.ts +0 -1
  113. package/dist/mermaid.test.js +0 -256
  114. package/dist/sources.test.d.ts +0 -1
  115. package/dist/sources.test.js +0 -265
  116. package/dist/sources.validation.test.d.ts +0 -12
  117. package/dist/sources.validation.test.js +0 -380
  118. package/dist/workflow-builder.test.d.ts +0 -1
  119. package/dist/workflow-builder.test.js +0 -123
package/dist/browser.d.ts CHANGED
@@ -1,14 +1,20 @@
1
1
  /**
2
2
  * Browser-safe entry point for @sweny-ai/core
3
3
  *
4
- * Re-exports everything EXCEPT ClaudeClient (which depends on
5
- * @anthropic-ai/claude-agent-sdk, a Node-only package). Use this entry point
6
- * in browser environments (Vite, webpack, Astro, etc.).
4
+ * Exports only APIs that bundle cleanly in browser environments (Vite,
5
+ * webpack, Astro, etc.). Anything that pulls `node:fs`, `node:path`, or the
6
+ * Claude Code SDK lives in the Node entry (`@sweny-ai/core`).
7
+ *
8
+ * Not exported from here:
9
+ * - `ClaudeClient` — depends on `@anthropic-ai/claude-agent-sdk` (Node-only).
10
+ * - `execute` / `ExecuteOptions` — `executor.ts` → `source-resolver.ts`
11
+ * imports `node:fs/promises` for URL/file Source fetching. Import from
12
+ * the Node entry when you need to run a workflow.
13
+ * - `buildWorkflow` / `refineWorkflow` — depend on the Claude interface.
14
+ * - Testing helpers — `MockClaude`, `createFileSkill` use `node:fs`.
7
15
  */
8
16
  export type { Skill, SkillCategory, Tool, ToolContext, ConfigField, JSONSchema, Workflow, Node, Edge, NodeResult, ToolCall, ExecutionEvent, Observer, Claude, Logger, } from "./types.js";
9
17
  export { consoleLogger } from "./types.js";
10
- export { execute } from "./executor.js";
11
- export type { ExecuteOptions } from "./executor.js";
12
18
  export { github, linear, slack, sentry, datadog, notification, builtinSkills, createSkillMap, allSkills, isSkillConfigured, configuredBuiltinSkills, validateWorkflowSkills, } from "./skills/index.js";
13
19
  export type { SkillValidationResult } from "./skills/index.js";
14
20
  export { workflowZ, nodeZ, edgeZ, skillZ, parseWorkflow, validateWorkflow, workflowJsonSchema } from "./schema.js";
package/dist/browser.js CHANGED
@@ -1,22 +1,22 @@
1
1
  /**
2
2
  * Browser-safe entry point for @sweny-ai/core
3
3
  *
4
- * Re-exports everything EXCEPT ClaudeClient (which depends on
5
- * @anthropic-ai/claude-agent-sdk, a Node-only package). Use this entry point
6
- * in browser environments (Vite, webpack, Astro, etc.).
4
+ * Exports only APIs that bundle cleanly in browser environments (Vite,
5
+ * webpack, Astro, etc.). Anything that pulls `node:fs`, `node:path`, or the
6
+ * Claude Code SDK lives in the Node entry (`@sweny-ai/core`).
7
+ *
8
+ * Not exported from here:
9
+ * - `ClaudeClient` — depends on `@anthropic-ai/claude-agent-sdk` (Node-only).
10
+ * - `execute` / `ExecuteOptions` — `executor.ts` → `source-resolver.ts`
11
+ * imports `node:fs/promises` for URL/file Source fetching. Import from
12
+ * the Node entry when you need to run a workflow.
13
+ * - `buildWorkflow` / `refineWorkflow` — depend on the Claude interface.
14
+ * - Testing helpers — `MockClaude`, `createFileSkill` use `node:fs`.
7
15
  */
8
16
  export { consoleLogger } from "./types.js";
9
- // Executor
10
- export { execute } from "./executor.js";
11
17
  // Skills (browser-safe — no filesystem access, no `process.env` reads)
12
18
  export { github, linear, slack, sentry, datadog, notification, builtinSkills, createSkillMap, allSkills, isSkillConfigured, configuredBuiltinSkills, validateWorkflowSkills, } from "./skills/index.js";
13
19
  // Schema & validation
14
20
  export { workflowZ, nodeZ, edgeZ, skillZ, parseWorkflow, validateWorkflow, workflowJsonSchema } from "./schema.js";
15
21
  // Studio adapter
16
22
  export { workflowToFlow, flowToWorkflow, applyExecutionEvent, exportAsTypescript, getSkillCatalog } from "./studio.js";
17
- // Workflow builder — NOT included in browser entry.
18
- // buildWorkflow/refineWorkflow depend on the Claude interface (Node-only).
19
- // Import from "@sweny-ai/core" in Node environments.
20
- // Testing utilities — NOT included in browser entry.
21
- // MockClaude and createFileSkill depend on node:fs.
22
- // Import from "@sweny-ai/core/testing" in Node environments.
package/dist/claude.d.ts CHANGED
@@ -58,3 +58,19 @@ export declare class ClaudeClient implements Claude {
58
58
  context: Record<string, unknown>;
59
59
  }): Promise<string>;
60
60
  }
61
+ /**
62
+ * Best-effort recovery of typed output from a tool_result's `content`.
63
+ *
64
+ * The MCP protocol sends tool results as string content. Our in-process
65
+ * wrapper JSON-stringifies structured output before returning; external
66
+ * MCP servers generally do the same for JSON payloads. If the string
67
+ * looks like a JSON object or array and parses, return the parsed value
68
+ * so verify's output-path walks work against typed data.
69
+ *
70
+ * Raw strings are preserved verbatim. JSON-primitive strings (e.g. the
71
+ * literal four characters `"42"`) are intentionally NOT parsed — we
72
+ * cannot distinguish a tool that returned the number 42 (wrapper sends
73
+ * `"42"`) from a tool that returned the string "42" (wrapper also sends
74
+ * `"42"`). Preserving the string is safer than guessing.
75
+ */
76
+ export declare function parseToolResultContent(content: unknown): unknown;
package/dist/claude.js CHANGED
@@ -41,9 +41,28 @@ export class ClaudeClient {
41
41
  }
42
42
  async run(opts) {
43
43
  const { instruction, context, tools, outputSchema, onProgress, maxTurns } = opts;
44
+ // Tool-call accounting (Fix #1).
45
+ //
46
+ // Stream-driven recording, correlated by tool_use_id:
47
+ // - `assistant` message with tool_use block → create a pending ToolCall
48
+ // and register it in `pendingByUseId` under its tool_use_id.
49
+ // - `user` message with tool_result block → look up by tool_use_id, set
50
+ // `status` from `is_error`, and recover the typed output by parsing
51
+ // the stringified `content` field (our wrapper JSON-stringifies it
52
+ // before returning; external MCP servers also return structured
53
+ // content).
54
+ //
55
+ // Keying by tool_use_id (not by tool name) avoids mis-pairing when
56
+ // Claude invokes the same tool in parallel — the self-review test
57
+ // `pairs parallel same-named in-process tool outputs correctly` shows
58
+ // why FIFO-by-name is wrong.
44
59
  const toolCalls = [];
45
- // Convert core tools to SDK MCP tools
46
- const sdkTools = tools.map((t) => coreToolToSdkTool(t, this.defaultContext, toolCalls));
60
+ const pendingByUseId = new Map();
61
+ // Convert core tools to SDK MCP tools. The wrapper invokes the user's
62
+ // handler and returns `content` via the MCP transport. We do not push
63
+ // to toolCalls from the wrapper — the stream's tool_result is the sole
64
+ // signal of completion, which keeps pairing correct under parallelism.
65
+ const sdkTools = tools.map((t) => coreToolToSdkTool(t, this.defaultContext));
47
66
  // Create in-process MCP server
48
67
  const mcpServer = createSdkMcpServer({
49
68
  name: "sweny-core",
@@ -96,19 +115,51 @@ export class ClaudeClient {
96
115
  }
97
116
  }
98
117
  else if (message.type === "assistant") {
99
- // Extract tool_use blocks from assistant messages (MCP tool calls)
118
+ // Tool_use blocks start a ToolCall record. Status + output are
119
+ // filled in when the matching user tool_result arrives.
100
120
  const am = message;
101
121
  if (am.message?.content && Array.isArray(am.message.content)) {
102
122
  for (const block of am.message.content) {
103
123
  if (block.type === "tool_use") {
104
- toolCalls.push({
124
+ const call = {
105
125
  tool: stripMcpPrefix(block.name ?? ""),
106
126
  input: block.input,
107
- });
127
+ };
128
+ toolCalls.push(call);
129
+ if (typeof block.id === "string")
130
+ pendingByUseId.set(block.id, call);
108
131
  }
109
132
  }
110
133
  }
111
134
  }
135
+ else if (message.type === "user") {
136
+ // Tool_result blocks close the loop. Pair to the pending ToolCall
137
+ // by tool_use_id — NOT by name, which would break parallel calls
138
+ // of the same tool (see regression test).
139
+ //
140
+ // Output handling is unified across in-process and external tools:
141
+ // our wrapper JSON-stringifies structured output before returning,
142
+ // and external MCP servers also send structured content as string.
143
+ // We parse the content back into a value so verify's output-path
144
+ // checks work against typed data.
145
+ const um = message;
146
+ if (um.message?.content && Array.isArray(um.message.content)) {
147
+ for (const block of um.message.content) {
148
+ if (block.type !== "tool_result" || typeof block.tool_use_id !== "string")
149
+ continue;
150
+ const call = pendingByUseId.get(block.tool_use_id);
151
+ if (!call)
152
+ continue;
153
+ // Delete after first match so a duplicate tool_result (SDK
154
+ // retry, malformed stream) cannot overwrite a completed call.
155
+ pendingByUseId.delete(block.tool_use_id);
156
+ const isError = block.is_error === true;
157
+ call.status = isError ? "error" : "success";
158
+ const parsed = parseToolResultContent(block.content);
159
+ call.output = isError ? { error: parsed } : parsed;
160
+ }
161
+ }
162
+ }
112
163
  else if (message.type === "result") {
113
164
  const resultMsg = message;
114
165
  if (resultMsg.subtype === "success" && "result" in resultMsg) {
@@ -247,22 +298,27 @@ export class ClaudeClient {
247
298
  // ─── Tool conversion ────────────────────────────────────────────
248
299
  /**
249
300
  * Convert a core Tool to an SDK MCP tool definition.
250
- * Bridges JSON Schema → Zod and wraps the handler to return CallToolResult.
301
+ *
302
+ * The wrapper runs the user's handler and returns the MCP `CallToolResult`.
303
+ * Output bookkeeping happens on the stream side (see the tool_result
304
+ * handler in `run()`), keyed by `tool_use_id` — never from inside the
305
+ * wrapper. Keeping the wrapper stateless means parallel calls to the same
306
+ * tool cannot mis-pair outputs.
251
307
  */
252
- function coreToolToSdkTool(coreTool, defaultCtx, toolCalls) {
308
+ function coreToolToSdkTool(coreTool, defaultCtx) {
253
309
  const zodShape = jsonSchemaToZodShape(coreTool.input_schema);
254
310
  return sdkTool(coreTool.name, coreTool.description, zodShape, async (args) => {
255
311
  try {
256
312
  // The executor wraps handlers to inject ToolContext.
257
313
  // When used standalone, defaultCtx is the fallback.
258
314
  const output = await coreTool.handler(args, defaultCtx);
259
- toolCalls.push({ tool: coreTool.name, input: args, output });
315
+ // JSON-stringify structured output so the tool_result handler can
316
+ // recover it via parseToolResultContent. Strings pass through.
260
317
  return {
261
318
  content: [{ type: "text", text: typeof output === "string" ? output : JSON.stringify(output) }],
262
319
  };
263
320
  }
264
321
  catch (err) {
265
- toolCalls.push({ tool: coreTool.name, input: args, output: { error: err.message } });
266
322
  return {
267
323
  content: [{ type: "text", text: `Error: ${err.message}` }],
268
324
  isError: true,
@@ -270,6 +326,39 @@ function coreToolToSdkTool(coreTool, defaultCtx, toolCalls) {
270
326
  }
271
327
  });
272
328
  }
329
+ /**
330
+ * Best-effort recovery of typed output from a tool_result's `content`.
331
+ *
332
+ * The MCP protocol sends tool results as string content. Our in-process
333
+ * wrapper JSON-stringifies structured output before returning; external
334
+ * MCP servers generally do the same for JSON payloads. If the string
335
+ * looks like a JSON object or array and parses, return the parsed value
336
+ * so verify's output-path walks work against typed data.
337
+ *
338
+ * Raw strings are preserved verbatim. JSON-primitive strings (e.g. the
339
+ * literal four characters `"42"`) are intentionally NOT parsed — we
340
+ * cannot distinguish a tool that returned the number 42 (wrapper sends
341
+ * `"42"`) from a tool that returned the string "42" (wrapper also sends
342
+ * `"42"`). Preserving the string is safer than guessing.
343
+ */
344
+ export function parseToolResultContent(content) {
345
+ if (typeof content !== "string")
346
+ return content;
347
+ const trimmed = content.trim();
348
+ if (trimmed.length === 0)
349
+ return content;
350
+ const first = trimmed[0];
351
+ // Only objects and arrays are unambiguous to parse. Strings, numbers,
352
+ // booleans, and null would all corrupt or discard information.
353
+ if (first !== "{" && first !== "[")
354
+ return content;
355
+ try {
356
+ return JSON.parse(trimmed);
357
+ }
358
+ catch {
359
+ return content;
360
+ }
361
+ }
273
362
  // ─── JSON Schema → Zod conversion ───────────────────────────────
274
363
  /**
275
364
  * Convert a JSON Schema object to a Zod raw shape for the agent SDK.
@@ -76,6 +76,28 @@ export declare function parseCliInputs(options: Record<string, unknown>, fileCon
76
76
  */
77
77
  export declare const SUPPORTED_WORKSPACE_TOOLS: Set<string>;
78
78
  export declare function validateInputs(config: CliConfig): string[];
79
+ /**
80
+ * Parse a value as a positive integer (>= 1).
81
+ *
82
+ * Returns:
83
+ * - the parsed integer when the input represents a positive finite one
84
+ * - the fallback when the input is null/undefined/empty (unset flag)
85
+ * - NaN for any malformed or non-positive input, so validateInputs can
86
+ * surface a field-specific error
87
+ *
88
+ * Name enforces the contract: non-positive values (0, -5) return NaN so
89
+ * validation rejects them loudly rather than quietly passing through as
90
+ * `0` and breaking downstream invariants.
91
+ *
92
+ * Why zero is rejected: every current caller (max-investigate-turns,
93
+ * max-implement-turns, cache-ttl) uses the result as a positive count or
94
+ * timeout. There is no "zero means disabled" semantics anywhere — a
95
+ * cache-ttl of 0 wouldn't disable cache, it would expire entries
96
+ * immediately, which is broken. If a future flag wants "0 = disabled",
97
+ * use a sibling helper (parseNonNegativeInt) rather than weakening this
98
+ * one's contract.
99
+ */
100
+ export declare function parsePositiveInt(raw: unknown, fallback: number): number;
79
101
  /**
80
102
  * Returns non-fatal warnings about the configuration.
81
103
  * These are surfaced before the spinner starts but do not abort the run.
@@ -116,8 +116,8 @@ export function parseCliInputs(options, fileConfig = {}) {
116
116
  severityFocus: options.severityFocus || f("severity-focus") || "errors",
117
117
  serviceFilter: options.serviceFilter || f("service-filter") || "*",
118
118
  investigationDepth: options.investigationDepth || f("investigation-depth") || "standard",
119
- maxInvestigateTurns: parseInt(String(options.maxInvestigateTurns || f("max-investigate-turns") || "50"), 10),
120
- maxImplementTurns: parseInt(String(options.maxImplementTurns || f("max-implement-turns") || "30"), 10),
119
+ maxInvestigateTurns: parsePositiveInt(options.maxInvestigateTurns ?? f("max-investigate-turns"), 50),
120
+ maxImplementTurns: parsePositiveInt(options.maxImplementTurns ?? f("max-implement-turns"), 30),
121
121
  baseBranch: options.baseBranch || f("base-branch") || "main",
122
122
  prLabels: (options.prLabels || f("pr-labels") || "agent,triage,needs-review")
123
123
  .split(",")
@@ -165,7 +165,7 @@ export function parseCliInputs(options, fileConfig = {}) {
165
165
  stream: Boolean(options.stream),
166
166
  bell: Boolean(options.bell),
167
167
  cacheDir: options.cacheDir || env.SWENY_CACHE_DIR || f("cache-dir") || ".sweny/cache",
168
- cacheTtl: parseInt(String(options.cacheTtl || f("cache-ttl") || "86400"), 10),
168
+ cacheTtl: parsePositiveInt(options.cacheTtl ?? f("cache-ttl"), 86400),
169
169
  noCache: options.cache === false,
170
170
  outputDir: options.outputDir || env.SWENY_OUTPUT_DIR || f("output-dir") || ".sweny/output",
171
171
  mcpServers: parseMcpServers(env.SWENY_MCP_SERVERS || f("mcp-servers-json") || ""),
@@ -424,14 +424,61 @@ export function validateInputs(config) {
424
424
  if (!["auto", "review"].includes(config.reviewMode)) {
425
425
  errors.push("--review-mode must be one of: auto, review");
426
426
  }
427
- // Integer bounds
428
- if (config.maxInvestigateTurns < 1 || config.maxInvestigateTurns > 500) {
429
- errors.push("--max-investigate-turns must be between 1 and 500");
427
+ // Integer bounds — reject NaN and Infinity first (parseInt can yield NaN
428
+ // silently and NaN compares false to both < min and > max, so the old
429
+ // bounds check let junk through).
430
+ validateIntegerBound(errors, "--max-investigate-turns", config.maxInvestigateTurns, 1, 500);
431
+ validateIntegerBound(errors, "--max-implement-turns", config.maxImplementTurns, 1, 500);
432
+ return errors;
433
+ }
434
+ function validateIntegerBound(errors, flag, value, min, max) {
435
+ if (!Number.isFinite(value)) {
436
+ errors.push(`${flag} must be a finite integer between ${min} and ${max} (got "${value}")`);
437
+ return;
430
438
  }
431
- if (config.maxImplementTurns < 1 || config.maxImplementTurns > 500) {
432
- errors.push("--max-implement-turns must be between 1 and 500");
439
+ if (value < min || value > max) {
440
+ errors.push(`${flag} must be between ${min} and ${max}`);
433
441
  }
434
- return errors;
442
+ }
443
+ /**
444
+ * Parse a value as a positive integer (>= 1).
445
+ *
446
+ * Returns:
447
+ * - the parsed integer when the input represents a positive finite one
448
+ * - the fallback when the input is null/undefined/empty (unset flag)
449
+ * - NaN for any malformed or non-positive input, so validateInputs can
450
+ * surface a field-specific error
451
+ *
452
+ * Name enforces the contract: non-positive values (0, -5) return NaN so
453
+ * validation rejects them loudly rather than quietly passing through as
454
+ * `0` and breaking downstream invariants.
455
+ *
456
+ * Why zero is rejected: every current caller (max-investigate-turns,
457
+ * max-implement-turns, cache-ttl) uses the result as a positive count or
458
+ * timeout. There is no "zero means disabled" semantics anywhere — a
459
+ * cache-ttl of 0 wouldn't disable cache, it would expire entries
460
+ * immediately, which is broken. If a future flag wants "0 = disabled",
461
+ * use a sibling helper (parseNonNegativeInt) rather than weakening this
462
+ * one's contract.
463
+ */
464
+ export function parsePositiveInt(raw, fallback) {
465
+ if (raw === undefined || raw === null || raw === "")
466
+ return fallback;
467
+ if (typeof raw === "number") {
468
+ if (!Number.isFinite(raw))
469
+ return Number.NaN;
470
+ const truncated = Math.trunc(raw);
471
+ return truncated >= 1 ? truncated : Number.NaN;
472
+ }
473
+ const asString = String(raw).trim();
474
+ if (asString.length === 0)
475
+ return fallback;
476
+ // Number() is stricter than parseInt — "5o" → NaN rather than 5.
477
+ const parsed = Number(asString);
478
+ if (!Number.isFinite(parsed))
479
+ return Number.NaN;
480
+ const truncated = Math.trunc(parsed);
481
+ return truncated >= 1 ? truncated : Number.NaN;
435
482
  }
436
483
  const DEFAULT_SERVICE_MAP_PATH = ".github/service-map.yml";
437
484
  /**
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import type { Workflow } from "../types.js";
3
- export declare function loadWorkflowFile(filePath: string): Workflow;
3
+ export declare function loadWorkflowFile(filePath: string, knownSkills?: Set<string>): Workflow;
4
4
  export declare function workflowRunAction(file: string, options: Record<string, unknown> & {
5
5
  json?: boolean;
6
6
  stream?: boolean;
package/dist/cli/main.js CHANGED
@@ -11,11 +11,11 @@ import { triageWorkflow, implementWorkflow, seedContentWorkflow } from "../workf
11
11
  import { consoleLogger } from "../types.js";
12
12
  import { ClaudeClient } from "../claude.js";
13
13
  import { createSkillMap, validateWorkflowSkills } from "../skills/index.js";
14
- import { configuredSkills } from "../skills/custom-loader.js";
14
+ import { configuredSkills, configuredSkillsWithDiagnostics } from "../skills/custom-loader.js";
15
15
  import { buildAutoMcpServers, buildSkillMcpServers, buildProviderContext } from "../mcp.js";
16
16
  import { loadAdditionalContext } from "../templates.js";
17
- import { validateWorkflow as validateWorkflowSchema } from "../schema.js";
18
- import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
17
+ import { loadAndValidateWorkflow } from "../loader.js";
18
+ import { stringify as stringifyYaml } from "yaml";
19
19
  import { buildWorkflow, refineWorkflow } from "../workflow-builder.js";
20
20
  import { toMermaidBlock } from "../mermaid.js";
21
21
  import { runWorkflowDiagram } from "./diagram.js";
@@ -140,11 +140,20 @@ function buildProviderCtx(config, mcpServers) {
140
140
  /**
141
141
  * Resolve rules and context from config into structured workflow input fields.
142
142
  * All source kinds (inline, file, URL) are resolved eagerly.
143
+ *
144
+ * `offline` and `fetchAuth` are threaded through so CLI-preloaded rules/context
145
+ * honor the same policy as Sources resolved later by the executor (Fix #16).
143
146
  */
144
147
  async function resolveRulesAndContext(config) {
148
+ const loadOptions = {
149
+ cwd: process.cwd(),
150
+ offline: config.offline,
151
+ fetchAuth: config.fetchAuth,
152
+ env: process.env,
153
+ };
145
154
  const [rulesResult, contextResult] = await Promise.all([
146
- loadAdditionalContext(config.rules),
147
- loadAdditionalContext(config.context),
155
+ loadAdditionalContext(config.rules, loadOptions),
156
+ loadAdditionalContext(config.context, loadOptions),
148
157
  ]);
149
158
  return {
150
159
  rules: rulesResult.resolved,
@@ -172,7 +181,11 @@ triageCmd.action(async (options) => {
172
181
  console.log(formatBanner(config, version));
173
182
  }
174
183
  // ── Build skill map + MCP servers + Claude client ──────────
175
- const skills = createSkillMap(configuredSkills(process.env, process.cwd()));
184
+ const triageSkillDiscovery = configuredSkillsWithDiagnostics(process.env, process.cwd());
185
+ for (const w of triageSkillDiscovery.warnings) {
186
+ console.error(chalk.yellow(` ⚠ ${w.message}`));
187
+ }
188
+ const skills = createSkillMap(triageSkillDiscovery.skills);
176
189
  const mcpAutoConfig = buildMcpAutoConfig(config);
177
190
  const mcpServers = buildAutoMcpServers(mcpAutoConfig);
178
191
  const claude = new ClaudeClient({
@@ -423,7 +436,11 @@ implementCmd.action(async (issueId, options) => {
423
436
  fileConfig["output-dir"] ||
424
437
  ".sweny/output",
425
438
  };
426
- const skills = createSkillMap(configuredSkills(process.env, process.cwd()));
439
+ const implementSkillDiscovery = configuredSkillsWithDiagnostics(process.env, process.cwd());
440
+ for (const w of implementSkillDiscovery.warnings) {
441
+ console.error(chalk.yellow(` ⚠ ${w.message}`));
442
+ }
443
+ const skills = createSkillMap(implementSkillDiscovery.skills);
427
444
  const mcpAutoConfig = buildMcpAutoConfig(config);
428
445
  const mcpServers = buildAutoMcpServers(mcpAutoConfig);
429
446
  const claude = new ClaudeClient({
@@ -532,31 +549,36 @@ function promptUser(question) {
532
549
  });
533
550
  }
534
551
  const workflowCmd = program.command("workflow").description("Manage and run workflow files");
535
- /** Reads and parses a workflow file (YAML or JSON). Throws on I/O or parse error. */
536
- function parseWorkflowFileContent(filePath) {
537
- const content = fs.readFileSync(filePath, "utf-8");
538
- const ext = path.extname(filePath).toLowerCase();
539
- return ext === ".yaml" || ext === ".yml" ? parseYaml(content) : JSON.parse(content);
540
- }
541
- export function loadWorkflowFile(filePath) {
542
- const raw = parseWorkflowFileContent(filePath);
543
- const errors = validateWorkflowSchema(raw);
544
- if (errors.length > 0) {
545
- throw new Error(`Invalid workflow file:\n${errors.map((e) => ` ${e.message}`).join("\n")}`);
552
+ export function loadWorkflowFile(filePath, knownSkills) {
553
+ const result = loadAndValidateWorkflow(filePath, { knownSkills });
554
+ if (!result.ok) {
555
+ throw new Error(`Invalid workflow file:\n${result.errors.map((e) => ` ${e.message}`).join("\n")}`);
546
556
  }
547
- return raw;
557
+ return result.workflow;
548
558
  }
549
559
  export async function workflowRunAction(file, options) {
560
+ // Discover skills first so the loader can flag UNKNOWN_SKILL at parse
561
+ // time. validateWorkflowSkills below still runs for richer category /
562
+ // env-var diagnostics, but the loader's structural check fires earlier
563
+ // and gives users a clear "you typed `gtihub`" pointer before any other
564
+ // validation noise.
565
+ const earlySkillDiscovery = configuredSkillsWithDiagnostics(process.env, process.cwd());
566
+ for (const w of earlySkillDiscovery.warnings) {
567
+ console.error(chalk.yellow(` ⚠ ${w.message}`));
568
+ }
569
+ const knownSkillIds = new Set(earlySkillDiscovery.skills.map((s) => s.id));
550
570
  let workflow;
551
571
  try {
552
- workflow = loadWorkflowFile(file);
572
+ workflow = loadWorkflowFile(file, knownSkillIds);
553
573
  }
554
574
  catch (err) {
555
575
  console.error(chalk.red(` Error loading workflow file: ${err instanceof Error ? err.message : String(err)}`));
556
576
  process.exit(1);
557
577
  return;
558
578
  }
559
- if (options.dryRun) {
579
+ // --list-nodes: lightweight static inspection, no execution. Used to be
580
+ // the behavior of --dry-run before Fix #6.
581
+ if (options.listNodes) {
560
582
  console.log(chalk.green(` Workflow "${workflow.name}" is valid (${Object.keys(workflow.nodes).length} nodes)`));
561
583
  for (const [id, node] of Object.entries(workflow.nodes)) {
562
584
  console.log(chalk.dim(` ${id}: ${node.name}${node.skills.length ? ` skills=[${node.skills.join(",")}]` : ""}`));
@@ -568,11 +590,13 @@ export async function workflowRunAction(file, options) {
568
590
  const config = parseCliInputs(options, fileConfig);
569
591
  const isJson = Boolean(options.json);
570
592
  const isTTY = !isJson && (process.stderr.isTTY ?? false);
571
- const skills = createSkillMap(configuredSkills(process.env, process.cwd()));
593
+ // Reuse the discovery pulled before workflow load (above). Diagnostics
594
+ // already surfaced; just turn the skill list into a map for the executor.
595
+ const skills = createSkillMap(earlySkillDiscovery.skills);
572
596
  // Hard-fail at startup if the workflow references skills that aren't available.
573
597
  // Each node lists alternatives by category (e.g. [sentry, datadog, betterstack]);
574
598
  // we require at least one configured skill per category.
575
- const validation = validateWorkflowSkills(workflow, skills);
599
+ const validation = validateWorkflowSkills(workflow, skills, workflow.skills);
576
600
  if (validation.errors.length > 0) {
577
601
  console.error(chalk.red(`\n Workflow cannot run — missing required skills:\n`));
578
602
  for (const err of validation.errors)
@@ -743,47 +767,21 @@ export function workflowExportAction(name) {
743
767
  process.stdout.write(stringifyYaml(workflow, { indent: 2, lineWidth: 120 }));
744
768
  }
745
769
  export function workflowValidateAction(file, options) {
746
- let raw;
747
- try {
748
- raw = parseWorkflowFileContent(file);
749
- }
750
- catch (err) {
751
- const message = err instanceof Error ? err.message : String(err);
752
- if (options.json) {
753
- process.stderr.write(JSON.stringify({ valid: false, errors: [{ message }] }, null, 2) + "\n");
754
- }
755
- else {
756
- console.error(chalk.red(` Cannot read "${file}": ${message}`));
757
- }
758
- process.exit(1);
759
- return;
760
- }
761
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
762
- const kind = raw === null ? "null" : Array.isArray(raw) ? "array" : typeof raw;
763
- const message = `Expected a YAML/JSON object, got ${kind}`;
764
- if (options.json) {
765
- process.stderr.write(JSON.stringify({ valid: false, errors: [{ message }] }, null, 2) + "\n");
766
- }
767
- else {
768
- console.error(chalk.red(` \u2717 ${file}: ${message}`));
769
- }
770
- process.exit(1);
771
- return;
772
- }
773
- const errors = validateWorkflowSchema(raw);
770
+ const result = loadAndValidateWorkflow(file);
774
771
  if (options.json) {
775
- process.stdout.write(JSON.stringify({ valid: errors.length === 0, errors }, null, 2) + "\n");
772
+ const errs = result.ok ? [] : result.errors;
773
+ process.stdout.write(JSON.stringify({ valid: result.ok, errors: errs }, null, 2) + "\n");
776
774
  }
777
- else if (errors.length === 0) {
775
+ else if (result.ok) {
778
776
  console.log(chalk.green(` \u2713 ${file} is valid`));
779
777
  }
780
778
  else {
781
- console.error(chalk.red(` \u2717 ${file} has ${errors.length} validation error${errors.length > 1 ? "s" : ""}:`));
782
- for (const err of errors) {
779
+ console.error(chalk.red(` \u2717 ${file} has ${result.errors.length} validation error${result.errors.length > 1 ? "s" : ""}:`));
780
+ for (const err of result.errors) {
783
781
  console.error(chalk.dim(` ${err.message}`));
784
782
  }
785
783
  }
786
- process.exit(errors.length === 0 ? 0 : 1);
784
+ process.exit(result.ok ? 0 : 1);
787
785
  }
788
786
  workflowCmd
789
787
  .command("validate <file>")
@@ -793,7 +791,8 @@ workflowCmd
793
791
  workflowCmd
794
792
  .command("run <file>")
795
793
  .description("Run a workflow from a YAML or JSON file")
796
- .option("--dry-run", "Validate workflow without running")
794
+ .option("--dry-run", "Execute until the first conditional routing decision, then stop (no side effects past that point). NOTE: behavior changed in this release — previously --dry-run only printed the node list. For that behavior use --list-nodes.")
795
+ .option("--list-nodes", "Validate, print nodes/skills, and exit without running. (Replaces the pre-Fix-#6 --dry-run behavior.)")
797
796
  .option("--json", "Output result as JSON on stdout; suppress progress output")
798
797
  .option("--stream", "Stream NDJSON events to stdout (for Studio / automation)")
799
798
  .option("--mermaid", "Output a Mermaid diagram with execution state after run")
@@ -10,7 +10,7 @@ import * as p from "@clack/prompts";
10
10
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
11
11
  import { refineWorkflow } from "../workflow-builder.js";
12
12
  import { DagRenderer } from "./renderer.js";
13
- import { workflowZ, validateWorkflow } from "../schema.js";
13
+ import { validateParsed } from "../loader.js";
14
14
  import { loadConfigFile } from "./config-file.js";
15
15
  import { extractSkillsFromYaml, collectCredentialsForSkills, writeSwenyYmlIfMissing, appendMissingEnvKeys, writeWorkflowFile, } from "./new.js";
16
16
  export const MARKETPLACE_REPO = "swenyai/workflows";
@@ -194,20 +194,36 @@ export async function installMarketplaceWorkflow(id, options) {
194
194
  const overwrite = options.overwrite ?? false;
195
195
  // 1. Fetch
196
196
  const fetched = await fetchMarketplaceWorkflow(id);
197
- // 2. Load existing config to detect mismatches
197
+ // 2. Validate fetched content BEFORE any local side effects (Fix #3).
198
+ // Previously, an invalid workflow could still get written to disk and
199
+ // .sweny.yml / .env polluted with keys inferred from broken content.
200
+ let parsed;
201
+ {
202
+ let yamlParsed;
203
+ try {
204
+ yamlParsed = parseYaml(fetched.yaml);
205
+ }
206
+ catch (err) {
207
+ const msg = err instanceof Error ? err.message : String(err);
208
+ throw new Error(`Marketplace workflow "${id}" is not valid YAML: ${msg}`);
209
+ }
210
+ const validated = validateParsed(yamlParsed);
211
+ if (!validated.ok) {
212
+ throw new Error(`Marketplace workflow "${id}" failed validation:\n${validated.errors.map((e) => ` ${e.message}`).join("\n")}`);
213
+ }
214
+ parsed = validated.workflow;
215
+ }
216
+ // 3. Load existing config to detect mismatches
198
217
  const fileConfig = loadConfigFile(cwd);
199
218
  const workflowSkills = extractSkillsFromYaml(fetched.yaml);
200
219
  const mismatches = computeProviderMismatch(workflowSkills, fileConfig, availableSkills);
201
- // 3. Adapt if needed + possible
220
+ // 4. Adapt if needed + possible. Schema was already validated above, so
221
+ // adaptation failure no longer hides a schema failure — it falls back to
222
+ // installing the validated-original.
202
223
  let finalYaml = fetched.yaml;
203
224
  let adapted = false;
204
225
  if (mismatches.length > 0 && claude) {
205
226
  try {
206
- const parsed = workflowZ.parse(parseYaml(fetched.yaml));
207
- const errs = validateWorkflow(parsed);
208
- if (errs.length > 0) {
209
- throw new Error(`Marketplace workflow has schema errors: ${errs.map((e) => e.message).join("; ")}`);
210
- }
211
227
  const refined = await adaptWorkflowInteractive(parsed, {
212
228
  claude,
213
229
  skills: availableSkills,
@@ -224,7 +240,17 @@ export async function installMarketplaceWorkflow(id, options) {
224
240
  addedEnvKeys: 0,
225
241
  };
226
242
  }
227
- finalYaml = stringifyYaml(refined, { indent: 2, lineWidth: 120 });
243
+ // Re-validate the refined output before writing. refineWorkflow runs
244
+ // an LLM that produces a new Workflow object — it could drift away
245
+ // from the upfront-validated parse (renamed required fields, dropped
246
+ // edges). Reparsing the stringified YAML closes that loop so we
247
+ // never write content that hasn't been schema-checked.
248
+ const refinedYaml = stringifyYaml(refined, { indent: 2, lineWidth: 120 });
249
+ const revalidated = validateParsed(parseYaml(refinedYaml));
250
+ if (!revalidated.ok) {
251
+ throw new Error(`Adapted workflow failed re-validation:\n${revalidated.errors.map((e) => ` ${e.message}`).join("\n")}`);
252
+ }
253
+ finalYaml = refinedYaml;
228
254
  adapted = true;
229
255
  }
230
256
  catch (err) {
@@ -32,4 +32,14 @@ export declare function validateSkillDir(dirPath: string): {
32
32
  errors: string[];
33
33
  };
34
34
  export declare function runPublish(): Promise<PublishResult | null>;
35
+ /**
36
+ * Recursively copy a skill source directory to its destination (Fix #17).
37
+ *
38
+ * Previously a flat `fs.readdirSync` + `fs.copyFileSync` loop: skills with
39
+ * nested directories (scripts/, references/, assets/) either silently
40
+ * shipped incomplete or — more often — threw EISDIR on the first subdir.
41
+ *
42
+ * Exported for tests; node:fs cp's recursive mode preserves structure.
43
+ */
44
+ export declare function copySkillDir(sourceDir: string, destDir: string): void;
35
45
  export declare function registerPublishCommand(program: Command): void;