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