@netnodeag/kraftwerk 0.47.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 (50) hide show
  1. package/README.md +106 -5
  2. package/dist/cli/doctor.js +18 -1
  3. package/dist/cli/init.js +10 -8
  4. package/dist/cli/kraftwerk.js +16 -2
  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/dotenv.d.ts +40 -0
  15. package/dist/dotenv.js +84 -0
  16. package/dist/inspector/agents.js +2 -2
  17. package/dist/inspector/channels.d.ts +10 -1
  18. package/dist/inspector/channels.js +11 -2
  19. package/dist/inspector/chat/sessions.d.ts +8 -3
  20. package/dist/inspector/chat/sessions.js +58 -19
  21. package/dist/inspector/chat/types.d.ts +11 -0
  22. package/dist/inspector/git.js +5 -3
  23. package/dist/inspector/instances.d.ts +14 -12
  24. package/dist/inspector/instances.js +71 -31
  25. package/dist/inspector/notifications.js +2 -0
  26. package/dist/inspector/projects.d.ts +167 -0
  27. package/dist/inspector/projects.js +520 -0
  28. package/dist/inspector/search.d.ts +1 -1
  29. package/dist/inspector/search.js +3 -3
  30. package/dist/inspector/server.js +111 -20
  31. package/dist/inspector/settings.d.ts +3 -1
  32. package/dist/inspector/settings.js +14 -3
  33. package/inspector/dist/assets/{dist-D8pjVMTl.js → dist-B-8ArbIm.js} +1 -1
  34. package/inspector/dist/assets/{dist-DfAFwiGQ.js → dist-BFJWVdBm.js} +1 -1
  35. package/inspector/dist/assets/{dist-C2Mu5bT-.js → dist-BIvcKBWo.js} +1 -1
  36. package/inspector/dist/assets/{dist-CdyUhzAt.js → dist-BgYnJawU.js} +1 -1
  37. package/inspector/dist/assets/dist-Bl1ogZZM.js +1 -0
  38. package/inspector/dist/assets/{dist-BNUGzZS7.js → dist-BrgwFqUR.js} +1 -1
  39. package/inspector/dist/assets/{dist-DnmDnMK2.js → dist-Bz0bwjja.js} +1 -1
  40. package/inspector/dist/assets/{dist-CjMETDfE.js → dist-CFvGR87v.js} +1 -1
  41. package/inspector/dist/assets/{dist-CUeUDZ9j.js → dist-CjtxxsZ2.js} +1 -1
  42. package/inspector/dist/assets/{dist-CmOlZvwa.js → dist-QwwTJ2wk.js} +1 -1
  43. package/inspector/dist/assets/{dist-BOQhQPN9.js → dist-tfmgozDB.js} +1 -1
  44. package/inspector/dist/assets/{editor-DJbeG376.js → editor-C6m40u-h.js} +3 -3
  45. package/inspector/dist/assets/{index-DgEUOA11.js → index-Cvq-RiV2.js} +14 -14
  46. package/inspector/dist/assets/{index-DQrsXEWt.css → index-DcQwGaV2.css} +1 -1
  47. package/inspector/dist/index.html +2 -2
  48. package/package.json +1 -1
  49. package/schema/kraftwerk.schema.json +18 -0
  50. package/inspector/dist/assets/dist-rrIMDK_-.js +0 -1
