@netnodeag/kraftwerk 0.18.0 → 0.20.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.
package/README.md CHANGED
@@ -205,14 +205,20 @@ editing the files — the definition is read fresh for each new session).
205
205
 
206
206
  Chats — general ones and team sessions — can use skills: Claude-style
207
207
  instruction packages, one folder per skill with a `SKILL.md` (YAML
208
- frontmatter `name` + `description`, then the instructions). Two roots are
209
- discovered, with a project skill shadowing a same-named user skill:
208
+ frontmatter `name` + `description`, then the instructions). Discovered
209
+ roots, each shadowing same-named skills in the roots below it:
210
210
 
211
211
  ```
212
- <project>/.claude/skills/<name>/SKILL.md # git-tracked, per project
213
- ~/.claude/skills/<name>/SKILL.md # personal, per user
212
+ <project>/agents/<slug>/skills/<name>/SKILL.md # private to that one team agent
213
+ <project>/<skills root>/<name>/SKILL.md # workspace, git-tracked (kraftwerk.yml `skills`, default skills/)
214
+ <project>/.claude/skills/<name>/SKILL.md # git-tracked, per project
215
+ ~/.claude/skills/<name>/SKILL.md # personal, per user
214
216
  ```
215
217
 
218
+ Agent skills are visible only to that agent's sessions and always apply —
219
+ a member's `skills:` allowlist narrows the shared roots only. They're
220
+ managed in the agent profile ("own skills") or by editing the files.
221
+
216
222
  Every chat lists its visible skills as context ("## Your skills"), so the
217
223
  agent reaches for one when the request matches. Typing `/` in the composer
218
224
  opens an autocomplete over them; sending `/<name> <args>` expands the
@@ -12,6 +12,7 @@ import { renderCreateBrief } from "./create-brief.js";
12
12
  import { runDoctor } from "./doctor.js";
13
13
  import { runInit } from "./init.js";
14
14
  import { registerKnowledgeCommands } from "./knowledge.js";
15
+ import { registerRoutineCommands } from "./routines.js";
15
16
  import { listRuns, showRun } from "./runs.js";
16
17
  import { runUi } from "./ui.js";
