@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,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, "&")
|
|
155
|
+
.replace(/</g, "<")
|
|
156
|
+
.replace(/>/g, ">")
|
|
157
|
+
.replace(/"/g, """);
|
|
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, """);
|
|
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
|
+
}
|