@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
package/README.md CHANGED
@@ -94,7 +94,7 @@ newer version is on disk, right after "update now" or after a manual install.
94
94
 
95
95
  ```bash
96
96
  npm install -g @netnodeag/kraftwerk@latest
97
- kraftwerk projects # every workspace on this machine, running or not
97
+ kraftwerk workspaces # every workspace on this machine, running or not
98
98
  ```
99
99
 
100
100
  ## Consume
@@ -156,7 +156,8 @@ kraftwerk run # interactive: pick workflow, type the r
156
156
  kraftwerk runs # past runs from output/*/trace.jsonl; runs show <id> for detail
157
157
  kraftwerk knowledge # Knowledge: OKF bundles (list/get/put/verify/search/...)
158
158
  kraftwerk ui # inspector web UI on http://localhost:1981; --port, --output
159
- kraftwerk projects # every workspace on this machine; projects start|stop|forget <ref>
159
+ kraftwerk workspaces # every workspace on this machine; workspaces start|stop|forget <ref>
160
+ kraftwerk projects # goal-scoped project folders; projects create|show|link|log|remove
160
161
  kraftwerk doctor # preflight: harness CLIs, docker, workflows, declared env vars
161
162
  kraftwerk validate # all discovered: schema + semantics + files, exit 1 on failure
162
163
  kraftwerk validate src/workflows/pitch # specific paths
@@ -217,8 +218,8 @@ switcher:
217
218
  letters of an agent's or channel's name, description or workspace and hit
218
219
  enter to jump to it. It lists the active agents and the channels of every
219
220
  workspace on this machine, grouped by workspace and running or not (a
220
- stopped one is started on the way), read from the project registry under
221
- `~/.kraftwerk/projects`, which every inspector keeps current with its
221
+ stopped one is started on the way), read from the workspace registry under
222
+ `~/.kraftwerk/workspaces`, which every inspector keeps current with its
222
223
  roster and channel list.
223
224
 
224
225
  ### Triggering from CI, cron, or webhooks
@@ -380,7 +381,7 @@ working without a login. `kraftwerk doctor` reports the tunnel, the Access
380
381
  block and whether cloudflared is installed.
381
382
 
382
383
  **Variants.** `kraftwerk tunnel` runs the configured tunnel alone, for an
