@netnodeag/kraftwerk 0.48.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -5
- 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.js +5 -3
- 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/search.d.ts +1 -1
- package/dist/inspector/search.js +3 -3
- package/dist/inspector/server.js +111 -20
- package/dist/inspector/settings.d.ts +3 -1
- package/dist/inspector/settings.js +14 -3
- package/inspector/dist/assets/{dist-Dk5ZcwMb.js → dist-B-8ArbIm.js} +1 -1
- package/inspector/dist/assets/{dist-DUq31xBw.js → dist-BFJWVdBm.js} +1 -1
- package/inspector/dist/assets/{dist-D6fPO3Jz.js → dist-BIvcKBWo.js} +1 -1
- package/inspector/dist/assets/{dist-n8GR1Slo.js → dist-BgYnJawU.js} +1 -1
- package/inspector/dist/assets/dist-Bl1ogZZM.js +1 -0
- package/inspector/dist/assets/{dist-O97r5Ket.js → dist-BrgwFqUR.js} +1 -1
- package/inspector/dist/assets/{dist-CmH9PvLb.js → dist-Bz0bwjja.js} +1 -1
- package/inspector/dist/assets/{dist-Dv40dCIn.js → dist-CFvGR87v.js} +1 -1
- package/inspector/dist/assets/{dist-Bz3XVWXl.js → dist-CjtxxsZ2.js} +1 -1
- package/inspector/dist/assets/{dist-BNGsfwfO.js → dist-QwwTJ2wk.js} +1 -1
- package/inspector/dist/assets/{dist-BWkBM49t.js → dist-tfmgozDB.js} +1 -1
- package/inspector/dist/assets/{editor-toVFopCO.js → editor-C6m40u-h.js} +3 -3
- package/inspector/dist/assets/{index-CC0tY40i.js → index-Cvq-RiV2.js} +11 -11
- package/inspector/dist/assets/{index-DQrsXEWt.css → index-DcQwGaV2.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
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse, stringify } from "yaml";
|
|
4
|
+
import { projectsRootFor, resolveProject } from "../config.js";
|
|
5
|
+
import { EFFORTS, HARNESSES, listAgents } from "./agents.js";
|
|
6
|
+
import { getProjectRoot } from "./context.js";
|
|
7
|
+
import { knowledgeIndex } from "./knowledge.js";
|
|
8
|
+
import { newestMtime } from "./mtime.js";
|
|
9
|
+
import { listRepos } from "./repos.js";
|
|
10
|
+
import { listVibeables } from "./vibeables.js";
|
|
11
|
+
import { listWorkflows } from "./workflows.js";
|
|
12
|
+
/**
|
|
13
|
+
* Projects: a goal with everything the agents need to reach it gathered in
|
|
14
|
+
* one folder — the brief, the systems of record where the truth is managed
|
|
15
|
+
* outside kraftwerk, and links to the workspace's knowledge, vibeables,
|
|
16
|
+
* repositories, workflows and agents. Working in a project is chat: a
|
|
17
|
+
* session scoped to it carries all of this as context (sessions.ts).
|
|
18
|
+
*
|
|
19
|
+
* One folder per project under the projects root (`projects.root` in
|
|
20
|
+
* kraftwerk.yml, default kraftwerk-data/projects/). Like agents and
|
|
21
|
+
* knowledge it is part of the workspace: versioned by the workspace git,
|
|
22
|
+
* no repository of its own. The folder is the registry — whatever
|
|
23
|
+
* directory under the root holds a project.yml is a project.
|
|
24
|
+
*
|
|
25
|
+
* projects/<slug>/project.yml # title, status, goal, harness/model/effort, records, links (slug lists)
|
|
26
|
+
* projects/<slug>/brief.md # the goal in full: what done looks like, constraints, stakeholders
|
|
27
|
+
* projects/<slug>/state.md # current state, rewritten at the end of a session
|
|
28
|
+
* projects/<slug>/log.md # append-only: decisions, milestones (newest first, stamped)
|
|
29
|
+
*
|
|
30
|
+
* Links are one-directional lists of slugs; a target that does not exist
|
|
31
|
+
* is reported ("configured but not found"), never an error — the same
|
|
32
|
+
* rule agent knowledge follows. A system of record is context, not a
|
|
33
|
+
* credential and not a grant: it says where the truth lives and how it is
|
|
34
|
+
* usually reached, and the harness decides what the agent may call.
|
|
35
|
+
*/
|
|
36
|
+
export const PROJECT_FILE = "project.yml";
|
|
37
|
+
export const PROJECT_STATUSES = ["active", "paused", "done", "archived"];
|
|
38
|
+
/** What kraftwerk knows about a record kind: a label and what such a place usually holds. */
|
|
39
|
+
export const RECORD_KINDS = {
|
|
40
|
+
"my-netnode": { label: "my.netnode.ch", hint: "tickets, roadmap items, meetings and workspace memory" },
|
|
41
|
+
"google-drive": { label: "Google Drive", hint: "documents and folders" },
|
|
42
|
+
github: { label: "GitHub", hint: "issues, pull requests and discussions" },
|
|
43
|
+
bitbucket: { label: "Bitbucket", hint: "pull requests and pipelines" },
|
|
44
|
+
notion: { label: "Notion", hint: "pages and databases" },
|
|
45
|
+
slack: { label: "Slack", hint: "conversations and decisions" },
|
|
46
|
+
url: { label: "Link", hint: "" },
|
|
47
|
+
};
|
|
48
|
+
export const LINK_KINDS = ["knowledge", "vibeables", "repos", "workflows", "agents"];
|
|
49
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,48}$/;
|
|
50
|
+
export function safeProjectSlug(slug) {
|
|
51
|
+
if (!SLUG_RE.test(slug))
|
|
52
|
+
throw new Error(`invalid project name "${slug}"`);
|
|
53
|
+
return slug;
|
|
54
|
+
}
|
|
55
|
+
/** Derive a slug from a title: "Relaunch netnode.ch" -> "relaunch-netnode-ch". */
|
|
56
|
+
export function projectSlugFromTitle(title) {
|
|
57
|
+
return title
|
|
58
|
+
.normalize("NFKD")
|
|
59
|
+
.replace(/[̀-ͯ]/g, "")
|
|
60
|
+
.toLowerCase()
|
|
61
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
62
|
+
.replace(/^-+|-+$/g, "")
|
|
63
|
+
.slice(0, 48)
|
|
64
|
+
.replace(/-+$/, "");
|
|
65
|
+
}
|
|
66
|
+
/* ---------- root ---------- */
|
|
67
|
+
export async function openProjects() {
|
|
68
|
+
let project;
|
|
69
|
+
try {
|
|
70
|
+
project = await resolveProject(getProjectRoot());
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
return { error: err.message };
|
|
74
|
+
}
|
|
75
|
+
const root = projectsRootFor(project);
|
|
76
|
+
if (!root)
|
|
77
|
+
return { off: true, error: "projects are off (enable them in settings or add `projects:` to kraftwerk.yml)" };
|
|
78
|
+
return { project, root };
|
|
79
|
+
}
|
|
80
|
+
/** Make sure the root exists; runs when the feature is switched on. */
|
|
81
|
+
export async function ensureProjectsRoot() {
|
|
82
|
+
const opened = await openProjects();
|
|
83
|
+
if (opened.root)
|
|
84
|
+
await fs.mkdir(opened.root, { recursive: true });
|
|
85
|
+
}
|
|
86
|
+
async function requireRoot() {
|
|
87
|
+
const opened = await openProjects();
|
|
88
|
+
if (!opened.root)
|
|
89
|
+
throw new Error(opened.error ?? "projects are off");
|
|
90
|
+
return opened.root;
|
|
91
|
+
}
|
|
92
|
+
/* ---------- files ---------- */
|
|
93
|
+
const strList = (v, field) => {
|
|
94
|
+
if (v === undefined || v === null)
|
|
95
|
+
return [];
|
|
96
|
+
if (!Array.isArray(v) || !v.every((x) => typeof x === "string"))
|
|
97
|
+
throw new Error(`${field} must be a list of names`);
|
|
98
|
+
return [...new Set(v.map((x) => x.trim()).filter(Boolean))];
|
|
99
|
+
};
|
|
100
|
+
const cleanRecords = (v) => {
|
|
101
|
+
if (v === undefined || v === null)
|
|
102
|
+
return [];
|
|
103
|
+
if (!Array.isArray(v))
|
|
104
|
+
throw new Error("records must be a list");
|
|
105
|
+
return v.map((r, i) => {
|
|
106
|
+
if (typeof r !== "object" || r === null || Array.isArray(r))
|
|
107
|
+
throw new Error(`records[${i}] must be a mapping (kind, title, url, workspace, note)`);
|
|
108
|
+
const o = r;
|
|
109
|
+
const kind = typeof o.kind === "string" ? o.kind.trim().toLowerCase() : "";
|
|
110
|
+
if (!kind)
|
|
111
|
+
throw new Error(`records[${i}]: kind is required (e.g. my-netnode, google-drive, url)`);
|
|
112
|
+
const str = (k) => {
|
|
113
|
+
const x = o[k];
|
|
114
|
+
if (x === undefined || x === null || x === "")
|
|
115
|
+
return undefined;
|
|
116
|
+
if (typeof x === "number")
|
|
117
|
+
return String(x);
|
|
118
|
+
if (typeof x !== "string")
|
|
119
|
+
throw new Error(`records[${i}].${k} must be a string`);
|
|
120
|
+
return x.trim() || undefined;
|
|
121
|
+
};
|
|
122
|
+
const url = str("url");
|
|
123
|
+
if (url && !/^https?:\/\//.test(url))
|
|
124
|
+
throw new Error(`records[${i}].url must start with http:// or https://`);
|
|
125
|
+
const out = { kind };
|
|
126
|
+
const title = str("title");
|
|
127
|
+
const workspace = str("workspace");
|
|
128
|
+
const note = str("note");
|
|
129
|
+
if (title)
|
|
130
|
+
out.title = title;
|
|
131
|
+
if (url)
|
|
132
|
+
out.url = url;
|
|
133
|
+
if (workspace)
|
|
134
|
+
out.workspace = workspace;
|
|
135
|
+
if (note)
|
|
136
|
+
out.note = note;
|
|
137
|
+
return out;
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
const cleanHarness = (v) => {
|
|
141
|
+
if (v === undefined || v === null || v === "")
|
|
142
|
+
return "claude";
|
|
143
|
+
if (typeof v !== "string" || !HARNESSES.includes(v))
|
|
144
|
+
throw new Error(`harness must be one of ${HARNESSES.join(", ")}`);
|
|
145
|
+
return v;
|
|
146
|
+
};
|
|
147
|
+
const cleanEffort = (v) => {
|
|
148
|
+
if (v === undefined || v === null || v === "")
|
|
149
|
+
return undefined;
|
|
150
|
+
if (typeof v !== "string" || !EFFORTS.includes(v))
|
|
151
|
+
throw new Error(`effort must be one of ${EFFORTS.join(", ")}`);
|
|
152
|
+
return v;
|
|
153
|
+
};
|
|
154
|
+
const cleanModel = (v) => {
|
|
155
|
+
if (v === undefined || v === null)
|
|
156
|
+
return undefined;
|
|
157
|
+
if (typeof v !== "string")
|
|
158
|
+
throw new Error("model must be a string");
|
|
159
|
+
return v.trim() || undefined;
|
|
160
|
+
};
|
|
161
|
+
const cleanStatus = (v) => {
|
|
162
|
+
if (v === undefined || v === null || v === "")
|
|
163
|
+
return "active";
|
|
164
|
+
if (typeof v !== "string" || !PROJECT_STATUSES.includes(v)) {
|
|
165
|
+
throw new Error(`status must be one of ${PROJECT_STATUSES.join(", ")}`);
|
|
166
|
+
}
|
|
167
|
+
return v;
|
|
168
|
+
};
|
|
169
|
+
/** Parse project.yml text into a definition; every problem is one error the caller can show. */
|
|
170
|
+
function parseDef(slug, raw) {
|
|
171
|
+
const data = parse(raw);
|
|
172
|
+
if (data === null || data === undefined)
|
|
173
|
+
return { ...emptyDef(slug) };
|
|
174
|
+
if (typeof data !== "object" || Array.isArray(data))
|
|
175
|
+
throw new Error("project.yml must be a mapping");
|
|
176
|
+
const d = data;
|
|
177
|
+
const title = typeof d.title === "string" && d.title.trim() ? d.title.trim() : slug;
|
|
178
|
+
const goal = typeof d.goal === "string" ? d.goal.trim() : d.goal === undefined || d.goal === null ? "" : String(d.goal);
|
|
179
|
+
const model = cleanModel(d.model);
|
|
180
|
+
const effort = cleanEffort(d.effort);
|
|
181
|
+
return {
|
|
182
|
+
slug,
|
|
183
|
+
title,
|
|
184
|
+
status: cleanStatus(d.status),
|
|
185
|
+
goal,
|
|
186
|
+
harness: cleanHarness(d.harness),
|
|
187
|
+
...(model ? { model } : {}),
|
|
188
|
+
...(effort ? { effort } : {}),
|
|
189
|
+
records: cleanRecords(d.records),
|
|
190
|
+
knowledge: strList(d.knowledge, "knowledge"),
|
|
191
|
+
vibeables: strList(d.vibeables, "vibeables"),
|
|
192
|
+
repos: strList(d.repos, "repos"),
|
|
193
|
+
workflows: strList(d.workflows, "workflows"),
|
|
194
|
+
agents: strList(d.agents, "agents"),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
const emptyDef = (slug) => ({
|
|
198
|
+
slug,
|
|
199
|
+
title: slug,
|
|
200
|
+
status: "active",
|
|
201
|
+
goal: "",
|
|
202
|
+
harness: "claude",
|
|
203
|
+
records: [],
|
|
204
|
+
knowledge: [],
|
|
205
|
+
vibeables: [],
|
|
206
|
+
repos: [],
|
|
207
|
+
workflows: [],
|
|
208
|
+
agents: [],
|
|
209
|
+
});
|
|
210
|
+
/** project.yml as written: no slug (the folder is the slug), empty lists left out. */
|
|
211
|
+
function defToYaml(def) {
|
|
212
|
+
const doc = { title: def.title, status: def.status, goal: def.goal, harness: def.harness };
|
|
213
|
+
if (def.model)
|
|
214
|
+
doc.model = def.model;
|
|
215
|
+
if (def.effort)
|
|
216
|
+
doc.effort = def.effort;
|
|
217
|
+
if (def.records.length)
|
|
218
|
+
doc.records = def.records;
|
|
219
|
+
for (const kind of LINK_KINDS)
|
|
220
|
+
if (def[kind].length)
|
|
221
|
+
doc[kind] = def[kind];
|
|
222
|
+
return stringify(doc);
|
|
223
|
+
}
|
|
224
|
+
const readText = (file) => fs.readFile(file, "utf8").catch(() => "");
|
|
225
|
+
async function readSummary(root, slug) {
|
|
226
|
+
const dir = path.join(root, slug);
|
|
227
|
+
let raw;
|
|
228
|
+
try {
|
|
229
|
+
raw = await fs.readFile(path.join(dir, PROJECT_FILE), "utf8");
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
const newest = await newestMtime(dir).catch(() => 0);
|
|
235
|
+
const updatedAt = newest ? new Date(newest).toISOString() : undefined;
|
|
236
|
+
try {
|
|
237
|
+
return { ...parseDef(slug, raw), path: dir, ...(updatedAt ? { updatedAt } : {}) };
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
return { ...emptyDef(slug), path: dir, ...(updatedAt ? { updatedAt } : {}), configError: err.message };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/* ---------- reads ---------- */
|
|
244
|
+
const STATUS_ORDER = { active: 0, paused: 1, done: 2, archived: 3 };
|
|
245
|
+
/** Every project under the root: active ones first, then by title. */
|
|
246
|
+
export async function listProjects() {
|
|
247
|
+
const opened = await openProjects();
|
|
248
|
+
if (!opened.root)
|
|
249
|
+
return { enabled: false, projects: [], error: opened.error };
|
|
250
|
+
const root = opened.root;
|
|
251
|
+
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
252
|
+
const slugs = entries.filter((e) => e.isDirectory() && SLUG_RE.test(e.name)).map((e) => e.name);
|
|
253
|
+
const projects = (await Promise.all(slugs.map((s) => readSummary(root, s)))).filter((p) => p != null);
|
|
254
|
+
projects.sort((a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status] || a.title.localeCompare(b.title));
|
|
255
|
+
return { enabled: true, root, projects };
|
|
256
|
+
}
|
|
257
|
+
/** Which of the linked slugs exist right now, with a display label for the ones that do. */
|
|
258
|
+
export async function resolveLinks(def) {
|
|
259
|
+
const [knowledge, vibeables, repos, workflows, agents] = await Promise.all([
|
|
260
|
+
knowledgeIndex().catch(() => ({ bundles: [] })),
|
|
261
|
+
listVibeables().catch(() => ({ vibeables: [] })),
|
|
262
|
+
listRepos().catch(() => ({ repos: [] })),
|
|
263
|
+
listWorkflows().catch(() => ({ workflows: [] })),
|
|
264
|
+
listAgents().catch(() => []),
|
|
265
|
+
]);
|
|
266
|
+
const state = (slugs, lookup) => slugs.map((slug) => {
|
|
267
|
+
const label = lookup(slug);
|
|
268
|
+
return label === undefined ? { slug, found: false } : { slug, found: true, ...(label ? { label } : {}) };
|
|
269
|
+
});
|
|
270
|
+
return {
|
|
271
|
+
knowledge: state(def.knowledge, (s) => {
|
|
272
|
+
const b = knowledge.bundles.find((x) => x.name === s);
|
|
273
|
+
return b ? `${b.concepts} concepts` : undefined;
|
|
274
|
+
}),
|
|
275
|
+
vibeables: state(def.vibeables, (s) => {
|
|
276
|
+
const v = vibeables.vibeables.find((x) => x.slug === s);
|
|
277
|
+
return v ? (v.dev ? "dev" : "static") : undefined;
|
|
278
|
+
}),
|
|
279
|
+
repos: state(def.repos, (s) => {
|
|
280
|
+
const r = repos.repos.find((x) => x.slug === s);
|
|
281
|
+
return r ? r.branch ?? "" : undefined;
|
|
282
|
+
}),
|
|
283
|
+
workflows: state(def.workflows, (s) => {
|
|
284
|
+
const w = workflows.workflows.find((x) => x.slug === s);
|
|
285
|
+
return w ? w.description ?? w.name ?? "" : undefined;
|
|
286
|
+
}),
|
|
287
|
+
agents: state(def.agents, (s) => {
|
|
288
|
+
const a = agents.find((x) => x.slug === s && !x.archived);
|
|
289
|
+
return a ? `${a.emoji} ${a.name}` : undefined;
|
|
290
|
+
}),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
/** One project with its files and link states; null when the folder is not a project. */
|
|
294
|
+
export async function getProject(slug) {
|
|
295
|
+
const root = await requireRoot();
|
|
296
|
+
const summary = await readSummary(root, safeProjectSlug(slug));
|
|
297
|
+
if (!summary)
|
|
298
|
+
return null;
|
|
299
|
+
const [brief, state, log, links] = await Promise.all([
|
|
300
|
+
readText(path.join(summary.path, "brief.md")),
|
|
301
|
+
readText(path.join(summary.path, "state.md")),
|
|
302
|
+
readText(path.join(summary.path, "log.md")),
|
|
303
|
+
resolveLinks(summary),
|
|
304
|
+
]);
|
|
305
|
+
return { ...summary, brief, state, log, links };
|
|
306
|
+
}
|
|
307
|
+
/* ---------- writes ---------- */
|
|
308
|
+
/** The files a fresh project starts with. */
|
|
309
|
+
export function projectStarter(def) {
|
|
310
|
+
return {
|
|
311
|
+
[PROJECT_FILE]: defToYaml(def),
|
|
312
|
+
"brief.md": `# ${def.title}\n\n${def.goal ? `${def.goal}\n\n` : ""}## What done looks like\n\n- \n\n## Constraints\n\n- \n\n## Stakeholders\n\n- \n`,
|
|
313
|
+
"state.md": `# Current state\n\nNothing recorded yet.\n`,
|
|
314
|
+
"log.md": `# ${def.title} Log\n`,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
export async function createProject(input) {
|
|
318
|
+
const root = await requireRoot();
|
|
319
|
+
const title = (input.title ?? "").trim();
|
|
320
|
+
if (!title)
|
|
321
|
+
throw new Error("title is required");
|
|
322
|
+
const slug = safeProjectSlug((input.slug ?? "").trim() || projectSlugFromTitle(title) || "project");
|
|
323
|
+
const dir = path.join(root, slug);
|
|
324
|
+
if (await fs.stat(dir).catch(() => null))
|
|
325
|
+
throw new Error(`project "${slug}" already exists`);
|
|
326
|
+
const model = cleanModel(input.model);
|
|
327
|
+
const effort = cleanEffort(input.effort);
|
|
328
|
+
const def = {
|
|
329
|
+
...emptyDef(slug),
|
|
330
|
+
title,
|
|
331
|
+
goal: (input.goal ?? "").trim(),
|
|
332
|
+
harness: cleanHarness(input.harness),
|
|
333
|
+
...(model ? { model } : {}),
|
|
334
|
+
...(effort ? { effort } : {}),
|
|
335
|
+
};
|
|
336
|
+
await fs.mkdir(dir, { recursive: true });
|
|
337
|
+
for (const [name, content] of Object.entries(projectStarter(def))) {
|
|
338
|
+
await fs.writeFile(path.join(dir, name), content);
|
|
339
|
+
}
|
|
340
|
+
await appendProjectLog(slug, `**Created**: project "${title}".`, "human:user");
|
|
341
|
+
return (await getProject(slug));
|
|
342
|
+
}
|
|
343
|
+
/** Merge the given fields into project.yml (and brief/state when given); lists replace wholesale. */
|
|
344
|
+
export async function saveProject(slug, input) {
|
|
345
|
+
const current = await getProject(slug);
|
|
346
|
+
if (!current)
|
|
347
|
+
throw new Error(`no project "${slug}"`);
|
|
348
|
+
const next = {
|
|
349
|
+
slug: current.slug,
|
|
350
|
+
title: input.title !== undefined ? String(input.title).trim() || current.slug : current.title,
|
|
351
|
+
status: input.status !== undefined ? cleanStatus(input.status) : current.status,
|
|
352
|
+
goal: input.goal !== undefined ? String(input.goal).trim() : current.goal,
|
|
353
|
+
harness: input.harness !== undefined ? cleanHarness(input.harness) : current.harness,
|
|
354
|
+
records: input.records !== undefined ? cleanRecords(input.records) : current.records,
|
|
355
|
+
knowledge: input.knowledge !== undefined ? strList(input.knowledge, "knowledge") : current.knowledge,
|
|
356
|
+
vibeables: input.vibeables !== undefined ? strList(input.vibeables, "vibeables") : current.vibeables,
|
|
357
|
+
repos: input.repos !== undefined ? strList(input.repos, "repos") : current.repos,
|
|
358
|
+
workflows: input.workflows !== undefined ? strList(input.workflows, "workflows") : current.workflows,
|
|
359
|
+
agents: input.agents !== undefined ? strList(input.agents, "agents") : current.agents,
|
|
360
|
+
};
|
|
361
|
+
const model = input.model !== undefined ? cleanModel(input.model) : current.model;
|
|
362
|
+
const effort = input.effort !== undefined ? cleanEffort(input.effort) : current.effort;
|
|
363
|
+
if (model)
|
|
364
|
+
next.model = model;
|
|
365
|
+
if (effort)
|
|
366
|
+
next.effort = effort;
|
|
367
|
+
await fs.writeFile(path.join(current.path, PROJECT_FILE), defToYaml(next));
|
|
368
|
+
if (typeof input.brief === "string")
|
|
369
|
+
await fs.writeFile(path.join(current.path, "brief.md"), input.brief);
|
|
370
|
+
if (typeof input.state === "string")
|
|
371
|
+
await fs.writeFile(path.join(current.path, "state.md"), input.state);
|
|
372
|
+
return (await getProject(slug));
|
|
373
|
+
}
|
|
374
|
+
/** Add or remove one slug in a link list. */
|
|
375
|
+
export async function linkProject(slug, kind, target, remove = false) {
|
|
376
|
+
if (!LINK_KINDS.includes(kind))
|
|
377
|
+
throw new Error(`link kind must be one of ${LINK_KINDS.join(", ")}`);
|
|
378
|
+
const current = await getProject(slug);
|
|
379
|
+
if (!current)
|
|
380
|
+
throw new Error(`no project "${slug}"`);
|
|
381
|
+
const k = kind;
|
|
382
|
+
const t = target.trim();
|
|
383
|
+
if (!t)
|
|
384
|
+
throw new Error("target name is required");
|
|
385
|
+
const list = remove ? current[k].filter((x) => x !== t) : [...new Set([...current[k], t])];
|
|
386
|
+
return saveProject(slug, { [k]: list });
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Prepend an entry under today's heading in log.md (newest first, like a
|
|
390
|
+
* knowledge bundle's log). The actor is stamped so a line written by an
|
|
391
|
+
* agent reads as such next to a human's.
|
|
392
|
+
*/
|
|
393
|
+
export async function appendProjectLog(slug, entry, actor) {
|
|
394
|
+
const root = await requireRoot();
|
|
395
|
+
const dir = path.join(root, safeProjectSlug(slug));
|
|
396
|
+
if (!(await fs.stat(path.join(dir, PROJECT_FILE)).catch(() => null)))
|
|
397
|
+
throw new Error(`no project "${slug}"`);
|
|
398
|
+
const text = entry.trim().replace(/\s*\n\s*/g, " ");
|
|
399
|
+
if (!text)
|
|
400
|
+
throw new Error("entry is required");
|
|
401
|
+
const who = actor.trim() || "human:user";
|
|
402
|
+
const file = path.join(dir, "log.md");
|
|
403
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
404
|
+
let raw = await fs.readFile(file, "utf8").catch(() => null);
|
|
405
|
+
if (raw === null)
|
|
406
|
+
raw = `# ${slug} Log\n`;
|
|
407
|
+
const lines = raw.split("\n");
|
|
408
|
+
const line = `* ${text} (${who})`;
|
|
409
|
+
const todayIdx = lines.findIndex((l) => l.trim() === `## ${today}`);
|
|
410
|
+
if (todayIdx >= 0) {
|
|
411
|
+
lines.splice(todayIdx + 1, 0, line);
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
const titleIdx = lines.findIndex((l) => l.startsWith("# "));
|
|
415
|
+
lines.splice(titleIdx + 1, 0, "", `## ${today}`, line);
|
|
416
|
+
}
|
|
417
|
+
const out = lines.join("\n").replace(/\n{3,}/g, "\n\n");
|
|
418
|
+
await fs.writeFile(file, out);
|
|
419
|
+
return out;
|
|
420
|
+
}
|
|
421
|
+
/** Remove the folder; its history stays in the workspace git. */
|
|
422
|
+
export async function deleteProject(slug) {
|
|
423
|
+
const root = await requireRoot();
|
|
424
|
+
const dir = path.join(root, safeProjectSlug(slug));
|
|
425
|
+
if (!(await fs.stat(path.join(dir, PROJECT_FILE)).catch(() => null)))
|
|
426
|
+
throw new Error(`no project "${slug}"`);
|
|
427
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
428
|
+
}
|
|
429
|
+
/* ---------- chat context ---------- */
|
|
430
|
+
/** One line per system of record: what it is, where, and what usually lives there. */
|
|
431
|
+
export function recordLine(r) {
|
|
432
|
+
const known = RECORD_KINDS[r.kind];
|
|
433
|
+
const label = known?.label ?? r.kind;
|
|
434
|
+
const head = r.title ? `${label}: ${r.title}` : label;
|
|
435
|
+
const parts = [
|
|
436
|
+
r.workspace ? `workspace ${r.workspace}` : "",
|
|
437
|
+
r.url ?? "",
|
|
438
|
+
known?.hint ?? "",
|
|
439
|
+
r.note ?? "",
|
|
440
|
+
].filter(Boolean);
|
|
441
|
+
return `- ${head}${parts.length ? ` — ${parts.join("; ")}` : ""}`;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* The "## …" blocks a project chat starts with: brief, state, records,
|
|
445
|
+
* every link with how to reach it, and how to keep the project current.
|
|
446
|
+
* `actor` is stamped on log entries the agent writes.
|
|
447
|
+
*/
|
|
448
|
+
export async function projectContext(slug, actor, opts = {}) {
|
|
449
|
+
const p = await getProject(slug).catch(() => null);
|
|
450
|
+
if (!p) {
|
|
451
|
+
return opts.member
|
|
452
|
+
? `This channel belongs to project "${slug}", but its folder under the projects root is missing (or projects are switched off in this workspace). Tell the humans.`
|
|
453
|
+
: `You were opened for project "${slug}", but its folder under the projects root is missing (or projects are switched off in this workspace). Tell the user and ask how to proceed.`;
|
|
454
|
+
}
|
|
455
|
+
const linkLines = (kind, describe) => p.links[kind].map((l) => (l.found ? describe(l) : `- ${l.slug} (configured but not found in this workspace — tell the user if you need it)`)).join("\n");
|
|
456
|
+
const repos = await listRepos().catch(() => null);
|
|
457
|
+
const reposRoot = repos?.enabled && repos.root ? repos.root : undefined;
|
|
458
|
+
const vibeables = p.vibeables.length > 0;
|
|
459
|
+
const sections = [
|
|
460
|
+
(opts.member
|
|
461
|
+
? `## Project: ${p.title}\nThis channel works in the project "${p.title}" (status ${p.status}) of this kraftwerk workspace. ` +
|
|
462
|
+
`Goal: ${p.goal || "(no goal written yet)"}\n` +
|
|
463
|
+
`The project folder is ${p.path} (project.yml, brief.md, state.md, log.md); your working directory is the workspace root, ` +
|
|
464
|
+
`so every linked folder below is reachable by path. Keep your role, but work towards this goal with what the project has gathered, and keep its files current.`
|
|
465
|
+
: `You are the assistant of the project "${p.title}" in this kraftwerk workspace. Status: ${p.status}.\n` +
|
|
466
|
+
`Goal: ${p.goal || "(no goal written yet — ask the user what the project is for)"}\n` +
|
|
467
|
+
`The project folder is ${p.path} (project.yml, brief.md, state.md, log.md); your working directory is the workspace root, ` +
|
|
468
|
+
`so every linked folder below is reachable by path. Work towards the goal, use what the project has gathered, and keep its files current.`),
|
|
469
|
+
`## Brief\n${p.brief.trim() || "(no brief yet — ask the user what done looks like and write brief.md together)"}`,
|
|
470
|
+
`## Current state\n${p.state.trim() || "(nothing recorded yet)"}`,
|
|
471
|
+
p.records.length > 0
|
|
472
|
+
? `## Systems of record\nWhere the truth of this project is managed outside kraftwerk. Reach them through the tools your harness already has ` +
|
|
473
|
+
`(CLIs, MCP servers, the browser); an entry here grants nothing by itself — when a tool is missing, say so instead of guessing at the content.\n` +
|
|
474
|
+
p.records.map(recordLine).join("\n")
|
|
475
|
+
: "",
|
|
476
|
+
p.knowledge.length > 0
|
|
477
|
+
? `## Knowledge\nOKF bundles linked to this project (markdown + YAML frontmatter under the knowledge root):\n` +
|
|
478
|
+
linkLines("knowledge", (l) => `- ${l.slug}${l.label ? ` (${l.label})` : ""}`) +
|
|
479
|
+
`\nRead with \`npx kraftwerk knowledge list <bundle>\`, \`get <bundle>/<path>\`, \`search <text>\`. ` +
|
|
480
|
+
`Write ONLY through \`npx kraftwerk knowledge put <bundle>/<path> --file <tmp.md> --actor ${actor}\` ` +
|
|
481
|
+
`(stamps provenance, maintains index.md/log.md — never hand-edit those).`
|
|
482
|
+
: "",
|
|
483
|
+
p.repos.length > 0
|
|
484
|
+
? `## Repositories\nGit clones linked to this project${reposRoot ? `, under ${reposRoot}` : ""}:\n` +
|
|
485
|
+
linkLines("repos", (l) => {
|
|
486
|
+
const r = repos?.repos.find((x) => x.slug === l.slug);
|
|
487
|
+
return `- ${l.slug}${r ? ` — ${r.path}${r.branch ? `, branch ${r.branch}` : ""}${r.dirty ? `, ${r.dirty} uncommitted` : ""}` : ""}`;
|
|
488
|
+
}) +
|
|
489
|
+
(reposRoot
|
|
490
|
+
? `\nWork inside a clone's folder when the task concerns it: read its README first, keep commits on a branch unless told otherwise, never push or force-push without asking. ` +
|
|
491
|
+
`\`npx kraftwerk repos\` lists every clone, \`repos add <url>\` clones a new one into the root.`
|
|
492
|
+
: `\nRepositories are switched off in this workspace's settings, so these clones are not reachable right now — tell the user.`)
|
|
493
|
+
: "",
|
|
494
|
+
vibeables
|
|
495
|
+
? `## Vibeables\nSmall apps of this project (one folder each under the vibeables root):\n` +
|
|
496
|
+
linkLines("vibeables", (l) => `- ${l.slug}${l.label ? ` (${l.label})` : ""}`) +
|
|
497
|
+
`\nThe user opens one in the preview pane with the vibeable button; from then on that folder is your working directory and every file you save is what they see next.`
|
|
498
|
+
: "",
|
|
499
|
+
p.workflows.length > 0
|
|
500
|
+
? `## Workflows\nkraftwerk workflows that belong to this project. When the user's request matches one, run it instead of doing the work by hand:\n` +
|
|
501
|
+
linkLines("workflows", (l) => `- ${l.slug}${l.label ? `: ${l.label}` : ""}`) +
|
|
502
|
+
`\nRun one with:\n KRAFTWERK_YES=1 npx kraftwerk run <workflow> "<request>"\n` +
|
|
503
|
+
`The command blocks until the run finishes and prints the run id; artifacts land in the output folder. Confirm before a long or expensive run unless the user clearly asked for it.`
|
|
504
|
+
: "",
|
|
505
|
+
p.agents.length > 0
|
|
506
|
+
? `## Agents\nPersistent agents attached to this project:\n` +
|
|
507
|
+
linkLines("agents", (l) => `- ${l.slug}${l.label ? ` — ${l.label}` : ""}`) +
|
|
508
|
+
`\nThe user talks to them on the Agents screen; you cannot message them yourself. When a request clearly belongs to one of them, say so.`
|
|
509
|
+
: "",
|
|
510
|
+
`## Keeping the project current\n` +
|
|
511
|
+
`- brief.md is the user's: propose changes and write them only when they agree.\n` +
|
|
512
|
+
`- state.md is the project's memory between sessions. At the end of a session that changed something, rewrite it so the next session ` +
|
|
513
|
+
`(yours or a colleague's) can continue without this transcript: what is done, what is open, what was decided.\n` +
|
|
514
|
+
`- log.md is append-only. Record decisions and milestones with\n` +
|
|
515
|
+
` npx kraftwerk projects log ${p.slug} "<one line>" --actor ${actor}\n` +
|
|
516
|
+
` (stamps the date and you as the author; never edit log.md by hand).\n` +
|
|
517
|
+
`- Links (knowledge, vibeables, repositories, workflows, agents) live in project.yml: \`npx kraftwerk projects link ${p.slug} <kind> <name>\` adds one, \`unlink\` removes it.`,
|
|
518
|
+
];
|
|
519
|
+
return sections.filter(Boolean).join("\n\n");
|
|
520
|
+
}
|
|
@@ -4,7 +4,7 @@ import { type ChannelSummary } from "./channels.js";
|
|
|
4
4
|
* The ⌘K palette: every active agent and every channel in every workspace
|
|
5
5
|
* this machine knows, so a user can jump to one without switching
|
|
6
6
|
* workspaces first. This instance reads its own lists from disk; the others
|
|
7
|
-
* come from the
|
|
7
|
+
* come from the workspace registry, where each instance records them
|
|
8
8
|
* whenever it reads them (see instances.ts). Stopped workspaces are listed too — the
|
|
9
9
|
* palette starts them on the way to the agent.
|
|
10
10
|
*/
|
package/dist/inspector/search.js
CHANGED
|
@@ -3,7 +3,7 @@ import { resolveProject } from "../config.js";
|
|
|
3
3
|
import { getProjectRoot } from "./context.js";
|
|
4
4
|
import { listAgents, toSummary } from "./agents.js";
|
|
5
5
|
import { listChannels, toChannelSummary } from "./channels.js";
|
|
6
|
-
import { currentInstanceUrl, discoverWorkspaces,
|
|
6
|
+
import { currentInstanceUrl, discoverWorkspaces, listWorkspaceRecords } from "./instances.js";
|
|
7
7
|
export async function searchAgents() {
|
|
8
8
|
const project = await resolveProject(getProjectRoot()).catch(() => null);
|
|
9
9
|
const self = {
|
|
@@ -16,8 +16,8 @@ export async function searchAgents() {
|
|
|
16
16
|
agents: (await listAgents().catch(() => [])).filter((a) => !a.archived).map(toSummary),
|
|
17
17
|
channels: (await listChannels().catch(() => [])).map(toChannelSummary),
|
|
18
18
|
};
|
|
19
|
-
const [workspaces,
|
|
20
|
-
const records = new Map(
|
|
19
|
+
const [workspaces, known] = await Promise.all([discoverWorkspaces(), listWorkspaceRecords()]);
|
|
20
|
+
const records = new Map(known.map((p) => [p.root, p]));
|
|
21
21
|
const others = workspaces
|
|
22
22
|
.filter((w) => w.root && w.exists !== false)
|
|
23
23
|
.map((w) => ({
|