@netnodeag/kraftwerk 0.19.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
@@ -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(() => "");