@netnodeag/kraftwerk 0.48.0 → 0.49.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 (48) hide show
  1. package/README.md +96 -5
  2. package/dist/cli/doctor.js +7 -1
  3. package/dist/cli/init.js +1 -0
  4. package/dist/cli/kraftwerk.js +5 -1
  5. package/dist/cli/projects.d.ts +0 -14
  6. package/dist/cli/projects.js +181 -107
  7. package/dist/cli/repos.js +1 -1
  8. package/dist/cli/ui.js +1 -1
  9. package/dist/cli/vibeables.js +1 -1
  10. package/dist/cli/workspaces.d.ts +16 -0
  11. package/dist/cli/workspaces.js +149 -0
  12. package/dist/config.d.ts +22 -1
  13. package/dist/config.js +19 -3
  14. package/dist/inspector/agents.js +2 -2
  15. package/dist/inspector/channels.d.ts +10 -1
  16. package/dist/inspector/channels.js +11 -2
  17. package/dist/inspector/chat/sessions.d.ts +8 -3
  18. package/dist/inspector/chat/sessions.js +58 -19
  19. package/dist/inspector/chat/types.d.ts +11 -0
  20. package/dist/inspector/git.js +5 -3
  21. package/dist/inspector/instances.d.ts +14 -12
  22. package/dist/inspector/instances.js +71 -31
  23. package/dist/inspector/notifications.js +2 -0
  24. package/dist/inspector/projects.d.ts +167 -0
  25. package/dist/inspector/projects.js +520 -0
  26. package/dist/inspector/search.d.ts +1 -1
  27. package/dist/inspector/search.js +3 -3
  28. package/dist/inspector/server.js +111 -20
  29. package/dist/inspector/settings.d.ts +3 -1
  30. package/dist/inspector/settings.js +14 -3
  31. package/inspector/dist/assets/{dist-Dk5ZcwMb.js → dist-B-8ArbIm.js} +1 -1
  32. package/inspector/dist/assets/{dist-DUq31xBw.js → dist-BFJWVdBm.js} +1 -1
  33. package/inspector/dist/assets/{dist-D6fPO3Jz.js → dist-BIvcKBWo.js} +1 -1
  34. package/inspector/dist/assets/{dist-n8GR1Slo.js → dist-BgYnJawU.js} +1 -1
  35. package/inspector/dist/assets/dist-Bl1ogZZM.js +1 -0
  36. package/inspector/dist/assets/{dist-O97r5Ket.js → dist-BrgwFqUR.js} +1 -1
  37. package/inspector/dist/assets/{dist-CmH9PvLb.js → dist-Bz0bwjja.js} +1 -1
  38. package/inspector/dist/assets/{dist-Dv40dCIn.js → dist-CFvGR87v.js} +1 -1
  39. package/inspector/dist/assets/{dist-Bz3XVWXl.js → dist-CjtxxsZ2.js} +1 -1
  40. package/inspector/dist/assets/{dist-BNGsfwfO.js → dist-QwwTJ2wk.js} +1 -1
  41. package/inspector/dist/assets/{dist-BWkBM49t.js → dist-tfmgozDB.js} +1 -1
  42. package/inspector/dist/assets/{editor-toVFopCO.js → editor-C6m40u-h.js} +3 -3
  43. package/inspector/dist/assets/{index-CC0tY40i.js → index-Cvq-RiV2.js} +11 -11
  44. package/inspector/dist/assets/{index-DQrsXEWt.css → index-DcQwGaV2.css} +1 -1
  45. package/inspector/dist/index.html +2 -2
  46. package/package.json +1 -1
  47. package/schema/kraftwerk.schema.json +18 -0
  48. package/inspector/dist/assets/dist-JaBY_kZV.js +0 -1
@@ -14,25 +14,28 @@ import { selfCommand } from "./self-command.js";
14
14
  * Registered ports are verified by probing /api/meta on read; entries
15
15
  * that stop answering are pruned, so crashes leave no ghosts.
16
16
  *
17
- * projects/<hash(root)>.json — durable. One record per project root that
17
+ * workspaces/<hash(root)>.json — durable. One record per workspace root that
18
18
  * ever ran the inspector on this machine, keyed by the root and never
