@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
@@ -1,6 +1,6 @@
1
1
  import { type ContentBlock, type SessionConfigOption, type SessionNotification } from "@agentclientprotocol/sdk";
2
2
  import { type AcpAgent, type AcpExtensionNotification } from "../../acp.js";
3
- import type { BackendHooks, BackendTuning, ChatBackend, PromptFile } from "./backend.js";
3
+ import { type BackendHooks, type BackendTuning, type ChatBackend, type PromptFile } from "./backend.js";
4
4
  import type { AuthStatus, ChatEvent, ConfigOption, ElicitationField, SessionFailure } from "./types.js";
5
5
  /** The session failure a `session_info_update` carries, if it is one (AIR `sessionFailure` in `_meta`). */
6
6
  export declare function failureOf(meta: unknown): SessionFailure | undefined;
@@ -3,6 +3,7 @@ import { promises as fs } from "node:fs";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { RequestError, } from "@agentclientprotocol/sdk";
5
5
  import { adapterEnv, airMeta, connectAcp, EXT_METHODS, EXT_NOTIFICATIONS, fileChangeReportMeta, openSession, withAdapter, } from "../../acp.js";
6
+ import { identityEnv } from "./backend.js";
6
7
  import { tempEnv } from "../temp.js";
7
8
  import { unattendedMode } from "./permissions.js";
8
9
  /**
@@ -390,7 +391,7 @@ export async function startAcpBackend(agent, cwd, hooks, tuning = {}) {
390
391
  };
391
392
  const { child, conn } = await connectAcp(agent, {
392
393
  cwd,
393
- env: { ...adapterEnv(agent, tuning), ...(tuning.temp ? tempEnv(tuning.temp) : {}) },
394
+ env: { ...adapterEnv(agent, tuning), ...(tuning.temp ? tempEnv(tuning.temp) : {}), ...identityEnv(agent, tuning) },
394
395
  client,
395
396
  clientName: "kraftwerk-inspector",
396
397
  elicitation: true,
@@ -39,6 +39,8 @@ export interface BackendTuning {
39
39
  addDirs?: string[];
40
40
  /** The session's scratch folder (core/temp.ts): handed to the process as TMPDIR and KRAFTWERK_TEMP. */
41
41
  temp?: string;
42
+ /** The agent this seat is (its slug), handed to the process as KRAFTWERK_AGENT, with KRAFTWERK_ACTOR = <slug>/<harness>; absent for a project assistant or Ralv. */
43
+ agent?: string;
42
44
  /**
43
45
  * Nobody is watching this session live (routine runs). The backend selects
44
46
  * the harness's own hands-off permission mode — the harness keeps deciding
@@ -48,6 +50,15 @@ export interface BackendTuning {
48
50
  /** ACP agents: the session id to continue (from ChatMeta.sessions) instead of opening a new one. */
49
51
  resume?: string;
50
52
  }
53
+ /**
54
+ * Who the process is, for the CLI it runs: `kraftwerk tasks pick`, `tasks log`,
55
+ * `journal` and `knowledge put` read KRAFTWERK_AGENT / KRAFTWERK_ACTOR so an
56
+ * agent never has to name itself. A seat that is no agent (a project
57
+ * assistant, Ralv) is the actor `kraftwerk-chat/<harness>` — the name its
58
+ * context already tells it to write with — and has no KRAFTWERK_AGENT, so
59
+ * `tasks pick` still asks who picks.
60
+ */
61
+ export declare const identityEnv: (harness: string, tuning: BackendTuning) => Record<string, string>;
51
62
  /**
52
63
  * How a turn ended. `failure` is the typed session failure the agent
53
64
  * attached to the turn's own outcome (a usage limit, a rate limit, a
@@ -1 +1,9 @@
1
- export {};
1
+ /**
2
+ * Who the process is, for the CLI it runs: `kraftwerk tasks pick`, `tasks log`,
3
+ * `journal` and `knowledge put` read KRAFTWERK_AGENT / KRAFTWERK_ACTOR so an
4
+ * agent never has to name itself. A seat that is no agent (a project
5
+ * assistant, Ralv) is the actor `kraftwerk-chat/<harness>` — the name its
6
+ * context already tells it to write with — and has no KRAFTWERK_AGENT, so
7
+ * `tasks pick` still asks who picks.
8
+ */
9
+ export const identityEnv = (harness, tuning) => tuning.agent ? { KRAFTWERK_AGENT: tuning.agent, KRAFTWERK_ACTOR: `${tuning.agent}/${harness}` } : { KRAFTWERK_ACTOR: `kraftwerk-chat/${harness}` };
@@ -8,6 +8,7 @@ import { listRepos } from "../repos.js";
8
8
  import { listVibeables, vibeableStatus, VIBEABLE_CONFIG_FILE } from "../vibeables.js";
9
9
  import { projectContext } from "../projects.js";
10
10
  import { entitiesContext } from "../entities.js";
