@netnodeag/kraftwerk 0.6.0 → 0.8.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 (65) hide show
  1. package/README.md +27 -2
  2. package/dist/cli/doctor.js +40 -3
  3. package/dist/cli/init.js +66 -15
  4. package/dist/cli/kraftwerk.js +9 -10
  5. package/dist/cli/runs.js +13 -6
  6. package/dist/cli/ui.js +3 -4
  7. package/dist/config.d.ts +6 -0
  8. package/dist/config.js +4 -2
  9. package/dist/harnesses/claude.js +0 -1
  10. package/dist/inspector/chat/acp.js +10 -2
  11. package/dist/inspector/chat/backend.d.ts +6 -0
  12. package/dist/inspector/chat/pi.js +1 -0
  13. package/dist/inspector/chat/sessions.d.ts +4 -0
  14. package/dist/inspector/chat/sessions.js +105 -14
  15. package/dist/inspector/context.d.ts +7 -2
  16. package/dist/inspector/context.js +11 -3
  17. package/dist/inspector/runner.d.ts +10 -0
  18. package/dist/inspector/runner.js +6 -8
  19. package/dist/inspector/runs.d.ts +2 -0
  20. package/dist/inspector/runs.js +17 -12
  21. package/dist/inspector/server.d.ts +2 -0
  22. package/dist/inspector/server.js +40 -2
  23. package/dist/inspector/skills.d.ts +26 -0
  24. package/dist/inspector/skills.js +65 -0
  25. package/dist/inspector/team.d.ts +7 -0
  26. package/dist/inspector/team.js +2 -0
  27. package/dist/runner/docker.d.ts +3 -1
  28. package/dist/runner/docker.js +5 -5
  29. package/dist/workflow.d.ts +7 -0
  30. package/dist/workflow.js +19 -0
  31. package/dist/yaml.js +2 -2
  32. package/inspector/dist/assets/index-Bs8rMhqu.js +13 -0
  33. package/inspector/dist/assets/index-C8AKqc_i.css +1 -0
  34. package/inspector/dist/assets/roboto-latin-400-normal-BqEyEoaF.woff2 +0 -0
  35. package/inspector/dist/assets/roboto-latin-400-normal-D27F5sTu.woff +0 -0
  36. package/inspector/dist/assets/roboto-latin-500-normal-7RbcRiD8.woff2 +0 -0
  37. package/inspector/dist/assets/roboto-latin-500-normal-fsTFPL3E.woff +0 -0
  38. package/inspector/dist/assets/roboto-latin-700-normal-BZpUvMxY.woff2 +0 -0
  39. package/inspector/dist/assets/roboto-latin-700-normal-YuyVweIx.woff +0 -0
  40. package/inspector/dist/assets/roboto-mono-latin-400-normal-DBZPkcnn.woff +0 -0
  41. package/inspector/dist/assets/roboto-mono-latin-400-normal-GekRknry.woff2 +0 -0
  42. package/inspector/dist/assets/roboto-mono-latin-500-normal-D6j6AMzd.woff +0 -0
  43. package/inspector/dist/assets/roboto-mono-latin-500-normal-pxIOXZmA.woff2 +0 -0
  44. package/inspector/dist/assets/roboto-mono-latin-700-normal-CHYrj1DZ.woff +0 -0
  45. package/inspector/dist/assets/roboto-mono-latin-700-normal-foxDnrFi.woff2 +0 -0
  46. package/inspector/dist/index.html +2 -2
  47. package/package.json +1 -1
  48. package/inspector/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  49. package/inspector/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  50. package/inspector/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  51. package/inspector/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  52. package/inspector/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  53. package/inspector/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
  54. package/inspector/dist/assets/ibm-plex-mono-latin-700-normal-7sUh57Bg.woff2 +0 -0
  55. package/inspector/dist/assets/ibm-plex-mono-latin-700-normal-CNHXzs6v.woff +0 -0
  56. package/inspector/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  57. package/inspector/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
  58. package/inspector/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
  59. package/inspector/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
  60. package/inspector/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
  61. package/inspector/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  62. package/inspector/dist/assets/ibm-plex-sans-latin-700-normal-Bth3BMcD.woff +0 -0
  63. package/inspector/dist/assets/ibm-plex-sans-latin-700-normal-Bxkt5Cjx.woff2 +0 -0
  64. package/inspector/dist/assets/index-BtcPa8nx.css +0 -1
  65. package/inspector/dist/assets/index-CImcmBVs.js +0 -10
package/README.md CHANGED
@@ -153,6 +153,7 @@ model: sonnet # optional; harness default when omitted
153
153
  effort: medium # optional: low | medium | high | xhigh | max
154
154
  workflows: [tagline, website-check]
155
155
  knowledge: [customer-support] # OKF bundles the member consults & maintains
