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