11
+ import { boardsContext, taskContext } from "../boards.js";
11
12
  import { unattendedTimeoutLabel } from "./permissions.js";
12
13
  import { getChannel } from "../channels.js";
13
14
  import { JOURNAL_FILE, journalExcerpt, readJournal } from "../journal.js";
@@ -275,8 +276,9 @@ async function baseScopeContext(scope, agent) {
275
276
  (await agentFilesContext(def)) +
276
277
  `The working directory is the project root. Stay within your role; if a request is clearly outside it, ` +
277
278
  `say so and suggest which agent or tool fits better.` +
278
- (scope.routine
279
- ? `\n\nThis session was started automatically by your scheduled routine "${scope.routine}" — ` +
279
+ (scope.task ? `\n\n${await taskContext(scope.task)}` : "") +
280
+ (scope.routine || scope.task
281
+ ? `\n\nThis session was started automatically by ${scope.routine ? `your scheduled routine "${scope.routine}"` : `the board: task ${scope.task} was given to you`} — ` +
280
282
  `nobody may be watching live. Complete the task autonomously. Edits inside the project ` +
281
283
  `need no approval; anything your harness asks about (shell commands, network, files ` +
282
284
  `outside the project) is shown to a human who may look later — say in one line why you ` +
@@ -394,7 +396,7 @@ export async function scopeContext(meta, seat) {
394
396
  const agent = seat.harness;
395
397
  // The repositories block reads every clone from git, so it runs alongside
396
398
  // the rest instead of adding its spawns to the first prompt's latency.
397
- const [base, repos, vibe, skills, channel, project, entities] = await Promise.all([
399
+ const [base, repos, vibe, skills, channel, project, entities, boards] = await Promise.all([
398
400
  baseScopeContext(scope, agent),
399
401
  scope.kind === "agent" || scope.kind === "kraftwerk" ? reposContext() : Promise.resolve(""),
400
402
  meta.vibeable ? vibeableContext(meta.vibeable, meta.cwd) : Promise.resolve(""),
@@ -404,11 +406,21 @@ export async function scopeContext(meta, seat) {
404
406
  meta.scope.kind === "channel" && meta.project ? projectContext(meta.project, `${seat.key}/${agent}`, { member: true }) : Promise.resolve(""),
405
407
  // Who, where and when: the workspace's contacts, places and events, wherever the chat works (a run discusses its own artifacts only).
406
408
  scope.kind === "run" ? Promise.resolve("") : entitiesContext().catch(() => ""),
409
+ // Boards: a project chat sees the project's boards; an agent sees the workspace boards narrowed to its tasks, plus its project's when filed under one.
410
+ scope.kind === "project"
411
+ ? boardsContext(scope.slug, `kraftwerk-chat/${agent}`).catch(() => "")
412
+ : scope.kind === "agent"
413
+ ? Promise.all([boardsContext(undefined, `${scope.slug}/${agent}`, scope.slug), meta.project ? boardsContext(meta.project, `${scope.slug}/${agent}`, scope.slug) : Promise.resolve("")])
414
+ .then((xs) => xs.filter(Boolean).join("\n\n"))
415
+ .catch(() => "")
416
+ : Promise.resolve(""),
407
417
  ]);
408
418
  // Every session has a scratch folder of its own (core/temp.ts), named by its absolute path: a run chat or a
409
419
  // vibeable session works in another folder than the workspace root. The block comes after the files blocks it refers to.
410
420
  const temp = await sessionTemp(meta.id).catch(() => null);
411
- return [base, channel, project, repos, entities, vibe, temp ? tempContext(temp.abs) : "", RENDERING_BLOCK, skillsBlock(skills)].filter(Boolean).join("\n\n");
421
+ // "Work on this" from a task page: the chat is tagged with the task (meta.task) and reads it like the agent's own task session would.
422
+ const task = meta.task && !(scope.kind === "agent" && scope.task === meta.task) ? await taskContext(meta.task).catch(() => "") : "";
423
+ return [base, channel, project, boards, task, repos, entities, vibe, temp ? tempContext(temp.abs) : "", RENDERING_BLOCK, skillsBlock(skills)].filter(Boolean).join("\n\n");
412
424
  }
413
425
  /** The channel block a member agent gets once per process: who is here, how turns work, how to hand over. */
414
426
  async function channelContext(slug, self) {
@@ -1,2 +1,2 @@
1
- import type { BackendHooks, BackendTuning, ChatBackend } from "./backend.js";
1
+ import { type BackendHooks, type BackendTuning, type ChatBackend } from "./backend.js";
2
2
  export declare function startPiBackend(cwd: string, hooks: BackendHooks, tuning?: BackendTuning): ChatBackend;
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
+ import { identityEnv } from "./backend.js";
3
4
  import { workspaceEnv } from "../env.js";
4
5
  import { tempEnv } from "../temp.js";
5
6
  const clip = (raw) => {
@@ -25,7 +26,7 @@ export function startPiBackend(cwd, hooks, tuning = {}) {
25
26
  const proc = spawn("pi", ["-p", "--mode", "json", "--session-id", sessionId, ...tuningArgs], {
26
27
  cwd,
27
28
  stdio: ["pipe", "pipe", "pipe"],
28
- env: { ...workspaceEnv(), ...(tuning.temp ? tempEnv(tuning.temp) : {}) },
29
+ env: { ...workspaceEnv(), ...(tuning.temp ? tempEnv(tuning.temp) : {}), ...identityEnv("pi", tuning) },
29
30
  });
30
31
  child = proc;
31
32
  proc.stdin.write(text);
@@ -23,6 +23,7 @@ async function backendTuning(meta, seat) {
23
23
  const tuning = {};
24
24
  const agentSlug = scope.kind === "agent" ? scope.slug : scope.kind === "channel" ? seat.key : undefined;
25
25
  if (agentSlug) {
26
+ tuning.agent = agentSlug;
26
27
  const def = await getAgent(agentSlug).catch(() => null);
27
28
  if (def?.model)
28
29
  tuning.model = def.model;
@@ -106,7 +107,7 @@ export async function ensureBackend(state, seat) {
106
107
  });
107
108
  if (!state.pendingInfo.has(requestId))
108
109
  await dismissKey(notifyKey);
109
- });
110
+ }).catch(() => { });
110
111
  if (isUnattended(state.meta.scope)) {
111
112
  deadline = setTimeout(() => {
112
113
  const pending = state.pendingPermissions.get(requestId);
@@ -157,7 +158,7 @@ export async function ensureBackend(state, seat) {
157
158
  });
158
159
  if (!state.pendingInfo.has(requestId))
159
160
  await dismissKey(notifyKey);
160
- });
161
+ }).catch(() => { });
161
162
  if (isUnattended(state.meta.scope)) {
162
163
  deadline = setTimeout(() => {
163
164
  const pending = state.pendingElicitations.get(requestId);
@@ -30,6 +30,8 @@ export declare function createChat(opts: {
30
30
  resume?: string;
31
31
  /** The project an agent's chat works in (meta.project); the caller checks that it exists. */
32
32
  project?: string;
33
+ /** The board task the chat works on (meta.task); the caller checks that it exists. */
34
+ task?: string;
33
35
  /** Who starts it; default this machine's person. */
34
36
  author?: ChatPerson;
35
37
  /** A private routine's run: hidden from remote callers (meta.private). */
@@ -55,6 +55,7 @@ export async function createChat(opts) {
55
55
  cwd,
56
56
  scope: opts.scope,
57
57
  ...(opts.project && opts.scope.kind === "agent" ? { project: opts.project } : {}),
58
+ ...(opts.task ? { task: opts.task } : {}),
58
59
  ...(opts.resume && opts.agent !== "pi" ? { sessions: { [MAIN]: opts.resume } } : {}),
59
60
  author,
60
61
  ...(opts.private ? { private: true } : {}),
@@ -29,7 +29,7 @@ export const isBusy = (state) => [...state.seats.values()].some((s) => s.busy);
29
29
  /** The event author for a seat: channel members sign their events, the lone agent of a chat does not. */
30
30
  export const seatAuthor = (seat) => (seat.key === MAIN ? undefined : { kind: "agent", slug: seat.key });
31
31
  /** Routine-fired sessions: nobody is expected to be watching live. */
32
- export const isUnattended = (scope) => scope.kind === "agent" && !!scope.routine;
32
+ export const isUnattended = (scope) => scope.kind === "agent" && (!!scope.routine || !!scope.task);
33
33
  /** Diagnose handle for a failed routine session (only called for unattended agent scopes). */
34
34
  export function routineRef(meta) {
35
35
  const scope = meta.scope;
@@ -16,10 +16,13 @@ export type ChatScope = {
16
16
  } | {
17
17
  kind: "knowledge";
18
18
  bundle?: string;
19
- } | {
19
+ }
20
+ /** `routine`: a scheduled run; `task`: opened for a task (<project>/<board>/<task>) the agent was assigned — both unattended. */
21
+ | {
20
22
  kind: "agent";
21
23
  slug: string;
22
24
  routine?: string;
25
+ task?: string;
23
26
  }
24
27
  /** A project: the chat carries its brief, state, systems of record and links; see projects.ts. */
25
28
  | {
@@ -64,6 +67,13 @@ export interface ChatMeta {
64
67
  * the project and hands every seat the project's context.
65
68
  */
66
69
  project?: string;
70
+ /**
71
+ * The board task this chat works on (<project>/<board>/<task> or
72
+ * <board>/<task>) when the scope is not the agent's task session itself:
73
+ * "work on this" from the task page. The task page lists the chat as one
74
+ * of its sessions, and every seat gets the task block in its context.
75
+ */
76
+ task?: string;
67
77
  /**
68
78
  * The agent's own session id per seat ("main", or the agent slug in a
69
79
  * channel): after a restart or the idle reaper, the next backend resumes
@@ -19,7 +19,7 @@ export declare const PROJECT_FILES_DIR = "files";
19
19
  export declare const LOCAL_OVER_BYTES: number;
20
20
  /** Upload cap: a guard against a runaway request, not a policy. */
21
21
  export declare const MAX_UPLOAD_BYTES: number;
22
- /** "workspace" or "project:<slug>". */
22
+ /** "workspace", "project:<slug>" or "task:<board>/<task>" / "task:<project>/<board>/<task>" (a task's folder). */
23
23
  export type FilesScope = string;
24
24
  export interface FileEntry {
25
25
  name: string;
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { pipeline } from "node:stream/promises";
4
4
  import { resolveWorkspace } from "../config.js";
5
5
  import { workspaceRoot } from "./context.js";
6
+ import { findTask, noteTaskFile, TASK_FILE, TASK_LOG } from "./boards.js";
6
7
  import { listProjects, openProjects, safeProjectSlug } from "./projects.js";
7
8
  import { moveToTrash } from "./trash.js";
8
9
  /**
@@ -66,6 +67,13 @@ export async function scopeRoot(scope) {
66
67
  const abs = path.join(workspace, WORKSPACE_FILES_DIR);
67
68
  return { abs, rel: WORKSPACE_FILES_DIR, label: "Workspace", workspace };
68
69
  }
70
+ const task = /^task:(.+)$/.exec(scope);
71
+ if (task) {
72
+ const t = await findTask(task[1]);
73
+ if (!t)
74
+ throw new Error(`no task "${task[1]}"`);
75
+ return { abs: t.path, rel: path.relative(workspace, t.path).split(path.sep).join("/"), label: `${t.number} ${t.title}`, workspace };
76
+ }
69
77
  const m = /^project:(.+)$/.exec(scope);
70
78
  if (!m)
71
79
  throw new Error(`invalid files scope "${scope}"`);
@@ -153,9 +161,13 @@ export async function listFiles(scope, rel = "") {
153
161
  const local = new Set(await readIgnore(root.abs));
154
162
  const kids = st ? await fs.readdir(dir, { withFileTypes: true }) : [];
155
163
  const entries = [];
164
+ const taskScope = scope.startsWith("task:");
156
165
  for (const k of kids) {
157
166
  if (k.name.startsWith("."))
158
167
  continue;
168
+ // A task's own markdown and log are the task, not its files.
169
+ if (taskScope && dir === root.abs && (k.name === TASK_FILE || k.name === TASK_LOG))
170
+ continue;
159
171
  const abs = path.join(dir, k.name);
160
172
  const s = await fs.stat(abs).catch(() => null);
161
173
  if (!s)
@@ -228,6 +240,8 @@ export async function saveUpload(scope, dirRel, name, body) {
228
240
  const p = toPosix(path.relative(root.abs, abs));
229
241
  if (size > LOCAL_OVER_BYTES)
230
242
  await markLocal(root.abs, p, true);
243
+ if (scope.startsWith("task:"))
244
+ await noteTaskFile(scope.slice(5), "added", p).catch(() => { });
231
245
  return { name: final, path: p, type: "file", size, modifiedAt: new Date().toISOString(), mime: mimeOf(final), ...(size > LOCAL_OVER_BYTES ? { local: true } : {}) };
232
246
  }
233
247
  export async function makeFolder(scope, rel) {
@@ -271,10 +285,14 @@ export async function deleteFile(scope, rel) {
271
285
  if (!st)
272
286
  throw new Error(`no file "${rel}"`);
273
287
  const p = toPosix(path.relative(root.abs, abs));
274
- const owner = scope === "workspace" ? "workspace" : `projects/${scope.slice("project:".length)}`;
288
+ if (scope.startsWith("task:") && (p === TASK_FILE || p === TASK_LOG))
289
+ throw new Error("the task's own files cannot be removed");
290
+ const owner = scope === "workspace" ? "workspace" : scope.startsWith("task:") ? `tasks/${scope.slice(5)}` : `projects/${scope.slice("project:".length)}`;
275
291
  await moveToTrash("files", `${owner}/${p}`, abs);
276
292
  if (st.isFile() && st.size > LOCAL_OVER_BYTES)
277
293
  await markLocal(root.abs, p, false);
294
+ if (scope.startsWith("task:"))
295
+ await noteTaskFile(scope.slice(5), "removed", p).catch(() => { });
278
296
  }
279
297
  /* ---------- context for agents ---------- */
280
298
  /** "files/ (23 files; recent: designs/home-v3.png 2026-10-05, …)" — where the files are, not every file. */
package/dist/core/git.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { realpathSync } from "node:fs";
3
3
  import path from "node:path";
4
- import { ENTITY_KINDS, entitiesRootFor, projectsRootFor, reposRootFor, resolveWorkspace, vibeablesRootFor, isSafeGitName } from "../config.js";
4
+ import { boardsRootFor, ENTITY_KINDS, entitiesRootFor, projectsRootFor, reposRootFor, resolveWorkspace, vibeablesRootFor, isSafeGitName } from "../config.js";
5
5
  import { ENV_FILE } from "../runner/docker.js";
6
6
  import { getOutputDir, workspaceRoot } from "./context.js";
7
7
  import { perWorkspace, workspaceOpen } from "./workspace.js";
@@ -241,6 +241,8 @@ function scopeFor({ project, repoRoot }) {
241
241
  projectsRootFor(project),
242
242
  // Contacts, places and events: who, where and when — the workspace's records.
243
243
  ...ENTITY_KINDS.map((kind) => entitiesRootFor(project, kind)),
244
+ // Workspace boards: tasks, logs and their files (project boards sit inside the projects root).
245
+ boardsRootFor(project),
244
246
  // Workspace files: shared material, versioned (files over 25 MB ignore themselves, see files.ts).
245
247
  path.resolve(project.root, "kraftwerk-data/files"),
246
248
  project.configPath,
@@ -10,7 +10,7 @@ import type { ChatMeta } from "./chat/types.js";
10
10
  * Approval items carry a `key` so the same request is never listed twice
11
11
  * and disappears again the moment someone answers (dismissKey).
12
12
  */
13
- export type NotificationKind = "approval" | "routine_done" | "routine_failed" | "run_done" | "run_failed";
13
+ export type NotificationKind = "approval" | "routine_done" | "routine_failed" | "run_done" | "run_failed" | "task_woken" | "task_wake_failed";
14
14
  /**
15
15
  * What a "diagnose" on a failure item should look at: a workflow run (chat
16
16
  * in the run folder) or a routine (its agent, and the session that failed
@@ -13,7 +13,7 @@
13
13
  * like never do. A target that does not exist is reported (found: false),
14
14
  * never an error — the same rule project links have always followed.
15
15
  */
16
- export declare const REF_KIND_NAMES: readonly ["knowledge", "vibeables", "repos", "workflows", "agents", "projects", "contacts", "places", "events"];
16
+ export declare const REF_KIND_NAMES: readonly ["knowledge", "vibeables", "repos", "workflows", "agents", "projects", "contacts", "places", "events", "boards", "tasks"];
17
17
  /** The kind's plural: the link-list name in project.yml and the folder/route name. */
18
18
  export type RefKind = (typeof REF_KIND_NAMES)[number];
19
19
  /** One existing target. */
package/dist/core/refs.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { listAgents } from "./agents.js";
2
2
  import { CONTACTS, EVENTS, listEntities, PLACES } from "./entities.js";
3
+ import { boardKey, listAllBoards } from "./boards.js";
3
4
  import { knowledgeIndex } from "./knowledge.js";
4
5
  import { listProjects } from "./projects.js";
5
6
  import { listRepos } from "./repos.js";
@@ -20,7 +21,7 @@ import { listWorkflows } from "./workflows.js";
20
21
  * like never do. A target that does not exist is reported (found: false),
21
22
  * never an error — the same rule project links have always followed.
22
23
  */
23
- export const REF_KIND_NAMES = ["knowledge", "vibeables", "repos", "workflows", "agents", "projects", "contacts", "places", "events"];
24
+ export const REF_KIND_NAMES = ["knowledge", "vibeables", "repos", "workflows", "agents", "projects", "contacts", "places", "events", "boards", "tasks"];
24
25
  /** A list that fails to read is an empty list: a reference into it is "not found", never an error. */
25
26
  const safe = async (p, fallback) => p.catch(() => fallback);
26
27
  export const REF_KINDS = [
@@ -80,7 +81,30 @@ export const REF_KINDS = [
80
81
  noun: "event",
81
82
  list: async () => (await safe(listEntities(EVENTS), { items: [] })).items.map((e) => ({ slug: e.slug, label: EVENTS.describe(e), href: `/events/${e.slug}` })),
82
83
  },
84
+ {
85
+ kind: "boards",
86
+ token: "board",
87
+ noun: "board",
88
+ // A board's slug is unique within its project; across the workspace the first match wins and the label names where it is.
89
+ list: async () => (await safe(listAllBoards(), [])).map((b) => ({ slug: b.board, label: b.project ? `${b.title} · project ${b.project}` : b.title, href: boardHref(b) })),
90
+ },
91
+ {
92
+ kind: "tasks",
93
+ token: "task",
94
+ noun: "task",
95
+ list: async () => {
96
+ const { getBoard } = await import("./boards.js");
97
+ const out = [];
98
+ for (const b of await safe(listAllBoards(), [])) {
99
+ const detail = await safe(getBoard(b), null);
100
+ for (const t of detail?.items ?? [])
101
+ out.push({ slug: t.slug, label: `${t.title} · ${boardKey(b)} (${t.status})`, href: `${boardHref(b)}/${t.slug}` });
102
+ }
103
+ return out;
104
+ },
105
+ },
83
106
  ];
107
+ const boardHref = (b) => (b.project ? `/projects/${b.project}/boards/${b.board}` : `/boards/${b.board}`);
84
108
  const byKind = new Map(REF_KINDS.map((d) => [d.kind, d]));
85
109
  const byToken = new Map(REF_KINDS.map((d) => [d.token, d]));
86
110
  /** The kind registered under a plural name or a token; undefined for anything else. */
@@ -27,6 +27,18 @@ export interface WorkspaceAgents {
27
27
  projects: ProjectHit[];
28
28
  /** Contacts, places and events of this instance (the registry does not carry them); empty for other workspaces and while the features are off. */
29
29
  entities: EntityHit[];
30
+ /** Open tasks of this instance's boards; empty for other workspaces and while boards are off. */
31
+ tasks: TaskHit[];
32
+ }
33
+ /** What the palette needs of a task. */
34
+ export interface TaskHit {
35
+ slug: string;
36
+ title: string;
37
+ board: string;
38
+ project?: string;
39
+ status: string;
40
+ /** Where it opens. */
41
+ href: string;
30
42
  }
31
43
  /** What the palette needs of a contact, place or event. */
32
44
  export interface EntityHit {
@@ -6,6 +6,23 @@ import { listChannels, toChannelSummary } from "./channels.js";
6
6
  import { currentInstanceUrl, discoverWorkspaces, listWorkspaceRecords } from "./instances.js";
7
7
  import { CONTACTS, EVENTS, listEntities, PLACES } from "./entities.js";
8
8
  import { listProjects, toProjectHit } from "./projects.js";
9
+ import { getBoard, listAllBoards } from "./boards.js";
10
+ async function taskHits() {
11
+ const out = [];
12
+ for (const b of await listAllBoards().catch(() => [])) {
13
+ const detail = await getBoard(b).catch(() => null);
14
+ if (!detail)
15
+ continue;
16
+ const done = new Set(detail.columns.filter((c) => c.done).map((c) => c.slug));
17
+ for (const t of detail.items) {
18
+ if (t.configError || done.has(t.status))
19
+ continue;
20
+ const base = b.project ? `/projects/${b.project}/boards/${b.board}` : `/boards/${b.board}`;
21
+ out.push({ slug: t.slug, title: t.title, board: b.board, ...(b.project ? { project: b.project } : {}), status: t.status, href: `${base}/${t.slug}` });
22
+ }
23
+ }
24
+ return out;
25
+ }
9
26
  async function entityHits(def) {
10
27
  const view = await listEntities(def).catch(() => ({ items: [] }));
11
28
  return view.items
@@ -28,6 +45,7 @@ export async function searchAgents() {
28
45
  channels: (await listChannels().catch(() => [])).map(toChannelSummary),
29
46
  projects: (await listProjects().catch(() => ({ projects: [] }))).projects.filter((p) => p.status !== "archived").map(toProjectHit),
30
47
  entities: (await Promise.all([CONTACTS, PLACES, EVENTS].map(entityHits))).flat(),
48
+ tasks: await taskHits(),
31
49
  };
32
50
  const [workspaces, known] = await Promise.all([discoverWorkspaces(), listWorkspaceRecords()]);
33
51
  const records = new Map(known.map((p) => [p.root, p]));
@@ -45,6 +63,7 @@ export async function searchAgents() {
45
63
  channels: records.get(w.root)?.channels ?? [],
46
64
  projects: records.get(w.root)?.projects ?? [],
47
65
  entities: [],
66
+ tasks: [],
48
67
  }));
49
68
  return { workspaces: [self, ...others] };
50
69
  }
@@ -36,6 +36,8 @@ export interface SaveSettingsInput {
36
36
  contacts?: ReposSettingsInput;
37
37
  places?: ReposSettingsInput;
38
38
  events?: ReposSettingsInput;
39
+ /** Omitted = untouched. Same shape and rules as repos (boards.columns in the file is kept). */
40
+ boards?: ReposSettingsInput;
39
41
  }
40
42
  export interface ReposSettingsInput {
41
43
  enabled: boolean;
@@ -1,7 +1,8 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { parseDocument } from "yaml";
4
- import { ENTITY_KINDS, entityDefaultRoot, ignoreEntryFor, PROJECTS_DEFAULT_ROOT, REPOS_DEFAULT_ROOT, resolveWorkspace, VIBEABLES_DEFAULT_ROOT, isSafeGitName } from "../config.js";
4
+ import { BOARDS_DEFAULT_ROOT, ENTITY_KINDS, entityDefaultRoot, ignoreEntryFor, PROJECTS_DEFAULT_ROOT, REPOS_DEFAULT_ROOT, resolveWorkspace, VIBEABLES_DEFAULT_ROOT, isSafeGitName } from "../config.js";
5
+ import { ensureBoardsRoot } from "./boards.js";
5
6
  import { ensureEntitiesRoot } from "./entities.js";
6
7
  import { ensureProjectsRoot } from "./projects.js";
7
8
  import { disposeAllDevs, ensureVibeablesRoot } from "./vibeables.js";
@@ -84,6 +85,7 @@ const ROOT_DEFAULTS = {
84
85
  contacts: entityDefaultRoot("contacts"),
85
86
  places: entityDefaultRoot("places"),
86
87
  events: entityDefaultRoot("events"),
88
+ boards: BOARDS_DEFAULT_ROOT,
87
89
  };
88
90
  /** The repos, vibeables, projects, contacts, places or events block to write; null when the feature is off and nothing else is set. */
89
91
  function cleanRootBlock(block, value, projectRoot) {
@@ -181,6 +183,14 @@ export async function saveSettings(input) {
181
183
  else
182
184
  doc.set(kind, block);
183
185
  }
186
+ if (input.boards !== undefined) {
187
+ const block = cleanRootBlock("boards", input.boards, project.root);
188
+ const columns = project.config.boards?.columns;
189
+ if (block === null && !columns)
190
+ doc.delete("boards");
191
+ else
192
+ doc.set("boards", { ...(block ?? { enabled: false }), ...(columns ? { columns } : {}) });
193
+ }
184
194
  await fs.writeFile(configPath, doc.toString());
185
195
  // Turning repositories on prepares the folder so the first clone and the
186
196
  // workspace git both find it in the right state.
@@ -193,5 +203,7 @@ export async function saveSettings(input) {
193
203
  for (const kind of ENTITY_KINDS)
194
204
  if (input[kind]?.enabled)
195
205
  await ensureEntitiesRoot(kind).catch(() => { });
206
+ if (input.boards?.enabled)
207
+ await ensureBoardsRoot().catch(() => { });
196
208
  return getSettings();
197
209
  }
@@ -8,7 +8,7 @@
8
8
  */
9
9
  export declare const TRASH_DIR = "kraftwerk-data/trash";
10
10
  /** What can be in the trash; also the folder under trash/ it lands in. */
11
- export type TrashKind = "agents" | "agent-skills" | "projects" | "contacts" | "places" | "events" | "workflows" | "knowledge" | "vibeables" | "channels" | "repos" | "chats" | "runs" | "files";
11
+ export type TrashKind = "agents" | "agent-skills" | "projects" | "boards" | "tasks" | "contacts" | "places" | "events" | "workflows" | "knowledge" | "vibeables" | "channels" | "repos" | "chats" | "runs" | "files";
12
12
  export interface TrashEntry {
13
13
  /** Path inside the trash, forward slashes — the entry's handle. */
14
14
  id: string;
@@ -0,0 +1,105 @@
1
+ import * as z from "zod";
2
+ /**
3
+ * Boards and tasks over the API. A board is addressed by its slug and, for
4
+ * a project's board, `?project=<slug>`; a task by its folder name or its
5
+ * bare number. Writes that change a task's column or assignee are logged
6
+ * on the task and may wake the assignee (core/board-wake.ts).
7
+ */
8
+ export declare const boardErrors: (message: string) => number;
9
+ export declare const boardRoutes: (import("./router.js").Route<"boards.create", import("./router.js").Reply<import("../../core/boards.js").BoardDetail>, "/api/boards", z.ZodObject<{
10
+ title: z.ZodString;
11
+ slug: z.ZodOptional<z.ZodString>;
12
+ project: z.ZodOptional<z.ZodString>;
13
+ columns: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
14
+ slug: z.ZodString;
15
+ label: z.ZodOptional<z.ZodString>;
16
+ limit: z.ZodOptional<z.ZodNumber>;
17
+ done: z.ZodOptional<z.ZodBoolean>;
18
+ review: z.ZodOptional<z.ZodBoolean>;
19
+ }, z.core.$strip>]>>>;
20
+ agent: z.ZodOptional<z.ZodString>;
21
+ notes: z.ZodOptional<z.ZodString>;
22
+ }, z.core.$strip>> | import("./router.js").Route<"boards.delete", {
23
+ ok: boolean;
24
+ }, "/api/boards/:board", undefined> | import("./router.js").Route<"boards.ensure", import("../../core/boards.js").BoardDetail, "/api/boards/default", z.ZodObject<{
25
+ project: z.ZodOptional<z.ZodString>;
26
+ }, z.core.$strip>> | import("./router.js").Route<"boards.get", import("../../core/boards.js").BoardDetail, "/api/boards/:board", undefined> | import("./router.js").Route<"boards.list", import("../../core/boards.js").BoardsView, "/api/boards", undefined> | import("./router.js").Route<"boards.save", import("../../core/boards.js").BoardDetail, "/api/boards/:board", z.ZodObject<{
27
+ title: z.ZodOptional<z.ZodString>;
28
+ columns: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
29
+ slug: z.ZodString;
30
+ label: z.ZodOptional<z.ZodString>;
31
+ limit: z.ZodOptional<z.ZodNumber>;
32
+ done: z.ZodOptional<z.ZodBoolean>;
33
+ review: z.ZodOptional<z.ZodBoolean>;
34
+ }, z.core.$strip>]>>>;
35
+ agent: z.ZodOptional<z.ZodNullable<z.ZodString>>;
36
+ staleAfter: z.ZodOptional<z.ZodNullable<z.ZodString>>;
37
+ notes: z.ZodOptional<z.ZodString>;
38
+ }, z.core.$strip>> | import("./router.js").Route<"tasks.assign", import("../../core/boards.js").TaskDetail, "/api/boards/:board/tasks/:task/assign", z.ZodObject<{
39
+ to: z.ZodNullable<z.ZodString>;
40
+ message: z.ZodOptional<z.ZodString>;
41
+ actor: z.ZodOptional<z.ZodString>;
42
+ }, z.core.$strip>> | import("./router.js").Route<"tasks.create", import("./router.js").Reply<import("../../core/boards.js").TaskDetail>, "/api/boards/:board/tasks", z.ZodObject<{
43
+ status: z.ZodOptional<z.ZodString>;
44
+ order: z.ZodOptional<z.ZodNumber>;
45
+ assignee: z.ZodOptional<z.ZodNullable<z.ZodString>>;
46
+ due: z.ZodOptional<z.ZodNullable<z.ZodString>>;
47
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
48
+ refs: z.ZodOptional<z.ZodArray<z.ZodString>>;
49
+ notes: z.ZodOptional<z.ZodString>;
50
+ actor: z.ZodOptional<z.ZodString>;
51
+ message: z.ZodOptional<z.ZodString>;
52
+ title: z.ZodString;
53
+ }, z.core.$strip>> | import("./router.js").Route<"tasks.delete", {
54
+ ok: boolean;
55
+ }, "/api/boards/:board/tasks/:task", undefined> | import("./router.js").Route<"tasks.get", {
56
+ slug: string;
57
+ number: number;
58
+ title: string;
59
+ status: string;
60
+ order: number;
61
+ assignee?: string;
62
+ due?: string;
63
+ created: string;
64
+ tags: string[];
65
+ refs: string[];
66
+ extra?: Record<string, unknown>;
67
+ ref: string;
68
+ path: string;
69
+ updatedAt?: string;
70
+ configError?: string;
71
+ files: number;
72
+ lastLog?: string;
73
+ stale?: boolean;
74
+ notes: string;
75
+ log: string;
76
+ fileList: string[];
77
+ board: string;
78
+ project?: string;
79
+ sessions: import("../../core/board-wake.js").TaskSession[];
80
+ }, "/api/boards/:board/tasks/:task", undefined> | import("./router.js").Route<"tasks.log", import("../../core/boards.js").TaskDetail, "/api/boards/:board/tasks/:task/log", z.ZodObject<{
81
+ entry: z.ZodString;
82
+ actor: z.ZodOptional<z.ZodString>;
83
+ }, z.core.$strip>> | import("./router.js").Route<"tasks.move", import("../../core/boards.js").TaskDetail, "/api/boards/:board/tasks/:task/move", z.ZodObject<{
84
+ status: z.ZodString;
85
+ before: z.ZodOptional<z.ZodString>;
86
+ order: z.ZodOptional<z.ZodNumber>;
87
+ message: z.ZodOptional<z.ZodString>;
88
+ actor: z.ZodOptional<z.ZodString>;
89
+ }, z.core.$strip>> | import("./router.js").Route<"tasks.pick", {
90
+ task: import("../../core/boards.js").TaskDetail | null;
91
+ }, "/api/boards/:board/pick", z.ZodObject<{
92
+ agent: z.ZodString;
93
+ column: z.ZodOptional<z.ZodString>;
94
+ }, z.core.$strip>> | import("./router.js").Route<"tasks.save", import("../../core/boards.js").TaskDetail, "/api/boards/:board/tasks/:task", z.ZodObject<{
95
+ title: z.ZodOptional<z.ZodString>;
96
+ status: z.ZodOptional<z.ZodString>;
97
+ order: z.ZodOptional<z.ZodNumber>;
98
+ assignee: z.ZodOptional<z.ZodNullable<z.ZodString>>;
99
+ due: z.ZodOptional<z.ZodNullable<z.ZodString>>;
100
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
101
+ refs: z.ZodOptional<z.ZodArray<z.ZodString>>;
102
+ notes: z.ZodOptional<z.ZodString>;
103
+ actor: z.ZodOptional<z.ZodString>;
104
+ message: z.ZodOptional<z.ZodString>;
105
+ }, z.core.$strip>>)[];