@netnodeag/kraftwerk 0.80.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 (84) hide show
  1. package/README.md +57 -2
  2. package/dist/cli/boards.d.ts +2 -0
  3. package/dist/cli/boards.js +486 -0
  4. package/dist/cli/init.js +4 -2
  5. package/dist/cli/kraftwerk.js +6 -0
  6. package/dist/cli/temp.d.ts +2 -0
  7. package/dist/cli/temp.js +62 -0
  8. package/dist/client/routes.d.ts +16 -0
  9. package/dist/client/routes.js +16 -0
  10. package/dist/config.d.ts +19 -0
  11. package/dist/config.js +24 -1
  12. package/dist/core/attention.d.ts +2 -2
  13. package/dist/core/attention.js +22 -2
  14. package/dist/core/board-wake.d.ts +21 -0
  15. package/dist/core/board-wake.js +221 -0
  16. package/dist/core/boards.d.ts +256 -0
  17. package/dist/core/boards.js +936 -0
  18. package/dist/core/chat/acp.d.ts +1 -1
  19. package/dist/core/chat/acp.js +3 -1
  20. package/dist/core/chat/backend.d.ts +13 -0
  21. package/dist/core/chat/backend.js +9 -1
  22. package/dist/core/chat/context.js +26 -9
  23. package/dist/core/chat/pi.d.ts +1 -1
  24. package/dist/core/chat/pi.js +3 -1
  25. package/dist/core/chat/seats.js +6 -2
  26. package/dist/core/chat/sessions.d.ts +2 -0
  27. package/dist/core/chat/sessions.js +4 -0
  28. package/dist/core/chat/state.js +1 -1
  29. package/dist/core/chat/types.d.ts +11 -1
  30. package/dist/core/files.d.ts +1 -1
  31. package/dist/core/files.js +20 -1
  32. package/dist/core/git.js +3 -1
  33. package/dist/core/notifications.d.ts +1 -1
  34. package/dist/core/projects.js +1 -1
  35. package/dist/core/refs.d.ts +1 -1
  36. package/dist/core/refs.js +25 -1
  37. package/dist/core/search.d.ts +12 -0
  38. package/dist/core/search.js +19 -0
  39. package/dist/core/settings.d.ts +2 -0
  40. package/dist/core/settings.js +13 -1
  41. package/dist/core/temp.d.ts +73 -0
  42. package/dist/core/temp.js +154 -0
  43. package/dist/core/trash.d.ts +1 -1
  44. package/dist/harness.d.ts +2 -0
  45. package/dist/harnesses/acp.js +1 -1
  46. package/dist/harnesses/claude.js +1 -0
  47. package/dist/harnesses/codex.js +1 -0
  48. package/dist/harnesses/pi.js +1 -0
  49. package/dist/run.d.ts +7 -0
  50. package/dist/run.js +13 -1
  51. package/dist/server/api/boards.d.ts +105 -0
  52. package/dist/server/api/boards.js +160 -0
  53. package/dist/server/api/chats.d.ts +2 -0
  54. package/dist/server/api/chats.js +9 -3
  55. package/dist/server/api/files.d.ts +9 -1
  56. package/dist/server/api/files.js +13 -0
  57. package/dist/server/api/index.d.ts +113 -2
  58. package/dist/server/api/index.js +2 -0
  59. package/dist/server/api/workspace.d.ts +5 -0
  60. package/dist/server/api/workspace.js +2 -0
  61. package/dist/server/push.js +1 -1
  62. package/dist/server/server.js +3 -0
  63. package/dist/yaml.js +1 -0
  64. package/package.json +1 -1
  65. package/schema/kraftwerk.schema.json +39 -0
  66. package/web/dist/assets/{dist-DlZPeneT.js → dist-BBFUIftm.js} +1 -1
  67. package/web/dist/assets/{dist-u_oAKIhZ.js → dist-Bie1ebP9.js} +1 -1
  68. package/web/dist/assets/{dist-NX4_yvho.js → dist-BuwdjX6z.js} +1 -1
  69. package/web/dist/assets/{dist-1PajaYPg.js → dist-C0ve4Tka.js} +1 -1
  70. package/web/dist/assets/{dist-DgKgeYFJ.js → dist-C16bXf8c.js} +1 -1
  71. package/web/dist/assets/{dist-DKduisGW.js → dist-CDUZdAAS.js} +1 -1
  72. package/web/dist/assets/{dist-B4dqwo7_.js → dist-CH73jsgO.js} +1 -1
  73. package/web/dist/assets/{dist-D-f0nszo.js → dist-C_rJjRj7.js} +1 -1
  74. package/web/dist/assets/dist-DYICNp09.js +1 -0
  75. package/web/dist/assets/{dist-K_3Y5Nbl.js → dist-DtUB2Uu2.js} +1 -1
  76. package/web/dist/assets/{dist-CK2u1OXb.js → dist-DvkrCSGL.js} +1 -1
  77. package/web/dist/assets/{editor-CE9TlKkJ.js → editor-3z92i0tV.js} +3 -3
  78. package/web/dist/assets/{editor-CZMrhTTL.css → editor-C0_Snnzk.css} +1 -1
  79. package/web/dist/assets/index-DRhNELK_.js +232 -0
  80. package/web/dist/assets/index-unGEux3Y.css +2 -0
  81. package/web/dist/index.html +2 -2
  82. package/web/dist/assets/dist-Bp1iMuQ7.js +0 -1
  83. package/web/dist/assets/index-Dvt-vE1h.css +0 -2
  84. package/web/dist/assets/index-IAuwD22j.js +0 -231
