@netnodeag/kraftwerk 0.81.0 → 0.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +52 -0
  2. package/dist/cli/boards.d.ts +2 -0
  3. package/dist/cli/boards.js +486 -0
  4. package/dist/cli/init.js +1 -0
  5. package/dist/cli/kraftwerk.js +3 -0
  6. package/dist/client/routes.d.ts +14 -0
  7. package/dist/client/routes.js +14 -0
  8. package/dist/config.d.ts +19 -0
  9. package/dist/config.js +24 -1
  10. package/dist/core/attention.d.ts +2 -2
  11. package/dist/core/attention.js +22 -2
  12. package/dist/core/board-wake.d.ts +21 -0
  13. package/dist/core/board-wake.js +221 -0
  14. package/dist/core/boards.d.ts +256 -0
  15. package/dist/core/boards.js +936 -0
  16. package/dist/core/chat/acp.d.ts +1 -1
  17. package/dist/core/chat/acp.js +2 -1
  18. package/dist/core/chat/backend.d.ts +11 -0
  19. package/dist/core/chat/backend.js +9 -1
  20. package/dist/core/chat/context.js +16 -4
  21. package/dist/core/chat/pi.d.ts +1 -1
  22. package/dist/core/chat/pi.js +2 -1
  23. package/dist/core/chat/seats.js +3 -2
  24. package/dist/core/chat/sessions.d.ts +2 -0
  25. package/dist/core/chat/sessions.js +1 -0
  26. package/dist/core/chat/state.js +1 -1
  27. package/dist/core/chat/types.d.ts +11 -1
  28. package/dist/core/files.d.ts +1 -1
  29. package/dist/core/files.js +19 -1
  30. package/dist/core/git.js +3 -1
  31. package/dist/core/notifications.d.ts +1 -1
  32. package/dist/core/refs.d.ts +1 -1
  33. package/dist/core/refs.js +25 -1
  34. package/dist/core/search.d.ts +12 -0
  35. package/dist/core/search.js +19 -0
  36. package/dist/core/settings.d.ts +2 -0
  37. package/dist/core/settings.js +13 -1
  38. package/dist/core/trash.d.ts +1 -1
  39. package/dist/server/api/boards.d.ts +105 -0
  40. package/dist/server/api/boards.js +160 -0
  41. package/dist/server/api/chats.d.ts +2 -0
  42. package/dist/server/api/chats.js +9 -3
  43. package/dist/server/api/index.d.ts +105 -2
  44. package/dist/server/api/index.js +2 -0
  45. package/dist/server/api/workspace.d.ts +5 -0
  46. package/dist/server/api/workspace.js +2 -0
  47. package/dist/server/push.js +1 -1
  48. package/dist/server/server.js +3 -0
  49. package/package.json +1 -1
  50. package/schema/kraftwerk.schema.json +39 -0
  51. package/web/dist/assets/{dist-Dx3aajX1.js → dist-BBFUIftm.js} +1 -1
  52. package/web/dist/assets/{dist-ENZYo2jZ.js → dist-Bie1ebP9.js} +1 -1
  53. package/web/dist/assets/{dist-BiU4F98O.js → dist-BuwdjX6z.js} +1 -1
  54. package/web/dist/assets/{dist-8IOFzZx-.js → dist-C0ve4Tka.js} +1 -1
  55. package/web/dist/assets/{dist-FfKYtQxT.js → dist-C16bXf8c.js} +1 -1
  56. package/web/dist/assets/{dist-cewmjxFi.js → dist-CDUZdAAS.js} +1 -1
  57. package/web/dist/assets/{dist-BK0VhAPQ.js → dist-CH73jsgO.js} +1 -1
  58. package/web/dist/assets/{dist-Dy1SvhKC.js → dist-C_rJjRj7.js} +1 -1
  59. package/web/dist/assets/dist-DYICNp09.js +1 -0
  60. package/web/dist/assets/{dist-AhnSw4hy.js → dist-DtUB2Uu2.js} +1 -1
  61. package/web/dist/assets/{dist-Ba6WSVOB.js → dist-DvkrCSGL.js} +1 -1
  62. package/web/dist/assets/{editor-CaRa1NMC.js → editor-3z92i0tV.js} +3 -3
  63. package/web/dist/assets/{editor-CZMrhTTL.css → editor-C0_Snnzk.css} +1 -1
  64. package/web/dist/assets/index-DRhNELK_.js +232 -0
  65. package/web/dist/assets/index-unGEux3Y.css +2 -0
  66. package/web/dist/index.html +2 -2
  67. package/web/dist/assets/dist-lgUK2Fjt.js +0 -1
  68. package/web/dist/assets/index-Dpzux3OC.js +0 -231
  69. package/web/dist/assets/index-Dvt-vE1h.css +0 -2