19
19
  * pruned automatically. Everything else (name, icon, port) is derived from
20
20
  * the root's kraftwerk.yml at read time, so it is always current. A
21
21
  * project that is not running can be started again from the switcher or
22
- * `kraftwerk projects start` — that is what makes a killed UI findable.
22
+ * `kraftwerk workspaces start` — that is what makes a killed UI findable.
23
23
  * The record also carries the project's agent roster (written whenever the
24
24
  * instance reads it), so the ⌘K palette can list every workspace's agents
25
25
  * from a handful of small files without probing anything.
26
26
  */
27
27
  const HOME = path.join(os.homedir(), ".kraftwerk");
28
28
  const INSTANCES_DIR = path.join(HOME, "instances");
29
- const PROJECTS_DIR = path.join(HOME, "projects");
29
+ const WORKSPACES_DIR = path.join(HOME, "workspaces");
30
+ /** Where the records lived before 0.49 (`kraftwerk projects`); moved over once, see migrateRegistry. */
31
+ const LEGACY_WORKSPACES_DIR = path.join(HOME, "projects");
30
32
  const LOGS_DIR = path.join(HOME, "logs");
31
33
  const selfFile = () => path.join(INSTANCES_DIR, `${process.pid}.json`);
32
34
  let selfPort = null;
33
35
  let selfRoot = null;
34
- const projectKey = (root) => createHash("sha1").update(absolutePath(root)).digest("hex").slice(0, 16);
35
- const projectFile = (root) => path.join(PROJECTS_DIR, `${projectKey(root)}.json`);
36
+ /** File name of a root's record: a hash of the absolute root, the same under the old and the new directory. */
37
+ export const workspaceRecordName = (root) => `${createHash("sha1").update(absolutePath(root)).digest("hex").slice(0, 16)}.json`;
38
+ const recordFile = (root) => path.join(WORKSPACES_DIR, workspaceRecordName(root));
36
39
  /** ~/… for display. */