@@ -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;
@@ -0,0 +1,149 @@
1
+ import path from "node:path";
2
+ import chalk from "chalk";
3
+ import { absolutePath } from "../config.js";
4
+ import Table from "cli-table3";
5
+ import { discoverWorkspaces, forgetWorkspace, startWorkspace, stopWorkspace, tildify, } from "../inspector/instances.js";
6
+ /**
7
+ * `kraftwerk workspaces` — every workspace that ever ran the inspector on this
8
+ * machine (~/.kraftwerk/workspaces), joined with what is running right now.
9
+ * The answer to "where did I start that UI, and how do I get it back":
10
+ *
11
+ * kraftwerk workspaces list: name, root, running/stopped
12
+ * kraftwerk workspaces start <ref> relaunch `kraftwerk ui` in that root, detached
13
+ * kraftwerk workspaces stop <ref> SIGTERM a running UI (also ones started in a terminal)
14
+ * kraftwerk workspaces forget <ref> drop the record (the folder stays)
15
+ *
16
+ * <ref> is a workspace name, the root's folder name, the root path, or a
17
+ * port / localhost:port for running instances that recorded no root.
18
+ */
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`;
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 workspace "${ref}". See \`kraftwerk workspaces\`.`));
58
+ process.exit(2);
59
+ }
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);
64
+ }
65
+ export function registerWorkspaceCommands(program) {
66
+ const workspaces = program
67
+ .command("workspaces")
68
+ .description("Known kraftwerk workspaces on this machine: list, start a stopped UI, stop a running one, forget");
69
+ workspaces
70
+ .command("list", { isDefault: true })
71
+ .description("List known workspaces with their root and whether the UI is running")
72
+ .option("--json", "Machine-readable output")
73
+ .action(async (opts) => {
74
+ const entries = await discoverWorkspaces();
75
+ if (opts.json) {
76
+ console.log(JSON.stringify(entries, null, 2));
77
+ return;
78
+ }
79
+ if (entries.length === 0) {
80
+ console.log(chalk.dim("No workspaces known yet — `kraftwerk ui` registers the current one."));
81
+ return;
82
+ }
83
+ const table = new Table({
84
+ head: ["workspace", "root", "status", "last started"].map((h) => chalk.bold(h)),
85
+ wordWrap: true,
86
+ colWidths: [22, 48, 24, 14],
87
+ });
88
+ for (const e of entries) {
89
+ 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),
94
+ ]);
95
+ }
96
+ console.log(table.toString());
97
+ });
98
+ workspaces
99
+ .command("start")
100
+ .description("Relaunch the UI of a stopped workspace (detached; logs in ~/.kraftwerk/logs)")
101
+ .argument("<ref>", "Workspace 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;
107
+ }
108
+ const r = await startWorkspace(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);
114
+ }
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
+ });
118
+ workspaces
119
+ .command("stop")
120
+ .description("Stop a running UI (SIGTERM to its server; works for terminal-started ones too)")
121
+ .argument("<ref>", "Workspace 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;
127
+ }
128
+ const r = await stopWorkspace({ 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);
132
+ }
133
+ console.log(`${chalk.green("✔")} ${entry.name} stopped ${chalk.dim(`(${entry.url.replace(/^https?:\/\//, "")})`)}`);
134
+ });
135
+ workspaces
136
+ .command("forget")
137
+ .description("Remove a workspace from the registry (its folder is untouched)")
138
+ .argument("<ref>", "Workspace name, folder name, or root path")
139
+ .action(async (ref) => {
140
+ const entry = await resolveRef(ref);
141
+ if (await forgetWorkspace(entry.root)) {
142
+ console.log(`${chalk.green("✔")} forgot ${entry.name} (${entry.rootLabel})`);
143
+ }
144
+ else {
145
+ console.error(chalk.red(`No record for ${entry.root}`));
146
+ process.exit(1);
147
+ }
148
+ });
149
+ }
package/dist/config.d.ts CHANGED
@@ -29,7 +29,9 @@
29
29
  * repos: # git repositories the agents work on (absent = off, bare key = on)
30
30
  * root: kraftwerk-data/repos # where clones land, relative to the file. Default: repos
31
31
  * vibeables: # small apps built live in a chat, rendered in the inspector (absent = off, bare key = on)
32
- * root: apps # one folder per app, part of the workspace. Default: kraftwerk-data/vibeables
32
+ * root: apps # one folder per app, part of the workspace. Default: kraftwerk-data/vibeables
33
+ * projects: # goal-scoped folders (brief, systems of record, links) that chats work in (absent = off, bare key = on)
34
+ * root: kraftwerk-data/projects # one folder per project, part of the workspace. Default: kraftwerk-data/projects
33
35
  * public: https://kw.example.com # hostname the inspector is reached at through a tunnel or reverse proxy
34
36
  * tunnel: # Cloudflare Tunnel run by `kraftwerk ui` (absent = off, bare key = on)
35
37
  * name: kraftwerk # locally-managed tunnel (cloudflared tunnel create); absent: TUNNEL_TOKEN env, dashboard-managed
@@ -102,6 +104,23 @@ export interface VibeablesConfig {
102
104
  export declare const VIBEABLES_DEFAULT_ROOT = "kraftwerk-data/vibeables";
103
105
  /** Absolute vibeables root when the feature is on, undefined otherwise. */
104
106
  export declare function vibeablesRootFor(project: Project): string | undefined;
107
+ /**
108
+ * Projects: a goal with everything the agents need to reach it in one
109
+ * folder — a brief, the systems of record where the truth lives, and links
110
+ * to the workspace's knowledge, vibeables, repositories, workflows and
111
+ * agents. Each is one folder under the root; part of the workspace and
112
+ * synced by the workspace git like agents and knowledge.
113
+ */
114
+ export interface ProjectsConfig {
115
+ /** false keeps the block but turns the feature off. Default: true. */
116
+ enabled?: boolean;
117
+ /** Where the projects live, relative to the project root. Default: kraftwerk-data/projects */
118
+ root?: string;
119
+ }
120
+ /** Where projects live when `projects.root` is not set. */
121
+ export declare const PROJECTS_DEFAULT_ROOT = "kraftwerk-data/projects";
122
+ /** Absolute projects root when the feature is on, undefined otherwise. */
123
+ export declare function projectsRootFor(project: Project): string | undefined;
105
124
  /**
106
125
  * Cloudflare Access in front of the public hostname. Access puts a login
107
126
  * page on the hostname at Cloudflare's edge and hands the origin a signed
@@ -183,6 +202,8 @@ export interface ProjectConfig {
183
202
  repos?: ReposConfig;
184
203
  /** Vibeables: apps built live in a chat. Absent = off. */
185
204
  vibeables?: VibeablesConfig;
205
+ /** Projects: goal-scoped folders the chats work in. Absent = off. */
206
+ projects?: ProjectsConfig;
186
207
  /**
187
208
  * Hostname the inspector is reached at through a tunnel or reverse proxy,
188
209
  * e.g. "https://kw.example.com". The loopback bind then answers requests
package/dist/config.js CHANGED
@@ -33,7 +33,9 @@ import { parse } from "yaml";
33
33
  * repos: # git repositories the agents work on (absent = off, bare key = on)
34
34
  * root: kraftwerk-data/repos # where clones land, relative to the file. Default: repos
35
35
  * vibeables: # small apps built live in a chat, rendered in the inspector (absent = off, bare key = on)
36
- * root: apps # one folder per app, part of the workspace. Default: kraftwerk-data/vibeables
36
+ * root: apps # one folder per app, part of the workspace. Default: kraftwerk-data/vibeables
37
+ * projects: # goal-scoped folders (brief, systems of record, links) that chats work in (absent = off, bare key = on)
38
+ * root: kraftwerk-data/projects # one folder per project, part of the workspace. Default: kraftwerk-data/projects
37
39
  * public: https://kw.example.com # hostname the inspector is reached at through a tunnel or reverse proxy
38
40
  * tunnel: # Cloudflare Tunnel run by `kraftwerk ui` (absent = off, bare key = on)
39
41
  * name: kraftwerk # locally-managed tunnel (cloudflared tunnel create); absent: TUNNEL_TOKEN env, dashboard-managed
@@ -84,6 +86,15 @@ export function vibeablesRootFor(project) {
84
86
  return undefined;
85
87
  return path.resolve(project.root, v.root ?? VIBEABLES_DEFAULT_ROOT);
86
88
  }
89
+ /** Where projects live when `projects.root` is not set. */
90
+ export const PROJECTS_DEFAULT_ROOT = "kraftwerk-data/projects";
91
+ /** Absolute projects root when the feature is on, undefined otherwise. */
92
+ export function projectsRootFor(project) {
93
+ const p = project.config.projects;
94
+ if (!p || p.enabled === false)
95
+ return undefined;
96
+ return path.resolve(project.root, p.root ?? PROJECTS_DEFAULT_ROOT);
97
+ }
87
98
  /** The public hostname (lowercase, no port) when `public` is set, undefined otherwise. */
88
99
  export function publicHostFor(project) {
89
100
  return project.config.public ? parsePublic(project.config.public)?.hostname : undefined;
@@ -187,7 +198,7 @@ export async function resolveProject(cwd) {
187
198
  const root = gitFallback ?? start;
188
199
  return { root, config: {}, outputDir: path.join(root, "output") };
189
200
  }
190
- const KNOWN_KEYS = ["name", "icon", "color", "port", "workflows", "output", "knowledge", "agents", "skills", "switcher", "git", "repos", "vibeables", "public", "tunnel"];
201
+ const KNOWN_KEYS = ["name", "icon", "color", "port", "workflows", "output", "knowledge", "agents", "skills", "switcher", "git", "repos", "vibeables", "projects", "public", "tunnel"];
191
202
  async function loadConfig(configPath) {
192
203
  let raw;
193
204
  try {
@@ -238,6 +249,11 @@ async function loadConfig(configPath) {
238
249
  config[key] = {};
239
250
  validateRootBlock(configPath, "vibeables", config[key]);
240
251
  }
252
+ else if (key === "projects") {
253
+ if (config[key] === null)
254
+ config[key] = {};
255
+ validateRootBlock(configPath, "projects", config[key]);
256
+ }
241
257
  else if (key === "public") {
242
258
  if (typeof config[key] !== "string" || !parsePublic(config[key])) {
243
259
  throw new Error(`${path.basename(configPath)}: public must be a hostname or https URL like "https://kw.example.com"`);
@@ -353,7 +369,7 @@ function validateGit(configPath, value) {
353
369
  }
354
370
  }
355
371
  /**
356
- * `repos` and `vibeables` share one shape: { enabled?, root? }. The root
372
+ * `repos`, `vibeables` and `projects` share one shape: { enabled?, root? }. The root
357
373
  * must never be the project itself or anything outside it — it is what
358
374
  * remove/delete operate under, and what the workspace git may stage.
359
375
  */