@polderlabs/openkan 0.4.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 (114) hide show
  1. package/CHANGELOG.md +226 -0
  2. package/LICENSE +21 -0
  3. package/README.md +318 -0
  4. package/agents/openkan.md +254 -0
  5. package/bin/install-agent.mjs +63 -0
  6. package/bin/ok.mjs +17 -0
  7. package/bin/openkan.mjs +10 -0
  8. package/dist/.claude/skills/ok-planning/SKILL.md +285 -0
  9. package/dist/.claude/skills/ok-planning/references/integration.md +153 -0
  10. package/dist/.claude/skills/ok-planning/references/schemas.md +270 -0
  11. package/dist/.claude/skills/ok-planning/references/workflows.md +185 -0
  12. package/dist/.claude/skills/ok-planning/scripts/ok-init.sh +14 -0
  13. package/dist/.claude/skills/ok-planning/scripts/ok-resume.sh +38 -0
  14. package/dist/.claude/skills/ok-planning/scripts/ok-status.sh +24 -0
  15. package/dist/agents/openkan.md +254 -0
  16. package/dist/bin/install-agent.mjs +76 -0
  17. package/dist/bin/ok-install.js +58 -0
  18. package/dist/bin/ok.js +138 -0
  19. package/dist/bin/openkan.js +804 -0
  20. package/dist/commands/organize.md +15 -0
  21. package/dist/kanban/agent-profile.js +8 -0
  22. package/dist/kanban/archive.js +49 -0
  23. package/dist/kanban/bizar.js +242 -0
  24. package/dist/kanban/board.js +367 -0
  25. package/dist/kanban/bulk.js +139 -0
  26. package/dist/kanban/changelog.js +186 -0
  27. package/dist/kanban/chat.js +1280 -0
  28. package/dist/kanban/claude-state.js +974 -0
  29. package/dist/kanban/comments.js +80 -0
  30. package/dist/kanban/docs.js +144 -0
  31. package/dist/kanban/fs.js +163 -0
  32. package/dist/kanban/git.js +196 -0
  33. package/dist/kanban/images.js +140 -0
  34. package/dist/kanban/import.js +295 -0
  35. package/dist/kanban/inputs.js +94 -0
  36. package/dist/kanban/insights.js +140 -0
  37. package/dist/kanban/io.js +75 -0
  38. package/dist/kanban/mdx-render.js +348 -0
  39. package/dist/kanban/mdx.js +231 -0
  40. package/dist/kanban/projects.js +545 -0
  41. package/dist/kanban/search.js +121 -0
  42. package/dist/kanban/server.js +3296 -0
  43. package/dist/kanban/tags.js +124 -0
  44. package/dist/kanban/template.js +145 -0
  45. package/dist/kanban/tsx-sandbox.js +187 -0
  46. package/dist/kanban/watcher.js +270 -0
  47. package/dist/ok/commands/goal.js +65 -0
  48. package/dist/ok/commands/index.js +87 -0
  49. package/dist/ok/commands/init.js +15 -0
  50. package/dist/ok/commands/plan.js +155 -0
  51. package/dist/ok/commands/prd.js +202 -0
  52. package/dist/ok/commands/progress.js +31 -0
  53. package/dist/ok/commands/task.js +377 -0
  54. package/dist/ok/ids.js +98 -0
  55. package/dist/ok/lock.js +156 -0
  56. package/dist/ok/migrate.js +197 -0
  57. package/dist/ok/schemas.js +402 -0
  58. package/dist/ok/storage.js +222 -0
  59. package/dist/skills/openkan/SKILL.md +111 -0
  60. package/dist/skills/openkan/agents/openai.yaml +4 -0
  61. package/dist/skills/openkan/examples/simple-task.mdx +34 -0
  62. package/dist/skills/openkan/examples/with-ask.mdx +32 -0
  63. package/dist/skills/openkan/examples/with-choice.mdx +51 -0
  64. package/dist/skills/openkan/examples/with-preview.mdx +54 -0
  65. package/dist/skills/openkan/references/api.md +169 -0
  66. package/dist/skills/openkan/templates/task.mdx +46 -0
  67. package/dist/web/api.js +257 -0
  68. package/dist/web/app.js +4251 -0
  69. package/dist/web/bizar.js +39 -0
  70. package/dist/web/brand/agent-activity-sprite.svg +1 -0
  71. package/dist/web/brand/banner-docs.svg +24 -0
  72. package/dist/web/brand/banner.svg +32 -0
  73. package/dist/web/brand/empty-sessions.svg +17 -0
  74. package/dist/web/brand/empty-tasks.svg +17 -0
  75. package/dist/web/brand/favicon.svg +9 -0
  76. package/dist/web/brand/infinity-loader-animated.svg +220 -0
  77. package/dist/web/brand/infinity-loader-spritesheet.svg +230 -0
  78. package/dist/web/brand/logo-wordmark.svg +10 -0
  79. package/dist/web/brand/logo.svg +9 -0
  80. package/dist/web/brand/pixel-infinity-track.svg +1 -0
  81. package/dist/web/brand/social-card.svg +26 -0
  82. package/dist/web/changelog-view.js +456 -0
  83. package/dist/web/charts.js +269 -0
  84. package/dist/web/chat-sidebar.js +2397 -0
  85. package/dist/web/chat-status-motion.js +154 -0
  86. package/dist/web/claude-pane.js +820 -0
  87. package/dist/web/command-palette.js +381 -0
  88. package/dist/web/contributors-view.js +317 -0
  89. package/dist/web/cross-tab.js +102 -0
  90. package/dist/web/docs-view.js +168 -0
  91. package/dist/web/experience.css +165 -0
  92. package/dist/web/goals-view.js +45 -0
  93. package/dist/web/home-view.js +113 -0
  94. package/dist/web/images.js +311 -0
  95. package/dist/web/index.html +485 -0
  96. package/dist/web/insights.js +217 -0
  97. package/dist/web/keyboard.js +446 -0
  98. package/dist/web/mdx-viewer.js +600 -0
  99. package/dist/web/path-picker.js +787 -0
  100. package/dist/web/preview-frame.html +187 -0
  101. package/dist/web/settings.js +582 -0
  102. package/dist/web/style.css +8545 -0
  103. package/dist/web/task-view.js +1759 -0
  104. package/dist/web/vendor/gsap.min.js +11 -0
  105. package/dist/web/workspace.css +1513 -0
  106. package/package.json +71 -0
  107. package/skills/openkan/SKILL.md +111 -0
  108. package/skills/openkan/agents/openai.yaml +4 -0
  109. package/skills/openkan/examples/simple-task.mdx +34 -0
  110. package/skills/openkan/examples/with-ask.mdx +32 -0
  111. package/skills/openkan/examples/with-choice.mdx +51 -0
  112. package/skills/openkan/examples/with-preview.mdx +54 -0
  113. package/skills/openkan/references/api.md +169 -0
  114. package/skills/openkan/templates/task.mdx +46 -0
