@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,139 @@
1
+ // OpenKan — bulk batch operations across multiple tasks.
2
+ import { join } from "path";
3
+ import { getBoard, withWrite, renormalizeOrder, nowIso, KANBAN_DIR } from "./board.js";
4
+ import { removeDir } from "./io.js";
5
+ import { writeTaskMdx } from "./mdx.js";
6
+ import { recordEvent } from "./changelog.js";
7
+ // ─── Apply bulk ─────────────────────────────────────────────────────────────
8
+ /**
9
+ * Apply a bulk operation to the board atomically.
10
+ *
11
+ * - Uses `withWrite` for a single atomic board mutation.
12
+ * - Writes per-task MDX for every affected task.
13
+ * - Records ONE `kanban.bulk` changelog event with the full diff.
14
+ */
15
+ export async function applyBulk(board, operation) {
16
+ const applied = [];
17
+ const skipped = [];
18
+ // Collect task directories to delete AFTER withWrite (can't delete inside withWrite
19
+ // because the board is still referenced)
20
+ const toDelete = [];
21
+ await withWrite(async (b) => {
22
+ for (const taskId of operation.taskIds) {
23
+ const t = b.tasks.find(x => x.id === taskId);
24
+ if (!t) {
25
+ skipped.push({ taskId, kind: operation.kind, reason: "Task not found" });
26
+ continue;
27
+ }
28
+ const before = snapshot(t);
29
+ try {
30
+ switch (operation.kind) {
31
+ case "move": {
32
+ t.column = operation.column;
33
+ if (typeof operation.order === "number")
34
+ t.order = operation.order;
35
+ break;
36
+ }
37
+ case "set-priority": {
38
+ t.priority = operation.priority;
39
+ break;
40
+ }
41
+ case "set-category": {
42
+ t.category = operation.category;
43
+ break;
44
+ }
45
+ case "add-tags": {
46
+ t.tags = [...new Set([...t.tags, ...operation.tags])];
47
+ break;
48
+ }
49
+ case "remove-tag": {
50
+ t.tags = t.tags.filter(tag => tag !== operation.tag);
51
+ break;
52
+ }
53
+ case "assign": {
54
+ if (!t.assignees)
55
+ t.assignees = [];
56
+ if (!t.assignees.includes(operation.assignee)) {
57
+ t.assignees = [...t.assignees, operation.assignee];
58
+ }
59
+ break;
60
+ }
61
+ case "archive": {
62
+ t.archived = true;
63
+ break;
64
+ }
65
+ case "restore": {
66
+ t.archived = false;
67
+ break;
68
+ }
69
+ case "delete": {
70
+ // Capture before snapshot, then remove from board
71
+ toDelete.push({ taskId, before });
72
+ const idx = b.tasks.findIndex(x => x.id === taskId);
73
+ if (idx !== -1)
74
+ b.tasks.splice(idx, 1);
75
+ break;
76
+ }
77
+ }
78
+ // Skip setting updatedAt / snapshot for delete — already handled above
79
+ if (operation.kind !== "delete") {
80
+ t.updatedAt = nowIso();
81
+ const after = snapshot(t);
82
+ applied.push({ taskId, kind: operation.kind, before, after });
83
+ }
84
+ }
85
+ catch (e) {
86
+ skipped.push({ taskId, kind: operation.kind, reason: String(e) });
87
+ }
88
+ }
89
+ // Renormalize if any move operation occurred
90
+ if (applied.some(a => a.kind === "move")) {
91
+ b.tasks = renormalizeOrder(b.tasks);
92
+ }
93
+ });
94
+ // Write MDX for non-delete operations
95
+ const boardAfter = await getBoard();
96
+ for (const a of applied) {
97
+ const t = boardAfter.tasks.find(x => x.id === a.taskId);
98
+ if (t) {
99
+ await writeTaskMdx(t, KANBAN_DIR, boardAfter);
100
+ }
101
+ }
102
+ // Delete task directories for delete operations
103
+ for (const { taskId } of toDelete) {
104
+ const taskDir = join(KANBAN_DIR, "tasks", taskId);
105
+ removeDir(taskDir);
106
+ }
107
+ // Add delete operations to applied list with before snapshot
108
+ for (const { taskId, before } of toDelete) {
109
+ applied.push({ taskId, kind: "delete", before, after: undefined });
110
+ }
111
+ const summary = {
112
+ moved: applied.filter(a => a.kind === "move").length,
113
+ tagged: applied.filter(a => ["add-tags", "remove-tag", "set-priority", "set-category", "assign"].includes(a.kind)).length,
114
+ archived: applied.filter(a => ["archive", "restore"].includes(a.kind)).length,
115
+ deleted: toDelete.length,
116
+ skipped: skipped.length,
117
+ };
118
+ if (applied.length > 0) {
119
+ recordEvent(KANBAN_DIR, "kanban.bulk", {
120
+ author: "user",
121
+ summary: `bulk ${operation.kind} on ${applied.length} task(s)`,
122
+ payload: { operation: operation.kind, applied, skipped },
123
+ });
124
+ }
125
+ return { applied, skipped, summary };
126
+ }
127
+ // ─── Helpers ────────────────────────────────────────────────────────────────
128
+ function snapshot(t) {
129
+ return {
130
+ column: t.column,
131
+ order: t.order,
132
+ priority: t.priority,
133
+ category: t.category,
134
+ tags: [...t.tags],
135
+ assignees: [...(t.assignees ?? [])],
136
+ archived: t.archived,
137
+ state: t.state,
138
+ };
139
+ }
@@ -0,0 +1,186 @@
1
+ // OpenKan — append-only JSONL changelog.
2
+ import { readFileSync, appendFileSync, existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { nanoid } from "nanoid";
5
+ import { writeFileAtomic } from "./io.js";
6
+ const CHANGELOG_FILE = "changelog.jsonl";
7
+ function changelogPath(kanbanDir) {
8
+ return join(kanbanDir, CHANGELOG_FILE);
9
+ }
10
+ // Microsecond counter to break timestamp ties (multiple events in same ms)
11
+ let _lastTsMs = 0;
12
+ let _counter = 0;
13
+ function nextCounter() {
14
+ const now = Date.now();
15
+ if (now !== _lastTsMs) {
16
+ _lastTsMs = now;
17
+ _counter = 0;
18
+ }
19
+ return _counter++;
20
+ }
21
+ function makeId() {
22
+ return `chg-${nanoid(8)}-${String(nextCounter()).padStart(3, "0")}`;
23
+ }
24
+ function nowIsoMicro() {
25
+ const ts = nextCounter();
26
+ // ISO with 3-digit microseconds appended to millisecond part
27
+ const base = new Date(_lastTsMs).toISOString().replace("Z", "");
28
+ return base + String(ts).padStart(3, "0") + "Z";
29
+ }
30
+ // ─── Write ─────────────────────────────────────────────────────────────────
31
+ /**
32
+ * Append a new event to the changelog.
33
+ * Uses writeFileAtomic only when creating the file for the first time;
34
+ * thereafter uses appendFileSync so a crash mid-write cannot truncate history.
35
+ */
36
+ export function recordEvent(kanbanDir, kind, partial) {
37
+ const event = {
38
+ id: makeId(),
39
+ ts: nowIsoMicro(),
40
+ kind,
41
+ ...partial,
42
+ };
43
+ const path = changelogPath(kanbanDir);
44
+ const line = JSON.stringify(event) + "\n";
45
+ if (!existsSync(path)) {
46
+ // First write — use atomic to avoid creating a truncated file
47
+ writeFileAtomic(path, line);
48
+ }
49
+ else {
50
+ // All subsequent writes — append only
51
+ appendFileSync(path, line, "utf-8");
52
+ }
53
+ return event;
54
+ }
55
+ // ─── Read ──────────────────────────────────────────────────────────────────
56
+ /** Parse one non-empty line as JSON; warn to stderr on parse failure. */
57
+ function parseLine(line) {
58
+ const trimmed = line.trim();
59
+ if (!trimmed)
60
+ return null;
61
+ try {
62
+ return JSON.parse(trimmed);
63
+ }
64
+ catch {
65
+ console.warn(`[changelog] Skipping unparseable line: ${trimmed.slice(0, 120)}`);
66
+ return null;
67
+ }
68
+ }
69
+ /** Kinds that represent terminal / completion events — used for completedOnly filter */
70
+ export const COMPLETION_KINDS = new Set([
71
+ "task.deleted",
72
+ "task.archived",
73
+ "task.restored",
74
+ "kanban.organized",
75
+ "git.commit-attributed",
76
+ "agent.ended",
77
+ "settings.changed",
78
+ ]);
79
+ export function readEvents(kanbanDir, opts) {
80
+ const path = changelogPath(kanbanDir);
81
+ if (!existsSync(path))
82
+ return { events: [], total: 0 };
83
+ let raw;
84
+ try {
85
+ raw = readFileSync(path, "utf-8");
86
+ }
87
+ catch {
88
+ return { events: [], total: 0 };
89
+ }
90
+ const all = [];
91
+ for (const line of raw.split("\n")) {
92
+ const event = parseLine(line);
93
+ if (event)
94
+ all.push(event);
95
+ }
96
+ // Filter in-memory; changelog is small enough for v1
97
+ const kinds = opts?.kind
98
+ ? (Array.isArray(opts.kind) ? opts.kind : [opts.kind])
99
+ : null;
100
+ const filtered = all.filter(e => {
101
+ if (opts?.since && e.ts < opts.since)
102
+ return false;
103
+ if (opts?.until && e.ts > opts.until)
104
+ return false;
105
+ if (kinds && !kinds.includes(e.kind))
106
+ return false;
107
+ if (opts?.taskId && e.taskId !== opts.taskId)
108
+ return false;
109
+ if (opts?.author && e.author !== opts.author)
110
+ return false;
111
+ return true;
112
+ });
113
+ // Newest first
114
+ filtered.sort((a, b) => b.ts.localeCompare(a.ts));
115
+ // completedOnly post-filter: only show events for tasks in "done" column, or terminal kinds
116
+ let finalEvents = filtered;
117
+ if (opts?.completedOnly) {
118
+ // Load done-column task IDs from board.json (sync, using already-imported readFileSync)
119
+ const doneTaskIds = new Set();
120
+ if (opts?.kanbanDirForCompletedOnly) {
121
+ try {
122
+ const boardPath = join(opts.kanbanDirForCompletedOnly, "board.json");
123
+ if (existsSync(boardPath)) {
124
+ const board = JSON.parse(readFileSync(boardPath, "utf-8"));
125
+ for (const t of board.tasks) {
126
+ if (t.column === "done")
127
+ doneTaskIds.add(t.id);
128
+ }
129
+ }
130
+ }
131
+ catch { /* ignore */ }
132
+ }
133
+ finalEvents = filtered.filter(e => {
134
+ // Always include terminal kinds
135
+ if (COMPLETION_KINDS.has(e.kind))
136
+ return true;
137
+ // Include events whose task is currently in done column
138
+ if (e.taskId && doneTaskIds.has(e.taskId))
139
+ return true;
140
+ return false;
141
+ });
142
+ }
143
+ const total = finalEvents.length;
144
+ const offset = opts?.reset ? 0 : (opts?.offset ?? 0);
145
+ const limit = opts?.limit ?? 200;
146
+ const events = finalEvents.slice(offset, offset + limit);
147
+ return { events, total };
148
+ }
149
+ export function readEventById(kanbanDir, id) {
150
+ const path = changelogPath(kanbanDir);
151
+ if (!existsSync(path))
152
+ return null;
153
+ let raw;
154
+ try {
155
+ raw = readFileSync(path, "utf-8");
156
+ }
157
+ catch {
158
+ return null;
159
+ }
160
+ for (const line of raw.split("\n")) {
161
+ const event = parseLine(line);
162
+ if (event && event.id === id)
163
+ return event;
164
+ }
165
+ return null;
166
+ }
167
+ // ─── Summary ────────────────────────────────────────────────────────────────
168
+ export function readSummary(kanbanDir, opts) {
169
+ const path = changelogPath(kanbanDir);
170
+ const since = opts?.days
171
+ ? new Date(Date.now() - opts.days * 24 * 60 * 60 * 1000).toISOString()
172
+ : undefined;
173
+ const { events } = readEvents(kanbanDir, { since });
174
+ const byKind = {};
175
+ const byAuthor = {};
176
+ const byDay = {}; // YYYY-MM-DD in local time
177
+ for (const e of events) {
178
+ byKind[e.kind] = (byKind[e.kind] ?? 0) + 1;
179
+ byAuthor[e.author] = (byAuthor[e.author] ?? 0) + 1;
180
+ // Local date (not UTC) — uses the host timezone
181
+ const d = new Date(e.ts);
182
+ const local = d.toLocaleDateString("en-CA"); // YYYY-MM-DD in local timezone
183
+ byDay[local] = (byDay[local] ?? 0) + 1;
184
+ }
185
+ return { byKind, byAuthor, byDay, total: events.length };
186
+ }