@netnodeag/kraftwerk 0.48.0 → 0.50.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 (51) hide show
  1. package/README.md +103 -7
  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.d.ts +51 -0
  21. package/dist/inspector/git.js +22 -12
  22. package/dist/inspector/instances.d.ts +14 -12
  23. package/dist/inspector/instances.js +71 -31
  24. package/dist/inspector/notifications.js +2 -0
  25. package/dist/inspector/projects.d.ts +167 -0
  26. package/dist/inspector/projects.js +520 -0
  27. package/dist/inspector/repos.d.ts +47 -0
  28. package/dist/inspector/repos.js +121 -2
  29. package/dist/inspector/search.d.ts +1 -1
  30. package/dist/inspector/search.js +3 -3
  31. package/dist/inspector/server.js +149 -21
  32. package/dist/inspector/settings.d.ts +3 -1
  33. package/dist/inspector/settings.js +14 -3
  34. package/inspector/dist/assets/dist-BB5opUKX.js +1 -0
  35. package/inspector/dist/assets/{dist-Dv40dCIn.js → dist-BV28M6sp.js} +1 -1
  36. package/inspector/dist/assets/{dist-BNGsfwfO.js → dist-Bi4ZTD6W.js} +1 -1
  37. package/inspector/dist/assets/{dist-n8GR1Slo.js → dist-BuqvesGh.js} +1 -1
  38. package/inspector/dist/assets/{dist-O97r5Ket.js → dist-CLSTyUdp.js} +1 -1
  39. package/inspector/dist/assets/{dist-D6fPO3Jz.js → dist-Ca8OxgWP.js} +1 -1
  40. package/inspector/dist/assets/{dist-DUq31xBw.js → dist-DGB-RjHI.js} +1 -1
  41. package/inspector/dist/assets/{dist-Bz3XVWXl.js → dist-DHS6DxTY.js} +1 -1
  42. package/inspector/dist/assets/{dist-Dk5ZcwMb.js → dist-DVWyuKcp.js} +1 -1
  43. package/inspector/dist/assets/{dist-BWkBM49t.js → dist-DkEzjj-j.js} +1 -1
  44. package/inspector/dist/assets/{dist-CmH9PvLb.js → dist-Dv003C77.js} +1 -1
  45. package/inspector/dist/assets/{editor-toVFopCO.js → editor-B5WUiag9.js} +3 -3
  46. package/inspector/dist/assets/{index-CC0tY40i.js → index-BfiWjmwA.js} +11 -11
  47. package/inspector/dist/assets/{index-DQrsXEWt.css → index-DCpGWY1g.css} +1 -1
  48. package/inspector/dist/index.html +2 -2
  49. package/package.json +1 -1
  50. package/schema/kraftwerk.schema.json +18 -0
  51. package/inspector/dist/assets/dist-JaBY_kZV.js +0 -1
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { realpathSync } from "node:fs";
3
3
  import path from "node:path";
4
- import { reposRootFor, resolveProject, vibeablesRootFor, isSafeGitName } from "../config.js";
4
+ import { projectsRootFor, reposRootFor, resolveProject, vibeablesRootFor, isSafeGitName } from "../config.js";
5
5
  import { ENV_FILE } from "../runner/docker.js";
6
6
  import { getOutputDir, getProjectRoot } from "./context.js";
7
7
  /**
@@ -32,7 +32,8 @@ import { getOutputDir, getProjectRoot } from "./context.js";
32
32
  * terminal nobody is watching.
33
33
  */
34
34
  /** Files that must never be staged, whatever the scope says. */