@@ -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,8 @@ 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";
7
+ import { tempEnv } from "../temp.js";
6
8
  import { unattendedMode } from "./permissions.js";
7
9
  /**
8
10
  * ACP-backed chat: spawn an adapter (claude-agent-acp / codex-acp) as a
@@ -389,7 +391,7 @@ export async function startAcpBackend(agent, cwd, hooks, tuning = {}) {
389
391
  };
390
392
  const { child, conn } = await connectAcp(agent, {
391
393
  cwd,
392
- env: adapterEnv(agent, tuning),
394
+ env: { ...adapterEnv(agent, tuning), ...(tuning.temp ? tempEnv(tuning.temp) : {}), ...identityEnv(agent, tuning) },
393
395
  client,
394
396
  clientName: "kraftwerk-inspector",
395
397
  elicitation: true,
@@ -37,6 +37,10 @@ export interface BackendTuning {
37
37
  skillDirs?: string[];
38
38
  /** claude: extra directories the session may work in (e.g. project root for run chats). */
39
39
  addDirs?: string[];
40
+ /** The session's scratch folder (core/temp.ts): handed to the process as TMPDIR and KRAFTWERK_TEMP. */
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;
40
44
  /**
41
45
  * Nobody is watching this session live (routine runs). The backend selects
42
46
  * the harness's own hands-off permission mode — the harness keeps deciding
@@ -46,6 +50,15 @@ export interface BackendTuning {
46
50
  /** ACP agents: the session id to continue (from ChatMeta.sessions) instead of opening a new one. */
47
51
  resume?: string;