156
+ skills: [report-html] # optional allowlist; omit = all skills, [] = none
156
157
  ```
157
158
 
158
159
  Sessions with a member are ordinary chats scoped `{ kind: "team", member }`,
@@ -169,6 +170,29 @@ options and the thinking budget via `MAX_THINKING_TOKENS`; codex gets a
169
170
  `--model`/`--thinking` flags. Members are created and edited in the UI (or by
170
171
  editing the files — the definition is read fresh for each new session).
171
172
 
173
+ ### Skills in chat
174
+
175
+ Chats — general ones and team sessions — can use skills: Claude-style
176
+ instruction packages, one folder per skill with a `SKILL.md` (YAML
177
+ frontmatter `name` + `description`, then the instructions). Two roots are
178
+ discovered, with a project skill shadowing a same-named user skill:
179
+
180
+ ```
181
+ <project>/.claude/skills/<name>/SKILL.md # git-tracked, per project
182
+ ~/.claude/skills/<name>/SKILL.md # personal, per user
183
+ ```
184
+
185
+ Every chat lists its visible skills as context ("## Your skills"), so the
186
+ agent reaches for one when the request matches. Typing `/` in the composer
187
+ opens an autocomplete over them; sending `/<name> <args>` expands the
188
+ skill's SKILL.md into the prompt — which is why this works identically on
189
+ claude, codex, and pi. Natively on top of that, claude discovers
190
+ `.claude/skills` itself (a team member's `skills:` allowlist narrows that
191
+ via ACP session options) and pi loads each visible skill folder via
192
+ `--skill`. Team members take an optional `skills:` list in `agent.yml`:
193
+ omitted means all discovered skills, an empty list means none, otherwise
194
+ it's the allowlist. `GET /api/skills` returns what's discovered.
195
+
172
196
  ### Routines — scheduled prompts
173
197
 
174
198
  A member can have routines: cron-scheduled prompts, like standing orders for
@@ -405,8 +429,9 @@ Living examples:
405
429
  [`../agent-playground/src/workflows/tagline/`](../agent-playground/src/workflows/tagline/)
406
430
  and [`../agent-playground/src/workflows/pitch/`](../agent-playground/src/workflows/pitch/);
407
431
  `check`/`if` in [`../agent-playground/src/workflows/helpdesk-check/`](../agent-playground/src/workflows/helpdesk-check/).
408
- v1 is deliberately linear — approval loops, AGENTS.md-style context files
409
- and skills stay on the roadmap; anything non-linear is a TS workflow.
432
+ v1 is deliberately linear — approval loops and AGENTS.md-style context files
433
+ stay on the roadmap (skills exist in chat, not in workflow runs); anything
434
+ non-linear is a TS workflow.
410
435
 
411
436
  ### MCP servers alongside the workflow
412
437
 
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { readFile } from "node:fs/promises";
2
+ import { readFile, stat } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import chalk from "chalk";
5
5
  import { resolveProject } from "../config.js";
@@ -67,9 +67,46 @@ export async function runDoctor(cwd) {
67
67
  else {
68
68
  report("ok", `kraftwerk ${pkg.version}`, latest === pkg.version ? "latest on npm" : `ahead of npm (latest: ${latest})`);
69
69
  }
70
- // Project.
71
- const project = await resolveProject(cwd);
70
+ // Project. resolveProject throws on a malformed kraftwerk.yml (bad YAML,
71
+ // non-mapping, unknown keys, non-string values) — surface that as a check.
72
+ let project;
73
+ try {
74
+ project = await resolveProject(cwd);
75
+ }
76
+ catch (err) {
77
+ report("fail", "kraftwerk.yml invalid", err.message.split("\n")[0]);
78
+ console.log(chalk.red("\n1 problem(s) found."));
79
+ process.exit(1);
80
+ }
72
81
  report("info", `Project root: ${project.root}`, project.configPath ? path.basename(project.configPath) : "no kraftwerk.yml (fallback: workflows folder or .git)");
82
+ // Project config: well-formed (guaranteed by the parse above) and the
83
+ // configured paths actually exist.
84
+ if (!project.configPath) {
85
+ report("warn", "no kraftwerk.yml", "recommended as project root marker — `kraftwerk init` scaffolds one");
86
+ }
87
+ else {
88
+ const cfg = project.config;
89
+ const keys = Object.keys(cfg);
90
+ report("ok", `${path.basename(project.configPath)} well-formed`, keys.length ? keys.map((k) => `${k}: ${cfg[k]}`).join(", ") : "empty — defaults apply");
91
+ if (!cfg.name)
92
+ report("info", "name not set in kraftwerk.yml", "inspector header falls back to the folder name");
93
+ const isDir = async (p) => (await stat(p).catch(() => null))?.isDirectory() ?? false;
94
+ for (const key of ["workflows", "knowledge", "agents", "output"]) {
95
+ const value = cfg[key];
96
+ if (!value)
97
+ continue;
98
+ const abs = path.resolve(project.root, value);
99
+ if (await isDir(abs))
100
+ continue;
101
+ if (key === "output") {
102
+ report("info", `output: ${value}`, "directory not created yet — appears on first run");
103
+ }
104
+ else {
105
+ report("fail", `${key}: ${value}`, "configured directory does not exist");
106
+ failures++;
107
+ }
108
+ }
109
+ }
73
110
  const found = project.workflowsRoot ? await discoverWorkflows(cwd) : [];
74
111
  if (!project.workflowsRoot) {
75
112
  report("warn", "no workflows root", "expected src/workflows/ or workflows/ — `kraftwerk init` scaffolds one");
package/dist/cli/init.js CHANGED
@@ -2,16 +2,23 @@ import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import chalk from "chalk";
4
4
  import { SCHEMA_URL } from "../config.js";
5
+ import { initBundle, writeConcept } from "../okf.js";
5
6
  /**
6
7
  * `kraftwerk init` — make any repository a kraftwerk consumer in one
7
- * command: kraftwerk.yml (root marker + config), workflows/ with a runnable
8
- * example, and an output/ ignore entry. Everything is idempotent: existing
9
- * files are left untouched and reported.
8
+ * command: kraftwerk.yml (root marker + config), a kraftwerk-data/ tree with
9
+ * a runnable example workflow, a starter team agent ("max"), a demo
10
+ * knowledge bundle, and an output ignore entry. Everything is idempotent:
11
+ * existing files are left untouched and reported.
10
12
  */
11
- const CONFIG_TEMPLATE = `# kraftwerk project config — also marks the project root for the CLI.
13
+ const DATA_DIR = "kraftwerk-data";
14
+ const configTemplate = (name) => `# kraftwerk project config — also marks the project root for the CLI.
12
15
  # All fields optional. Docs: https://github.com/NETNODEAG/kraftwerk
13
- workflows: workflows # where workflows live
14
- output: output # where run artifacts land (git-ignored)
16
+ name: ${JSON.stringify(name)} # display name, shown in the inspector header + browser tab
17
+ icon: "⚡" # emoji shown as the inspector favicon
18
+ workflows: ${DATA_DIR}/workflows # where workflows live
19
+ output: ${DATA_DIR}/output # where run artifacts land (git-ignored)
20
+ knowledge: ${DATA_DIR}/knowledge # OKF knowledge bundles
21
+ agents: ${DATA_DIR}/agents # team agent definitions
15
22
  `;
