@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
@@ -0,0 +1,936 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { parse, stringify } from "yaml";
4
+ import { boardsRootFor, resolveWorkspace } from "../config.js";
5
+ import { changed } from "./changes.js";
6
+ import { getOutputDir, workspaceRoot } from "./context.js";
7
+ import { mergeDays } from "./journal.js";
8
+ import { newestMtime } from "./mtime.js";
9
+ import { openProjects, safeProjectSlug } from "./projects.js";
10
+ import { findRefs } from "./refs.js";
11
+ import { SLUG_RE, slugify } from "./slug.js";
12
+ import { moveToTrash } from "./trash.js";
13
+ /**
14
+ * Boards and tasks: the work of a project (or of the whole workspace) as
15
+ * columns of cards, stored as plain folders so humans, agents and git all
16
+ * read the same thing. A board holds columns and tasks; a task is a folder
17
+ * with the markdown that describes it, an append-only log of what happened
18
+ * to it, and whatever files it needs.
19
+ *
20
+ * projects/<p>/boards/<board>/board.md # title, columns, default agent + the board's rules (body)
21
+ * projects/<p>/boards/<board>/tasks/<n>-<slug>/task.md # title, status (a column), order, assignee, due, tags, refs + the task (body)
22
+ * projects/<p>/boards/<board>/tasks/<n>-<slug>/log.md # newest first: moves, hand-overs, comments, files (journal format)
23
+ * projects/<p>/boards/<board>/tasks/<n>-<slug>/… # the task's files
24
+ * kraftwerk-data/boards/<board>/… # workspace boards, same shape (`boards.root`)
25
+ *
26
+ * The folder is the registry: a folder with a board.md is a board, a
27
+ * folder under tasks/ with a task.md is a task. The column is a field of
28
+ * task.md, not a folder per column, so a move is a one-line diff and the
29
+ * task's path (links, files) never changes. Task folders are numbered per
30
+ * board (`50-write-a-press-release`): the number is the short handle in
31
+ * the CLI, the whole name is the reference token (task:50-write-a-press-release).
32
+ *
33
+ * Agents are woken by events, not by polling: whoever writes a task (an API
34
+ * route, the CLI in another process) appends a line to <output>/boards/events.jsonl
35
+ * when a task with an agent as assignee sits in a work column, and the server
36
+ * on this machine consumes the file (board-wake.ts). The output folder is
37
+ * local, so a clone of the workspace never sees another machine's events.
38
+ */
39
+ export const BOARD_FILE = "board.md";
40
+ export const TASK_FILE = "task.md";
41
+ export const TASK_LOG = "log.md";
42
+ const TASKS_DIR = "tasks";
43
+ const BOARDS_DIR = "boards";
44
+ const LOCK_FILE = ".lock";
45
+ /** A lock older than this is a crashed writer's, not a live one. */
46
+ const LOCK_STALE_MS = 10_000;
47
+ /** The folder (and the default board's slug) a project's tasks go to when no board is named. */
48
+ export const DEFAULT_BOARD = "tasks";
49
+ export const DEFAULT_COLUMNS = [
50
+ { slug: "draft", label: "Draft" },
51
+ { slug: "todo", label: "Todo" },
52
+ { slug: "progress", label: "In progress" },
53
+ { slug: "review", label: "Review", review: true },
54
+ { slug: "done", label: "Done", done: true },
55
+ ];
56
+ /* ---------- root ---------- */
57
+ export async function openBoards() {
58
+ let project;
59
+ try {
60
+ project = await resolveWorkspace(workspaceRoot());
61
+ }
62
+ catch (err) {
63
+ return { error: err.message };
64
+ }
65
+ const root = boardsRootFor(project);
66
+ if (!root)
67
+ return { off: true, error: "boards are off (enable them in settings or add `boards:` to kraftwerk.yml)" };
68
+ return { project, root };
69
+ }
70
+ /** Make sure the workspace boards root exists; runs when the feature is switched on. */
71
+ export async function ensureBoardsRoot() {
72
+ const opened = await openBoards();
73
+ if (opened.root)
74
+ await fs.mkdir(opened.root, { recursive: true });
75
+ }
76
+ async function requireRoot() {
77
+ const opened = await openBoards();
78
+ if (!opened.root)
79
+ throw new Error(opened.error ?? "boards are off");
80
+ return opened.root;
81
+ }
82
+ export function safeBoardSlug(slug) {
83
+ if (!SLUG_RE.test(slug))
84
+ throw new Error(`invalid board name "${slug}"`);
85
+ return slug;
86
+ }
87
+ const TASK_SLUG_RE = /^(\d+)-([a-z0-9][a-z0-9-]{0,48})$/;
88
+ /** The folder name of a task: <number>-<slug>. */
89
+ export function safeTaskSlug(slug) {
90
+ if (!TASK_SLUG_RE.test(slug))
91
+ throw new Error(`invalid task name "${slug}" (expected <number>-<slug>, e.g. 50-write-a-press-release)`);
92
+ return slug;
93
+ }
94
+ /** The folder a board lives in: inside its project (projects must be on) or under the workspace root. */
95
+ export async function boardDir(ref) {
96
+ const root = await requireRoot();
97
+ const board = safeBoardSlug(ref.board);
98
+ if (!ref.project)
99
+ return path.join(root, board);
100
+ const projects = await openProjects();
101
+ if (!projects.root)
102
+ throw new Error(projects.error ?? "projects are off");
103
+ const dir = path.join(projects.root, safeProjectSlug(ref.project));
104
+ if (!(await fs.stat(path.join(dir, "project.yml")).catch(() => null)))
105
+ throw new Error(`no project "${ref.project}"`);
106
+ return path.join(dir, BOARDS_DIR, board);
107
+ }
108
+ /** "<project>/<board>" or "<board>". */
109
+ export const boardKey = (ref) => (ref.project ? `${ref.project}/${ref.board}` : ref.board);
110
+ /** "<project>/<board>/<task>" or "<board>/<task>". */
111
+ export const taskRef = (ref, task) => `${boardKey(ref)}/${task}`;
112
+ /** Parse "<project>/<board>/<task>" or "<board>/<task>" (and the two-or-one-part board forms when `task` is false). */
113
+ export function parseTaskRef(text) {
114
+ const parts = text.trim().split("/").filter(Boolean);
115
+ if (parts.length === 2)
116
+ return { board: { board: safeBoardSlug(parts[0]) }, task: safeTaskSlug(parts[1]) };
117
+ if (parts.length === 3)
118
+ return { board: { project: safeProjectSlug(parts[0]), board: safeBoardSlug(parts[1]) }, task: safeTaskSlug(parts[2]) };
119
+ throw new Error(`invalid task reference "${text}" (expected <board>/<task> or <project>/<board>/<task>)`);
120
+ }
121
+ export function parseBoardRef(text) {
122
+ const parts = text.trim().split("/").filter(Boolean);
123
+ if (parts.length === 1)
124
+ return { board: safeBoardSlug(parts[0]) };
125
+ if (parts.length === 2)
126
+ return { project: safeProjectSlug(parts[0]), board: safeBoardSlug(parts[1]) };
127
+ throw new Error(`invalid board reference "${text}" (expected <board> or <project>/<board>)`);
128
+ }
129
+ /* ---------- board.md ---------- */
130
+ const cleanColumns = (v) => {
131
+ if (v === undefined || v === null)
132
+ return [];
133
+ if (!Array.isArray(v) || v.length === 0)
134
+ throw new Error("columns must be a non-empty list");
135
+ const out = [];
136
+ for (const raw of v) {
137
+ const c = typeof raw === "string" ? { slug: raw } : raw;
138
+ if (typeof c !== "object" || c === null || Array.isArray(c))
139
+ throw new Error("each column is a slug or {slug, label, limit, done, review}");
140
+ const r = c;
141
+ const slug = typeof r.slug === "string" ? r.slug.trim() : "";
142
+ if (!SLUG_RE.test(slug))
143
+ throw new Error(`column slug "${slug}" must be lowercase letters, digits and dashes`);
144
+ if (out.some((x) => x.slug === slug))
145
+ throw new Error(`column "${slug}" appears twice`);
146
+ const label = typeof r.label === "string" && r.label.trim() ? r.label.trim() : slug[0].toUpperCase() + slug.slice(1);
147
+ const col = { slug, label };
148
+ if (r.limit !== undefined && r.limit !== null) {
149
+ const n = Number(r.limit);
150
+ if (!Number.isInteger(n) || n < 1)
151
+ throw new Error(`column "${slug}": limit must be a positive whole number`);
152
+ col.limit = n;
153
+ }
154
+ if (r.done !== undefined && typeof r.done !== "boolean")
155
+ throw new Error(`column "${slug}": done must be true or false`);
156
+ if (r.review !== undefined && typeof r.review !== "boolean")
157
+ throw new Error(`column "${slug}": review must be true or false`);
158
+ if (r.done)
159
+ col.done = true;
160
+ if (r.review)
161
+ col.review = true;
162
+ out.push(col);
163
+ }
164
+ return out;
165
+ };
166
+ /** "1d", "12h", "90m" -> milliseconds. */
167
+ export function parseDuration(text) {
168
+ const m = /^(\d+)\s*([mhd])$/.exec(text.trim());
169
+ if (!m)
170
+ throw new Error(`stale_after must be like 90m, 12h or 2d, not "${text}"`);
171
+ return Number(m[1]) * { m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]];
172
+ }
173
+ const cleanAgentSlug = (v) => {
174
+ if (v === undefined || v === null || v === "")
175
+ return undefined;
176
+ if (typeof v !== "string" || !SLUG_RE.test(v.trim()))
177
+ throw new Error("agent must be an agent slug");
178
+ return v.trim();
179
+ };
180
+ /** Split a Markdown file into its frontmatter text and body (same rule as entities.ts). */
181
+ function splitFrontmatter(raw) {
182
+ if (!raw.startsWith("---"))
183
+ return { fm: null, body: raw };
184
+ const m = /^---[ \t]*\r?\n---[ \t]*(\r?\n|$)/.exec(raw) ?? /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(\r?\n|$)/.exec(raw);
185
+ if (!m)
186
+ return { fm: null, body: raw };
187
+ return { fm: m[1] ?? "", body: raw.slice(m[0].length).replace(/^\r?\n/, "") };
188
+ }
189
+ const toFile = (doc, body) => `---\n${stringify(doc)}---\n${body.trim() ? `\n${body.replace(/\s+$/, "")}\n` : ""}`;
190
+ const fmData = (fm) => {
191
+ const data = fm === null ? {} : (parse(fm) ?? {});
192
+ if (typeof data !== "object" || Array.isArray(data))
193
+ throw new Error("frontmatter must be a mapping");
194
+ return data;
195
+ };
196
+ /** The workspace's default columns: `boards.columns` in kraftwerk.yml, else DEFAULT_COLUMNS. */
197
+ async function defaultColumns() {
198
+ const opened = await openBoards();
199
+ const v = opened.project?.config.boards?.columns;
200
+ if (!v)
201
+ return DEFAULT_COLUMNS;
202
+ try {
203
+ return cleanColumns(v);
204
+ }
205
+ catch {
206
+ return DEFAULT_COLUMNS;
207
+ }
208
+ }
209
+ function parseBoard(ref, data, fallback) {
210
+ const title = typeof data.title === "string" && data.title.trim() ? data.title.trim() : ref.board;
211
+ const columns = cleanColumns(data.columns);
212
+ const def = { ...ref, title, columns: columns.length ? columns : fallback };
213
+ const agent = cleanAgentSlug(data.agent);
214
+ if (agent)
215
+ def.agent = agent;
216
+ if (data.stale_after !== undefined && data.stale_after !== null && data.stale_after !== "") {
217
+ parseDuration(String(data.stale_after));
218
+ def.staleAfter = String(data.stale_after).trim();
219
+ }
220
+ return def;
221
+ }
222
+ function boardToFile(def, notes, explicitColumns) {
223
+ const doc = { title: def.title };
224
+ if (explicitColumns)
225
+ doc.columns = def.columns.map((c) => (Object.keys(c).length === 2 && c.label === c.slug[0].toUpperCase() + c.slug.slice(1) ? c.slug : c));
226
+ if (def.agent)
227
+ doc.agent = def.agent;
228
+ if (def.staleAfter)
229
+ doc.stale_after = def.staleAfter;
230
+ return toFile(doc, notes);
231
+ }
232
+ const emptyBoard = (ref, columns) => ({ ...ref, title: ref.board, columns });
233
+ /** 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. */
234
+ export const pickColumn = (columns) => columns.find((c) => c.slug === "todo") ?? columns[1] ?? columns[0];
235
+ /** A column's role: work columns — from the pick column on, review and done excepted — are where an assigned agent is expected to act. */
236
+ export const isWorkColumn = (c, columns) => {
237
+ if (!c || c.done || c.review)
238
+ return false;
239
+ const pick = pickColumn(columns);
240
+ return !pick || columns.indexOf(c) >= columns.indexOf(pick);
241
+ };
242
+ export const reviewColumn = (columns) => columns.find((c) => c.review) ?? columns.find((c) => c.slug === "review");
243
+ async function readBoard(ref, dir, fallback) {
244
+ let raw;
245
+ try {
246
+ raw = await fs.readFile(path.join(dir, BOARD_FILE), "utf8");
247
+ }
248
+ catch {
249
+ return null;
250
+ }
251
+ const newest = await newestMtime(dir).catch(() => 0);
252
+ const updatedAt = newest ? new Date(newest).toISOString() : undefined;
253
+ const { fm, body } = splitFrontmatter(raw);
254
+ try {
255
+ return { summary: { ...parseBoard(ref, fmData(fm), fallback), path: dir, ...(updatedAt ? { updatedAt } : {}) }, notes: body };
256
+ }
257
+ catch (err) {
258
+ return { summary: { ...emptyBoard(ref, fallback), path: dir, ...(updatedAt ? { updatedAt } : {}), configError: err.message }, notes: body };
259
+ }
260
+ }
261
+ /* ---------- task.md ---------- */
262
+ const strList = (v, field) => {
263
+ if (v === undefined || v === null || v === "")
264
+ return [];
265
+ const list = typeof v === "string" ? v.split(",") : v;
266
+ if (!Array.isArray(list) || !list.every((x) => typeof x === "string"))
267
+ throw new Error(`${field} must be a list`);
268
+ return [...new Set(list.map((x) => x.trim()).filter(Boolean))];
269
+ };
270
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/;
271
+ const cleanDate = (v, field) => {
272
+ if (v === undefined || v === null || v === "")
273
+ return undefined;
274
+ const s = v instanceof Date ? v.toISOString() : String(v).trim();
275
+ const iso = v instanceof Date && /T00:00:00\.000Z$/.test(s) ? s.slice(0, 10) : s;
276
+ if (!DATE_RE.test(iso) || Number.isNaN(Date.parse(iso)))
277
+ throw new Error(`${field} must be an ISO date (2026-11-20)`);
278
+ return iso;
279
+ };
280
+ const TASK_KEYS = new Set(["title", "status", "order", "assignee", "due", "created", "tags", "refs"]);
281
+ const TASK_READ_ONLY = new Set(["slug", "number", "ref", "path", "updatedAt", "configError", "files", "lastLog", "stale", "notes", "log", "fileList", "board", "project", "extra"]);
282
+ function parseTask(slug, data, columns) {
283
+ const m = TASK_SLUG_RE.exec(slug);
284
+ const number = m ? Number(m[1]) : 0;
285
+ const title = typeof data.title === "string" && data.title.trim() ? data.title.trim() : slug;
286
+ const status = typeof data.status === "string" && data.status.trim() ? data.status.trim() : columns[0]?.slug ?? "draft";
287
+ if (!SLUG_RE.test(status))
288
+ throw new Error(`status "${status}" is not a column slug`);
289
+ const order = data.order === undefined || data.order === null ? 0 : Number(data.order);
290
+ if (!Number.isFinite(order))
291
+ throw new Error("order must be a number");
292
+ const def = { slug, number, title, status, order, created: cleanDate(data.created, "created") ?? "", tags: strList(data.tags, "tags"), refs: strList(data.refs, "refs") };
293
+ const assignee = cleanAgentSlug(data.assignee);
294
+ if (assignee)
295
+ def.assignee = assignee;
296
+ const due = cleanDate(data.due, "due");
297
+ if (due)
298
+ def.due = due;
299
+ const extra = Object.fromEntries(Object.entries(data).filter(([k, v]) => !TASK_KEYS.has(k) && !TASK_READ_ONLY.has(k) && v !== undefined));
300
+ if (Object.keys(extra).length)
301
+ def.extra = extra;
302
+ return def;
303
+ }
304
+ function taskToFile(def, notes) {
305
+ const doc = { title: def.title, status: def.status, order: def.order };
306
+ if (def.assignee)
307
+ doc.assignee = def.assignee;
308
+ if (def.due)
309
+ doc.due = def.due;
310
+ if (def.created)
311
+ doc.created = def.created;
312
+ if (def.tags.length)
313
+ doc.tags = def.tags;
314
+ if (def.refs.length)
315
+ doc.refs = def.refs;
316
+ for (const [k, v] of Object.entries(def.extra ?? {}))
317
+ if (!(k in doc))
318
+ doc[k] = v;
319
+ return toFile(doc, notes);
320
+ }
321
+ const emptyTask = (slug, columns) => ({
322
+ slug,
323
+ number: Number(TASK_SLUG_RE.exec(slug)?.[1] ?? 0),
324
+ title: slug,
325
+ status: columns[0]?.slug ?? "draft",
326
+ order: 0,
327
+ created: "",
328
+ tags: [],
329
+ refs: [],
330
+ });
331
+ /** The files of a task folder: everything but task.md, log.md and dot files, relative to the folder. */
332
+ async function taskFiles(dir) {
333
+ const out = [];
334
+ const walk = async (d) => {
335
+ if (out.length > 500)
336
+ return;
337
+ for (const k of await fs.readdir(d, { withFileTypes: true }).catch(() => [])) {
338
+ if (k.name.startsWith("."))
339
+ continue;
340
+ const abs = path.join(d, k.name);
341
+ if (k.isDirectory())
342
+ await walk(abs);
343
+ else if (k.isFile() && !(d === dir && (k.name === TASK_FILE || k.name === TASK_LOG)))
344
+ out.push(path.relative(dir, abs).split(path.sep).join("/"));
345
+ }
346
+ };
347
+ await walk(dir);
348
+ return out.sort();
349
+ }
350
+ /** The newest entry of a log in the journal format, without its stamp and actor. */
351
+ function lastLogLine(raw) {
352
+ const line = raw.split("\n").find((l) => l.startsWith("* "));
353
+ return line ? line.replace(/^\* (\d\d:\d\d )?/, "").replace(/\s\([^()]*\)\s*$/, "").trim() : undefined;
354
+ }
355
+ /** The moment of the newest log entry (local time, as the journal format writes it). */
356
+ function lastLogAt(raw) {
357
+ const lines = raw.split("\n");
358
+ const day = lines.find((l) => /^## \d{4}-\d{2}-\d{2}/.test(l))?.slice(3).trim();
359
+ const time = lines.find((l) => /^\* \d\d:\d\d /.test(l))?.slice(2, 7);
360
+ if (!day)
361
+ return undefined;
362
+ const t = Date.parse(`${day}T${time ?? "00:00"}`);
363
+ return Number.isNaN(t) ? undefined : t;
364
+ }
365
+ async function readTask(ref, board, slug) {
366
+ const dir = path.join(await boardDir(ref), TASKS_DIR, slug);
367
+ let raw;
368
+ let st;
369
+ const file = path.join(dir, TASK_FILE);
370
+ try {
371
+ [raw, st] = await Promise.all([fs.readFile(file, "utf8"), fs.stat(file)]);
372
+ }
373
+ catch {
374
+ return null;
375
+ }
376
+ const [log, files] = await Promise.all([fs.readFile(path.join(dir, TASK_LOG), "utf8").catch(() => ""), taskFiles(dir)]);
377
+ const updatedAt = new Date(st.mtimeMs).toISOString();
378
+ const { fm, body } = splitFrontmatter(raw);
379
+ const base = { ref: taskRef(ref, slug), path: dir, updatedAt, files: files.length };
380
+ const last = lastLogLine(log);
381
+ try {
382
+ const def = parseTask(slug, fmData(fm), board.columns);
383
+ const col = board.columns.find((c) => c.slug === def.status);
384
+ const summary = { ...def, ...base, ...(last ? { lastLog: last } : {}) };
385
+ if (def.assignee && isWorkColumn(col, board.columns)) {
386
+ const at = lastLogAt(log) ?? st.mtimeMs;
387
+ if (Date.now() - at > parseDuration(board.staleAfter ?? "1d"))
388
+ summary.stale = true;
389
+ }
390
+ return { summary, notes: body, log };
391
+ }
392
+ catch (err) {
393
+ return { summary: { ...emptyTask(slug, board.columns), ...base, configError: err.message, ...(last ? { lastLog: last } : {}) }, notes: body, log };
394
+ }
395
+ }
396
+ /* ---------- reads ---------- */
397
+ async function boardSlugsIn(dir) {
398
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
399
+ return entries.filter((e) => e.isDirectory() && SLUG_RE.test(e.name)).map((e) => e.name).sort();
400
+ }
401
+ async function taskSlugsIn(dir) {
402
+ const entries = await fs.readdir(path.join(dir, TASKS_DIR), { withFileTypes: true }).catch(() => []);
403
+ return entries
404
+ .filter((e) => e.isDirectory() && TASK_SLUG_RE.test(e.name))
405
+ .map((e) => e.name)
406
+ .sort((a, b) => Number(TASK_SLUG_RE.exec(a)[1]) - Number(TASK_SLUG_RE.exec(b)[1]));
407
+ }
408
+ const byColumn = (columns) => {
409
+ const rank = new Map(columns.map((c, i) => [c.slug, i]));
410
+ return (a, b) => (rank.get(a.status) ?? columns.length) - (rank.get(b.status) ?? columns.length) || a.order - b.order || (a.due ?? "9").localeCompare(b.due ?? "9") || a.number - b.number;
411
+ };
412
+ async function summarise(ref, dir, fallback) {
413
+ const read = await readBoard(ref, dir, fallback);
414
+ if (!read)
415
+ return null;
416
+ const items = (await Promise.all((await taskSlugsIn(dir)).map((s) => readTask(ref, read.summary, s)))).filter((t) => t != null).map((t) => t.summary);
417
+ items.sort(byColumn(read.summary.columns));
418
+ const counts = Object.fromEntries(read.summary.columns.map((c) => [c.slug, 0]));
419
+ for (const t of items)
420
+ counts[read.summary.columns.some((c) => c.slug === t.status) ? t.status : ""] = (counts[read.summary.columns.some((c) => c.slug === t.status) ? t.status : ""] ?? 0) + 1;
421
+ const review = items.filter((t) => read.summary.columns.find((c) => c.slug === t.status)?.review).length;
422
+ return { summary: { ...read.summary, counts, tasks: items.length, review }, notes: read.notes, items };
423
+ }
424
+ /** The boards of a project (its boards/ folder), or the workspace boards when no project is given. */
425
+ export async function listBoards(project) {
426
+ const opened = await openBoards();
427
+ if (!opened.root)
428
+ return { enabled: false, boards: [], error: opened.error };
429
+ const fallback = await defaultColumns();
430
+ let dir;
431
+ if (project) {
432
+ const projects = await openProjects();
433
+ if (!projects.root)
434
+ return { enabled: true, root: opened.root, boards: [], error: projects.error };
435
+ dir = path.join(projects.root, safeProjectSlug(project), BOARDS_DIR);
436
+ }
437
+ else
438
+ dir = opened.root;
439
+ const boards = (await Promise.all((await boardSlugsIn(dir)).map((b) => summarise({ ...(project ? { project } : {}), board: b }, path.join(dir, b), fallback))))
440
+ .filter((x) => x != null)
441
+ .map((x) => x.summary);
442
+ return { enabled: true, root: opened.root, boards };
443
+ }
444
+ /** Every board of the workspace: the workspace ones first, then each project's. */
445
+ export async function listAllBoards() {
446
+ const opened = await openBoards();
447
+ if (!opened.root)
448
+ return [];
449
+ const out = (await listBoards()).boards;
450
+ const projects = await openProjects();
451
+ if (projects.root) {
452
+ const slugs = (await fs.readdir(projects.root, { withFileTypes: true }).catch(() => [])).filter((e) => e.isDirectory() && SLUG_RE.test(e.name)).map((e) => e.name);
453
+ for (const p of slugs)
454
+ out.push(...(await listBoards(p).catch(() => ({ boards: [] }))).boards);
455
+ }
456
+ return out;
457
+ }
458
+ export async function getBoard(ref) {
459
+ const dir = await boardDir(ref);
460
+ const read = await summarise(ref, dir, await defaultColumns());
461
+ return read ? { ...read.summary, notes: read.notes, items: read.items } : null;
462
+ }
463
+ async function requireBoard(ref) {
464
+ const b = await getBoard(ref);
465
+ if (!b)
466
+ throw new Error(`no board "${boardKey(ref)}"`);
467
+ return b;
468
+ }
469
+ /** Tasks of a board, optionally narrowed by column, assignee (or "" for unassigned) and tag. */
470
+ export async function listTasks(ref, filter = {}) {
471
+ const b = await requireBoard(ref);
472
+ return b.items.filter((t) => (filter.status === undefined || t.status === filter.status) && (filter.assignee === undefined || (t.assignee ?? "") === filter.assignee) && (filter.tag === undefined || t.tags.includes(filter.tag)));
473
+ }
474
+ /** A task by its folder name or its bare number. */
475
+ async function resolveTaskSlug(ref, idOrNumber) {
476
+ const id = idOrNumber.trim();
477
+ if (TASK_SLUG_RE.test(id))
478
+ return id;
479
+ if (/^\d+$/.test(id)) {
480
+ const n = Number(id);
481
+ const slugs = await taskSlugsIn(await boardDir(ref));
482
+ const hit = slugs.find((s) => Number(TASK_SLUG_RE.exec(s)[1]) === n);
483
+ if (hit)
484
+ return hit;
485
+ throw new Error(`no task ${n} on board "${boardKey(ref)}"`);
486
+ }
487
+ return safeTaskSlug(id);
488
+ }
489
+ export async function getTask(ref, idOrNumber) {
490
+ const board = await requireBoard(ref);
491
+ const slug = await resolveTaskSlug(ref, idOrNumber).catch(() => null);
492
+ if (!slug)
493
+ return null;
494
+ const read = await readTask(ref, board, slug);
495
+ if (!read)
496
+ return null;
497
+ return { ...read.summary, notes: read.notes, log: read.log, fileList: await taskFiles(read.summary.path), board: ref.board, ...(ref.project ? { project: ref.project } : {}) };
498
+ }
499
+ /** Find a task by its reference anywhere: "<project>/<board>/<task>" or "<board>/<task>". */
500
+ export async function findTask(reference) {
501
+ const { board, task } = parseTaskRef(reference);
502
+ return getTask(board, task).catch(() => null);
503
+ }
504
+ /* ---------- writes: boards ---------- */
505
+ export async function createBoard(input) {
506
+ const title = (input.title ?? "").trim();
507
+ if (!title)
508
+ throw new Error("title is required");
509
+ const slug = safeBoardSlug((input.slug ?? "").trim() || slugify(title) || "board");
510
+ const ref = { ...(input.project ? { project: safeProjectSlug(input.project) } : {}), board: slug };
511
+ const dir = await boardDir(ref);
512
+ if (await fs.stat(dir).catch(() => null))
513
+ throw new Error(`board "${boardKey(ref)}" already exists`);
514
+ const columns = cleanColumns(input.columns);
515
+ const def = { ...ref, title, columns: columns.length ? columns : await defaultColumns() };
516
+ const agent = cleanAgentSlug(input.agent);
517
+ if (agent)
518
+ def.agent = agent;
519
+ await fs.mkdir(path.join(dir, TASKS_DIR), { recursive: true });
520
+ await fs.writeFile(path.join(dir, BOARD_FILE), boardToFile(def, input.notes ?? "", columns.length > 0), { flag: "wx" });
521
+ changed();
522
+ return (await getBoard(ref));
523
+ }
524
+ export async function saveBoard(ref, input) {
525
+ const current = await requireBoard(ref);
526
+ if (current.configError)
527
+ throw new Error(`board "${boardKey(ref)}" cannot be saved until its file is fixed by hand (${current.configError}): ${current.path}`);
528
+ const raw = await fs.readFile(path.join(current.path, BOARD_FILE), "utf8");
529
+ const { fm } = splitFrontmatter(raw);
530
+ const data = fmData(fm);
531
+ const explicit = input.columns !== undefined ? cleanColumns(input.columns).length > 0 : data.columns !== undefined && data.columns !== null;
532
+ const next = {
533
+ ...ref,
534
+ title: input.title !== undefined ? String(input.title).trim() || ref.board : current.title,
535
+ columns: input.columns !== undefined && cleanColumns(input.columns).length ? cleanColumns(input.columns) : explicit ? current.columns : await defaultColumns(),
536
+ };
537
+ const agent = input.agent !== undefined ? cleanAgentSlug(input.agent ?? undefined) : current.agent;
538
+ if (agent)
539
+ next.agent = agent;
540
+ const stale = input.staleAfter !== undefined ? (input.staleAfter ? input.staleAfter.trim() : undefined) : current.staleAfter;
541
+ if (stale) {
542
+ parseDuration(stale);
543
+ next.staleAfter = stale;
544
+ }
545
+ await fs.writeFile(path.join(current.path, BOARD_FILE), boardToFile(next, typeof input.notes === "string" ? input.notes : current.notes, explicit));
546
+ changed();
547
+ return (await getBoard(ref));
548
+ }
549
+ /** Move the board folder, tasks and all, to the trash. */
550
+ export async function deleteBoard(ref) {
551
+ const b = await requireBoard(ref);
552
+ await moveToTrash("boards", boardKey(ref), b.path);
553
+ changed();
554
+ }
555
+ /** The project's default board (`tasks`), created on first use. */
556
+ export async function ensureDefaultBoard(project) {
557
+ const ref = { ...(project ? { project } : {}), board: DEFAULT_BOARD };
558
+ if (await getBoard(ref))
559
+ return ref;
560
+ await createBoard({ project, title: "Tasks", slug: DEFAULT_BOARD, notes: "The tasks of this " + (project ? "project" : "workspace") + ". Agents pick from Todo, work in In progress and hand finished work to Review; only a human moves a task to Done." });
561
+ return ref;
562
+ }
563
+ /* ---------- the task lock ---------- */
564
+ /**
565
+ * One writer at a time per task folder: the UI, an agent's CLI and the
566
+ * event consumer may all touch task.md within the same second. The lock is
567
+ * a file created exclusively; a lock older than LOCK_STALE_MS belonged to
568
+ * a writer that died and is taken over.
569
+ */
570
+ async function withTaskLock(dir, fn) {
571
+ const lock = path.join(dir, LOCK_FILE);
572
+ const deadline = Date.now() + 5_000;
573
+ for (;;) {
574
+ try {
575
+ await fs.writeFile(lock, String(process.pid), { flag: "wx" });
576
+ break;
577
+ }
578
+ catch (err) {
579
+ if (err.code !== "EEXIST")
580
+ throw err;
581
+ const st = await fs.stat(lock).catch(() => null);
582
+ if (!st || Date.now() - st.mtimeMs > LOCK_STALE_MS) {
583
+ await fs.rm(lock, { force: true });
584
+ continue;
585
+ }
586
+ if (Date.now() > deadline)
587
+ throw new Error(`task is being written by another process (${lock})`);
588
+ await new Promise((r) => setTimeout(r, 50 + Math.random() * 100));
589
+ }
590
+ }
591
+ try {
592
+ return await fn();
593
+ }
594
+ finally {
595
+ await fs.rm(lock, { force: true });
596
+ }
597
+ }
598
+ /* ---------- the task log ---------- */
599
+ const pad = (n) => String(n).padStart(2, "0");
600
+ const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
601
+ const clock = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
602
+ /** Prepend one stamped line to a task's log.md (journal format: `## day`, `* HH:MM text (actor)`, newest first). No lock: callers hold it. */
603
+ async function writeLogLine(dir, title, text, actor, now = new Date()) {
604
+ const file = path.join(dir, TASK_LOG);
605
+ const raw = (await fs.readFile(file, "utf8").catch(() => null)) ?? `# ${title} — log\n`;
606
+ const lines = mergeDays(raw).split("\n");
607
+ const line = `* ${clock(now)} ${text} (${actor})`;
608
+ const today = localDate(now);
609
+ const todayIdx = lines.findIndex((l) => l.trim() === `## ${today}`);
610
+ if (todayIdx >= 0)
611
+ lines.splice(todayIdx + 1, 0, line);
612
+ else {
613
+ const titleIdx = lines.findIndex((l) => l.startsWith("# "));
614
+ lines.splice(titleIdx + 1, 0, "", `## ${today}`, line);
615
+ }
616
+ await fs.writeFile(file, lines.join("\n").replace(/\n{3,}/g, "\n\n"));
617
+ }
618
+ const cleanActor = (actor) => (actor ?? "").trim() || "human:user";
619
+ const oneLine = (text) => text.trim().replace(/\s*\n\s*/g, " ");
620
+ /** Append a free entry to a task's log: what was done, a comment, a question. */
621
+ export async function appendTaskLog(ref, idOrNumber, entry, actor) {
622
+ const t = await getTask(ref, idOrNumber);
623
+ if (!t)
624
+ throw new Error(`no task "${idOrNumber}" on board "${boardKey(ref)}"`);
625
+ const text = oneLine(entry);
626
+ if (!text)
627
+ throw new Error("entry is required");
628
+ await withTaskLock(t.path, () => writeLogLine(t.path, t.title, text, cleanActor(actor)));
629
+ changed();
630
+ return (await getTask(ref, t.slug));
631
+ }
632
+ export const EVENTS_FILE = "boards/events.jsonl";
633
+ /** Append an event for the server on this machine (board-wake.ts reads the file). Local run-state: never in git. */
634
+ async function emitTaskEvent(ev) {
635
+ const file = path.join(getOutputDir(), EVENTS_FILE);
636
+ await fs.mkdir(path.dirname(file), { recursive: true });
637
+ await fs.appendFile(file, `${JSON.stringify(ev)}\n`);
638
+ changed();
639
+ }
640
+ /** After a write: wake the assignee when the (assignee, status) pair changed into "an agent in a work column". */
641
+ async function maybeEmit(ref, board, before, after, reason, actor, message) {
642
+ const col = board.columns.find((c) => c.slug === after.status);
643
+ if (!after.assignee || !isWorkColumn(col, board.columns))
644
+ return;
645
+ if (before && before.assignee === after.assignee && before.status === after.status)
646
+ return;
647
+ // The assignee acting on its own task (moving it to progress, logging) is already at work: no wake.
648
+ if (actor === after.assignee || actor.startsWith(`${after.assignee}/`))
649
+ return;
650
+ await emitTaskEvent({
651
+ at: new Date().toISOString(),
652
+ ref: taskRef(ref, after.slug),
653
+ ...(ref.project ? { project: ref.project } : {}),
654
+ board: ref.board,
655
+ task: after.slug,
656
+ title: after.title,
657
+ agent: after.assignee,
658
+ status: after.status,
659
+ reason,
660
+ actor,
661
+ ...(message ? { message } : {}),
662
+ });
663
+ }
664
+ /* ---------- writes: tasks ---------- */
665
+ /** Validate refs tokens: each must be a kind:slug the registry knows (a missing target is fine). */
666
+ const cleanRefs = (v) => strList(v, "refs").map((t) => {
667
+ const found = findRefs(t);
668
+ if (found.length !== 1 || found[0].token !== t)
669
+ throw new Error(`refs must be kind:slug tokens (contact:anna-meier), not "${t}"`);
670
+ return t;
671
+ });
672
+ function applyInput(def, input, board) {
673
+ const next = { ...def, tags: [...def.tags], refs: [...def.refs] };
674
+ if (input.title !== undefined) {
675
+ const t = String(input.title).trim();
676
+ if (!t)
677
+ throw new Error("title is required");
678
+ next.title = t;
679
+ }
680
+ if (input.status !== undefined) {
681
+ const s = String(input.status).trim();
682
+ if (!board.columns.some((c) => c.slug === s))
683
+ throw new Error(`no column "${s}" on board "${boardKey(board)}" (columns: ${board.columns.map((c) => c.slug).join(", ")})`);
684
+ next.status = s;
685
+ }
686
+ if (input.order !== undefined) {
687
+ if (!Number.isFinite(Number(input.order)))
688
+ throw new Error("order must be a number");
689
+ next.order = Number(input.order);
690
+ }
691
+ if (input.assignee !== undefined) {
692
+ const a = cleanAgentSlug(input.assignee ?? undefined);
693
+ if (a)
694
+ next.assignee = a;
695
+ else
696
+ delete next.assignee;
697
+ }
698
+ if (input.due !== undefined) {
699
+ const d = cleanDate(input.due ?? undefined, "due");
700
+ if (d)
701
+ next.due = d;
702
+ else
703
+ delete next.due;
704
+ }
705
+ if (input.tags !== undefined)
706
+ next.tags = strList(input.tags, "tags");
707
+ if (input.refs !== undefined)
708
+ next.refs = cleanRefs(input.refs);
709
+ return next;
710
+ }
711
+ /** Sparse order numbers, so a card can be dropped between two others without renumbering the column every time. */
712
+ const ORDER_STEP = 10;
713
+ /** The next order value at the end of a column. */
714
+ const nextOrder = (items, status) => Math.max(0, ...items.filter((t) => t.status === status).map((t) => t.order)) + ORDER_STEP;
715
+ export async function createTask(ref, input) {
716
+ const board = await requireBoard(ref);
717
+ if (board.configError)
718
+ throw new Error(`board "${boardKey(ref)}" cannot take tasks until its file is fixed by hand (${board.configError}): ${board.path}`);
719
+ const title = (input.title ?? "").trim();
720
+ if (!title)
721
+ throw new Error("title is required");
722
+ const actor = cleanActor(input.actor);
723
+ const base = emptyTask("0-task", board.columns);
724
+ base.created = localDate(new Date());
725
+ let def = applyInput(base, { ...input, title }, board);
726
+ // A task created straight into todo on a board with a default agent is that agent's.
727
+ if (!def.assignee && board.agent && def.status === pickColumn(board.columns)?.slug)
728
+ def.assignee = board.agent;
729
+ if (input.order === undefined)
730
+ def.order = nextOrder(board.items, def.status);
731
+ const tasksDir = path.join(board.path, TASKS_DIR);
732
+ await fs.mkdir(tasksDir, { recursive: true });
733
+ // The number counts up per board; exclusive mkdir settles a race between two creators.
734
+ let dir = "";
735
+ for (let attempt = 0; attempt < 20; attempt++) {
736
+ const slugs = await taskSlugsIn(board.path);
737
+ const n = Math.max(0, ...slugs.map((s) => Number(TASK_SLUG_RE.exec(s)[1]))) + 1;
738
+ const slug = `${n}-${slugify(title, 40) || "task"}`;
739
+ try {
740
+ await fs.mkdir(path.join(tasksDir, slug));
741
+ def = { ...def, slug, number: n };
742
+ dir = path.join(tasksDir, slug);
743
+ break;
744
+ }
745
+ catch (err) {
746
+ if (err.code !== "EEXIST")
747
+ throw err;
748
+ }
749
+ }
750
+ if (!dir)
751
+ throw new Error("could not allot a task number");
752
+ await fs.writeFile(path.join(dir, TASK_FILE), taskToFile(def, typeof input.notes === "string" ? input.notes : ""), { flag: "wx" });
753
+ const col = board.columns.find((c) => c.slug === def.status);
754
+ await writeLogLine(dir, def.title, `Created in ${col?.label ?? def.status}${def.assignee ? `, assigned to ${def.assignee}` : ""}${input.message ? `: ${oneLine(input.message)}` : ""}`, actor);
755
+ await maybeEmit(ref, board, null, def, "created", actor, input.message);
756
+ changed();
757
+ return (await getTask(ref, def.slug));
758
+ }
759
+ /** Merge fields (and the notes when given) into task.md; a status or assignee change is logged and may wake the assignee. */
760
+ export async function saveTask(ref, idOrNumber, input, opts = {}) {
761
+ const board = await requireBoard(ref);
762
+ const current = await getTask(ref, idOrNumber);
763
+ if (!current)
764
+ throw new Error(`no task "${idOrNumber}" on board "${boardKey(ref)}"`);
765
+ if (current.configError)
766
+ throw new Error(`task "${current.slug}" cannot be saved until its file is fixed by hand (${current.configError}): ${current.path}`);
767
+ const actor = cleanActor(opts.actor);
768
+ const { ref: _r, path: _p, updatedAt: _u, configError: _c, files: _f, lastLog: _l, stale: _s, notes: _n, log: _g, fileList: _fl, board: _b, project: _pj, ...def } = current;
769
+ const next = applyInput(def, input, board);
770
+ if (input.status !== undefined && input.status !== def.status && input.order === undefined)
771
+ next.order = nextOrder(board.items, next.status);
772
+ await withTaskLock(current.path, async () => {
773
+ await fs.writeFile(path.join(current.path, TASK_FILE), taskToFile(next, typeof input.notes === "string" ? input.notes : current.notes));
774
+ const msg = opts.message ? `: ${oneLine(opts.message)}` : "";
775
+ const label = (s) => board.columns.find((c) => c.slug === s)?.label ?? s;
776
+ if (next.status !== def.status)
777
+ await writeLogLine(current.path, next.title, `Moved ${label(def.status)} → ${label(next.status)}${msg}`, actor);
778
+ if ((next.assignee ?? "") !== (def.assignee ?? "")) {
779
+ await writeLogLine(current.path, next.title, next.assignee ? `Assigned ${def.assignee ? `${def.assignee} → ` : ""}${next.assignee}${msg}` : `Unassigned${def.assignee ? ` (was ${def.assignee})` : ""}${msg}`, actor);
780
+ }
781
+ else if (opts.message && next.status === def.status)
782
+ await writeLogLine(current.path, next.title, oneLine(opts.message), actor);
783
+ });
784
+ await maybeEmit(ref, board, def, next, next.status !== def.status ? "moved" : "assigned", actor, opts.message);
785
+ changed();
786
+ return (await getTask(ref, next.slug));
787
+ }
788
+ /** Put a task into a column (and, with `order`, at a position: the orders of the column are renumbered around it). */
789
+ export async function moveTask(ref, idOrNumber, status, opts = {}) {
790
+ const board = await requireBoard(ref);
791
+ const slug = await resolveTaskSlug(ref, idOrNumber);
792
+ let order = opts.order;
793
+ if (opts.before !== undefined) {
794
+ // Drop above a sibling: take its order and push the rest of the column down.
795
+ const column = board.items.filter((t) => t.status === status && t.slug !== slug).sort((a, b) => a.order - b.order);
796
+ const idx = column.findIndex((t) => t.slug === opts.before);
797
+ if (idx < 0)
798
+ order = nextOrder(board.items, status);
799
+ else {
800
+ order = (idx + 1) * ORDER_STEP;
801
+ for (let i = 0; i < column.length; i++) {
802
+ const want = (i + 1 + (i >= idx ? 1 : 0)) * ORDER_STEP;
803
+ if (column[i].order !== want)
804
+ await withTaskLock(column[i].path, () => rewriteOrder(column[i].path, want));
805
+ }
806
+ }
807
+ }
808
+ return saveTask(ref, slug, { status, ...(order !== undefined ? { order } : {}) }, opts);
809
+ }
810
+ async function rewriteOrder(dir, order) {
811
+ const file = path.join(dir, TASK_FILE);
812
+ const raw = await fs.readFile(file, "utf8");
813
+ const { fm, body } = splitFrontmatter(raw);
814
+ const data = fmData(fm);
815
+ data.order = order;
816
+ await fs.writeFile(file, toFile(data, body));
817
+ }
818
+ /** Give the task to an agent, or back to the humans (`to` = null: unassigned and into the review column). */
819
+ export async function assignTask(ref, idOrNumber, to, opts = {}) {
820
+ const board = await requireBoard(ref);
821
+ if (to === null || to === "" || to === "human") {
822
+ const review = reviewColumn(board.columns);
823
+ return saveTask(ref, idOrNumber, { assignee: null, ...(review ? { status: review.slug } : {}) }, opts);
824
+ }
825
+ return saveTask(ref, idOrNumber, { assignee: to }, opts);
826
+ }
827
+ /**
828
+ * An agent takes the next task: the first unassigned (or its own) task in
829
+ * the pick column, in the column's order (due date as tie-break), becomes
830
+ * its and moves to the first work column after it. Exclusive: under the
831
+ * task's lock the file is read again, so two agents never take the same one.
832
+ */
833
+ export async function pickTask(ref, agent, opts = {}) {
834
+ const board = await requireBoard(ref);
835
+ const from = opts.column ? board.columns.find((c) => c.slug === opts.column) : pickColumn(board.columns);
836
+ if (!from)
837
+ throw new Error(`board "${boardKey(ref)}" has no column to pick from`);
838
+ const toCol = board.columns.slice(board.columns.indexOf(from) + 1).find((c) => isWorkColumn(c, board.columns)) ?? from;
839
+ const slugAgent = cleanAgentSlug(agent);
840
+ if (!slugAgent)
841
+ throw new Error("agent is required");
842
+ const candidates = board.items.filter((t) => !t.configError && t.status === from.slug && (!t.assignee || t.assignee === slugAgent)).sort((a, b) => a.order - b.order || (a.due ?? "9").localeCompare(b.due ?? "9") || a.number - b.number);
843
+ for (const c of candidates) {
844
+ const won = await withTaskLock(c.path, async () => {
845
+ const fresh = await readTask(ref, board, c.slug);
846
+ if (!fresh || fresh.summary.status !== from.slug || (fresh.summary.assignee && fresh.summary.assignee !== slugAgent))
847
+ return false;
848
+ const { ref: _r, path: _p, updatedAt: _u, configError: _c, files: _f, lastLog: _l, stale: _s, ...def } = fresh.summary;
849
+ const next = { ...def, assignee: slugAgent, status: toCol.slug, order: nextOrder(board.items, toCol.slug) };
850
+ await fs.writeFile(path.join(c.path, TASK_FILE), taskToFile(next, fresh.notes));
851
+ await writeLogLine(c.path, next.title, `Picked up (${from.label} → ${toCol.label})`, cleanActor(opts.actor ?? slugAgent));
852
+ return true;
853
+ });
854
+ if (won) {
855
+ changed();
856
+ return (await getTask(ref, c.slug));
857
+ }
858
+ }
859
+ return null;
860
+ }
861
+ /** Move the task folder, files and log included, to the trash. */
862
+ export async function deleteTask(ref, idOrNumber) {
863
+ const t = await getTask(ref, idOrNumber);
864
+ if (!t)
865
+ throw new Error(`no task "${idOrNumber}" on board "${boardKey(ref)}"`);
866
+ await moveToTrash("tasks", taskRef(ref, t.slug), t.path);
867
+ changed();
868
+ }
869
+ /** Called by the files API after a file was added to or removed from a task folder. */
870
+ export async function noteTaskFile(reference, what, file, actor) {
871
+ const t = await findTask(reference);
872
+ if (!t)
873
+ return;
874
+ await withTaskLock(t.path, () => writeLogLine(t.path, t.title, `File ${what}: ${file}`, cleanActor(actor)));
875
+ changed();
876
+ }
877
+ /* ---------- agent context ---------- */
878
+ const cliBoard = (ref) => (ref.project ? `--project ${ref.project} --board ${ref.board}` : `--board ${ref.board}`);
879
+ /** One line per task for a context block. */
880
+ const taskLine = (t) => `- ${t.number} ${t.title} [${t.status}${t.assignee ? `, ${t.assignee}` : ""}${t.due ? `, due ${t.due}` : ""}${t.files ? `, ${t.files} file${t.files === 1 ? "" : "s"}` : ""}]${t.lastLog ? ` — ${t.lastLog}` : ""}`;
881
+ /**
882
+ * The "## Board: …" blocks of a project chat (or of an agent for the
883
+ * boards it has tasks on): the board's rules, its columns with counts, the
884
+ * open tasks, and the CLI. `agent` narrows the task list to that agent's
885
+ * and the unassigned ones in the pick column.
886
+ */
887
+ export async function boardsContext(project, actor, agent) {
888
+ const view = await listBoards(project).catch(() => ({ enabled: false, boards: [] }));
889
+ if (!view.enabled || view.boards.length === 0)
890
+ return "";
891
+ const blocks = [];
892
+ for (const b of view.boards) {
893
+ const detail = await getBoard(b).catch(() => null);
894
+ if (!detail)
895
+ continue;
896
+ const pick = pickColumn(detail.columns);
897
+ const open = detail.items.filter((t) => {
898
+ const col = detail.columns.find((c) => c.slug === t.status);
899
+ if (!col || col.done)
900
+ return false;
901
+ if (!agent)
902
+ return true;
903
+ return t.assignee === agent || (!t.assignee && col.slug === pick?.slug);
904
+ });
905
+ const cols = detail.columns.map((c) => `${c.label} (${detail.counts[c.slug] ?? 0})${c.review ? " — waits for a human" : c.done ? " — finished, humans only" : ""}`).join(", ");
906
+ blocks.push(`## Board: ${detail.title}${project ? "" : " (workspace)"}\n` +
907
+ (detail.notes.trim() ? `${detail.notes.trim()}\n` : "") +
908
+ `Columns: ${cols}.\n` +
909
+ (open.length ? `${agent ? "Your tasks and the unassigned ones in " + (pick?.label ?? "the pick column") : "Open tasks"}:\n${open.map(taskLine).join("\n")}\n` : "No open tasks.\n") +
910
+ `Work the board through the CLI, never by editing task.md by hand (${cliBoard(b)} on every command; the number alone names a task):\n` +
911
+ `- \`npx kraftwerk tasks list ${cliBoard(b)} --json\`, \`tasks show <n> ${cliBoard(b)}\` (prints the folder path, the files and the log)\n` +
912
+ `- \`npx kraftwerk tasks create "<title>" ${cliBoard(b)} --status ${pick?.slug ?? "todo"} --assign <agent> --file $KRAFTWERK_TEMP/task.md\` (the file is the task's markdown: what, done-when checklist, notes) — assigning an agent wakes it\n` +
913
+ `- \`npx kraftwerk tasks pick ${cliBoard(b)} --as <agent>\` takes the next task in ${pick?.label ?? "the pick column"}; \`tasks move <n> <column> -m "<why>"\`; \`tasks assign <n> <agent> -m "<hand-over note>"\` (or \`--to human\` to give it back); \`tasks log <n> "<what you did>"\`\n` +
914
+ `- Put the task's files into its folder (\`tasks show\` prints it), scratch into $KRAFTWERK_TEMP. Log progress as you go: the humans read the log, not your chat.\n` +
915
+ `- Never move a task into a finished column; hand it to Review and a human decides. Actor on your lines: ${actor}.`);
916
+ }
917
+ return blocks.join("\n\n");
918
+ }
919
+ /** The block a session gets when it was opened for one task: the task, its files and its whole log. */
920
+ export async function taskContext(reference) {
921
+ const t = await findTask(reference);
922
+ if (!t)
923
+ return `You were opened for task ${reference}, but it does not exist any more (removed, or its board is gone). Tell the user.`;
924
+ const ref = { ...(t.project ? { project: t.project } : {}), board: t.board };
925
+ const board = await getBoard(ref).catch(() => null);
926
+ const cols = board ? board.columns.map((c) => c.slug).join(", ") : "";
927
+ return (`## Your task: ${t.number} ${t.title}\n` +
928
+ `Board ${boardKey(ref)}${board ? ` (columns: ${cols})` : ""}; status ${t.status}${t.assignee ? `, assigned to ${t.assignee}` : ""}${t.due ? `, due ${t.due}` : ""}. Folder: ${t.path}\n` +
929
+ (board?.notes.trim() ? `Board rules: ${board.notes.trim()}\n` : "") +
930
+ `\n### task.md\n${t.notes.trim() || "(no description — ask the user what done looks like and write it with `tasks set <n> --notes-file`)"}\n` +
931
+ `\n### Files\n${t.fileList.length ? t.fileList.map((f) => `- ${f}`).join("\n") : "(none yet)"}\n` +
932
+ `\n### Log (newest first)\n${t.log.trim() || "(empty)"}\n\n` +
933
+ `Work it through the CLI (${cliBoard(ref)}, the number ${t.number} names the task): \`npx kraftwerk tasks move ${t.number} progress ${cliBoard(ref)}\` when you start, ` +
934
+ `\`tasks log ${t.number} "<what you did>" ${cliBoard(ref)}\` as you go, files into the folder above (scratch into $KRAFTWERK_TEMP), ` +
935
+ `\`tasks assign ${t.number} <agent> -m "<note>" ${cliBoard(ref)}\` to hand it over, \`tasks move ${t.number} review -m "<summary>" ${cliBoard(ref)}\` when done. Never move it to a finished column.`);
936
+ }