48
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>;
49
62
  /**
50
63
  * How a turn ended. `failure` is the typed session failure the agent
51
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,10 +8,12 @@ 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";
14
15
  import { filesSummary } from "../files.js";
16
+ import { sessionTemp, tempContext } from "../temp.js";
15
17
  /**
16
18
  * What an agent is told about where it works: the skills it may use and
17
19
  * the scope's context block (the workspace, a run, a project, a channel,
@@ -85,8 +87,8 @@ async function agentKnowledgeContext(def) {
85
87
  `part of your job. Consult them before answering questions in their domain, and keep them current ` +
86
88
  `when you learn something durable:\n${lines}\n\n` +
87
89
  `Read with \`npx kraftwerk knowledge list <bundle>\`, \`get <bundle>/<path>\`, \`search <text>\`. ` +
88
- `Write ONLY through \`npx kraftwerk knowledge put <bundle>/<path> --file <tmp.md> --actor ${def.slug}/${def.harness}\` ` +
89
- `(write the markdown to a temp file first; the CLI stamps provenance and maintains index.md/log.md). ` +
90
+ `Write ONLY through \`npx kraftwerk knowledge put <bundle>/<path> --file $KRAFTWERK_TEMP/<name>.md --actor ${def.slug}/${def.harness}\` ` +
91
+ `(write the markdown into your temp folder first; the CLI stamps provenance and maintains index.md/log.md). ` +
90
92
  `Never hand-edit index.md or log.md, and never add \`verified\` yourself — verification is the human's ` +
91
93
  `click in the UI.\n\n`);
92
94
  }
@@ -132,7 +134,8 @@ export async function agentFilesContext(def) {
132
134
  return (`## Files\nThe workspace's shared files (any format: brand assets, templates, contracts, …) are in ${ws.root}/ (${ws.count} file${ws.count === 1 ? "" : "s"}). ` +
133
135
  (linked ? `The folders that are part of your job:\n${linked}\n` : "") +
134
136
  `Work you do for a project belongs in that project's files folder, not here. What you produce in a direct session goes to ${ws.root}/inbox/. ` +
135
- `Delete nothing there unless asked.\n\n`);
137
+ `Only finished work and the user's own material belong in these folders — scratch, intermediate and downloaded files go to your ` +
138
+ `temp folder (below), never here. Delete nothing there unless asked.\n\n`);
136
139
  }
137
140
  /**
138
141
  * "## Your vibeables" block: the apps an agent builds and maintains, linked
@@ -273,8 +276,9 @@ async function baseScopeContext(scope, agent) {
273
276
  (await agentFilesContext(def)) +
274
277
  `The working directory is the project root. Stay within your role; if a request is clearly outside it, ` +
275
278
  `say so and suggest which agent or tool fits better.` +
276
- (scope.routine
277
- ? `\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`} — ` +
278
282
  `nobody may be watching live. Complete the task autonomously. Edits inside the project ` +
279
283
  `need no approval; anything your harness asks about (shell commands, network, files ` +
280
284
  `outside the project) is shown to a human who may look later — say in one line why you ` +
@@ -310,7 +314,7 @@ async function baseScopeContext(scope, agent) {
310
314
  `- Favor structural markdown (headings, tables, lists) over prose.\n\n` +
311
315
  `ALWAYS write through the kraftwerk CLI so provenance is stamped and the bundle log/index stay maintained:\n` +
312
316
  `- \`npx kraftwerk knowledge init <bundle>\` — new bundle\n` +
313
- `- \`npx kraftwerk knowledge put <bundle>/<path> --file <tmp.md> --actor kraftwerk-chat/${agent}\` — create/update a concept ` +
317
+ `- \`npx kraftwerk knowledge put <bundle>/<path> --file $KRAFTWERK_TEMP/<name>.md --actor kraftwerk-chat/${agent}\` — create/update a concept ` +
314
318
  `(write the markdown to a temp file first; the CLI stamps generated.by/at, appends log.md, regenerates index.md)\n` +
315
319
  `- \`npx kraftwerk knowledge list [bundle]\`, \`get <bundle>/<path>\`, \`search <text>\`, \`validate\`\n` +
316
320
  `Do not hand-edit index.md or log.md (derived/maintained), and do not add \`verified\` yourself — ` +
@@ -349,7 +353,7 @@ async function baseScopeContext(scope, agent) {
349
353
  `## Recent runs\n${runLines || "(none)"}\n\n` +
350
354
  `## Knowledge\nOKF bundles in this project:\n${bundleLines || "(none)"}\n` +
351
355
  `Read with \`npx kraftwerk knowledge list [bundle]\`, \`get <bundle>/<path>\`, \`search <text>\`. ` +
352
- `Write ONLY through \`npx kraftwerk knowledge put <bundle>/<path> --file <tmp.md> --actor kraftwerk-chat/${agent}\` ` +
356
+ `Write ONLY through \`npx kraftwerk knowledge put <bundle>/<path> --file $KRAFTWERK_TEMP/<name>.md --actor kraftwerk-chat/${agent}\` ` +
353
357
  `(stamps provenance, maintains index.md/log.md — never hand-edit those).\n\n` +
354
358
  `## Agents\n${agentLines || "(none)"}\n` +
355
359
  `These are persistent agents (defined under agents/); the user talks to them on the ` +
@@ -392,7 +396,7 @@ export async function scopeContext(meta, seat) {
392
396
  const agent = seat.harness;
393
397
  // The repositories block reads every clone from git, so it runs alongside
394
398
  // the rest instead of adding its spawns to the first prompt's latency.
395
- const [base, repos, vibe, skills, channel, project, entities] = await Promise.all([
399
+ const [base, repos, vibe, skills, channel, project, entities, boards] = await Promise.all([
396
400
  baseScopeContext(scope, agent),
397
401
  scope.kind === "agent" || scope.kind === "kraftwerk" ? reposContext() : Promise.resolve(""),
398
402
  meta.vibeable ? vibeableContext(meta.vibeable, meta.cwd) : Promise.resolve(""),
@@ -402,8 +406,21 @@ export async function scopeContext(meta, seat) {
402
406
  meta.scope.kind === "channel" && meta.project ? projectContext(meta.project, `${seat.key}/${agent}`, { member: true }) : Promise.resolve(""),
403
407
  // Who, where and when: the workspace's contacts, places and events, wherever the chat works (a run discusses its own artifacts only).
404
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(""),
405
417
  ]);
406
- return [base, channel, project, repos, entities, vibe, RENDERING_BLOCK, skillsBlock(skills)].filter(Boolean).join("\n\n");
418
+ // Every session has a scratch folder of its own (core/temp.ts), named by its absolute path: a run chat or a
419
+ // vibeable session works in another folder than the workspace root. The block comes after the files blocks it refers to.
420
+ const temp = await sessionTemp(meta.id).catch(() => null);
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");
407
424
  }
408
425
  /** The channel block a member agent gets once per process: who is here, how turns work, how to hand over. */