16
23
  const WORKFLOW_TEMPLATE = `# yaml-language-server: $schema=${SCHEMA_URL}
17
24
  name: hello
@@ -38,6 +45,34 @@ const PROMPT_TEMPLATE = `Request: \${{ request }}
38
45
  Answer the request and write the answer to answer.md
39
46
  (Markdown, with a short heading).
40
47
  `;
48
+ const AGENT_YML_TEMPLATE = `name: Max - Workflow Runner
49
+ emoji: 🛠️
50
+ description: Runs and explains this project's workflows
51
+ harness: claude
52
+ model: sonnet
53
+ effort: medium
54
+ workflows:
55
+ - hello
56
+ knowledge:
57
+ - demo-customer-support
58
+ `;
59
+ const AGENT_SYSTEM_TEMPLATE = `You are Max, this project's workflow runner. You run and explain the
60
+ project's kraftwerk workflows, and you consult the knowledge bundles you
61
+ have access to before answering questions they cover.
62
+ `;
63
+ const DEMO_BUNDLE = "demo-customer-support";
64
+ const DEMO_CONCEPT = `---
65
+ type: Playbook
66
+ title: Refund handling
67
+ description: How support handles refund requests.
68
+ tags: [ support, refunds ]
69
+ ---
70
+
71
+ # Steps
72
+
73
+ 1. Check the order in the shop backend.
74
+ 2. Refunds under CHF 50 are approved directly.
75
+ `;
41
76
  export async function runInit(cwd) {
42
77
  const exists = async (p) => !!(await stat(p).catch(() => null));
43
78
  const created = [];
@@ -52,20 +87,36 @@ export async function runInit(cwd) {
52
87
  await writeFile(abs, content);
53
88
  created.push(rel);
54
89
  };
55
- await put("kraftwerk.yml", CONFIG_TEMPLATE);
56
- await put("workflows/hello/workflow.yml", WORKFLOW_TEMPLATE);
57
- await put("workflows/hello/prompts/assistant.md", PERSONA_TEMPLATE);
58
- await put("workflows/hello/prompts/answer.md", PROMPT_TEMPLATE);
59
- // .gitignore: append output/ if it's not covered yet.
90
+ await put("kraftwerk.yml", configTemplate(path.basename(path.resolve(cwd))));
91
+ await put(`${DATA_DIR}/workflows/hello/workflow.yml`, WORKFLOW_TEMPLATE);
92
+ await put(`${DATA_DIR}/workflows/hello/prompts/assistant.md`, PERSONA_TEMPLATE);
93
+ await put(`${DATA_DIR}/workflows/hello/prompts/answer.md`, PROMPT_TEMPLATE);
94
+ // Team agent "max".
95
+ await put(`${DATA_DIR}/agents/max/agent.yml`, AGENT_YML_TEMPLATE);
96
+ await put(`${DATA_DIR}/agents/max/system.md`, AGENT_SYSTEM_TEMPLATE);
97
+ // Demo knowledge bundle, through the canonical OKF write path so
98
+ // index.md and log.md come out consistent.
99
+ const knowledgeRoot = path.join(cwd, DATA_DIR, "knowledge");
100
+ const bundleRel = `${DATA_DIR}/knowledge/${DEMO_BUNDLE}/`;
101
+ if (await exists(path.join(knowledgeRoot, DEMO_BUNDLE))) {
102
+ skipped.push(bundleRel);
103
+ }
104
+ else {
105
+ await initBundle(knowledgeRoot, DEMO_BUNDLE);
106
+ await writeConcept(knowledgeRoot, DEMO_BUNDLE, "playbooks/refunds", DEMO_CONCEPT, "kraftwerk-init");
107
+ created.push(bundleRel);
108
+ }
109
+ // .gitignore: append the output dir if it's not covered yet.
110
+ const outputEntry = `${DATA_DIR}/output`;
60
111
  const gitignorePath = path.join(cwd, ".gitignore");
61
112
  const gitignore = (await readFile(gitignorePath, "utf8").catch(() => null)) ?? null;
62
113
  if (gitignore === null) {
63
- await writeFile(gitignorePath, "output/\n");
114
+ await writeFile(gitignorePath, `${outputEntry}/\n`);
64
115
  created.push(".gitignore");
65
116
  }
66
- else if (!gitignore.split("\n").some((l) => l.trim().replace(/\/$/, "") === "output")) {
67
- await appendFile(gitignorePath, `${gitignore.endsWith("\n") ? "" : "\n"}output/\n`);
68
- created.push(".gitignore (output/ added)");
117
+ else if (!gitignore.split("\n").some((l) => l.trim().replace(/\/$/, "") === outputEntry)) {
118
+ await appendFile(gitignorePath, `${gitignore.endsWith("\n") ? "" : "\n"}${outputEntry}/\n`);
119
+ created.push(`.gitignore (${outputEntry}/ added)`);
69
120
  }
70
121
  else {
71
122
  skipped.push(".gitignore");
@@ -17,7 +17,7 @@ import { runUi } from "./ui.js";
17
17
  /**
18
18
  * kraftwerk — the kraftwerk CLI.
19
19
  *
20
- * kraftwerk init scaffold kraftwerk.yml + workflows/ + example
20
+ * kraftwerk init scaffold kraftwerk.yml + kraftwerk-data/ (workflow, agent, knowledge)
21
21
  * kraftwerk list discover + list workflows (--json, --from)
22
22
  * kraftwerk run [workflow] [text] run one (prompts interactively if omitted)
23
23
  * kraftwerk runs [show <id>] inspect past runs from their traces
@@ -129,7 +129,7 @@ program
129
129
  .option("--from <source>", "Workflows from a git remote: github:org/repo[@ref] or git URL")
130
130
  .option("--sandbox", "Run inside the Docker sandbox container (image: kraftwerk-runner)")
131
131
  .option("--ssh", "Forward the SSH agent + known_hosts into the sandbox (only with --sandbox)")
132
- .option("--run-id <id>", "Pin the run folder name (output/<id>), e.g. for external triggers")
132
+ .option("--run-id <id>", "Pin the run folder name (output/runs/<id>), e.g. for external triggers")
133
133
  .action(async (name, requestParts, opts) => {
134
134
  const machine = !!opts.json;
135
135
  const fail = (code, message) => {
@@ -172,8 +172,10 @@ program
172
172
  }
173
173
  if (opts.sandbox) {
174
174
  const { runSandboxed } = await import("../runner/docker.js");
175
+ const { resolveProject } = await import("../config.js");
175
176
  const handle = await runSandboxed({
176
177
  projectRoot: baseDir,
178
+ outputDir: opts.from ? undefined : (await resolveProject(baseDir)).outputDir,
177
179
  workflowPath: entry.path,
178
180
  workflowName: workflow.name,
179
181
  request,
@@ -184,17 +186,14 @@ program
184
186
  console.log(chalk.dim(`sandbox ${handle.containerName} → ${handle.runDir}`));
185
187
  process.exit(await handle.finished);
186
188
  }
187
- if (opts.runId) {
188
- process.env.KRAFTWERK_RUN_DIR = path.resolve("output", opts.runId);
189
- }
190
- else if (!process.env.KRAFTWERK_RUN_DIR) {
189
+ if (opts.runId || !process.env.KRAFTWERK_RUN_DIR) {
191
190
  // Root the run dir explicitly: at the project's output dir (honors
192
191
  // kraftwerk.yml `output:` and works from subdirs); for remote runs at
193
192
  // the caller's cwd — never inside the clone cache.
194
- const { runStamp } = await import("../workflow.js");
193
+ const { newRunId, runDirFor } = await import("../workflow.js");
195
194
  const { resolveProject } = await import("../config.js");
196
195
  const outBase = opts.from ? path.resolve("output") : (await resolveProject(baseDir)).outputDir;
197
- process.env.KRAFTWERK_RUN_DIR = path.join(outBase, `run-${runStamp()}`);
196
+ process.env.KRAFTWERK_RUN_DIR = runDirFor(outBase, opts.runId ?? newRunId(workflow.name));
198
197
  }
199
198
  // Machine/quiet mode: workflow narration must not pollute stdout — the
200
199
  // engine logs via console.log, so reroute it for the duration of the run.
@@ -225,7 +224,7 @@ program
225
224
  });
226
225
  program
227
226
  .command("init")
228
- .description("Scaffold the project: kraftwerk.yml, workflows/ with an example, .gitignore")
227
+ .description("Scaffold the project: kraftwerk.yml, kraftwerk-data/ (example workflow, agent max, demo knowledge), .gitignore")
229
228
  .action(async () => {
230
229
  await runInit(process.cwd());
231
230
  });
@@ -248,7 +247,7 @@ runs
248
247
  runs
249
248
  .command("show")
250
249
  .description("Show one run in detail: phases, gates, cost")
251
- .argument("<runId>", "Folder name under output/, see `kraftwerk runs`")
250
+ .argument("<runId>", "Folder name under output/runs/, see `kraftwerk runs`")
252
251
  .option("--json", "Machine-readable output (all trace events)")
253
252
  .action(async (runId, opts) => {
254
253
  await showRun(process.cwd(), runId, opts);
package/dist/cli/runs.js CHANGED
@@ -4,6 +4,7 @@ import chalk from "chalk";
4
4
  import Table from "cli-table3";
5
5
  import { resolveProject } from "../config.js";
6
6
  import { fmtDuration, fmtTokens } from "../stats.js";
7
+ import { runDirFor } from "../workflow.js";
7
8
  async function readRun(dir) {
8
9
  const tracePath = path.join(dir, "trace.jsonl");
9
10
  const raw = await readFile(tracePath, "utf8").catch(() => null);
@@ -37,12 +38,18 @@ async function readRun(dir) {
37
38
  }
38
39
  async function collectRuns(cwd) {
39
40
  const { outputDir } = await resolveProject(cwd);
40
- const entries = await readdir(outputDir, { withFileTypes: true }).catch(() => []);
41
+ // New runs live under <output>/runs/; legacy run-* folders sit at the root.
42
+ const dirs = [
43
+ ...(await readdir(path.join(outputDir, "runs"), { withFileTypes: true }).catch(() => []))
44
+ .filter((e) => e.isDirectory())
45
+ .map((e) => path.join(outputDir, "runs", e.name)),
46
+ ...(await readdir(outputDir, { withFileTypes: true }).catch(() => []))
47
+ .filter((e) => e.isDirectory() && e.name.startsWith("run-"))
48
+ .map((e) => path.join(outputDir, e.name)),
49
+ ];
41
50
  const runs = [];
42
- for (const entry of entries) {
43
- if (!entry.isDirectory())
44
- continue;
45
- const info = await readRun(path.join(outputDir, entry.name));
51
+ for (const dir of dirs) {
52
+ const info = await readRun(dir);
46
53
  if (info)
47
54
  runs.push(info);
48
55
  }
@@ -83,7 +90,7 @@ export async function listRuns(cwd, opts = {}) {
83
90
  }
84
91
  export async function showRun(cwd, id, opts = {}) {
85
92
  const { outputDir } = await resolveProject(cwd);
86
- const dir = path.join(outputDir, id);
93
+ const dir = runDirFor(outputDir, id);
87
94
  if (!(await stat(dir).catch(() => null))) {
88
95
  console.error(chalk.red(`Run "${id}" not found under ${outputDir}.`));
89
96
  process.exit(2);
package/dist/cli/ui.js CHANGED
@@ -42,11 +42,10 @@ function ensureBuilt() {
42
42
  }
43
43
  export async function runUi(cwd, opts) {
44
44
  const staticDir = ensureBuilt();
45
- const outputDir = opts.output
46
- ? path.resolve(cwd, opts.output)
47
- : (await resolveProject(cwd)).outputDir;
45
+ const project = await resolveProject(cwd);
46
+ const outputDir = opts.output ? path.resolve(cwd, opts.output) : project.outputDir;
48
47
  const port = Number(opts.port ?? "1981");
49
- await startInspector({ outputDir, staticDir, port });
48
+ await startInspector({ outputDir, staticDir, port, projectRoot: project.root });
50
49
  console.log(`${chalk.green("✔")} Inspector: ${chalk.cyan(`http://localhost:${port}`)} ` +
51
50
  chalk.dim(`(output: ${outputDir})`));
52
51
  }
package/dist/config.d.ts CHANGED
@@ -9,6 +9,8 @@
9
9
  *
10
10
  * All fields are optional — a valid kraftwerk.yml may be empty:
11
11
  *
12
+ * name: my-project # display name (inspector header, "environment")
13
+ * icon: "⚡" # emoji shown as inspector favicon
12
14
  * workflows: src/workflows # workflows root, relative to the file
13
15
  * output: output # run-artifact directory, relative to the file
14
16
  * knowledge: knowledge # OKF knowledge-bundle root, relative to the file
@@ -18,6 +20,10 @@
18
20
  export declare const SCHEMA_URL = "https://raw.githubusercontent.com/NETNODEAG/kraftwerk/main/kraftwerk/schema/workflow.schema.json";
19
21
  export declare const CONFIG_FILENAMES: string[];
20
22
  export interface ProjectConfig {
23
+ /** Display name of the project ("environment"), shown in the inspector header. */
24
+ name?: string;
25
+ /** Emoji used as the inspector favicon (browser-tab icon). */
26
+ icon?: string;
21
27
  /** Workflows root relative to the project root. */
22
28
  workflows?: string;
23
29
  /** Run-artifact directory relative to the project root. Default: output */
package/dist/config.js CHANGED
@@ -12,6 +12,8 @@ import { parse } from "yaml";
12
12
  *
13
13
  * All fields are optional — a valid kraftwerk.yml may be empty:
14
14
  *
15
+ * name: my-project # display name (inspector header, "environment")
16
+ * icon: "⚡" # emoji shown as inspector favicon
15
17
  * workflows: src/workflows # workflows root, relative to the file
16
18
  * output: output # run-artifact directory, relative to the file
17
19
  * knowledge: knowledge # OKF knowledge-bundle root, relative to the file
@@ -83,10 +85,10 @@ async function loadConfig(configPath) {
83
85
  if (raw === null || raw === undefined)
84
86
  return {};
85
87
  if (typeof raw !== "object" || Array.isArray(raw)) {
86
- throw new Error(`${path.basename(configPath)}: expected a mapping (workflows, output, knowledge)`);
88
+ throw new Error(`${path.basename(configPath)}: expected a mapping (name, icon, workflows, output, knowledge, agents)`);
87
89
  }
88
90
  const config = raw;
89
- const known = ["workflows", "output", "knowledge", "agents"];
91
+ const known = ["name", "icon", "workflows", "output", "knowledge", "agents"];
90
92
  for (const key of Object.keys(config)) {
91
93
  if (!known.includes(key)) {
92
94
  throw new Error(`${path.basename(configPath)}: unknown key "${key}" (allowed: ${known.join(", ")})`);
@@ -8,7 +8,6 @@ function invokeClaude(inv) {
8
8
  ];
9
9
  const args = [
10
10
  "-p",
11
- "--chrome",
12
11
  "--output-format", "stream-json",
13
12
  "--verbose",
14
13
  "--model", inv.model,
@@ -118,11 +118,19 @@ export async function startAcpBackend(agent, cwd, hooks, tuning = {}) {
118
118
  clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
119
119
  clientInfo: { name: "kraftwerk-inspector", version: "1.0.0" },
120
120
  });
121
+ // Claude-only session options ride on _meta.claudeCode.options (the
122
+ // adapter spreads them into the Agent SDK options): model override,
123
+ // skill allowlist (undefined = all discovered skills), extra work dirs.
124
+ const claudeOptions = {
125
+ ...(tuning.model ? { model: tuning.model } : {}),
126
+ ...(tuning.skills ? { skills: tuning.skills } : {}),
127
+ ...(tuning.addDirs?.length ? { additionalDirectories: tuning.addDirs } : {}),
128
+ };
121
129
  const session = await conn.newSession({
122
130
  cwd,
123
131
  mcpServers: [],
124
- ...(agent === "claude" && tuning.model
125
- ? { _meta: { claudeCode: { options: { model: tuning.model } } } }
132
+ ...(agent === "claude" && Object.keys(claudeOptions).length
133
+ ? { _meta: { claudeCode: { options: claudeOptions } } }
126
134
  : {}),
127
135
  });
128
136
  const sessionId = session.sessionId;
@@ -25,6 +25,12 @@ export interface BackendHooks {
25
25
  export interface BackendTuning {
26
26
  model?: string;
27
27
  effort?: string;
28
+ /** claude: restrict native skill discovery to these names (undefined = all). */
29
+ skills?: string[];
30
+ /** pi: skill folders to load explicitly via --skill. */
31
+ skillDirs?: string[];
32
+ /** claude: extra directories the session may work in (e.g. project root for run chats). */
33
+ addDirs?: string[];
28
34
  }
29
35
  export interface ChatBackend {
30
36
  /** Send one user message; resolves with the stop reason at turn end. */
@@ -11,6 +11,7 @@ export function startPiBackend(cwd, hooks, tuning = {}) {
11
11
  const tuningArgs = [
12
12
  ...(tuning.model ? ["--model", tuning.model] : []),
13
13
  ...(tuning.effort ? ["--thinking", tuning.effort] : []),
14
+ ...(tuning.skillDirs ?? []).flatMap((dir) => ["--skill", dir]),
14
15
  ];
15
16
  return {
16
17
  prompt(text) {
@@ -19,6 +19,10 @@ export declare function postMessage(id: string, text: string): Promise<{
19
19
  export declare function resolvePermission(id: string, requestId: string, optionId: string | null): Promise<{
20
20
  error?: string;
21
21
  }>;
22
+ /** Delete a chat: kill its backend, drop the live state, remove its folder. */
23
+ export declare function deleteChat(id: string): Promise<{
24
+ error?: string;
25
+ }>;
22
26
  export declare function cancelChat(id: string): Promise<{
23
27
  error?: string;
24
28
  }>;
@@ -1,12 +1,14 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
2
3
  import { getOutputDir, getProjectRoot } from "../context.js";
3
4
  import { knowledgeIndex } from "../knowledge.js";
4
5
  import { getRun, listRuns, safeRunDir } from "../runs.js";
6
+ import { listSkills, readSkill } from "../skills.js";
5
7
  import { listWorkflows } from "../workflows.js";
6
8
  import { getMember } from "../team.js";
7
9
  import { startAcpBackend } from "./acp.js";
8
10
  import { startPiBackend } from "./pi.js";
9
- import { appendEvent, listChatMetas, newChatId, readEvents, readMeta, writeMeta } from "./store.js";
11
+ import { appendEvent, listChatMetas, newChatId, readEvents, readMeta, safeChatDir, writeMeta, } from "./store.js";
10
12
  const states = new Map();
11
13
  async function loadState(id) {
12
14
  const existing = states.get(id);
@@ -42,6 +44,54 @@ function emit(state, ev) {
42
44
  fn(stored);
43
45
  return stored;
44
46
  }
47
+ /* ---------- skills ---------- */
48
+ /**
49
+ * Skills visible to a chat: every discovered skill (project + user
50
+ * .claude/skills), narrowed by the team member's allowlist when the chat
51
+ * is a team session (absent allowlist = all, empty = none).
52
+ */
53
+ async function availableSkills(scope) {
54
+ const all = await listSkills().catch(() => []);
55
+ if (scope.kind !== "team" || all.length === 0)
56
+ return all;
57
+ const member = await getMember(scope.member).catch(() => null);
58
+ if (!member || member.skills === undefined)
59
+ return all;
60
+ const allowed = new Set(member.skills.map((n) => n.toLowerCase()));
61
+ return all.filter((s) => allowed.has(s.name.toLowerCase()));
62
+ }
63
+ /** "## Your skills" context block (empty when no skills are visible). */
64
+ function skillsBlock(skills) {
65
+ if (skills.length === 0)
66
+ return "";
67
+ const lines = skills
68
+ .map((s) => `- /${s.name}${s.description ? `: ${s.description}` : ""} — ${s.path}`)
69
+ .join("\n");
70
+ return (`## Your skills\nSkills are reusable instruction packages (a SKILL.md per skill). When the ` +
71
+ `user's request matches one, read its SKILL.md file and follow the instructions in it. The user ` +
72
+ `can also invoke one explicitly by starting a message with /<skill-name>.\n${lines}`);
73
+ }
74
+ /**
75
+ * Explicit skill invocation: a message starting with /<skill-name> is
76
+ * expanded into the skill's SKILL.md instructions (the stored user_message
77
+ * keeps the original "/name args" text). Unknown names pass through as-is.
78
+ */
79
+ async function expandSkillInvocation(scope, text) {
80
+ const m = /^\/([A-Za-z0-9][\w-]*)[ \t]*([\s\S]*)$/.exec(text);
81
+ if (!m)
82
+ return text;
83
+ const skills = await availableSkills(scope);
84
+ const skill = skills.find((s) => s.name.toLowerCase() === m[1].toLowerCase());
85
+ if (!skill)
86
+ return text;
87
+ const body = await readSkill(skill);
88
+ if (!body.trim())
89
+ return text;
90
+ const args = m[2].trim();
91
+ return (`The user invoked the skill "${skill.name}" (${skill.path}). Follow the skill's instructions:\n\n` +
92
+ `<skill name="${skill.name}">\n${body.trim()}\n</skill>\n\n` +
93
+ `Arguments the user passed to the skill: ${args || "(none)"}`);
94
+ }
45
95
  /* ---------- scope context ---------- */
