@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,545 @@
1
+ // OpenKan — multi-project registry stored at ~/.config/openkan/projects.json
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, realpathSync } from "node:fs";
3
+ import { join, basename, resolve, dirname } from "node:path";
4
+ import { homedir } from "node:os";
5
+ // ─── Registry path ─────────────────────────────────────────────────────────────
6
+ let _testingRegistryPath = null;
7
+ export function setRegistryPathForTesting(p) {
8
+ _testingRegistryPath = p;
9
+ }
10
+ export function registryPath() {
11
+ if (_testingRegistryPath)
12
+ return _testingRegistryPath;
13
+ const configDir = join(homedir(), ".config", "openkan");
14
+ return join(configDir, "projects.json");
15
+ }
16
+ // ─── Low-level load/save ──────────────────────────────────────────────────────
17
+ export function loadRegistry() {
18
+ const path = registryPath();
19
+ if (!existsSync(path))
20
+ return { projects: [] };
21
+ try {
22
+ return JSON.parse(readFileSync(path, "utf-8"));
23
+ }
24
+ catch {
25
+ return { projects: [] };
26
+ }
27
+ }
28
+ export function saveRegistry(reg) {
29
+ const path = registryPath();
30
+ const dir = dirname(path);
31
+ mkdirSync(dir, { recursive: true });
32
+ writeFileSync(path, JSON.stringify(reg, null, 2), "utf-8");
33
+ }
34
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
35
+ function slugify(name) {
36
+ return name
37
+ .toLowerCase()
38
+ .replace(/[^a-z0-9]+/g, "-")
39
+ .replace(/^-+|-+$/g, "");
40
+ }
41
+ // ─── Public API ───────────────────────────────────────────────────────────────
42
+ function canonicalRoot(root) {
43
+ try {
44
+ return realpathSync(resolve(root));
45
+ }
46
+ catch {
47
+ return resolve(root);
48
+ }
49
+ }
50
+ function uniqueId(id, used) {
51
+ const base = id || "project";
52
+ let candidate = base;
53
+ for (let i = 2; used.has(candidate); i++)
54
+ candidate = `${base}-${i}`;
55
+ return candidate;
56
+ }
57
+ function distinctProjects(projects) {
58
+ const roots = new Map();
59
+ for (const project of projects) {
60
+ const root = canonicalRoot(project.root);
61
+ const prior = roots.get(root);
62
+ if (!prior)
63
+ roots.set(root, { ...project, root });
64
+ else
65
+ roots.set(root, {
66
+ ...(project.active ? project : prior), root,
67
+ active: prior.active || project.active,
68
+ lastOpenedAt: [prior.lastOpenedAt, project.lastOpenedAt].filter(Boolean).sort().at(-1),
69
+ });
70
+ }
71
+ const ids = new Set();
72
+ return [...roots.values()].map(project => {
73
+ const id = uniqueId(project.id, ids);
74
+ ids.add(id);
75
+ return { ...project, id };
76
+ });
77
+ }
78
+ function projectActivity(project) {
79
+ let latest = Date.parse(project.lastOpenedAt || project.addedAt) || 0;
80
+ const files = [".ok/board.json", ".ok/tasks", ".ok/plans", ".ok/prds", ".ok/changelog.jsonl", ".git/index", ".git/logs/HEAD"];
81
+ // Session turns append to existing files; inspect mtimes, never transcript contents.
82
+ try {
83
+ for (const name of readdirSync(join(project.root, ".ok/sessions")))
84
+ files.push(`.ok/sessions/${name}`);
85
+ }
86
+ catch { /* no sessions */ }
87
+ for (const file of files) {
88
+ try {
89
+ latest = Math.max(latest, statSync(join(project.root, file)).mtimeMs);
90
+ }
91
+ catch { /* optional activity source */ }
92
+ }
93
+ return Math.min(latest, Date.now());
94
+ }
95
+ /** List one entry per physical repository, newest activity first. */
96
+ export function listProjects() {
97
+ // Claude worktrees are execution sandboxes, not independent OpenKan
98
+ // projects. Keep legacy registry entries on disk for compatibility, but
99
+ // never surface them in selectors or overview APIs.
100
+ return distinctProjects(loadRegistry().projects)
101
+ .filter((project) => !isWorktreePath(project.root))
102
+ .map(project => ({ ...project, lastActivityAt: new Date(projectActivity(project)).toISOString() }))
103
+ .sort((a, b) => b.lastActivityAt.localeCompare(a.lastActivityAt) || a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
104
+ }
105
+ export function isWorktreePath(root) {
106
+ const path = root.replace(/\\/g, "/").toLowerCase();
107
+ if (path.includes("/.claude/worktrees/") || path.includes("/.git/worktrees/") || /\/worktrees\/[^/]+$/.test(path) || /\/(?:wt|worktree)-/i.test(path))
108
+ return true;
109
+ // Linked Git worktrees store `.git` as a text file pointing into the main
110
+ // repository's `.git/worktrees/<name>` directory, even when their own root
111
+ // has an innocuous name such as `wt-feature`.
112
+ try {
113
+ const git = join(root, ".git");
114
+ if (existsSync(git) && statSync(git).isFile()) {
115
+ return /gitdir:\s+.*[\\/]\.git[\\/]worktrees[\\/]/i.test(readFileSync(git, "utf-8"));
116
+ }
117
+ }
118
+ catch { /* unreadable entries remain eligible projects */ }
119
+ return false;
120
+ }
121
+ /** Return the currently active project, or null. */
122
+ export function activeProject() {
123
+ return listProjects().find(p => p.active) ?? null;
124
+ }
125
+ /**
126
+ * Set the active project by id.
127
+ * Returns the PREVIOUS active project (or null if none was active).
128
+ * Returns null if the id doesn't exist.
129
+ */
130
+ export function setActiveProject(id) {
131
+ const reg = { projects: distinctProjects(loadRegistry().projects) };
132
+ const prev = reg.projects.find(p => p.active) ?? null;
133
+ const target = reg.projects.find(p => p.id === id);
134
+ if (!target)
135
+ return null;
136
+ reg.projects = reg.projects.map(p => ({ ...p, active: p.id === id, ...(p.id === id ? { lastOpenedAt: new Date().toISOString() } : {}) }));
137
+ saveRegistry(reg);
138
+ return prev;
139
+ }
140
+ /**
141
+ * Add a new project to the registry.
142
+ * Auto-derives `id` from `root` basename if not provided.
143
+ * Sets it as active (deactivates any previous).
144
+ */
145
+ export function addProject(input) {
146
+ const root = canonicalRoot(input.root);
147
+ const now = new Date().toISOString();
148
+ const reg = { projects: distinctProjects(loadRegistry().projects) };
149
+ const existing = reg.projects.find(project => project.root === root);
150
+ const id = existing?.id ?? uniqueId(input.id ?? slugify(basename(root)), new Set(reg.projects.map(project => project.id)));
151
+ const entry = {
152
+ ...existing, id, name: input.name, root,
153
+ addedAt: existing?.addedAt || now, lastOpenedAt: now, active: true,
154
+ };
155
+ reg.projects = reg.projects.filter(project => project.root !== root).map(project => ({ ...project, active: false }));
156
+ reg.projects.push(entry);
157
+ saveRegistry(reg);
158
+ return entry;
159
+ }
160
+ /** Remove a project by id. Returns true if found and removed. */
161
+ export function removeProject(id) {
162
+ const reg = { projects: distinctProjects(loadRegistry().projects) };
163
+ const idx = reg.projects.findIndex(p => p.id === id);
164
+ if (idx === -1)
165
+ return false;
166
+ const wasActive = reg.projects[idx].active;
167
+ reg.projects.splice(idx, 1);
168
+ // If removed was active, activate the first remaining (or none)
169
+ if (wasActive && reg.projects.length > 0) {
170
+ reg.projects[0].active = true;
171
+ }
172
+ saveRegistry(reg);
173
+ return true;
174
+ }
175
+ /**
176
+ * Resolve a project's openkan dir. Returns null if root doesn't exist.
177
+ * Returns <root>/.ok
178
+ */
179
+ export function projectKanbanDir(p) {
180
+ if (!existsSync(p.root))
181
+ return null;
182
+ return join(p.root, ".ok");
183
+ }
184
+ /**
185
+ * Resolve a project kanban directory by either project id (`openkan`,
186
+ * `sample-kanban-project`) or absolute project root path. Returns null
187
+ * when no matching registered project exists. This is the supported
188
+ * cross-project lookup used by cross-project task move.
189
+ */
190
+ export function resolveProjectKanbanDir(ref) {
191
+ // Try by id first.
192
+ const byId = listProjects().find((p) => p.id === ref);
193
+ if (byId)
194
+ return projectKanbanDir(byId);
195
+ // Fall back to root path match.
196
+ const normalised = resolve(ref);
197
+ const byRoot = listProjects().find((p) => resolve(p.root) === normalised);
198
+ if (byRoot)
199
+ return projectKanbanDir(byRoot);
200
+ return null;
201
+ }
202
+ /** Find a registered project entry by id (case-insensitive). Returns null when missing. */
203
+ export function findProject(id) {
204
+ if (!id)
205
+ return null;
206
+ return listProjects().find((p) => p.id === id) ?? null;
207
+ }
208
+ /**
209
+ * Resolve a target `.ok/` directory from a project id, project root path,
210
+ * or an already-absolute `.ok/` directory. When the supplied ref points
211
+ * directly at a directory that already contains a `.ok/` subdir, that
212
+ * subdir wins — callers usually want the kanban root, not the project
213
+ * root. Falls back to the conventional `<root>/.ok` layout.
214
+ */
215
+ export function resolveKanbanDir(ref) {
216
+ if (!ref)
217
+ return ref;
218
+ const nested = join(ref, ".ok");
219
+ if (existsSync(nested) && statSync(nested).isDirectory())
220
+ return nested;
221
+ // Registry lookup by id or root path.
222
+ const resolved = resolveProjectKanbanDir(ref);
223
+ if (resolved)
224
+ return resolved;
225
+ // Bare `.ok/` path.
226
+ if (existsSync(ref) && statSync(ref).isDirectory())
227
+ return ref;
228
+ return nested;
229
+ }
230
+ // ─── Auto-detect helpers ──────────────────────────────────────────────────────
231
+ const DEFAULT_SKIP_DIRS = new Set([
232
+ "node_modules", ".git", "dist", ".next",
233
+ ".cache", ".npm", ".local", ".config", "openkan-test",
234
+ ]);
235
+ /**
236
+ * Walk upward from `cwd` looking for a `.git` file or directory.
237
+ * Returns the repo root (parent of `.git`) or null if not found.
238
+ */
239
+ export async function findClosestGitRepo(cwd) {
240
+ let dir = resolve(cwd);
241
+ const root = resolve("/");
242
+ while (dir !== root) {
243
+ try {
244
+ const stat = statSync(join(dir, ".git"));
245
+ if (stat.isDirectory() || stat.isFile()) {
246
+ return dir;
247
+ }
248
+ }
249
+ catch {
250
+ // No .git here, walk up
251
+ }
252
+ const parent = resolve(dir, "..");
253
+ if (parent === dir)
254
+ break;
255
+ dir = parent;
256
+ }
257
+ return null;
258
+ }
259
+ /**
260
+ * Synchronous walk of `dir` up to `maxDepth` levels, returning git repo roots.
261
+ * Respects skipHidden and skipDirs options.
262
+ */
263
+ function walkForGitRepos(dir, maxDepth, currentDepth, skipHidden, skipDirs, results, maxResults) {
264
+ if (results.length >= maxResults)
265
+ return;
266
+ if (currentDepth > maxDepth)
267
+ return;
268
+ let entries;
269
+ try {
270
+ entries = readdirSync(dir);
271
+ }
272
+ catch {
273
+ return;
274
+ }
275
+ for (const entry of entries) {
276
+ if (results.length >= maxResults)
277
+ break;
278
+ // Check hidden
279
+ if (skipHidden && entry.startsWith("."))
280
+ continue;
281
+ // Check skip dirs
282
+ if (skipDirs.has(entry))
283
+ continue;
284
+ const fullPath = join(dir, entry);
285
+ let stat;
286
+ try {
287
+ stat = statSync(fullPath);
288
+ }
289
+ catch {
290
+ continue;
291
+ }
292
+ if (stat.isDirectory()) {
293
+ // Check if it's a git repo directly
294
+ const gitPath = join(fullPath, ".git");
295
+ let isGitRepo = false;
296
+ try {
297
+ const gitStat = statSync(gitPath);
298
+ isGitRepo = gitStat.isDirectory() || gitStat.isFile();
299
+ }
300
+ catch {
301
+ // Not a git repo
302
+ }
303
+ if (isGitRepo) {
304
+ results.push(fullPath);
305
+ // Don't recurse into git repos
306
+ continue;
307
+ }
308
+ // Recurse into subdirectories (at next depth level)
309
+ if (currentDepth < maxDepth) {
310
+ walkForGitRepos(fullPath, maxDepth, currentDepth + 1, skipHidden, skipDirs, results, maxResults);
311
+ }
312
+ }
313
+ }
314
+ }
315
+ /**
316
+ * Scan common locations for git repositories.
317
+ * Returns the list of discovered repo root paths (not deduplicated).
318
+ */
319
+ export async function scanForGitRepos(opts) {
320
+ const homes = opts?.homes ?? [homedir()];
321
+ const suffixes = opts?.suffixes ?? ["projects", "work", "repos", "src", "code", "Documents"];
322
+ const maxDepth = opts?.maxDepth ?? 2;
323
+ const maxResults = opts?.maxResults ?? 50;
324
+ const skipHidden = opts?.skipHidden ?? true;
325
+ const skipDirs = new Set(opts?.skipDirs ?? [...DEFAULT_SKIP_DIRS]);
326
+ const results = [];
327
+ // Start from cwd
328
+ const cwd = process.cwd();
329
+ const cwdRepo = await findClosestGitRepo(cwd);
330
+ if (cwdRepo)
331
+ results.push(cwdRepo);
332
+ // Walk each home directory's suffixes
333
+ for (const home of homes) {
334
+ for (const suffix of suffixes) {
335
+ if (results.length >= maxResults)
336
+ break;
337
+ const dir = join(home, suffix);
338
+ try {
339
+ if (!statSync(dir).isDirectory())
340
+ continue;
341
+ }
342
+ catch {
343
+ continue;
344
+ }
345
+ walkForGitRepos(dir, maxDepth, 0, skipHidden, skipDirs, results, maxResults);
346
+ }
347
+ }
348
+ return results;
349
+ }
350
+ /**
351
+ * Auto-detect git repositories and register new ones in the project registry.
352
+ * Does NOT make any project active; does NOT modify existing entries.
353
+ */
354
+ export async function autoDetectProjects(opts) {
355
+ const homes = opts?.homes ?? [homedir()];
356
+ const suffixes = opts?.suffixes ?? ["projects", "work", "repos", "src", "code", "Documents"];
357
+ const maxDepth = opts?.maxDepth ?? 2;
358
+ const maxResults = opts?.maxResults ?? 50;
359
+ const skipHidden = opts?.skipHidden ?? true;
360
+ const skipDirs = new Set(opts?.skipDirs ?? [...DEFAULT_SKIP_DIRS]);
361
+ const registry = { projects: distinctProjects(loadRegistry().projects) };
362
+ const knownRoots = new Set(registry.projects.map((p) => canonicalRoot(p.root)));
363
+ const result = { scanned: [], discovered: [], alreadyKnown: [] };
364
+ // Start from cwd
365
+ const cwd = process.cwd();
366
+ const cwdRepo = await findClosestGitRepo(cwd);
367
+ if (cwdRepo)
368
+ result.scanned.push(cwdRepo);
369
+ // Walk each home directory's suffixes
370
+ for (const home of homes) {
371
+ for (const suffix of suffixes) {
372
+ if (result.scanned.length >= maxResults)
373
+ break;
374
+ const dir = join(home, suffix);
375
+ try {
376
+ if (!statSync(dir).isDirectory())
377
+ continue;
378
+ }
379
+ catch {
380
+ continue;
381
+ }
382
+ walkForGitRepos(dir, maxDepth, 0, skipHidden, skipDirs, result.scanned, maxResults);
383
+ }
384
+ }
385
+ // Deduplicate scanned by resolved path BEFORE the loop that adds to registry.
386
+ // This prevents the same repo (found via cwd + suffix-walk overlap, or via
387
+ // multiple suffix iterations pointing to the same physical directory) from
388
+ // being registered multiple times in a single run.
389
+ const seenRoots = new Set();
390
+ const uniqueScanned = [];
391
+ for (const raw of result.scanned) {
392
+ const resolved = canonicalRoot(raw);
393
+ if (seenRoots.has(resolved))
394
+ continue;
395
+ seenRoots.add(resolved);
396
+ uniqueScanned.push(resolved); // store resolved form for consistency
397
+ }
398
+ // Filter against registry
399
+ for (const repoRoot of uniqueScanned) {
400
+ if (isWorktreePath(repoRoot))
401
+ continue;
402
+ if (knownRoots.has(repoRoot)) {
403
+ result.alreadyKnown.push(repoRoot);
404
+ continue;
405
+ }
406
+ const id = uniqueId(slugify(basename(repoRoot)), new Set([...registry.projects, ...result.discovered].map(project => project.id)));
407
+ const entry = {
408
+ id,
409
+ name: basename(repoRoot),
410
+ root: repoRoot,
411
+ addedAt: new Date().toISOString(),
412
+ active: false,
413
+ };
414
+ result.discovered.push(entry);
415
+ if (result.discovered.length >= maxResults)
416
+ break;
417
+ }
418
+ // Persist new discoveries
419
+ if (result.discovered.length > 0) {
420
+ registry.projects = [...registry.projects, ...result.discovered];
421
+ saveRegistry(registry);
422
+ }
423
+ return result;
424
+ }
425
+ // ─── Active project root resolution ──────────────────────────────────────────
426
+ /**
427
+ * Return the active project's root, or process.cwd() if none is set
428
+ * or the active root doesn't exist on disk.
429
+ */
430
+ export function getActiveProjectRoot() {
431
+ const active = activeProject();
432
+ if (!active)
433
+ return process.cwd();
434
+ if (!existsSync(active.root))
435
+ return process.cwd();
436
+ return active.root;
437
+ }
438
+ /**
439
+ * Clean the registry:
440
+ * - Dedup by resolved root (keep first occurrence, or one with active:true if tie)
441
+ * - Dedup by id (same tiebreaker)
442
+ * - Optionally prune entries whose root no longer exists on disk
443
+ * - Optionally persist the cleaned registry
444
+ */
445
+ export function cleanupRegistry(opts) {
446
+ const verbose = opts?.verbose ?? false;
447
+ const pruneMissing = opts?.pruneMissing ?? false;
448
+ const persist = opts?.persist ?? false;
449
+ const reg = loadRegistry();
450
+ const before = reg.projects;
451
+ if (verbose)
452
+ console.error(`[cleanupRegistry] before: ${before.length} entries`);
453
+ // Phase 1: dedup by resolved root — keep first (or active:true) occurrence
454
+ const rootSeen = new Map();
455
+ const rootDeduped = []; // indices of duplicates
456
+ for (let i = 0; i < before.length; i++) {
457
+ const resolved = resolve(before[i].root);
458
+ const existing = rootSeen.get(resolved);
459
+ if (!existing) {
460
+ rootSeen.set(resolved, before[i]);
461
+ }
462
+ else {
463
+ // Keep the one with active:true if there's a tie
464
+ if (existing.active && !before[i].active) {
465
+ // existing wins — mark current as dup
466
+ rootDeduped.push(i);
467
+ if (verbose)
468
+ console.error(`[cleanupRegistry] root dedup: ${resolved} (kept existing active)`);
469
+ }
470
+ else if (!existing.active && before[i].active) {
471
+ // current wins — replace
472
+ rootSeen.set(resolved, before[i]);
473
+ rootDeduped.push(i);
474
+ if (verbose)
475
+ console.error(`[cleanupRegistry] root dedup: ${resolved} (replaced with active)`);
476
+ }
477
+ else {
478
+ // Neither active or both active — keep first, mark current as dup
479
+ rootDeduped.push(i);
480
+ if (verbose)
481
+ console.error(`[cleanupRegistry] root dedup: ${resolved} (kept first)`);
482
+ }
483
+ }
484
+ }
485
+ let after = before.filter((_, i) => !rootDeduped.includes(i));
486
+ // Phase 2: dedup by id — keep first (or active:true)
487
+ const idSeen = new Map();
488
+ const idDeduped = [];
489
+ for (let i = 0; i < after.length; i++) {
490
+ const entry = after[i];
491
+ const existing = idSeen.get(entry.id);
492
+ if (!existing) {
493
+ idSeen.set(entry.id, entry);
494
+ }
495
+ else {
496
+ // Same id but different root — tiebreak by active
497
+ if (existing.active && !entry.active) {
498
+ idDeduped.push(i);
499
+ if (verbose)
500
+ console.error(`[cleanupRegistry] id dedup: ${entry.id} (kept existing active)`);
501
+ }
502
+ else if (!existing.active && entry.active) {
503
+ idSeen.set(entry.id, entry);
504
+ idDeduped.push(i);
505
+ if (verbose)
506
+ console.error(`[cleanupRegistry] id dedup: ${entry.id} (replaced with active)`);
507
+ }
508
+ else {
509
+ idDeduped.push(i);
510
+ if (verbose)
511
+ console.error(`[cleanupRegistry] id dedup: ${entry.id} (kept first)`);
512
+ }
513
+ }
514
+ }
515
+ after = after.filter((_, i) => !idDeduped.includes(i));
516
+ // Phase 3: optional prune of missing roots
517
+ let pruned = 0;
518
+ if (pruneMissing) {
519
+ const beforeCount = after.length;
520
+ after = after.filter(p => {
521
+ const exists = existsSync(p.root);
522
+ if (!exists && verbose)
523
+ console.error(`[cleanupRegistry] prune: ${p.root} does not exist`);
524
+ return exists;
525
+ });
526
+ pruned = beforeCount - after.length;
527
+ }
528
+ if (verbose) {
529
+ console.error(`[cleanupRegistry] after: ${after.length} entries`);
530
+ console.error(`[cleanupRegistry] deduped: ${rootDeduped.length + idDeduped.length}`);
531
+ console.error(`[cleanupRegistry] pruned: ${pruned}`);
532
+ }
533
+ if (persist) {
534
+ saveRegistry({ projects: after });
535
+ if (verbose)
536
+ console.error(`[cleanupRegistry] persisted cleaned registry to ${registryPath()}`);
537
+ }
538
+ return {
539
+ before,
540
+ after,
541
+ removed: before.length - after.length,
542
+ deduped: rootDeduped.length + idDeduped.length,
543
+ pruned,
544
+ };
545
+ }
@@ -0,0 +1,121 @@
1
+ // OpenKan — pure indexed text search across tasks.
2
+ import { existsSync, readFileSync } from "fs";
3
+ import { join } from "path";
4
+ import { getBoard } from "./board.js";
5
+ // ─── Helpers ────────────────────────────────────────────────────────────────
6
+ /** Case-insensitive substring check. */
7
+ function matches(haystack, needle) {
8
+ return haystack.toLowerCase().includes(needle.toLowerCase());
9
+ }
10
+ /** Load raw MDX content for a task, or "" if not found. */
11
+ function loadMdxContent(kanbanDir, task) {
12
+ const mdxPath = join(kanbanDir, task.artifacts.mdxPath);
13
+ if (!existsSync(mdxPath))
14
+ return "";
15
+ try {
16
+ return readFileSync(mdxPath, "utf-8");
17
+ }
18
+ catch {
19
+ return "";
20
+ }
21
+ }
22
+ /** Find which fields in the task match the query string. */
23
+ function findMatchIn(task, query, mdxContent) {
24
+ const fields = [];
25
+ const q = query.toLowerCase();
26
+ if (matches(task.title, q))
27
+ fields.push("title");
28
+ if (matches(task.description, q))
29
+ fields.push("description");
30
+ if (task.tags?.some(t => matches(t, q)))
31
+ fields.push("tags");
32
+ if (task.assignees?.some(a => matches(a, q)))
33
+ fields.push("assignees");
34
+ if (matches(mdxContent, q))
35
+ fields.push("content");
36
+ return fields;
37
+ }
38
+ // ─── Core search ────────────────────────────────────────────────────────────
39
+ /**
40
+ * Pure search across all tasks.
41
+ *
42
+ * Matching:
43
+ * - `query` — matches against title, description, tags, assignees, and MDX content
44
+ * (case-insensitive substring on each field)
45
+ * - `column` — filters to that column (default: all)
46
+ * - `tags` — AND filter: task must contain ALL listed tags
47
+ * - `assignee` — task must have this name in assignees
48
+ * - `priority` — must match (or "all" to skip)
49
+ * - `category` — must match (or "all" to skip)
50
+ * - `archived` — include archived tasks (default: false)
51
+ * - `limit/offset` — pagination
52
+ *
53
+ * Returns tasks with a `matchIn` array listing which fields the query matched.
54
+ */
55
+ export async function search(opts) {
56
+ const { kanbanDir, query = "", column, tags, assignee, priority, category, archived = false, limit = 50, offset = 0, } = opts;
57
+ const board = await getBoard();
58
+ // Pre-load MDX content for all tasks (avoids N sequential reads)
59
+ const mdxCache = new Map();
60
+ if (query) {
61
+ for (const task of board.tasks) {
62
+ mdxCache.set(task.id, loadMdxContent(kanbanDir, task));
63
+ }
64
+ }
65
+ const q = query.trim();
66
+ // Filter + match in one pass
67
+ const matched = [];
68
+ for (const task of board.tasks) {
69
+ // archived filter
70
+ if (!archived && task.archived)
71
+ continue;
72
+ // column filter
73
+ if (column && task.column !== column)
74
+ continue;
75
+ // priority filter
76
+ if (priority && priority !== "all" && task.priority !== priority)
77
+ continue;
78
+ // category filter
79
+ if (category && category !== "all" && task.category !== category)
80
+ continue;
81
+ // assignee filter (case-insensitive)
82
+ if (assignee) {
83
+ const found = task.assignees?.some(a => matches(a, assignee)) ?? false;
84
+ if (!found)
85
+ continue;
86
+ }
87
+ // AND tags filter
88
+ if (tags && tags.length > 0) {
89
+ const taskTagsLower = (task.tags ?? []).map(t => t.toLowerCase());
90
+ const allMatch = tags.every(tag => taskTagsLower.some(t => matches(t, tag)));
91
+ if (!allMatch)
92
+ continue;
93
+ }
94
+ // text query
95
+ if (q) {
96
+ const mdxContent = mdxCache.get(task.id) ?? "";
97
+ const matchIn = findMatchIn(task, q, mdxContent);
98
+ if (matchIn.length === 0)
99
+ continue;
100
+ matched.push({ ...task, matchIn });
101
+ }
102
+ else {
103
+ // no query — return everything passing filters with empty matchIn
104
+ matched.push({ ...task, matchIn: [] });
105
+ }
106
+ }
107
+ // Sort: active tasks first by column order, then archived
108
+ const colOrder = Object.fromEntries(board.columns.map((c, i) => [c.id, i]));
109
+ matched.sort((a, b) => {
110
+ if (a.archived !== b.archived)
111
+ return a.archived ? 1 : -1;
112
+ const ca = colOrder[a.column] ?? 99;
113
+ const cb = colOrder[b.column] ?? 99;
114
+ if (ca !== cb)
115
+ return ca - cb;
116
+ return a.order - b.order;
117
+ });
118
+ const total = matched.length;
119
+ const paginated = matched.slice(offset, offset + limit);
120
+ return { results: paginated, total };
121
+ }