@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,75 @@
1
+ // OpenKan — low-level filesystem I/O helpers.
2
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { join } from "node:path";
4
+ /**
5
+ * Write `data` to `path` atomically:
6
+ * - write to `<path>.tmp`
7
+ * - fsync the temp file
8
+ * - rename to `path`
9
+ * This matches the pattern used in kanban/board.ts:persist.
10
+ */
11
+ export function writeFileAtomic(path, data) {
12
+ const tmp = `${path}.tmp`;
13
+ writeFileSync(tmp, data, "utf-8");
14
+ try {
15
+ const fd = openSync(tmp, "r");
16
+ fsyncSync(fd);
17
+ closeSync(fd);
18
+ }
19
+ catch (_) { }
20
+ renameSync(tmp, path);
21
+ }
22
+ /** Recursively ensure `dir` exists. Analogous to `mkdir -p`. */
23
+ export function ensureDir(dir) {
24
+ if (!existsSync(dir))
25
+ mkdirSync(dir, { recursive: true });
26
+ }
27
+ /**
28
+ * Remove any `.tmp` files under `dir` older than `maxAgeMs` (default: 1 hour).
29
+ * Used by initBoard to clean up stale atomic-write temporaries.
30
+ */
31
+ export function cleanupStaleTmp(dir, maxAgeMs = 60 * 60 * 1000) {
32
+ if (!existsSync(dir))
33
+ return;
34
+ let entries;
35
+ try {
36
+ entries = readdirSync(dir);
37
+ }
38
+ catch {
39
+ return;
40
+ }
41
+ const now = Date.now();
42
+ for (const entry of entries) {
43
+ if (!entry.endsWith(".tmp"))
44
+ continue;
45
+ const full = join(dir, entry);
46
+ let st;
47
+ try {
48
+ st = statSync(full);
49
+ }
50
+ catch {
51
+ continue;
52
+ }
53
+ if (!st.isFile())
54
+ continue;
55
+ if (now - st.mtimeMs > maxAgeMs) {
56
+ try {
57
+ unlinkSync(full);
58
+ }
59
+ catch {
60
+ // ignore
61
+ }
62
+ }
63
+ }
64
+ }
65
+ /** Recursively remove a directory (or file), silently ignoring errors. */
66
+ export function removeDir(dir) {
67
+ if (!existsSync(dir))
68
+ return;
69
+ try {
70
+ rmSync(dir, { force: true, recursive: true });
71
+ }
72
+ catch {
73
+ // ignore
74
+ }
75
+ }
@@ -0,0 +1,348 @@
1
+ // OpenKan — server-side MDX → HTML renderer with block markers and custom components.
2
+ import { createHash } from "node:crypto";
3
+ import sanitizeHtml from "sanitize-html";
4
+ // ─── Block ID ─────────────────────────────────────────────────────────────────
5
+ /**
6
+ * Produce a stable block ID from block text and its position among top-level siblings.
7
+ * Same content at the same sibling position → same ID even if lines are added above.
8
+ */
9
+ export function blockIdFor(blockText, siblingIndex) {
10
+ const normalised = normalizeLine(blockText).slice(0, 80);
11
+ const input = `${siblingIndex}|${normalised}`;
12
+ const sha = createHash("sha1").update(input).digest("hex").slice(0, 12);
13
+ return `blk-${sha}`;
14
+ }
15
+ // ─── Normalization helpers ────────────────────────────────────────────────────
16
+ function normalizeLine(text) {
17
+ return text.replace(/\s+/g, " ").trim();
18
+ }
19
+ function trimLines(text) {
20
+ return text.replace(/^[ \t]+|[ \t]+$/gm, "");
21
+ }
22
+ const CUSTOM_COMPONENTS = ["Ask", "Choice", "Input", "Confirm", "Preview"];
23
+ function isCustomComponent(name) {
24
+ return CUSTOM_COMPONENTS.includes(name);
25
+ }
26
+ function detectBlockType(firstLine) {
27
+ const t = firstLine.trim();
28
+ if (/^#{1,6}\s/.test(t))
29
+ return "heading";
30
+ if (t === "---")
31
+ return "other"; // frontmatter separator — treated as other
32
+ if (/^(```|~~~)/.test(t))
33
+ return "code";
34
+ if (/^>\s/.test(t))
35
+ return "quote";
36
+ if (/^\|/.test(t))
37
+ return "table";
38
+ if (/^[-*+]\s/.test(t))
39
+ return "list";
40
+ if (/^<[A-Z][a-zA-Z]*(\s|>|\/>)/.test(t))
41
+ return "component";
42
+ return "paragraph";
43
+ }
44
+ function isBlankLine(line) {
45
+ return /^ *$/.test(line);
46
+ }
47
+ /**
48
+ * Parse source MDX into top-level blocks.
49
+ * Each block has: type, lines (raw), startLine (1-indexed).
50
+ */
51
+ function parseBlocks(source) {
52
+ const lines = source.split("\n");
53
+ const blocks = [];
54
+ let i = 0;
55
+ let siblingIndex = 0;
56
+ while (i < lines.length) {
57
+ // Skip blank lines before a block
58
+ while (i < lines.length && isBlankLine(lines[i]))
59
+ i++;
60
+ if (i >= lines.length)
61
+ break;
62
+ const startLine = i + 1; // 1-indexed
63
+ const firstLine = lines[i];
64
+ // Check for code fence open/close
65
+ if (/^(```|~~~)/.test(firstLine.trim())) {
66
+ const fence = firstLine.trim().match(/^(```|~~~)/)[1];
67
+ const fenceRE = new RegExp(`^${fence === "```" ? "\\`\\`\\`" : "~~~"}\s*$`);
68
+ const blockLines = [firstLine];
69
+ i++;
70
+ while (i < lines.length && !fenceRE.test(lines[i].trim())) {
71
+ blockLines.push(lines[i]);
72
+ i++;
73
+ }
74
+ if (i < lines.length)
75
+ blockLines.push(lines[i]); // closing fence
76
+ i++;
77
+ blocks.push({ type: "code", lines: blockLines, startLine });
78
+ siblingIndex++;
79
+ continue;
80
+ }
81
+ // Detect component on a single line: <Name ... />
82
+ if (/^<[A-Z][a-zA-Z]*\s/.test(firstLine.trim()) || /^<[A-Z][a-zA-Z]*\/>$/.test(firstLine.trim())) {
83
+ const singleLine = firstLine.trim();
84
+ // Check if it's self-closing or has a closing tag on same line
85
+ const selfClose = /^\s*<([A-Z][a-zA-Z]*)[^>]*\/>\s*$/.test(singleLine);
86
+ const sameLineClose = /<\/[A-Z][a-zA-Z]*>\s*$/.test(singleLine);
87
+ if (selfClose || sameLineClose) {
88
+ blocks.push({ type: "component", lines: [firstLine], startLine });
89
+ i++;
90
+ siblingIndex++;
91
+ continue;
92
+ }
93
+ // Multi-line component: find closing tag
94
+ const openMatch = singleLine.match(/^<([A-Z][a-zA-Z]*)/);
95
+ if (openMatch) {
96
+ const tagName = openMatch[1];
97
+ const closeRE = new RegExp(`</${tagName}\\s*>\\s*$`);
98
+ const blockLines = [firstLine];
99
+ i++;
100
+ while (i < lines.length && !closeRE.test(lines[i].trim())) {
101
+ blockLines.push(lines[i]);
102
+ i++;
103
+ }
104
+ if (i < lines.length)
105
+ blockLines.push(lines[i]);
106
+ i++;
107
+ blocks.push({ type: "component", lines: blockLines, startLine });
108
+ siblingIndex++;
109
+ continue;
110
+ }
111
+ }
112
+ // For other types, accumulate lines that belong to the block
113
+ const bType = detectBlockType(firstLine);
114
+ const blockLines = [firstLine];
115
+ i++;
116
+ if (bType === "heading" || bType === "code" || bType === "component") {
117
+ // Already handled above; heading shouldn't reach here
118
+ }
119
+ else {
120
+ // Accumulate continuation lines for paragraph/list/quote/table
121
+ while (i < lines.length) {
122
+ const peek = lines[i];
123
+ const peekTrimmed = peek.trim();
124
+ if (isBlankLine(peek)) {
125
+ // Blank line ends paragraph/quote/table
126
+ if (bType === "paragraph" || bType === "quote" || bType === "table")
127
+ break;
128
+ i++;
129
+ continue;
130
+ }
131
+ if (/^#{1,6}\s/.test(peekTrimmed))
132
+ break; // next heading
133
+ if (/^(```|~~~)/.test(peekTrimmed))
134
+ break; // next fence
135
+ if (/^<\/?[A-Z]/.test(peekTrimmed))
136
+ break; // next component tag
137
+ if (bType === "list" && !/^[-*+]\s/.test(peekTrimmed) && !/^\d+\.\s/.test(peekTrimmed)) {
138
+ // Indented continuation is ok; non-indented non-list ends the block
139
+ if (!/^\s/.test(peek) && !/^\d+\.\s/.test(peekTrimmed))
140
+ break;
141
+ }
142
+ blockLines.push(peek);
143
+ i++;
144
+ }
145
+ }
146
+ blocks.push({ type: bType, lines: blockLines, startLine });
147
+ siblingIndex++;
148
+ }
149
+ return blocks;
150
+ }
151
+ // ─── Block-to-HTML ─────────────────────────────────────────────────────────────
152
+ function escapeHtml(text) {
153
+ return text
154
+ .replace(/&/g, "&amp;")
155
+ .replace(/</g, "&lt;")
156
+ .replace(/>/g, "&gt;")
157
+ .replace(/"/g, "&quot;");
158
+ }
159
+ function normalizeWhitespace(text) {
160
+ return text.replace(/\s+/g, " ").trim();
161
+ }
162
+ function blockPreview(blockLines) {
163
+ return normalizeWhitespace(trimLines(blockLines.join(" "))).slice(0, 80);
164
+ }
165
+ // Extract attributes from a self-closing or block component tag
166
+ function extractAttrs(line) {
167
+ const attrs = {};
168
+ // Match key="value" or key='value' patterns
169
+ const re = /([a-zA-Z_][a-zA-Z0-9_]*)=["']([^"']*)["']/g;
170
+ let m;
171
+ while ((m = re.exec(line)) !== null) {
172
+ attrs[m[1]] = m[2];
173
+ }
174
+ return attrs;
175
+ }
176
+ function renderCustomComponent(tagName, blockLines, blockId, line, opts) {
177
+ // For Preview, we emit a placeholder that the frontend replaces with an iframe
178
+ if (tagName === "Preview") {
179
+ // Get all content between <Preview ... /> or <Preview ...>...</Preview>
180
+ const full = blockLines.join("\n");
181
+ const tsxMatch = full.match(/tsx=["']([^"']*)["']/) || full.match(/tsx=["']([^"']*)["']/);
182
+ const propsMatch = full.match(/props=["']([^"']*)["']/) || full.match(/props=["']([^"']*)["']/);
183
+ const tsx = tsxMatch ? tsxMatch[1] : "";
184
+ const props = propsMatch ? propsMatch[1] : "{}";
185
+ const escapedTsx = tsx.replace(/<\/script>/gi, "<\\/script>").replace(/<\/iframe>/gi, "<\\/iframe>");
186
+ const escapedProps = props.replace(/"/g, "&quot;");
187
+ return `<section data-mdx-component="Preview" data-mdx-tsx="${escapedTsx}" data-mdx-props="${escapedProps}" data-block-id="${blockId}" data-line="${line}"></section>`;
188
+ }
189
+ // For Ask/Choice/Input/Confirm — render as interactive widgets
190
+ const full = blockLines.join("\n");
191
+ const questionMatch = full.match(/question=["']([^"']*)["']/) || /<Ask>([^<]*)<\/Ask>/s.test(full) ? full.match(/<Ask>([^<]*)<\/Ask>/s)?.[1] : null;
192
+ const question = questionMatch ? questionMatch[1] : "";
193
+ const placeholder = extractAttrs(full).placeholder ?? "";
194
+ const opts_match = full.match(/options=\{(\[[^\]]*\])/);
195
+ const optionsJson = opts_match ? opts_match[1] : "[]";
196
+ return `<section data-mdx-component="${tagName}" data-block-id="${blockId}" data-line="${line}" data-question="${escapeHtml(question)}" data-placeholder="${escapeHtml(placeholder)}" data-options="${escapeHtml(optionsJson)}"></section>`;
197
+ }
198
+ function blockToHtml(block, siblingIndex, opts) {
199
+ const blockId = blockIdFor(block.lines.join("\n"), siblingIndex);
200
+ const preview = blockPreview(block.lines);
201
+ const rawLines = block.lines.join("\n");
202
+ if (block.type === "component") {
203
+ const firstLine = block.lines[0].trim();
204
+ const tagMatch = firstLine.match(/^<([A-Z][a-zA-Z]*)/);
205
+ if (tagMatch) {
206
+ const tagName = tagMatch[1];
207
+ if (isCustomComponent(tagName)) {
208
+ const inner = renderCustomComponent(tagName, block.lines, blockId, block.startLine, opts);
209
+ return `<section class="mdx-block" data-block-id="${blockId}" data-line="${block.startLine}" data-block-type="${block.type}">${inner}</section>`;
210
+ }
211
+ }
212
+ // Unknown component — pass through safely
213
+ const inner = escapeHtml(rawLines);
214
+ return `<section class="mdx-block" data-block-id="${blockId}" data-line="${block.startLine}" data-block-type="${block.type}">${inner}</section>`;
215
+ }
216
+ // Build inner HTML from the block content
217
+ let inner = "";
218
+ const trimmed = trimLines(rawLines);
219
+ switch (block.type) {
220
+ case "heading": {
221
+ const m = trimmed.match(/^(#{1,6})\s(.*)/);
222
+ const level = m ? m[1].length : 2;
223
+ inner = `<h${level}>${escapeHtml(m ? m[2] : trimmed.replace(/^#+\s/, ""))}</h${level}>`;
224
+ break;
225
+ }
226
+ case "code": {
227
+ const codeLines = block.lines.slice(1, -1).join("\n");
228
+ const langMatch = block.lines[0].trim().match(/^```(\w*)/);
229
+ const lang = langMatch && langMatch[1] ? langMatch[1] : "";
230
+ inner = `<pre><code class="language-${lang}">${escapeHtml(codeLines)}</code></pre>`;
231
+ break;
232
+ }
233
+ case "list": {
234
+ const items = block.lines.map((l) => {
235
+ const text = l.replace(/^\s*[-*+]\s/, "").replace(/^\s*\d+\.\s/, "");
236
+ return `<li>${escapeHtml(text.trim())}</li>`;
237
+ }).join("");
238
+ inner = trimmed.startsWith("1.") ? `<ol>${items}</ol>` : `<ul>${items}</ul>`;
239
+ break;
240
+ }
241
+ case "quote": {
242
+ const text = block.lines.map((l) => l.replace(/^>\s?/, "")).join(" ");
243
+ inner = `<blockquote>${escapeHtml(text.trim())}</blockquote>`;
244
+ break;
245
+ }
246
+ case "table": {
247
+ // Simple pipe table: first row = header, second row = separator, rest = body
248
+ const rows = block.lines.map((l) => l.trim()).filter((l) => l.startsWith("|"));
249
+ if (rows.length < 2) {
250
+ inner = `<p>${escapeHtml(trimmed)}</p>`;
251
+ }
252
+ else {
253
+ const cells = rows[0].split("|").map((c) => c.trim()).filter((c) => c !== "");
254
+ const header = `<thead><tr>${cells.map((c) => `<th>${escapeHtml(c)}</th>`).join("")}</tr></thead>`;
255
+ const bodyRows = rows.slice(2).map((row) => {
256
+ const cells = row.split("|").map((c) => c.trim()).filter((c) => c !== "");
257
+ return `<tr>${cells.map((c) => `<td>${escapeHtml(c)}</td>`).join("")}</tr>`;
258
+ }).join("");
259
+ inner = `<table>${header}<tbody>${bodyRows}</tbody></table>`;
260
+ }
261
+ break;
262
+ }
263
+ case "paragraph":
264
+ default: {
265
+ inner = `<p>${escapeHtml(normalizeWhitespace(trimmed))}</p>`;
266
+ break;
267
+ }
268
+ }
269
+ return `<section class="mdx-block" data-block-id="${blockId}" data-line="${block.startLine}" data-block-type="${block.type}">${inner}</section>`;
270
+ }
271
+ // ─── Frontmatter stripping ─────────────────────────────────────────────────────
272
+ /**
273
+ * Strip YAML frontmatter from an MDX string.
274
+ * Handles the common ---...--- delimiter format used in task MDX files.
275
+ * Returns the body text (without frontmatter) trimmed of leading whitespace.
276
+ */
277
+ export function stripMdxFrontmatter(mdx) {
278
+ if (!mdx || typeof mdx !== "string")
279
+ return mdx ?? "";
280
+ // Frontmatter: starts with `---\n` or `---\r\n`, ends with `\n---\n` or `\r?\n---\r?\n`
281
+ const m = mdx.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
282
+ if (!m)
283
+ return mdx;
284
+ return mdx.slice(m[0].length).trimStart();
285
+ }
286
+ // ─── Main render function ──────────────────────────────────────────────────────
287
+ const SANITIZE_ALLOWED_TAGS = new Set([
288
+ "h1", "h2", "h3", "h4", "h5", "h6", "p", "ul", "ol", "li",
289
+ "code", "pre", "blockquote", "a", "strong", "em", "hr", "br",
290
+ "table", "thead", "tbody", "tr", "th", "td",
291
+ "section", "iframe", "button", "input", "select", "option", "label",
292
+ ]);
293
+ const SANITIZE_ALLOWED_ATTRS = {
294
+ a: ["href", "title"],
295
+ code: ["class"],
296
+ td: ["align"],
297
+ th: ["align"],
298
+ img: ["src", "alt", "width", "height"],
299
+ section: ["class", "data-block-id", "data-line", "data-block-type", "data-mdx-component", "data-mdx-tsx", "data-mdx-props", "data-question", "data-placeholder", "data-options"],
300
+ iframe: ["sandbox", "referrerpolicy", "srcdoc", "title"],
301
+ button: ["type", "onclick", "class"],
302
+ input: ["type", "placeholder", "value", "oninput", "class"],
303
+ select: ["class"],
304
+ option: ["value", "selected"],
305
+ label: ["class", "for"],
306
+ };
307
+ const SANITIZE_ALLOWED_SCHEMES = ["http:", "https:", "mailto:", "data:image/"];
308
+ function urlFilter(url) {
309
+ try {
310
+ const u = new URL(url);
311
+ if (SANITIZE_ALLOWED_SCHEMES.some((s) => u.protocol === s))
312
+ return true;
313
+ if (u.protocol === "javascript:")
314
+ return false;
315
+ return false;
316
+ }
317
+ catch {
318
+ return !url.startsWith("javascript:") && !url.startsWith("data:");
319
+ }
320
+ }
321
+ export async function renderMdx(source, opts) {
322
+ const blocks = parseBlocks(stripMdxFrontmatter(source));
323
+ const warnings = [];
324
+ const htmlParts = [];
325
+ const blockResults = [];
326
+ blocks.forEach((b, i) => {
327
+ const blockId = blockIdFor(b.lines.join("\n"), i);
328
+ blockResults.push({
329
+ id: blockId,
330
+ line: b.startLine,
331
+ preview: blockPreview(b.lines),
332
+ type: b.type,
333
+ });
334
+ htmlParts.push(blockToHtml(b, i, opts));
335
+ });
336
+ let html = htmlParts.join("\n");
337
+ // Sanitize final HTML
338
+ const clean = sanitizeHtml(html, {
339
+ allowedTags: Array.from(SANITIZE_ALLOWED_TAGS),
340
+ allowedAttributes: SANITIZE_ALLOWED_ATTRS,
341
+ allowedSchemes: SANITIZE_ALLOWED_SCHEMES,
342
+ });
343
+ return {
344
+ html: clean,
345
+ blocks: blockResults,
346
+ warnings,
347
+ };
348
+ }
@@ -0,0 +1,231 @@
1
+ import { writeFileSync, mkdirSync, existsSync, readFileSync, statSync } from "fs";
2
+ import { join } from "path";
3
+ import { listInputs } from "./inputs.js";
4
+ export function categoryEmoji(c) {
5
+ const map = {
6
+ frontend: "🎨", backend: "⚙️", infra: "☁️", docs: "📖",
7
+ test: "🧪", design: "✏️", data: "🗄️", security: "🔒", task: "📋",
8
+ };
9
+ return map[c] ?? "📋";
10
+ }
11
+ export function priorityEmoji(p) {
12
+ const map = {
13
+ urgent: "🚨", high: "⬆️", normal: "➡️", low: "⬇️",
14
+ };
15
+ return map[p] ?? "➡️";
16
+ }
17
+ const TASKS_DIR = "tasks";
18
+ const SESSIONS_DIR = "sessions";
19
+ const TRUNCATE_AT = 4000;
20
+ // ─── Pure formatters ──────────────────────────────────────────────────────────
21
+ export function taskToMarkdown(task, board, extra) {
22
+ const col = board.columns.find(c => c.id === task.column);
23
+ const prio = task.priority ?? "normal";
24
+ const effort = task.effort ?? "—";
25
+ const tags = task.tags ?? [];
26
+ const lines = [
27
+ "---",
28
+ `title: ${task.title}`,
29
+ `id: ${task.id}`,
30
+ `column: ${task.column}`,
31
+ `order: ${task.order}`,
32
+ `state: ${task.state}`,
33
+ `status: ${task.status}`,
34
+ `agent: ${task.agent || "(default)"}`,
35
+ `model: ${task.model ?? "(default)"}`,
36
+ `createdAt: ${task.createdAt}`,
37
+ `updatedAt: ${task.updatedAt}`,
38
+ `tags: [${tags.map(t => `"${t}"`).join(", ")}]`,
39
+ `category: ${task.category ?? "task"}`,
40
+ `priority: ${prio}`,
41
+ `effort: ${effort}`,
42
+ `archived: ${task.archived ? "true" : "false"}`,
43
+ "---",
44
+ "",
45
+ `# ${task.title}`,
46
+ "",
47
+ `> **Category:** \`${task.category ?? "task"}\` · **Priority:** ${priorityEmoji(prio)} ${prio} · **Effort:** ${effort}`,
48
+ `> **Tags:** ${tags.map(t => `\`${t}\``).join(" ")}`,
49
+ "",
50
+ ];
51
+ if (task.description) {
52
+ lines.push(task.description, "");
53
+ }
54
+ if (task.stale) {
55
+ lines.push("> ⚠️ Source has changed since this task was imported. Re-run kanban_import to refresh.", "");
56
+ }
57
+ if (task.source) {
58
+ lines.push(`> 📄 Source: \`${task.source.path}:${task.source.line}\` *(imported from line ${task.source.line})*`, "");
59
+ }
60
+ lines.push(`_agent: ${task.agent || "(default)"} · _state: ${task.state}_`);
61
+ if (task.sessionId) {
62
+ lines.push(`_session: ${task.sessionId}_`);
63
+ }
64
+ if (task.lastError) {
65
+ lines.push("", `**Last error:** ${task.lastError}`);
66
+ }
67
+ if (task.sessionArtifact) {
68
+ lines.push("", `[Session transcript](./sessions/${task.sessionId}.mdx)`);
69
+ }
70
+ // If there are pending inputs, append an "Awaiting input" section
71
+ const pending = extra?.pendingInputs ?? [];
72
+ const unresolved = pending.filter(i => i.status === "pending");
73
+ if (unresolved.length > 0) {
74
+ lines.push("", "## Awaiting input", "");
75
+ for (const inp of unresolved) {
76
+ if (inp.type === "choice" && inp.options) {
77
+ const opts = inp.options.map(o => `- **${o.label}**${o.description ? `: ${o.description}` : ""}`).join("\n");
78
+ lines.push(`<Choice question="${inp.question}" options={${JSON.stringify(inp.options.map(o => ({ id: o.id, label: o.label })))}} />`);
79
+ }
80
+ else {
81
+ lines.push(`<Ask question="${inp.question}" />`);
82
+ }
83
+ lines.push("");
84
+ }
85
+ }
86
+ // Agent progress section — read from progress.json in the task dir
87
+ const progressFile = join(task.artifacts.mdxPath, "..", "progress.json");
88
+ let progressNotes = [];
89
+ try {
90
+ if (existsSync(progressFile)) {
91
+ progressNotes = JSON.parse(readFileSync(progressFile, "utf-8"));
92
+ }
93
+ }
94
+ catch { /* ignore */ }
95
+ if (progressNotes.length > 0) {
96
+ lines.push("", "## Agent progress", "");
97
+ for (const note of progressNotes) {
98
+ const time = new Date(note.ts).toLocaleTimeString("en-US", { hour12: false });
99
+ lines.push(`- [${time}] ${note.text}`);
100
+ }
101
+ }
102
+ return lines.join("\n");
103
+ }
104
+ export function sessionToMarkdown(sessionId, rec, task, messages, status) {
105
+ const lines = [
106
+ "---",
107
+ `title: Session ${sessionId}`,
108
+ `id: ${sessionId}`,
109
+ `taskId: ${rec.taskId}`,
110
+ `status: ${rec.status}`,
111
+ `startedAt: ${rec.startedAt}`,
112
+ `endedAt: ${rec.endedAt ?? ""}`,
113
+ "---",
114
+ "",
115
+ `# Session ${sessionId}`,
116
+ "",
117
+ ];
118
+ if (task) {
119
+ lines.push(`**Task:** [${task.title}](./${task.id}.mdx)`, "");
120
+ }
121
+ lines.push(`**Status:** ${status}`, `**Started:** ${rec.startedAt}`, rec.endedAt ? `**Ended:** ${rec.endedAt}` : "", "", "## Transcript", "");
122
+ for (const msg of messages) {
123
+ const content = msg.content.length > TRUNCATE_AT
124
+ ? msg.content.slice(0, TRUNCATE_AT) + "\n\n_(truncated — message too long)_"
125
+ : msg.content;
126
+ lines.push(`### ${msg.role}`, "", content, "");
127
+ }
128
+ return lines.join("\n");
129
+ }
130
+ export function boardToMarkdown(board) {
131
+ const cols = board.columns;
132
+ const lines = [
133
+ "---",
134
+ `title: Kanban Board`,
135
+ `generated: ${new Date().toISOString()}`,
136
+ `columns: ${cols.map(c => c.id).join(", ")}`,
137
+ "---",
138
+ "",
139
+ "# Kanban Board",
140
+ "",
141
+ ];
142
+ for (const col of cols) {
143
+ const tasks = board.tasks
144
+ .filter(t => t.column === col.id)
145
+ .sort((a, b) => a.order - b.order);
146
+ lines.push(`## ${col.title}`, "");
147
+ if (tasks.length === 0) {
148
+ lines.push("_No tasks_", "");
149
+ }
150
+ else {
151
+ for (const t of tasks) {
152
+ const agent = t.agent || "(default)";
153
+ lines.push(`- [${t.id.toUpperCase()}] ${t.title}`);
154
+ lines.push(` _agent: ${agent} · status: ${t.status}_`);
155
+ lines.push("");
156
+ }
157
+ }
158
+ }
159
+ return lines.join("\n");
160
+ }
161
+ // ─── I/O ──────────────────────────────────────────────────────────────────────
162
+ function ensureDir(dir, sub) {
163
+ const p = join(dir, sub);
164
+ if (!existsSync(p))
165
+ mkdirSync(p, { recursive: true });
166
+ }
167
+ /** Write task MDX to per-task directory (tasks/<id>/task.mdx). */
168
+ export async function writeTaskMdx(task, dir, board) {
169
+ ensureDir(dir, TASKS_DIR);
170
+ const taskDir = join(dir, TASKS_DIR, task.id);
171
+ ensureDir(dir, `${TASKS_DIR}/${task.id}`);
172
+ const path = join(taskDir, "task.mdx");
173
+ // Load pending inputs for the "Awaiting input" section
174
+ let pendingInputs = [];
175
+ try {
176
+ pendingInputs = listInputs(task.id, dir);
177
+ }
178
+ catch {
179
+ // ignore
180
+ }
181
+ const mdxContent = taskToMarkdown(task, board, { pendingInputs });
182
+ writeFileSync(path, mdxContent, "utf-8");
183
+ }
184
+ export async function writeSessionMdx(sessionId, rec, task, dir, _client) {
185
+ ensureDir(dir, SESSIONS_DIR);
186
+ const path = join(dir, SESSIONS_DIR, `${sessionId}.mdx`);
187
+ // Placeholder — real transcript requires client.session.history() which
188
+ // the SDK may not expose yet. Write a stub; caller can rewrite later.
189
+ const stub = sessionToMarkdown(sessionId, rec, task, [], rec.status);
190
+ writeFileSync(path, stub, "utf-8");
191
+ }
192
+ export async function writeBoardMdx(board, dir) {
193
+ const path = join(dir, "board.mdx");
194
+ writeFileSync(path, boardToMarkdown(board), "utf-8");
195
+ }
196
+ export async function regenerateAll(board, dir) {
197
+ await writeBoardMdx(board, dir);
198
+ for (const task of board.tasks) {
199
+ await writeTaskMdx(task, dir, board);
200
+ }
201
+ for (const [sid, rec] of Object.entries(board.sessions)) {
202
+ const task = board.tasks.find(t => t.id === rec.taskId);
203
+ await writeSessionMdx(sid, rec, task, dir, undefined);
204
+ }
205
+ }
206
+ // ─── Frontmatter helpers ───────────────────────────────────────────────────────
207
+ /**
208
+ * Strip YAML frontmatter from an MDX string and return the body text, trimmed.
209
+ * Handles the common ---...--- delimiter format used in task MDX files.
210
+ */
211
+ export function extractDescription(mdx) {
212
+ const match = mdx.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n([\s\S]*)$/);
213
+ if (!match)
214
+ return mdx.trim();
215
+ return match[1].trim();
216
+ }
217
+ /**
218
+ * Return the mtime of a task's MDX file, or null if it doesn't exist.
219
+ * @param taskId - the task id
220
+ * @param kanbanDir - the .ok directory path
221
+ */
222
+ export function statMdxMtime(taskId, kanbanDir) {
223
+ const mdxPath = join(kanbanDir, "tasks", taskId, "task.mdx");
224
+ try {
225
+ if (existsSync(mdxPath)) {
226
+ return statSync(mdxPath).mtime.toISOString();
227
+ }
228
+ }
229
+ catch { /* ignore */ }
230
+ return null;
231
+ }