@netnodeag/kraftwerk 0.79.0 → 0.80.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 +83 -0
- package/dist/cli/daemon.js +10 -9
- package/dist/cli/doctor.js +1 -1
- package/dist/cli/entities.d.ts +8 -0
- package/dist/cli/entities.js +222 -0
- package/dist/cli/init.js +3 -0
- package/dist/cli/kraftwerk.js +7 -0
- package/dist/cli/ui.js +2 -1
- package/dist/client/routes.d.ts +16 -0
- package/dist/client/routes.js +16 -0
- package/dist/config.d.ts +25 -0
- package/dist/config.js +17 -6
- package/dist/core/chat/context.js +5 -2
- package/dist/core/entities.d.ts +171 -0
- package/dist/core/entities.js +441 -0
- package/dist/core/git.js +3 -1
- package/dist/core/journal.d.ts +12 -2
- package/dist/core/journal.js +50 -5
- package/dist/core/log.d.ts +18 -0
- package/dist/core/log.js +23 -0
- package/dist/core/projects.d.ts +22 -33
- package/dist/core/projects.js +40 -134
- package/dist/core/records.d.ts +28 -0
- package/dist/core/records.js +71 -0
- package/dist/core/refs.d.ts +74 -0
- package/dist/core/refs.js +143 -0
- package/dist/core/search.d.ts +10 -0
- package/dist/core/search.js +12 -0
- package/dist/core/settings.d.ts +4 -0
- package/dist/core/settings.js +24 -3
- package/dist/core/slug.d.ts +9 -0
- package/dist/core/slug.js +18 -0
- package/dist/core/trash.d.ts +1 -1
- package/dist/server/api/devices.js +2 -1
- package/dist/server/api/entities.d.ts +74 -0
- package/dist/server/api/entities.js +60 -0
- package/dist/server/api/index.d.ts +66 -4
- package/dist/server/api/index.js +5 -0
- package/dist/server/api/projects.d.ts +3 -0
- package/dist/server/api/projects.js +3 -0
- package/dist/server/api/workspace.d.ts +15 -0
- package/dist/server/api/workspace.js +6 -0
- package/dist/server/cloud.js +3 -2
- package/dist/server/relay.d.ts +3 -2
- package/dist/server/relay.js +7 -7
- package/dist/server/server.js +3 -2
- package/package.json +1 -1
- package/schema/kraftwerk.schema.json +54 -0
- package/web/dist/assets/{dist-cNijUfNl.js → dist-1PajaYPg.js} +1 -1
- package/web/dist/assets/{dist-BTD8k55t.js → dist-B4dqwo7_.js} +1 -1
- package/web/dist/assets/dist-Bp1iMuQ7.js +1 -0
- package/web/dist/assets/{dist-A0LN_JdM.js → dist-CK2u1OXb.js} +1 -1
- package/web/dist/assets/{dist-DdOsafun.js → dist-D-f0nszo.js} +1 -1
- package/web/dist/assets/{dist-xLZN2eGO.js → dist-DKduisGW.js} +1 -1
- package/web/dist/assets/{dist-BasQkRXA.js → dist-DgKgeYFJ.js} +1 -1
- package/web/dist/assets/{dist-wtIUmbnn.js → dist-DlZPeneT.js} +1 -1
- package/web/dist/assets/{dist-E48sO9Gc.js → dist-K_3Y5Nbl.js} +1 -1
- package/web/dist/assets/{dist-DgP31lcC.js → dist-NX4_yvho.js} +1 -1
- package/web/dist/assets/{dist-D93lSM0a.js → dist-u_oAKIhZ.js} +1 -1
- package/web/dist/assets/{editor-BQlHN7qT.js → editor-CE9TlKkJ.js} +3 -3
- package/web/dist/assets/index-Dvt-vE1h.css +2 -0
- package/web/dist/assets/index-IAuwD22j.js +231 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/dist-B04mMuvH.js +0 -1
- package/web/dist/assets/index-BKDJocDd.css +0 -2
- package/web/dist/assets/index-C7ddpWNC.js +0 -231
package/dist/core/journal.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* An agent's journal: agents/<slug>/journal.md, the agent's own memory
|
|
3
3
|
* across sessions. What it learned, decided, promised and finished, one
|
|
4
|
-
* line per entry under a dated heading, newest
|
|
5
|
-
* log.md). The agent writes it through `kraftwerk journal` and reads the
|
|
4
|
+
* line per entry stamped with its minute, under a dated heading, newest
|
|
5
|
+
* first (like a project's log.md). Times are the machine's local time. The agent writes it through `kraftwerk journal` and reads the
|
|
6
6
|
* recent part back at the start of every session, so next week it is the
|
|
7
7
|
* same colleague; a human can add a line from the inspector.
|
|
8
8
|
*/
|
|
@@ -19,7 +19,17 @@ export declare function readJournal(slug: string): Promise<string>;
|
|
|
19
19
|
export declare function appendJournal(slug: string, entry: string, opts?: {
|
|
20
20
|
kind?: string;
|
|
21
21
|
actor?: string;
|
|
22
|
+
now?: Date;
|
|
22
23
|
}): Promise<string>;
|
|
24
|
+
/**
|
|
25
|
+
* One section per day again, its entries newest first by their minute. Two
|
|
26
|
+
* people adding to a journal on different machines both put a line under
|
|
27
|
+
* the same day (or both open a new day); once git has kept both sides, the
|
|
28
|
+
* next entry tidies the file: a day heading that appears twice is merged
|
|
29
|
+
* into one, and stamped lines are ordered by time, ahead of older unstamped
|
|
30
|
+
* ones, which keep their order.
|
|
31
|
+
*/
|
|
32
|
+
export declare function mergeDays(raw: string): string;
|
|
23
33
|
/**
|
|
24
34
|
* The recent part of a journal for a session's context: whole days, newest
|
|
25
35
|
* first, until the budget is spent. Older days stay in the file, where the
|
package/dist/core/journal.js
CHANGED
|
@@ -4,8 +4,8 @@ import { agentsRoot, getAgent, safeAgentSlug } from "./agents.js";
|
|
|
4
4
|
/**
|
|
5
5
|
* An agent's journal: agents/<slug>/journal.md, the agent's own memory
|
|
6
6
|
* across sessions. What it learned, decided, promised and finished, one
|
|
7
|
-
* line per entry under a dated heading, newest
|
|
8
|
-
* log.md). The agent writes it through `kraftwerk journal` and reads the
|
|
7
|
+
* line per entry stamped with its minute, under a dated heading, newest
|
|
8
|
+
* first (like a project's log.md). Times are the machine's local time. The agent writes it through `kraftwerk journal` and reads the
|
|
9
9
|
* recent part back at the start of every session, so next week it is the
|
|
10
10
|
* same colleague; a human can add a line from the inspector.
|
|
11
11
|
*/
|
|
@@ -35,11 +35,12 @@ export async function appendJournal(slug, entry, opts = {}) {
|
|
|
35
35
|
throw new Error(`kind must be one of: ${JOURNAL_KINDS.join(", ")}`);
|
|
36
36
|
const actor = opts.actor?.trim();
|
|
37
37
|
const own = !actor || actor === slug || actor.startsWith(`${slug}/`);
|
|
38
|
-
const
|
|
38
|
+
const now = opts.now ?? new Date();
|
|
39
|
+
const line = `* ${clock(now)} ${kind === "note" ? "" : `**${kind[0].toUpperCase()}${kind.slice(1)}**: `}${text}${own ? "" : ` (${actor})`}`;
|
|
39
40
|
const file = await journalPath(slug);
|
|
40
|
-
const today =
|
|
41
|
+
const today = localDate(now);
|
|
41
42
|
const raw = (await fs.readFile(file, "utf8").catch(() => null)) ?? `# ${def.name}'s journal\n`;
|
|
42
|
-
const lines = raw.split("\n");
|
|
43
|
+
const lines = mergeDays(raw).split("\n");
|
|
43
44
|
const todayIdx = lines.findIndex((l) => l.trim() === `## ${today}`);
|
|
44
45
|
if (todayIdx >= 0) {
|
|
45
46
|
lines.splice(todayIdx + 1, 0, line);
|
|
@@ -52,6 +53,50 @@ export async function appendJournal(slug, entry, opts = {}) {
|
|
|
52
53
|
await fs.writeFile(file, out);
|
|
53
54
|
return out;
|
|
54
55
|
}
|
|
56
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
57
|
+
/** YYYY-MM-DD in local time. */
|
|
58
|
+
const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
59
|
+
/** HH:MM in local time. */
|
|
60
|
+
const clock = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
61
|
+
/**
|
|
62
|
+
* One section per day again, its entries newest first by their minute. Two
|
|
63
|
+
* people adding to a journal on different machines both put a line under
|
|
64
|
+
* the same day (or both open a new day); once git has kept both sides, the
|
|
65
|
+
* next entry tidies the file: a day heading that appears twice is merged
|
|
66
|
+
* into one, and stamped lines are ordered by time, ahead of older unstamped
|
|
67
|
+
* ones, which keep their order.
|
|
68
|
+
*/
|
|
69
|
+
export function mergeDays(raw) {
|
|
70
|
+
const lines = raw.split("\n");
|
|
71
|
+
const firstDay = lines.findIndex((l) => /^## /.test(l));
|
|
72
|
+
if (firstDay < 0)
|
|
73
|
+
return raw;
|
|
74
|
+
const head = lines.slice(0, firstDay);
|
|
75
|
+
const days = new Map();
|
|
76
|
+
let cur = null;
|
|
77
|
+
for (const l of lines.slice(firstDay)) {
|
|
78
|
+
const m = /^## (.+?)\s*$/.exec(l);
|
|
79
|
+
if (m) {
|
|
80
|
+
cur = days.get(m[1]) ?? [];
|
|
81
|
+
days.set(m[1], cur);
|
|
82
|
+
}
|
|
83
|
+
else if (cur && l.trim()) {
|
|
84
|
+
cur.push(l);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (days.size === lines.slice(firstDay).filter((l) => /^## /.test(l)).length) {
|
|
88
|
+
// no day twice: leave the order of a day's lines as written
|
|
89
|
+
return raw;
|
|
90
|
+
}
|
|
91
|
+
const stamp = (l) => /^\* (\d\d:\d\d) /.exec(l)?.[1] ?? "";
|
|
92
|
+
const order = [...days.keys()].sort((a, b) => b.localeCompare(a));
|
|
93
|
+
const body = order.map((d) => {
|
|
94
|
+
const entries = days.get(d).map((l, i) => ({ l, i, t: stamp(l) }));
|
|
95
|
+
entries.sort((a, b) => (a.t && b.t ? b.t.localeCompare(a.t) : a.t ? -1 : b.t ? 1 : 0) || a.i - b.i);
|
|
96
|
+
return [`## ${d}`, ...[...new Set(entries.map((e) => e.l))]].join("\n");
|
|
97
|
+
});
|
|
98
|
+
return [...head, body.join("\n\n"), ""].join("\n").replace(/\n{3,}/g, "\n\n");
|
|
99
|
+
}
|
|
55
100
|
/**
|
|
56
101
|
* The recent part of a journal for a session's context: whole days, newest
|
|
57
102
|
* first, until the budget is spent. Older days stay in the file, where the
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon's log line, one event per line in a fixed shape:
|
|
3
|
+
*
|
|
4
|
+
* 2026-10-15T14:26:47.662Z INFO relay connected to https://… as N8I…
|
|
5
|
+
* 2026-10-15T14:26:47.901Z WARN cloud https://… unreachable — no answer (retrying)
|
|
6
|
+
* 2026-10-15T12:25:33.105Z ERROR server crashed: Error: listen EADDRINUSE …
|
|
7
|
+
*
|
|
8
|
+
* ISO-8601 UTC time, a padded level, a padded component, the message —
|
|
9
|
+
* stable columns, grep-able, no glyphs, no color. The daemon's stdout IS
|
|
10
|
+
* ~/.kraftwerk/logs/daemon.log (daemon-control appends the stream), so the
|
|
11
|
+
* daemon and the long-running pieces it hosts (cloud client, relay,
|
|
12
|
+
* scheduler) speak this and nothing else. Interactive commands keep their
|
|
13
|
+
* human output; `kraftwerk daemon status` greps for "crashed:" and
|
|
14
|
+
* "stopped unexpectedly", which stay literal in the message.
|
|
15
|
+
*/
|
|
16
|
+
export type LogLevel = "info" | "warn" | "error";
|
|
17
|
+
/** INFO to stdout, WARN and ERROR to stderr — the daemon writes both into one file. */
|
|
18
|
+
export declare function log(level: LogLevel, component: string, message: string): void;
|
package/dist/core/log.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon's log line, one event per line in a fixed shape:
|
|
3
|
+
*
|
|
4
|
+
* 2026-10-15T14:26:47.662Z INFO relay connected to https://… as N8I…
|
|
5
|
+
* 2026-10-15T14:26:47.901Z WARN cloud https://… unreachable — no answer (retrying)
|
|
6
|
+
* 2026-10-15T12:25:33.105Z ERROR server crashed: Error: listen EADDRINUSE …
|
|
7
|
+
*
|
|
8
|
+
* ISO-8601 UTC time, a padded level, a padded component, the message —
|
|
9
|
+
* stable columns, grep-able, no glyphs, no color. The daemon's stdout IS
|
|
10
|
+
* ~/.kraftwerk/logs/daemon.log (daemon-control appends the stream), so the
|
|
11
|
+
* daemon and the long-running pieces it hosts (cloud client, relay,
|
|
12
|
+
* scheduler) speak this and nothing else. Interactive commands keep their
|
|
13
|
+
* human output; `kraftwerk daemon status` greps for "crashed:" and
|
|
14
|
+
* "stopped unexpectedly", which stay literal in the message.
|
|
15
|
+
*/
|
|
16
|
+
/** INFO to stdout, WARN and ERROR to stderr — the daemon writes both into one file. */
|
|
17
|
+
export function log(level, component, message) {
|
|
18
|
+
const line = `${new Date().toISOString()} ${level.toUpperCase().padEnd(5)} ${component.padEnd(9)} ${message}`;
|
|
19
|
+
if (level === "info")
|
|
20
|
+
console.log(line);
|
|
21
|
+
else
|
|
22
|
+
console.error(line);
|
|
23
|
+
}
|
package/dist/core/projects.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type ResolvedWorkspace } from "../config.js";
|
|
2
2
|
import type { ChatAgentId } from "./chat/types.js";
|
|
3
|
+
import { RECORD_KINDS, recordLine, type SystemOfRecord } from "./records.js";
|
|
4
|
+
import { type LinkState } from "./refs.js";
|
|
3
5
|
/**
|
|
4
6
|
* Projects: a goal with everything the agents need to reach it gathered in
|
|
5
7
|
* one folder — the brief, the systems of record where the truth is managed
|
|
@@ -18,33 +20,20 @@ import type { ChatAgentId } from "./chat/types.js";
|
|
|
18
20
|
* projects/<slug>/state.md # current state, rewritten at the end of a session
|
|
19
21
|
* projects/<slug>/log.md # append-only: decisions, milestones (newest first, stamped)
|
|
20
22
|
*
|
|
21
|
-
* Links are one-directional lists of slugs
|
|
22
|
-
*
|
|
23
|
-
*
|
|
23
|
+
* Links are one-directional lists of slugs, resolved through the reference
|
|
24
|
+
* registry (refs.ts); a target that does not exist is reported
|
|
25
|
+
* ("configured but not found"), never an error — the same rule agent
|
|
26
|
+
* knowledge follows. A system of record (records.ts) is context, not a
|
|
24
27
|
* credential and not a grant: it says where the truth lives and how it is
|
|
25
28
|
* usually reached, and the harness decides what the agent may call.
|
|
26
29
|
*/
|
|
27
30
|
export declare const PROJECT_FILE = "project.yml";
|
|
28
31
|
export declare const PROJECT_STATUSES: readonly ["active", "paused", "done", "archived"];
|
|
29
32
|
export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
|
|
30
|
-
|
|
31
|
-
export
|
|
32
|
-
|
|
33
|
-
|
|
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"];
|
|
33
|
+
export { RECORD_KINDS, recordLine, type SystemOfRecord };
|
|
34
|
+
export type { LinkState };
|
|
35
|
+
/** What a project links to, in the order project.yml and the UI list them; each is a kind of the reference registry. */
|
|
36
|
+
export declare const LINK_KINDS: readonly ["knowledge", "vibeables", "repos", "workflows", "agents", "contacts", "places", "events"];
|
|
48
37
|
export type LinkKind = (typeof LINK_KINDS)[number];
|
|
49
38
|
export interface ProjectDef {
|
|
50
39
|
slug: string;
|
|
@@ -69,6 +58,12 @@ export interface ProjectDef {
|
|
|
69
58
|
workflows: string[];
|
|
70
59
|
/** Agent slugs. */
|
|
71
60
|
agents: string[];
|
|
61
|
+
/** Contact slugs (contact:<slug>). */
|
|
62
|
+
contacts: string[];
|
|
63
|
+
/** Place slugs. */
|
|
64
|
+
places: string[];
|
|
65
|
+
/** Event slugs. */
|
|
66
|
+
events: string[];
|
|
72
67
|
}
|
|
73
68
|
/** What the ⌘K palette needs of a project: also the shape kept in the workspace registry. */
|
|
74
69
|
export interface ProjectHit {
|
|
@@ -86,13 +81,6 @@ export interface ProjectSummary extends ProjectDef {
|
|
|
86
81
|
/** Set when project.yml exists but is unusable; the rest shows defaults. */
|
|
87
82
|
configError?: string;
|
|
88
83
|
}
|
|
89
|
-
/** One linked slug and whether its target exists right now. */
|
|
90
|
-
export interface LinkState {
|
|
91
|
-
slug: string;
|
|
92
|
-
found: boolean;
|
|
93
|
-
/** Display name of the target when found (an agent's name, a workflow's description). */
|
|
94
|
-
label?: string;
|
|
95
|
-
}
|
|
96
84
|
export type ProjectLinks = Record<LinkKind, LinkState[]>;
|
|
97
85
|
export interface ProjectDetail extends ProjectSummary {
|
|
98
86
|
brief: string;
|
|
@@ -136,12 +124,15 @@ export interface SaveProjectInput {
|
|
|
136
124
|
repos?: unknown;
|
|
137
125
|
workflows?: unknown;
|
|
138
126
|
agents?: unknown;
|
|
127
|
+
contacts?: unknown;
|
|
128
|
+
places?: unknown;
|
|
129
|
+
events?: unknown;
|
|
139
130
|
brief?: string;
|
|
140
131
|
state?: string;
|
|
141
132
|
}
|
|
142
133
|
export declare function safeProjectSlug(slug: string): string;
|
|
143
134
|
/** Derive a slug from a title: "Relaunch netnode.ch" -> "relaunch-netnode-ch". */
|
|
144
|
-
export declare
|
|
135
|
+
export declare const projectSlugFromTitle: (title: string) => string;
|
|
145
136
|
export declare function openProjects(): Promise<{
|
|
146
137
|
project?: ResolvedWorkspace;
|
|
147
138
|
root?: string;
|
|
@@ -154,8 +145,8 @@ export declare function ensureProjectsRoot(): Promise<void>;
|
|
|
154
145
|
export declare function listProjects(): Promise<ProjectsView>;
|
|
155
146
|
/** Save the sidebar's order (PUT /api/projects); section names must be unique and non-empty. */
|
|
156
147
|
export declare function saveLayout(input: unknown): Promise<ProjectLayout>;
|
|
157
|
-
/** Which of the linked slugs exist right now, with a display label for the ones that do. */
|
|
158
|
-
export declare
|
|
148
|
+
/** Which of the linked slugs exist right now, with a display label for the ones that do (the reference registry decides). */
|
|
149
|
+
export declare const resolveLinks: (def: ProjectDef) => Promise<ProjectLinks>;
|
|
159
150
|
/** One project with its files and link states; null when the folder is not a project. */
|
|
160
151
|
export declare function getProject(slug: string): Promise<ProjectDetail | null>;
|
|
161
152
|
/** The files a fresh project starts with. */
|
|
@@ -180,8 +171,6 @@ export declare function linkProject(slug: string, kind: string, target: string,
|
|
|
180
171
|
export declare function appendProjectLog(slug: string, entry: string, actor: string): Promise<string>;
|
|
181
172
|
/** Move the folder to the trash. */
|
|
182
173
|
export declare function deleteProject(slug: string): Promise<void>;
|
|
183
|
-
/** One line per system of record: what it is, where, and what usually lives there. */
|
|
184
|
-
export declare function recordLine(r: SystemOfRecord): string;
|
|
185
174
|
/**
|
|
186
175
|
* The "## …" blocks a project chat starts with: brief, state, records,
|
|
187
176
|
* every link with how to reach it, and how to keep the project current.
|
package/dist/core/projects.js
CHANGED
|
@@ -2,15 +2,16 @@ import { promises as fs } from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parse, stringify } from "yaml";
|
|
4
4
|
import { projectsRootFor, resolveWorkspace } from "../config.js";
|
|
5
|
-
import { EFFORTS, HARNESSES
|
|
5
|
+
import { EFFORTS, HARNESSES } from "./agents.js";
|
|
6
6
|
import { workspaceRoot } from "./context.js";
|
|
7
|
+
import { CONTACTS, entityLine, EVENTS, listEntities, PLACES } from "./entities.js";
|
|
7
8
|
import { syncWorkspaceProjects } from "./instances.js";
|
|
8
|
-
import { knowledgeIndex } from "./knowledge.js";
|
|
9
9
|
import { newestMtime } from "./mtime.js";
|
|
10
|
+
import { cleanRecords, RECORD_KINDS, recordLine } from "./records.js";
|
|
11
|
+
import { resolveRefs } from "./refs.js";
|
|
10
12
|
import { listRepos } from "./repos.js";
|
|
13
|
+
import { SLUG_RE, slugify } from "./slug.js";
|
|
11
14
|
import { moveToTrash } from "./trash.js";
|
|
12
|
-
import { listVibeables } from "./vibeables.js";
|
|
13
|
-
import { listWorkflows } from "./workflows.js";
|
|
14
15
|
import { filesContext } from "./files.js";
|
|
15
16
|
/**
|
|
16
17
|
* Projects: a goal with everything the agents need to reach it gathered in
|
|
@@ -30,48 +31,31 @@ import { filesContext } from "./files.js";
|
|
|
30
31
|
* projects/<slug>/state.md # current state, rewritten at the end of a session
|
|
31
32
|
* projects/<slug>/log.md # append-only: decisions, milestones (newest first, stamped)
|
|
32
33
|
*
|
|
33
|
-
* Links are one-directional lists of slugs
|
|
34
|
-
*
|
|
35
|
-
*
|
|
34
|
+
* Links are one-directional lists of slugs, resolved through the reference
|
|
35
|
+
* registry (refs.ts); a target that does not exist is reported
|
|
36
|
+
* ("configured but not found"), never an error — the same rule agent
|
|
37
|
+
* knowledge follows. A system of record (records.ts) is context, not a
|
|
36
38
|
* credential and not a grant: it says where the truth lives and how it is
|
|
37
39
|
* usually reached, and the harness decides what the agent may call.
|
|
38
40
|
*/
|
|
39
41
|
export const PROJECT_FILE = "project.yml";
|
|
40
42
|
export const PROJECT_STATUSES = ["active", "paused", "done", "archived"];
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
"google-drive": { label: "Google Drive", hint: "documents and folders" },
|
|
45
|
-
github: { label: "GitHub", hint: "issues, pull requests and discussions" },
|
|
46
|
-
bitbucket: { label: "Bitbucket", hint: "pull requests and pipelines" },
|
|
47
|
-
notion: { label: "Notion", hint: "pages and databases" },
|
|
48
|
-
slack: { label: "Slack", hint: "conversations and decisions" },
|
|
49
|
-
url: { label: "Link", hint: "" },
|
|
50
|
-
};
|
|
51
|
-
export const LINK_KINDS = ["knowledge", "vibeables", "repos", "workflows", "agents"];
|
|
43
|
+
export { RECORD_KINDS, recordLine };
|
|
44
|
+
/** What a project links to, in the order project.yml and the UI list them; each is a kind of the reference registry. */
|
|
45
|
+
export const LINK_KINDS = ["knowledge", "vibeables", "repos", "workflows", "agents", "contacts", "places", "events"];
|
|
52
46
|
export const toProjectHit = (p) => ({
|
|
53
47
|
slug: p.slug,
|
|
54
48
|
title: p.title,
|
|
55
49
|
status: p.status,
|
|
56
50
|
...(p.goal ? { goal: p.goal } : {}),
|
|
57
51
|
});
|
|
58
|
-
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,48}$/;
|
|
59
52
|
export function safeProjectSlug(slug) {
|
|
60
53
|
if (!SLUG_RE.test(slug))
|
|
61
54
|
throw new Error(`invalid project name "${slug}"`);
|
|
62
55
|
return slug;
|
|
63
56
|
}
|
|
64
57
|
/** Derive a slug from a title: "Relaunch netnode.ch" -> "relaunch-netnode-ch". */
|
|
65
|
-
export
|
|
66
|
-
return title
|
|
67
|
-
.normalize("NFKD")
|
|
68
|
-
.replace(/[̀-ͯ]/g, "")
|
|
69
|
-
.toLowerCase()
|
|
70
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
71
|
-
.replace(/^-+|-+$/g, "")
|
|
72
|
-
.slice(0, 48)
|
|
73
|
-
.replace(/-+$/, "");
|
|
74
|
-
}
|
|
58
|
+
export const projectSlugFromTitle = (title) => slugify(title);
|
|
75
59
|
/* ---------- root ---------- */
|
|
76
60
|
export async function openProjects() {
|
|
77
61
|
let project;
|
|
@@ -106,46 +90,6 @@ const strList = (v, field) => {
|
|
|
106
90
|
throw new Error(`${field} must be a list of names`);
|
|
107
91
|
return [...new Set(v.map((x) => x.trim()).filter(Boolean))];
|
|
108
92
|
};
|
|
109
|
-
const cleanRecords = (v) => {
|
|
110
|
-
if (v === undefined || v === null)
|
|
111
|
-
return [];
|
|
112
|
-
if (!Array.isArray(v))
|
|
113
|
-
throw new Error("records must be a list");
|
|
114
|
-
return v.map((r, i) => {
|
|
115
|
-
if (typeof r !== "object" || r === null || Array.isArray(r))
|
|
116
|
-
throw new Error(`records[${i}] must be a mapping (kind, title, url, workspace, note)`);
|
|
117
|
-
const o = r;
|
|
118
|
-
const kind = typeof o.kind === "string" ? o.kind.trim().toLowerCase() : "";
|
|
119
|
-
if (!kind)
|
|
120
|
-
throw new Error(`records[${i}]: kind is required (e.g. my-netnode, google-drive, url)`);
|
|
121
|
-
const str = (k) => {
|
|
122
|
-
const x = o[k];
|
|
123
|
-
if (x === undefined || x === null || x === "")
|
|
124
|
-
return undefined;
|
|
125
|
-
if (typeof x === "number")
|
|
126
|
-
return String(x);
|
|
127
|
-
if (typeof x !== "string")
|
|
128
|
-
throw new Error(`records[${i}].${k} must be a string`);
|
|
129
|
-
return x.trim() || undefined;
|
|
130
|
-
};
|
|
131
|
-
const url = str("url");
|
|
132
|
-
if (url && !/^https?:\/\//.test(url))
|
|
133
|
-
throw new Error(`records[${i}].url must start with http:// or https://`);
|
|
134
|
-
const out = { kind };
|
|
135
|
-
const title = str("title");
|
|
136
|
-
const workspace = str("workspace");
|
|
137
|
-
const note = str("note");
|
|
138
|
-
if (title)
|
|
139
|
-
out.title = title;
|
|
140
|
-
if (url)
|
|
141
|
-
out.url = url;
|
|
142
|
-
if (workspace)
|
|
143
|
-
out.workspace = workspace;
|
|
144
|
-
if (note)
|
|
145
|
-
out.note = note;
|
|
146
|
-
return out;
|
|
147
|
-
});
|
|
148
|
-
};
|
|
149
93
|
const cleanHarness = (v) => {
|
|
150
94
|
if (v === undefined || v === null || v === "")
|
|
151
95
|
return "claude";
|
|
@@ -196,13 +140,11 @@ function parseDef(slug, raw) {
|
|
|
196
140
|
...(model ? { model } : {}),
|
|
197
141
|
...(effort ? { effort } : {}),
|
|
198
142
|
records: cleanRecords(d.records),
|
|
199
|
-
|
|
200
|
-
vibeables: strList(d.vibeables, "vibeables"),
|
|
201
|
-
repos: strList(d.repos, "repos"),
|
|
202
|
-
workflows: strList(d.workflows, "workflows"),
|
|
203
|
-
agents: strList(d.agents, "agents"),
|
|
143
|
+
...linkLists((kind) => strList(d[kind], kind)),
|
|
204
144
|
};
|
|
205
145
|
}
|
|
146
|
+
/** The link lists of a definition, one per LINK_KIND, from a function that gives each. */
|
|
147
|
+
const linkLists = (of) => Object.fromEntries(LINK_KINDS.map((kind) => [kind, of(kind)]));
|
|
206
148
|
const emptyDef = (slug) => ({
|
|
207
149
|
slug,
|
|
208
150
|
title: slug,
|
|
@@ -210,11 +152,7 @@ const emptyDef = (slug) => ({
|
|
|
210
152
|
goal: "",
|
|
211
153
|
harness: "claude",
|
|
212
154
|
records: [],
|
|
213
|
-
|
|
214
|
-
vibeables: [],
|
|
215
|
-
repos: [],
|
|
216
|
-
workflows: [],
|
|
217
|
-
agents: [],
|
|
155
|
+
...linkLists(() => []),
|
|
218
156
|
});
|
|
219
157
|
/** project.yml as written: no slug (the folder is the slug), empty lists left out. */
|
|
220
158
|
function defToYaml(def) {
|
|
@@ -315,42 +253,8 @@ export async function saveLayout(input) {
|
|
|
315
253
|
await fs.writeFile(path.join(root, ORDER_FILE), `# The order of the projects in the sidebar, with its sections. Drag them there.\n${stringify(doc)}`);
|
|
316
254
|
return layout;
|
|
317
255
|
}
|
|
318
|
-
/** Which of the linked slugs exist right now, with a display label for the ones that do. */
|
|
319
|
-
export
|
|
320
|
-
const [knowledge, vibeables, repos, workflows, agents] = await Promise.all([
|
|
321
|
-
knowledgeIndex().catch(() => ({ bundles: [] })),
|
|
322
|
-
listVibeables().catch(() => ({ vibeables: [] })),
|
|
323
|
-
listRepos().catch(() => ({ repos: [] })),
|
|
324
|
-
listWorkflows().catch(() => ({ workflows: [] })),
|
|
325
|
-
listAgents().catch(() => []),
|
|
326
|
-
]);
|
|
327
|
-
const state = (slugs, lookup) => slugs.map((slug) => {
|
|
328
|
-
const label = lookup(slug);
|
|
329
|
-
return label === undefined ? { slug, found: false } : { slug, found: true, ...(label ? { label } : {}) };
|
|
330
|
-
});
|
|
331
|
-
return {
|
|
332
|
-
knowledge: state(def.knowledge, (s) => {
|
|
333
|
-
const b = knowledge.bundles.find((x) => x.name === s);
|
|
334
|
-
return b ? `${b.concepts} concepts` : undefined;
|
|
335
|
-
}),
|
|
336
|
-
vibeables: state(def.vibeables, (s) => {
|
|
337
|
-
const v = vibeables.vibeables.find((x) => x.slug === s);
|
|
338
|
-
return v ? (v.dev ? "dev" : "static") : undefined;
|
|
339
|
-
}),
|
|
340
|
-
repos: state(def.repos, (s) => {
|
|
341
|
-
const r = repos.repos.find((x) => x.slug === s);
|
|
342
|
-
return r ? r.branch ?? "" : undefined;
|
|
343
|
-
}),
|
|
344
|
-
workflows: state(def.workflows, (s) => {
|
|
345
|
-
const w = workflows.workflows.find((x) => x.slug === s);
|
|
346
|
-
return w ? w.description ?? w.name ?? "" : undefined;
|
|
347
|
-
}),
|
|
348
|
-
agents: state(def.agents, (s) => {
|
|
349
|
-
const a = agents.find((x) => x.slug === s && !x.archived);
|
|
350
|
-
return a ? `${a.emoji} ${a.name}` : undefined;
|
|
351
|
-
}),
|
|
352
|
-
};
|
|
353
|
-
}
|
|
256
|
+
/** Which of the linked slugs exist right now, with a display label for the ones that do (the reference registry decides). */
|
|
257
|
+
export const resolveLinks = (def) => resolveRefs(linkLists((kind) => def[kind]));
|
|
354
258
|
/** One project with its files and link states; null when the folder is not a project. */
|
|
355
259
|
export async function getProject(slug) {
|
|
356
260
|
const root = await requireRoot();
|
|
@@ -413,11 +317,7 @@ export async function saveProject(slug, input) {
|
|
|
413
317
|
goal: input.goal !== undefined ? String(input.goal).trim() : current.goal,
|
|
414
318
|
harness: input.harness !== undefined ? cleanHarness(input.harness) : current.harness,
|
|
415
319
|
records: input.records !== undefined ? cleanRecords(input.records) : current.records,
|
|
416
|
-
|
|
417
|
-
vibeables: input.vibeables !== undefined ? strList(input.vibeables, "vibeables") : current.vibeables,
|
|
418
|
-
repos: input.repos !== undefined ? strList(input.repos, "repos") : current.repos,
|
|
419
|
-
workflows: input.workflows !== undefined ? strList(input.workflows, "workflows") : current.workflows,
|
|
420
|
-
agents: input.agents !== undefined ? strList(input.agents, "agents") : current.agents,
|
|
320
|
+
...linkLists((kind) => (input[kind] !== undefined ? strList(input[kind], kind) : current[kind])),
|
|
421
321
|
};
|
|
422
322
|
const model = input.model !== undefined ? cleanModel(input.model) : current.model;
|
|
423
323
|
const effort = input.effort !== undefined ? cleanEffort(input.effort) : current.effort;
|
|
@@ -488,19 +388,6 @@ export async function deleteProject(slug) {
|
|
|
488
388
|
await moveToTrash("projects", slug, dir);
|
|
489
389
|
}
|
|
490
390
|
/* ---------- chat context ---------- */
|
|
491
|
-
/** One line per system of record: what it is, where, and what usually lives there. */
|
|
492
|
-
export function recordLine(r) {
|
|
493
|
-
const known = RECORD_KINDS[r.kind];
|
|
494
|
-
const label = known?.label ?? r.kind;
|
|
495
|
-
const head = r.title ? `${label}: ${r.title}` : label;
|
|
496
|
-
const parts = [
|
|
497
|
-
r.workspace ? `workspace ${r.workspace}` : "",
|
|
498
|
-
r.url ?? "",
|
|
499
|
-
known?.hint ?? "",
|
|
500
|
-
r.note ?? "",
|
|
501
|
-
].filter(Boolean);
|
|
502
|
-
return `- ${head}${parts.length ? ` — ${parts.join("; ")}` : ""}`;
|
|
503
|
-
}
|
|
504
391
|
/**
|
|
505
392
|
* The "## …" blocks a project chat starts with: brief, state, records,
|
|
506
393
|
* every link with how to reach it, and how to keep the project current.
|
|
@@ -517,6 +404,22 @@ export async function projectContext(slug, actor, opts = {}) {
|
|
|
517
404
|
const repos = await listRepos().catch(() => null);
|
|
518
405
|
const reposRoot = repos?.enabled && repos.root ? repos.root : undefined;
|
|
519
406
|
const vibeables = p.vibeables.length > 0;
|
|
407
|
+
// Who, where and when: the linked entries as one-liners, each with its reference token.
|
|
408
|
+
const entityBlock = async (def, slugs, heading, intro) => {
|
|
409
|
+
if (slugs.length === 0)
|
|
410
|
+
return "";
|
|
411
|
+
const view = await listEntities(def).catch(() => ({ items: [] }));
|
|
412
|
+
const lines = slugs.map((slug) => {
|
|
413
|
+
const item = view.items.find((x) => x.slug === slug);
|
|
414
|
+
return item ? entityLine(def, item) : `- ${def.singular}:${slug} (configured but not found in this workspace — tell the user if you need it)`;
|
|
415
|
+
});
|
|
416
|
+
return `## ${heading}\n${intro}\n${lines.join("\n")}\n\`npx kraftwerk ${def.kind} show <slug> --json\` has the full entry and notes.`;
|
|
417
|
+
};
|
|
418
|
+
const [contactsBlock, placesBlock, eventsBlock] = await Promise.all([
|
|
419
|
+
entityBlock(CONTACTS, p.contacts, "Contacts", "People and organisations of this project (reference one as contact:<slug>):"),
|
|
420
|
+
entityBlock(PLACES, p.places, "Places", "Where this project happens (reference one as place:<slug>):"),
|
|
421
|
+
entityBlock(EVENTS, p.events, "Events", "Dates of this project (reference one as event:<slug>):"),
|
|
422
|
+
]);
|
|
520
423
|
const sections = [
|
|
521
424
|
(opts.member
|
|
522
425
|
? `## Project: ${p.title}\nThis channel works in the project "${p.title}" (status ${p.status}) of this kraftwerk workspace. ` +
|
|
@@ -569,6 +472,9 @@ export async function projectContext(slug, actor, opts = {}) {
|
|
|
569
472
|
linkLines("agents", (l) => `- ${l.slug}${l.label ? ` — ${l.label}` : ""}`) +
|
|
570
473
|
`\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.`
|
|
571
474
|
: "",
|
|
475
|
+
contactsBlock,
|
|
476
|
+
placesBlock,
|
|
477
|
+
eventsBlock,
|
|
572
478
|
`## Keeping the project current\n` +
|
|
573
479
|
`- brief.md is the user's: propose changes and write them only when they agree.\n` +
|
|
574
480
|
`- state.md is the project's memory between sessions. At the end of a session that changed something, rewrite it so the next session ` +
|
|
@@ -576,7 +482,7 @@ export async function projectContext(slug, actor, opts = {}) {
|
|
|
576
482
|
`- log.md is append-only. Record decisions and milestones with\n` +
|
|
577
483
|
` npx kraftwerk projects log ${p.slug} "<one line>" --actor ${actor}\n` +
|
|
578
484
|
` (stamps the date and you as the author; never edit log.md by hand).\n` +
|
|
579
|
-
`- Links (knowledge, vibeables, repositories, workflows, agents) live in project.yml: \`npx kraftwerk projects link ${p.slug} <kind> <name>\` adds one, \`unlink\` removes it.`,
|
|
485
|
+
`- Links (knowledge, vibeables, repositories, workflows, agents, contacts, places, events) live in project.yml: \`npx kraftwerk projects link ${p.slug} <kind> <name>\` adds one, \`unlink\` removes it.`,
|
|
580
486
|
];
|
|
581
487
|
return sections.filter(Boolean).join("\n\n");
|
|
582
488
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Systems of record: where the truth of something is managed outside
|
|
3
|
+
* kraftwerk — a tool, a folder, a board. A project points at its ticket
|
|
4
|
+
* system, a contact at the CRM and the newsletter tool, an event at its
|
|
5
|
+
* calendar entry, a place at Google Maps. An entry is context, not a
|
|
6
|
+
* credential and not a grant: it says where the truth lives and how it is
|
|
7
|
+
* usually reached, and the harness decides what the agent may call.
|
|
8
|
+
*/
|
|
9
|
+
export interface SystemOfRecord {
|
|
10
|
+
/** my-netnode | google-drive | github | bitbucket | notion | slack | crm | newsletter | calendar | google-maps | url | anything else (rendered by name). */
|
|
11
|
+
kind: string;
|
|
12
|
+
/** Display name ("Figma board", "Contracts folder"). */
|
|
13
|
+
title?: string;
|
|
14
|
+
url?: string;
|
|
15
|
+
/** For tools with workspaces or boards: the id the agent needs (a my.netnode.ch workspace, a Slack channel). */
|
|
16
|
+
workspace?: string;
|
|
17
|
+
/** What lives there and how it is usually reached ("tickets and roadmap, `my` CLI"). */
|
|
18
|
+
note?: string;
|
|
19
|
+
}
|
|
20
|
+
/** What kraftwerk knows about a record kind: a label and what such a place usually holds. */
|
|
21
|
+
export declare const RECORD_KINDS: Record<string, {
|
|
22
|
+
label: string;
|
|
23
|
+
hint: string;
|
|
24
|
+
}>;
|
|
25
|
+
/** Any input -> a clean list of records; every problem is one error the caller can show. */
|
|
26
|
+
export declare const cleanRecords: (v: unknown) => SystemOfRecord[];
|
|
27
|
+
/** One line per system of record: what it is, where, and what usually lives there. */
|
|
28
|
+
export declare function recordLine(r: SystemOfRecord): string;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Systems of record: where the truth of something is managed outside
|
|
3
|
+
* kraftwerk — a tool, a folder, a board. A project points at its ticket
|
|
4
|
+
* system, a contact at the CRM and the newsletter tool, an event at its
|
|
5
|
+
* calendar entry, a place at Google Maps. An entry is context, not a
|
|
6
|
+
* credential and not a grant: it says where the truth lives and how it is
|
|
7
|
+
* usually reached, and the harness decides what the agent may call.
|
|
8
|
+
*/
|
|
9
|
+
/** What kraftwerk knows about a record kind: a label and what such a place usually holds. */
|
|
10
|
+
export const RECORD_KINDS = {
|
|
11
|
+
"my-netnode": { label: "my.netnode.ch", hint: "tickets, roadmap items, meetings and workspace memory" },
|
|
12
|
+
"google-drive": { label: "Google Drive", hint: "documents and folders" },
|
|
13
|
+
github: { label: "GitHub", hint: "issues, pull requests and discussions" },
|
|
14
|
+
bitbucket: { label: "Bitbucket", hint: "pull requests and pipelines" },
|
|
15
|
+
notion: { label: "Notion", hint: "pages and databases" },
|
|
16
|
+
slack: { label: "Slack", hint: "conversations and decisions" },
|
|
17
|
+
crm: { label: "CRM", hint: "the contact's record, deals and history" },
|
|
18
|
+
newsletter: { label: "Newsletter tool", hint: "subscription and mailing lists" },
|
|
19
|
+
calendar: { label: "Calendar", hint: "the calendar entry, invitees and reminders" },
|
|
20
|
+
"google-maps": { label: "Google Maps", hint: "the place on the map, opening hours and directions" },
|
|
21
|
+
url: { label: "Link", hint: "" },
|
|
22
|
+
};
|
|
23
|
+
/** Any input -> a clean list of records; every problem is one error the caller can show. */
|
|
24
|
+
export const cleanRecords = (v) => {
|
|
25
|
+
if (v === undefined || v === null)
|
|
26
|
+
return [];
|
|
27
|
+
if (!Array.isArray(v))
|
|
28
|
+
throw new Error("records must be a list");
|
|
29
|
+
return v.map((r, i) => {
|
|
30
|
+
if (typeof r !== "object" || r === null || Array.isArray(r))
|
|
31
|
+
throw new Error(`records[${i}] must be a mapping (kind, title, url, workspace, note)`);
|
|
32
|
+
const o = r;
|
|
33
|
+
const kind = typeof o.kind === "string" ? o.kind.trim().toLowerCase() : "";
|
|
34
|
+
if (!kind)
|
|
35
|
+
throw new Error(`records[${i}]: kind is required (e.g. my-netnode, google-drive, url)`);
|
|
36
|
+
const str = (k) => {
|
|
37
|
+
const x = o[k];
|
|
38
|
+
if (x === undefined || x === null || x === "")
|
|
39
|
+
return undefined;
|
|
40
|
+
if (typeof x === "number")
|
|
41
|
+
return String(x);
|
|
42
|
+
if (typeof x !== "string")
|
|
43
|
+
throw new Error(`records[${i}].${k} must be a string`);
|
|
44
|
+
return x.trim() || undefined;
|
|
45
|
+
};
|
|
46
|
+
const url = str("url");
|
|
47
|
+
if (url && !/^https?:\/\//.test(url))
|
|
48
|
+
throw new Error(`records[${i}].url must start with http:// or https://`);
|
|
49
|
+
const out = { kind };
|
|
50
|
+
const title = str("title");
|
|
51
|
+
const workspace = str("workspace");
|
|
52
|
+
const note = str("note");
|
|
53
|
+
if (title)
|
|
54
|
+
out.title = title;
|
|
55
|
+
if (url)
|
|
56
|
+
out.url = url;
|
|
57
|
+
if (workspace)
|
|
58
|
+
out.workspace = workspace;
|
|
59
|
+
if (note)
|
|
60
|
+
out.note = note;
|
|
61
|
+
return out;
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
/** One line per system of record: what it is, where, and what usually lives there. */
|
|
65
|
+
export function recordLine(r) {
|
|
66
|
+
const known = RECORD_KINDS[r.kind];
|
|
67
|
+
const label = known?.label ?? r.kind;
|
|
68
|
+
const head = r.title ? `${label}: ${r.title}` : label;
|
|
69
|
+
const parts = [r.workspace ? `workspace ${r.workspace}` : "", r.url ?? "", known?.hint ?? "", r.note ?? ""].filter(Boolean);
|
|
70
|
+
return `- ${head}${parts.length ? ` — ${parts.join("; ")}` : ""}`;
|
|
71
|
+
}
|