46
96
  /** "## Your knowledge" block for a team member's connected OKF bundles. */
47
97
  async function teamKnowledgeContext(member) {
@@ -65,7 +115,8 @@ async function teamKnowledgeContext(member) {
65
115
  `Never hand-edit index.md or log.md, and never add \`verified\` yourself — verification is the human's ` +
66
116
  `click in the UI.\n\n`);
67
117
  }
68
- async function scopeContext(scope, agent) {
118
+ /** Base context per scope; scopeContext() appends the shared skills block. */
119
+ async function baseScopeContext(scope, agent) {
69
120
  if (scope.kind === "team") {
70
121
  const member = await getMember(scope.member).catch(() => null);
71
122
  if (!member) {
@@ -90,7 +141,7 @@ async function scopeContext(scope, agent) {
90
141
  : "") +
91
142
  `\nRun a workflow with:\n` +
92
143
  ` KRAFTWERK_YES=1 npx kraftwerk run <workflow> "<request>"\n` +
93
- `The command blocks until the run finishes; artifacts land in output/run-*/ — tell the user the ` +
144
+ `The command blocks until the run finishes; artifacts land in output/runs/*/ — tell the user the ` +
94
145
  `run id and summarize the result. Confirm with the user before starting a long or expensive run ` +
95
146
  `unless they clearly asked for it.\n\n`
96
147
  : "") +
@@ -141,7 +192,7 @@ async function scopeContext(scope, agent) {
141
192
  .map((r) => `- ${r.id} — ${r.workflow ?? "?"} [${r.status}] ${r.request ?? ""}`.trim())
142
193
  .join("\n");
143
194
  return (`You are the assistant inside the kraftwerk inspector, a UI for a workflow-as-code agent framework. ` +
144
- `The consumer project root is ${getProjectRoot()}; run outputs live in ${getOutputDir()} (one run-* folder per run, each with a trace.jsonl and working files).\n\n` +
195
+ `The consumer project root is ${getProjectRoot()}; run outputs live in ${getOutputDir()} (one folder per run under runs/, each with a trace.jsonl and working files).\n\n` +
145
196
  `Workflows in this project:\n${wfLines || "(none)"}\n\nRecent runs:\n${runLines || "(none)"}\n\n` +
146
197
  `Answer questions about workflows and runs by reading these files. Do not modify run outputs unless asked.`);
147
198
  }
@@ -160,16 +211,41 @@ async function scopeContext(scope, agent) {
160
211
  }
161
212
  return "";
162
213
  }
214
+ async function scopeContext(scope, agent) {
215
+ const [base, skills] = await Promise.all([
216
+ baseScopeContext(scope, agent),
217
+ availableSkills(scope),
218
+ ]);
219
+ const block = skillsBlock(skills);
220
+ if (!block)
221
+ return base;
222
+ return base ? `${base}\n\n${block}` : block;
223
+ }
163
224
  /* ---------- backend lifecycle ---------- */
164
225
  /** Team chats carry the member's model/effort; resolved live so edits apply to new backends. */
165
- async function backendTuning(scope) {
166
- if (scope.kind !== "team")
167
- return {};
168
- const member = await getMember(scope.member).catch(() => null);
169
- return {
170
- ...(member?.model ? { model: member.model } : {}),
171
- ...(member?.effort ? { effort: member.effort } : {}),
172
- };
226
+ async function backendTuning(scope, agent) {
227
+ const tuning = {};
228
+ if (scope.kind === "team") {
229
+ const member = await getMember(scope.member).catch(() => null);
230
+ if (member?.model)
231
+ tuning.model = member.model;
232
+ if (member?.effort)
233
+ tuning.effort = member.effort;
234
+ // Claude discovers skills natively; a defined allowlist narrows that.
235
+ if (agent === "claude" && member?.skills)
236
+ tuning.skills = member.skills;
237
+ }
238
+ // Run chats live in the run folder — grant claude the project root so
239
+ // project-level skills and files stay reachable.
240
+ if (scope.kind === "run" && agent === "claude")
241
+ tuning.addDirs = [getProjectRoot()];
242
+ // pi loads skills only when told: hand it every visible skill folder.
243
+ if (agent === "pi") {
244
+ const skills = await availableSkills(scope);
245
+ if (skills.length > 0)
246
+ tuning.skillDirs = skills.map((s) => s.dir);
247
+ }
248
+ return tuning;
173
249
  }
174
250
  async function ensureBackend(state) {
175
251
  if (state.backend)
@@ -199,7 +275,7 @@ async function ensureBackend(state) {
199
275
  },
200
276
  };
201
277
  const { agent, cwd } = state.meta;
202
- const tuning = await backendTuning(state.meta.scope);
278
+ const tuning = await backendTuning(state.meta.scope, agent);
203
279
  state.backend =
204
280
  agent === "pi"
205
281
  ? startPiBackend(cwd, hooks, tuning)
@@ -263,7 +339,10 @@ export async function postMessage(id, text) {
263
339
  try {
264
340
  const backend = await ensureBackend(state);
265
341
  const context = isFirst ? await scopeContext(state.meta.scope, state.meta.agent) : "";
266
- const promptText = context ? `<context>\n${context}\n</context>\n\n${text}` : text;
342
+ // "/<skill> args" expands to the skill's instructions for the agent;
343
+ // the thread keeps the short form the user typed.
344
+ const body = await expandSkillInvocation(state.meta.scope, text);
345
+ const promptText = context ? `<context>\n${context}\n</context>\n\n${body}` : body;
267
346
  const stopReason = await backend.prompt(promptText);
268
347
  emit(state, { type: "turn_end", stopReason });
269
348
  }
@@ -291,6 +370,18 @@ export async function resolvePermission(id, requestId, optionId) {
291
370
  resolve(optionId);
292
371
  return {};
293
372
  }
373
+ /** Delete a chat: kill its backend, drop the live state, remove its folder. */
374
+ export async function deleteChat(id) {
375
+ const state = await loadState(id);
376
+ if (!state)
377
+ return { error: "not found" };
378
+ for (const resolve of state.pendingPermissions.values())
379
+ resolve(null);
380
+ state.backend?.dispose();
381
+ states.delete(id);
382
+ await fs.rm(safeChatDir(id), { recursive: true, force: true });
383
+ return {};
384
+ }
294
385
  export async function cancelChat(id) {
295
386
  const state = await loadState(id);
296
387
  if (!state)