@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
package/dist/ok/ids.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// ok/ids.ts — id and timestamp helpers used by every planning entity.
|
|
2
|
+
//
|
|
3
|
+
// IDs follow the `<prefix>-<short>` convention: `tsk-abc12def`, `pln-…`,
|
|
4
|
+
// `prd-…`. The short suffix is a nanoid URL-safe string (8 chars by default),
|
|
5
|
+
// which gives ~47 bits of entropy — plenty for per-workspace uniqueness
|
|
6
|
+
// without becoming unreadable in listings.
|
|
7
|
+
import { nanoid } from "nanoid";
|
|
8
|
+
/** Generate a new id of the given kind, e.g. `tsk-Vn4kRp2x`. */
|
|
9
|
+
export function newId(kind, alphabetSize = 8) {
|
|
10
|
+
return `${kind}-${nanoid(alphabetSize)}`;
|
|
11
|
+
}
|
|
12
|
+
/** Strip the `<kind>-` prefix off an id, returning the suffix or `null`. */
|
|
13
|
+
export function idSuffix(id) {
|
|
14
|
+
const m = /^[a-z]{3}-([A-Za-z0-9_-]+)$/.exec(id);
|
|
15
|
+
return m ? m[1] : null;
|
|
16
|
+
}
|
|
17
|
+
/** True when `id` is a well-formed id of `kind`. */
|
|
18
|
+
export function isIdOf(id, kind) {
|
|
19
|
+
const m = new RegExp(`^${kind}-[A-Za-z0-9_-]+$`).exec(id);
|
|
20
|
+
return m !== null;
|
|
21
|
+
}
|
|
22
|
+
/** Return current ISO timestamp (millisecond precision). */
|
|
23
|
+
export function nowIso() {
|
|
24
|
+
return new Date().toISOString();
|
|
25
|
+
}
|
|
26
|
+
/** Compare two ISO timestamps lexicographically. */
|
|
27
|
+
export function isoCompare(a, b) {
|
|
28
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
29
|
+
}
|
|
30
|
+
export function parseArgs(argv) {
|
|
31
|
+
const positionals = [];
|
|
32
|
+
const flags = {};
|
|
33
|
+
for (let i = 0; i < argv.length; i++) {
|
|
34
|
+
const tok = argv[i];
|
|
35
|
+
if (!tok.startsWith("--")) {
|
|
36
|
+
positionals.push(tok);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const eq = tok.indexOf("=");
|
|
40
|
+
let name;
|
|
41
|
+
let inline;
|
|
42
|
+
if (eq !== -1) {
|
|
43
|
+
name = tok.slice(2, eq);
|
|
44
|
+
inline = tok.slice(eq + 1);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
name = tok.slice(2);
|
|
48
|
+
inline = undefined;
|
|
49
|
+
}
|
|
50
|
+
if (inline !== undefined) {
|
|
51
|
+
flags[name] = inline;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const next = argv[i + 1];
|
|
55
|
+
if (next === undefined || next.startsWith("--")) {
|
|
56
|
+
flags[name] = true;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
flags[name] = next;
|
|
60
|
+
i++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { positionals, flags };
|
|
64
|
+
}
|
|
65
|
+
/** Read a string flag. Returns `undefined` when missing or boolean-shaped. */
|
|
66
|
+
export function flagString(flags, name) {
|
|
67
|
+
const v = flags[name];
|
|
68
|
+
return typeof v === "string" ? v : undefined;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Read a CSV-style flag, splitting on either `,` or `|`. `|` is the
|
|
72
|
+
* canonical separator for prd goals / milestones per the design contract;
|
|
73
|
+
* `,` matches the more common shell convention for scope/deps/acceptance.
|
|
74
|
+
*/
|
|
75
|
+
export function flagCsv(flags, name) {
|
|
76
|
+
const v = flags[name];
|
|
77
|
+
if (typeof v !== "string")
|
|
78
|
+
return [];
|
|
79
|
+
return v
|
|
80
|
+
.split(/[,|]/)
|
|
81
|
+
.map((s) => s.trim())
|
|
82
|
+
.filter((s) => s.length > 0);
|
|
83
|
+
}
|
|
84
|
+
/** Read a boolean flag (present at all). */
|
|
85
|
+
export function flagBool(flags, name) {
|
|
86
|
+
return flags[name] !== undefined;
|
|
87
|
+
}
|
|
88
|
+
/** Wrap an async main so callers don't need to repeat the boilerplate. */
|
|
89
|
+
export async function runMain(fn) {
|
|
90
|
+
try {
|
|
91
|
+
const code = await fn();
|
|
92
|
+
process.exit(code);
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
process.stderr.write(`ok: ${e?.message ?? e}\n`);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
}
|
package/dist/ok/lock.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// ok/lock.ts — per-task claim/heartbeat/release lock protocol.
|
|
2
|
+
//
|
|
3
|
+
// Locks are advisory JSON files under `.ok/locks/`. They prevent the
|
|
4
|
+
// most common multi-agent failure (two writers updating the same task
|
|
5
|
+
// at once) without requiring native fcntl or OS-level coordination.
|
|
6
|
+
//
|
|
7
|
+
// Protocol:
|
|
8
|
+
// claim(id, owner, leaseMs) -> writes .ok/locks/<id>.lock with
|
|
9
|
+
// {owner, leasedUntil}. If a lock already exists held by a *different*
|
|
10
|
+
// non-expired owner, claim throws. If the existing owner is "us"
|
|
11
|
+
// (same string), heartbeat semantics apply.
|
|
12
|
+
// heartbeat(id, owner, leaseMs) -> same write, refreshes leasedUntil.
|
|
13
|
+
// Throws if the lock is not held by `owner`.
|
|
14
|
+
// release(id, owner) -> removes the lock if held by `owner`.
|
|
15
|
+
// inspect(id) -> returns the current Lock | undefined.
|
|
16
|
+
//
|
|
17
|
+
// Expiration is checked against `now`. A lock whose `leasedUntil` is
|
|
18
|
+
// in the past is treated as if it didn't exist — another writer can
|
|
19
|
+
// claim it.
|
|
20
|
+
import { promises as fs } from "node:fs";
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
import { nowIso } from "./ids.js";
|
|
23
|
+
const DEFAULT_LEASE_MS = 60 * 60 * 1000;
|
|
24
|
+
export class LockHeldError extends Error {
|
|
25
|
+
currentOwner;
|
|
26
|
+
leasedUntil;
|
|
27
|
+
constructor(currentOwner, leasedUntil) {
|
|
28
|
+
super(`locked by ${currentOwner} until ${leasedUntil}`);
|
|
29
|
+
this.name = "LockHeldError";
|
|
30
|
+
this.currentOwner = currentOwner;
|
|
31
|
+
this.leasedUntil = leasedUntil;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export class LockNotHeldError extends Error {
|
|
35
|
+
constructor(id, owner) {
|
|
36
|
+
super(`lock for ${id} is not held by ${owner}`);
|
|
37
|
+
this.name = "LockNotHeldError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function lockPath(p, id) {
|
|
41
|
+
if (!/^tsk-[A-Za-z0-9_-]+$/.test(id))
|
|
42
|
+
throw new Error(`invalid task id for lock: ${id}`);
|
|
43
|
+
return path.join(p.locksDir, `${id}.lock`);
|
|
44
|
+
}
|
|
45
|
+
export async function inspect(p, id) {
|
|
46
|
+
try {
|
|
47
|
+
const raw = await fs.readFile(lockPath(p, id), "utf-8");
|
|
48
|
+
const obj = JSON.parse(raw);
|
|
49
|
+
if (typeof obj.owner !== "string" || typeof obj.leasedUntil !== "string")
|
|
50
|
+
return undefined;
|
|
51
|
+
return {
|
|
52
|
+
owner: obj.owner,
|
|
53
|
+
leasedUntil: obj.leasedUntil,
|
|
54
|
+
acquiredAt: typeof obj.acquiredAt === "string" ? obj.acquiredAt : undefined,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
if (e?.code === "ENOENT")
|
|
59
|
+
return undefined;
|
|
60
|
+
throw e;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function isExpired(lock, now = Date.now()) {
|
|
64
|
+
const t = Date.parse(lock.leasedUntil);
|
|
65
|
+
if (Number.isNaN(t))
|
|
66
|
+
return true;
|
|
67
|
+
return t <= now;
|
|
68
|
+
}
|
|
69
|
+
async function writeLockFile(filePath, body) {
|
|
70
|
+
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
71
|
+
const data = JSON.stringify(body, null, 2);
|
|
72
|
+
let fh;
|
|
73
|
+
try {
|
|
74
|
+
fh = await fs.open(tmp, "w");
|
|
75
|
+
await fh.writeFile(data, "utf-8");
|
|
76
|
+
await fh.sync();
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
if (fh)
|
|
80
|
+
await fh.close();
|
|
81
|
+
}
|
|
82
|
+
await fs.rename(tmp, filePath);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Acquire (or refresh) a lock for `id`. If the lock is absent, expired,
|
|
86
|
+
* or already owned by `owner`, succeeds. If owned by a *different*
|
|
87
|
+
* non-expired holder, throws `LockHeldError`.
|
|
88
|
+
*/
|
|
89
|
+
export async function claim(p, id, owner, opts = {}) {
|
|
90
|
+
if (!owner)
|
|
91
|
+
throw new Error("claim requires an owner");
|
|
92
|
+
const leaseMs = opts.leaseMs ?? DEFAULT_LEASE_MS;
|
|
93
|
+
const filePath = lockPath(p, id);
|
|
94
|
+
await fs.mkdir(p.locksDir, { recursive: true });
|
|
95
|
+
const existing = await inspect(p, id);
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
const target = {
|
|
98
|
+
owner,
|
|
99
|
+
leasedUntil: new Date(now + leaseMs).toISOString(),
|
|
100
|
+
acquiredAt: existing?.owner === owner ? existing.acquiredAt ?? nowIso() : nowIso(),
|
|
101
|
+
};
|
|
102
|
+
if (existing && existing.owner !== owner && !isExpired(existing, now)) {
|
|
103
|
+
throw new LockHeldError(existing.owner, existing.leasedUntil);
|
|
104
|
+
}
|
|
105
|
+
await writeLockFile(filePath, target);
|
|
106
|
+
return target;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Refresh an existing lock's lease. Throws if the lock is absent or
|
|
110
|
+
* owned by a different owner.
|
|
111
|
+
*/
|
|
112
|
+
export async function heartbeat(p, id, owner, opts = {}) {
|
|
113
|
+
const existing = await inspect(p, id);
|
|
114
|
+
if (!existing || existing.owner !== owner) {
|
|
115
|
+
throw new LockNotHeldError(id, owner);
|
|
116
|
+
}
|
|
117
|
+
const leaseMs = opts.leaseMs ?? DEFAULT_LEASE_MS;
|
|
118
|
+
const target = {
|
|
119
|
+
owner,
|
|
120
|
+
leasedUntil: new Date(Date.now() + leaseMs).toISOString(),
|
|
121
|
+
acquiredAt: existing.acquiredAt ?? nowIso(),
|
|
122
|
+
};
|
|
123
|
+
await writeLockFile(lockPath(p, id), target);
|
|
124
|
+
return target;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Remove a lock owned by `owner`. No-op if not held by `owner`.
|
|
128
|
+
* Returns true if the lock was removed.
|
|
129
|
+
*/
|
|
130
|
+
export async function release(p, id, owner) {
|
|
131
|
+
const existing = await inspect(p, id);
|
|
132
|
+
if (!existing)
|
|
133
|
+
return false;
|
|
134
|
+
if (existing.owner !== owner)
|
|
135
|
+
return false;
|
|
136
|
+
try {
|
|
137
|
+
await fs.unlink(lockPath(p, id));
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
if (e?.code !== "ENOENT")
|
|
141
|
+
throw e;
|
|
142
|
+
}
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Assert that the lock is either absent, expired, or owned by `owner`.
|
|
147
|
+
* Used by task transitions that may have been pre-claimed.
|
|
148
|
+
*/
|
|
149
|
+
export async function assertUsable(p, id, owner) {
|
|
150
|
+
const existing = await inspect(p, id);
|
|
151
|
+
if (!existing || isExpired(existing))
|
|
152
|
+
return;
|
|
153
|
+
if (existing.owner !== owner) {
|
|
154
|
+
throw new LockHeldError(existing.owner, existing.leasedUntil);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// ok/migrate.ts — one-shot import from a legacy `.ok/` workspace into the
|
|
2
|
+
// new `.ok/` schema. Idempotent: rerunning reports what was newly imported
|
|
3
|
+
// (i.e. what wasn't already in `.ok/`).
|
|
4
|
+
//
|
|
5
|
+
// Mapping:
|
|
6
|
+
// .ok/tasks.json -> .ok/tasks/<id>.json (Task)
|
|
7
|
+
// .ok/tasks/<id>/task.mdx -> .ok/tasks/<id>.json (description/body)
|
|
8
|
+
// .ok/tasks/<id>/comments.json -> .ok/tasks/<id>.json#comments (inlined evidence)
|
|
9
|
+
// .ok/board.json -> index entries only (column/order state is folded
|
|
10
|
+
// into Task.status / acceptance).
|
|
11
|
+
//
|
|
12
|
+
// Status mapping:
|
|
13
|
+
// board.json.state = "done" -> ok Task.status = "done"
|
|
14
|
+
// board.json.state = "running" -> ok Task.status = "in_progress"
|
|
15
|
+
// board.json.state = "waiting-for-input" -> ok Task.status = "in_progress"
|
|
16
|
+
// board.json.state = "cancelled" -> ok Task.status = "cancelled"
|
|
17
|
+
// board.json.state = "failed" -> ok Task.status = "cancelled"
|
|
18
|
+
// otherwise -> ok Task.status = "pending" (and column != backlog/todo)
|
|
19
|
+
import { promises as fs } from "node:fs";
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
import { newId, nowIso } from "./ids.js";
|
|
22
|
+
import { initIfMissing, readTask, writeTask, } from "./storage.js";
|
|
23
|
+
function mapStatus(t) {
|
|
24
|
+
const s = (t.state ?? "").toLowerCase();
|
|
25
|
+
if (s === "done")
|
|
26
|
+
return "done";
|
|
27
|
+
if (s === "running" || s === "waiting-for-input")
|
|
28
|
+
return "in_progress";
|
|
29
|
+
if (s === "cancelled" || s === "failed")
|
|
30
|
+
return "cancelled";
|
|
31
|
+
// Use column to infer: review -> review; doing -> in_progress; everything
|
|
32
|
+
// else (backlog, todo) stays pending.
|
|
33
|
+
const col = (t.column ?? "").toLowerCase();
|
|
34
|
+
if (col === "review")
|
|
35
|
+
return "review";
|
|
36
|
+
if (col === "doing")
|
|
37
|
+
return "in_progress";
|
|
38
|
+
return "pending";
|
|
39
|
+
}
|
|
40
|
+
function mapPriority(p) {
|
|
41
|
+
if (!p)
|
|
42
|
+
return undefined;
|
|
43
|
+
if (p === "urgent" || p === "high")
|
|
44
|
+
return "p0";
|
|
45
|
+
if (p === "normal")
|
|
46
|
+
return "p2";
|
|
47
|
+
if (p === "low")
|
|
48
|
+
return "p3";
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
function deriveNewId(legacy) {
|
|
52
|
+
const original = (legacy.id ?? "").trim();
|
|
53
|
+
// Old IDs are 8-char base-N strings (e.g. "bug3kx7p"). Reuse when well-formed
|
|
54
|
+
// to keep URLs / log references stable.
|
|
55
|
+
if (/^[A-Za-z0-9_-]{6,16}$/.test(original) && !original.startsWith("tsk-")) {
|
|
56
|
+
return `tsk-${original}`;
|
|
57
|
+
}
|
|
58
|
+
if (/^tsk-[A-Za-z0-9_-]+$/.test(original))
|
|
59
|
+
return original;
|
|
60
|
+
return newId("tsk");
|
|
61
|
+
}
|
|
62
|
+
async function importTaskFromJson(p, legacy, openkanDir) {
|
|
63
|
+
const id = deriveNewId(legacy);
|
|
64
|
+
const existing = await readTask(p, id);
|
|
65
|
+
if (existing)
|
|
66
|
+
return { id, created: false };
|
|
67
|
+
const task = {
|
|
68
|
+
schema: "ok.task.v1",
|
|
69
|
+
id,
|
|
70
|
+
title: (legacy.title ?? "untitled").slice(0, 200),
|
|
71
|
+
status: mapStatus(legacy),
|
|
72
|
+
createdAt: legacy.createdAt ?? nowIso(),
|
|
73
|
+
updatedAt: legacy.updatedAt ?? nowIso(),
|
|
74
|
+
};
|
|
75
|
+
if (legacy.agent)
|
|
76
|
+
task.owner = legacy.agent;
|
|
77
|
+
// Legacy Task type does not have `description`; pull from the .mdx body
|
|
78
|
+
// if available so migrated tasks aren't blank. Resolve relative to the
|
|
79
|
+
// openkanDir (not process.cwd) so the migration is portable.
|
|
80
|
+
if (legacy.mdxPath) {
|
|
81
|
+
const mdxAbs = path.isAbsolute(legacy.mdxPath)
|
|
82
|
+
? legacy.mdxPath
|
|
83
|
+
: path.join(openkanDir, legacy.mdxPath);
|
|
84
|
+
try {
|
|
85
|
+
const raw = await fs.readFile(mdxAbs, "utf-8");
|
|
86
|
+
const fmStripped = raw.replace(/^---[\s\S]*?---\n?/, "");
|
|
87
|
+
if (fmStripped.trim().length > 0)
|
|
88
|
+
task.description = fmStripped.trim();
|
|
89
|
+
}
|
|
90
|
+
catch { /* optional */ }
|
|
91
|
+
}
|
|
92
|
+
const p2 = mapPriority(legacy.priority);
|
|
93
|
+
if (p2)
|
|
94
|
+
task.priority = p2;
|
|
95
|
+
if (Array.isArray(legacy.tags) && legacy.tags.length)
|
|
96
|
+
task.scopes = legacy.tags;
|
|
97
|
+
await writeTask(p, task);
|
|
98
|
+
return { id, created: true };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Run the migration in `root`. Returns counts and the list of imported
|
|
102
|
+
* task ids. If `.ok/` does not exist, returns zero-import result.
|
|
103
|
+
*/
|
|
104
|
+
export async function migrateFromOpenkan(root) {
|
|
105
|
+
const okPaths = await initIfMissing(root);
|
|
106
|
+
// Source: the legacy `.openkan/` directory. Even after the project itself
|
|
107
|
+
// migrated, this entry point is still useful for users that keep `.openkan/`
|
|
108
|
+
// data alongside an old `.ok/` installation (e.g. on shared filesystems).
|
|
109
|
+
const openkanDir = path.join(root, ".openkan");
|
|
110
|
+
let tasksJsonRaw;
|
|
111
|
+
try {
|
|
112
|
+
tasksJsonRaw = await fs.readFile(path.join(openkanDir, "tasks.json"), "utf-8");
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
if (e?.code !== "ENOENT")
|
|
116
|
+
throw e;
|
|
117
|
+
}
|
|
118
|
+
let boardJsonRaw;
|
|
119
|
+
try {
|
|
120
|
+
boardJsonRaw = await fs.readFile(path.join(openkanDir, "board.json"), "utf-8");
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
if (e?.code !== "ENOENT")
|
|
124
|
+
throw e;
|
|
125
|
+
}
|
|
126
|
+
const imported = [];
|
|
127
|
+
const skipped = [];
|
|
128
|
+
const seen = new Set();
|
|
129
|
+
const fromOpenkan = openkanDir;
|
|
130
|
+
if (tasksJsonRaw) {
|
|
131
|
+
const parsed = JSON.parse(tasksJsonRaw);
|
|
132
|
+
for (const legacy of parsed.tasks ?? []) {
|
|
133
|
+
const id = deriveNewId(legacy);
|
|
134
|
+
if (seen.has(id))
|
|
135
|
+
continue;
|
|
136
|
+
seen.add(id);
|
|
137
|
+
const res = await importTaskFromJson(okPaths, legacy, openkanDir);
|
|
138
|
+
if (res.created)
|
|
139
|
+
imported.push(res.id);
|
|
140
|
+
else
|
|
141
|
+
skipped.push(res.id);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (boardJsonRaw) {
|
|
145
|
+
const parsed = JSON.parse(boardJsonRaw);
|
|
146
|
+
for (const legacy of parsed.tasks ?? []) {
|
|
147
|
+
if (legacy.archived)
|
|
148
|
+
continue;
|
|
149
|
+
const id = deriveNewId(legacy);
|
|
150
|
+
if (seen.has(id))
|
|
151
|
+
continue;
|
|
152
|
+
seen.add(id);
|
|
153
|
+
const res = await importTaskFromJson(okPaths, legacy, openkanDir);
|
|
154
|
+
if (res.created)
|
|
155
|
+
imported.push(res.id);
|
|
156
|
+
else
|
|
157
|
+
skipped.push(res.id);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { fromOpenkan, imported: imported.length, skipped: skipped.length, tasks: imported };
|
|
161
|
+
}
|
|
162
|
+
/** Thin CLI wrapper used by `bin/ok.ts migrate-from-openkan`. */
|
|
163
|
+
export async function cmdMigrateFromOpenkan(argv) {
|
|
164
|
+
// Accept `--path <dir>` (or `--path=<dir>`) as an explicit alias for the
|
|
165
|
+
// positional `root`. Reject a bare `--path` token (no value) with a clear
|
|
166
|
+
// error so a missing value cannot silently become the source directory.
|
|
167
|
+
const positionals = [];
|
|
168
|
+
let pathFlag;
|
|
169
|
+
for (let i = 0; i < argv.length; i++) {
|
|
170
|
+
const tok = argv[i];
|
|
171
|
+
if (tok === "--path") {
|
|
172
|
+
const next = argv[i + 1];
|
|
173
|
+
if (next === undefined || next.startsWith("--")) {
|
|
174
|
+
throw new Error("--path requires a directory argument");
|
|
175
|
+
}
|
|
176
|
+
pathFlag = next;
|
|
177
|
+
i++;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (tok.startsWith("--path=")) {
|
|
181
|
+
pathFlag = tok.slice("--path=".length);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (tok.startsWith("--") && tok !== "--list") {
|
|
185
|
+
throw new Error(`unknown flag: ${tok}`);
|
|
186
|
+
}
|
|
187
|
+
positionals.push(tok);
|
|
188
|
+
}
|
|
189
|
+
const root = pathFlag ?? positionals[0] ?? process.cwd();
|
|
190
|
+
const res = await migrateFromOpenkan(root);
|
|
191
|
+
process.stdout.write(`migrated ${res.imported} tasks from ${res.fromOpenkan} (skipped ${res.skipped} already in .ok/)\n`);
|
|
192
|
+
if (argv.includes("--list")) {
|
|
193
|
+
for (const id of res.tasks)
|
|
194
|
+
process.stdout.write(` ${id}\n`);
|
|
195
|
+
}
|
|
196
|
+
return 0;
|
|
197
|
+
}
|