package/dist/config.d.ts CHANGED
@@ -135,6 +135,23 @@ export interface EntitiesConfig {
135
135
  root?: string;
136
136
  }
137
137
  export declare const ENTITY_KINDS: readonly ["contacts", "places", "events"];
138
+ /**
139
+ * Boards: the work as columns of tasks, one folder per task (task.md, log.md,
140
+ * files) under a board folder — inside a project (projects/<p>/boards/) or
141
+ * at the workspace level under the boards root. Part of the workspace and
142
+ * synced by the workspace git; agents are woken by events on this machine.
143
+ */
144
+ export interface BoardsConfig {
145
+ /** false keeps the block but turns the feature off. Default: true. */
146
+ enabled?: boolean;
147
+ /** Where the workspace boards live, relative to the project root. Default: kraftwerk-data/boards */
148
+ root?: string;
149
+ /** Default columns for boards that name none (slugs or {slug, label, limit, done, review}). Default: draft, todo, progress, review, done. */
150
+ columns?: unknown;
151
+ }
152
+ export declare const BOARDS_DEFAULT_ROOT = "kraftwerk-data/boards";
153
+ /** Absolute workspace boards root when the feature is on, undefined otherwise. */
154
+ export declare function boardsRootFor(project: ResolvedWorkspace): string | undefined;
138
155
  export type EntityKind = (typeof ENTITY_KINDS)[number];
139
156
  /** Where a kind's files live when its `root` is not set. */
140
157
  export declare const entityDefaultRoot: (kind: EntityKind) => string;
@@ -218,6 +235,8 @@ export interface WorkspaceConfig {
218
235
  places?: EntitiesConfig;
219
236
  /** Events: dated entries (one-off), one Markdown file each. Absent = off. */
220
237
  events?: EntitiesConfig;
238
+ /** Boards: tasks as folders in columns, per project and workspace-wide. Absent = off. */
239
+ boards?: BoardsConfig;
221
240
  /** The machine's cloud record lists this workspace. Absent = listed; `enabled: false` leaves it out. */
222
241
  cloud?: CloudConfig;
223
242
  }
package/dist/config.js CHANGED
@@ -97,6 +97,14 @@ export function projectsRootFor(project) {
97
97
  return path.resolve(project.root, p.root ?? PROJECTS_DEFAULT_ROOT);
98
98
  }
99
99
  export const ENTITY_KINDS = ["contacts", "places", "events"];
100
+ export const BOARDS_DEFAULT_ROOT = "kraftwerk-data/boards";
101
+ /** Absolute workspace boards root when the feature is on, undefined otherwise. */
102
+ export function boardsRootFor(project) {
103
+ const b = project.config.boards;
104
+ if (!b || b.enabled === false)
105
+ return undefined;
106
+ return path.resolve(project.root, b.root ?? BOARDS_DEFAULT_ROOT);
107
+ }
100
108
  /** Where a kind's files live when its `root` is not set. */
101
109
  export const entityDefaultRoot = (kind) => `kraftwerk-data/${kind}`;
102
110
  /** Absolute root of a kind when its feature is on, undefined otherwise. */
@@ -241,7 +249,7 @@ export async function addWorkspaceId(configPath) {
241
249
  await writeFile(configPath, out);
242
250
  return id;
243
251
  }
244
- const KNOWN_KEYS = ["id", "name", "slug", "icon", "color", "port", "workflows", "output", "knowledge", "agents", "skills", "switcher", "git", "repos", "vibeables", "projects", "contacts", "places", "events", "cloud"];
252
+ const KNOWN_KEYS = ["id", "name", "slug", "icon", "color", "port", "workflows", "output", "knowledge", "agents", "skills", "switcher", "git", "repos", "vibeables", "projects", "contacts", "places", "events", "boards", "cloud"];
245
253
  /**
246
254
  * Keys an older kraftwerk understood and this one ignores, so the files
247
255
  * that still carry them keep loading: `public:` and `tunnel:` (the
@@ -313,6 +321,11 @@ async function loadConfig(configPath) {
313
321
  config[key] = {};
314
322
  validateRootBlock(configPath, key, config[key]);
315
323
  }
324
+ else if (key === "boards") {
325
+ if (config[key] === null)
326
+ config[key] = {};
327
+ validateBoards(configPath, config[key]);
328
+ }
316
329
  else if (key === "cloud") {
317
330
  if (config[key] === null)
318
331
  config[key] = {};
@@ -418,6 +431,16 @@ function validateGit(configPath, value) {
418
431
  * anything outside it — it is what remove/delete operate under, and what
419
432
  * the workspace git may stage.
420
433
  */
