@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,140 @@
1
+ // OpenKan — image storage helpers.
2
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, openSync, fsyncSync, closeSync, renameSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { nanoid } from "nanoid";
5
+ import { ensureDir } from "./io.js";
6
+ // ─── Constants ────────────────────────────────────────────────────────────────
7
+ const ALLOWED_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg"]);
8
+ const MAX_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
9
+ const CONTENT_TYPE_MAP = {
10
+ png: "image/png",
11
+ jpg: "image/jpeg",
12
+ jpeg: "image/jpeg",
13
+ gif: "image/gif",
14
+ webp: "image/webp",
15
+ svg: "image/svg+xml",
16
+ };
17
+ const STORE_FILE = "images.json";
18
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
19
+ function imagesStorePath(taskDir) {
20
+ return join(taskDir, STORE_FILE);
21
+ }
22
+ function loadStore(taskDir) {
23
+ const p = imagesStorePath(taskDir);
24
+ if (!existsSync(p))
25
+ return { images: [] };
26
+ try {
27
+ return JSON.parse(readFileSync(p, "utf-8"));
28
+ }
29
+ catch {
30
+ return { images: [] };
31
+ }
32
+ }
33
+ function saveStore(taskDir, store) {
34
+ ensureDir(taskDir);
35
+ writeFileSync(imagesStorePath(taskDir), JSON.stringify(store, null, 2), "utf-8");
36
+ }
37
+ function validateExtension(ext) {
38
+ const lower = ext.toLowerCase().replace(/^\./, "");
39
+ if (!ALLOWED_EXTENSIONS.has(lower))
40
+ return null;
41
+ return lower;
42
+ }
43
+ // ─── Public API ───────────────────────────────────────────────────────────────
44
+ /** Return the path to the images directory for a task. */
45
+ export function imagesDir(taskId, kanbanDir) {
46
+ return join(kanbanDir, "tasks", taskId, "images");
47
+ }
48
+ /** Ensure the images directory for a task exists and return its path. */
49
+ export function ensureImagesDir(taskId, kanbanDir) {
50
+ const dir = imagesDir(taskId, kanbanDir);
51
+ ensureDir(dir);
52
+ return dir;
53
+ }
54
+ /**
55
+ * Save an image buffer to a task's images directory.
56
+ * v1 accepts JSON with base64-encoded data.
57
+ *
58
+ * @returns ImageMeta for the saved file.
59
+ * @throws Error if extension is not allowed or size exceeds 10 MB.
60
+ */
61
+ export function saveImage(taskId, kanbanDir, buffer, ext, contentType, author) {
62
+ const validExt = validateExtension(ext);
63
+ if (!validExt) {
64
+ throw new Error(`Invalid file extension: ${ext}. Allowed: ${[...ALLOWED_EXTENSIONS].join(", ")}`);
65
+ }
66
+ if (buffer.length > MAX_SIZE_BYTES) {
67
+ throw new Error(`File too large: ${buffer.length} bytes. Maximum allowed: ${MAX_SIZE_BYTES} bytes.`);
68
+ }
69
+ const dir = ensureImagesDir(taskId, kanbanDir);
70
+ const name = `img-${nanoid(8)}.${validExt}`;
71
+ const filePath = join(dir, name);
72
+ // Write atomically
73
+ const tmp = `${filePath}.tmp`;
74
+ writeFileSync(tmp, buffer);
75
+ try {
76
+ const fd = openSync(tmp, "r");
77
+ fsyncSync(fd);
78
+ closeSync(fd);
79
+ }
80
+ catch (_) { /* atomic write best-effort on this filesystem */ }
81
+ renameSync(tmp, filePath);
82
+ const meta = {
83
+ name,
84
+ taskId,
85
+ size: buffer.length,
86
+ contentType: contentType ?? CONTENT_TYPE_MAP[validExt] ?? "application/octet-stream",
87
+ uploadedAt: new Date().toISOString(),
88
+ uploadedBy: author,
89
+ };
90
+ // Record in images.json
91
+ const taskDir = join(kanbanDir, "tasks", taskId);
92
+ const store = loadStore(taskDir);
93
+ store.images.push(meta);
94
+ saveStore(taskDir, store);
95
+ return meta;
96
+ }
97
+ /** List all images for a task. */
98
+ export function listImages(taskId, kanbanDir) {
99
+ const taskDir = join(kanbanDir, "tasks", taskId);
100
+ const { images } = loadStore(taskDir);
101
+ return images.filter(img => img.taskId === taskId);
102
+ }
103
+ /** Delete a named image from a task. Returns true if deleted, false if not found. */
104
+ export function deleteImage(taskId, kanbanDir, name) {
105
+ const taskDir = join(kanbanDir, "tasks", taskId);
106
+ const dir = imagesDir(taskId, kanbanDir);
107
+ const filePath = join(dir, name);
108
+ const store = loadStore(taskDir);
109
+ const before = store.images.length;
110
+ store.images = store.images.filter(img => !(img.name === name && img.taskId === taskId));
111
+ if (store.images.length === before)
112
+ return false;
113
+ // Remove file
114
+ try {
115
+ if (existsSync(filePath))
116
+ unlinkSync(filePath);
117
+ }
118
+ catch { /* ignore */ }
119
+ saveStore(taskDir, store);
120
+ return true;
121
+ }
122
+ /**
123
+ * Read an image file for serving via HTTP.
124
+ * Returns { buffer, contentType } or null if not found.
125
+ */
126
+ export function readImage(taskId, kanbanDir, name) {
127
+ const dir = imagesDir(taskId, kanbanDir);
128
+ const filePath = join(dir, name);
129
+ if (!existsSync(filePath))
130
+ return null;
131
+ const store = loadStore(join(kanbanDir, "tasks", taskId));
132
+ const meta = store.images.find(img => img.name === name && img.taskId === taskId);
133
+ const contentType = meta?.contentType ?? "application/octet-stream";
134
+ try {
135
+ return { buffer: readFileSync(filePath), contentType };
136
+ }
137
+ catch {
138
+ return null;
139
+ }
140
+ }
@@ -0,0 +1,295 @@
1
+ // OpenKan — checkbox import scanner.
2
+ // Scans .md/.mdx files for "- [ ]" checkboxes and produces ScanResult hits.
3
+ import { readdirSync, readFileSync, statSync } from "fs";
4
+ import { join, relative } from "path";
5
+ import { createHash } from "node:crypto";
6
+ import { withWrite, getBoard, taskArtifacts } from "./board.js";
7
+ import { extractMetadata } from "./tags.js";
8
+ import { writeTaskMdx, writeBoardMdx } from "./mdx.js";
9
+ import { ensureDir } from "./io.js";
10
+ import { recordEvent } from "./changelog.js";
11
+ /** Compute a short SHA-256 hex digest (first 16 chars) of file content. */
12
+ export function computeSourceHash(content) {
13
+ return createHash("sha256").update(content).digest("hex").slice(0, 16);
14
+ }
15
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
16
+ const MD_EXTENSIONS = new Set([".md", ".mdx"]);
17
+ // Dirs that are always skipped regardless of include/exclude.
18
+ const SYSTEM_DIRS = new Set([
19
+ "node_modules", ".git", ".ok", "dist", ".next",
20
+ ]);
21
+ function isSystemDir(name) {
22
+ if (name.startsWith(".") && name !== ".ok")
23
+ return true;
24
+ return SYSTEM_DIRS.has(name);
25
+ }
26
+ /** Recursively collect .md/.mdx files under root, respecting include/exclude. */
27
+ export function scanFiles(opts) {
28
+ const { root, include = [], exclude = [] } = opts;
29
+ const files = [];
30
+ let scanned = 0;
31
+ let skipped = 0;
32
+ function matches(pattern, name) {
33
+ // `**` is a multi-segment wildcard. `*` is a basename wildcard within a segment.
34
+ // We split the pattern at `**` first, then per-segment wildcards go through matchBasename.
35
+ const norm = name.replace(/^\.\//, "");
36
+ const pSegs = pattern.split("/");
37
+ const segs = norm.split("/");
38
+ return matchPath(pSegs, segs);
39
+ }
40
+ function matchPath(pSegs, segs) {
41
+ if (pSegs.length === 0)
42
+ return segs.length === 0;
43
+ if (pSegs[0] === "**") {
44
+ for (let k = 0; k <= segs.length; k++) {
45
+ if (matchPath(pSegs.slice(1), segs.slice(k)))
46
+ return true;
47
+ }
48
+ return false;
49
+ }
50
+ if (segs.length === 0)
51
+ return false;
52
+ const seg = segs[0];
53
+ if (pSegs[0].includes("*") && !pSegs[0].includes("/")) {
54
+ if (!matchBasename(pSegs[0], seg))
55
+ return false;
56
+ }
57
+ else if (pSegs[0] !== seg) {
58
+ return false;
59
+ }
60
+ return matchPath(pSegs.slice(1), segs.slice(1));
61
+ }
62
+ function matchBasename(pattern, name) {
63
+ const base = name.split("/").pop() ?? name;
64
+ // Convert glob to anchored regex: `*` → `.*`, `?` → `.`, literals escaped.
65
+ const re = new RegExp("^" + pattern.split("*").map(escapeRegex).join(".*") + "$");
66
+ return re.test(base);
67
+ }
68
+ function escapeRegex(s) {
69
+ return s.replace(/[\\^$.+?()[\]{}|]/g, "\\$&");
70
+ }
71
+ function matchesAny(patterns, relPath, baseName) {
72
+ for (const p of patterns) {
73
+ if (matches(p, relPath) || matches(p, baseName))
74
+ return true;
75
+ }
76
+ return false;
77
+ }
78
+ function walk(dir) {
79
+ let entries;
80
+ try {
81
+ entries = readdirSync(dir);
82
+ }
83
+ catch {
84
+ return;
85
+ }
86
+ for (const entry of entries) {
87
+ const full = join(dir, entry);
88
+ let stat;
89
+ try {
90
+ stat = statSync(full);
91
+ }
92
+ catch {
93
+ continue;
94
+ }
95
+ if (stat.isDirectory()) {
96
+ if (isSystemDir(entry)) {
97
+ skipped++;
98
+ continue;
99
+ }
100
+ walk(full);
101
+ }
102
+ else if (stat.isFile()) {
103
+ const dot = entry.lastIndexOf(".");
104
+ const ext = dot >= 0 ? entry.slice(dot) : "";
105
+ if (!MD_EXTENSIONS.has(ext))
106
+ continue;
107
+ // include/exclude: default to docs/** + *.md + *.mdx
108
+ const defaultInclude = ["docs/**", "*.md", "*.mdx"];
109
+ const effectiveInclude = include.length ? include : defaultInclude;
110
+ const rel = relative(root, full).replace(/\\/g, "/");
111
+ if (effectiveInclude.length && !matchesAny(effectiveInclude, rel, entry)) {
112
+ skipped++;
113
+ continue;
114
+ }
115
+ if (exclude.length && matchesAny(exclude, rel, entry)) {
116
+ skipped++;
117
+ continue;
118
+ }
119
+ files.push(full);
120
+ scanned++;
121
+ }
122
+ }
123
+ }
124
+ walk(root);
125
+ return { files, scanned, skipped };
126
+ }
127
+ // ─── Checkbox parser ─────────────────────────────────────────────────────────
128
+ /**
129
+ * Extract all checkbox lines from file content.
130
+ * - Ignores checkboxes inside fenced code blocks (backtick OR tilde fences,
131
+ * CommonMark style — closing fence must match opener kind).
132
+ * - Matches indented checkboxes (leading whitespace preserved in raw).
133
+ * - `- [x]` (done) → hit.done = true (caller filters as needed).
134
+ */
135
+ export function parseCheckboxes(content, repoRelPath) {
136
+ const hits = [];
137
+ const lines = content.split("\n");
138
+ // Track which delimiter opened the fence (` for backtick, ~ for tilde),
139
+ // null when not inside a fence. CommonMark: closing fence must use a
140
+ // delimiter that matches the opening one (or any ≥3-length sequence of
141
+ // the same char, but for our purposes same + ≥3 is enough).
142
+ let fenceChar = null;
143
+ for (let i = 0; i < lines.length; i++) {
144
+ const raw = lines[i];
145
+ const trimmed = raw.trimStart();
146
+ // Detect a fence line: at least three backticks or three tildes, followed by optional info string.
147
+ const fenceMatch = trimmed.match(/^(`{3,}|~{3,})(.*)$/);
148
+ if (fenceMatch) {
149
+ const opener = fenceMatch[1][0];
150
+ if (fenceChar === null) {
151
+ fenceChar = opener === "`" ? "`" : "~";
152
+ continue;
153
+ }
154
+ // Closing fence must match opener kind.
155
+ if (fenceChar === opener) {
156
+ fenceChar = null;
157
+ continue;
158
+ }
159
+ // Wrong-kind delimiter inside a fence is just content; skip in either case.
160
+ if (fenceChar !== null)
161
+ continue;
162
+ }
163
+ if (fenceChar !== null)
164
+ continue;
165
+ // - [ ] or - [x] — allow leading whitespace
166
+ const m = trimmed.match(/^-\s*\[([ x])\]\s*(.*)$/);
167
+ if (!m)
168
+ continue;
169
+ const done = m[1] === "x";
170
+ const text = m[2].trim();
171
+ hits.push({
172
+ path: repoRelPath,
173
+ line: i + 1, // 1-indexed
174
+ raw: text,
175
+ done,
176
+ });
177
+ }
178
+ return hits;
179
+ }
180
+ // ─── Stable ID ───────────────────────────────────────────────────────────────
181
+ /** Format a stable, deterministic import ID from a checkbox hit. */
182
+ export function stableImportId(hit) {
183
+ const slug = slugFromRaw(hit.raw);
184
+ const input = `${hit.path}:${hit.line}:${slug}`;
185
+ const short = createHash("sha256").update(input).digest("hex").slice(0, 12);
186
+ return `imp-${short}`;
187
+ }
188
+ /** Derive a URL-safe slug from checkbox raw text. */
189
+ export function slugFromRaw(raw) {
190
+ return raw
191
+ .toLowerCase()
192
+ .replace(/[^a-z0-9]+/g, "-")
193
+ .replace(/^-+|-+$/g, "")
194
+ .slice(0, 32) || "untitled";
195
+ }
196
+ /**
197
+ * Core import engine: scans for unchecked checkboxes, creates one Backlog task
198
+ * per hit, and writes MDX artifacts. Uses withWrite so it's safe under
199
+ * concurrent writers.
200
+ */
201
+ export async function runImport(ctx, opts = {}) {
202
+ const { include = ["docs/**", "*.md", "*.mdx"], exclude = [] } = opts;
203
+ const kanbanDir = ctx.directory;
204
+ // Compose scanFiles + parseCheckboxes to get all checkbox hits
205
+ const { files } = await scanFiles({ root: kanbanDir, include, exclude });
206
+ const allHits = [];
207
+ for (const file of files) {
208
+ const relPath = relative(kanbanDir, file).replace(/\\/g, "/");
209
+ const content = readFileSync(file, "utf-8");
210
+ const hits = parseCheckboxes(content, relPath);
211
+ allHits.push(...hits);
212
+ }
213
+ const uncheckedHits = allHits.filter((h) => !h.done);
214
+ if (uncheckedHits.length === 0) {
215
+ return { imported: [] };
216
+ }
217
+ const now = new Date().toISOString();
218
+ const createdIds = [];
219
+ await withWrite(async (board) => {
220
+ const colTasks = board.tasks.filter((t) => t.column === "backlog");
221
+ let order = colTasks.length;
222
+ for (const hit of uncheckedHits) {
223
+ const id = stableImportId(hit);
224
+ const title = hit.raw || "Untitled import";
225
+ const description = "";
226
+ const derived = extractMetadata({ title, description });
227
+ // Read full file content for sourceHash
228
+ let sourceHash = "";
229
+ try {
230
+ const fileContent = readFileSync(join(kanbanDir, hit.path), "utf-8");
231
+ sourceHash = computeSourceHash(fileContent);
232
+ }
233
+ catch {
234
+ // non-fatal: file may have been deleted between scan and now
235
+ }
236
+ const arts = taskArtifacts(id);
237
+ const task = {
238
+ id,
239
+ title,
240
+ description,
241
+ column: "backlog",
242
+ order: order++,
243
+ sessionId: null,
244
+ agent: "",
245
+ model: null,
246
+ status: "idle",
247
+ state: "idle",
248
+ lastError: null,
249
+ createdAt: now,
250
+ updatedAt: now,
251
+ artifact: arts.mdxPath,
252
+ sessionArtifact: null,
253
+ pendingInputs: [],
254
+ artifacts: arts,
255
+ tags: derived.tags,
256
+ category: derived.category,
257
+ priority: derived.priority,
258
+ effort: derived.effort,
259
+ archived: false,
260
+ assignees: ["user"],
261
+ images: [],
262
+ parentId: null,
263
+ subtaskIds: [],
264
+ source: { path: hit.path, line: hit.line, slug: slugFromRaw(hit.raw) },
265
+ sourceHash,
266
+ };
267
+ board.tasks.push(task);
268
+ createdIds.push(id);
269
+ }
270
+ });
271
+ // Write MDX artifacts for each created task
272
+ const board = await getBoard();
273
+ for (const id of createdIds) {
274
+ const task = board.tasks.find((t) => t.id === id);
275
+ if (!task)
276
+ continue;
277
+ const taskDir = join(kanbanDir, "tasks", id);
278
+ ensureDir(taskDir);
279
+ await writeTaskMdx(task, kanbanDir, board);
280
+ }
281
+ // Write board MDX and record events
282
+ await writeBoardMdx(board, kanbanDir);
283
+ for (const id of createdIds) {
284
+ const task = board.tasks.find((t) => t.id === id);
285
+ if (task) {
286
+ recordEvent(kanbanDir, "task.created", {
287
+ taskId: id,
288
+ author: "user",
289
+ summary: `imported '${task.title}'`,
290
+ payload: { column: task.column },
291
+ });
292
+ }
293
+ }
294
+ return { imported: createdIds };
295
+ }
@@ -0,0 +1,94 @@
1
+ // OpenKan — input request CRUD (ask/choice/input/confirm).
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { ensureDir } from "./io.js";
6
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
7
+ const STORE_FILE = "inputs.json";
8
+ function storePath(taskDir) {
9
+ return join(taskDir, STORE_FILE);
10
+ }
11
+ function loadStore(taskDir) {
12
+ const p = storePath(taskDir);
13
+ if (!existsSync(p))
14
+ return { inputs: [] };
15
+ try {
16
+ return JSON.parse(readFileSync(p, "utf-8"));
17
+ }
18
+ catch {
19
+ return { inputs: [] };
20
+ }
21
+ }
22
+ function saveStore(taskDir, store) {
23
+ ensureDir(taskDir);
24
+ writeFileSync(storePath(taskDir), JSON.stringify(store, null, 2), "utf-8");
25
+ }
26
+ function makeId(taskId, question, blockId) {
27
+ const input = `${taskId}${question}${blockId ?? ""}${Date.now()}`;
28
+ const sha = createHash("sha1").update(input).digest("hex").slice(0, 12);
29
+ return `inp-${sha}`;
30
+ }
31
+ // ─── Public API ───────────────────────────────────────────────────────────────
32
+ /** List all inputs for a task, newest first. */
33
+ export function listInputs(taskId, dir) {
34
+ const taskDir = join(dir, taskId);
35
+ const { inputs } = loadStore(taskDir);
36
+ return inputs.filter((i) => i.taskId === taskId).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
37
+ }
38
+ /** Return the most recent pending input for a task, or null. */
39
+ export function getPendingInput(taskId, dir) {
40
+ const taskDir = join(dir, taskId);
41
+ const { inputs } = loadStore(taskDir);
42
+ const pending = inputs
43
+ .filter((i) => i.taskId === taskId && i.status === "pending")
44
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); // newest first
45
+ return pending[0] ?? null;
46
+ }
47
+ /** Create and persist a new input. Returns the new Input. */
48
+ export function addInput(taskId, dir, data) {
49
+ const taskDir = join(dir, taskId);
50
+ ensureDir(taskDir);
51
+ const store = loadStore(taskDir);
52
+ const input = {
53
+ ...data,
54
+ id: makeId(taskId, data.question, data.blockId),
55
+ taskId,
56
+ status: "pending",
57
+ createdAt: new Date().toISOString(),
58
+ respondedAt: null,
59
+ };
60
+ store.inputs.push(input);
61
+ saveStore(taskDir, store);
62
+ return input;
63
+ }
64
+ /**
65
+ * Record a response to an input.
66
+ * `response` — free-text value (for ask/input/confirm).
67
+ * `optionId` — selected option id (for choice).
68
+ */
69
+ export function respondInput(taskId, dir, inputId, response) {
70
+ const taskDir = join(dir, taskId);
71
+ const store = loadStore(taskDir);
72
+ const idx = store.inputs.findIndex((i) => i.id === inputId && i.taskId === taskId);
73
+ if (idx === -1)
74
+ throw new Error(`Input ${inputId} not found for task ${taskId}`);
75
+ const input = store.inputs[idx];
76
+ input.status = "responded";
77
+ input.response = response.value ?? null;
78
+ input.responseOptionId = response.optionId ?? null;
79
+ input.respondedAt = new Date().toISOString();
80
+ store.inputs[idx] = input;
81
+ saveStore(taskDir, store);
82
+ return input;
83
+ }
84
+ /** Cancel a pending input (mark it cancelled). */
85
+ export function cancelInput(taskId, dir, inputId) {
86
+ const taskDir = join(dir, taskId);
87
+ const store = loadStore(taskDir);
88
+ const idx = store.inputs.findIndex((i) => i.id === inputId && i.taskId === taskId);
89
+ if (idx === -1)
90
+ throw new Error(`Input ${inputId} not found for task ${taskId}`);
91
+ store.inputs[idx].status = "cancelled";
92
+ saveStore(taskDir, store);
93
+ return store.inputs[idx];
94
+ }
@@ -0,0 +1,140 @@
1
+ // OpenKan — Insights aggregator. Reads `.ok/changelog.jsonl` and
2
+ // produces per-day, per-column move counts for the Insights tab.
3
+ //
4
+ // `task.moved` payload quirk (see kanban/server.ts:641-646): the existing
5
+ // emit writes `payload.from = patch.column`, but at that point `patch.column`
6
+ // is the destination, not the source. The aggregator parses the destination
7
+ // from the summary string ("moved 'X' to <col>") and infers the source
8
+ // from the most recent prior `task.moved` for the same taskId.
9
+ import { readEvents } from "./changelog.js";
10
+ export const COLUMNS = [
11
+ "backlog",
12
+ "todo",
13
+ "doing",
14
+ "review",
15
+ "done",
16
+ ];
17
+ const MOVE_DEST_RE = /moved '.*' to (\w+)/;
18
+ function zeroBuckets(days, generatedAt) {
19
+ return {
20
+ days: [],
21
+ backlog: new Array(days).fill(0),
22
+ todo: new Array(days).fill(0),
23
+ doing: new Array(days).fill(0),
24
+ review: new Array(days).fill(0),
25
+ done: new Array(days).fill(0),
26
+ windowDays: days,
27
+ generatedAt,
28
+ };
29
+ }
30
+ /**
31
+ * Build the list of `days` local YYYY-MM-DD strings ending today (inclusive).
32
+ * Oldest first.
33
+ */
34
+ function buildDayWindow(days, endLocalDate) {
35
+ const out = [];
36
+ // Parse the local YYYY-MM-DD into a Date at noon local time to avoid DST edges.
37
+ const parts = endLocalDate.split("-").map(Number);
38
+ const y = parts[0];
39
+ const m = parts[1];
40
+ const d = parts[2];
41
+ const end = new Date(y, m - 1, d, 12, 0, 0, 0);
42
+ for (let i = days - 1; i >= 0; i--) {
43
+ const t = new Date(end);
44
+ t.setDate(end.getDate() - i);
45
+ const yyyy = t.getFullYear();
46
+ const mm = String(t.getMonth() + 1).padStart(2, "0");
47
+ const dd = String(t.getDate()).padStart(2, "0");
48
+ out.push(`${yyyy}-${mm}-${dd}`);
49
+ }
50
+ return out;
51
+ }
52
+ /** Convert ISO timestamp to local YYYY-MM-DD. Mirrors changelog.ts convention. */
53
+ function toLocalDay(iso) {
54
+ return new Date(iso).toLocaleDateString("en-CA");
55
+ }
56
+ function indexFor(buckets, localDay) {
57
+ return buckets.days.indexOf(localDay);
58
+ }
59
+ /** Typed dispatcher: pick the right column-array and add `delta`. */
60
+ function addToColumn(b, col, idx, delta) {
61
+ if (idx < 0)
62
+ return;
63
+ switch (col) {
64
+ case "backlog":
65
+ b.backlog[idx] += delta;
66
+ return;
67
+ case "todo":
68
+ b.todo[idx] += delta;
69
+ return;
70
+ case "doing":
71
+ b.doing[idx] += delta;
72
+ return;
73
+ case "review":
74
+ b.review[idx] += delta;
75
+ return;
76
+ case "done":
77
+ b.done[idx] += delta;
78
+ return;
79
+ }
80
+ }
81
+ function isColumn(s) {
82
+ return !!s && COLUMNS.includes(s);
83
+ }
84
+ /**
85
+ * Compute per-day, per-column move-into counts for the last `days` days,
86
+ * ending today. Reads the changelog via `readEvents`; bad JSONL lines are
87
+ * skipped (parseLine warns and returns null) so one corrupt line does
88
+ * not abort the computation.
89
+ *
90
+ * Returns zero-filled arrays when the changelog is missing or empty.
91
+ */
92
+ export function computeVelocity(okDir, days = 30) {
93
+ const window = Math.max(1, Math.floor(days));
94
+ const generatedAt = new Date().toISOString();
95
+ const todayLocal = new Date().toLocaleDateString("en-CA");
96
+ const buckets = zeroBuckets(window, generatedAt);
97
+ buckets.days = buildDayWindow(window, todayLocal);
98
+ // Pull a generous slice of events. readEvents filters in-memory and
99
+ // returns newest first; we filter by kind to keep the working set
100
+ // small and trust parseLine to drop bad lines.
101
+ const { events } = readEvents(okDir, {
102
+ kind: ["task.created", "task.moved"],
103
+ limit: 5000,
104
+ });
105
+ if (events.length === 0)
106
+ return buckets;
107
+ // Track the most recent destination per task (across ALL events, not
108
+ // just the window — we need the state before the first in-window move
109
+ // to infer the source of that first move). We rebuild this from oldest
110
+ // to newest so "most recent prior" is the previous move in the log.
111
+ const lastDest = new Map();
112
+ // Sort oldest-first for the bucketing pass.
113
+ const ordered = [...events].sort((a, b) => a.ts.localeCompare(b.ts));
114
+ for (const ev of ordered) {
115
+ if (ev.kind === "task.created") {
116
+ const col = ev.payload.column;
117
+ if (!isColumn(col))
118
+ continue;
119
+ const dayIdx = indexFor(buckets, toLocalDay(ev.ts));
120
+ addToColumn(buckets, col, dayIdx, 1);
121
+ // First move's source is treated as null (task created into col).
122
+ lastDest.set(ev.taskId ?? "_anon", col);
123
+ }
124
+ else if (ev.kind === "task.moved") {
125
+ const match = MOVE_DEST_RE.exec(ev.summary ?? "");
126
+ const dest = match?.[1];
127
+ if (!dest || !isColumn(dest))
128
+ continue;
129
+ const dayIdx = indexFor(buckets, toLocalDay(ev.ts));
130
+ addToColumn(buckets, dest, dayIdx, 1);
131
+ // Decrement the prior column (move-out) on the same day.
132
+ const prior = lastDest.get(ev.taskId ?? "_anon");
133
+ if (prior && prior !== dest) {
134
+ addToColumn(buckets, prior, dayIdx, -1);
135
+ }
136
+ lastDest.set(ev.taskId ?? "_anon", dest);
137
+ }
138
+ }
139
+ return buckets;
140
+ }