383
- inspector that already runs elsewhere (started by `kraftwerk projects
384
+ inspector that already runs elsewhere (started by `kraftwerk workspaces
384
385
  start`, or in a container). For a tunnel created in the Zero Trust
385
386
  dashboard instead (Networks → Tunnels), leave `name` out, route the
386
387
  hostname to `http://localhost:<port>` there and export its token as
@@ -403,6 +404,96 @@ Quick tunnels (`trycloudflare.com`) are deliberately not supported: Access
403
404
  cannot be attached to them, which would leave the UI open to anyone with
404
405
  the URL.
405
406
 
407
+ ## Projects
408
+
409
+ Turn on `projects:` in `kraftwerk.yml` (or the checkbox in settings) and
410
+ the workspace gets one folder per project — a goal with everything the
411
+ agents need to reach it:
412
+
413
+ ```yaml
414
+ projects:
415
+ root: kraftwerk-data/projects # default; part of the workspace, synced like agents and knowledge
416
+ ```
417
+
418
+ ```
419
+ kraftwerk-data/projects/<slug>/
420
+ project.yml # title, status, goal, records, links
421
+ brief.md # the goal in full: what done looks like, constraints, stakeholders
422
+ state.md # current state, rewritten at the end of a session
423
+ log.md # append-only, newest first: decisions and milestones, dated and attributed
424
+ ```
425
+
426
+ `project.yml` holds the one-line goal, a status (`active`, `paused`, `done`,
427
+ `archived`), the harness, model and effort its chats run on (the same three
428
+ fields an agent has; the harness decides, not the caller), the **systems of
429
+ record** and the **links**:
430
+
431
+ ```yaml
432
+ title: Relaunch netnode.ch
433
+ status: active
434
+ goal: Ship the new site on NodeHive by 2026-11-30
435
+ harness: claude # claude | codex | pi — every chat in the project runs on it
436
+ model: sonnet # optional, like an agent's
437
+ effort: high # optional: low | medium | high | xhigh | max
438
+ records:
439
+ - kind: my-netnode # my-netnode | google-drive | github | bitbucket | notion | slack | url | anything
440
+ workspace: 22
441
+ url: https://my.netnode.ch/workspace/22
442
+ note: tickets, roadmap and meetings
443
+ - kind: google-drive
444
+ title: Contracts and briefs
445
+ url: https://drive.google.com/drive/folders/…
446
+ knowledge: [netnode-helpdesk] # OKF bundle names
447
+ vibeables: [launch-tracker] # folders under the vibeables root
448
+ repos: [netnode-frontend] # folders under the repos root
449
+ workflows: [website-check] # workflow slugs
450
+ agents: [max] # agent slugs
451
+ ```
452
+
453
+ A system of record says where the truth of the project is managed outside
454
+ kraftwerk and what usually lives there. It is context, not a credential and
455
+ not a grant: the agent reaches it through the tools its harness already has
456
+ (a CLI, an MCP server, the browser), and the harness decides what it may
457
+ call. Kraftwerk knows a few kinds only to label them and phrase the context
458
+ better; any other kind passes through as a link with a note.
459
+
460
+ Links are one-directional lists of slugs. A target that does not exist is
461
+ shown as "not found" on the project page and told to the agent, never an
462
+ error — the same rule an agent's knowledge list follows.
463
+
464
+ **Working in a project is chat.** The Projects screen lists the projects,
465
+ the chats of the selected one, and the thread; opening a project opens its
466
+ latest chat. A session scoped to a project starts with the brief, the
467
+ current state, the records, every link with how to reach it, and the rule
468
+ for keeping the project current: `state.md` is rewritten at the end of a
469
+ session that changed something, so the next session (by anyone) continues
470
+ without the transcript; `log.md` is appended through
471
+ `kraftwerk projects log <slug> "<line>" --actor <who>` so every line is
472
+ dated and attributed; `brief.md` belongs to the user. The working
473
+ directory stays the workspace root, so linked repositories and knowledge
474
+ are reachable by path; opening a vibeable moves it into the app folder as
475
+ in any chat.
476
+
477
+ **Coworkers.** "Add coworker" on a project chat works as on an agent
478
+ session: the chat becomes a channel whose members are the agents you pick,
479
+ with everything said so far and the project's brief, state, records and
480
+ links as context for every member (`project:` in `channel.yml`). The
481
+ channel lives on the channels screen and stays listed under the project.
482
+ The first agent picked answers messages that mention nobody.
483
+
484
+ ```bash
485
+ kraftwerk projects # list: title, status, goal, links
486
+ kraftwerk projects create "Relaunch netnode.ch" --goal "Ship by November"
487
+ kraftwerk projects show relaunch-netnode-ch # definition, records, link states, state, log
488
+ kraftwerk projects set relaunch-netnode-ch --harness codex --model gpt-5.6-sol --effort high
489
+ kraftwerk projects link relaunch-netnode-ch workflows website-check
490
+ kraftwerk projects log relaunch-netnode-ch "Decided on NodeHive." --actor human:lukas
491
+ kraftwerk projects remove relaunch-netnode-ch
492
+ ```
493
+
494
+ The registry of workspaces on this machine, which answered to
495
+ `kraftwerk projects` until 0.48, is `kraftwerk workspaces` now.
496
+
406
497
  ## Persistent agents
407
498
 
408
499
  The inspector's "agents" screen turns chat agents into persistent teammates. An
@@ -496,8 +587,13 @@ repos:
496
587
  The folder is the registry. Whatever has a `.git` directly under the root is
497
588
  a repository, whether the "repositories" screen cloned it, `kraftwerk repos
498
589
  add <url>` did, or an agent ran `git clone` there. Every entry is read live
499
- from git: origin, branch, head, uncommitted changes, ahead/behind. The
500
- screen clones by url, fetches and fast-forwards clean clones, and removes
590
+ from git: origin, branch, head, uncommitted changes, ahead/behind. Each
591
+ clone has a page (`#/repos/<name>`, the "changes" button) that shows what
592
+ is happening inside: the changed files with their diff against HEAD, the
593
+ line counts, and the recent commits with the unpushed ones marked — click
594
+ a commit for its patch. It polls, so an agent working in the clone is
595
+ watched live. Secrets (`.env`, keys) are never shown, as on the git screen.
596
+ The list clones by url, fetches and fast-forwards clean clones, and removes
501
597
  them (refusing while they hold unpushed or uncommitted work). Cloning uses
502
598
  your own git credentials and never prompts, so a private remote has to work
503
599
  from a terminal first.
@@ -88,7 +88,13 @@ export async function runDoctor(cwd) {
88
88
  else {
89
89
  const cfg = project.config;
90
90
  const keys = Object.keys(cfg);
91
- report("ok", `${path.basename(project.configPath)} well-formed`, keys.length ? keys.map((k) => `${k}: ${cfg[k]}`).join(", ") : "empty — defaults apply");
91
+ // Blocks (git, repos, vibeables, projects, tunnel) print as their keys, not [object Object].
92
+ const show = (v) => typeof v === "object" && v !== null
93
+ ? Array.isArray(v)
94
+ ? `[${v.length}]`
95
+ : `{${Object.entries(v).map(([k, x]) => `${k}: ${show(x)}`).join(", ") || " "}}`
96
+ : String(v);
97
+ report("ok", `${path.basename(project.configPath)} well-formed`, keys.length ? keys.map((k) => `${k}: ${show(cfg[k])}`).join(", ") : "empty — defaults apply");
92
98
  if (!cfg.name)
93
99
  report("info", "name not set in kraftwerk.yml", "inspector header falls back to the folder name");
94
100
  for (const key of ["workflows", "knowledge", "agents", "output"]) {
package/dist/cli/init.js CHANGED
@@ -26,6 +26,7 @@ skills: ${DATA_DIR}/skills # workspace skills (shared instruction packag
26
26
  # repos: # git repositories the agents work on (uncomment both lines to enable)
27
27
  # root: ${DATA_DIR}/repos # clones land here (git-ignored); a bare \`repos:\` uses repos/ instead
28
28
  # vibeables: # small apps built live in a chat with a preview pane (uncomment to enable; one folder per app under ${DATA_DIR}/vibeables, versioned with the workspace)
29
+ # projects: # goal-scoped folders — brief, systems of record, links to knowledge/vibeables/repos/workflows/agents — that chats work in (uncomment to enable; one folder per project under ${DATA_DIR}/projects)
29
30
  # public: https://kw.example.com # hostname the inspector is reached at through a tunnel or reverse proxy
30
31
  # tunnel: # Cloudflare Tunnel run by \`kraftwerk ui\` (needs public:; put a Cloudflare Access policy on the hostname — the UI has no login of its own)
31
32
  # name: kraftwerk # locally-managed tunnel (cloudflared tunnel create/route dns); omit and export TUNNEL_TOKEN for a dashboard-managed one
@@ -13,6 +13,7 @@ import { runDoctor } from "./doctor.js";
13
13
  import { runInit } from "./init.js";
14
14
  import { registerKnowledgeCommands } from "./knowledge.js";
15
15
  import { registerProjectCommands } from "./projects.js";
16
+ import { registerWorkspaceCommands } from "./workspaces.js";
16
17
  import { registerRepoCommands } from "./repos.js";
17
18
  import { registerVibeableCommands } from "./vibeables.js";
18
19
  import { registerTunnelCommands } from "./tunnel.js";
@@ -31,7 +32,9 @@ import { runUi } from "./ui.js";
31
32
  * kraftwerk knowledge ... Knowledge: OKF bundles (list/get/put/verify/...)
32
33
  * kraftwerk routines ... per-agent scheduled prompts (list/add/remove/enable/run)
33
34
  * kraftwerk ui start the inspector web UI (localhost:1981)
34
- * kraftwerk projects ... known projects on this machine (list/start/forget)
35
+ * kraftwerk workspaces ... known workspaces on this machine (list/start/stop/forget)
36
+ * kraftwerk projects ... goal-scoped project folders (list/create/show/link/log/remove)
37
+ * kraftwerk vibeables ... small apps built live in a chat (list/create/remove)
35
38
  * kraftwerk repos ... repositories the agents work on (list/add/update/remove)
36
39
  * kraftwerk tunnel [setup <host>] Cloudflare Tunnel to the inspector: run it alone, or set one up
37
40
  * kraftwerk doctor preflight: harness CLIs, docker, workflows, env
@@ -279,6 +282,7 @@ runs
279
282
  });
280
283
  registerKnowledgeCommands(program);
281
284
  registerRoutineCommands(program);
285
+ registerWorkspaceCommands(program);
282
286
  registerProjectCommands(program);
283
287
  registerRepoCommands(program);
284
288
  registerVibeableCommands(program);
@@ -1,16 +1,2 @@
1
1
  import type { Command } from "commander";
2
- /**
3
- * `kraftwerk projects` — every project that ever ran the inspector on this
4
- * machine (~/.kraftwerk/projects), joined with what is running right now.
5
- * The answer to "where did I start that UI, and how do I get it back":
6
- *
7
- * kraftwerk projects list: name, root, running/stopped
8
- * kraftwerk projects start <ref> relaunch `kraftwerk ui` in that root, detached
9
- * kraftwerk projects stop <ref> SIGTERM a running UI (also ones started in a terminal)
10
- * kraftwerk projects forget <ref> drop the record (the folder stays)
11
- *
12
- * <ref> is a project name, the root's folder name, the root path, or a
13
- * port / localhost:port for running instances that recorded no root.
14
- */
15
- export declare const fmtAgo: (iso?: string) => string;
16
2
  export declare function registerProjectCommands(program: Command): void;
@@ -1,149 +1,223 @@
1
- import path from "node:path";
2
1
  import chalk from "chalk";
3
- import { absolutePath } from "../config.js";
4
2
  import Table from "cli-table3";
5
- import { discoverWorkspaces, forgetProject, startProject, stopProject, tildify, } from "../inspector/instances.js";
3
+ import { readFile } from "node:fs/promises";
4
+ import { appendProjectLog, createProject, deleteProject, getProject, linkProject, listProjects, LINK_KINDS, recordLine, saveProject, } from "../inspector/projects.js";
5
+ import { initContext as prepare } from "./routines.js";
6
+ import { fmtAgo } from "./workspaces.js";
6
7
  /**
7
- * `kraftwerk projects` — every project that ever ran the inspector on this
8
- * machine (~/.kraftwerk/projects), joined with what is running right now.
9
- * The answer to "where did I start that UI, and how do I get it back":
8
+ * `kraftwerk projects` — the goal-scoped folders under the projects root
9
+ * (`projects.root` in kraftwerk.yml): a brief, the systems of record, and
10
+ * links to knowledge, vibeables, repositories, workflows and agents. The
11
+ * same module the inspector uses, so a project created here shows up in
12
+ * the UI at once, and an agent in a project chat uses `log` and `link`
13
+ * to keep the project current:
10
14
  *
11
- * kraftwerk projects list: name, root, running/stopped
12
- * kraftwerk projects start <ref> relaunch `kraftwerk ui` in that root, detached
13
- * kraftwerk projects stop <ref> SIGTERM a running UI (also ones started in a terminal)
14
- * kraftwerk projects forget <ref> drop the record (the folder stays)
15
+ * kraftwerk projects list: title, status, goal, links
16
+ * kraftwerk projects create <title> [--goal ..] new folder from the starter (project.yml, brief, state, log); --harness/--model/--effort
17
+ * kraftwerk projects set <slug> [--harness ..] change harness, model, effort, status or goal
18
+ * kraftwerk projects show <slug> definition, links (found / missing), records, state, log
19
+ * kraftwerk projects link <slug> <kind> <name> add a knowledge|vibeables|repos|workflows|agents link
20
+ * kraftwerk projects unlink <slug> <kind> <name> remove one
21
+ * kraftwerk projects log <slug> "<entry>" append a stamped line to log.md (--actor)
22
+ * kraftwerk projects remove <slug> delete the folder (its history stays in the workspace git)
15
23
  *
16
- * <ref> is a project name, the root's folder name, the root path, or a
17
- * port / localhost:port for running instances that recorded no root.
24
+ * The workspace registry answered to this name until 0.48; it is
25
+ * `kraftwerk workspaces` now (workspaces.ts).
18
26
  */
19
- export const fmtAgo = (iso) => {
20
- if (!iso)
21
- return "—";
22
- const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
23
- if (s < 90)
24
- return "just now";
25
- if (s < 5400)
26
- return `${Math.round(s / 60)} min ago`;
27
- if (s < 129600)
28
- return `${Math.round(s / 3600)} h ago`;
29
- return `${Math.round(s / 86400)} d ago`;
27
+ const die = (msg) => {
28
+ console.error(chalk.red(msg));
29
+ process.exit(1);
30
30
  };
31
- /**
32
- * Running → its url. Not running: "stopped" after a clean shutdown, "died"
33
- * when the last start has no matching stop (killed, crashed, terminal
34
- * closed) — the case this command exists for.
35
- */
36
- const status = (e) => {
37
- if (e.live)
38
- return chalk.green(`● ${e.url.replace(/^https?:\/\//, "")}`);
39
- if (e.exists === false)
40
- return chalk.red("path missing");
41
- const clean = !!e.lastStopped && !!e.lastStarted && e.lastStopped >= e.lastStarted;
42
- return clean ? chalk.dim("stopped") : chalk.yellow("died");
43
- };
44
- /** Match a ref against known workspaces; `needRoot` excludes rootless live instances (start/forget need a root). */
45
- async function resolveRef(ref, needRoot = true) {
46
- const all = (await discoverWorkspaces()).filter((e) => e.root || !needRoot);
47
- const abs = absolutePath(ref);
48
- const port = /^\d+$/.test(ref) ? `http://localhost:${ref}` : `http://${ref.replace(/^https?:\/\//, "")}`;
49
- const hits = all.filter((e) => e.root === abs ||
50
- e.root === ref ||
51
- e.name === ref ||
52
- (e.root && path.basename(e.root) === ref) ||
53
- e.url === port);
54
- if (hits.length === 1)
55
- return hits[0];
56
- if (hits.length === 0) {
57
- console.error(chalk.red(`No known project "${ref}". See \`kraftwerk projects\`.`));
58
- process.exit(2);
31
+ const linkSummary = (p) => LINK_KINDS.map((k) => (p[k].length ? `${p[k].length} ${k}` : ""))
32
+ .filter(Boolean)
33
+ .join(", ");
34
+ function printDetail(p) {
35
+ console.log(`${chalk.bold(p.title)} ${chalk.dim(`(${p.slug}, ${p.status})`)}`);
36
+ console.log(p.goal ? p.goal : chalk.dim("(no goal yet)"));
37
+ console.log(chalk.dim(`runs on ${p.harness}${p.model ? ` · ${p.model}` : ""}${p.effort ? ` · effort ${p.effort}` : ""}`));
38
+ console.log(chalk.dim(p.path));
39
+ if (p.configError)
40
+ console.log(chalk.red(`project.yml: ${p.configError}`));
41
+ if (p.records.length) {
42
+ console.log(`\n${chalk.bold("systems of record")}`);
43
+ for (const r of p.records)
44
+ console.log(recordLine(r));
45
+ }
46
+ for (const kind of LINK_KINDS) {
47
+ if (!p.links[kind].length)
48
+ continue;
49
+ console.log(`\n${chalk.bold(kind)}`);
50
+ for (const l of p.links[kind]) {
51
+ console.log(l.found ? ` ${chalk.green("✔")} ${l.slug}${l.label ? chalk.dim(` ${l.label}`) : ""}` : ` ${chalk.yellow("?")} ${l.slug} ${chalk.dim("(not found)")}`);
52
+ }
59
53
  }
60
- console.error(chalk.red(`"${ref}" is ambiguous — use the root path or port:`));
61
- for (const h of hits)
62
- console.error(` ${h.root ?? h.url}`);
63
- process.exit(2);
54
+ if (p.state.trim())
55
+ console.log(`\n${chalk.bold("state.md")}\n${p.state.trim()}`);
56
+ if (p.log.trim())
57
+ console.log(`\n${chalk.bold("log.md")}\n${p.log.trim()}`);
64
58
  }
65
59
  export function registerProjectCommands(program) {
66
60
  const projects = program
67
61
  .command("projects")
68
- .description("Known kraftwerk projects on this machine: list, start a stopped UI, stop a running one, forget");
62
+ .description("Projects: a goal with its brief, systems of record and links, worked on in chat — list, create, link, log");
69
63
  projects
70
64
  .command("list", { isDefault: true })
71
- .description("List known projects with their root and whether the UI is running")
65
+ .description("List the projects under the projects root")
72
66
  .option("--json", "Machine-readable output")
73
67
  .action(async (opts) => {
74
- const entries = await discoverWorkspaces();
68
+ await prepare();
69
+ const view = await listProjects();
75
70
  if (opts.json) {
76
- console.log(JSON.stringify(entries, null, 2));
71
+ console.log(JSON.stringify(view, null, 2));
77
72
  return;
78
73
  }
79
- if (entries.length === 0) {
80
- console.log(chalk.dim("No projects known yet — `kraftwerk ui` registers the current one."));
74
+ if (!view.enabled)
75
+ die(view.error ?? "projects are off");
76
+ if (view.projects.length === 0) {
77
+ console.log(chalk.dim(`No projects under ${view.root} yet — \`kraftwerk projects create "<title>"\` starts one.`));
81
78
  return;
82
79
  }
83
80
  const table = new Table({
84
- head: ["project", "root", "status", "last started"].map((h) => chalk.bold(h)),
81
+ head: ["project", "status", "goal", "links", "changed"].map((h) => chalk.bold(h)),
85
82
  wordWrap: true,
86
- colWidths: [22, 48, 24, 14],
83
+ colWidths: [26, 10, 40, 24, 14],
87
84
  });
88
- for (const e of entries) {
85
+ for (const p of view.projects) {
89
86
  table.push([
90
- `${e.icon ? e.icon + " " : ""}${chalk.cyan(e.name)}`,
91
- e.rootLabel ?? chalk.dim("(no root recorded)"),
92
- status(e),
93
- fmtAgo(e.lastStarted),
87
+ `${chalk.cyan(p.title)}\n${chalk.dim(p.slug)}`,
88
+ p.status === "active" ? chalk.green(p.status) : chalk.dim(p.status),
89
+ p.configError ? chalk.red(p.configError) : p.goal || chalk.dim("—"),
90
+ chalk.dim(linkSummary(p) || "—"),
91
+ chalk.dim(fmtAgo(p.updatedAt)),
94
92
  ]);
95
93
  }
96
94
  console.log(table.toString());
95
+ console.log(chalk.dim(view.root ?? ""));
97
96
  });
98
97
  projects
99
- .command("start")
100
- .description("Relaunch the UI of a stopped project (detached; logs in ~/.kraftwerk/logs)")
101
- .argument("<ref>", "Project name, folder name, or root path")
102
- .action(async (ref) => {
103
- const entry = await resolveRef(ref);
104
- if (entry.live) {
105
- console.log(`${chalk.green("✔")} ${entry.name} already running: ${chalk.cyan(entry.url)}`);
106
- return;
98
+ .command("create")
99
+ .description("Create a project folder from the starter (project.yml, brief.md, state.md, log.md)")
100
+ .argument("<title>", "Display title; the folder name is derived from it unless --slug is given")
101
+ .option("--goal <text>", "One-line goal")
102
+ .option("--slug <slug>", "Folder name under the root (lowercase, digits, dashes)")
103
+ .option("--harness <harness>", "Harness every chat in the project runs on: claude | codex | pi (default: claude)")
104
+ .option("--model <model>", "Model for that harness (default: the harness default)")
105
+ .option("--effort <effort>", "Reasoning effort: low | medium | high | xhigh | max")
106
+ .option("--json", "Print the new project as JSON")
107
+ .action(async (title, opts) => {
108
+ await prepare();
109
+ try {
110
+ const p = await createProject({ title, goal: opts.goal, slug: opts.slug, harness: opts.harness, model: opts.model, effort: opts.effort });
111
+ if (opts.json)
112
+ console.log(JSON.stringify(p, null, 2));
113
+ else
114
+ console.log(`${chalk.green("✔")} ${chalk.cyan(p.slug)} → ${p.path} ${chalk.dim("(open it on the Projects screen, or `kraftwerk projects show " + p.slug + "`)")}`);
107
115
  }
108
- const r = await startProject(entry.root);
109
- if (!r.ok) {
110
- console.error(chalk.red(`Could not start ${entry.name}: ${r.error}`));
111
- if (r.log)
112
- console.error(chalk.dim(`log: ${tildify(r.log)}`));
113
- process.exit(1);
116
+ catch (err) {
117
+ die(err.message);
114
118
  }
115
- console.log(`${chalk.green("✔")} ${entry.name} ${r.live ? "running" : "starting"}: ${chalk.cyan(r.url)}` +
116
- chalk.dim(` (pid ${r.pid}${r.log ? `, log ${tildify(r.log)}` : ""})`));
117
119
  });
118
120
  projects
119
- .command("stop")
120
- .description("Stop a running UI (SIGTERM to its server; works for terminal-started ones too)")
121
- .argument("<ref>", "Project name, folder name, root path, or port")
122
- .action(async (ref) => {
123
- const entry = await resolveRef(ref, false);
124
- if (!entry.live) {
125
- console.log(chalk.dim(`${entry.name} is not running.`));
126
- return;
121
+ .command("show")
122
+ .description("Show one project: definition, records, links with their state, state.md and log.md")
123
+ .argument("<slug>", "Folder name under the root")
124
+ .option("--json", "Machine-readable output")
125
+ .action(async (slug, opts) => {
126
+ await prepare();
127
+ try {
128
+ const p = await getProject(slug);
129
+ if (!p)
130
+ die(`no project "${slug}"`);
131
+ if (opts.json)
132
+ console.log(JSON.stringify(p, null, 2));
133
+ else
134
+ printDetail(p);
135
+ }
136
+ catch (err) {
137
+ die(err.message);
138
+ }
139
+ });
140
+ projects
141
+ .command("set")
142
+ .description("Change the project's harness, model, effort, status or goal (like an agent's settings)")
143
+ .argument("<slug>", "Project folder name")
144
+ .option("--harness <harness>", "claude | codex | pi")
145
+ .option("--model <model>", 'Model for the harness; "" for the default')
146
+ .option("--effort <effort>", 'low | medium | high | xhigh | max; "" for the default')
147
+ .option("--status <status>", "active | paused | done | archived")
148
+ .option("--goal <text>", "One-line goal")
149
+ .option("--json", "Print the project as JSON")
150
+ .action(async (slug, opts) => {
151
+ await prepare();
152
+ const { json: asJson, ...fields } = opts;
153
+ if (Object.values(fields).every((v) => v === undefined))
154
+ die("nothing to set — pass --harness, --model, --effort, --status or --goal");
155
+ try {
156
+ const p = await saveProject(slug, fields);
157
+ if (asJson)
158
+ console.log(JSON.stringify(p, null, 2));
159
+ else
160
+ console.log(`${chalk.green("✔")} ${chalk.cyan(p.slug)} runs on ${p.harness}${p.model ? ` · ${p.model}` : ""}${p.effort ? ` · effort ${p.effort}` : ""} ${chalk.dim(`(${p.status})`)}`);
161
+ }
162
+ catch (err) {
163
+ die(err.message);
164
+ }
165
+ });
166
+ for (const verb of ["link", "unlink"]) {
167
+ projects
168
+ .command(verb)
169
+ .description(verb === "link" ? "Add a linked knowledge bundle, vibeable, repository, workflow or agent" : "Remove a link")
170
+ .argument("<slug>", "Project folder name")
171
+ .argument("<kind>", `One of ${LINK_KINDS.join(", ")}`)
172
+ .argument("<name>", "The target's name (bundle, folder, workflow or agent slug)")
173
+ .option("--json", "Print the project as JSON")
174
+ .action(async (slug, kind, name, opts) => {
175
+ await prepare();
176
+ try {
177
+ const p = await linkProject(slug, kind, name, verb === "unlink");
178
+ if (opts.json)
179
+ console.log(JSON.stringify(p, null, 2));
180
+ else {
181
+ const state = p.links[kind]?.find((l) => l.slug === name);
182
+ const note = verb === "link" && state && !state.found ? chalk.yellow(" (not found in this workspace yet)") : "";
183
+ console.log(`${chalk.green("✔")} ${verb === "link" ? "linked" : "unlinked"} ${kind} ${chalk.cyan(name)} ${verb === "link" ? "to" : "from"} ${p.slug}${note}`);
184
+ }
185
+ }
186
+ catch (err) {
187
+ die(err.message);
188
+ }
189
+ });
190
+ }
191
+ projects
192
+ .command("log")
193
+ .description("Append a dated, attributed line to the project's log.md (newest first)")
194
+ .argument("<slug>", "Project folder name")
195
+ .argument("[entry]", "The line to record; omit with --file")
196
+ .option("--file <path>", "Read the entry from a file")
197
+ .option("--actor <actor>", "Who writes: human:<id>, <agent>/<harness>, process:<id>", "human:user")
198
+ .action(async (slug, entry, opts) => {
199
+ await prepare();
200
+ try {
201
+ const text = opts.file ? await readFile(opts.file, "utf8") : entry ?? "";
202
+ await appendProjectLog(slug, text, opts.actor);
203
+ console.log(`${chalk.green("✔")} logged to ${chalk.cyan(slug)} ${chalk.dim(`(${opts.actor})`)}`);
127
204
  }
128
- const r = await stopProject({ root: entry.root, url: entry.url });
129
- if (!r.ok) {
130
- console.error(chalk.red(`Could not stop ${entry.name}: ${r.error}`));
131
- process.exit(1);
205
+ catch (err) {
206
+ die(err.message);
132
207
  }
133
- console.log(`${chalk.green("✔")} ${entry.name} stopped ${chalk.dim(`(${entry.url.replace(/^https?:\/\//, "")})`)}`);
134
208
  });
135
209
  projects
136
- .command("forget")
137
- .description("Remove a project from the registry (its folder is untouched)")
138
- .argument("<ref>", "Project name, folder name, or root path")
139
- .action(async (ref) => {
140
- const entry = await resolveRef(ref);
141
- if (await forgetProject(entry.root)) {
142
- console.log(`${chalk.green("✔")} forgot ${entry.name} (${entry.rootLabel})`);
210
+ .command("remove")
211
+ .description("Delete a project folder; its history stays in the workspace git")
212
+ .argument("<slug>", "Folder name under the root")
213
+ .action(async (slug) => {
214
+ await prepare();
215
+ try {
216
+ await deleteProject(slug);
217
+ console.log(`${chalk.green("✔")} removed ${chalk.cyan(slug)}`);
143
218
  }
144
- else {
145
- console.error(chalk.red(`No record for ${entry.root}`));
146
- process.exit(1);
219
+ catch (err) {
220
+ die(err.message);
147
221
  }
148
222
  });
149
223
  }
package/dist/cli/repos.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import chalk from "chalk";
2
2
  import Table from "cli-table3";
3
3
  import { addRepo, listRepos, removeRepo, updateRepo } from "../inspector/repos.js";
4
- import { fmtAgo } from "./projects.js";
4
+ import { fmtAgo } from "./workspaces.js";
5
5
  import { initContext as prepare } from "./routines.js";
6
6
  /**
7
7
  * `kraftwerk repos` — the git repositories the agents work on, kept under
package/dist/cli/ui.js CHANGED
@@ -69,7 +69,7 @@ export async function runUi(cwd, opts) {
69
69
  * local node_modules, npx, and a file: dev checkout. Any exit other than
70
70
  * RESTART_EXIT_CODE ends the loop. A SIGTERM/SIGINT to the supervisor is
71
71
  * forwarded to the server, so `kill <supervisor pid>` (what `kraftwerk
72
- * projects start` reports) takes the whole UI down; Ctrl-C signals the
72
+ * workspaces start` reports) takes the whole UI down; Ctrl-C signals the
73
73
  * foreground group and reaches both anyway.
74
74
  */
75
75
  async function superviseUi(cwd, opts) {
@@ -1,7 +1,7 @@
1
1
  import chalk from "chalk";
2
2
  import Table from "cli-table3";
3
3
  import { createVibeable, deleteVibeable, listVibeables } from "../inspector/vibeables.js";
4
- import { fmtAgo } from "./projects.js";
4
+ import { fmtAgo } from "./workspaces.js";
5
5
  import { initContext as prepare } from "./routines.js";
6
6
  /**
7
7
  * `kraftwerk vibeables` — the small apps built live in a chat, one folder
@@ -0,0 +1,16 @@
1
+ import type { Command } from "commander";
2
+ /**
3
+ * `kraftwerk workspaces` — every workspace that ever ran the inspector on this
4
+ * machine (~/.kraftwerk/workspaces), joined with what is running right now.
5
+ * The answer to "where did I start that UI, and how do I get it back":
6
+ *
7
+ * kraftwerk workspaces list: name, root, running/stopped
8
+ * kraftwerk workspaces start <ref> relaunch `kraftwerk ui` in that root, detached
9
+ * kraftwerk workspaces stop <ref> SIGTERM a running UI (also ones started in a terminal)
10
+ * kraftwerk workspaces forget <ref> drop the record (the folder stays)
11
+ *
12
+ * <ref> is a workspace name, the root's folder name, the root path, or a
13
+ * port / localhost:port for running instances that recorded no root.
14
+ */
15
+ export declare const fmtAgo: (iso?: string) => string;
16
+ export declare function registerWorkspaceCommands(program: Command): void;