409
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,6 +1,8 @@
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";
5
+ import { tempEnv } from "../temp.js";
4
6
  const clip = (raw) => {
5
7
  const oneLine = String(raw ?? "").replace(/\s+/g, " ").trim();
6
8
  return oneLine.length > 120 ? `${oneLine.slice(0, 120)}…` : oneLine;
@@ -24,7 +26,7 @@ export function startPiBackend(cwd, hooks, tuning = {}) {
24
26
  const proc = spawn("pi", ["-p", "--mode", "json", "--session-id", sessionId, ...tuningArgs], {
25
27
  cwd,
26
28
  stdio: ["pipe", "pipe", "pipe"],
27
- env: workspaceEnv(),
29
+ env: { ...workspaceEnv(), ...(tuning.temp ? tempEnv(tuning.temp) : {}), ...identityEnv("pi", tuning) },
28
30
  });
29
31
  child = proc;
30
32
  proc.stdin.write(text);
@@ -9,6 +9,7 @@ import { chatHref, dismissKey, pushNotification } from "../notifications.js";
9
9
  import { writeMeta } from "./store.js";
10
10
  import { availableSkills } from "./context.js";
11
11
  import { effectiveCwd } from "./sessions.js";
12
+ import { sessionTemp } from "../temp.js";
12
13
  import { emit, isUnattended, ownerLabel, seatAuthor } from "./state.js";
13
14
  /**
14
15
  * Starting an agent process for a seat: its harness, tuning (model,
@@ -22,6 +23,7 @@ async function backendTuning(meta, seat) {
22
23
  const tuning = {};
23
24
  const agentSlug = scope.kind === "agent" ? scope.slug : scope.kind === "channel" ? seat.key : undefined;
24
25
  if (agentSlug) {
26
+ tuning.agent = agentSlug;
25
27
  const def = await getAgent(agentSlug).catch(() => null);
26
28
  if (def?.model)
27
29
  tuning.model = def.model;
@@ -57,6 +59,8 @@ async function backendTuning(meta, seat) {
57
59
  if (skills.length > 0)
58
60
  tuning.skillDirs = skills.map((s) => s.dir);
59
61
  }
62
+ // Every seat gets the chat's scratch folder as its temp dir (core/temp.ts), so nothing temporary lands in files/ or /tmp.
63
+ tuning.temp = (await sessionTemp(meta.id)).abs;
60
64
  return tuning;
61
65
  }
62
66
  export async function ensureBackend(state, seat) {
@@ -103,7 +107,7 @@ export async function ensureBackend(state, seat) {
103
107
  });
104
108
  if (!state.pendingInfo.has(requestId))
105
109
  await dismissKey(notifyKey);
106
- });
110
+ }).catch(() => { });
107
111
  if (isUnattended(state.meta.scope)) {
108
112
  deadline = setTimeout(() => {
109
113
  const pending = state.pendingPermissions.get(requestId);
@@ -154,7 +158,7 @@ export async function ensureBackend(state, seat) {
154
158
  });
155
159
  if (!state.pendingInfo.has(requestId))
156
160
  await dismissKey(notifyKey);
157
- });
161
+ }).catch(() => { });
158
162
  if (isUnattended(state.meta.scope)) {
159
163
  deadline = setTimeout(() => {
160
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). */
@@ -7,6 +7,7 @@ import { deleteAgentSession, listAgentSessions } from "./acp.js";
7
7
  import { chatHref, pushNotification } from "../notifications.js";
8
8
  import { readIdentity } from "../identity.js";
9
9
  import { moveToTrash } from "../trash.js";
10
+ import { removeSessionTemp } from "../temp.js";
10
11
  import { attachmentPath, copyChatFiles, listChatMetas, newChatId, readMetaAt, safeChatDir, writeMeta } from "./store.js";
11
12
  import { postChannelMessage } from "./channel-turns.js";
12
13
  import { expandSkillInvocation, scopeContext } from "./context.js";
@@ -54,6 +55,7 @@ export async function createChat(opts) {
54
55
  cwd,
55
56
  scope: opts.scope,
56
57
  ...(opts.project && opts.scope.kind === "agent" ? { project: opts.project } : {}),
58
+ ...(opts.task ? { task: opts.task } : {}),
57
59
  ...(opts.resume && opts.agent !== "pi" ? { sessions: { [MAIN]: opts.resume } } : {}),
58
60
  author,
59
61
  ...(opts.private ? { private: true } : {}),
@@ -526,6 +528,8 @@ export async function deleteChat(id) {
526
528
  dropBackend(state);
527
529
  chatStates().delete(id);
528
530
  await moveToTrash("chats", id, safeChatDir(id));
531
+ // Scratch, not a deliverable: gone for good with the chat.
532
+ await removeSessionTemp(id).catch(() => { });
529
533
  return {};
530
534
  }
531
535
  /** The agents' own transcripts of a chat folder (one in the trash), deleted for good. Best effort. */
@@ -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. */
@@ -312,5 +330,6 @@ export async function filesContext(scope, heading, intro) {
312
330
  return (`## ${heading}\n${intro} ${s.root}/ — ${s.count} file${s.count === 1 ? "" : "s"}` +
313
331
  (recent ? `, most recently changed:\n${recent}\n` : " (empty so far)\n") +
314
332
  `Read and write them there like any folder; put what you produce for the user in ${s.root}/deliverables/. ` +
333
+ `Only finished work and the material the user gave you belong here — scratch, intermediate and downloaded files go to your temp folder (see Temporary files), never into this folder. ` +
315
334
  `Files over 25 MB stay out of git automatically. Delete nothing there unless asked.`);
316
335
  }
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
@@ -441,7 +441,7 @@ export async function projectContext(slug, actor, opts = {}) {
441
441
  ? `## Knowledge\nOKF bundles linked to this project (markdown + YAML frontmatter under the knowledge root):\n` +
442
442
  linkLines("knowledge", (l) => `- ${l.slug}${l.label ? ` (${l.label})` : ""}`) +
443
443
  `\nRead with \`npx kraftwerk knowledge list <bundle>\`, \`get <bundle>/<path>\`, \`search <text>\`. ` +
444
- `Write ONLY through \`npx kraftwerk knowledge put <bundle>/<path> --file <tmp.md> --actor ${actor}\` ` +
444
+ `Write ONLY through \`npx kraftwerk knowledge put <bundle>/<path> --file $KRAFTWERK_TEMP/<name>.md --actor ${actor}\` ` +
445
445
  `(stamps provenance, maintains index.md/log.md — never hand-edit those).`
446
446
  : "",
447
447
  p.repos.length > 0
@@ -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
  }