@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,80 @@
|
|
|
1
|
+
// OpenKan — comment CRUD anchored to MDX blocks.
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { nanoid } from "nanoid";
|
|
5
|
+
import { ensureDir } from "./io.js";
|
|
6
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
7
|
+
const STORE_FILE = "comments.json";
|
|
8
|
+
function storePath(taskDir) {
|
|
9
|
+
return join(taskDir, STORE_FILE);
|
|
10
|
+
}
|
|
11
|
+
function loadStore(taskDir) {
|
|
12
|
+
const p = storePath(taskDir);
|
|
13
|
+
if (!existsSync(p))
|
|
14
|
+
return { comments: [] };
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(readFileSync(p, "utf-8"));
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return { comments: [] };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function saveStore(taskDir, store) {
|
|
23
|
+
ensureDir(taskDir);
|
|
24
|
+
writeFileSync(storePath(taskDir), JSON.stringify(store, null, 2), "utf-8");
|
|
25
|
+
}
|
|
26
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
27
|
+
/** List all comments for a task, newest first. */
|
|
28
|
+
export function listComments(taskId, dir) {
|
|
29
|
+
const taskDir = join(dir, taskId);
|
|
30
|
+
const { comments } = loadStore(taskDir);
|
|
31
|
+
return comments
|
|
32
|
+
.filter((c) => c.taskId === taskId)
|
|
33
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
34
|
+
}
|
|
35
|
+
/** Add a new comment. */
|
|
36
|
+
export function addComment(taskId, dir, data) {
|
|
37
|
+
const taskDir = join(dir, taskId);
|
|
38
|
+
ensureDir(taskDir);
|
|
39
|
+
const store = loadStore(taskDir);
|
|
40
|
+
const comment = {
|
|
41
|
+
id: `cmt-${nanoid(8)}`,
|
|
42
|
+
taskId,
|
|
43
|
+
blockId: data.blockId,
|
|
44
|
+
line: data.line ?? 1,
|
|
45
|
+
text: data.text,
|
|
46
|
+
author: data.author,
|
|
47
|
+
createdAt: new Date().toISOString(),
|
|
48
|
+
resolved: false,
|
|
49
|
+
};
|
|
50
|
+
store.comments.push(comment);
|
|
51
|
+
saveStore(taskDir, store);
|
|
52
|
+
return comment;
|
|
53
|
+
}
|
|
54
|
+
/** Delete a comment by id. Returns true if deleted. */
|
|
55
|
+
export function deleteComment(taskId, dir, commentId) {
|
|
56
|
+
const taskDir = join(dir, taskId);
|
|
57
|
+
const store = loadStore(taskDir);
|
|
58
|
+
const before = store.comments.length;
|
|
59
|
+
store.comments = store.comments.filter((c) => !(c.id === commentId && c.taskId === taskId));
|
|
60
|
+
if (store.comments.length === before)
|
|
61
|
+
return false;
|
|
62
|
+
saveStore(taskDir, store);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
/** Set resolved state of a comment. Returns updated Comment or null if not found. */
|
|
66
|
+
export function resolveComment(taskId, dir, commentId, resolved, resolvedBy, resolvedAt, resolvedReason) {
|
|
67
|
+
const taskDir = join(dir, taskId);
|
|
68
|
+
const store = loadStore(taskDir);
|
|
69
|
+
const idx = store.comments.findIndex((c) => c.id === commentId && c.taskId === taskId);
|
|
70
|
+
if (idx === -1)
|
|
71
|
+
return null;
|
|
72
|
+
const now = resolvedAt ?? new Date().toISOString();
|
|
73
|
+
store.comments[idx] = {
|
|
74
|
+
...store.comments[idx],
|
|
75
|
+
resolved,
|
|
76
|
+
...(resolved ? { resolvedBy: resolvedBy ?? store.comments[idx].author, resolvedAt: now, resolvedReason } : {}),
|
|
77
|
+
};
|
|
78
|
+
saveStore(taskDir, store);
|
|
79
|
+
return store.comments[idx];
|
|
80
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// OpenKan — docs file tree and read helpers for <projectRoot>/docs/
|
|
2
|
+
import { existsSync, readFileSync, statSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join, resolve, sep } from "node:path";
|
|
4
|
+
import { renderMdx } from "./mdx-render.js";
|
|
5
|
+
// ─── Path safety ─────────────────────────────────────────────────────────────
|
|
6
|
+
/**
|
|
7
|
+
* Reject any path that escapes the docs root (contains ".." or resolves outside).
|
|
8
|
+
*/
|
|
9
|
+
function isSafeRelPath(docsRoot, relPath) {
|
|
10
|
+
if (relPath.includes(".."))
|
|
11
|
+
return false;
|
|
12
|
+
const full = resolve(docsRoot, relPath);
|
|
13
|
+
const normalizedRoot = resolve(docsRoot);
|
|
14
|
+
// full must equal the root exactly OR be inside it (start with root + /)
|
|
15
|
+
if (full !== normalizedRoot && !full.startsWith(normalizedRoot + sep))
|
|
16
|
+
return false;
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
// ─── listDocs ────────────────────────────────────────────────────────────────
|
|
20
|
+
function buildEntry(docsRoot, relPath, depth, maxDepth) {
|
|
21
|
+
const full = join(docsRoot, relPath);
|
|
22
|
+
if (!existsSync(full))
|
|
23
|
+
return null;
|
|
24
|
+
let st;
|
|
25
|
+
try {
|
|
26
|
+
st = statSync(full);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const name = relPath.split("/").pop() ?? relPath;
|
|
32
|
+
if (!st.isDirectory()) {
|
|
33
|
+
return {
|
|
34
|
+
path: relPath,
|
|
35
|
+
name,
|
|
36
|
+
isDir: false,
|
|
37
|
+
size: st.size,
|
|
38
|
+
modified: st.mtime.toISOString(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// Directory
|
|
42
|
+
const children = [];
|
|
43
|
+
if (depth < maxDepth) {
|
|
44
|
+
let entries;
|
|
45
|
+
try {
|
|
46
|
+
entries = readdirSync(full);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
entries = [];
|
|
50
|
+
}
|
|
51
|
+
for (const entry of entries.sort()) {
|
|
52
|
+
// Skip hidden files/dirs
|
|
53
|
+
if (entry.startsWith("."))
|
|
54
|
+
continue;
|
|
55
|
+
const childRel = relPath ? `${relPath}/${entry}` : entry;
|
|
56
|
+
const child = buildEntry(docsRoot, childRel, depth + 1, maxDepth);
|
|
57
|
+
if (child)
|
|
58
|
+
children.push(child);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
path: relPath,
|
|
63
|
+
name,
|
|
64
|
+
isDir: true,
|
|
65
|
+
children,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* List the docs directory tree.
|
|
70
|
+
* @param opts.root - project root
|
|
71
|
+
* @param opts.docsDir - defaults to "docs"
|
|
72
|
+
* @param opts.maxDepth - defaults to 4
|
|
73
|
+
*/
|
|
74
|
+
export function listDocs(opts) {
|
|
75
|
+
const docsRoot = join(opts.root, opts.docsDir ?? "docs");
|
|
76
|
+
const maxDepth = opts.maxDepth ?? 4;
|
|
77
|
+
if (!existsSync(docsRoot))
|
|
78
|
+
return { entries: [] };
|
|
79
|
+
if (!isSafeRelPath(docsRoot, "."))
|
|
80
|
+
return { entries: [] };
|
|
81
|
+
let entries = [];
|
|
82
|
+
let dirEntries;
|
|
83
|
+
try {
|
|
84
|
+
dirEntries = readdirSync(docsRoot);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return { entries: [] };
|
|
88
|
+
}
|
|
89
|
+
for (const entry of dirEntries.sort()) {
|
|
90
|
+
if (entry.startsWith("."))
|
|
91
|
+
continue;
|
|
92
|
+
const child = buildEntry(docsRoot, entry, 1, maxDepth);
|
|
93
|
+
if (child)
|
|
94
|
+
entries.push(child);
|
|
95
|
+
}
|
|
96
|
+
return { entries };
|
|
97
|
+
}
|
|
98
|
+
// ─── readDoc ─────────────────────────────────────────────────────────────────
|
|
99
|
+
/**
|
|
100
|
+
* Read a single doc file, optionally render it.
|
|
101
|
+
* @param opts.root - project root
|
|
102
|
+
* @param opts.relPath - relative to docs root, no leading slash
|
|
103
|
+
* @param opts.render - default true for .md/.mdx
|
|
104
|
+
*/
|
|
105
|
+
export async function readDoc(opts) {
|
|
106
|
+
const docsRoot = join(opts.root, opts.docsDir ?? "docs");
|
|
107
|
+
// Security check: reject traversal attempts
|
|
108
|
+
if (!isSafeRelPath(docsRoot, opts.relPath)) {
|
|
109
|
+
throw new Error("Unsafe path");
|
|
110
|
+
}
|
|
111
|
+
const fullPath = resolve(docsRoot, opts.relPath);
|
|
112
|
+
if (!existsSync(fullPath))
|
|
113
|
+
throw new Error("File not found");
|
|
114
|
+
let st;
|
|
115
|
+
try {
|
|
116
|
+
st = statSync(fullPath);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
throw new Error("File not found");
|
|
120
|
+
}
|
|
121
|
+
if (!st.isFile())
|
|
122
|
+
throw new Error("Not a file");
|
|
123
|
+
const raw = readFileSync(fullPath, "utf-8");
|
|
124
|
+
const ext = fullPath.split(".").pop()?.toLowerCase() ?? "";
|
|
125
|
+
const doRender = (opts.render ?? true) && (ext === "md" || ext === "mdx");
|
|
126
|
+
const result = {
|
|
127
|
+
path: opts.relPath,
|
|
128
|
+
mtime: st.mtime.toISOString(),
|
|
129
|
+
size: st.size,
|
|
130
|
+
};
|
|
131
|
+
if (doRender) {
|
|
132
|
+
const rendered = await renderMdx(raw);
|
|
133
|
+
result.html = rendered.html;
|
|
134
|
+
result.rendered = rendered.html; // back-compat alias
|
|
135
|
+
result.blocks = rendered.blocks;
|
|
136
|
+
result.raw = raw; // keep raw available even in rendered mode
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
// Raw mode: caller handles content-type; return minimal shape
|
|
140
|
+
result.raw = raw;
|
|
141
|
+
result.contentType = "text/markdown";
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// kanban/fs.ts — file-system helpers for the browser
|
|
2
|
+
import { readdirSync, statSync, lstatSync, realpathSync } from "node:fs";
|
|
3
|
+
import { join, basename, resolve } from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
|
6
|
+
/** Realpath but returns undefined on error (e.g. broken symlink). */
|
|
7
|
+
function safeRealpath(p) {
|
|
8
|
+
try {
|
|
9
|
+
return realpathSync(p);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** Build a single FsEntry from a path (without children). */
|
|
16
|
+
function entryFromPath(absPath) {
|
|
17
|
+
try {
|
|
18
|
+
// Use lstatSync to detect symlinks without following them
|
|
19
|
+
const lst = lstatSync(absPath, { throwIfNoEntry: false });
|
|
20
|
+
if (!lst)
|
|
21
|
+
return null;
|
|
22
|
+
const isSymlink = lst.isSymbolicLink();
|
|
23
|
+
// Use statSync to get the type of the target (follows symlinks)
|
|
24
|
+
const st = statSync(absPath, { throwIfNoEntry: false });
|
|
25
|
+
if (!st)
|
|
26
|
+
return null;
|
|
27
|
+
return {
|
|
28
|
+
name: basename(absPath),
|
|
29
|
+
path: absPath,
|
|
30
|
+
isDir: st.isDirectory(),
|
|
31
|
+
isFile: st.isFile(),
|
|
32
|
+
isSymlink,
|
|
33
|
+
size: st.size,
|
|
34
|
+
mtime: st.mtime.toISOString(),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Scan a single directory and return sorted FsEntry[] (no children).
|
|
43
|
+
* Caps at maxEntries.
|
|
44
|
+
*/
|
|
45
|
+
function scanDir(dirPath, includeHidden, maxEntries) {
|
|
46
|
+
let entries;
|
|
47
|
+
try {
|
|
48
|
+
entries = readdirSync(dirPath, { encoding: "utf8", withFileTypes: false });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
const result = [];
|
|
54
|
+
const sorted = [...entries].sort();
|
|
55
|
+
for (const name of sorted) {
|
|
56
|
+
if (result.length >= maxEntries)
|
|
57
|
+
break;
|
|
58
|
+
// Skip . and .. entries returned by readdirSync
|
|
59
|
+
if (name === "." || name === "..")
|
|
60
|
+
continue;
|
|
61
|
+
if (!includeHidden && name.startsWith("."))
|
|
62
|
+
continue;
|
|
63
|
+
const absPath = join(dirPath, name);
|
|
64
|
+
const entry = entryFromPath(absPath);
|
|
65
|
+
if (entry)
|
|
66
|
+
result.push(entry);
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
// ─── Security helpers ─────────────────────────────────────────────────────────
|
|
71
|
+
const DENY_LIST = ["/etc", "/proc", "/sys", "/dev", "/boot", "/root"];
|
|
72
|
+
/** Check if a path starts with a deny-listed prefix. */
|
|
73
|
+
export function isDenyListed(path) {
|
|
74
|
+
const abs = resolve(path);
|
|
75
|
+
return DENY_LIST.some(prefix => abs === prefix || abs.startsWith(prefix + "/"));
|
|
76
|
+
}
|
|
77
|
+
/** Canonicalize a symlink and check if it resolves inside an allowed tree. */
|
|
78
|
+
export function realPathIfAllowed(path) {
|
|
79
|
+
const abs = resolve(path);
|
|
80
|
+
const real = safeRealpath(abs);
|
|
81
|
+
if (!real)
|
|
82
|
+
return { realPath: abs, allowed: false };
|
|
83
|
+
if (isDenyListed(real))
|
|
84
|
+
return { realPath: real, allowed: false };
|
|
85
|
+
return { realPath: real, allowed: true };
|
|
86
|
+
}
|
|
87
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
88
|
+
/**
|
|
89
|
+
* Walk a directory tree up to `depth` levels.
|
|
90
|
+
* - Hidden files (starting with `.`) excluded by default.
|
|
91
|
+
* - Symlinks are noted but NOT followed (security).
|
|
92
|
+
* - Each directory caps at `maxEntries` entries.
|
|
93
|
+
*/
|
|
94
|
+
export async function listFs(opts) {
|
|
95
|
+
const { root, depth = 1, includeHidden = false, followSymlinks = false, maxEntries = 500, } = opts;
|
|
96
|
+
const absRoot = resolve(root);
|
|
97
|
+
function buildEntry(absPath, currentDepth) {
|
|
98
|
+
const entry = entryFromPath(absPath);
|
|
99
|
+
if (!entry)
|
|
100
|
+
return null;
|
|
101
|
+
if (entry.isDir && currentDepth < depth) {
|
|
102
|
+
const children = scanDir(absPath, includeHidden, maxEntries);
|
|
103
|
+
entry.children = children.flatMap(child => {
|
|
104
|
+
const built = buildEntry(child.path, currentDepth + 1);
|
|
105
|
+
return built !== null ? [built] : [];
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return entry;
|
|
109
|
+
}
|
|
110
|
+
const rootEntry = buildEntry(absRoot, 0);
|
|
111
|
+
if (!rootEntry) {
|
|
112
|
+
return {
|
|
113
|
+
name: basename(absRoot),
|
|
114
|
+
path: absRoot,
|
|
115
|
+
isDir: false,
|
|
116
|
+
isFile: false,
|
|
117
|
+
isSymlink: false,
|
|
118
|
+
size: 0,
|
|
119
|
+
mtime: new Date(0).toISOString(),
|
|
120
|
+
children: [],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return rootEntry;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Return the user's home directory and its top-level entries (depth 1).
|
|
127
|
+
*/
|
|
128
|
+
export async function readHome() {
|
|
129
|
+
const home = os.homedir();
|
|
130
|
+
const absHome = resolve(home);
|
|
131
|
+
const rootEntry = await listFs({
|
|
132
|
+
root: absHome,
|
|
133
|
+
depth: 1,
|
|
134
|
+
includeHidden: false,
|
|
135
|
+
followSymlinks: false,
|
|
136
|
+
maxEntries: 500,
|
|
137
|
+
});
|
|
138
|
+
return {
|
|
139
|
+
home: absHome,
|
|
140
|
+
entries: rootEntry.children ?? [],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Return ancestor entries for `path` up to `maxDepth` levels (each depth 0, no children).
|
|
145
|
+
* Used for breadcrumb display.
|
|
146
|
+
* Note: The path itself is NOT included — only its parent directories.
|
|
147
|
+
*/
|
|
148
|
+
export function parents(path, maxDepth) {
|
|
149
|
+
const absPath = resolve(path);
|
|
150
|
+
// Split by "/" to get segments (works on all platforms since absPath uses forward slashes)
|
|
151
|
+
const raw = absPath.split("/").filter(Boolean);
|
|
152
|
+
// raw = ["home", "drb0rk", "Projects", "openkan", ".ok"] for /home/drb0rk/Projects/openkan/.ok
|
|
153
|
+
const result = [];
|
|
154
|
+
// Build each ancestor by accumulating segments from root
|
|
155
|
+
for (let i = 0; i < raw.length - 1 && result.length < maxDepth; i++) {
|
|
156
|
+
// Skip raw.length-1 (the leaf) so we only get ancestors
|
|
157
|
+
const accumulated = "/" + raw.slice(0, i + 1).join("/");
|
|
158
|
+
const entry = entryFromPath(accumulated);
|
|
159
|
+
if (entry)
|
|
160
|
+
result.push(entry);
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// OpenKan — pure git utilities (read-only).
|
|
2
|
+
import { spawnSync } from "child_process";
|
|
3
|
+
const GIT_TIMEOUT_MS = 5000;
|
|
4
|
+
function git(args, root) {
|
|
5
|
+
try {
|
|
6
|
+
const result = spawnSync("git", args, {
|
|
7
|
+
cwd: root,
|
|
8
|
+
timeout: GIT_TIMEOUT_MS,
|
|
9
|
+
encoding: "utf-8",
|
|
10
|
+
shell: false,
|
|
11
|
+
});
|
|
12
|
+
if (result.error || result.status !== 0)
|
|
13
|
+
return "";
|
|
14
|
+
return (result.stdout ?? "");
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// ─── Basic checks ──────────────────────────────────────────────────────────
|
|
21
|
+
export function isGitRepo(root) {
|
|
22
|
+
try {
|
|
23
|
+
const result = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
24
|
+
cwd: root,
|
|
25
|
+
timeout: GIT_TIMEOUT_MS,
|
|
26
|
+
encoding: "utf-8",
|
|
27
|
+
});
|
|
28
|
+
return (result.stdout ?? "").trim() === "true";
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function currentUser(root) {
|
|
35
|
+
// Read ONLY local config to avoid inheriting from global ~/.gitconfig
|
|
36
|
+
const name = git(["config", "--local", "user.name"], root).trim();
|
|
37
|
+
const email = git(["config", "--local", "user.email"], root).trim();
|
|
38
|
+
if (!name || !email)
|
|
39
|
+
return null;
|
|
40
|
+
return { name, email };
|
|
41
|
+
}
|
|
42
|
+
// ─── Contributors ──────────────────────────────────────────────────────────
|
|
43
|
+
export function listContributors(root, opts) {
|
|
44
|
+
// Use git log to enumerate commits with author info, then group by email
|
|
45
|
+
const args = ["log", "--no-merges", "--format=%aI|%an|%ae", "--use-mailmap"];
|
|
46
|
+
if (opts?.since)
|
|
47
|
+
args.push(`--since=${opts.since}`);
|
|
48
|
+
if (opts?.until)
|
|
49
|
+
args.push(`--until=${opts.until}`);
|
|
50
|
+
if (opts?.maxCount)
|
|
51
|
+
args.push(`--max-count=${opts.maxCount}`);
|
|
52
|
+
const output = git(args, root);
|
|
53
|
+
if (!output)
|
|
54
|
+
return [];
|
|
55
|
+
// Group by email
|
|
56
|
+
const byEmail = new Map();
|
|
57
|
+
for (const line of output.split("\n")) {
|
|
58
|
+
const trimmed = line.trim();
|
|
59
|
+
if (!trimmed)
|
|
60
|
+
continue;
|
|
61
|
+
const idx = trimmed.indexOf("|");
|
|
62
|
+
if (idx === -1)
|
|
63
|
+
continue;
|
|
64
|
+
const ts = trimmed.slice(0, idx);
|
|
65
|
+
const rest = trimmed.slice(idx + 1);
|
|
66
|
+
const nidx = rest.indexOf("|");
|
|
67
|
+
if (nidx === -1)
|
|
68
|
+
continue;
|
|
69
|
+
const name = rest.slice(0, nidx);
|
|
70
|
+
const email = rest.slice(nidx + 1);
|
|
71
|
+
if (!byEmail.has(email)) {
|
|
72
|
+
byEmail.set(email, { name, emails: new Set([email]), dates: [] });
|
|
73
|
+
}
|
|
74
|
+
byEmail.get(email).dates.push(ts);
|
|
75
|
+
}
|
|
76
|
+
const contributors = [];
|
|
77
|
+
for (const [email, info] of byEmail) {
|
|
78
|
+
const dates = info.dates.sort();
|
|
79
|
+
contributors.push({
|
|
80
|
+
name: info.name,
|
|
81
|
+
email,
|
|
82
|
+
commits: info.dates.length,
|
|
83
|
+
firstSeen: dates[0] ?? new Date(0).toISOString(),
|
|
84
|
+
lastSeen: dates[dates.length - 1] ?? new Date().toISOString(),
|
|
85
|
+
linesAdded: 0,
|
|
86
|
+
linesDeleted: 0,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return contributors;
|
|
90
|
+
}
|
|
91
|
+
// ─── Commits ────────────────────────────────────────────────────────────────
|
|
92
|
+
export function listCommits(root, opts) {
|
|
93
|
+
// Format: each commit is preceded by --OPENKAN-SEP-- on its own line.
|
|
94
|
+
// Per commit: sha\nauthor\nemail\niso\nsubject\nbody\nfilename1\nfilename2\n
|
|
95
|
+
// Since --pretty=format with %b outputs nothing when body is empty, the
|
|
96
|
+
// number of header lines varies. We use --OPENKAN-SEP-- as an anchor:
|
|
97
|
+
// --OPENKAN-SEP--\nSHA\nAN\nAE\nTS\nSUBJECT\n%B\nFILES...\n\n
|
|
98
|
+
// But %b with empty body still consumes a \n, so files appear 6 lines after SHA
|
|
99
|
+
// regardless of whether body is empty. Use this invariant to extract files.
|
|
100
|
+
const SEP = "--OPENKAN-SEP";
|
|
101
|
+
const args = [
|
|
102
|
+
"log",
|
|
103
|
+
`--pretty=format:${SEP}%n%H%n%an%n%ae%n%aI%n%s%n%b`,
|
|
104
|
+
"--name-only",
|
|
105
|
+
"--no-merges",
|
|
106
|
+
];
|
|
107
|
+
if (opts?.since)
|
|
108
|
+
args.push(`--since=${opts.since}`);
|
|
109
|
+
if (opts?.until)
|
|
110
|
+
args.push(`--until=${opts.until}`);
|
|
111
|
+
if (opts?.maxCount)
|
|
112
|
+
args.push(`--max-count=${opts.maxCount}`);
|
|
113
|
+
if (opts?.author)
|
|
114
|
+
args.push(`--author=${opts.author}`);
|
|
115
|
+
const output = git(args, root);
|
|
116
|
+
if (!output)
|
|
117
|
+
return [];
|
|
118
|
+
// Split into commit records: "SEP\nSHA\nAN\nAE\nTS\nSUBJECT\n%B\nFILE1\nFILE2\n\n"
|
|
119
|
+
const SEP_PATTERN = `${SEP}\n`;
|
|
120
|
+
const rawRecords = output.split(SEP_PATTERN).filter(r => r.trim());
|
|
121
|
+
const commits = [];
|
|
122
|
+
for (const raw of rawRecords) {
|
|
123
|
+
const lines = raw.split("\n");
|
|
124
|
+
if (lines.length < 6)
|
|
125
|
+
continue;
|
|
126
|
+
// sha at lines[0], author at [1], email at [2], ts at [3], subject at [4]
|
|
127
|
+
// %b output at [5] (empty string if no body), then files from [6] onward
|
|
128
|
+
// until first empty line (blank separator between commits)
|
|
129
|
+
const sha = lines[0];
|
|
130
|
+
const author = lines[1];
|
|
131
|
+
const email = lines[2];
|
|
132
|
+
const ts = lines[3];
|
|
133
|
+
const subject = lines[4];
|
|
134
|
+
// body is lines[5] (may be empty string) — we use it as-is
|
|
135
|
+
const body = lines[5] ?? "";
|
|
136
|
+
// Files: lines[6] onward, until first empty string (blank line between records)
|
|
137
|
+
const fileLines = [];
|
|
138
|
+
for (let i = 6; i < lines.length; i++) {
|
|
139
|
+
if (lines[i] === "")
|
|
140
|
+
break;
|
|
141
|
+
fileLines.push(lines[i]);
|
|
142
|
+
}
|
|
143
|
+
commits.push({ sha, author, email, ts, subject, body, files: fileLines });
|
|
144
|
+
}
|
|
145
|
+
return commits;
|
|
146
|
+
}
|
|
147
|
+
// ─── Commit-to-task attribution ────────────────────────────────────────────
|
|
148
|
+
export function attributeCommitsToTasks(root, tasks, opts) {
|
|
149
|
+
const result = new Map();
|
|
150
|
+
const commits = listCommits(root, { since: opts?.since, maxCount: opts?.maxCount });
|
|
151
|
+
for (const task of tasks) {
|
|
152
|
+
result.set(task.id, []);
|
|
153
|
+
}
|
|
154
|
+
for (const commit of commits) {
|
|
155
|
+
for (const task of tasks) {
|
|
156
|
+
let matched = false;
|
|
157
|
+
// 1. Exact file match
|
|
158
|
+
if (task.source?.path) {
|
|
159
|
+
if (commit.files.includes(task.source.path)) {
|
|
160
|
+
matched = true;
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
// 2. Commit touches a file under the same directory
|
|
164
|
+
const taskDir = task.source.path.replace(/\/[^/]+$/, ""); // parent dir
|
|
165
|
+
if (taskDir && commit.files.some(f => f.startsWith(taskDir + "/"))) {
|
|
166
|
+
matched = true;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// 3. Subject or body contains task id (tsk-xxxxxxxx)
|
|
171
|
+
if (!matched && task.id) {
|
|
172
|
+
if (commit.subject.includes(task.id) || commit.body.includes(task.id)) {
|
|
173
|
+
matched = true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// 4. Subject or body contains title (case-insensitive, min 8 chars)
|
|
177
|
+
if (!matched && task.title && task.title.length >= 8) {
|
|
178
|
+
const lc = task.title.toLowerCase();
|
|
179
|
+
if (commit.subject.toLowerCase().includes(lc) || commit.body.toLowerCase().includes(lc)) {
|
|
180
|
+
matched = true;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (matched) {
|
|
184
|
+
result.get(task.id).push({
|
|
185
|
+
sha: commit.sha,
|
|
186
|
+
author: commit.author,
|
|
187
|
+
email: commit.email,
|
|
188
|
+
ts: commit.ts,
|
|
189
|
+
subject: commit.subject,
|
|
190
|
+
files: commit.files,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return result;
|
|
196
|
+
}
|