@netnodeag/kraftwerk 0.35.0 → 0.37.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 (57) hide show
  1. package/README.md +65 -20
  2. package/dist/cli/doctor.js +18 -1
  3. package/dist/cli/init.js +19 -12
  4. package/dist/cli/kraftwerk.js +3 -0
  5. package/dist/cli/projects.d.ts +14 -0
  6. package/dist/cli/projects.js +1 -1
  7. package/dist/cli/repos.d.ts +2 -0
  8. package/dist/cli/repos.js +115 -0
  9. package/dist/cli/routines.d.ts +10 -0
  10. package/dist/cli/routines.js +10 -10
  11. package/dist/config.d.ts +29 -2
  12. package/dist/config.js +56 -2
  13. package/dist/inspector/{team.d.ts → agents.d.ts} +21 -12
  14. package/dist/inspector/{team.js → agents.js} +34 -19
  15. package/dist/inspector/chat/backend.d.ts +1 -1
  16. package/dist/inspector/chat/sessions.js +72 -45
  17. package/dist/inspector/chat/store.js +13 -1
  18. package/dist/inspector/chat/types.d.ts +2 -2
  19. package/dist/inspector/git.d.ts +14 -0
  20. package/dist/inspector/git.js +11 -9
  21. package/dist/inspector/instances.d.ts +12 -0
  22. package/dist/inspector/instances.js +28 -0
  23. package/dist/inspector/repos.d.ts +118 -0
  24. package/dist/inspector/repos.js +249 -0
  25. package/dist/inspector/routines.d.ts +4 -4
  26. package/dist/inspector/routines.js +11 -11
  27. package/dist/inspector/search.d.ts +25 -0
  28. package/dist/inspector/search.js +31 -0
  29. package/dist/inspector/server.js +77 -30
  30. package/dist/inspector/settings.d.ts +7 -1
  31. package/dist/inspector/settings.js +32 -1
  32. package/dist/inspector/skills.d.ts +6 -6
  33. package/dist/inspector/skills.js +8 -8
  34. package/inspector/dist/assets/{editor-BABv2LmA.js → editor-vM6JSj5h.js} +3 -3
  35. package/inspector/dist/assets/{index-CCACy6uF.js → index-BAAjszPI.js} +1 -1
  36. package/inspector/dist/assets/{index-DuwmzZAr.js → index-BKcOp35r.js} +1 -1
  37. package/inspector/dist/assets/index-Ba8VU6Wz.js +217 -0
  38. package/inspector/dist/assets/{index-Ca9Qs5LZ.js → index-Bb08wK-l.js} +1 -1
  39. package/inspector/dist/assets/{index-ByWpqzAI.js → index-BrCwDkJZ.js} +1 -1
  40. package/inspector/dist/assets/{index-hkVov8T4.js → index-Bs1U0rWW.js} +1 -1
  41. package/inspector/dist/assets/{index-CdwTmXTV.js → index-C1UXlW32.js} +1 -1
  42. package/inspector/dist/assets/{index-HBdsdJhD.js → index-CQ2XKqmD.js} +1 -1
  43. package/inspector/dist/assets/{index-DCR7u4Xa.js → index-CReepirH.js} +1 -1
  44. package/inspector/dist/assets/{index-CzPQygR7.js → index-CUGfxoYt.js} +1 -1
  45. package/inspector/dist/assets/{index-sLxulPOm.js → index-CaKmxcD6.js} +1 -1
  46. package/inspector/dist/assets/{index-Bmm4zJNU.js → index-CtMGH62C.js} +1 -1
  47. package/inspector/dist/assets/{index-BXspF2KR.css → index-D4_mLTSH.css} +1 -1
  48. package/inspector/dist/assets/{index-BbTer62d.js → index-D_11IZBV.js} +1 -1
  49. package/inspector/dist/assets/{index-wkYwz0Tl.js → index-DfZT0IoM.js} +1 -1
  50. package/inspector/dist/assets/{index-Gx93brFq.js → index-DykDNrYm.js} +1 -1
  51. package/inspector/dist/assets/{index-DuPp3MUc.js → index-MWv5H1oP.js} +1 -1
  52. package/inspector/dist/assets/{index-CjlJeW9z.js → index-oIQqfbwm.js} +1 -1
  53. package/inspector/dist/assets/{index-DbWUNbt-.js → index-yGZsomcy.js} +1 -1
  54. package/inspector/dist/index.html +2 -2
  55. package/package.json +1 -1
  56. package/schema/kraftwerk.schema.json +15 -0
  57. package/inspector/dist/assets/index-uEig4--d.js +0 -217
package/dist/config.js CHANGED
@@ -18,7 +18,7 @@ import { parse } from "yaml";
18
18
  * workflows: src/workflows # workflows root, relative to the file
19
19
  * output: output # run-artifact directory, relative to the file
20
20
  * knowledge: knowledge # OKF knowledge-bundle root, relative to the file