434
+ /** `boards`: the root block plus `columns`, which core/boards.ts reads (a bad list falls back to the defaults there). */
435
+ function validateBoards(configPath, value) {
436
+ const file = path.basename(configPath);
437
+ if (typeof value !== "object" || value === null || Array.isArray(value))
438
+ throw new Error(`${file}: boards must be a mapping (enabled, root, columns)`);
439
+ const { columns, ...rest } = value;
440
+ if (columns !== undefined && columns !== null && !Array.isArray(columns))
441
+ throw new Error(`${file}: boards.columns must be a list of columns`);
442
+ validateRootBlock(configPath, "boards", rest);
443
+ }
421
444
  function validateRootBlock(configPath, block, value) {
422
445
  const file = path.basename(configPath);
423
446
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
@@ -33,8 +33,8 @@ export interface AttentionOwner {
33
33
  export interface AttentionItem {
34
34
  /** The request id (live requests) or the notification id (failures). */
35
35
  id: string;
36
- /** runner: routines without a runner, or naming a retired identity of this machine (see routines.ts). */
37
- kind: "approval" | "question" | "failed" | "runner";
36
+ /** runner: routines without a runner, or naming a retired identity of this machine (see routines.ts); review: a task waits in a review column (boards.ts). */
37
+ kind: "approval" | "question" | "failed" | "runner" | "review";
38
38
  /** What is asked, or what went wrong. */
39
39
  title: string;
40
40
  /** The session it sits in. */
@@ -1,14 +1,16 @@
1
1
  import { listAgents } from "./agents.js";
2
+ import { getBoard, listAllBoards } from "./boards.js";
2
3
  import { waitingRequests } from "./chat/sessions.js";
3
4
  import { chatHref, listNotifications } from "./notifications.js";
4
5
  import { listProjects } from "./projects.js";
5
6
  import { runnerFindings } from "./routines.js";
6
7
  export async function listAttention() {
7
- const [agents, projects, notes, runners] = await Promise.all([
8
+ const [agents, projects, notes, runners, boards] = await Promise.all([
8
9
  listAgents().catch(() => []),
9
10
  listProjects().catch(() => ({ projects: [] })),
10
11
  listNotifications().catch(() => ({ items: [] })),
11
12
  runnerFindings().catch(() => ({ unassigned: [], retired: [] })),
13
+ listAllBoards().catch(() => []),
12
14
  ]);
13
15
  const agentOf = (slug) => {
14
16
  const a = agents.find((x) => x.slug === slug);
@@ -101,6 +103,24 @@ export async function listAttention() {
101
103
  routines: these,
102
104
  });
103
105
  }
104
- const items = [...waiting, ...failed, ...runner].sort((a, b) => a.since.localeCompare(b.since));
106
+ // Tasks in a review column: the agents are done, a human decides. One item per task, placed in its project (workspace boards: general).
107
+ const review = [];
108
+ for (const b of boards) {
109
+ const detail = await getBoard(b).catch(() => null);
110
+ if (!detail)
111
+ continue;
112
+ const reviewCols = new Set(detail.columns.filter((c) => c.review).map((c) => c.slug));
113
+ for (const t of detail.items.filter((t) => reviewCols.has(t.status))) {
114
+ review.push({
115
+ id: `review:${t.ref}`,
116
+ kind: "review",
117
+ title: `${t.number} ${t.title} waits for your review${t.lastLog ? ` — ${t.lastLog}` : ""}`,
118
+ href: b.project ? `/projects/${b.project}/boards/${b.board}/${t.slug}` : `/boards/${b.board}/${t.slug}`,
119
+ since: t.updatedAt ?? new Date(0).toISOString(),
120
+ owner: b.project ? { project: projectOf(b.project) } : { general: true },
121
+ });
122
+ }
123
+ }
124
+ const items = [...waiting, ...failed, ...runner, ...review].sort((a, b) => a.since.localeCompare(b.since));
105
125
  return { items };
106
126
  }
@@ -0,0 +1,21 @@
1
+ /** A chat on the task page: the agent's task session (scope.task) or a chat tagged with the task (meta.task, "work on this"). */
2
+ export interface TaskSession {
3
+ id: string;
4
+ /** The agent slug of a task session; "claude" | "codex" | "pi" for a plain chat tagged with the task. */
5
+ agent: string;
6
+ /** The agent's display name and emoji when it is one of the workspace's agents. */
7
+ name?: string;
8
+ emoji?: string;
9
+ title: string;
10
+ updatedAt: string;
11
+ busy: boolean;
12
+ /** The chat's route in the UI (#/agents/<slug>/chat/<id>, #/projects/<p>/chat/<id>, …). */
13
+ href: string;
14
+ }
15
+ /** The sessions that worked on a task, newest first: what the task page links the log's actors to. */
16
+ export declare function taskSessions(ref: string): Promise<TaskSession[]>;
17
+ /** Consume what was appended since the last run. Serialised: a run that overlaps is queued, never doubled. */
18
+ export declare function consumeTaskEvents(): Promise<number>;
19
+ /** Start consuming the current workspace's task events: on every change signal, on file changes, and every FALLBACK_MS anyway. */
20
+ export declare function startBoardWake(): void;
21
+ export declare function stopBoardWake(): void;
@@ -0,0 +1,221 @@
1
+ import { promises as fs, watch } from "node:fs";
2
+ import path from "node:path";
3
+ import { getAgent, listAgents } from "./agents.js";
4
+ import { EVENTS_FILE } from "./boards.js";
5
+ import { onChanged } from "./changes.js";
6
+ import { createChat, listChats, postMessage } from "./chat/sessions.js";
7
+ import { getOutputDir } from "./context.js";
8
+ import { chatHref, dismissKey, pushNotification } from "./notifications.js";
9
+ import { perWorkspace, workspaceOpen } from "./workspace.js";
10
+ /**
11
+ * Waking agents for their tasks. boards.ts appends a TaskEvent line to
12
+ * <output>/boards/events.jsonl whenever a task with an agent as assignee
13
+ * sits in a work column after a change (created there, moved there,
14
+ * assigned); this module, started by the server for every open workspace,
15
+ * consumes the file: for each event it opens a session for the agent with
16
+ * the task as its scope (ChatScope.task → the task block in its context,
17
+ * unattended like a routine run) and posts why it was woken — or, when the
18
+ * agent already has a live session for that task, posts into that one.
19
+ *
20
+ * The file is read past a remembered offset (<output>/boards/events.offset),
21
+ * so every line is acted on once; it is rotated when it grows large. Writers
22
+ * append whole lines (O_APPEND), so a half-written line never appears.
23
+ */
24
+ const STATE_FILE = "boards/events.offset";
25
+ const ROTATE_OVER = 256 * 1024;
26
+ const FALLBACK_MS = 20_000;
27
+ const consumer = perWorkspace(() => ({
28
+ timer: null,
29
+ watcher: null,
30
+ unsubscribe: null,
31
+ running: false,
32
+ again: false,
33
+ }));
34
+ const eventsFile = () => path.join(getOutputDir(), EVENTS_FILE);
35
+ const offsetFile = () => path.join(getOutputDir(), STATE_FILE);
36
+ async function readOffset() {
37
+ const n = Number(await fs.readFile(offsetFile(), "utf8").catch(() => "0"));
38
+ return Number.isFinite(n) && n >= 0 ? n : 0;
39
+ }
40
+ /** Lines appended since the offset; the new offset. */
41
+ async function readNew() {
42
+ const file = eventsFile();
43
+ const st = await fs.stat(file).catch(() => null);
44
+ if (!st)
45
+ return { events: [], offset: 0, size: 0 };
46
+ let offset = await readOffset();
47
+ if (offset > st.size)
48
+ offset = 0; // the file was rotated or replaced
49
+ if (offset === st.size)
50
+ return { events: [], offset, size: st.size };
51
+ const fh = await fs.open(file, "r");
52
+ try {
53
+ const buf = Buffer.alloc(st.size - offset);
54
+ await fh.read(buf, 0, buf.length, offset);
55
+ const text = buf.toString("utf8");
56
+ const complete = text.lastIndexOf("\n");
57
+ if (complete < 0)
58
+ return { events: [], offset, size: st.size };
59
+ const events = [];
60
+ for (const line of text.slice(0, complete).split("\n")) {
61
+ if (!line.trim())
62
+ continue;
63
+ try {
64
+ events.push(JSON.parse(line));
65
+ }
66
+ catch {
67
+ /* a broken line is skipped, not retried forever */
68
+ }
69
+ }
70
+ return { events, offset: offset + complete + 1, size: st.size };
71
+ }
72
+ finally {
73
+ await fh.close();
74
+ }
75
+ }
76
+ /** Keep the file small: once everything is consumed and it is big, start over (a writer racing the rename lands in the new file). */
77
+ async function rotate(offset, size) {
78
+ if (size < ROTATE_OVER || offset < size)
79
+ return;
80
+ await fs.rename(eventsFile(), `${eventsFile()}.old`).catch(() => { });
81
+ await fs.rm(`${eventsFile()}.old`, { force: true }).catch(() => { });
82
+ await fs.writeFile(offsetFile(), "0");
83
+ }
84
+ /** The message the agent gets: why it was woken, in one paragraph; the task itself is in its context block. */
85
+ function wakeMessage(ev, resumed) {
86
+ const who = ev.actor.replace(/^human:/, "");
87
+ const why = ev.reason === "created"
88
+ ? `Task ${ev.task} ("${ev.title}") was created for you by ${who}`
89
+ : ev.reason === "assigned"
90
+ ? `Task ${ev.task} ("${ev.title}") was handed to you by ${who}`
91
+ : `Task ${ev.task} ("${ev.title}") was moved to ${ev.status} for you by ${who}`;
92
+ return (`${why}${ev.message ? `: ${ev.message}` : "."}\n` +
93
+ (resumed
94
+ ? `Continue with it: read the task's log for what changed (\`npx kraftwerk tasks show ${ev.task} ${cliFlags(ev)}\`).`
95
+ : `It is in ${ev.status}. Read the task block in your context (task.md, files, log), move it to the work column when you start, log what you do, and hand it to review when done.`));
96
+ }
97
+ const cliFlags = (ev) => (ev.project ? `--project ${ev.project} --board ${ev.board}` : `--board ${ev.board}`);
98
+ const taskOf = (c) => c.task ?? (c.scope.kind === "agent" ? c.scope.task : undefined);
99
+ /** The sessions that worked on a task, newest first: what the task page links the log's actors to. */
100
+ export async function taskSessions(ref) {
101
+ const [chats, agents] = await Promise.all([listChats(), listAgents().catch(() => [])]);
102
+ return chats
103
+ .filter((c) => taskOf(c) === ref)
104
+ .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1))
105
+ .map((c) => {
106
+ const slug = c.scope.kind === "agent" ? c.scope.slug : c.agent;
107
+ const def = agents.find((a) => a.slug === slug);
108
+ return { id: c.id, agent: slug, ...(def ? { name: def.name, emoji: def.emoji } : {}), title: c.title, updatedAt: c.updatedAt, busy: c.busy, href: chatHref(c) };
109
+ });
110
+ }
111
+ /** One event: post into the agent's live session for this task, or open one. */
112
+ async function wake(ev) {
113
+ const def = await getAgent(ev.agent);
114
+ if (!def)
115
+ throw new Error(`agent "${ev.agent}" not found`);
116
+ const existing = (await listChats()).find((c) => c.scope.kind === "agent" && c.scope.slug === ev.agent && c.scope.task === ev.ref);
117
+ // Already on it (a turn is running in its session for this task): the agent reads the log when it looks; no second session.
118
+ if (existing?.busy)
119
+ return;
120
+ const meta = existing ??
121
+ (await createChat({
122
+ agent: def.harness,
123
+ scope: { kind: "agent", slug: ev.agent, task: ev.ref },
124
+ title: `📋 ${ev.task}`,
125
+ ...(ev.project ? { project: ev.project } : {}),
126
+ }));
127
+ const { error } = await postMessage(meta.id, wakeMessage(ev, !!existing));
128
+ if (error)
129
+ throw new Error(error);
130
+ await dismissKey(`task-wake:${ev.ref}`);
131
+ await pushNotification({
132
+ kind: "task_woken",
133
+ title: `📋 ${ev.task} → ${def.emoji} ${def.name}`,
134
+ body: ev.message ?? ev.title,
135
+ href: chatHref(meta),
136
+ key: `task-woken:${ev.ref}:${ev.agent}`,
137
+ }).catch(() => { });
138
+ }
139
+ /** Consume what was appended since the last run. Serialised: a run that overlaps is queued, never doubled. */
140
+ export async function consumeTaskEvents() {
141
+ const c = consumer();
142
+ if (c.running) {
143
+ c.again = true;
144
+ return 0;
145
+ }
146
+ c.running = true;
147
+ let handled = 0;
148
+ try {
149
+ const { events, offset, size } = await readNew();
150
+ if (events.length) {
151
+ // The offset moves first: an event that keeps failing must not be retried on every tick.
152
+ await fs.mkdir(path.dirname(offsetFile()), { recursive: true });
153
+ await fs.writeFile(offsetFile(), String(offset));
154
+ // The same task for the same agent twice in one batch (assign, then move): the last reason wins.
155
+ const latest = new Map();
156
+ for (const ev of events)
157
+ latest.set(`${ev.ref}:${ev.agent}`, ev);
158
+ for (const ev of latest.values()) {
159
+ try {
160
+ await wake(ev);
161
+ handled++;
162
+ }
163
+ catch (err) {
164
+ await pushNotification({
165
+ kind: "task_wake_failed",
166
+ title: `📋 ${ev.task}: ${ev.agent} could not be started`,
167
+ body: err.message,
168
+ href: ev.project ? `/projects/${ev.project}/boards/${ev.board}/${ev.task}` : `/boards/${ev.board}/${ev.task}`,
169
+ key: `task-wake:${ev.ref}`,
170
+ }).catch(() => { });
171
+ }
172
+ }
173
+ }
174
+ await rotate(offset, size);
175
+ }
176
+ finally {
177
+ c.running = false;
178
+ if (c.again) {
179
+ c.again = false;
180
+ void consumeTaskEvents().catch(() => { });
181
+ }
182
+ }
183
+ return handled;
184
+ }
185
+ /** Start consuming the current workspace's task events: on every change signal, on file changes, and every FALLBACK_MS anyway. */
186
+ export function startBoardWake() {
187
+ const c = consumer();
188
+ if (c.timer)
189
+ return;
190
+ const kick = () => {
191
+ if (!workspaceOpen())
192
+ return;
193
+ void consumeTaskEvents().catch(() => { });
194
+ };
195
+ c.timer = setInterval(kick, FALLBACK_MS);
196
+ c.timer.unref?.();
197
+ c.unsubscribe = onChanged(kick);
198
+ const dir = path.dirname(eventsFile());
199
+ void fs.mkdir(dir, { recursive: true }).then(() => {
200
+ if (!c.timer || c.watcher)
201
+ return;
202
+ try {
203
+ c.watcher = watch(dir, { persistent: false }, kick);
204
+ c.watcher.on("error", () => { });
205
+ }
206
+ catch {
207
+ /* no watcher: the interval still runs */
208
+ }
209
+ });
210
+ kick();
211
+ }
212
+ export function stopBoardWake() {
213
+ const c = consumer();
214
+ if (c.timer)
215
+ clearInterval(c.timer);
216
+ c.timer = null;
217
+ c.unsubscribe?.();
218
+ c.unsubscribe = null;
219
+ c.watcher?.close();
220
+ c.watcher = null;
221
+ }
@@ -0,0 +1,256 @@
1
+ import { type ResolvedWorkspace } from "../config.js";
2
+ /**
3
+ * Boards and tasks: the work of a project (or of the whole workspace) as
4
+ * columns of cards, stored as plain folders so humans, agents and git all
5
+ * read the same thing. A board holds columns and tasks; a task is a folder
6
+ * with the markdown that describes it, an append-only log of what happened
7
+ * to it, and whatever files it needs.
8
+ *
9
+ * projects/<p>/boards/<board>/board.md # title, columns, default agent + the board's rules (body)
10
+ * projects/<p>/boards/<board>/tasks/<n>-<slug>/task.md # title, status (a column), order, assignee, due, tags, refs + the task (body)
11
+ * projects/<p>/boards/<board>/tasks/<n>-<slug>/log.md # newest first: moves, hand-overs, comments, files (journal format)
12
+ * projects/<p>/boards/<board>/tasks/<n>-<slug>/… # the task's files
13
+ * kraftwerk-data/boards/<board>/… # workspace boards, same shape (`boards.root`)
14
+ *
15
+ * The folder is the registry: a folder with a board.md is a board, a
16
+ * folder under tasks/ with a task.md is a task. The column is a field of
17
+ * task.md, not a folder per column, so a move is a one-line diff and the
18
+ * task's path (links, files) never changes. Task folders are numbered per
19
+ * board (`50-write-a-press-release`): the number is the short handle in
20
+ * the CLI, the whole name is the reference token (task:50-write-a-press-release).
21
+ *
22
+ * Agents are woken by events, not by polling: whoever writes a task (an API
23
+ * route, the CLI in another process) appends a line to <output>/boards/events.jsonl
24
+ * when a task with an agent as assignee sits in a work column, and the server
25
+ * on this machine consumes the file (board-wake.ts). The output folder is
26
+ * local, so a clone of the workspace never sees another machine's events.
27
+ */
28
+ export declare const BOARD_FILE = "board.md";
29
+ export declare const TASK_FILE = "task.md";
30
+ export declare const TASK_LOG = "log.md";
31
+ /** The folder (and the default board's slug) a project's tasks go to when no board is named. */
32
+ export declare const DEFAULT_BOARD = "tasks";
33
+ export interface Column {
34
+ slug: string;
35
+ label: string;
36
+ /** Work-in-progress limit; the UI marks the column when it is exceeded, nothing is refused. */
37
+ limit?: number;
38
+ /** Tasks here are finished. */
39
+ done?: boolean;
40
+ /** Tasks here wait for a human: they count as attention. */
41
+ review?: boolean;
42
+ }
43
+ export declare const DEFAULT_COLUMNS: Column[];
44
+ /** Where a board lives: in a project, or at the workspace level. */
45
+ export interface BoardRef {
46
+ project?: string;
47
+ board: string;
48
+ }
49
+ export interface BoardDef extends BoardRef {
50
+ title: string;
51
+ columns: Column[];
52
+ /** Tasks entering todo without an assignee get this agent (and wake it). */
53
+ agent?: string;
54
+ /** A task in a work column with no log entry for this long shows as stale. Default: 1d. */
55
+ staleAfter?: string;
56
+ }
57
+ export interface BoardSummary extends BoardDef {
58
+ /** Absolute folder path. */
59
+ path: string;
60
+ updatedAt?: string;
61
+ /** Set when board.md exists but is unusable; the rest shows defaults. */
62
+ configError?: string;
63
+ /** Tasks per column slug (tasks in no known column under ""). */
64
+ counts: Record<string, number>;
65
+ tasks: number;
66
+ /** Tasks in a review column. */
67
+ review: number;
68
+ }
69
+ export interface BoardDetail extends BoardSummary {
70
+ /** The body of board.md: what the board is for, the rules agents follow. */
71
+ notes: string;
72
+ items: TaskSummary[];
73
+ }
74
+ export interface TaskDef {
75
+ /** The folder name: <number>-<slug>. */
76
+ slug: string;
77
+ number: number;
78
+ title: string;
79
+ /** A column slug of the board. */
80
+ status: string;
81
+ /** Position inside the column; sparse, renumbered on save. */
82
+ order: number;
83
+ /** An agent slug; absent = unassigned. */
84
+ assignee?: string;
85
+ due?: string;
86
+ created: string;
87
+ tags: string[];
88
+ /** kind:slug tokens this task refers to. */
89
+ refs: string[];
90
+ /** Unknown frontmatter keys, kept as written. */
91
+ extra?: Record<string, unknown>;
92
+ }
93
+ export interface TaskSummary extends TaskDef {
94
+ /** The task's reference: <project>/<board>/<slug> or <board>/<slug>. */
95
+ ref: string;
96
+ path: string;
97
+ updatedAt?: string;
98
+ configError?: string;
99
+ /** Files in the folder (task.md, log.md and dot files not counted). */
100
+ files: number;
101
+ /** The newest log line, without its stamp. */
102
+ lastLog?: string;
103
+ /** In a work column with an assignee and nothing logged for staleAfter. */
104
+ stale?: boolean;
105
+ }
106
+ export interface TaskDetail extends TaskSummary {
107
+ /** The body of task.md. */
108
+ notes: string;
109
+ /** log.md as written. */
110
+ log: string;
111
+ /** The task's files, relative to its folder. */
112
+ fileList: string[];
113
+ board: string;
114
+ project?: string;
115
+ }
116
+ export interface BoardsView {
117
+ enabled: boolean;
118
+ /** The workspace boards root. */
119
+ root?: string;
120
+ boards: BoardSummary[];
121
+ error?: string;
122
+ }
123
+ /** Every field optional: omitted = untouched. */
124
+ export interface SaveBoardInput {
125
+ title?: string;
126
+ columns?: unknown;
127
+ agent?: string | null;
128
+ staleAfter?: string | null;
129
+ notes?: string;
130
+ }
131
+ export interface TaskInput {
132
+ title?: string;
133
+ status?: string;
134
+ order?: number;
135
+ assignee?: string | null;
136
+ due?: string | null;
137
+ tags?: unknown;
138
+ refs?: unknown;
139
+ notes?: string;
140
+ }
141
+ /** Who writes (an actor string: human:user, lisa/claude, …) and an optional message for the log. */
142
+ export interface WriteOpts {
143
+ actor?: string;
144
+ message?: string;
145
+ }
146
+ export declare function openBoards(): Promise<{
147
+ project?: ResolvedWorkspace;
148
+ root?: string;
149
+ off?: boolean;
150
+ error?: string;
151
+ }>;
152
+ /** Make sure the workspace boards root exists; runs when the feature is switched on. */
153
+ export declare function ensureBoardsRoot(): Promise<void>;
154
+ export declare function safeBoardSlug(slug: string): string;
155
+ /** The folder name of a task: <number>-<slug>. */
156
+ export declare function safeTaskSlug(slug: string): string;
157
+ /** The folder a board lives in: inside its project (projects must be on) or under the workspace root. */
158
+ export declare function boardDir(ref: BoardRef): Promise<string>;
159
+ /** "<project>/<board>" or "<board>". */
160
+ export declare const boardKey: (ref: BoardRef) => string;
161
+ /** "<project>/<board>/<task>" or "<board>/<task>". */
162
+ export declare const taskRef: (ref: BoardRef, task: string) => string;
163
+ /** Parse "<project>/<board>/<task>" or "<board>/<task>" (and the two-or-one-part board forms when `task` is false). */
164
+ export declare function parseTaskRef(text: string): {
165
+ board: BoardRef;
166
+ task: string;
167
+ };
168
+ export declare function parseBoardRef(text: string): BoardRef;
169
+ /** "1d", "12h", "90m" -> milliseconds. */
170
+ export declare function parseDuration(text: string): number;
171
+ /** The column `tasks pick` takes from: "todo" when the board has it, else the second column (the first is the inbox), else the only one. */
172
+ export declare const pickColumn: (columns: Column[]) => Column | undefined;
173
+ /** A column's role: work columns — from the pick column on, review and done excepted — are where an assigned agent is expected to act. */
174
+ export declare const isWorkColumn: (c: Column | undefined, columns: Column[]) => boolean;
175
+ export declare const reviewColumn: (columns: Column[]) => Column | undefined;
176
+ /** The boards of a project (its boards/ folder), or the workspace boards when no project is given. */
177
+ export declare function listBoards(project?: string): Promise<BoardsView>;
178
+ /** Every board of the workspace: the workspace ones first, then each project's. */
179
+ export declare function listAllBoards(): Promise<BoardSummary[]>;
180
+ export declare function getBoard(ref: BoardRef): Promise<BoardDetail | null>;
181
+ /** Tasks of a board, optionally narrowed by column, assignee (or "" for unassigned) and tag. */
182
+ export declare function listTasks(ref: BoardRef, filter?: {
183
+ status?: string;
184
+ assignee?: string;
185
+ tag?: string;
186
+ }): Promise<TaskSummary[]>;
187
+ export declare function getTask(ref: BoardRef, idOrNumber: string): Promise<TaskDetail | null>;
188
+ /** Find a task by its reference anywhere: "<project>/<board>/<task>" or "<board>/<task>". */
189
+ export declare function findTask(reference: string): Promise<TaskDetail | null>;
190
+ export declare function createBoard(input: {
191
+ project?: string;
192
+ title: string;
193
+ slug?: string;
194
+ columns?: unknown;
195
+ agent?: string;
196
+ notes?: string;
197
+ }): Promise<BoardDetail>;
198
+ export declare function saveBoard(ref: BoardRef, input: SaveBoardInput): Promise<BoardDetail>;
199
+ /** Move the board folder, tasks and all, to the trash. */
200
+ export declare function deleteBoard(ref: BoardRef): Promise<void>;
201
+ /** The project's default board (`tasks`), created on first use. */
202
+ export declare function ensureDefaultBoard(project?: string): Promise<BoardRef>;
203
+ /** Append a free entry to a task's log: what was done, a comment, a question. */
204
+ export declare function appendTaskLog(ref: BoardRef, idOrNumber: string, entry: string, actor?: string): Promise<TaskDetail>;
205
+ /** What the server acts on: a task with an agent in a work column, after a change of that pair. */
206
+ export interface TaskEvent {
207
+ at: string;
208
+ ref: string;
209
+ project?: string;
210
+ board: string;
211
+ task: string;
212
+ title: string;
213
+ agent: string;
214
+ status: string;
215
+ /** Why the agent is woken, for the message it gets. */
216
+ reason: "assigned" | "moved" | "created";
217
+ actor: string;
218
+ message?: string;
219
+ }
220
+ export declare const EVENTS_FILE = "boards/events.jsonl";
221
+ export declare function createTask(ref: BoardRef, input: TaskInput & {
222
+ actor?: string;
223
+ message?: string;
224
+ }): Promise<TaskDetail>;
225
+ /** Merge fields (and the notes when given) into task.md; a status or assignee change is logged and may wake the assignee. */
226
+ export declare function saveTask(ref: BoardRef, idOrNumber: string, input: TaskInput, opts?: WriteOpts): Promise<TaskDetail>;
227
+ /** Put a task into a column (and, with `order`, at a position: the orders of the column are renumbered around it). */
228
+ export declare function moveTask(ref: BoardRef, idOrNumber: string, status: string, opts?: WriteOpts & {
229
+ order?: number;
230
+ before?: string;
231
+ }): Promise<TaskDetail>;
232
+ /** Give the task to an agent, or back to the humans (`to` = null: unassigned and into the review column). */
233
+ export declare function assignTask(ref: BoardRef, idOrNumber: string, to: string | null, opts?: WriteOpts): Promise<TaskDetail>;
234
+ /**
235
+ * An agent takes the next task: the first unassigned (or its own) task in
236
+ * the pick column, in the column's order (due date as tie-break), becomes
237
+ * its and moves to the first work column after it. Exclusive: under the
238
+ * task's lock the file is read again, so two agents never take the same one.
239
+ */
240
+ export declare function pickTask(ref: BoardRef, agent: string, opts?: {
241
+ column?: string;
242
+ actor?: string;
243
+ }): Promise<TaskDetail | null>;
244
+ /** Move the task folder, files and log included, to the trash. */
245
+ export declare function deleteTask(ref: BoardRef, idOrNumber: string): Promise<void>;
246
+ /** Called by the files API after a file was added to or removed from a task folder. */
247
+ export declare function noteTaskFile(reference: string, what: "added" | "removed", file: string, actor?: string): Promise<void>;
248
+ /**
249
+ * The "## Board: …" blocks of a project chat (or of an agent for the
250
+ * boards it has tasks on): the board's rules, its columns with counts, the
251
+ * open tasks, and the CLI. `agent` narrows the task list to that agent's
252
+ * and the unassigned ones in the pick column.
253
+ */
254
+ export declare function boardsContext(project: string | undefined, actor: string, agent?: string): Promise<string>;
255
+ /** The block a session gets when it was opened for one task: the task, its files and its whole log. */
256
+ export declare function taskContext(reference: string): Promise<string>;