@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.
- package/CHANGELOG.md +226 -0
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/agents/openkan.md +254 -0
- package/bin/install-agent.mjs +63 -0
- package/bin/ok.mjs +17 -0
- package/bin/openkan.mjs +10 -0
- package/dist/.claude/skills/ok-planning/SKILL.md +285 -0
- package/dist/.claude/skills/ok-planning/references/integration.md +153 -0
- package/dist/.claude/skills/ok-planning/references/schemas.md +270 -0
- package/dist/.claude/skills/ok-planning/references/workflows.md +185 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-init.sh +14 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-resume.sh +38 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-status.sh +24 -0
- package/dist/agents/openkan.md +254 -0
- package/dist/bin/install-agent.mjs +76 -0
- package/dist/bin/ok-install.js +58 -0
- package/dist/bin/ok.js +138 -0
- package/dist/bin/openkan.js +804 -0
- package/dist/commands/organize.md +15 -0
- package/dist/kanban/agent-profile.js +8 -0
- package/dist/kanban/archive.js +49 -0
- package/dist/kanban/bizar.js +242 -0
- package/dist/kanban/board.js +367 -0
- package/dist/kanban/bulk.js +139 -0
- package/dist/kanban/changelog.js +186 -0
- package/dist/kanban/chat.js +1280 -0
- package/dist/kanban/claude-state.js +974 -0
- package/dist/kanban/comments.js +80 -0
- package/dist/kanban/docs.js +144 -0
- package/dist/kanban/fs.js +163 -0
- package/dist/kanban/git.js +196 -0
- package/dist/kanban/images.js +140 -0
- package/dist/kanban/import.js +295 -0
- package/dist/kanban/inputs.js +94 -0
- package/dist/kanban/insights.js +140 -0
- package/dist/kanban/io.js +75 -0
- package/dist/kanban/mdx-render.js +348 -0
- package/dist/kanban/mdx.js +231 -0
- package/dist/kanban/projects.js +545 -0
- package/dist/kanban/search.js +121 -0
- package/dist/kanban/server.js +3296 -0
- package/dist/kanban/tags.js +124 -0
- package/dist/kanban/template.js +145 -0
- package/dist/kanban/tsx-sandbox.js +187 -0
- package/dist/kanban/watcher.js +270 -0
- package/dist/ok/commands/goal.js +65 -0
- package/dist/ok/commands/index.js +87 -0
- package/dist/ok/commands/init.js +15 -0
- package/dist/ok/commands/plan.js +155 -0
- package/dist/ok/commands/prd.js +202 -0
- package/dist/ok/commands/progress.js +31 -0
- package/dist/ok/commands/task.js +377 -0
- package/dist/ok/ids.js +98 -0
- package/dist/ok/lock.js +156 -0
- package/dist/ok/migrate.js +197 -0
- package/dist/ok/schemas.js +402 -0
- package/dist/ok/storage.js +222 -0
- package/dist/skills/openkan/SKILL.md +111 -0
- package/dist/skills/openkan/agents/openai.yaml +4 -0
- package/dist/skills/openkan/examples/simple-task.mdx +34 -0
- package/dist/skills/openkan/examples/with-ask.mdx +32 -0
- package/dist/skills/openkan/examples/with-choice.mdx +51 -0
- package/dist/skills/openkan/examples/with-preview.mdx +54 -0
- package/dist/skills/openkan/references/api.md +169 -0
- package/dist/skills/openkan/templates/task.mdx +46 -0
- package/dist/web/api.js +257 -0
- package/dist/web/app.js +4251 -0
- package/dist/web/bizar.js +39 -0
- package/dist/web/brand/agent-activity-sprite.svg +1 -0
- package/dist/web/brand/banner-docs.svg +24 -0
- package/dist/web/brand/banner.svg +32 -0
- package/dist/web/brand/empty-sessions.svg +17 -0
- package/dist/web/brand/empty-tasks.svg +17 -0
- package/dist/web/brand/favicon.svg +9 -0
- package/dist/web/brand/infinity-loader-animated.svg +220 -0
- package/dist/web/brand/infinity-loader-spritesheet.svg +230 -0
- package/dist/web/brand/logo-wordmark.svg +10 -0
- package/dist/web/brand/logo.svg +9 -0
- package/dist/web/brand/pixel-infinity-track.svg +1 -0
- package/dist/web/brand/social-card.svg +26 -0
- package/dist/web/changelog-view.js +456 -0
- package/dist/web/charts.js +269 -0
- package/dist/web/chat-sidebar.js +2397 -0
- package/dist/web/chat-status-motion.js +154 -0
- package/dist/web/claude-pane.js +820 -0
- package/dist/web/command-palette.js +381 -0
- package/dist/web/contributors-view.js +317 -0
- package/dist/web/cross-tab.js +102 -0
- package/dist/web/docs-view.js +168 -0
- package/dist/web/experience.css +165 -0
- package/dist/web/goals-view.js +45 -0
- package/dist/web/home-view.js +113 -0
- package/dist/web/images.js +311 -0
- package/dist/web/index.html +485 -0
- package/dist/web/insights.js +217 -0
- package/dist/web/keyboard.js +446 -0
- package/dist/web/mdx-viewer.js +600 -0
- package/dist/web/path-picker.js +787 -0
- package/dist/web/preview-frame.html +187 -0
- package/dist/web/settings.js +582 -0
- package/dist/web/style.css +8545 -0
- package/dist/web/task-view.js +1759 -0
- package/dist/web/vendor/gsap.min.js +11 -0
- package/dist/web/workspace.css +1513 -0
- package/package.json +71 -0
- package/skills/openkan/SKILL.md +111 -0
- package/skills/openkan/agents/openai.yaml +4 -0
- package/skills/openkan/examples/simple-task.mdx +34 -0
- package/skills/openkan/examples/with-ask.mdx +32 -0
- package/skills/openkan/examples/with-choice.mdx +51 -0
- package/skills/openkan/examples/with-preview.mdx +54 -0
- package/skills/openkan/references/api.md +169 -0
- 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
|
+
}
|