21
- * agents: agents # team agent-definition root, relative to the file
21
+ * agents: agents # agent-definition root, relative to the file
22
22
  * skills: skills # workspace skill root, relative to the file
23
23
  * switcher: # other kraftwerk workspaces, linked from the header dropdown
24
24
  * - name: other space
@@ -28,6 +28,8 @@ import { parse } from "yaml";
28
28
  * remote: origin
29
29
  * interval: 300 # seconds between background fetches
30
30
  * autosync: pull # off | pull
31
+ * repos: # git repositories the agents work on (absent = off, bare key = on)
32
+ * root: kraftwerk-data/repos # where clones land, relative to the file. Default: repos
31
33
  */
32
34
  /** Stable, versionless URL of the workflow JSON schema (editor validation). */
33
35
  export const SCHEMA_URL = "https://raw.githubusercontent.com/NETNODEAG/kraftwerk/main/kraftwerk/schema/workflow.schema.json";
@@ -35,6 +37,30 @@ export const SCHEMA_URL = "https://raw.githubusercontent.com/NETNODEAG/kraftwerk
35
37
  export const CONFIG_SCHEMA_URL = "https://raw.githubusercontent.com/NETNODEAG/kraftwerk/main/kraftwerk/schema/kraftwerk.schema.json";
36
38
  export const CONFIG_FILENAMES = ["kraftwerk.yml", "kraftwerk.yaml"];
37
39
  const WORKFLOW_ROOT_CANDIDATES = ["src/workflows", "workflows"];