35
- const DENY = [
35
+ /** Paths whose content is never shown or synced, whichever repo they sit in. */
36
+ export const DENY = [
36
37
  /(^|\/)\.env($|\.)/,
37
38
  /(^|\/)\.envrc$/,
38
39
  new RegExp(`(^|/)${ENV_FILE.replace(/\./g, "\\.")}$`),
@@ -147,7 +148,7 @@ function netArgs(cmd, ...names) {
147
148
  * Pathspecs are globs by default, so a file called `note[1].md` would not
148
149
  * match itself. `:(literal)` turns the pattern off.
149
150
  */
150
- const literal = (p) => `:(literal)${p}`;
151
+ export const literal = (p) => `:(literal)${p}`;
151
152
  /** Repo root for a directory, or null when it is not inside a git repo. */
152
153
  export async function repoRootFor(dir) {
153
154
  const r = await git(["rev-parse", "--show-toplevel"], dir);
@@ -203,7 +204,7 @@ async function checkedOut(repo) {
203
204
  }
204
205
  /**
205
206
  * The roots that may be committed, as repo-relative paths: the workflows,
206
- * knowledge, agents, skills, channels and vibeables roots this project
207
+ * knowledge, agents, skills, channels, vibeables and projects roots this project
207
208
  * declares, plus kraftwerk.yml and the project README. The output directory
208
209
  * is excluded even when it lives inside one of them.
209
210
  */
@@ -234,6 +235,8 @@ function scopeFor({ project, repoRoot }) {
234
235
  path.resolve(project.root, "channels"),
235
236
  // Vibeables are the workspace's own apps: versioned with it, unlike clones.
236
237
  vibeablesRootFor(project),
238
+ // Projects too: briefs, state and logs are the workspace's own record.
239
+ projectsRootFor(project),
237
240
  project.configPath,
238
241
  path.resolve(project.root, "README.md"),
239
242
  ];
@@ -284,7 +287,7 @@ async function hasLocalChanges(repoRoot) {
284
287
  return !(await git(["diff", "--cached", "--quiet"], repoRoot)).ok;
285
288
  }
286
289
  /** Label for a porcelain code, using whichever half is set. */
287
- function label(code) {
290
+ export function label(code) {
288
291
  if (code === "??")
289
292
  return "untracked";
290
293
  const c = code.replace(/ /g, "");
@@ -305,7 +308,7 @@ function label(code) {
305
308
  return c || "changed";
306
309
  }
307
310
  /** Parse `git status --porcelain=v1 -z`, which NUL-separates and doubles up on renames and copies. */
308
- function parseStatus(out) {
311
+ export function parseStatus(out) {
309
312
  const parts = out.split("\0").filter((p) => p.length > 0);
310
313
  const files = [];
311
314
  for (let i = 0; i < parts.length; i++) {
@@ -411,7 +414,7 @@ export async function gitStatus(fresh = false) {
411
414
  const blocked = all.filter((f) => !f.syncable).sort(byPath);
412
415
  const blockedHidden = Math.max(0, blocked.length - MAX_BLOCKED);
413
416
  const scopeError = scope.wholeRepo
414
- ? "A configured root points at the repository root, which would put the whole repo in scope. Narrow workflows/knowledge/agents/skills/vibeables in kraftwerk.yml."
417
+ ? "A configured root points at the repository root, which would put the whole repo in scope. Narrow workflows/knowledge/agents/skills/vibeables/projects in kraftwerk.yml."
415
418
  : undefined;
416
419
  const value = {
417
420
  ...base,
@@ -448,11 +451,18 @@ export async function gitDiff(file) {
448
451
  return { diff: "", error: "not shown: not a changed file under the workspace paths" };
449
452
  if (!entry.syncable)
450
453
  return { diff: "", error: `not shown: ${entry.reason}` };
451
- const { repoRoot } = st;
452
- // A repo without commits yet has nothing for `diff HEAD` to compare with;
453
- // there, as for an untracked file, the whole file is the change.
454
+ return diffAgainstHead(st.repoRoot, file, entry.code);
455
+ }
456
+ /**
457
+ * Unified diff of one path in a repo against HEAD, capped. The caller has
458
+ * checked that the path is a listed, showable change. A repo without
459
+ * commits yet has nothing for `diff HEAD` to compare with; there, as for an
460
+ * untracked file, the whole file is the change. Shared by the workspace
461
+ * diff and the repositories screen so the two never drift.
462
+ */
463
+ export async function diffAgainstHead(repoRoot, file, code) {
454
464
  const hasHead = (await git(["rev-parse", "--verify", "-q", "HEAD"], repoRoot)).ok;
455
- if (entry.code === "??" || !hasHead) {
465
+ if (code === "??" || !hasHead) {
456
466
  // --no-index exits 1 when the files differ, which is the normal case here.
457
467
  const r = await git(["diff", "--no-index", "--", "/dev/null", file], repoRoot);
458
468
  return r.stdout ? capDiff(r.stdout) : { diff: "", error: r.stderr || "no diff available" };
@@ -460,7 +470,7 @@ export async function gitDiff(file) {
460
470
  const r = await git(["diff", "HEAD", "--", literal(file)], repoRoot);
461
471
  return r.ok ? capDiff(r.stdout) : { diff: "", error: r.stderr || "diff failed" };
462
472
  }
463
- function capDiff(diff) {
473
+ export function capDiff(diff) {
464
474
  if (diff.length <= MAX_DIFF)
465
475
  return { diff };
466
476
  const cut = diff.lastIndexOf("\n", MAX_DIFF);
@@ -1,7 +1,7 @@
1
1
  import type { AgentSummary } from "./agents.js";
2
2
  import type { ChannelSummary } from "./channels.js";
3
3
  /** Durable per-project record. The root is the key; everything else derives from it. */
4
- export interface ProjectRecord {
4
+ export interface WorkspaceRecord {
5
5
  root: string;
6
6
  firstSeen: string;
7
7
  lastStarted: string;
@@ -29,7 +29,7 @@ export interface DiscoveredInstance {
29
29
  }
30
30
  /**
31
31
  * One workspace as the switcher shows it: a known project (running or
32
- * not) or a running instance the projects registry doesn't know yet.
32
+ * not) or a running instance the workspace registry doesn't know yet.
33
33
  */
34
34
  export interface WorkspaceEntry {
35
35
  name: string;
@@ -49,6 +49,8 @@ export interface WorkspaceEntry {
49
49
  lastStarted?: string;
50
50
  lastStopped?: string;
51
51
  }
52
+ /** File name of a root's record: a hash of the absolute root, the same under the old and the new directory. */
53
+ export declare const workspaceRecordName: (root: string) => string;
52
54
  /** ~/… for display. */
53
55
  export declare const tildify: (p: string) => string;
54
56
  /** Write this instance's registry file (call once the server listens). */
@@ -60,15 +62,15 @@ export declare function unregisterInstance(): void;
60
62
  /** All other running instances, verified live. Cached briefly — /api/meta is polled. */
61
63
  export declare function discoverInstances(): Promise<DiscoveredInstance[]>;
62
64
  /** Upsert the project record for a root (call on every inspector start). */
63
- export declare function registerProject(root: string): Promise<void>;
64
- export declare const syncProjectAgents: (root: string, agents: AgentSummary[]) => Promise<void>;
65
- export declare const syncProjectChannels: (root: string, channels: ChannelSummary[]) => Promise<void>;
65
+ export declare function registerWorkspace(root: string): Promise<void>;
66
+ export declare const syncWorkspaceAgents: (root: string, agents: AgentSummary[]) => Promise<void>;
67
+ export declare const syncWorkspaceChannels: (root: string, channels: ChannelSummary[]) => Promise<void>;
66
68
  /** Stamp lastStopped on clean shutdown. Sync so exit handlers can call it. */
67
- export declare function markProjectStopped(): void;
68
- /** All known projects, most recently started first. */
69
- export declare function listProjects(): Promise<ProjectRecord[]>;
70
- /** Drop a project record (the root itself is untouched). */
71
- export declare function forgetProject(root: string): Promise<boolean>;
69
+ export declare function markWorkspaceStopped(): void;
70
+ /** All known workspaces, most recently started first. */
71
+ export declare function listWorkspaceRecords(): Promise<WorkspaceRecord[]>;
72
+ /** Drop a workspace record (the root itself is untouched) — from the legacy directory too, see migrateRegistry. */
73
+ export declare function forgetWorkspace(root: string): Promise<boolean>;
72
74
  /**
73
75
  * The switcher's view: every known project joined with the live instances,
74
76
  * plus running instances with no project record yet. The calling instance
@@ -118,14 +120,14 @@ export interface StartResult {
118
120
  * Waits up to ~5s for the new inspector to answer so callers can link
119
121
  * straight to it.
120
122
  */
121
- export declare function startProject(root: string): Promise<StartResult>;
123
+ export declare function startWorkspace(root: string): Promise<StartResult>;
122
124
  /**
123
125
  * Stop a running workspace: SIGTERM to its server process, which
124
126
  * unregisters, stamps lastStopped and exits; the `kraftwerk ui` supervisor
125
127
  * follows. Matched by root, or by url for instances from older versions
126
128
  * that registered no root. Waits up to ~5s for the port to go quiet.
127
129
  */
128
- export declare function stopProject(target: {
130
+ export declare function stopWorkspace(target: {
129
131
  root?: string;
130
132
  url?: string;
131
133
  }): Promise<{
@@ -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>;