@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,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
|
+
}
|