@sweny-ai/core 0.1.27 → 0.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/executor.test.js +17 -10
- package/dist/__tests__/integration/e2e-workflow.integration.test.js +1 -1
- package/dist/__tests__/skills.test.js +120 -0
- package/dist/cli/main.js +7 -7
- package/dist/executor.d.ts +5 -3
- package/dist/executor.js +22 -3
- package/dist/executor.test.js +3 -3
- package/dist/index.d.ts +1 -1
- package/dist/mermaid.d.ts +6 -2
- package/dist/mermaid.js +67 -20
- package/dist/mermaid.test.js +38 -1
- package/dist/schema.d.ts +4 -0
- package/dist/schema.js +4 -0
- package/dist/skills/index.d.ts +12 -2
- package/dist/skills/index.js +76 -3
- package/dist/types.d.ts +30 -0
- package/package.json +1 -1
|
@@ -66,7 +66,7 @@ describe("executor", () => {
|
|
|
66
66
|
},
|
|
67
67
|
});
|
|
68
68
|
const events = [];
|
|
69
|
-
const results = await execute(linearWorkflow, { alert: "test" }, {
|
|
69
|
+
const { results } = await execute(linearWorkflow, { alert: "test" }, {
|
|
70
70
|
skills: createSkillMap([fileSkill]),
|
|
71
71
|
claude,
|
|
72
72
|
observer: (e) => events.push(e),
|
|
@@ -92,7 +92,7 @@ describe("executor", () => {
|
|
|
92
92
|
},
|
|
93
93
|
routes: { check: "high" },
|
|
94
94
|
});
|
|
95
|
-
const results = await execute(branchingWorkflow, { alert: "cpu" }, {
|
|
95
|
+
const { results } = await execute(branchingWorkflow, { alert: "cpu" }, {
|
|
96
96
|
skills: createSkillMap([]),
|
|
97
97
|
claude,
|
|
98
98
|
config: {},
|
|
@@ -111,7 +111,7 @@ describe("executor", () => {
|
|
|
111
111
|
},
|
|
112
112
|
routes: { check: "low" },
|
|
113
113
|
});
|
|
114
|
-
const results = await execute(branchingWorkflow, { alert: "minor" }, {
|
|
114
|
+
const { results } = await execute(branchingWorkflow, { alert: "minor" }, {
|
|
115
115
|
skills: createSkillMap([]),
|
|
116
116
|
claude,
|
|
117
117
|
config: {},
|
|
@@ -154,7 +154,7 @@ describe("executor", () => {
|
|
|
154
154
|
c: { data: {} },
|
|
155
155
|
},
|
|
156
156
|
});
|
|
157
|
-
const results = await execute(linearWorkflow, {}, {
|
|
157
|
+
const { results } = await execute(linearWorkflow, {}, {
|
|
158
158
|
skills: createSkillMap([]),
|
|
159
159
|
claude,
|
|
160
160
|
observer: () => {
|
|
@@ -193,7 +193,7 @@ describe("executor", () => {
|
|
|
193
193
|
const claude = new MockClaude({
|
|
194
194
|
responses: { only: { data: { result: "done" } } },
|
|
195
195
|
});
|
|
196
|
-
const results = await execute(singleNode, {}, {
|
|
196
|
+
const { results } = await execute(singleNode, {}, {
|
|
197
197
|
skills: createSkillMap([]),
|
|
198
198
|
claude,
|
|
199
199
|
config: {},
|
|
@@ -252,7 +252,7 @@ describe("executor", () => {
|
|
|
252
252
|
claude,
|
|
253
253
|
})).rejects.toThrow("Missing required config");
|
|
254
254
|
// Should succeed with override
|
|
255
|
-
const results = await execute(linearWorkflow, {}, {
|
|
255
|
+
const { results } = await execute(linearWorkflow, {}, {
|
|
256
256
|
skills: createSkillMap([skillWithConfig]),
|
|
257
257
|
claude,
|
|
258
258
|
config: { MY_KEY: "provided" },
|
|
@@ -267,7 +267,7 @@ describe("executor", () => {
|
|
|
267
267
|
c: { data: {} },
|
|
268
268
|
},
|
|
269
269
|
});
|
|
270
|
-
const results = await execute(linearWorkflow, {}, {
|
|
270
|
+
const { results } = await execute(linearWorkflow, {}, {
|
|
271
271
|
skills: createSkillMap([]),
|
|
272
272
|
claude,
|
|
273
273
|
config: {},
|
|
@@ -316,13 +316,20 @@ describe("executor", () => {
|
|
|
316
316
|
return opts.choices[0].id;
|
|
317
317
|
},
|
|
318
318
|
};
|
|
319
|
-
const results = await execute(retryWorkflow, {}, { skills: createSkillMap([]), claude: mockClaude, config: {} });
|
|
319
|
+
const { results, trace } = await execute(retryWorkflow, {}, { skills: createSkillMap([]), claude: mockClaude, config: {} });
|
|
320
320
|
// extract ran twice (initial + 1 retry), judge ran twice, done ran once
|
|
321
321
|
expect(extractCount).toBe(2);
|
|
322
322
|
expect(judgeCount).toBe(2);
|
|
323
323
|
expect(results.has("done")).toBe(true);
|
|
324
324
|
// The second extract result overwrites the first
|
|
325
325
|
expect(results.get("extract")?.data.attempt).toBe(2);
|
|
326
|
+
// Trace captures the full execution path including loops
|
|
327
|
+
expect(trace.steps).toHaveLength(5); // extract, judge, extract(retry), judge, done
|
|
328
|
+
expect(trace.steps[0]).toEqual({ node: "extract", status: "success", iteration: 1 });
|
|
329
|
+
expect(trace.steps[2]).toEqual({ node: "extract", status: "success", iteration: 2 });
|
|
330
|
+
// Trace edges include the retry loop
|
|
331
|
+
const retryEdge = trace.edges.find((e) => e.from === "judge" && e.to === "extract");
|
|
332
|
+
expect(retryEdge).toBeDefined();
|
|
326
333
|
});
|
|
327
334
|
it("respects max_iterations=1 as single retry", async () => {
|
|
328
335
|
const singleRetry = {
|
|
@@ -356,7 +363,7 @@ describe("executor", () => {
|
|
|
356
363
|
return opts.choices[0].id;
|
|
357
364
|
},
|
|
358
365
|
};
|
|
359
|
-
const results = await execute(singleRetry, {}, { skills: createSkillMap([]), claude: mockClaude, config: {} });
|
|
366
|
+
const { results } = await execute(singleRetry, {}, { skills: createSkillMap([]), claude: mockClaude, config: {} });
|
|
360
367
|
// A runs twice (once initially, once via retry), then edge is exhausted → falls to "end"
|
|
361
368
|
expect(aCount).toBe(2);
|
|
362
369
|
expect(results.has("end")).toBe(true);
|
|
@@ -371,7 +378,7 @@ describe("executor", () => {
|
|
|
371
378
|
return "nonexistent_node"; // invalid target
|
|
372
379
|
},
|
|
373
380
|
};
|
|
374
|
-
const results = await execute(branchingWorkflow, {}, {
|
|
381
|
+
const { results } = await execute(branchingWorkflow, {}, {
|
|
375
382
|
skills: createSkillMap([]),
|
|
376
383
|
claude: badRouteClaude,
|
|
377
384
|
config: {},
|
|
@@ -47,7 +47,7 @@ describe.runIf(available.claude)("e2e workflow with real Claude", () => {
|
|
|
47
47
|
maxTurns: 3,
|
|
48
48
|
});
|
|
49
49
|
const events = [];
|
|
50
|
-
const results = await execute(analysisWorkflow, {
|
|
50
|
+
const { results } = await execute(analysisWorkflow, {
|
|
51
51
|
alert: "CPU usage at 98% for 15 minutes on production server web-03",
|
|
52
52
|
service: "api-gateway",
|
|
53
53
|
timestamp: "2025-03-25T08:00:00Z",
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
2
5
|
import { github } from "../skills/github.js";
|
|
3
6
|
import { linear } from "../skills/linear.js";
|
|
4
7
|
import { slack } from "../skills/slack.js";
|
|
5
8
|
import { sentry } from "../skills/sentry.js";
|
|
6
9
|
import { datadog } from "../skills/datadog.js";
|
|
7
10
|
import { notification } from "../skills/notification.js";
|
|
11
|
+
import { loadCustomSkills, configuredSkills, createSkillMap } from "../skills/index.js";
|
|
8
12
|
// ─── Helpers ─────────────────────────────────────────────────────
|
|
9
13
|
const mockCtx = (config = {}) => ({
|
|
10
14
|
config,
|
|
@@ -294,3 +298,119 @@ describe("notification skill", () => {
|
|
|
294
298
|
expect(fetchMock.mock.calls[0][1].signal).toBeDefined();
|
|
295
299
|
});
|
|
296
300
|
});
|
|
301
|
+
// ─── Custom skill discovery tests ──────────────────────────────
|
|
302
|
+
describe("loadCustomSkills", () => {
|
|
303
|
+
let tmpDir;
|
|
304
|
+
beforeEach(() => {
|
|
305
|
+
tmpDir = mkdtempSync(join(tmpdir(), "sweny-skills-test-"));
|
|
306
|
+
});
|
|
307
|
+
afterEach(() => {
|
|
308
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
309
|
+
});
|
|
310
|
+
function writeSkill(dirName, content) {
|
|
311
|
+
const dir = join(tmpDir, ".claude", "skills", dirName);
|
|
312
|
+
mkdirSync(dir, { recursive: true });
|
|
313
|
+
writeFileSync(join(dir, "SKILL.md"), content);
|
|
314
|
+
}
|
|
315
|
+
it("returns empty array when .claude/skills/ does not exist", () => {
|
|
316
|
+
const skills = loadCustomSkills(tmpDir);
|
|
317
|
+
expect(skills).toEqual([]);
|
|
318
|
+
});
|
|
319
|
+
it("returns empty array when .claude/skills/ is empty", () => {
|
|
320
|
+
mkdirSync(join(tmpDir, ".claude", "skills"), { recursive: true });
|
|
321
|
+
const skills = loadCustomSkills(tmpDir);
|
|
322
|
+
expect(skills).toEqual([]);
|
|
323
|
+
});
|
|
324
|
+
it("discovers a valid custom skill from SKILL.md frontmatter", () => {
|
|
325
|
+
writeSkill("my-skill", `---
|
|
326
|
+
name: my-skill
|
|
327
|
+
description: A test skill
|
|
328
|
+
---
|
|
329
|
+
|
|
330
|
+
Instructions for Claude go here.
|
|
331
|
+
`);
|
|
332
|
+
const skills = loadCustomSkills(tmpDir);
|
|
333
|
+
expect(skills).toHaveLength(1);
|
|
334
|
+
expect(skills[0]).toMatchObject({
|
|
335
|
+
id: "my-skill",
|
|
336
|
+
name: "my-skill",
|
|
337
|
+
description: "A test skill",
|
|
338
|
+
category: "general",
|
|
339
|
+
});
|
|
340
|
+
expect(skills[0].tools).toEqual([]);
|
|
341
|
+
expect(skills[0].config).toEqual({});
|
|
342
|
+
});
|
|
343
|
+
it("discovers multiple skills", () => {
|
|
344
|
+
writeSkill("skill-a", `---\nname: skill-a\ndescription: First\n---\nBody`);
|
|
345
|
+
writeSkill("skill-b", `---\nname: skill-b\ndescription: Second\n---\nBody`);
|
|
346
|
+
const skills = loadCustomSkills(tmpDir);
|
|
347
|
+
expect(skills).toHaveLength(2);
|
|
348
|
+
const ids = skills.map((s) => s.id).sort();
|
|
349
|
+
expect(ids).toEqual(["skill-a", "skill-b"]);
|
|
350
|
+
});
|
|
351
|
+
it("skips directories without SKILL.md", () => {
|
|
352
|
+
mkdirSync(join(tmpDir, ".claude", "skills", "no-skill-file"), { recursive: true });
|
|
353
|
+
writeFileSync(join(tmpDir, ".claude", "skills", "no-skill-file", "README.md"), "not a skill");
|
|
354
|
+
const skills = loadCustomSkills(tmpDir);
|
|
355
|
+
expect(skills).toEqual([]);
|
|
356
|
+
});
|
|
357
|
+
it("skips SKILL.md without frontmatter", () => {
|
|
358
|
+
writeSkill("no-frontmatter", "Just plain text, no YAML frontmatter.");
|
|
359
|
+
const skills = loadCustomSkills(tmpDir);
|
|
360
|
+
expect(skills).toEqual([]);
|
|
361
|
+
});
|
|
362
|
+
it("skips SKILL.md with frontmatter missing name field", () => {
|
|
363
|
+
writeSkill("no-name", `---\ndescription: Missing name\n---\nBody`);
|
|
364
|
+
const skills = loadCustomSkills(tmpDir);
|
|
365
|
+
expect(skills).toEqual([]);
|
|
366
|
+
});
|
|
367
|
+
it("uses fallback description when description is missing", () => {
|
|
368
|
+
writeSkill("name-only", `---\nname: name-only\n---\nBody`);
|
|
369
|
+
const skills = loadCustomSkills(tmpDir);
|
|
370
|
+
expect(skills).toHaveLength(1);
|
|
371
|
+
expect(skills[0].description).toBe("Custom skill: name-only");
|
|
372
|
+
});
|
|
373
|
+
it("skips malformed YAML in frontmatter", () => {
|
|
374
|
+
writeSkill("bad-yaml", `---\n: [invalid yaml\n---\nBody`);
|
|
375
|
+
const skills = loadCustomSkills(tmpDir);
|
|
376
|
+
expect(skills).toEqual([]);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
describe("configuredSkills with custom skills", () => {
|
|
380
|
+
let tmpDir;
|
|
381
|
+
beforeEach(() => {
|
|
382
|
+
tmpDir = mkdtempSync(join(tmpdir(), "sweny-configured-test-"));
|
|
383
|
+
});
|
|
384
|
+
afterEach(() => {
|
|
385
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
386
|
+
});
|
|
387
|
+
function writeSkill(dirName, content) {
|
|
388
|
+
const dir = join(tmpDir, ".claude", "skills", dirName);
|
|
389
|
+
mkdirSync(dir, { recursive: true });
|
|
390
|
+
writeFileSync(join(dir, "SKILL.md"), content);
|
|
391
|
+
}
|
|
392
|
+
it("includes custom skills alongside configured built-ins", () => {
|
|
393
|
+
writeSkill("my-custom", `---\nname: my-custom\ndescription: Custom\n---\nBody`);
|
|
394
|
+
// Provide a notification webhook so at least one built-in is configured
|
|
395
|
+
const skills = configuredSkills({ NOTIFICATION_WEBHOOK_URL: "https://example.com" }, tmpDir);
|
|
396
|
+
const ids = skills.map((s) => s.id);
|
|
397
|
+
expect(ids).toContain("my-custom");
|
|
398
|
+
expect(ids).toContain("notification");
|
|
399
|
+
});
|
|
400
|
+
it("built-in skills take precedence over custom skills with same ID", () => {
|
|
401
|
+
// notification is configured when a webhook URL is set
|
|
402
|
+
writeSkill("notification", `---\nname: notification\ndescription: Custom override attempt\n---\nBody`);
|
|
403
|
+
const skills = configuredSkills({ NOTIFICATION_WEBHOOK_URL: "https://example.com" }, tmpDir);
|
|
404
|
+
const notif = skills.filter((s) => s.id === "notification");
|
|
405
|
+
expect(notif).toHaveLength(1);
|
|
406
|
+
// Should be the built-in (has tools), not the custom one (tools: [])
|
|
407
|
+
expect(notif[0].tools.length).toBeGreaterThan(0);
|
|
408
|
+
});
|
|
409
|
+
it("custom skills are usable in createSkillMap", () => {
|
|
410
|
+
writeSkill("extract-colors", `---\nname: extract-colors\ndescription: Route color extraction\n---\nBody`);
|
|
411
|
+
const skills = configuredSkills({}, tmpDir);
|
|
412
|
+
const map = createSkillMap(skills);
|
|
413
|
+
expect(map.has("extract-colors")).toBe(true);
|
|
414
|
+
expect(map.get("extract-colors").tools).toEqual([]);
|
|
415
|
+
});
|
|
416
|
+
});
|
package/dist/cli/main.js
CHANGED
|
@@ -194,7 +194,7 @@ triageCmd.action(async (options) => {
|
|
|
194
194
|
console.log(formatBanner(config, version));
|
|
195
195
|
}
|
|
196
196
|
// ── Build skill map + MCP servers + Claude client ──────────
|
|
197
|
-
const skills = createSkillMap(configuredSkills());
|
|
197
|
+
const skills = createSkillMap(configuredSkills(process.env, process.cwd()));
|
|
198
198
|
const mcpAutoConfig = buildMcpAutoConfig(config);
|
|
199
199
|
const mcpServers = buildAutoMcpServers(mcpAutoConfig);
|
|
200
200
|
const claude = new ClaudeClient({
|
|
@@ -358,7 +358,7 @@ triageCmd.action(async (options) => {
|
|
|
358
358
|
contextUrls,
|
|
359
359
|
};
|
|
360
360
|
try {
|
|
361
|
-
const results = await execute(triageWorkflow, workflowInput, {
|
|
361
|
+
const { results } = await execute(triageWorkflow, workflowInput, {
|
|
362
362
|
skills,
|
|
363
363
|
claude,
|
|
364
364
|
observer,
|
|
@@ -412,7 +412,7 @@ implementCmd.action(async (issueId, options) => {
|
|
|
412
412
|
fileConfig["output-dir"] ||
|
|
413
413
|
".sweny/output",
|
|
414
414
|
};
|
|
415
|
-
const skills = createSkillMap(configuredSkills());
|
|
415
|
+
const skills = createSkillMap(configuredSkills(process.env, process.cwd()));
|
|
416
416
|
const mcpAutoConfig = buildMcpAutoConfig(config);
|
|
417
417
|
const mcpServers = buildAutoMcpServers(mcpAutoConfig);
|
|
418
418
|
const claude = new ClaudeClient({
|
|
@@ -474,7 +474,7 @@ implementCmd.action(async (issueId, options) => {
|
|
|
474
474
|
contextUrls,
|
|
475
475
|
};
|
|
476
476
|
try {
|
|
477
|
-
const results = await execute(implementWorkflow, workflowInput, {
|
|
477
|
+
const { results } = await execute(implementWorkflow, workflowInput, {
|
|
478
478
|
skills,
|
|
479
479
|
claude,
|
|
480
480
|
observer,
|
|
@@ -546,7 +546,7 @@ export async function workflowRunAction(file, options) {
|
|
|
546
546
|
const config = parseCliInputs(options, fileConfig);
|
|
547
547
|
const isJson = Boolean(options.json);
|
|
548
548
|
const isTTY = !isJson && (process.stderr.isTTY ?? false);
|
|
549
|
-
const skills = createSkillMap(configuredSkills());
|
|
549
|
+
const skills = createSkillMap(configuredSkills(process.env, process.cwd()));
|
|
550
550
|
const mcpAutoConfig = buildMcpAutoConfig(config);
|
|
551
551
|
const mcpServers = buildAutoMcpServers(mcpAutoConfig);
|
|
552
552
|
const claude = new ClaudeClient({
|
|
@@ -624,7 +624,7 @@ export async function workflowRunAction(file, options) {
|
|
|
624
624
|
};
|
|
625
625
|
}
|
|
626
626
|
try {
|
|
627
|
-
const results = await execute(workflow, workflowInput, {
|
|
627
|
+
const { results, trace } = await execute(workflow, workflowInput, {
|
|
628
628
|
skills,
|
|
629
629
|
claude,
|
|
630
630
|
observer,
|
|
@@ -642,7 +642,7 @@ export async function workflowRunAction(file, options) {
|
|
|
642
642
|
for (const [nodeId, result] of results) {
|
|
643
643
|
state[nodeId] = result.status === "success" ? "success" : result.status === "failed" ? "failed" : "skipped";
|
|
644
644
|
}
|
|
645
|
-
process.stdout.write(toMermaidBlock(workflow, { state, title: workflow.name }) + "\n");
|
|
645
|
+
process.stdout.write(toMermaidBlock(workflow, { state, trace, title: workflow.name }) + "\n");
|
|
646
646
|
}
|
|
647
647
|
const hasFailed = [...results.values()].some((r) => r.status === "failed");
|
|
648
648
|
if (hasFailed) {
|
package/dist/executor.d.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* This replaces ~8k lines of engine + recipe step code.
|
|
14
14
|
*/
|
|
15
|
-
import type { Workflow, Skill, Claude, Observer,
|
|
15
|
+
import type { Workflow, Skill, Claude, Observer, Logger, ExecutionResult } from "./types.js";
|
|
16
16
|
export interface ExecuteOptions {
|
|
17
17
|
/** Registered skills (id → Skill) */
|
|
18
18
|
skills: Map<string, Skill>;
|
|
@@ -28,6 +28,8 @@ export interface ExecuteOptions {
|
|
|
28
28
|
/**
|
|
29
29
|
* Execute a workflow from entry to completion.
|
|
30
30
|
*
|
|
31
|
-
* Returns
|
|
31
|
+
* Returns an ExecutionResult with:
|
|
32
|
+
* - `results`: map of node ID → final result (last execution if retried)
|
|
33
|
+
* - `trace`: full ordered execution trace including loops and routing decisions
|
|
32
34
|
*/
|
|
33
|
-
export declare function execute(workflow: Workflow, input: unknown, options: ExecuteOptions): Promise<
|
|
35
|
+
export declare function execute(workflow: Workflow, input: unknown, options: ExecuteOptions): Promise<ExecutionResult>;
|
package/dist/executor.js
CHANGED
|
@@ -16,7 +16,9 @@ import { consoleLogger } from "./types.js";
|
|
|
16
16
|
/**
|
|
17
17
|
* Execute a workflow from entry to completion.
|
|
18
18
|
*
|
|
19
|
-
* Returns
|
|
19
|
+
* Returns an ExecutionResult with:
|
|
20
|
+
* - `results`: map of node ID → final result (last execution if retried)
|
|
21
|
+
* - `trace`: full ordered execution trace including loops and routing decisions
|
|
20
22
|
*/
|
|
21
23
|
export async function execute(workflow, input, options) {
|
|
22
24
|
const { skills, claude, observer } = options;
|
|
@@ -24,6 +26,8 @@ export async function execute(workflow, input, options) {
|
|
|
24
26
|
const logger = options.logger ?? consoleLogger;
|
|
25
27
|
const results = new Map();
|
|
26
28
|
const edgeCounts = new Map(); // "from→to" → times followed
|
|
29
|
+
const nodeRunCounts = new Map(); // node → times executed
|
|
30
|
+
const trace = { steps: [], edges: [] };
|
|
27
31
|
validate(workflow, skills);
|
|
28
32
|
safeObserve(observer, { type: "workflow:start", workflow: workflow.id }, logger);
|
|
29
33
|
let currentId = workflow.entry;
|
|
@@ -31,6 +35,8 @@ export async function execute(workflow, input, options) {
|
|
|
31
35
|
const node = workflow.nodes[currentId];
|
|
32
36
|
if (!node)
|
|
33
37
|
throw new Error(`Unknown node: "${currentId}"`);
|
|
38
|
+
const iteration = (nodeRunCounts.get(currentId) ?? 0) + 1;
|
|
39
|
+
nodeRunCounts.set(currentId, iteration);
|
|
34
40
|
safeObserve(observer, { type: "node:enter", node: currentId, instruction: node.instruction }, logger);
|
|
35
41
|
logger.info(`→ ${node.name}`, { node: currentId });
|
|
36
42
|
// Gather tools from the node's skills
|
|
@@ -64,6 +70,7 @@ export async function execute(workflow, input, options) {
|
|
|
64
70
|
},
|
|
65
71
|
});
|
|
66
72
|
results.set(currentId, result);
|
|
73
|
+
trace.steps.push({ node: currentId, status: result.status, iteration });
|
|
67
74
|
safeObserve(observer, { type: "node:exit", node: currentId, result }, logger);
|
|
68
75
|
logger.info(` ✓ ${result.status}`, { node: currentId, toolCalls: result.toolCalls.length });
|
|
69
76
|
// Dry run hard gate — stop at the first conditional routing decision.
|
|
@@ -80,13 +87,20 @@ export async function execute(workflow, input, options) {
|
|
|
80
87
|
}
|
|
81
88
|
}
|
|
82
89
|
// Resolve next node via edge conditions
|
|
90
|
+
const prevId = currentId;
|
|
83
91
|
currentId = await resolveNext(workflow, currentId, results, input, claude, observer, edgeCounts, logger);
|
|
92
|
+
// Record the routing decision in the trace
|
|
93
|
+
if (currentId) {
|
|
94
|
+
const edgeKey = `${prevId}→${currentId}`;
|
|
95
|
+
const reason = workflow.edges.find((e) => e.from === prevId && e.to === currentId)?.when ?? "only path";
|
|
96
|
+
trace.edges.push({ from: prevId, to: currentId, reason });
|
|
97
|
+
}
|
|
84
98
|
}
|
|
85
99
|
safeObserve(observer, {
|
|
86
100
|
type: "workflow:end",
|
|
87
101
|
results: Object.fromEntries(results),
|
|
88
102
|
}, logger);
|
|
89
|
-
return results;
|
|
103
|
+
return { results, trace };
|
|
90
104
|
}
|
|
91
105
|
// ─── Internals ───────────────────────────────────────────────────
|
|
92
106
|
/**
|
|
@@ -119,7 +133,12 @@ function buildNodeInstruction(baseInstruction, input) {
|
|
|
119
133
|
return baseInstruction;
|
|
120
134
|
return `${sections.join("\n\n")}\n\n---\n\n${baseInstruction}`;
|
|
121
135
|
}
|
|
122
|
-
/**
|
|
136
|
+
/**
|
|
137
|
+
* Call observer without letting exceptions crash the workflow.
|
|
138
|
+
*
|
|
139
|
+
* Accepts a typed ExecutionEvent so TypeScript catches mistakes in
|
|
140
|
+
* event construction at compile time rather than silently at runtime.
|
|
141
|
+
*/
|
|
123
142
|
function safeObserve(observer, event, logger) {
|
|
124
143
|
if (!observer)
|
|
125
144
|
return;
|
package/dist/executor.test.js
CHANGED
|
@@ -167,7 +167,7 @@ describe("executor", () => {
|
|
|
167
167
|
},
|
|
168
168
|
});
|
|
169
169
|
const events = [];
|
|
170
|
-
const results = await execute(simpleWorkflow, { alert: "test" }, {
|
|
170
|
+
const { results } = await execute(simpleWorkflow, { alert: "test" }, {
|
|
171
171
|
skills,
|
|
172
172
|
claude,
|
|
173
173
|
observer: (e) => events.push(e),
|
|
@@ -202,7 +202,7 @@ describe("executor", () => {
|
|
|
202
202
|
check: "handle_high", // Route to high severity handler
|
|
203
203
|
},
|
|
204
204
|
});
|
|
205
|
-
const results = await execute(branchingWorkflow, { alert: "cpu spike" }, {
|
|
205
|
+
const { results } = await execute(branchingWorkflow, { alert: "cpu spike" }, {
|
|
206
206
|
skills,
|
|
207
207
|
claude,
|
|
208
208
|
config: {},
|
|
@@ -224,7 +224,7 @@ describe("executor", () => {
|
|
|
224
224
|
check: "handle_low",
|
|
225
225
|
},
|
|
226
226
|
});
|
|
227
|
-
const results = await execute(branchingWorkflow, { alert: "minor" }, {
|
|
227
|
+
const { results } = await execute(branchingWorkflow, { alert: "minor" }, {
|
|
228
228
|
skills,
|
|
229
229
|
claude,
|
|
230
230
|
config: {},
|
package/dist/index.d.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* })
|
|
22
22
|
* ```
|
|
23
23
|
*/
|
|
24
|
-
export type { Skill, SkillCategory, Tool, ToolContext, ConfigField, JSONSchema, Workflow, Node, Edge, NodeResult, ToolCall, ExecutionEvent, Observer, Claude, Logger, } from "./types.js";
|
|
24
|
+
export type { Skill, SkillCategory, Tool, ToolContext, ConfigField, JSONSchema, Workflow, Node, Edge, NodeResult, ToolCall, ExecutionEvent, Observer, Claude, Logger, TraceStep, TraceEdge, ExecutionTrace, ExecutionResult, } from "./types.js";
|
|
25
25
|
export { consoleLogger } from "./types.js";
|
|
26
26
|
export { execute } from "./executor.js";
|
|
27
27
|
export type { ExecuteOptions } from "./executor.js";
|
package/dist/mermaid.d.ts
CHANGED
|
@@ -6,13 +6,16 @@
|
|
|
6
6
|
* VS Code preview, documentation sites, and anywhere Mermaid is supported.
|
|
7
7
|
*
|
|
8
8
|
* Accepts optional execution state to color nodes by status
|
|
9
|
-
* (current/success/failed/skipped)
|
|
9
|
+
* (current/success/failed/skipped) and optional execution trace
|
|
10
|
+
* to highlight which edges were taken and show loop iterations.
|
|
10
11
|
*/
|
|
11
|
-
import type { Workflow } from "./types.js";
|
|
12
|
+
import type { Workflow, ExecutionTrace } from "./types.js";
|
|
12
13
|
export type NodeStatus = "current" | "success" | "failed" | "skipped";
|
|
13
14
|
export interface MermaidOptions {
|
|
14
15
|
/** Execution state — nodes not in this map render as default (pending) */
|
|
15
16
|
state?: Record<string, NodeStatus>;
|
|
17
|
+
/** Execution trace — highlights taken/not-taken edges and loop counts */
|
|
18
|
+
trace?: ExecutionTrace;
|
|
16
19
|
/** Graph direction: TB (top-bottom) or LR (left-right). Default: TB */
|
|
17
20
|
direction?: "TB" | "LR";
|
|
18
21
|
/** Title rendered above the diagram */
|
|
@@ -27,6 +30,7 @@ export interface MermaidOptions {
|
|
|
27
30
|
*
|
|
28
31
|
* const md = toMermaid(workflow, {
|
|
29
32
|
* state: { triage: 'success', investigate: 'current' },
|
|
33
|
+
* trace: executionResult.trace,
|
|
30
34
|
* });
|
|
31
35
|
* // Wrap in ```mermaid fence for GitHub rendering
|
|
32
36
|
* ```
|
package/dist/mermaid.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* VS Code preview, documentation sites, and anywhere Mermaid is supported.
|
|
7
7
|
*
|
|
8
8
|
* Accepts optional execution state to color nodes by status
|
|
9
|
-
* (current/success/failed/skipped)
|
|
9
|
+
* (current/success/failed/skipped) and optional execution trace
|
|
10
|
+
* to highlight which edges were taken and show loop iterations.
|
|
10
11
|
*/
|
|
11
12
|
/**
|
|
12
13
|
* Convert a Workflow to a Mermaid flowchart diagram string.
|
|
@@ -17,12 +18,13 @@
|
|
|
17
18
|
*
|
|
18
19
|
* const md = toMermaid(workflow, {
|
|
19
20
|
* state: { triage: 'success', investigate: 'current' },
|
|
21
|
+
* trace: executionResult.trace,
|
|
20
22
|
* });
|
|
21
23
|
* // Wrap in ```mermaid fence for GitHub rendering
|
|
22
24
|
* ```
|
|
23
25
|
*/
|
|
24
26
|
export function toMermaid(workflow, options = {}) {
|
|
25
|
-
const { state = {}, direction = "TB", title } = options;
|
|
27
|
+
const { state = {}, trace, direction = "TB", title } = options;
|
|
26
28
|
const lines = [];
|
|
27
29
|
if (title) {
|
|
28
30
|
lines.push("---");
|
|
@@ -30,45 +32,80 @@ export function toMermaid(workflow, options = {}) {
|
|
|
30
32
|
lines.push("---");
|
|
31
33
|
}
|
|
32
34
|
lines.push(`graph ${direction}`);
|
|
35
|
+
// Build node iteration counts from trace
|
|
36
|
+
const nodeIterations = new Map();
|
|
37
|
+
if (trace) {
|
|
38
|
+
for (const step of trace.steps) {
|
|
39
|
+
nodeIterations.set(step.node, Math.max(nodeIterations.get(step.node) ?? 0, step.iteration));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
33
42
|
// Nodes
|
|
34
43
|
for (const [id, node] of Object.entries(workflow.nodes)) {
|
|
35
|
-
|
|
44
|
+
let label = escapeLabel(node.name);
|
|
36
45
|
const isEntry = id === workflow.entry;
|
|
37
46
|
const entryTag = isEntry ? " \\u25B6" : "";
|
|
38
|
-
//
|
|
47
|
+
// Show iteration count for nodes that ran multiple times
|
|
48
|
+
const iterations = nodeIterations.get(id);
|
|
49
|
+
const iterTag = iterations && iterations > 1 ? ` ×${iterations}` : "";
|
|
39
50
|
if (isEntry) {
|
|
40
|
-
lines.push(` ${sanitizeId(id)}([${label}${entryTag}])`);
|
|
51
|
+
lines.push(` ${sanitizeId(id)}([${label}${entryTag}${iterTag}])`);
|
|
41
52
|
}
|
|
42
53
|
else {
|
|
43
|
-
lines.push(` ${sanitizeId(id)}[${label}]`);
|
|
54
|
+
lines.push(` ${sanitizeId(id)}[${label}${iterTag}]`);
|
|
44
55
|
}
|
|
45
56
|
}
|
|
46
57
|
lines.push("");
|
|
47
|
-
//
|
|
58
|
+
// Build set of taken edges from trace for lookup
|
|
59
|
+
const takenEdges = new Set();
|
|
60
|
+
const edgeTakenCounts = new Map();
|
|
61
|
+
if (trace) {
|
|
62
|
+
for (const te of trace.edges) {
|
|
63
|
+
const key = `${te.from}→${te.to}`;
|
|
64
|
+
takenEdges.add(key);
|
|
65
|
+
edgeTakenCounts.set(key, (edgeTakenCounts.get(key) ?? 0) + 1);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Edges — track index for linkStyle directives
|
|
69
|
+
const takenIndices = [];
|
|
70
|
+
const notTakenIndices = [];
|
|
71
|
+
let edgeIndex = 0;
|
|
48
72
|
for (const edge of workflow.edges) {
|
|
49
73
|
const from = sanitizeId(edge.from);
|
|
50
74
|
const to = sanitizeId(edge.to);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
75
|
+
const key = `${edge.from}→${edge.to}`;
|
|
76
|
+
const taken = takenEdges.has(key);
|
|
77
|
+
const takenCount = edgeTakenCounts.get(key) ?? 0;
|
|
78
|
+
// Build label parts
|
|
79
|
+
const labelParts = [];
|
|
80
|
+
if (edge.when)
|
|
81
|
+
labelParts.push(edge.when);
|
|
82
|
+
if (edge.max_iterations)
|
|
83
|
+
labelParts.push(`max ${edge.max_iterations}x`);
|
|
84
|
+
// Show how many times this edge was actually followed
|
|
85
|
+
if (takenCount > 1)
|
|
86
|
+
labelParts.push(`taken ${takenCount}x`);
|
|
87
|
+
if (labelParts.length > 0) {
|
|
88
|
+
const label = escapeLabel(labelParts.join(" · "));
|
|
89
|
+
lines.push(` ${from} -->|"${label}"| ${to}`);
|
|
62
90
|
}
|
|
63
91
|
else {
|
|
64
92
|
lines.push(` ${from} --> ${to}`);
|
|
65
93
|
}
|
|
94
|
+
// Track edge indices for styling
|
|
95
|
+
if (trace) {
|
|
96
|
+
if (taken) {
|
|
97
|
+
takenIndices.push(edgeIndex);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
notTakenIndices.push(edgeIndex);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
edgeIndex++;
|
|
66
104
|
}
|
|
67
|
-
//
|
|
105
|
+
// Node status styling
|
|
68
106
|
const statusNodes = groupByStatus(state);
|
|
69
107
|
if (statusNodes.size > 0) {
|
|
70
108
|
lines.push("");
|
|
71
|
-
// Define style classes
|
|
72
109
|
lines.push(" classDef current fill:#3b82f6,stroke:#2563eb,color:#fff,stroke-width:2px");
|
|
73
110
|
lines.push(" classDef success fill:#22c55e,stroke:#16a34a,color:#fff,stroke-width:2px");
|
|
74
111
|
lines.push(" classDef failed fill:#ef4444,stroke:#dc2626,color:#fff,stroke-width:2px");
|
|
@@ -78,6 +115,16 @@ export function toMermaid(workflow, options = {}) {
|
|
|
78
115
|
lines.push(` class ${sanitized} ${status}`);
|
|
79
116
|
}
|
|
80
117
|
}
|
|
118
|
+
// Edge styling — taken edges bold green, not-taken edges dashed gray
|
|
119
|
+
if (trace) {
|
|
120
|
+
lines.push("");
|
|
121
|
+
if (takenIndices.length > 0) {
|
|
122
|
+
lines.push(` linkStyle ${takenIndices.join(",")} stroke:#22c55e,stroke-width:3px`);
|
|
123
|
+
}
|
|
124
|
+
if (notTakenIndices.length > 0) {
|
|
125
|
+
lines.push(` linkStyle ${notTakenIndices.join(",")} stroke:#6b7280,stroke-width:1px,stroke-dasharray:5 5`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
81
128
|
return lines.join("\n");
|
|
82
129
|
}
|
|
83
130
|
/**
|
package/dist/mermaid.test.js
CHANGED
|
@@ -72,7 +72,7 @@ describe("toMermaid", () => {
|
|
|
72
72
|
});
|
|
73
73
|
it("renders max_iterations on edges", () => {
|
|
74
74
|
const result = toMermaid(withLoop);
|
|
75
|
-
expect(result).toContain('a -->|"needs retry
|
|
75
|
+
expect(result).toContain('a -->|"needs retry · max 3x"| a');
|
|
76
76
|
expect(result).toContain("a --> b");
|
|
77
77
|
});
|
|
78
78
|
it("renders max_iterations without condition", () => {
|
|
@@ -168,6 +168,43 @@ describe("toMermaid", () => {
|
|
|
168
168
|
expect(result).toContain("class a,b success");
|
|
169
169
|
expect(result).toContain("class c current");
|
|
170
170
|
});
|
|
171
|
+
it("highlights taken edges and dims not-taken edges with trace", () => {
|
|
172
|
+
const trace = {
|
|
173
|
+
steps: [
|
|
174
|
+
{ node: "triage", status: "success", iteration: 1 },
|
|
175
|
+
{ node: "investigate", status: "success", iteration: 1 },
|
|
176
|
+
{ node: "close", status: "success", iteration: 1 },
|
|
177
|
+
],
|
|
178
|
+
edges: [
|
|
179
|
+
{ from: "triage", to: "investigate", reason: "severity is high or critical" },
|
|
180
|
+
{ from: "investigate", to: "close", reason: "only path" },
|
|
181
|
+
],
|
|
182
|
+
};
|
|
183
|
+
const result = toMermaid(branching, { trace });
|
|
184
|
+
// Taken edges get green bold styling (edges 0 and 2 in the workflow)
|
|
185
|
+
expect(result).toContain("linkStyle");
|
|
186
|
+
expect(result).toContain("stroke:#22c55e,stroke-width:3px");
|
|
187
|
+
// Not-taken edges get dashed gray
|
|
188
|
+
expect(result).toContain("stroke:#6b7280,stroke-width:1px,stroke-dasharray:5 5");
|
|
189
|
+
});
|
|
190
|
+
it("shows iteration count on nodes and edges from trace", () => {
|
|
191
|
+
const trace = {
|
|
192
|
+
steps: [
|
|
193
|
+
{ node: "a", status: "success", iteration: 1 },
|
|
194
|
+
{ node: "a", status: "success", iteration: 2 },
|
|
195
|
+
{ node: "b", status: "success", iteration: 1 },
|
|
196
|
+
],
|
|
197
|
+
edges: [
|
|
198
|
+
{ from: "a", to: "a", reason: "needs retry" },
|
|
199
|
+
{ from: "a", to: "b", reason: "only path" },
|
|
200
|
+
],
|
|
201
|
+
};
|
|
202
|
+
const result = toMermaid(withLoop, { trace });
|
|
203
|
+
// Node a ran twice — should show ×2
|
|
204
|
+
expect(result).toContain("×2");
|
|
205
|
+
// Node b ran once — no iteration tag
|
|
206
|
+
expect(result).not.toContain("Done ×");
|
|
207
|
+
});
|
|
171
208
|
});
|
|
172
209
|
describe("builtin workflows", () => {
|
|
173
210
|
it("renders triage workflow", () => {
|
package/dist/schema.d.ts
CHANGED
|
@@ -238,7 +238,11 @@ export interface WorkflowError {
|
|
|
238
238
|
* - All edge sources and targets reference existing nodes
|
|
239
239
|
* - No self-loops
|
|
240
240
|
* - All nodes are reachable from entry
|
|
241
|
+
* - Unbounded cycles (cycles with no max_iterations guard)
|
|
241
242
|
* - (optional) All referenced skills exist in the provided skill set
|
|
243
|
+
*
|
|
244
|
+
* Note: nodes with no outgoing edges are valid terminal nodes — the executor
|
|
245
|
+
* returns null when it reaches one, ending the workflow cleanly.
|
|
242
246
|
*/
|
|
243
247
|
export declare function validateWorkflow(workflow: z.infer<typeof workflowZ>, knownSkills?: Set<string>): WorkflowError[];
|
|
244
248
|
/**
|
package/dist/schema.js
CHANGED
|
@@ -65,7 +65,11 @@ export function parseWorkflow(raw) {
|
|
|
65
65
|
* - All edge sources and targets reference existing nodes
|
|
66
66
|
* - No self-loops
|
|
67
67
|
* - All nodes are reachable from entry
|
|
68
|
+
* - Unbounded cycles (cycles with no max_iterations guard)
|
|
68
69
|
* - (optional) All referenced skills exist in the provided skill set
|
|
70
|
+
*
|
|
71
|
+
* Note: nodes with no outgoing edges are valid terminal nodes — the executor
|
|
72
|
+
* returns null when it reaches one, ending the workflow cleanly.
|
|
69
73
|
*/
|
|
70
74
|
export function validateWorkflow(workflow, knownSkills) {
|
|
71
75
|
const errors = [];
|
package/dist/skills/index.d.ts
CHANGED
|
@@ -14,6 +14,15 @@ import { notification } from "./notification.js";
|
|
|
14
14
|
import { supabase } from "./supabase.js";
|
|
15
15
|
export declare const builtinSkills: Skill[];
|
|
16
16
|
export { github, linear, slack, sentry, datadog, betterstack, notification, supabase };
|
|
17
|
+
/**
|
|
18
|
+
* Load custom skills from a `.claude/skills/` directory.
|
|
19
|
+
*
|
|
20
|
+
* Custom skills are instruction-only (no tools or config) — they provide
|
|
21
|
+
* guidance documents that the coding agent reads from the filesystem.
|
|
22
|
+
* Registering them here ensures workflow validation recognises the skill IDs
|
|
23
|
+
* so nodes referencing them don't produce spurious warnings.
|
|
24
|
+
*/
|
|
25
|
+
export declare function loadCustomSkills(cwd?: string): Skill[];
|
|
17
26
|
/**
|
|
18
27
|
* Build a skill map from an array of skills.
|
|
19
28
|
* Pass to `execute()` as the `skills` option.
|
|
@@ -39,9 +48,10 @@ export declare function allSkills(): Map<string, Skill>;
|
|
|
39
48
|
*/
|
|
40
49
|
export declare function isSkillConfigured(skill: Skill, env?: Record<string, string | undefined>): boolean;
|
|
41
50
|
/**
|
|
42
|
-
* From all builtins, return only the skills that
|
|
51
|
+
* From all builtins + custom repo skills, return only the skills that are usable.
|
|
52
|
+
* Built-in skills are filtered by env vars; custom skills are always included.
|
|
43
53
|
*/
|
|
44
|
-
export declare function configuredSkills(env?: Record<string, string | undefined
|
|
54
|
+
export declare function configuredSkills(env?: Record<string, string | undefined>, cwd?: string): Skill[];
|
|
45
55
|
/**
|
|
46
56
|
* Validate that a workflow's skill requirements are satisfiable.
|
|
47
57
|
*
|
package/dist/skills/index.js
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* All built-in skills + helpers to build a skill map from config.
|
|
5
5
|
*/
|
|
6
|
+
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import YAML from "yaml";
|
|
6
9
|
import { github } from "./github.js";
|
|
7
10
|
import { linear } from "./linear.js";
|
|
8
11
|
import { slack } from "./slack.js";
|
|
@@ -14,6 +17,65 @@ import { supabase } from "./supabase.js";
|
|
|
14
17
|
// ─── Built-in skill catalog ─────────────────────────────────────
|
|
15
18
|
export const builtinSkills = [github, linear, slack, sentry, datadog, betterstack, notification, supabase];
|
|
16
19
|
export { github, linear, slack, sentry, datadog, betterstack, notification, supabase };
|
|
20
|
+
// ─── Custom skill discovery ─────────────────────────────────────
|
|
21
|
+
/**
|
|
22
|
+
* Load custom skills from a `.claude/skills/` directory.
|
|
23
|
+
*
|
|
24
|
+
* Custom skills are instruction-only (no tools or config) — they provide
|
|
25
|
+
* guidance documents that the coding agent reads from the filesystem.
|
|
26
|
+
* Registering them here ensures workflow validation recognises the skill IDs
|
|
27
|
+
* so nodes referencing them don't produce spurious warnings.
|
|
28
|
+
*/
|
|
29
|
+
export function loadCustomSkills(cwd = process.cwd()) {
|
|
30
|
+
const skillsDir = join(cwd, ".claude", "skills");
|
|
31
|
+
if (!existsSync(skillsDir))
|
|
32
|
+
return [];
|
|
33
|
+
const skills = [];
|
|
34
|
+
let entries;
|
|
35
|
+
try {
|
|
36
|
+
entries = readdirSync(skillsDir, { withFileTypes: true })
|
|
37
|
+
.filter((d) => d.isDirectory())
|
|
38
|
+
.map((d) => d.name);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
for (const dirName of entries) {
|
|
44
|
+
const skillFile = join(skillsDir, dirName, "SKILL.md");
|
|
45
|
+
if (!existsSync(skillFile))
|
|
46
|
+
continue;
|
|
47
|
+
try {
|
|
48
|
+
const content = readFileSync(skillFile, "utf-8");
|
|
49
|
+
const frontmatter = parseFrontmatter(content);
|
|
50
|
+
if (!frontmatter?.name)
|
|
51
|
+
continue;
|
|
52
|
+
skills.push({
|
|
53
|
+
id: frontmatter.name,
|
|
54
|
+
name: frontmatter.name,
|
|
55
|
+
description: frontmatter.description ?? `Custom skill: ${frontmatter.name}`,
|
|
56
|
+
category: "general",
|
|
57
|
+
config: {},
|
|
58
|
+
tools: [],
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Skip malformed skill files
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return skills;
|
|
66
|
+
}
|
|
67
|
+
/** Extract YAML frontmatter from a markdown file (between --- delimiters). */
|
|
68
|
+
function parseFrontmatter(content) {
|
|
69
|
+
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
70
|
+
if (!match)
|
|
71
|
+
return null;
|
|
72
|
+
try {
|
|
73
|
+
return YAML.parse(match[1]);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
17
79
|
// ─── Registry helpers ───────────────────────────────────────────
|
|
18
80
|
/**
|
|
19
81
|
* Build a skill map from an array of skills.
|
|
@@ -62,10 +124,21 @@ export function isSkillConfigured(skill, env = process.env) {
|
|
|
62
124
|
return envFields.some((f) => env[f.env]);
|
|
63
125
|
}
|
|
64
126
|
/**
|
|
65
|
-
* From all builtins, return only the skills that
|
|
127
|
+
* From all builtins + custom repo skills, return only the skills that are usable.
|
|
128
|
+
* Built-in skills are filtered by env vars; custom skills are always included.
|
|
66
129
|
*/
|
|
67
|
-
export function configuredSkills(env = process.env) {
|
|
68
|
-
|
|
130
|
+
export function configuredSkills(env = process.env, cwd) {
|
|
131
|
+
const configured = builtinSkills.filter((s) => isSkillConfigured(s, env));
|
|
132
|
+
const custom = loadCustomSkills(cwd);
|
|
133
|
+
// Deduplicate: built-in skills take precedence over custom skills with the same ID
|
|
134
|
+
const ids = new Set(configured.map((s) => s.id));
|
|
135
|
+
for (const skill of custom) {
|
|
136
|
+
if (!ids.has(skill.id)) {
|
|
137
|
+
configured.push(skill);
|
|
138
|
+
ids.add(skill.id);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return configured;
|
|
69
142
|
}
|
|
70
143
|
export function validateWorkflowSkills(workflow, available) {
|
|
71
144
|
const configured = [];
|
package/dist/types.d.ts
CHANGED
|
@@ -107,6 +107,36 @@ export type ExecutionEvent = {
|
|
|
107
107
|
results: Record<string, NodeResult>;
|
|
108
108
|
};
|
|
109
109
|
export type Observer = (event: ExecutionEvent) => void;
|
|
110
|
+
/** A single node execution within the trace */
|
|
111
|
+
export interface TraceStep {
|
|
112
|
+
/** Node ID */
|
|
113
|
+
node: string;
|
|
114
|
+
/** Outcome of this execution */
|
|
115
|
+
status: "success" | "failed" | "skipped";
|
|
116
|
+
/** 1-based iteration count (2 = second time this node ran) */
|
|
117
|
+
iteration: number;
|
|
118
|
+
}
|
|
119
|
+
/** A routing decision between nodes */
|
|
120
|
+
export interface TraceEdge {
|
|
121
|
+
from: string;
|
|
122
|
+
to: string;
|
|
123
|
+
/** Why this edge was chosen (condition text or "only path") */
|
|
124
|
+
reason: string;
|
|
125
|
+
}
|
|
126
|
+
/** Full execution trace — ordered steps + edges taken */
|
|
127
|
+
export interface ExecutionTrace {
|
|
128
|
+
/** Ordered list of node executions (includes repeats from retry loops) */
|
|
129
|
+
steps: TraceStep[];
|
|
130
|
+
/** Ordered list of routing decisions */
|
|
131
|
+
edges: TraceEdge[];
|
|
132
|
+
}
|
|
133
|
+
/** Result of execute() — final node results + full execution trace */
|
|
134
|
+
export interface ExecutionResult {
|
|
135
|
+
/** Final result per node (last execution if retried) */
|
|
136
|
+
results: Map<string, NodeResult>;
|
|
137
|
+
/** Full execution trace including loops and routing decisions */
|
|
138
|
+
trace: ExecutionTrace;
|
|
139
|
+
}
|
|
110
140
|
export interface Claude {
|
|
111
141
|
/** Run a node: give Claude an instruction, context, and tools */
|
|
112
142
|
run(opts: {
|