@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.
- package/README.md +103 -7
- package/dist/cli/doctor.js +7 -1
- package/dist/cli/init.js +1 -0
- package/dist/cli/kraftwerk.js +5 -1
- package/dist/cli/projects.d.ts +0 -14
- package/dist/cli/projects.js +181 -107
- package/dist/cli/repos.js +1 -1
- package/dist/cli/ui.js +1 -1
- package/dist/cli/vibeables.js +1 -1
- package/dist/cli/workspaces.d.ts +16 -0
- package/dist/cli/workspaces.js +149 -0
- package/dist/config.d.ts +22 -1
- package/dist/config.js +19 -3
- package/dist/inspector/agents.js +2 -2
- package/dist/inspector/channels.d.ts +10 -1
- package/dist/inspector/channels.js +11 -2
- package/dist/inspector/chat/sessions.d.ts +8 -3
- package/dist/inspector/chat/sessions.js +58 -19
- package/dist/inspector/chat/types.d.ts +11 -0
- package/dist/inspector/git.d.ts +51 -0
- package/dist/inspector/git.js +22 -12
- package/dist/inspector/instances.d.ts +14 -12
- package/dist/inspector/instances.js +71 -31
- package/dist/inspector/notifications.js +2 -0
- package/dist/inspector/projects.d.ts +167 -0
- package/dist/inspector/projects.js +520 -0
- package/dist/inspector/repos.d.ts +47 -0
- package/dist/inspector/repos.js +121 -2
- package/dist/inspector/search.d.ts +1 -1
- package/dist/inspector/search.js +3 -3
- package/dist/inspector/server.js +149 -21
- package/dist/inspector/settings.d.ts +3 -1
- package/dist/inspector/settings.js +14 -3
- package/inspector/dist/assets/dist-BB5opUKX.js +1 -0
- package/inspector/dist/assets/{dist-Dv40dCIn.js → dist-BV28M6sp.js} +1 -1
- package/inspector/dist/assets/{dist-BNGsfwfO.js → dist-Bi4ZTD6W.js} +1 -1
- package/inspector/dist/assets/{dist-n8GR1Slo.js → dist-BuqvesGh.js} +1 -1
- package/inspector/dist/assets/{dist-O97r5Ket.js → dist-CLSTyUdp.js} +1 -1
- package/inspector/dist/assets/{dist-D6fPO3Jz.js → dist-Ca8OxgWP.js} +1 -1
- package/inspector/dist/assets/{dist-DUq31xBw.js → dist-DGB-RjHI.js} +1 -1
- package/inspector/dist/assets/{dist-Bz3XVWXl.js → dist-DHS6DxTY.js} +1 -1
- package/inspector/dist/assets/{dist-Dk5ZcwMb.js → dist-DVWyuKcp.js} +1 -1
- package/inspector/dist/assets/{dist-BWkBM49t.js → dist-DkEzjj-j.js} +1 -1
- package/inspector/dist/assets/{dist-CmH9PvLb.js → dist-Dv003C77.js} +1 -1
- package/inspector/dist/assets/{editor-toVFopCO.js → editor-B5WUiag9.js} +3 -3
- package/inspector/dist/assets/{index-CC0tY40i.js → index-BfiWjmwA.js} +11 -11
- package/inspector/dist/assets/{index-DQrsXEWt.css → index-DCpGWY1g.css} +1 -1
- package/inspector/dist/index.html +2 -2
- package/package.json +1 -1
- package/schema/kraftwerk.schema.json +18 -0
- package/inspector/dist/assets/dist-JaBY_kZV.js +0 -1
package/dist/inspector/git.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
64
|
-
export declare const
|
|
65
|
-
export declare const
|
|
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
|
|
68
|
-
/** All known
|
|
69
|
-
export declare function
|
|
70
|
-
/** Drop a
|
|
71
|
-
export declare function
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
35
|
-
const
|
|
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
|
-
/* ----------
|
|
136
|
-
|
|
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
|
|
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(
|
|
161
|
-
const prev = await
|
|
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(
|
|
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
|
|
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 =
|
|
187
|
-
const rec = await
|
|
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
|
|
196
|
-
export const
|
|
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
|
|
233
|
+
export function markWorkspaceStopped() {
|
|
199
234
|
if (!selfRoot)
|
|
200
235
|
return;
|
|
201
236
|
try {
|
|
202
|
-
const file =
|
|
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
|
|
210
|
-
export async function
|
|
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(
|
|
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) =>
|
|
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
|
|
224
|
-
export async function
|
|
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(
|
|
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([
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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>;
|