17
18
  /**
@@ -22,6 +23,7 @@ import { runUi } from "./ui.js";
22
23
  * kraftwerk run [workflow] [text] run one (prompts interactively if omitted)
23
24
  * kraftwerk runs [show <id>] inspect past runs from their traces
24
25
  * kraftwerk knowledge ... Context & Knowledge: OKF bundles (list/get/put/verify/...)
26
+ * kraftwerk routines ... per-agent scheduled prompts (list/add/remove/enable/run)
25
27
  * kraftwerk ui start the inspector web UI (localhost:1981)
26
28
  * kraftwerk doctor preflight: harness CLIs, docker, workflows, env
27
29
  * kraftwerk validate [paths...] validate without executing
@@ -253,6 +255,7 @@ runs
253
255
  await showRun(process.cwd(), runId, opts);
254
256
  });
255
257
  registerKnowledgeCommands(program);
258
+ registerRoutineCommands(program);
256
259
  program
257
260
  .command("ui")
258
261
  .description("Start the inspector web UI for this project's runs and workflows")
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function registerRoutineCommands(program: Command): void;
@@ -0,0 +1,177 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import chalk from "chalk";
4
+ import { resolveProject } from "../config.js";
5
+ import { setOutputDir, setProjectRoot } from "../inspector/context.js";
6
+ import { deleteRoutine, listRoutines, routineStatuses, saveRoutine, } from "../inspector/routines.js";
7
+ import { getMember, listMembers } from "../inspector/team.js";
8
+ /**
9
+ * `kraftwerk routines` — CLI surface over per-agent scheduled prompts
10
+ * (agents/<slug>/routines.yml). Definition CRUD works directly on the
11
+ * files; `run` fires through the running inspector server, because the
12
+ * session (chat) and the scheduler live there.
13
+ */
14
+ /** Point the inspector context at this project so team/routine paths resolve. */
15
+ async function initContext() {
16
+ const project = await resolveProject(process.cwd());
17
+ setProjectRoot(project.root);
18
+ setOutputDir(project.outputDir);
19
+ return { port: project.config.port ?? 1981 };
20
+ }
21
+ async function requireMember(slug) {
22
+ if (!(await getMember(slug))) {
23
+ console.error(chalk.red(`Agent "${slug}" not found (expected agents/${slug}/agent.yml).`));
24
+ process.exit(2);
25
+ }
26
+ }
27
+ async function readStdin() {
28
+ let data = "";
29
+ for await (const chunk of process.stdin)
30
+ data += chunk;
31
+ return data;
32
+ }
33
+ /** ISO timestamp → local "YYYY-MM-DD HH:mm" (schedules are server-local time). */
34
+ const fmtTime = (iso) => {
35
+ if (!iso)
36
+ return "—";
37
+ const d = new Date(iso);
38
+ const p = (n) => String(n).padStart(2, "0");
39
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
40
+ };
41
+ function routineLine(r) {
42
+ const state = r.enabled ? chalk.green("on ") : chalk.dim("off");
43
+ const flags = r.lastError ? ` ${chalk.red(`error: ${r.lastError}`)}` : "";
44
+ return (` ${chalk.cyan(r.id.padEnd(24))} ${state} ${r.schedule.padEnd(14)} ` +
45
+ chalk.dim(`next ${fmtTime(r.nextRunAt).padEnd(17)} last ${fmtTime(r.lastRunAt)}`) +
46
+ flags);
47
+ }
48
+ export function registerRoutineCommands(program) {
49
+ const routines = program
50
+ .command("routines")
51
+ .description("Per-agent scheduled prompts (agents/<slug>/routines.yml): list, add, run, ...");
52
+ routines
53
+ .command("list", { isDefault: true })
54
+ .description("List routines — all agents, or one agent with schedule state")
55
+ .argument("[agent]", "Agent slug (folder under agents/)")
56
+ .option("--json", "Machine-readable output")
57
+ .action(async (agent, opts) => {
58
+ await initContext();
59
+ const slugs = agent ? [agent] : (await listMembers()).map((m) => m.slug);
60
+ if (agent)
61
+ await requireMember(agent);
62
+ const all = {};
63
+ for (const slug of slugs)
64
+ all[slug] = await routineStatuses(slug);
65
+ if (opts.json)
66
+ return void console.log(JSON.stringify(all, null, 2));
67
+ let any = false;
68
+ for (const [slug, rs] of Object.entries(all)) {
69
+ if (rs.length === 0)
70
+ continue;
71
+ any = true;
72
+ console.log(chalk.bold(slug));
73
+ for (const r of rs)
74
+ console.log(routineLine(r));
75
+ }
76
+ if (!any) {
77
+ console.log(chalk.yellow("No routines yet — add one with `kraftwerk routines add <agent> --name ... --schedule ... --prompt ...`."));
78
+ }
79
+ });
80
+ routines
81
+ .command("add")
82
+ .description("Create or update a routine (same id = update)")
83
+ .argument("<agent>", "Agent slug")
84
+ .requiredOption("--name <name>", 'Display name, e.g. "Morning triage"')
85
+ .requiredOption("--schedule <cron>", '5-field cron ("0 9 * * 1-5") or @hourly/@daily/@weekly/@monthly')
86
+ .option("--prompt <text>", "The prompt to post (or pipe it on stdin / use --file)")
87
+ .option("--file <path>", "Read the prompt from a file")
88
+ .option("--id <id>", "Routine id (default: derived from the name)")
89
+ .option("--disabled", "Create it switched off")
90
+ .action(async (agent, opts) => {
91
+ await initContext();
92
+ await requireMember(agent);
93
+ const prompt = opts.prompt ??
94
+ (opts.file ? await readFile(path.resolve(opts.file), "utf8") : process.stdin.isTTY ? "" : await readStdin());
95
+ if (!prompt.trim()) {
96
+ console.error(chalk.red("No prompt (--prompt, --file, or pipe it on stdin)."));
97
+ process.exit(2);
98
+ }
99
+ try {
100
+ const existing = new Set((await listRoutines(agent)).map((r) => r.id));
101
+ const routine = await saveRoutine(agent, {
102
+ ...(opts.id ? { id: opts.id } : {}),
103
+ name: opts.name,
104
+ schedule: opts.schedule,
105
+ prompt,
106
+ enabled: !opts.disabled,
107
+ });
108
+ const verb = existing.has(routine.id) ? "updated" : "created";
109
+ console.log(`${chalk.green("✔")} ${verb} ${chalk.cyan(`${agent}/${routine.id}`)} ` +
110
+ chalk.dim(`(${routine.schedule}${routine.enabled ? "" : ", disabled"})`));
111
+ if (routine.enabled) {
112
+ console.log(chalk.dim(" fires from the inspector server — make sure `kraftwerk ui` is running."));
113
+ }
114
+ }
115
+ catch (err) {
116
+ console.error(chalk.red(err.message));
117
+ process.exit(2);
118
+ }
119
+ });
120
+ routines
121
+ .command("remove")
122
+ .description("Delete a routine")
123
+ .argument("<agent>", "Agent slug")
124
+ .argument("<id>", "Routine id (see `kraftwerk routines <agent>`)")
125
+ .action(async (agent, id) => {
126
+ await initContext();
127
+ await requireMember(agent);
128
+ if (!(await listRoutines(agent)).some((r) => r.id === id)) {
129
+ console.error(chalk.red(`Routine "${agent}/${id}" not found.`));
130
+ process.exit(2);
131
+ }
132
+ await deleteRoutine(agent, id);
133
+ console.log(`${chalk.green("✔")} removed ${chalk.cyan(`${agent}/${id}`)}`);
134
+ });
135
+ for (const [verb, enabled] of [["enable", true], ["disable", false]]) {
136
+ routines
137
+ .command(verb)
138
+ .description(`${verb === "enable" ? "Enable" : "Disable"} a routine`)
139
+ .argument("<agent>", "Agent slug")
140
+ .argument("<id>", "Routine id")
141
+ .action(async (agent, id) => {
142
+ await initContext();
143
+ const routine = (await listRoutines(agent)).find((r) => r.id === id);
144
+ if (!routine) {
145
+ console.error(chalk.red(`Routine "${agent}/${id}" not found.`));
146
+ process.exit(2);
147
+ }
148
+ await saveRoutine(agent, { ...routine, enabled });
149
+ console.log(`${chalk.green("✔")} ${chalk.cyan(`${agent}/${id}`)} ${verb}d`);
150
+ });
151
+ }
152
+ routines
153
+ .command("run")
154
+ .description("Fire a routine now (opens a session via the running `kraftwerk ui` server)")
155
+ .argument("<agent>", "Agent slug")
156
+ .argument("<id>", "Routine id")
157
+ .option("--port <port>", "Inspector port (default: kraftwerk.yml `port`, else 1981)")
158
+ .action(async (agent, id, opts) => {
159
+ const { port } = await initContext();
160
+ const p = opts.port ? Number(opts.port) : port;
161
+ const url = `http://localhost:${p}/api/team/${encodeURIComponent(agent)}/routines/${encodeURIComponent(id)}/run`;
162
+ let body;
163
+ try {
164
+ body = (await (await fetch(url, { method: "POST" })).json());
165
+ }
166
+ catch {
167
+ console.error(chalk.red(`Inspector not reachable on localhost:${p} — routines fire through the server; start it with \`kraftwerk ui\`.`));
168
+ process.exit(2);
169
+ }
170
+ if (body.error || !body.chatId) {
171
+ console.error(chalk.red(body.error ?? "run failed"));
172
+ process.exit(2);
173
+ }
174
+ console.log(`${chalk.green("✔")} ${chalk.cyan(`${agent}/${id}`)} fired → session ${body.chatId}\n` +
175
+ chalk.dim(` http://localhost:${p}/#/agents/${encodeURIComponent(agent)}/chat/${body.chatId}`));
176
+ });
177
+ }
@@ -74,18 +74,20 @@ function emit(state, ev) {
74
74
  /* ---------- skills ---------- */
75
75
  /**
76
76
  * Skills visible to a chat: every discovered skill (workspace skills root
77
- * + .claude/skills roots), narrowed by the team member's allowlist when the chat
78
- * is a team session (absent allowlist = all, empty = none).
77
+ * + .claude/skills roots, plus the member's own agents/<slug>/skills for team
78
+ * sessions), narrowed by the team member's allowlist when the chat is a team
79
+ * session (absent allowlist = all, empty = none). The agent's own skills
80
+ * always apply — the allowlist narrows shared skills only.
79
81
  */
80
82
  async function availableSkills(scope) {
81
- const all = await listSkills().catch(() => []);
82
- if (scope.kind !== "team" || all.length === 0)
83
+ const all = await listSkills(scope.kind === "team" ? scope.member : undefined).catch(() => []);
84
+ if (scope.kind !== "team")
83
85
  return all;
84
86
  const member = await getMember(scope.member).catch(() => null);
85
87
  if (!member || member.skills === undefined)
86
88
  return all;
87
89
  const allowed = new Set(member.skills.map((n) => n.toLowerCase()));
88
- return all.filter((s) => allowed.has(s.name.toLowerCase()));
90
+ return all.filter((s) => s.source === "agent" || allowed.has(s.name.toLowerCase()));
89
91
  }
90
92
  /** "## Your skills" context block (empty when no skills are visible). */
91
93
  function skillsBlock(skills) {
@@ -9,7 +9,7 @@ import { dockerStatus, triggerRun, stopRun } from "./runner.js";
9
9
  import { cancelChat, createChat, deleteChat, disposeAllBackends, getChat, listChats, postMessage, resolvePermission, subscribeChat, } from "./chat/sessions.js";
10
10
  import { bundleDetail, conceptDetail, createBundle, knowledgeIndex, putConcept, verifyFromUi, } from "./knowledge.js";
11
11
  import { deleteMember, getMember, listMembers, saveMember, teamRoot, } from "./team.js";
12
- import { getSkill, listSkills, skillsRoot } from "./skills.js";
12
+ import { deleteAgentSkill, getSkill, listAgentSkills, listSkills, readSkill, saveAgentSkill, skillsRoot, } from "./skills.js";
13
13
  import { deleteRoutine, routineStatuses, runRoutineNow, saveRoutine, startRoutineScheduler, } from "./routines.js";
14
14
  /**
15
15
  * The inspector server: a plain node:http server with no dependencies.
@@ -279,6 +279,41 @@ async function handleApi(req, res, url) {
279
279
  }
280
280
  }
281
281
  }
282
+ // GET/POST /api/team/:slug/skills — the agent's own skills / create or update one
283
+ if (seg.length === 4 && seg[1] === "team" && seg[3] === "skills") {
284
+ const slug = decodeURIComponent(seg[2]);
285
+ try {
286
+ if (method === "GET")
287
+ return json(res, { skills: await listAgentSkills(slug) });
288
+ if (method === "POST") {
289
+ const body = JSON.parse(await readBody(req));
290
+ return json(res, await saveAgentSkill(slug, String(body.name ?? ""), String(body.content ?? "")));
291
+ }
292
+ }
293
+ catch (err) {
294
+ return json(res, { error: err.message }, 400);
295
+ }
296
+ }
297
+ // GET/DELETE /api/team/:slug/skills/:name — one agent skill with content / delete
298
+ if (seg.length === 5 && seg[1] === "team" && seg[3] === "skills") {
299
+ const slug = decodeURIComponent(seg[2]);
300
+ const name = decodeURIComponent(seg[4]);
301
+ try {
302
+ if (method === "GET") {
303
+ const skill = (await listAgentSkills(slug)).find((s) => s.name.toLowerCase() === name.toLowerCase());
304
+ if (!skill)
305
+ return json(res, { error: "not found" }, 404);
306
+ return json(res, { ...skill, content: await readSkill(skill) });
307
+ }
308
+ if (method === "DELETE") {
309
+ await deleteAgentSkill(slug, name);
310
+ return json(res, { ok: true });
311
+ }
312
+ }
313
+ catch (err) {
314
+ return json(res, { error: err.message }, 400);
315
+ }
316
+ }
282
317
  // GET/POST /api/team/:slug/routines — list (with run state) / upsert
283
318
  if (seg.length === 4 && seg[1] === "team" && seg[3] === "routines") {
284
319
  const slug = decodeURIComponent(seg[2]);
@@ -12,6 +12,14 @@
12
12
  * shadows a same-named project skill, which shadows a same-named global
13
13
  * skill. Skills are surfaced to every chat as context (and expanded on
14
14
  * /<name> invocation), so they work identically on claude, codex, and pi.
15
+ *
16
+ * On top of the shared roots, each team agent can carry private skills in
17
+ *
18
+ * agents/<slug>/skills/<name>/SKILL.md (agent, git-tracked)
19
+ *
20
+ * Agent skills are visible only to that agent's sessions, always apply
21
+ * (the member's allowlist narrows shared skills only), and shadow
22
+ * same-named shared skills.
15
23
  */
16
24
  export interface SkillInfo {
17
25
  /** Frontmatter `name`, falling back to the folder name. */
@@ -21,8 +29,10 @@ export interface SkillInfo {
21
29
  path: string;
22
30
  /** Absolute path to the skill folder (for harnesses that take a dir). */
23
31
  dir: string;
24
- /** workspace = kraftwerk skills root; project = .claude/skills; user = ~/.claude/skills. */
25
- source: "workspace" | "project" | "user";
32
+ /** workspace = kraftwerk skills root; project = .claude/skills; user = ~/.claude/skills; agent = agents/<slug>/skills. */
33
+ source: "workspace" | "project" | "user" | "agent";
34
+ /** For source "agent": the owning member's slug. */
35
+ agent?: string;
26
36
  }
27
37
  export interface SkillDetail extends SkillInfo {
28
38
  /** Full SKILL.md contents. */
@@ -32,8 +42,21 @@ export interface SkillDetail extends SkillInfo {
32
42
  }
33
43
  /** Absolute workspace skills root (kraftwerk.yml `skills`, default skills/). */
34
44
  export declare function skillsRoot(): Promise<string>;
35
- /** All discovered skills; workspace shadows project shadows user (global). */
36
- export declare function listSkills(): Promise<SkillInfo[]>;
45
+ /** The private skills root of one team agent: agents/<slug>/skills. */
46
+ export declare function agentSkillsRoot(slug: string): Promise<string>;
47
+ /** One agent's private skills (agents/<slug>/skills/<name>/SKILL.md). */
48
+ export declare function listAgentSkills(slug: string): Promise<SkillInfo[]>;
49
+ /**
50
+ * All discovered skills; agent (when a member is given) shadows workspace
51
+ * shadows project shadows user (global).
52
+ */
53
+ export declare function listSkills(member?: string): Promise<SkillInfo[]>;
54
+ /** Create or update one agent skill; writes agents/<slug>/skills/<name>/SKILL.md. */
55
+ export declare function saveAgentSkill(slug: string, name: string, content: string): Promise<SkillInfo & {
56
+ content: string;
57
+ }>;
58
+ /** Delete one agent skill (removes its folder). */
59
+ export declare function deleteAgentSkill(slug: string, name: string): Promise<void>;
37
60
  /** Read a skill's SKILL.md contents (empty string when unreadable). */
38
61
  export declare function readSkill(skill: SkillInfo): Promise<string>;
39
62
  /** One skill by name (post-shadowing), with SKILL.md content + bundled files. */
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { parse } from "yaml";
5
5
  import { resolveProject } from "../config.js";
6
6
  import { getProjectRoot } from "./context.js";
7
+ import { safeMemberSlug, teamRoot } from "./team.js";
7
8
  const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
8
9
  function frontmatter(md) {
9
10
  const m = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/.exec(md);
@@ -57,16 +58,29 @@ async function scanRoot(root, source) {
57
58
  }));
58
59
  return skills.filter(Boolean).sort((a, b) => a.name.localeCompare(b.name));
59
60
  }
60
- /** All discovered skills; workspace shadows project shadows user (global). */
61
- export async function listSkills() {
62
- const [workspace, project, user] = await Promise.all([
61
+ /** The private skills root of one team agent: agents/<slug>/skills. */
62
+ export async function agentSkillsRoot(slug) {
63
+ return path.join(await teamRoot(), safeMemberSlug(slug), "skills");
64
+ }
65
+ /** One agent's private skills (agents/<slug>/skills/<name>/SKILL.md). */
66
+ export async function listAgentSkills(slug) {
67
+ const skills = await scanRoot(await agentSkillsRoot(slug), "agent");
68
+ return skills.map((s) => ({ ...s, agent: slug }));
69
+ }
70
+ /**
71
+ * All discovered skills; agent (when a member is given) shadows workspace
72
+ * shadows project shadows user (global).
73
+ */
74
+ export async function listSkills(member) {
75
+ const [agent, workspace, project, user] = await Promise.all([
76
+ member ? listAgentSkills(member).catch(() => []) : [],
63
77
  skillsRoot().then((root) => scanRoot(root, "workspace")),
64
78
  scanRoot(path.join(getProjectRoot(), ".claude", "skills"), "project"),
65
79
  scanRoot(path.join(os.homedir(), ".claude", "skills"), "user"),
66
80
  ]);
67
- const out = [...workspace];
81
+ const out = [...agent];
68
82
  const seen = new Set(out.map((s) => s.name.toLowerCase()));
69
- for (const s of [...project, ...user]) {
83
+ for (const s of [...workspace, ...project, ...user]) {
70
84
  if (seen.has(s.name.toLowerCase()))
71
85
  continue;
72
86
  seen.add(s.name.toLowerCase());
@@ -74,6 +88,37 @@ export async function listSkills() {
74
88
  }
75
89
  return out;
76
90
  }
91
+ /** Create or update one agent skill; writes agents/<slug>/skills/<name>/SKILL.md. */
92
+ export async function saveAgentSkill(slug, name, content) {
93
+ const n = name.trim().toLowerCase();
94
+ if (!NAME_RE.test(n)) {
95
+ throw new Error("skill name must be letters, digits, - or _ (e.g. report-html)");
96
+ }
97
+ if (!content.trim())
98
+ throw new Error("content (SKILL.md) is required");
99
+ const dir = path.join(await agentSkillsRoot(slug), n);
100
+ await fs.mkdir(dir, { recursive: true });
101
+ const file = path.join(dir, "SKILL.md");
102
+ await fs.writeFile(file, content.endsWith("\n") ? content : content + "\n");
103
+ const fm = frontmatter(content);
104
+ const display = String(fm.name ?? n).trim() || n;
105
+ return {
106
+ name: NAME_RE.test(display) ? display : n,
107
+ description: String(fm.description ?? "").trim(),
108
+ path: file,
109
+ dir,
110
+ source: "agent",
111
+ agent: slug,
112
+ content,
113
+ };
114
+ }
115
+ /** Delete one agent skill (removes its folder). */
116
+ export async function deleteAgentSkill(slug, name) {
117
+ const skill = (await listAgentSkills(slug)).find((s) => s.name.toLowerCase() === name.toLowerCase());
118
+ if (!skill)
119
+ throw new Error(`skill "${name}" not found`);
120
+ await fs.rm(skill.dir, { recursive: true, force: true });
121
+ }
77
122
  /** Read a skill's SKILL.md contents (empty string when unreadable). */
78
123
  export function readSkill(skill) {
79
124
  return fs.readFile(skill.path, "utf8").catch(() => "");