40
+ /** Where clones land when `repos.root` is not set. */
41
+ export const REPOS_DEFAULT_ROOT = "repos";
42
+ /**
43
+ * Absolute repos root when the feature is on, undefined otherwise. The one
44
+ * place that reads the block, so the sync exclude, the doctor check, the
45
+ * settings save and the module itself cannot disagree.
46
+ */
47
+ export function reposRootFor(project) {
48
+ const r = project.config.repos;
49
+ if (!r || r.enabled === false)
50
+ return undefined;
51
+ return path.resolve(project.root, r.root ?? REPOS_DEFAULT_ROOT);
52
+ }
53
+ /** A directory as a .gitignore entry: relative, forward slashes, no trailing slash; undefined outside the root. */
54
+ export function ignoreEntryFor(projectRoot, dir) {
55
+ const rel = path.relative(projectRoot, path.resolve(projectRoot, dir)).split(path.sep).join("/");
56
+ if (!rel || rel === "." || rel.startsWith(".."))
57
+ return undefined;
58
+ return rel;
59
+ }
60
+ /** True when .gitignore text already covers the entry (with or without a leading or trailing slash). */
61
+ export function gitignoreHas(text, entry) {
62
+ return text.split("\n").some((l) => l.trim().replace(/^\//, "").replace(/\/$/, "") === entry);
63
+ }
38
64
  const exists = async (p) => !!(await stat(p).catch(() => null));
39
65
  /** True when the path exists and is a directory. */
40
66
  export const isDir = async (p) => (await stat(p).catch(() => null))?.isDirectory() ?? false;
@@ -87,7 +113,7 @@ export async function resolveProject(cwd) {
87
113
  const root = gitFallback ?? start;
88
114
  return { root, config: {}, outputDir: path.join(root, "output") };
89
115
  }
90
- const KNOWN_KEYS = ["name", "icon", "port", "workflows", "output", "knowledge", "agents", "skills", "switcher", "git"];
116
+ const KNOWN_KEYS = ["name", "icon", "port", "workflows", "output", "knowledge", "agents", "skills", "switcher", "git", "repos"];
91
117
  async function loadConfig(configPath) {
92
118
  let raw;
93
119
  try {
@@ -123,6 +149,11 @@ async function loadConfig(configPath) {
123
149
  config[key] = {};
124
150
  validateGit(configPath, config[key]);
125
151
  }
152
+ else if (key === "repos") {
153
+ if (config[key] === null)
154
+ config[key] = {};
155
+ validateRepos(configPath, config[key]);
156
+ }
126
157
  else if (typeof config[key] !== "string") {
127
158
  throw new Error(`${path.basename(configPath)}: ${key} must be a string`);
128
159
  }
@@ -182,3 +213,26 @@ function validateGit(configPath, value) {
182
213
  throw new Error(`${file}: git.autosync must be "off" or "pull"`);
183
214
  }
184
215
  }
216
+ function validateRepos(configPath, value) {
217
+ const file = path.basename(configPath);
218
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
219
+ throw new Error(`${file}: repos must be a mapping (enabled, root)`);
220
+ }
221
+ const r = value;
222
+ for (const key of Object.keys(r)) {
223
+ if (!["enabled", "root"].includes(key)) {
224
+ throw new Error(`${file}: repos.${key} is unknown (allowed: enabled, root)`);
225
+ }
226
+ }
227
+ if (r.enabled !== undefined && typeof r.enabled !== "boolean") {
228
+ throw new Error(`${file}: repos.enabled must be true or false`);
229
+ }
230
+ if (r.root !== undefined && (typeof r.root !== "string" || !r.root.trim())) {
231
+ throw new Error(`${file}: repos.root must be a non-empty string`);
232
+ }
233
+ // The root is what `repos remove --force` deletes under, so it must never
234
+ // be the project itself or anything outside it.
235
+ if (typeof r.root === "string" && !ignoreEntryFor(path.dirname(configPath), r.root.trim())) {
236
+ throw new Error(`${file}: repos.root must be a directory inside the project (not ".", ".." or an absolute path outside it)`);
237
+ }
238
+ }
@@ -1,17 +1,17 @@
1
1
  import type { ChatAgentId } from "./chat/types.js";
2
2
  /**
3
- * Team: persistent agent teammates, defined on the filesystem. Each member
3
+ * Agents: persistent agents, defined on the filesystem. Each one
4
4
  * is one folder under the project's agents/ root:
5
5
  *
6
6
  * agents/<slug>/agent.yml # name, emoji, harness, model, effort, workflows
7
- * agents/<slug>/system.md # the member's system prompt / role description
7
+ * agents/<slug>/system.md # the agent's system prompt / role description
8
8
  *
9
9
  * Definitions are git-tracked project config (like workflows/), not run
10
10
  * state — sessions with a member are ordinary chats scoped to it.
11
11
  */
12
12
  export declare const HARNESSES: ChatAgentId[];
13
13
  export declare const EFFORTS: readonly ["low", "medium", "high", "xhigh", "max"];
14
- export interface TeamMember {
14
+ export interface Agent {
15
15
  slug: string;
16
16
  name: string;
17
17
  emoji: string;
@@ -33,17 +33,26 @@ export interface TeamMember {
33
33
  /** Archived: hidden from the active roster, restorable any time. */
34
34
  archived?: boolean;
35
35
  }
36
- export interface TeamMemberDetail extends TeamMember {
36
+ /** What the ⌘K palette needs to find and show an agent — kept in the project registry. */
37
+ export interface AgentSummary {
38
+ slug: string;
39
+ name: string;
40
+ emoji: string;
41
+ description?: string;
42
+ group?: string;
43
+ }
44
+ export declare const toSummary: (a: Agent) => AgentSummary;
45
+ export interface AgentDetail extends Agent {
37
46
  /** Contents of system.md — the member's system prompt. */
38
47
  system: string;
39
48
  }
40
- export declare function teamRoot(): Promise<string>;
41
- export declare function safeMemberSlug(slug: string): string;
49
+ export declare function agentsRoot(): Promise<string>;
50
+ export declare function safeAgentSlug(slug: string): string;
42
51
  /** Derive a slug from a display name: "Max Müller" -> "max-mueller"-ish. */
43
52
  export declare function slugFromName(name: string): string;
44
- export declare function listMembers(): Promise<TeamMember[]>;
45
- export declare function getMember(slug: string): Promise<TeamMemberDetail | null>;
46
- export interface SaveMemberInput {
53
+ export declare function listAgents(): Promise<Agent[]>;
54
+ export declare function getAgent(slug: string): Promise<AgentDetail | null>;
55
+ export interface SaveAgentInput {
47
56
  slug?: string;
48
57
  name: string;
49
58
  emoji?: string;
@@ -59,7 +68,7 @@ export interface SaveMemberInput {
59
68
  skills?: string[];
60
69
  system?: string;
61
70
  }
62
- export declare function saveMember(input: SaveMemberInput): Promise<TeamMemberDetail>;
71
+ export declare function saveAgent(input: SaveAgentInput): Promise<AgentDetail>;
63
72
  /** Archive/unarchive a member: toggles `archived:` in agent.yml, nothing else. */
64
- export declare function setMemberArchived(slug: string, archived: boolean): Promise<TeamMemberDetail>;
65
- export declare function deleteMember(slug: string): Promise<void>;
73
+ export declare function setAgentArchived(slug: string, archived: boolean): Promise<AgentDetail>;
74
+ export declare function deleteAgent(slug: string): Promise<void>;
@@ -3,24 +3,32 @@ import path from "node:path";
3
3
  import { parse, stringify } from "yaml";
4
4
  import { resolveProject } from "../config.js";
5
5
  import { getProjectRoot } from "./context.js";
6
+ import { syncProjectAgents } from "./instances.js";
6
7
  /**
7
- * Team: persistent agent teammates, defined on the filesystem. Each member
8
+ * Agents: persistent agents, defined on the filesystem. Each one
8
9
  * is one folder under the project's agents/ root:
9
10
  *
10
11
  * agents/<slug>/agent.yml # name, emoji, harness, model, effort, workflows
11
- * agents/<slug>/system.md # the member's system prompt / role description
12
+ * agents/<slug>/system.md # the agent's system prompt / role description
12
13
  *
13
14
  * Definitions are git-tracked project config (like workflows/), not run
14
15
  * state — sessions with a member are ordinary chats scoped to it.
15
16
  */
16
17
  export const HARNESSES = ["claude", "codex", "pi"];
17
18
  export const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
18
- export async function teamRoot() {
19
+ export const toSummary = (a) => ({
20
+ slug: a.slug,
21
+ name: a.name,
22
+ emoji: a.emoji,
23
+ ...(a.description ? { description: a.description } : {}),
24
+ ...(a.group ? { group: a.group } : {}),
25
+ });
26
+ export async function agentsRoot() {
19
27
  const project = await resolveProject(getProjectRoot());
20
28
  return path.resolve(project.root, project.config.agents ?? "agents");
21
29
  }
22
30
  const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,48}$/;
23
- export function safeMemberSlug(slug) {
31
+ export function safeAgentSlug(slug) {
24
32
  if (!SLUG_RE.test(slug))
25
33
  throw new Error(`invalid agent slug "${slug}"`);
26
34
  return slug;
@@ -55,8 +63,8 @@ function normalize(slug, raw) {
55
63
  ...(raw.archived === true ? { archived: true } : {}),
56
64
  };
57
65
  }
58
- export async function listMembers() {
59
- const root = await teamRoot();
66
+ export async function listAgents() {
67
+ const root = await agentsRoot();
60
68
  let entries;
61
69
  try {
62
70
  entries = (await fs.readdir(root, { withFileTypes: true }))
@@ -66,7 +74,7 @@ export async function listMembers() {
66
74
  catch {
67
75
  return [];
68
76
  }
69
- const members = await Promise.all(entries.map(async (slug) => {
77
+ const agents = await Promise.all(entries.map(async (slug) => {
70
78
  try {
71
79
  const raw = parse(await fs.readFile(path.join(root, slug, "agent.yml"), "utf8"));
72
80
  return normalize(slug, (raw ?? {}));
@@ -75,10 +83,17 @@ export async function listMembers() {
75
83
  return null;
76
84
  }
77
85
  }));
78
- return members.filter(Boolean).sort((a, b) => a.name.localeCompare(b.name));
86
+ const list = agents.filter(Boolean).sort((a, b) => a.name.localeCompare(b.name));
87
+ // Keep the registry's copy of the roster current for the other instances'
88
+ // palettes. Awaited so a read that follows a save sees the record updated;
89
+ // it only writes when the roster changed.
90
+ await resolveProject(getProjectRoot())
91
+ .then((p) => syncProjectAgents(p.root, list.filter((a) => !a.archived).map(toSummary)))
92
+ .catch(() => { });
93
+ return list;
79
94
  }
80
- export async function getMember(slug) {
81
- const dir = path.join(await teamRoot(), safeMemberSlug(slug));
95
+ export async function getAgent(slug) {
96
+ const dir = path.join(await agentsRoot(), safeAgentSlug(slug));
82
97
  let raw;
83
98
  try {
84
99
  raw = parse(await fs.readFile(path.join(dir, "agent.yml"), "utf8"));
@@ -89,7 +104,7 @@ export async function getMember(slug) {
89
104
  const system = await fs.readFile(path.join(dir, "system.md"), "utf8").catch(() => "");
90
105
  return { ...normalize(slug, (raw ?? {})), system: system.trim() };
91
106
  }
92
- export async function saveMember(input) {
107
+ export async function saveAgent(input) {
93
108
  const name = input.name?.trim();
94
109
  if (!name)
95
110
  throw new Error("name is required");
@@ -99,8 +114,8 @@ export async function saveMember(input) {
99
114
  if (input.effort && !EFFORTS.includes(input.effort)) {
100
115
  throw new Error(`effort must be one of: ${EFFORTS.join(", ")}`);
101
116
  }
102
- const slug = input.slug ? safeMemberSlug(input.slug) : slugFromName(name);
103
- const dir = path.join(await teamRoot(), slug);
117
+ const slug = input.slug ? safeAgentSlug(input.slug) : slugFromName(name);
118
+ const dir = path.join(await agentsRoot(), slug);
104
119
  await fs.mkdir(dir, { recursive: true });
105
120
  // Profile edits must not silently unarchive: carry the flag over.
106
121
  const existing = await fs
@@ -122,11 +137,11 @@ export async function saveMember(input) {
122
137
  };
123
138
  await fs.writeFile(path.join(dir, "agent.yml"), stringify(yml));
124
139
  await fs.writeFile(path.join(dir, "system.md"), (input.system ?? "").trim() + "\n");
125
- return (await getMember(slug));
140
+ return (await getAgent(slug));
126
141
  }
127
142
  /** Archive/unarchive a member: toggles `archived:` in agent.yml, nothing else. */
128
- export async function setMemberArchived(slug, archived) {
129
- const dir = path.join(await teamRoot(), safeMemberSlug(slug));
143
+ export async function setAgentArchived(slug, archived) {
144
+ const dir = path.join(await agentsRoot(), safeAgentSlug(slug));
130
145
  let raw;
131
146
  try {
132
147
  raw = (parse(await fs.readFile(path.join(dir, "agent.yml"), "utf8")) ?? {});
@@ -140,9 +155,9 @@ export async function setMemberArchived(slug, archived) {
140
155
  else
141
156
  delete yml.archived;
142
157
  await fs.writeFile(path.join(dir, "agent.yml"), stringify(yml));
143
- return (await getMember(slug));
158
+ return (await getAgent(slug));
144
159
  }
145
- export async function deleteMember(slug) {
146
- const dir = path.join(await teamRoot(), safeMemberSlug(slug));
160
+ export async function deleteAgent(slug) {
161
+ const dir = path.join(await agentsRoot(), safeAgentSlug(slug));
147
162
  await fs.rm(dir, { recursive: true, force: true });
148
163
  }
@@ -19,7 +19,7 @@ export interface BackendHooks {
19
19
  }>): Promise<string | null>;
20
20
  }
21
21
  /**
22
- * Optional per-chat model/effort overrides (from a team member definition).
22
+ * Optional per-chat model/effort overrides (from an agent definition).
23
23
  * Each backend maps them onto whatever its agent actually supports.
24
24
  */
25
25
  export interface BackendTuning {
@@ -5,7 +5,8 @@ import { knowledgeIndex } from "../knowledge.js";
5
5
  import { getRun, listRuns, safeRunDir } from "../runs.js";
6
6
  import { listSkills, readSkill } from "../skills.js";
7
7
  import { listWorkflows } from "../workflows.js";
8
- import { getMember, listMembers } from "../team.js";
8
+ import { getAgent, listAgents } from "../agents.js";
9
+ import { listRepos } from "../repos.js";
9
10
  import { startAcpBackend } from "./acp.js";
10
11
  import { startPiBackend } from "./pi.js";
11
12
  import { appendEvent, listChatMetas, newChatId, readEvents, readMeta, safeChatDir, writeMeta, } from "./store.js";
@@ -74,19 +75,19 @@ function emit(state, ev) {
74
75
  /* ---------- skills ---------- */
75
76
  /**
76
77
  * Skills visible to a chat: every discovered skill (workspace skills root
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
78
+ * + .claude/skills roots, plus the agent's own agents/<slug>/skills for agent
79
+ * sessions), narrowed by the agent's allowlist when the chat is an agent
79
80
  * session (absent allowlist = all, empty = none). The agent's own skills
80
81
  * always apply — the allowlist narrows shared skills only.
81
82
  */
82
83
  async function availableSkills(scope) {
83
- const all = await listSkills(scope.kind === "team" ? scope.member : undefined).catch(() => []);
84
- if (scope.kind !== "team")
84
+ const all = await listSkills(scope.kind === "agent" ? scope.slug : undefined).catch(() => []);
85
+ if (scope.kind !== "agent")
85
86
  return all;
86
- const member = await getMember(scope.member).catch(() => null);
87
- if (!member || member.skills === undefined)
87
+ const def = await getAgent(scope.slug).catch(() => null);
88
+ if (!def || def.skills === undefined)
88
89
  return all;
89
- const allowed = new Set(member.skills.map((n) => n.toLowerCase()));
90
+ const allowed = new Set(def.skills.map((n) => n.toLowerCase()));
90
91
  return all.filter((s) => s.source === "agent" || allowed.has(s.name.toLowerCase()));
91
92
  }
92
93
  /** "## Your skills" context block (empty when no skills are visible). */
@@ -122,12 +123,12 @@ async function expandSkillInvocation(scope, text) {
122
123
  `Arguments the user passed to the skill: ${args || "(none)"}`);
123
124
  }
124
125
  /* ---------- scope context ---------- */
125
- /** "## Your knowledge" block for a team member's connected OKF bundles. */
126
- async function teamKnowledgeContext(member) {
127
- if (member.knowledge.length === 0)
126
+ /** "## Your knowledge" block for a agent's connected OKF bundles. */
127
+ async function agentKnowledgeContext(def) {
128
+ if (def.knowledge.length === 0)
128
129
  return "";
129
130
  const { bundles } = await knowledgeIndex().catch(() => ({ bundles: [] }));
130
- const lines = member.knowledge
131
+ const lines = def.knowledge
131
132
  .map((name) => {
132
133
  const b = bundles.find((x) => x.name === name);
133
134
  return b
@@ -139,30 +140,53 @@ async function teamKnowledgeContext(member) {
139
140
  `part of your job. Consult them before answering questions in their domain, and keep them current ` +
140
141
  `when you learn something durable:\n${lines}\n\n` +
141
142
  `Read with \`npx kraftwerk knowledge list <bundle>\`, \`get <bundle>/<path>\`, \`search <text>\`. ` +
142
- `Write ONLY through \`npx kraftwerk knowledge put <bundle>/<path> --file <tmp.md> --actor ${member.slug}/${member.harness}\` ` +
143
+ `Write ONLY through \`npx kraftwerk knowledge put <bundle>/<path> --file <tmp.md> --actor ${def.slug}/${def.harness}\` ` +
143
144
  `(write the markdown to a temp file first; the CLI stamps provenance and maintains index.md/log.md). ` +
144
145
  `Never hand-edit index.md or log.md, and never add \`verified\` yourself — verification is the human's ` +
145
146
  `click in the UI.\n\n`);
146
147
  }
148
+ /**
149
+ * "## Repositories" block: the clones under the repos root, when the
150
+ * feature is on. Empty when it is off, so agents in a workspace without
151
+ * it never hear about it.
152
+ */
153
+ async function reposContext() {
154
+ const view = await listRepos().catch(() => null);
155
+ if (!view?.enabled || !view.root)
156
+ return "";
157
+ const lines = view.repos
158
+ .map((r) => {
159
+ const state = r.error ? `unreadable: ${r.error}` : [r.dirty ? `${r.dirty} uncommitted` : "clean", r.ahead ? `${r.ahead} ahead` : "", r.behind ? `${r.behind} behind` : ""].filter(Boolean).join(", ");
160
+ return `- ${r.slug} — ${r.path}${r.url ? ` (${r.url})` : ""}${r.branch ? `, branch ${r.branch}` : ""}${r.head ? ` @ ${r.head}` : ""}, ${state}`;
161
+ })
162
+ .join("\n");
163
+ return (`## Repositories\nGit repositories this workspace works on live under ${view.root} (one clone per folder). ` +
164
+ `When the user names one of them, work inside its folder: read its README and structure first, keep commits on a ` +
165
+ `branch unless told otherwise, and never push or force-push without asking.\n${lines || "(none cloned yet)"}\n\n` +
166
+ `Clone a new one with \`npx kraftwerk repos add <url> [--name <folder>] [--branch <b>] [--depth <n>]\` so it lands in ` +
167
+ `that root (\`--depth 1\` for a large repository; a plain \`git clone\` into that folder works too); ` +
168
+ `\`npx kraftwerk repos\` lists them, \`update <name>\` fetches and fast-forwards a clean clone, \`remove <name>\` ` +
169
+ `deletes one. A repository the user mentions that is not listed is not cloned yet — offer to add it.`);
170
+ }
147
171
  /** Base context per scope; scopeContext() appends the shared skills block. */
148
172
  async function baseScopeContext(scope, agent) {
149
- if (scope.kind === "team") {
150
- const member = await getMember(scope.member).catch(() => null);
151
- if (!member) {
152
- return `You were opened as team agent "${scope.member}", but its definition under agents/ is missing. Tell the user and ask them to recreate it.`;
173
+ if (scope.kind === "agent") {
174
+ const def = await getAgent(scope.slug).catch(() => null);
175
+ if (!def) {
176
+ return `You were opened as agent "${scope.slug}", but its definition under agents/ is missing. Tell the user and ask them to recreate it.`;
153
177
  }
154
178
  const { workflows } = await listWorkflows().catch(() => ({ workflows: [] }));
155
- const connected = workflows.filter((w) => member.workflows.includes(w.slug));
156
- const missing = member.workflows.filter((slug) => !connected.some((w) => w.slug === slug));
179
+ const connected = workflows.filter((w) => def.workflows.includes(w.slug));
180
+ const missing = def.workflows.filter((slug) => !connected.some((w) => w.slug === slug));
157
181
  const wfLines = connected
158
182
  .map((w) => `- ${w.slug}: ${w.description ?? w.name ?? ""} (${w.steps} steps)`)
159
183
  .join("\n");
160
- return (`You are ${member.emoji} ${member.name}, a persistent agent teammate on this project's team ` +
161
- `(defined in agents/${member.slug}/). The user works with you like with a colleague: every session ` +
162
- `is a conversation with the same ${member.name}, so keep this role consistently. You are an AI agent, ` +
184
+ return (`You are ${def.emoji} ${def.name}, a persistent agent of this project ` +
185
+ `(defined in agents/${def.slug}/). The user works with you like with a colleague: every session ` +
186
+ `is a conversation with the same ${def.name}, so keep this role consistently. You are an AI agent, ` +
163
187
  `not a human — never pretend otherwise, but do own your role.\n\n` +
164
- `## Your role\n${member.system || "(no system prompt written yet — ask the user what your job should be)"}\n\n` +
165
- (member.workflows.length > 0
188
+ `## Your role\n${def.system || "(no system prompt written yet — ask the user what your job should be)"}\n\n` +
189
+ (def.workflows.length > 0
166
190
  ? `## Your workflows\nThese kraftwerk workflows are part of your job. When the user's request matches one, ` +
167
191
  `run it yourself instead of doing the work by hand:\n${wfLines || "(none found)"}\n` +
168
192
  (missing.length > 0
@@ -174,9 +198,9 @@ async function baseScopeContext(scope, agent) {
174
198
  `run id and summarize the result. Confirm with the user before starting a long or expensive run ` +
175
199
  `unless they clearly asked for it.\n\n`
176
200
  : "") +
177
- (await teamKnowledgeContext(member)) +
201
+ (await agentKnowledgeContext(def)) +
178
202
  `The working directory is the project root. Stay within your role; if a request is clearly outside it, ` +
179
- `say so and suggest which teammate or tool fits better.` +
203
+ `say so and suggest which agent or tool fits better.` +
180
204
  (scope.routine
181
205
  ? `\n\nThis session was started automatically by your scheduled routine "${scope.routine}" — ` +
182
206
  `nobody may be watching live. Complete the task autonomously (tool permissions are ` +
@@ -212,11 +236,11 @@ async function baseScopeContext(scope, agent) {
212
236
  `verification is the human's click in the UI. Ask the user what knowledge to capture, then author concise, well-typed concepts.`);
213
237
  }
214
238
  if (scope.kind === "kraftwerk") {
215
- const [{ workflows }, runs, knowledge, members] = await Promise.all([
239
+ const [{ workflows }, runs, knowledge, agents] = await Promise.all([
216
240
  listWorkflows(),
217
241
  listRuns(),
218
242
  knowledgeIndex().catch(() => ({ bundles: [] })),
219
- listMembers().catch(() => []),
243
+ listAgents().catch(() => []),
220
244
  ]);
221
245
  const wfLines = workflows
222
246
  .map((w) => `- ${w.slug}: ${w.description ?? w.name ?? ""} (${w.steps} steps, ${w.agents} agents)`)
@@ -228,7 +252,7 @@ async function baseScopeContext(scope, agent) {
228
252
  const bundleLines = knowledge.bundles
229
253
  .map((b) => `- ${b.name} (${b.concepts} concepts${b.updatedAt ? `, updated ${b.updatedAt.slice(0, 10)}` : ""})`)
230
254
  .join("\n");
231
- const memberLines = members
255
+ const agentLines = agents
232
256
  .map((m) => `- ${m.emoji} ${m.name} (${m.slug})${m.description ? `: ${m.description}` : ""}`)
233
257
  .join("\n");
234
258
  return (`You are the assistant inside the kraftwerk inspector, a UI for a workflow-as-code agent framework. ` +
@@ -244,8 +268,8 @@ async function baseScopeContext(scope, agent) {
244
268
  `Read with \`npx kraftwerk knowledge list [bundle]\`, \`get <bundle>/<path>\`, \`search <text>\`. ` +
245
269
  `Write ONLY through \`npx kraftwerk knowledge put <bundle>/<path> --file <tmp.md> --actor kraftwerk-chat/${agent}\` ` +
246
270
  `(stamps provenance, maintains index.md/log.md — never hand-edit those).\n\n` +
247
- `## Team agents\n${memberLines || "(none)"}\n` +
248
- `These are persistent agent teammates (defined under agents/); the user talks to them on the ` +
271
+ `## Agents\n${agentLines || "(none)"}\n` +
272
+ `These are persistent agents (defined under agents/); the user talks to them on the ` +
249
273
  `Agents screen. Point the user there when a request clearly belongs to one of them.\n\n` +
250
274
  `Answer questions about workflows and runs by reading the files above. Do not modify run outputs unless asked.`);
251
275
  }
@@ -278,25 +302,28 @@ const RENDERING_BLOCK = `## Chat rendering\n` +
278
302
  `Prefer these relative /api/... URLs over file paths when showing results: they render directly ` +
279
303
  `in this chat and keep working wherever the inspector is reachable.`;
280
304
  async function scopeContext(scope, agent) {
281
- const [base, skills] = await Promise.all([
305
+ // The repositories block reads every clone from git, so it runs alongside
306
+ // the rest instead of adding its spawns to the first prompt's latency.
307
+ const [base, repos, skills] = await Promise.all([
282
308
  baseScopeContext(scope, agent),
309
+ scope.kind === "agent" || scope.kind === "kraftwerk" ? reposContext() : Promise.resolve(""),
283
310
  availableSkills(scope),
284
311
  ]);
285
- return [base, RENDERING_BLOCK, skillsBlock(skills)].filter(Boolean).join("\n\n");
312
+ return [base, repos, RENDERING_BLOCK, skillsBlock(skills)].filter(Boolean).join("\n\n");
286
313
  }
287
314
  /* ---------- backend lifecycle ---------- */
288
- /** Team chats carry the member's model/effort; resolved live so edits apply to new backends. */
315
+ /** Agent sessions carry the agent's model/effort; resolved live so edits apply to new backends. */
289
316
  async function backendTuning(scope, agent) {
290
317
  const tuning = {};
291
- if (scope.kind === "team") {
292
- const member = await getMember(scope.member).catch(() => null);
293
- if (member?.model)
294
- tuning.model = member.model;
295
- if (member?.effort)
296
- tuning.effort = member.effort;
297
- // Claude discovers skills natively; a defined allowlist narrows that.
298
- if (agent === "claude" && member?.skills)
299
- tuning.skills = member.skills;
318
+ if (scope.kind === "agent") {
319
+ const def = await getAgent(scope.slug).catch(() => null);
320
+ if (def?.model)
321
+ tuning.model = def.model;
322
+ if (def?.effort)
323
+ tuning.effort = def.effort;
324
+ // Claude discovers skills natively; an agentined allowlist narrows that.
325
+ if (agent === "claude" && def?.skills)
326
+ tuning.skills = def.skills;
300
327
  }
301
328
  // Run chats live in the run folder — grant claude the project root so
302
329
  // project-level skills and files stay reachable.
@@ -319,7 +346,7 @@ async function ensureBackend(state) {
319
346
  // Routine-triggered sessions run unattended: auto-approve, but keep
320
347
  // the request/resolution pair in the thread as an audit trail.
321
348
  const scope = state.meta.scope;
322
- if (scope.kind === "team" && scope.routine) {
349
+ if (scope.kind === "agent" && scope.routine) {
323
350
  const allow = options.find((o) => o.kind?.startsWith("allow")) ?? options[0];
324
351
  const requestId = randomUUID();
325
352
  emit(state, { type: "permission_request", requestId, title, options });
@@ -418,7 +445,7 @@ export async function postMessage(id, text) {
418
445
  // Routine sessions are one-shot and unattended: release the agent
419
446
  // process as soon as the turn ends instead of waiting for the reaper.
420
447
  const scope = state.meta.scope;
421
- if (scope.kind === "team" && scope.routine)
448
+ if (scope.kind === "agent" && scope.routine)
422
449
  dropBackend(state);
423
450
  }
424
451
  catch (err) {
@@ -26,12 +26,24 @@ export async function writeMeta(meta) {
26
26
  }
27
27
  export async function readMeta(id) {
28
28
  try {
29
- return JSON.parse(await fs.readFile(path.join(safeChatDir(id), "meta.json"), "utf8"));
29
+ return upgradeMeta(JSON.parse(await fs.readFile(path.join(safeChatDir(id), "meta.json"), "utf8")));
30
30
  }
31
31
  catch {
32
32
  return null;
33
33
  }
34
34
  }
35
+ /**
36
+ * Chats written before 0.36 scoped agent sessions as
37
+ * { kind: "team", member } — read them as { kind: "agent", slug } so the
38
+ * folders on disk never need rewriting.
39
+ */
40
+ function upgradeMeta(meta) {
41
+ const legacy = meta.scope;
42
+ if (legacy?.kind === "team" && typeof legacy.member === "string") {
43
+ meta.scope = { kind: "agent", slug: legacy.member, ...(legacy.routine ? { routine: legacy.routine } : {}) };
44
+ }
45
+ return meta;
46
+ }
35
47
  export async function appendEvent(id, ev) {
36
48
  await fs.appendFile(path.join(safeChatDir(id), "events.jsonl"), JSON.stringify(ev) + "\n");
37
49
  }
@@ -17,8 +17,8 @@ export type ChatScope = {
17
17
  kind: "knowledge";
18
18
  bundle?: string;
19
19
  } | {
20
- kind: "team";
21
- member: string;
20
+ kind: "agent";
21
+ slug: string;
22
22
  routine?: string;
23
23
  };
24
24
  export interface ChatMeta {
@@ -40,6 +40,20 @@ export interface GitDiff {
40
40
  /** The diff was cut at MAX_DIFF; the rest is available in a terminal. */
41
41
  truncated?: boolean;
42
42
  }
43
+ export interface GitResult {
44
+ ok: boolean;
45
+ /** Exit status, or null when git never ran (spawn error) or was killed. */
46
+ code: number | null;
47
+ stdout: string;
48
+ stderr: string;
49
+ }
50
+ export declare const NET_TIMEOUT = 60000;
51
+ export declare function git(args: string[], cwd: string, timeoutMs?: number, env?: NodeJS.ProcessEnv): Promise<GitResult>;
52
+ export declare function sshCommandFor(repoRoot: string): Promise<string>;
53
+ /** A git call that may touch the network: longer timeout, non-interactive ssh. */
54
+ export declare function gitNet(args: string[], repoRoot: string): Promise<GitResult>;
55
+ /** Repo root for a directory, or null when it is not inside a git repo. */
56
+ export declare function repoRootFor(dir: string): Promise<string | null>;
43
57
  /**
44
58
  * Everything the git screen renders. Each call shells out to git a handful
45
59
  * of times, and the nav badge polls it from every open tab, so results are