@@ -0,0 +1,15 @@
1
+ ---
2
+ description: Conservatively organize an OpenKan board using its local HTTP API.
3
+ ---
4
+
5
+ Organize the OpenKan board at `http://127.0.0.1:7777/`.
6
+
7
+ 1. Read `/api/board`, `/api/tasks-index`, and relevant task details.
8
+ 2. Identify miscategorized, related, stale, or underspecified tasks.
9
+ 3. Preserve tasks in In Progress or Review unless they are clearly abandoned.
10
+ 4. Build the smallest useful batch of move, tag, priority, effort, area, archive,
11
+ or restore operations.
12
+ 5. Submit the operations to `POST /api/organize`.
13
+ 6. Re-read the board and report exactly what changed and what was skipped.
14
+
15
+ Be conservative. Do not delete tasks. Do not alter active Bizar sessions.
@@ -0,0 +1,8 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import matter from 'gray-matter';
3
+ export const OPENKAN_AGENT_ID = 'openkan';
4
+ /** Provider-neutral prompt source; Claude's adapter maps it to a session agent. */
5
+ export function openkanAgentDefinition() {
6
+ const { data, content } = matter(readFileSync(new URL('../agents/openkan.md', import.meta.url), 'utf8'));
7
+ return { description: String(data.description), prompt: content.trim(), model: 'inherit' };
8
+ }
@@ -0,0 +1,49 @@
1
+ // OpenKan — archive / restore helpers.
2
+ import { withWrite, getBoard, nowIso } from "./board.js";
3
+ import { writeTaskMdx } from "./mdx.js";
4
+ import { recordEvent } from "./changelog.js";
5
+ export async function archiveTask(task, kanbanDir, author) {
6
+ let updated;
7
+ await withWrite(async (board) => {
8
+ const t = board.tasks.find(x => x.id === task.id);
9
+ if (!t)
10
+ return;
11
+ t.archived = true;
12
+ t.updatedAt = nowIso();
13
+ updated = { ...t };
14
+ });
15
+ if (!updated)
16
+ return task;
17
+ // Write the task MDX so frontmatter reflects archived state
18
+ const board = await getBoard();
19
+ await writeTaskMdx(updated, kanbanDir, board);
20
+ recordEvent(kanbanDir, "task.archived", {
21
+ taskId: task.id,
22
+ author,
23
+ summary: `archived '${task.title}'`,
24
+ payload: {},
25
+ });
26
+ return updated;
27
+ }
28
+ export async function restoreTask(task, kanbanDir, author) {
29
+ let updated;
30
+ await withWrite(async (board) => {
31
+ const t = board.tasks.find(x => x.id === task.id);
32
+ if (!t)
33
+ return;
34
+ t.archived = false;
35
+ t.updatedAt = nowIso();
36
+ updated = { ...t };
37
+ });
38
+ if (!updated)
39
+ return task;
40
+ const board = await getBoard();
41
+ await writeTaskMdx(updated, kanbanDir, board);
42
+ recordEvent(kanbanDir, "task.restored", {
43
+ taskId: task.id,
44
+ author,
45
+ summary: `restored '${task.title}'`,
46
+ payload: {},
47
+ });
48
+ return updated;
49
+ }
@@ -0,0 +1,242 @@
1
+ import { existsSync, readFileSync, } from "node:fs";
2
+ import { resolve, join, isAbsolute } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { WebSocketServer, WebSocket } from "ws";
5
+ import { readSnapshot as readClaudeSnapshot } from "./claude-state.js";
6
+ export class BizarBridgeError extends Error {
7
+ status;
8
+ details;
9
+ constructor(message, status = 500, details) {
10
+ super(message);
11
+ this.name = "BizarBridgeError";
12
+ this.status = status;
13
+ this.details = details;
14
+ }
15
+ }
16
+ function loadOpenKanConfig(openkanProjectRoot) {
17
+ const path = join(openkanProjectRoot, ".ok", "openkan.json");
18
+ try {
19
+ return JSON.parse(readFileSync(path, "utf8"));
20
+ }
21
+ catch {
22
+ return {};
23
+ }
24
+ }
25
+ function resolveConfiguredPath(base, value) {
26
+ return isAbsolute(value) ? resolve(value) : resolve(base, value);
27
+ }
28
+ export function resolveBizarConfig(openkanProjectRoot) {
29
+ const root = resolve(openkanProjectRoot);
30
+ const config = loadOpenKanConfig(root);
31
+ const raw = (config.bizar && typeof config.bizar === "object"
32
+ ? config.bizar
33
+ : {});
34
+ const configuredRoot = process.env.OPENKAN_BIZAR_PROJECT_ROOT
35
+ || raw.projectRoot
36
+ || root;
37
+ const projectRoot = resolveConfiguredPath(root, configuredRoot);
38
+ const command = process.env.OPENKAN_BIZAR_COMMAND
39
+ || raw.command
40
+ || (existsSync(join(projectRoot, "cli", "bin.mjs"))
41
+ ? join(projectRoot, "cli", "bin.mjs")
42
+ : "bizar");
43
+ return {
44
+ enabled: raw.enabled !== false,
45
+ projectRoot,
46
+ command: isAbsolute(command) || command.includes("/")
47
+ ? resolveConfiguredPath(root, command)
48
+ : command,
49
+ };
50
+ }
51
+ /**
52
+ * Legacy snapshot endpoint. The Bizar CLI bridge has been removed; the
53
+ * legacy `/api/bizar/*` URL namespace now sources its data directly from the
54
+ * native Claude Code readers in `claude-state.ts`. New integrations should
55
+ * prefer `/api/claude/*`.
56
+ */
57
+ export async function getBizarSnapshot(openkanProjectRoot) {
58
+ const snapshot = await readClaudeSnapshot(openkanProjectRoot);
59
+ return {
60
+ version: 1,
61
+ projectRoot: openkanProjectRoot,
62
+ agents: snapshot.agents ?? [],
63
+ tasks: [],
64
+ sessions: [],
65
+ messages: [],
66
+ };
67
+ }
68
+ /**
69
+ * Legacy session/message/task command shim. With the Bizar CLI removed, this
70
+ * no longer spawns external processes. Callers receive back a local session
71
+ * identifier that the UI can display alongside the task; nothing actually
72
+ * runs off-host. Mutations (claim/heartbeat/complete/cancel) return
73
+ * `{deprecated:true, status:410}` so existing clients can detect the change.
74
+ * For new integrations use `/api/claude/*` POST handlers and the
75
+ * `/api/claude/events` SSE stream.
76
+ */
77
+ export function executeBizarCommand(_openkanProjectRoot, command, payload) {
78
+ if (command === "start-session") {
79
+ const sessionId = `ses-local-${randomUUID()}`;
80
+ return { session: { sessionId }, deprecated: true, successor: "/api/claude/*" };
81
+ }
82
+ if (command === "stop-session" || command === "send-session" || command === "send-message") {
83
+ return { ok: true, deprecated: true, successor: "/api/claude/*" };
84
+ }
85
+ if (command === "create-task" ||
86
+ command === "claim-task" ||
87
+ command === "heartbeat-task" ||
88
+ command === "complete-task" ||
89
+ command === "cancel-task") {
90
+ return { ok: true, deprecated: true, status: 410, successor: "/api/claude/*" };
91
+ }
92
+ throw new BizarBridgeError(`Unknown Bizar command: ${command}`, 404);
93
+ }
94
+ function response(value, status = 200) {
95
+ return new Response(JSON.stringify(value), {
96
+ status,
97
+ headers: { "content-type": "application/json; charset=utf-8" },
98
+ });
99
+ }
100
+ function errorResponse(error) {
101
+ const bridge = error instanceof BizarBridgeError
102
+ ? error
103
+ : new BizarBridgeError(error?.message || String(error));
104
+ return response({
105
+ error: bridge.message,
106
+ ...(bridge.details ? { details: bridge.details } : {}),
107
+ }, bridge.status);
108
+ }
109
+ function gone(message) {
110
+ return response({
111
+ error: message,
112
+ deprecated: true,
113
+ successor: "/api/claude/*",
114
+ }, 410);
115
+ }
116
+ const GONE = "Bizar CLI integration removed; use /api/claude/*";
117
+ export async function handleBizarRequest(openkanProjectRoot, req, path) {
118
+ try {
119
+ if (req.method === "GET") {
120
+ const snapshot = await getBizarSnapshot(openkanProjectRoot);
121
+ if (path === "/api/bizar/snapshot")
122
+ return response(snapshot);
123
+ if (path === "/api/bizar/agents")
124
+ return response({ agents: snapshot.agents || [] });
125
+ if (path === "/api/bizar/tasks")
126
+ return response({ tasks: snapshot.tasks || [] });
127
+ if (path === "/api/bizar/sessions")
128
+ return response({ sessions: snapshot.sessions || [] });
129
+ if (path === "/api/bizar/messages")
130
+ return response({ messages: snapshot.messages || [] });
131
+ }
132
+ if (req.method !== "POST")
133
+ return response({ error: "Not found" }, 404);
134
+ return gone(GONE);
135
+ }
136
+ catch (error) {
137
+ return errorResponse(error);
138
+ }
139
+ }
140
+ function isLoopback(req) {
141
+ const address = req.socket.remoteAddress || "";
142
+ return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
143
+ }
144
+ export function attachBizarWebSocket(server, projectRoot) {
145
+ const wss = new WebSocketServer({ noServer: true });
146
+ function send(socket, value) {
147
+ if (socket.readyState === WebSocket.OPEN)
148
+ socket.send(JSON.stringify(value));
149
+ }
150
+ async function snapshot(socket) {
151
+ try {
152
+ const event = { type: "snapshot", data: await getBizarSnapshot(projectRoot()) };
153
+ if (socket)
154
+ send(socket, event);
155
+ else
156
+ for (const client of wss.clients)
157
+ send(client, event);
158
+ }
159
+ catch (error) {
160
+ const event = {
161
+ type: "error",
162
+ error: error?.message || String(error),
163
+ };
164
+ if (socket)
165
+ send(socket, event);
166
+ else
167
+ for (const client of wss.clients)
168
+ send(client, event);
169
+ }
170
+ }
171
+ const upgrade = (req, socket, head) => {
172
+ let pathname = "";
173
+ try {
174
+ pathname = new URL(req.url || "/", "http://localhost").pathname;
175
+ }
176
+ catch { /* invalid */ }
177
+ if (pathname !== "/api/bizar/ws")
178
+ return;
179
+ if (!isLoopback(req)) {
180
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
181
+ socket.destroy();
182
+ return;
183
+ }
184
+ wss.handleUpgrade(req, socket, head, (client) => wss.emit("connection", client, req));
185
+ };
186
+ server.on("upgrade", upgrade);
187
+ wss.on("connection", (socket) => {
188
+ snapshot(socket);
189
+ socket.on("message", (raw) => {
190
+ let message;
191
+ try {
192
+ message = JSON.parse(raw.toString());
193
+ }
194
+ catch {
195
+ send(socket, { type: "error", error: "Invalid JSON" });
196
+ return;
197
+ }
198
+ if (message.type === "refresh") {
199
+ snapshot(socket);
200
+ return;
201
+ }
202
+ if (message.type !== "command") {
203
+ send(socket, { type: "error", requestId: message.requestId, error: "Unknown message type" });
204
+ return;
205
+ }
206
+ try {
207
+ // The legacy WS command channel has been superseded by the native
208
+ // /api/claude/events SSE stream and the /api/claude/* POST endpoints.
209
+ // Acknowledging receipt with a 410-styled result keeps existing clients
210
+ // log-parsable while signalling that no work will be performed.
211
+ send(socket, {
212
+ type: "result",
213
+ requestId: message.requestId,
214
+ data: { deprecated: true, status: 410, error: GONE, successor: "/api/claude/*" },
215
+ });
216
+ snapshot();
217
+ }
218
+ catch (error) {
219
+ send(socket, {
220
+ type: "error",
221
+ requestId: message.requestId,
222
+ error: error?.message || String(error),
223
+ });
224
+ }
225
+ });
226
+ });
227
+ const interval = setInterval(() => {
228
+ if (wss.clients.size > 0)
229
+ snapshot();
230
+ }, 5_000);
231
+ interval.unref?.();
232
+ return {
233
+ broadcastSnapshot: () => snapshot(),
234
+ close() {
235
+ clearInterval(interval);
236
+ server.off("upgrade", upgrade);
237
+ for (const client of wss.clients)
238
+ client.close();
239
+ wss.close();
240
+ },
241
+ };
242
+ }
@@ -0,0 +1,367 @@
1
+ import { nanoid } from "nanoid";
2
+ import { existsSync, readFileSync } from "fs";
3
+ import { join } from "path";
4
+ import { writeFileAtomic, ensureDir, cleanupStaleTmp } from "./io.js";
5
+ import { writeTask, readTask as readOkTask, writeConfig, paths as okPaths, rebuildIndex } from "../ok/storage.js";
6
+ import { nowIso as okNowIso } from "../ok/ids.js";
7
+ // ─── Task getter / setter helpers ─────────────────────────────────────────────
8
+ /** Return artifact paths for a task, under .ok/. */
9
+ export function taskArtifacts(taskId) {
10
+ return {
11
+ mdxPath: `tasks/${taskId}/task.mdx`,
12
+ commentsPath: `tasks/${taskId}/comments.json`,
13
+ inputsPath: `tasks/${taskId}/inputs.json`,
14
+ statePath: `tasks/${taskId}/state.json`,
15
+ };
16
+ }
17
+ // ─── Constants ───────────────────────────────────────────────────────────────
18
+ export const DEFAULT_COLUMNS = [
19
+ { id: "backlog", title: "Backlog" },
20
+ { id: "todo", title: "To Do" },
21
+ { id: "doing", title: "In Progress" },
22
+ { id: "review", title: "Review" },
23
+ { id: "done", title: "Done" },
24
+ ];
25
+ export let KANBAN_DIR = ""; // set by initBoard or setKanbanDir
26
+ export const BOARD_FILE = "board.json";
27
+ export const BOARD_MDX = "board.mdx";
28
+ export const TASKS_INDEX_FILE = "tasks.json";
29
+ // ─── Project root (set by the CLI/server before project operations) ─────────
30
+ let _projectRoot = null;
31
+ export function setProjectRoot(dir) { _projectRoot = dir; }
32
+ export function getProjectRoot() {
33
+ if (!_projectRoot)
34
+ throw new Error("Project root not initialised — call setProjectRoot first");
35
+ return _projectRoot;
36
+ }
37
+ /** Allow server.ts to override KANBAN_DIR when the active project changes at runtime. */
38
+ export function setKanbanDir(dir) {
39
+ KANBAN_DIR = dir;
40
+ }
41
+ let _board = null;
42
+ // `KANBAN_DIR` names the persistence directory, but a running server also
43
+ // needs to know which root populated its in-memory board. Keeping this
44
+ // separately prevents a project switch from merely changing output paths
45
+ // while continuing to serve the previous project's cached tasks.
46
+ let _loadedProjectRoot = "";
47
+ let _writeQueue = Promise.resolve();
48
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
49
+ export function newId(prefix) {
50
+ return `${prefix}-${nanoid(8)}`;
51
+ }
52
+ export function nowIso() {
53
+ return new Date().toISOString();
54
+ }
55
+ export function renormalizeOrder(tasks) {
56
+ // Active tasks get renumbered; archived tasks retain their last order so they
57
+ // reappear in the right position when restored.
58
+ const active = tasks.filter(t => !t.archived);
59
+ const archived = tasks.filter(t => t.archived);
60
+ const sortedActive = [...active]
61
+ .sort((a, b) => a.order - b.order || a.id.localeCompare(b.id))
62
+ .map((t, i) => ({ ...t, order: i }));
63
+ return [...sortedActive, ...archived];
64
+ }
65
+ // ─── Init ─────────────────────────────────────────────────────────────────────
66
+ /** One-time idempotent migration: flat tasks/<id>.mdx → tasks/<id>/task.mdx */
67
+ async function migrateLegacyTaskArtifacts(board) {
68
+ for (const task of board.tasks) {
69
+ const flatMdxPath = join(KANBAN_DIR, "tasks", `${task.id}.mdx`);
70
+ if (existsSync(flatMdxPath)) {
71
+ const taskDir = join(KANBAN_DIR, "tasks", task.id);
72
+ ensureDir(taskDir);
73
+ const newMdxPath = join(taskDir, "task.mdx");
74
+ // Read legacy file, write to new location
75
+ const content = readFileSync(flatMdxPath, "utf-8");
76
+ writeFileAtomic(newMdxPath, content);
77
+ // Update task.artifact to new layout
78
+ task.artifact = `tasks/${task.id}/task.mdx`;
79
+ task.artifacts = taskArtifacts(task.id);
80
+ try {
81
+ // Remove old flat file
82
+ const { unlinkSync } = await import("node:fs");
83
+ unlinkSync(flatMdxPath);
84
+ }
85
+ catch {
86
+ // ignore
87
+ }
88
+ }
89
+ else if (!task.artifacts) {
90
+ // Back-compat: ensure artifacts field exists for old tasks loaded from board.json
91
+ task.artifacts = taskArtifacts(task.id);
92
+ }
93
+ // Ensure state field exists for old tasks
94
+ if (!task.state) {
95
+ task.state = task.status ?? "idle";
96
+ }
97
+ if (!task.pendingInputs) {
98
+ task.pendingInputs = [];
99
+ }
100
+ // New fields: apply defaults for legacy tasks
101
+ if (!task.tags)
102
+ task.tags = [];
103
+ if (!task.category)
104
+ task.category = "task";
105
+ if (!task.priority)
106
+ task.priority = "normal";
107
+ if (task.effort === undefined)
108
+ task.effort = null;
109
+ if (task.archived === undefined)
110
+ task.archived = false;
111
+ if (!task.assignees)
112
+ task.assignees = [];
113
+ if (!task.images)
114
+ task.images = [];
115
+ if (task.parentId === undefined)
116
+ task.parentId = null;
117
+ if (!task.subtaskIds)
118
+ task.subtaskIds = [];
119
+ }
120
+ }
121
+ export async function initBoard(ctx) {
122
+ // Finish writes against the current board before replacing its cache. This
123
+ // makes project activation an atomic boundary for callers sharing a server.
124
+ await _writeQueue.catch(() => undefined);
125
+ const dir = join(ctx.directory, ".ok");
126
+ KANBAN_DIR = dir;
127
+ ensureDir(dir);
128
+ cleanupStaleTmp(dir);
129
+ // Write tasks.json from board.tasks if tasks.json doesn't exist (migration helper)
130
+ const tasksIndexPath = join(dir, TASKS_INDEX_FILE);
131
+ if (!existsSync(tasksIndexPath)) {
132
+ // Will be written after board is loaded
133
+ }
134
+ const boardPath = join(dir, BOARD_FILE);
135
+ if (existsSync(boardPath)) {
136
+ const raw = readFileSync(boardPath, "utf-8");
137
+ _board = JSON.parse(raw);
138
+ // Apply migrations
139
+ await migrateLegacyTaskArtifacts(_board);
140
+ await persist(_board);
141
+ }
142
+ else {
143
+ _board = {
144
+ version: 1,
145
+ columns: [...DEFAULT_COLUMNS],
146
+ tasks: [],
147
+ sessions: {},
148
+ };
149
+ await persist(_board);
150
+ }
151
+ _loadedProjectRoot = ctx.directory;
152
+ return { board: _board, dir };
153
+ }
154
+ /**
155
+ * Load the board for `ctx.directory` when the active project changed.
156
+ * Unlike `setKanbanDir`, this refreshes the in-memory board as well as its
157
+ * destination path, which is required for a long-lived multi-project server.
158
+ */
159
+ export async function ensureBoardForProject(ctx) {
160
+ if (_board && _loadedProjectRoot === ctx.directory) {
161
+ return { board: _board, dir: KANBAN_DIR };
162
+ }
163
+ return initBoard(ctx);
164
+ }
165
+ export async function getBoard() {
166
+ if (!_board)
167
+ throw new Error("Board not initialised — call initBoard first");
168
+ return _board;
169
+ }
170
+ // ─── Write queue ─────────────────────────────────────────────────────────────
171
+ export async function withWrite(fn) {
172
+ const operation = _writeQueue.catch(() => undefined).then(async () => {
173
+ if (!_board)
174
+ throw new Error("Board not initialised");
175
+ const result = await fn(_board);
176
+ await persist(_board);
177
+ return result;
178
+ });
179
+ _writeQueue = operation.then(() => undefined, () => undefined);
180
+ return operation;
181
+ }
182
+ // ─── Persist ─────────────────────────────────────────────────────────────────
183
+ /**
184
+ * Map an OpenKan engine `Task` onto the planning-system `ok.task.v1`
185
+ * shape. Column placement (`backlog|todo|doing|review|done`) maps onto
186
+ * the planning status enum (`pending|in_progress|review|done|cancelled`)
187
+ * so a single field is the canonical lifecycle indicator.
188
+ */
189
+ function toPlanningTask(task) {
190
+ const status = mapColumnToStatus(task.column, task.state, task.archived);
191
+ const ok = {
192
+ schema: "ok.task.v1",
193
+ id: task.id.startsWith("tsk-") ? task.id : `tsk-${task.id}`,
194
+ title: task.title || "untitled",
195
+ status,
196
+ createdAt: task.createdAt,
197
+ updatedAt: task.updatedAt,
198
+ };
199
+ if (task.agent)
200
+ ok.owner = task.agent;
201
+ if (task.description && task.description.trim().length > 0)
202
+ ok.description = task.description;
203
+ if (task.tags && task.tags.length > 0)
204
+ ok.scopes = task.tags;
205
+ return ok;
206
+ }
207
+ function mapColumnToStatus(column, state, archived) {
208
+ if (archived)
209
+ return "cancelled";
210
+ if (state === "done")
211
+ return "done";
212
+ if (state === "cancelled" || state === "failed")
213
+ return "cancelled";
214
+ if (state === "running" || state === "waiting-for-input")
215
+ return "in_progress";
216
+ if (column === "review")
217
+ return "review";
218
+ if (column === "doing")
219
+ return "in_progress";
220
+ if (column === "done")
221
+ return "done";
222
+ return "pending";
223
+ }
224
+ let _okMirrorWritesDisabled = false;
225
+ /** Disable the side-effect per-task mirror writes (used by tests). */
226
+ export function setOkMirrorWritesDisabled(v) { _okMirrorWritesDisabled = v; }
227
+ /**
228
+ * Mirror the engine board into the planning-system store. Idempotent:
229
+ * existing per-task JSONs are overwritten with the latest engine view.
230
+ * `config.json` is created on first call so the planning system sees the
231
+ * project as initialised.
232
+ */
233
+ async function mirrorToOkStore(board) {
234
+ if (_okMirrorWritesDisabled)
235
+ return;
236
+ if (!KANBAN_DIR)
237
+ return;
238
+ // KANBAN_DIR = <root>/.ok/. The planning layout is exactly that.
239
+ // We resolve the project root as the parent of KANBAN_DIR.
240
+ const projectRoot = join(KANBAN_DIR, "..");
241
+ const p = okPaths(projectRoot);
242
+ ensureDir(p.tasksDir);
243
+ ensureDir(p.plansDir);
244
+ ensureDir(p.prdsDir);
245
+ ensureDir(p.sessionsDir);
246
+ ensureDir(p.locksDir);
247
+ // Ensure config.json exists
248
+ if (!existsSync(p.configFile)) {
249
+ const now = okNowIso();
250
+ await writeConfig(p, { schema: "ok.config.v1", version: 1, createdAt: now, updatedAt: now });
251
+ }
252
+ // Write one JSON per task, plus idempotent tasks.json index.
253
+ const seen = new Set();
254
+ const indexEntries = [];
255
+ for (const t of board.tasks) {
256
+ const okTask = toPlanningTask(t);
257
+ await writeTask(p, okTask);
258
+ seen.add(okTask.id);
259
+ indexEntries.push({ id: okTask.id, status: okTask.status, title: okTask.title, updatedAt: okTask.updatedAt });
260
+ }
261
+ // Best-effort index rebuild (non-fatal if it fails).
262
+ try {
263
+ await rebuildIndex(p);
264
+ }
265
+ catch { /* swallow */ }
266
+ }
267
+ /**
268
+ * Map a planning-system `ok.task.v1` entry onto the engine Task schema.
269
+ * Used when the source of truth for a task lives in `.ok/tasks/<id>.json`
270
+ * (e.g. an agent invoked `ok task add` directly). The reverse direction
271
+ * is `toPlanningTask` above; together they keep both stores coherent.
272
+ */
273
+ function fromPlanningTask(ok) {
274
+ if (!ok.id || !/^tsk-[A-Za-z0-9_-]+$/.test(ok.id))
275
+ return null;
276
+ const column = ok.status === "done" ? "done" :
277
+ ok.status === "review" ? "review" :
278
+ ok.status === "in_progress" ? "doing" :
279
+ ok.status === "cancelled" ? "backlog" :
280
+ ok.status === "pending" ? "todo" :
281
+ "todo";
282
+ const state = ok.status === "done" ? "done" :
283
+ ok.status === "cancelled" ? "cancelled" :
284
+ ok.status === "in_progress" ? "running" :
285
+ "idle";
286
+ const arts = taskArtifacts(ok.id);
287
+ return {
288
+ id: ok.id,
289
+ title: ok.title ?? "untitled",
290
+ description: ok.description ?? "",
291
+ column,
292
+ order: 0,
293
+ sessionId: null,
294
+ agent: ok.owner ?? "",
295
+ model: null,
296
+ status: state,
297
+ state,
298
+ lastError: null,
299
+ createdAt: ok.createdAt,
300
+ updatedAt: ok.updatedAt,
301
+ artifact: arts.mdxPath,
302
+ sessionArtifact: null,
303
+ pendingInputs: [],
304
+ artifacts: arts,
305
+ tags: ok.scopes ? [...ok.scopes] : [],
306
+ category: "task",
307
+ priority: (ok.priority ?? "p3"),
308
+ effort: null,
309
+ archived: ok.status === "cancelled",
310
+ assignees: ok.owner ? [ok.owner] : [],
311
+ images: [],
312
+ parentId: null,
313
+ subtaskIds: [],
314
+ };
315
+ }
316
+ /**
317
+ * Reconcile a single `.ok/tasks/<id>.json` entry into the engine board.
318
+ * Called by the SSE watcher when an agent (e.g. via `ok task add`) writes
319
+ * a planning task while a dashboard server is running. No-op when the
320
+ * board already has the task (subsequent edits flow through the existing
321
+ * HTTP PATCH path); returns the inserted task for broadcasting.
322
+ *
323
+ * The function intentionally mirrors the `apiCreateTask` shape so the
324
+ * dashboard's broadcast and write-quote semantics stay consistent.
325
+ */
326
+ export async function reconcileOkTask(taskId, kanbanDir = KANBAN_DIR) {
327
+ if (!/^tsk-[A-Za-z0-9_-]+$/.test(taskId))
328
+ return null;
329
+ if (!kanbanDir)
330
+ return null;
331
+ const projectRoot = join(kanbanDir, "..");
332
+ const p = okPaths(projectRoot);
333
+ const ok = await readOkTask(p, taskId);
334
+ if (!ok)
335
+ return null;
336
+ // Skip if the board already has this id — the HTTP path owns updates.
337
+ if (_board && _board.tasks.some(t => t.id === ok.id))
338
+ return null;
339
+ const task = fromPlanningTask(ok);
340
+ if (!task)
341
+ return null;
342
+ let inserted;
343
+ await withWrite(async (board) => {
344
+ // Re-check after queueing; another reconcile could have won.
345
+ if (board.tasks.some(t => t.id === task.id)) {
346
+ inserted = board.tasks.find(t => t.id === task.id);
347
+ return;
348
+ }
349
+ const colTasks = board.tasks.filter(t => t.column === task.column);
350
+ task.order = colTasks.length;
351
+ board.tasks.push(task);
352
+ inserted = task;
353
+ });
354
+ return inserted ?? null;
355
+ }
356
+ export async function persist(board) {
357
+ if (!KANBAN_DIR)
358
+ return;
359
+ const dest = join(KANBAN_DIR, BOARD_FILE);
360
+ writeFileAtomic(dest, JSON.stringify(board, null, 2));
361
+ // Mirror into the planning-system store. Side effect only; failure does
362
+ // not abort the engine write.
363
+ try {
364
+ await mirrorToOkStore(board);
365
+ }
366
+ catch { /* swallow */ }
367
+ }