37
40
  export const tildify = (p) => {
38
41
  const home = os.homedir();
@@ -132,8 +135,38 @@ export async function discoverInstances() {
132
135
  cache = { at: Date.now(), entries };
133
136
  return entries;
134
137
  }
135
- /* ---------- projects (durable) ---------- */
136
- async function readProject(file) {
138
+ /* ---------- workspaces (durable) ---------- */
139
+ let migrated = null;
140
+ /**
141
+ * Records were written under ~/.kraftwerk/projects until 0.48. Copy them
142
+ * into the new directory once per process, never overwriting a record
143
+ * that is already there, so a machine that upgrades keeps every workspace
144
+ * it knew. The old directory stays: an inspector of an older version may
145
+ * still be running and reading it. forgetWorkspace drops a record from
146
+ * both, so a forgotten workspace does not come back on the next start.
147
+ * Best-effort like everything else in this registry.
148
+ */
149
+ function migrateRegistry() {
150
+ return (migrated ??= (async () => {
151
+ let legacy;
152
+ try {
153
+ legacy = await fs.readdir(LEGACY_WORKSPACES_DIR);
154
+ }
155
+ catch {
156
+ return;
157
+ }
158
+ try {
159
+ await fs.mkdir(WORKSPACES_DIR, { recursive: true });
160
+ for (const f of legacy) {
161
+ if (!f.endsWith(".json"))
162
+ continue;
163
+ await fs.copyFile(path.join(LEGACY_WORKSPACES_DIR, f), path.join(WORKSPACES_DIR, f), fsSync.constants.COPYFILE_EXCL).catch(() => { });
164
+ }
165
+ }
166
+ catch { }
167
+ })());
168
+ }
169
+ async function readRecord(file) {
137
170
  try {
138
171
  const rec = JSON.parse(await fs.readFile(file, "utf8"));
139
172
  if (typeof rec.root !== "string")
@@ -153,12 +186,13 @@ async function readProject(file) {
153
186
  }
154
187
  }
155
188
  /** Upsert the project record for a root (call on every inspector start). */
156
- export async function registerProject(root) {
189
+ export async function registerWorkspace(root) {
190
+ await migrateRegistry();
157
191
  const abs = absolutePath(root);
158
192
  const now = new Date().toISOString();
159
193
  try {
160
- await fs.mkdir(PROJECTS_DIR, { recursive: true });
161
- const prev = await readProject(projectFile(abs));
194
+ await fs.mkdir(WORKSPACES_DIR, { recursive: true });
195
+ const prev = await readRecord(recordFile(abs));
162
196
  const rec = {
163
197
  root: abs,
164
198
  firstSeen: prev?.firstSeen || now,
@@ -167,7 +201,7 @@ export async function registerProject(root) {
167
201
  ...(prev?.agents ? { agents: prev.agents } : {}),
168
202
  ...(prev?.channels ? { channels: prev.channels } : {}),
169
203
  };
170
- await fs.writeFile(projectFile(abs), JSON.stringify(rec, null, 2));
204
+ await fs.writeFile(recordFile(abs), JSON.stringify(rec, null, 2));
171
205
  }
172
206
  catch { } // best-effort, like the instance file
173
207
  }
@@ -178,13 +212,14 @@ const synced = new Map();
178
212
  * changes when the list did. Roots with no record (a CLI run in a project
179
213
  * that never started the inspector) are left alone.
180
214
  */
181
- async function syncProjectList(root, key, list) {
215
+ async function syncWorkspaceList(root, key, list) {
216
+ await migrateRegistry();
182
217
  const stamp = `${root}\n${JSON.stringify(list)}`;
183
218
  if (synced.get(key) === stamp)
184
219
  return;
185
220
  try {
186
- const file = projectFile(root);
187
- const rec = await readProject(file);
221
+ const file = recordFile(root);
222
+ const rec = await readRecord(file);
188
223
  if (!rec)
189
224
  return;
190
225
  await fs.writeFile(file, JSON.stringify({ ...rec, [key]: list }, null, 2));
@@ -192,41 +227,46 @@ async function syncProjectList(root, key, list) {
192
227
  }
193
228
  catch { }
194
229
  }
195
- export const syncProjectAgents = (root, agents) => syncProjectList(root, "agents", agents);
196
- export const syncProjectChannels = (root, channels) => syncProjectList(root, "channels", channels);
230
+ export const syncWorkspaceAgents = (root, agents) => syncWorkspaceList(root, "agents", agents);
231
+ export const syncWorkspaceChannels = (root, channels) => syncWorkspaceList(root, "channels", channels);
197
232
  /** Stamp lastStopped on clean shutdown. Sync so exit handlers can call it. */
198
- export function markProjectStopped() {
233
+ export function markWorkspaceStopped() {
199
234
  if (!selfRoot)
200
235
  return;
201
236
  try {
202
- const file = projectFile(selfRoot);
237
+ const file = recordFile(selfRoot);
203
238
  const rec = JSON.parse(fsSync.readFileSync(file, "utf8"));
204
239
  rec.lastStopped = new Date().toISOString();
205
240
  fsSync.writeFileSync(file, JSON.stringify(rec, null, 2));
206
241
  }
207
242
  catch { }
208
243
  }
209
- /** All known projects, most recently started first. */
210
- export async function listProjects() {
244
+ /** All known workspaces, most recently started first. */
245
+ export async function listWorkspaceRecords() {
246
+ await migrateRegistry();
211
247
  let files;
212
248
  try {
213
- files = await fs.readdir(PROJECTS_DIR);
249
+ files = await fs.readdir(WORKSPACES_DIR);
214
250
  }
215
251
  catch {
216
252
  return [];
217
253
  }
218
- const recs = await Promise.all(files.filter((f) => f.endsWith(".json")).map((f) => readProject(path.join(PROJECTS_DIR, f))));
254
+ const recs = await Promise.all(files.filter((f) => f.endsWith(".json")).map((f) => readRecord(path.join(WORKSPACES_DIR, f))));
219
255
  return recs
220
256
  .filter((r) => r != null)
221
257
  .sort((a, b) => b.lastStarted.localeCompare(a.lastStarted));
222
258
  }
223
- /** Drop a project record (the root itself is untouched). */
224
- export async function forgetProject(root) {
259
+ /** Drop a workspace record (the root itself is untouched) — from the legacy directory too, see migrateRegistry. */
260
+ export async function forgetWorkspace(root) {
261
+ await migrateRegistry();
262
+ const legacy = fs.unlink(path.join(LEGACY_WORKSPACES_DIR, workspaceRecordName(root))).catch(() => { });
225
263
  try {
226
- await fs.unlink(projectFile(root));
264
+ await fs.unlink(recordFile(root));
265
+ await legacy;
227
266
  return true;
228
267
  }
229
268
  catch {
269
+ await legacy;
230
270
  return false;
231
271
  }
232
272
  }
@@ -268,7 +308,7 @@ let wsCache = null;
268
308
  export async function discoverWorkspaces() {
269
309
  if (wsCache && Date.now() - wsCache.at < 5_000)
270
310
  return wsCache.entries;
271
- const [projects, live] = await Promise.all([listProjects(), discoverInstances()]);
311
+ const [projects, live] = await Promise.all([listWorkspaceRecords(), discoverInstances()]);
272
312
  const liveByRoot = new Map(live.filter((i) => i.root).map((i) => [i.root, i]));
273
313
  const consumed = new Set();
274
314
  const entries = (await Promise.all(projects
@@ -353,7 +393,7 @@ export async function listWorkspacesDetailed() {
353
393
  const all = [...others];
354
394
  if (selfRoot && selfPort) {
355
395
  const d = await describeRoot(selfRoot);
356
- const rec = await readProject(projectFile(selfRoot));
396
+ const rec = await readRecord(recordFile(selfRoot));
357
397
  all.unshift({
358
398
  name: d.name,
359
399
  icon: d.icon,
@@ -373,7 +413,7 @@ export async function listWorkspacesDetailed() {
373
413
  const base = { ...e, current, state: e.live ? "running" : "stopped" };
374
414
  if (!e.root)
375
415
  return base;
376
- const rec = await readProject(projectFile(e.root));
416
+ const rec = await readRecord(recordFile(e.root));
377
417
  base.firstSeen = rec?.firstSeen;
378
418
  base.startCount = rec?.startCount;
379
419
  // "missing" = the folder is gone; "orphaned" = it is still there but
@@ -418,7 +458,7 @@ export async function listWorkspacesDetailed() {
418
458
  * Waits up to ~5s for the new inspector to answer so callers can link
419
459
  * straight to it.
420
460
  */
421
- export async function startProject(root) {
461
+ export async function startWorkspace(root) {
422
462
  const abs = absolutePath(root);
423
463
  const { name, port, exists } = await describeRoot(abs);
424
464
  if (!exists)
@@ -441,7 +481,7 @@ export async function startProject(root) {
441
481
  let stdio = ["ignore", "ignore", "ignore"];
442
482
  try {
443
483
  await fs.mkdir(LOGS_DIR, { recursive: true });
444
- log = path.join(LOGS_DIR, `${projectKey(abs)}.log`);
484
+ log = path.join(LOGS_DIR, workspaceRecordName(abs).replace(/\.json$/, ".log"));
445
485
  const fd = fsSync.openSync(log, "a");
446
486
  fsSync.writeSync(fd, `\n--- ${new Date().toISOString()} start ${name} (${abs}) ---\n`);
447
487
  stdio = ["ignore", fd, fd];
@@ -487,7 +527,7 @@ export async function startProject(root) {
487
527
  * follows. Matched by root, or by url for instances from older versions
488
528
  * that registered no root. Waits up to ~5s for the port to go quiet.
489
529
  */
490
- export async function stopProject(target) {
530
+ export async function stopWorkspace(target) {
491
531
  if (target.root && absolutePath(target.root) === selfRoot) {
492
532
  return { ok: false, error: "that is this workspace — stop it from its own terminal or pid" };
493
533
  }
@@ -43,6 +43,8 @@ export function clipBody(text) {
43
43
  export function chatHref(meta) {
44
44
  if (meta.scope.kind === "channel")
45
45
  return `/channels/${meta.scope.slug}`;
46
+ if (meta.scope.kind === "project")
47
+ return `/projects/${meta.scope.slug}/chat/${meta.id}`;
46
48
  return meta.scope.kind === "agent" ? `/agents/${meta.scope.slug}/chat/${meta.id}` : `/agents/chats/${meta.id}`;
47
49
  }
48
50
  export async function pushNotification(input) {
@@ -0,0 +1,167 @@
1
+ import { type Project } from "../config.js";
2
+ import type { ChatAgentId } from "./chat/types.js";
3
+ /**
4
+ * Projects: a goal with everything the agents need to reach it gathered in
5
+ * one folder — the brief, the systems of record where the truth is managed
6
+ * outside kraftwerk, and links to the workspace's knowledge, vibeables,
7
+ * repositories, workflows and agents. Working in a project is chat: a
8
+ * session scoped to it carries all of this as context (sessions.ts).
9
+ *
10
+ * One folder per project under the projects root (`projects.root` in
11
+ * kraftwerk.yml, default kraftwerk-data/projects/). Like agents and
12
+ * knowledge it is part of the workspace: versioned by the workspace git,
13
+ * no repository of its own. The folder is the registry — whatever
14
+ * directory under the root holds a project.yml is a project.
15
+ *
16
+ * projects/<slug>/project.yml # title, status, goal, harness/model/effort, records, links (slug lists)
17
+ * projects/<slug>/brief.md # the goal in full: what done looks like, constraints, stakeholders
18
+ * projects/<slug>/state.md # current state, rewritten at the end of a session
19
+ * projects/<slug>/log.md # append-only: decisions, milestones (newest first, stamped)
20
+ *
21
+ * Links are one-directional lists of slugs; a target that does not exist
22
+ * is reported ("configured but not found"), never an error — the same
23
+ * rule agent knowledge follows. A system of record is context, not a
24
+ * credential and not a grant: it says where the truth lives and how it is
25
+ * usually reached, and the harness decides what the agent may call.
26
+ */
27
+ export declare const PROJECT_FILE = "project.yml";
28
+ export declare const PROJECT_STATUSES: readonly ["active", "paused", "done", "archived"];
29
+ export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
30
+ /** Where truth is managed outside kraftwerk: a tool, a folder, a board. */
31
+ export interface SystemOfRecord {
32
+ /** my-netnode | google-drive | github | bitbucket | notion | slack | url | anything else (rendered by name). */
33
+ kind: string;
34
+ /** Display name ("Figma board", "Contracts folder"). */
35
+ title?: string;
36
+ url?: string;
37
+ /** For tools with workspaces or boards: the id the agent needs (a my.netnode.ch workspace, a Slack channel). */
38
+ workspace?: string;
39
+ /** What lives there and how it is usually reached ("tickets and roadmap, `my` CLI"). */
40
+ note?: string;
41
+ }
42
+ /** What kraftwerk knows about a record kind: a label and what such a place usually holds. */
43
+ export declare const RECORD_KINDS: Record<string, {
44
+ label: string;
45
+ hint: string;
46
+ }>;
47
+ export declare const LINK_KINDS: readonly ["knowledge", "vibeables", "repos", "workflows", "agents"];
48
+ export type LinkKind = (typeof LINK_KINDS)[number];
49
+ export interface ProjectDef {
50
+ slug: string;
51
+ title: string;
52
+ status: ProjectStatus;
53
+ /** One line: what the project is for. brief.md holds the long form. */
54
+ goal: string;
55
+ /** Harness every chat in the project runs on, like an agent's. Default: claude. */
56
+ harness: ChatAgentId;
57
+ /** Model for that harness; absent = the harness default. */
58
+ model?: string;
59
+ /** Reasoning effort (low … max); absent = the harness default. */
60
+ effort?: string;
61
+ records: SystemOfRecord[];
62
+ /** OKF bundle names. */
63
+ knowledge: string[];
64
+ /** Folder names under the vibeables root. */
65
+ vibeables: string[];
66
+ /** Folder names under the repos root. */
67
+ repos: string[];
68
+ /** Workflow slugs. */
69
+ workflows: string[];
70
+ /** Agent slugs. */
71
+ agents: string[];
72
+ }
73
+ export interface ProjectSummary extends ProjectDef {
74
+ /** Absolute folder path. */
75
+ path: string;
76
+ /** Newest change inside the folder. */
77
+ updatedAt?: string;
78
+ /** Set when project.yml exists but is unusable; the rest shows defaults. */
79
+ configError?: string;
80
+ }
81
+ /** One linked slug and whether its target exists right now. */
82
+ export interface LinkState {
83
+ slug: string;
84
+ found: boolean;
85
+ /** Display name of the target when found (an agent's name, a workflow's description). */
86
+ label?: string;
87
+ }
88
+ export type ProjectLinks = Record<LinkKind, LinkState[]>;
89
+ export interface ProjectDetail extends ProjectSummary {
90
+ brief: string;
91
+ state: string;
92
+ log: string;
93
+ links: ProjectLinks;
94
+ }
95
+ export interface ProjectsView {
96
+ enabled: boolean;
97
+ root?: string;
98
+ projects: ProjectSummary[];
99
+ /** Set when the feature is off or the root cannot be read. */
100
+ error?: string;
101
+ }
102
+ /** Every field optional: omitted = untouched. */
103
+ export interface SaveProjectInput {
104
+ title?: string;
105
+ status?: string;
106
+ goal?: string;
107
+ harness?: string;
108
+ model?: string;
109
+ effort?: string;
110
+ records?: unknown;
111
+ knowledge?: unknown;
112
+ vibeables?: unknown;
113
+ repos?: unknown;
114
+ workflows?: unknown;
115
+ agents?: unknown;
116
+ brief?: string;
117
+ state?: string;
118
+ }
119
+ export declare function safeProjectSlug(slug: string): string;
120
+ /** Derive a slug from a title: "Relaunch netnode.ch" -> "relaunch-netnode-ch". */
121
+ export declare function projectSlugFromTitle(title: string): string;
122
+ export declare function openProjects(): Promise<{
123
+ project?: Project;
124
+ root?: string;
125
+ off?: boolean;
126
+ error?: string;
127
+ }>;
128
+ /** Make sure the root exists; runs when the feature is switched on. */
129
+ export declare function ensureProjectsRoot(): Promise<void>;
130
+ /** Every project under the root: active ones first, then by title. */
131
+ export declare function listProjects(): Promise<ProjectsView>;
132
+ /** Which of the linked slugs exist right now, with a display label for the ones that do. */
133
+ export declare function resolveLinks(def: ProjectDef): Promise<ProjectLinks>;
134
+ /** One project with its files and link states; null when the folder is not a project. */
135
+ export declare function getProject(slug: string): Promise<ProjectDetail | null>;
136
+ /** The files a fresh project starts with. */
137
+ export declare function projectStarter(def: ProjectDef): Record<string, string>;
138
+ export declare function createProject(input: {
139
+ title: string;
140
+ goal?: string;
141
+ slug?: string;
142
+ harness?: string;
143
+ model?: string;
144
+ effort?: string;
145
+ }): Promise<ProjectDetail>;
146
+ /** Merge the given fields into project.yml (and brief/state when given); lists replace wholesale. */
147
+ export declare function saveProject(slug: string, input: SaveProjectInput): Promise<ProjectDetail>;
148
+ /** Add or remove one slug in a link list. */
149
+ export declare function linkProject(slug: string, kind: string, target: string, remove?: boolean): Promise<ProjectDetail>;
150
+ /**
151
+ * Prepend an entry under today's heading in log.md (newest first, like a
152
+ * knowledge bundle's log). The actor is stamped so a line written by an
153
+ * agent reads as such next to a human's.
154
+ */
155
+ export declare function appendProjectLog(slug: string, entry: string, actor: string): Promise<string>;
156
+ /** Remove the folder; its history stays in the workspace git. */
157
+ export declare function deleteProject(slug: string): Promise<void>;
158
+ /** One line per system of record: what it is, where, and what usually lives there. */
159
+ export declare function recordLine(r: SystemOfRecord): string;
160
+ /**
161
+ * The "## …" blocks a project chat starts with: brief, state, records,
162
+ * every link with how to reach it, and how to keep the project current.
163
+ * `actor` is stamped on log entries the agent writes.
164
+ */
165
+ export declare function projectContext(slug: string, actor: string, opts?: {
166
+ member?: boolean;
167
+ }): Promise<string>;