@polderlabs/openkan 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +226 -0
  2. package/LICENSE +21 -0
  3. package/README.md +318 -0
  4. package/agents/openkan.md +254 -0
  5. package/bin/install-agent.mjs +63 -0
  6. package/bin/ok.mjs +17 -0
  7. package/bin/openkan.mjs +10 -0
  8. package/dist/.claude/skills/ok-planning/SKILL.md +285 -0
  9. package/dist/.claude/skills/ok-planning/references/integration.md +153 -0
  10. package/dist/.claude/skills/ok-planning/references/schemas.md +270 -0
  11. package/dist/.claude/skills/ok-planning/references/workflows.md +185 -0
  12. package/dist/.claude/skills/ok-planning/scripts/ok-init.sh +14 -0
  13. package/dist/.claude/skills/ok-planning/scripts/ok-resume.sh +38 -0
  14. package/dist/.claude/skills/ok-planning/scripts/ok-status.sh +24 -0
  15. package/dist/agents/openkan.md +254 -0
  16. package/dist/bin/install-agent.mjs +76 -0
  17. package/dist/bin/ok-install.js +58 -0
  18. package/dist/bin/ok.js +138 -0
  19. package/dist/bin/openkan.js +804 -0
  20. package/dist/commands/organize.md +15 -0
  21. package/dist/kanban/agent-profile.js +8 -0
  22. package/dist/kanban/archive.js +49 -0
  23. package/dist/kanban/bizar.js +242 -0
  24. package/dist/kanban/board.js +367 -0
  25. package/dist/kanban/bulk.js +139 -0
  26. package/dist/kanban/changelog.js +186 -0
  27. package/dist/kanban/chat.js +1280 -0
  28. package/dist/kanban/claude-state.js +974 -0
  29. package/dist/kanban/comments.js +80 -0
  30. package/dist/kanban/docs.js +144 -0
  31. package/dist/kanban/fs.js +163 -0
  32. package/dist/kanban/git.js +196 -0
  33. package/dist/kanban/images.js +140 -0
  34. package/dist/kanban/import.js +295 -0
  35. package/dist/kanban/inputs.js +94 -0
  36. package/dist/kanban/insights.js +140 -0
  37. package/dist/kanban/io.js +75 -0
  38. package/dist/kanban/mdx-render.js +348 -0
  39. package/dist/kanban/mdx.js +231 -0
  40. package/dist/kanban/projects.js +545 -0
  41. package/dist/kanban/search.js +121 -0
  42. package/dist/kanban/server.js +3296 -0
  43. package/dist/kanban/tags.js +124 -0
  44. package/dist/kanban/template.js +145 -0
  45. package/dist/kanban/tsx-sandbox.js +187 -0
  46. package/dist/kanban/watcher.js +270 -0
  47. package/dist/ok/commands/goal.js +65 -0
  48. package/dist/ok/commands/index.js +87 -0
  49. package/dist/ok/commands/init.js +15 -0
  50. package/dist/ok/commands/plan.js +155 -0
  51. package/dist/ok/commands/prd.js +202 -0
  52. package/dist/ok/commands/progress.js +31 -0
  53. package/dist/ok/commands/task.js +377 -0
  54. package/dist/ok/ids.js +98 -0
  55. package/dist/ok/lock.js +156 -0
  56. package/dist/ok/migrate.js +197 -0
  57. package/dist/ok/schemas.js +402 -0
  58. package/dist/ok/storage.js +222 -0
  59. package/dist/skills/openkan/SKILL.md +111 -0
  60. package/dist/skills/openkan/agents/openai.yaml +4 -0
  61. package/dist/skills/openkan/examples/simple-task.mdx +34 -0
  62. package/dist/skills/openkan/examples/with-ask.mdx +32 -0
  63. package/dist/skills/openkan/examples/with-choice.mdx +51 -0
  64. package/dist/skills/openkan/examples/with-preview.mdx +54 -0
  65. package/dist/skills/openkan/references/api.md +169 -0
  66. package/dist/skills/openkan/templates/task.mdx +46 -0
  67. package/dist/web/api.js +257 -0
  68. package/dist/web/app.js +4251 -0
  69. package/dist/web/bizar.js +39 -0
  70. package/dist/web/brand/agent-activity-sprite.svg +1 -0
  71. package/dist/web/brand/banner-docs.svg +24 -0
  72. package/dist/web/brand/banner.svg +32 -0
  73. package/dist/web/brand/empty-sessions.svg +17 -0
  74. package/dist/web/brand/empty-tasks.svg +17 -0
  75. package/dist/web/brand/favicon.svg +9 -0
  76. package/dist/web/brand/infinity-loader-animated.svg +220 -0
  77. package/dist/web/brand/infinity-loader-spritesheet.svg +230 -0
  78. package/dist/web/brand/logo-wordmark.svg +10 -0
  79. package/dist/web/brand/logo.svg +9 -0
  80. package/dist/web/brand/pixel-infinity-track.svg +1 -0
  81. package/dist/web/brand/social-card.svg +26 -0
  82. package/dist/web/changelog-view.js +456 -0
  83. package/dist/web/charts.js +269 -0
  84. package/dist/web/chat-sidebar.js +2397 -0
  85. package/dist/web/chat-status-motion.js +154 -0
  86. package/dist/web/claude-pane.js +820 -0
  87. package/dist/web/command-palette.js +381 -0
  88. package/dist/web/contributors-view.js +317 -0
  89. package/dist/web/cross-tab.js +102 -0
  90. package/dist/web/docs-view.js +168 -0
  91. package/dist/web/experience.css +165 -0
  92. package/dist/web/goals-view.js +45 -0
  93. package/dist/web/home-view.js +113 -0
  94. package/dist/web/images.js +311 -0
  95. package/dist/web/index.html +485 -0
  96. package/dist/web/insights.js +217 -0
  97. package/dist/web/keyboard.js +446 -0
  98. package/dist/web/mdx-viewer.js +600 -0
  99. package/dist/web/path-picker.js +787 -0
  100. package/dist/web/preview-frame.html +187 -0
  101. package/dist/web/settings.js +582 -0
  102. package/dist/web/style.css +8545 -0
  103. package/dist/web/task-view.js +1759 -0
  104. package/dist/web/vendor/gsap.min.js +11 -0
  105. package/dist/web/workspace.css +1513 -0
  106. package/package.json +71 -0
  107. package/skills/openkan/SKILL.md +111 -0
  108. package/skills/openkan/agents/openai.yaml +4 -0
  109. package/skills/openkan/examples/simple-task.mdx +34 -0
  110. package/skills/openkan/examples/with-ask.mdx +32 -0
  111. package/skills/openkan/examples/with-choice.mdx +51 -0
  112. package/skills/openkan/examples/with-preview.mdx +54 -0
  113. package/skills/openkan/references/api.md +169 -0
  114. package/skills/openkan/templates/task.mdx +46 -0
@@ -0,0 +1,402 @@
1
+ // ok/schemas.ts — TypeScript types and validators for every planning entity.
2
+ //
3
+ // The single source of truth lives in JSDoc-typed TypeScript interfaces and
4
+ // is materialised to JSON via the standalone `validate*` helpers below.
5
+ // `ok doctor` runs every persisted JSON through these helpers and reports
6
+ // the first offending path; consumers that already trust the data should
7
+ // still treat the helpers as the contract.
8
+ import { idSuffix, isoCompare, nowIso } from "./ids.js";
9
+ export function isTask(obj) {
10
+ if (typeof obj !== "object" || obj === null)
11
+ return false;
12
+ const t = obj;
13
+ if (t.schema !== "ok.task.v1")
14
+ return false;
15
+ if (typeof t.id !== "string" || !/^tsk-[A-Za-z0-9_-]+$/.test(t.id))
16
+ return false;
17
+ if (typeof t.title !== "string" || t.title.length === 0 || t.title.length > 200)
18
+ return false;
19
+ const statuses = ["pending", "in_progress", "review", "done", "cancelled"];
20
+ if (typeof t.status !== "string" || !statuses.includes(t.status))
21
+ return false;
22
+ if (!isIso(t.createdAt) || !isIso(t.updatedAt))
23
+ return false;
24
+ if (t.startedAt !== undefined && !isIso(t.startedAt))
25
+ return false;
26
+ if (t.completedAt !== undefined && !isIso(t.completedAt))
27
+ return false;
28
+ if (t.priority !== undefined && !["p0", "p1", "p2", "p3"].includes(t.priority))
29
+ return false;
30
+ if (t.description !== undefined && typeof t.description !== "string")
31
+ return false;
32
+ if (t.owner !== undefined && typeof t.owner !== "string")
33
+ return false;
34
+ if (t.plan !== undefined && (typeof t.plan !== "string" || !/^pln-/.test(t.plan)))
35
+ return false;
36
+ if (t.prd !== undefined && (typeof t.prd !== "string" || !/^prd-/.test(t.prd)))
37
+ return false;
38
+ if (t.scopes !== undefined && !isStringArray(t.scopes))
39
+ return false;
40
+ if (t.deps !== undefined && !isStringArray(t.deps))
41
+ return false;
42
+ if (t.evidence !== undefined && !isStringArray(t.evidence))
43
+ return false;
44
+ if (t.acceptance !== undefined && !isStringArray(t.acceptance))
45
+ return false;
46
+ return true;
47
+ }
48
+ export function validateTask(obj) {
49
+ if (typeof obj !== "object" || obj === null) {
50
+ return { reason: "task must be an object" };
51
+ }
52
+ const t = obj;
53
+ if (t.schema !== "ok.task.v1")
54
+ return { reason: `unknown schema ${String(t.schema)}` };
55
+ if (typeof t.id !== "string" || !/^tsk-[A-Za-z0-9_-]+$/.test(t.id)) {
56
+ return { id: typeof t.id === "string" ? t.id : undefined, reason: "id must match tsk-<id>" };
57
+ }
58
+ if (typeof t.title !== "string")
59
+ return { id: t.id, reason: "title must be a string" };
60
+ if (t.title.length === 0 || t.title.length > 200) {
61
+ return { id: t.id, reason: "title must be 1..200 chars" };
62
+ }
63
+ const statuses = ["pending", "in_progress", "review", "done", "cancelled"];
64
+ if (typeof t.status !== "string" || !statuses.includes(t.status)) {
65
+ return { id: t.id, reason: `status must be one of ${statuses.join("|")}` };
66
+ }
67
+ if (!isIso(t.createdAt))
68
+ return { id: t.id, reason: "createdAt must be ISO timestamp" };
69
+ if (!isIso(t.updatedAt))
70
+ return { id: t.id, reason: "updatedAt must be ISO timestamp" };
71
+ if (t.startedAt !== undefined && !isIso(t.startedAt)) {
72
+ return { id: t.id, reason: "startedAt must be ISO timestamp when present" };
73
+ }
74
+ if (t.completedAt !== undefined && !isIso(t.completedAt)) {
75
+ return { id: t.id, reason: "completedAt must be ISO timestamp when present" };
76
+ }
77
+ if (t.priority !== undefined && !["p0", "p1", "p2", "p3"].includes(t.priority)) {
78
+ return { id: t.id, reason: "priority must be p0|p1|p2|p3 when present" };
79
+ }
80
+ if (t.scopes !== undefined && !isStringArray(t.scopes)) {
81
+ return { id: t.id, reason: "scopes must be string[] when present" };
82
+ }
83
+ if (t.deps !== undefined && !isStringArray(t.deps)) {
84
+ return { id: t.id, reason: "deps must be string[] when present" };
85
+ }
86
+ if (t.evidence !== undefined && !isStringArray(t.evidence)) {
87
+ return { id: t.id, reason: "evidence must be string[] when present" };
88
+ }
89
+ if (t.acceptance !== undefined && !isStringArray(t.acceptance)) {
90
+ return { id: t.id, reason: "acceptance must be string[] when present" };
91
+ }
92
+ if (t.plan !== undefined && (typeof t.plan !== "string" || !/^pln-/.test(t.plan))) {
93
+ return { id: t.id, reason: "plan must be a pln-<id> string when present" };
94
+ }
95
+ if (t.prd !== undefined && (typeof t.prd !== "string" || !/^prd-/.test(t.prd))) {
96
+ return { id: t.id, reason: "prd must be a prd-<id> string when present" };
97
+ }
98
+ return null;
99
+ }
100
+ export function isPlan(obj) {
101
+ if (typeof obj !== "object" || obj === null)
102
+ return false;
103
+ const p = obj;
104
+ if (p.schema !== "ok.plan.v1")
105
+ return false;
106
+ if (typeof p.id !== "string" || !/^pln-[A-Za-z0-9_-]+$/.test(p.id))
107
+ return false;
108
+ if (typeof p.title !== "string" || p.title.length === 0)
109
+ return false;
110
+ if (typeof p.summary !== "string")
111
+ return false;
112
+ const statuses = ["draft", "active", "blocked", "complete", "abandoned"];
113
+ if (typeof p.status !== "string" || !statuses.includes(p.status))
114
+ return false;
115
+ if (!Array.isArray(p.tasks) || !p.tasks.every((x) => typeof x === "string"))
116
+ return false;
117
+ if (!Array.isArray(p.acceptance) || !p.acceptance.every((x) => typeof x === "string"))
118
+ return false;
119
+ if (!isIso(p.createdAt) || !isIso(p.updatedAt))
120
+ return false;
121
+ if (p.prd !== undefined && (typeof p.prd !== "string" || !/^prd-/.test(p.prd)))
122
+ return false;
123
+ if (p.phase !== undefined && typeof p.phase !== "string")
124
+ return false;
125
+ return true;
126
+ }
127
+ export function validatePlan(obj) {
128
+ if (typeof obj !== "object" || obj === null)
129
+ return { reason: "plan must be an object" };
130
+ const p = obj;
131
+ if (p.schema !== "ok.plan.v1")
132
+ return { reason: `unknown schema ${String(p.schema)}` };
133
+ if (typeof p.id !== "string" || !/^pln-[A-Za-z0-9_-]+$/.test(p.id)) {
134
+ return { id: typeof p.id === "string" ? p.id : undefined, reason: "id must match pln-<id>" };
135
+ }
136
+ if (typeof p.title !== "string" || p.title.length === 0) {
137
+ return { id: typeof p.id === "string" ? p.id : undefined, reason: "title required" };
138
+ }
139
+ if (typeof p.summary !== "string") {
140
+ return { id: p.id, reason: "summary required" };
141
+ }
142
+ const statuses = ["draft", "active", "blocked", "complete", "abandoned"];
143
+ if (typeof p.status !== "string" || !statuses.includes(p.status)) {
144
+ return { id: p.id, reason: `status must be one of ${statuses.join("|")}` };
145
+ }
146
+ if (!Array.isArray(p.tasks) || !p.tasks.every((x) => typeof x === "string")) {
147
+ return { id: p.id, reason: "tasks must be string[]" };
148
+ }
149
+ if (!Array.isArray(p.acceptance) || !p.acceptance.every((x) => typeof x === "string")) {
150
+ return { id: p.id, reason: "acceptance must be string[]" };
151
+ }
152
+ if (!isIso(p.createdAt))
153
+ return { id: p.id, reason: "createdAt must be ISO" };
154
+ if (!isIso(p.updatedAt))
155
+ return { id: p.id, reason: "updatedAt must be ISO" };
156
+ return null;
157
+ }
158
+ export function isPrd(obj) {
159
+ if (typeof obj !== "object" || obj === null)
160
+ return false;
161
+ const p = obj;
162
+ if (p.schema !== "ok.prd.v1")
163
+ return false;
164
+ if (typeof p.id !== "string" || !/^prd-[A-Za-z0-9_-]+$/.test(p.id))
165
+ return false;
166
+ if (typeof p.title !== "string" || p.title.length === 0)
167
+ return false;
168
+ if (typeof p.vision !== "string")
169
+ return false;
170
+ const statuses = ["draft", "active", "shipped", "abandoned"];
171
+ if (typeof p.status !== "string" || !statuses.includes(p.status))
172
+ return false;
173
+ if (!isIso(p.createdAt) || !isIso(p.updatedAt))
174
+ return false;
175
+ if (!Array.isArray(p.goals) || !p.goals.every(isGoal))
176
+ return false;
177
+ if (!Array.isArray(p.nonGoals) || !p.nonGoals.every((x) => typeof x === "string"))
178
+ return false;
179
+ if (!Array.isArray(p.successMetrics) || !p.successMetrics.every(isMetric))
180
+ return false;
181
+ if (!Array.isArray(p.milestones) || !p.milestones.every(isMilestone))
182
+ return false;
183
+ if (!Array.isArray(p.risks) || !p.risks.every(isRisk))
184
+ return false;
185
+ if (!Array.isArray(p.plans) || !p.plans.every((x) => typeof x === "string" && /^pln-/.test(x)))
186
+ return false;
187
+ if (!Array.isArray(p.owners) || !p.owners.every((x) => typeof x === "string"))
188
+ return false;
189
+ if (p.reviewCadence !== undefined && typeof p.reviewCadence !== "string")
190
+ return false;
191
+ if (p.nextReviewAt !== undefined && !isIso(p.nextReviewAt))
192
+ return false;
193
+ return true;
194
+ }
195
+ export function validatePrd(obj) {
196
+ if (typeof obj !== "object" || obj === null)
197
+ return { reason: "prd must be an object" };
198
+ const p = obj;
199
+ if (p.schema !== "ok.prd.v1")
200
+ return { reason: `unknown schema ${String(p.schema)}` };
201
+ if (typeof p.id !== "string" || !/^prd-[A-Za-z0-9_-]+$/.test(p.id)) {
202
+ return { id: typeof p.id === "string" ? p.id : undefined, reason: "id must match prd-<id>" };
203
+ }
204
+ if (typeof p.title !== "string" || p.title.length === 0) {
205
+ return { id: p.id, reason: "title required" };
206
+ }
207
+ if (typeof p.vision !== "string") {
208
+ return { id: p.id, reason: "vision required" };
209
+ }
210
+ const statuses = ["draft", "active", "shipped", "abandoned"];
211
+ if (typeof p.status !== "string" || !statuses.includes(p.status)) {
212
+ return { id: p.id, reason: `status must be one of ${statuses.join("|")}` };
213
+ }
214
+ if (!Array.isArray(p.goals) || !p.goals.every(isGoal)) {
215
+ return { id: p.id, reason: "goals must be well-formed" };
216
+ }
217
+ if (!Array.isArray(p.nonGoals) || !p.nonGoals.every((x) => typeof x === "string")) {
218
+ return { id: p.id, reason: "nonGoals must be string[]" };
219
+ }
220
+ if (!Array.isArray(p.successMetrics) || !p.successMetrics.every(isMetric)) {
221
+ return { id: p.id, reason: "successMetrics must be well-formed" };
222
+ }
223
+ if (!Array.isArray(p.milestones) || !p.milestones.every(isMilestone)) {
224
+ return { id: p.id, reason: "milestones must be well-formed" };
225
+ }
226
+ if (!Array.isArray(p.risks) || !p.risks.every(isRisk)) {
227
+ return { id: p.id, reason: "risks must be well-formed" };
228
+ }
229
+ if (!Array.isArray(p.plans) || !p.plans.every((x) => typeof x === "string" && /^pln-/.test(x))) {
230
+ return { id: p.id, reason: "plans must be pln-<id> strings" };
231
+ }
232
+ if (!Array.isArray(p.owners) || !p.owners.every((x) => typeof x === "string")) {
233
+ return { id: p.id, reason: "owners must be string[]" };
234
+ }
235
+ if (!isIso(p.createdAt))
236
+ return { id: p.id, reason: "createdAt must be ISO" };
237
+ if (!isIso(p.updatedAt))
238
+ return { id: p.id, reason: "updatedAt must be ISO" };
239
+ return null;
240
+ }
241
+ function isGoal(g) {
242
+ if (typeof g !== "object" || g === null)
243
+ return false;
244
+ const o = g;
245
+ if (typeof o.id !== "string" || typeof o.text !== "string")
246
+ return false;
247
+ return ["open", "in_progress", "met", "dropped"].includes(o.status);
248
+ }
249
+ function isMetric(m) {
250
+ if (typeof m !== "object" || m === null)
251
+ return false;
252
+ const o = m;
253
+ return typeof o.name === "string" && typeof o.target === "string"
254
+ && (o.current === undefined || typeof o.current === "string");
255
+ }
256
+ function isMilestone(m) {
257
+ if (typeof m !== "object" || m === null)
258
+ return false;
259
+ const o = m;
260
+ if (typeof o.id !== "string" || typeof o.title !== "string")
261
+ return false;
262
+ return ["open", "hit", "missed", "dropped"].includes(o.status)
263
+ && (o.dueBy === undefined || isIso(o.dueBy));
264
+ }
265
+ function isRisk(r) {
266
+ if (typeof r !== "object" || r === null)
267
+ return false;
268
+ const o = r;
269
+ if (typeof o.id !== "string" || typeof o.text !== "string")
270
+ return false;
271
+ return ["low", "med", "high"].includes(o.severity)
272
+ && (o.mitigation === undefined || typeof o.mitigation === "string");
273
+ }
274
+ export function isOkConfig(obj) {
275
+ if (typeof obj !== "object" || obj === null)
276
+ return false;
277
+ const c = obj;
278
+ if (c.schema !== "ok.config.v1")
279
+ return false;
280
+ if (c.version !== 1)
281
+ return false;
282
+ if (!isIso(c.createdAt) || !isIso(c.updatedAt))
283
+ return false;
284
+ if (c.defaultOwner !== undefined && typeof c.defaultOwner !== "string")
285
+ return false;
286
+ return true;
287
+ }
288
+ export function isOkIndex(obj) {
289
+ if (typeof obj !== "object" || obj === null)
290
+ return false;
291
+ const i = obj;
292
+ if (i.schema !== "ok.index.v1")
293
+ return false;
294
+ if (!Array.isArray(i.tasks) || !Array.isArray(i.plans) || !Array.isArray(i.prds))
295
+ return false;
296
+ if (!isIso(i.updatedAt))
297
+ return false;
298
+ for (const arr of [i.tasks, i.plans, i.prds]) {
299
+ for (const e of arr) {
300
+ if (!e || typeof e !== "object")
301
+ return false;
302
+ const o = e;
303
+ if (typeof o.id !== "string" || typeof o.status !== "string" ||
304
+ typeof o.title !== "string" || typeof o.updatedAt !== "string") {
305
+ return false;
306
+ }
307
+ }
308
+ }
309
+ return true;
310
+ }
311
+ // ─── Helpers ────────────────────────────────────────────────────────────────
312
+ function isStringArray(v) {
313
+ return Array.isArray(v) && v.every((x) => typeof x === "string");
314
+ }
315
+ function isIso(v) {
316
+ return typeof v === "string" && !Number.isNaN(Date.parse(v));
317
+ }
318
+ /** Bump `updatedAt` to now. Returns a new object. */
319
+ export function touch(entity) {
320
+ return { ...entity, updatedAt: nowIso() };
321
+ }
322
+ /** Stable sort by `updatedAt` descending. Returns a new array. */
323
+ export function byUpdatedDesc(items) {
324
+ return [...items].sort((a, b) => isoCompare(b.updatedAt, a.updatedAt));
325
+ }
326
+ export function isAgentProfile(v) {
327
+ return (typeof v === "object" && v !== null &&
328
+ v.schema === "openkan.agent-profile.v1");
329
+ }
330
+ export function isAgentsConfig(v) {
331
+ return (typeof v === "object" && v !== null &&
332
+ v.schema === "openkan.agents.v1");
333
+ }
334
+ const VALID_KINDS = new Set(["claude-code", "codex-cli", "cursor", "cline", "custom"]);
335
+ /**
336
+ * Validate a single AgentProfile.
337
+ * Returns null on success, or the first error found.
338
+ */
339
+ export function validateAgentProfile(obj) {
340
+ if (typeof obj !== "object" || obj === null) {
341
+ return { path: "", reason: "expected object" };
342
+ }
343
+ const o = obj;
344
+ if (typeof o["id"] !== "string" || !o["id"]) {
345
+ return { path: "id", reason: "required, non-empty string" };
346
+ }
347
+ if (!o["kind"] || !VALID_KINDS.has(o["kind"])) {
348
+ return { path: "kind", reason: `required, must be one of: ${[...VALID_KINDS].join(", ")}` };
349
+ }
350
+ if (typeof o["bin"] !== "string" || !o["bin"].trim()) {
351
+ return { path: "bin", reason: "required, non-empty string" };
352
+ }
353
+ if (o["description"] !== undefined && typeof o["description"] !== "string") {
354
+ return { path: "description", reason: "optional, must be string" };
355
+ }
356
+ if (o["meta"] !== undefined && (typeof o["meta"] !== "object" || o["meta"] === null || Array.isArray(o["meta"]))) {
357
+ return { path: "meta", reason: "optional, must be object" };
358
+ }
359
+ return null;
360
+ }
361
+ /**
362
+ * Validate an AgentsConfig object.
363
+ * Returns null on success, or the first error found.
364
+ */
365
+ export function validateAgentsConfig(obj) {
366
+ if (typeof obj !== "object" || obj === null) {
367
+ return { path: "", reason: "expected object" };
368
+ }
369
+ const o = obj;
370
+ if (typeof o["active"] !== "string" || !o["active"]) {
371
+ return { path: "active", reason: "required, non-empty string" };
372
+ }
373
+ if (!Array.isArray(o["profiles"])) {
374
+ return { path: "profiles", reason: "required, must be array" };
375
+ }
376
+ for (let i = 0; i < o["profiles"].length; i++) {
377
+ const err = validateAgentProfile(o["profiles"][i]);
378
+ if (err) {
379
+ err.path = `profiles[${i}].${err.path}`;
380
+ return err;
381
+ }
382
+ }
383
+ return null;
384
+ }
385
+ /** Default agent profile registered when no profiles exist yet. */
386
+ export const DEFAULT_AGENT_PROFILE = {
387
+ schema: "openkan.agent-profile.v1",
388
+ id: "claude-code",
389
+ kind: "claude-code",
390
+ bin: "claude",
391
+ description: "Default Claude Code agent profile",
392
+ };
393
+ /** Default agents config used when none exists. */
394
+ export const DEFAULT_AGENTS_CONFIG = {
395
+ schema: "openkan.agents.v1",
396
+ active: "claude-code",
397
+ profiles: [DEFAULT_AGENT_PROFILE],
398
+ };
399
+ /** Strip the id prefix from each id-suffix in the array, for logging. */
400
+ export function suffixList(items) {
401
+ return items.map((i) => idSuffix(i.id) ?? i.id);
402
+ }
@@ -0,0 +1,222 @@
1
+ // ok/storage.ts — async JSON storage for every planning entity.
2
+ //
3
+ // Storage rules:
4
+ // - One file per entity (e.g. `.ok/tasks/tsk-Vn4kRp2x.json`).
5
+ // - Writes are atomic via `.tmp` + `rename` (same pattern as kanban/io.ts).
6
+ // - Reads tolerate concurrent overwrites but never silently mutate.
7
+ // - Unknown files in directories are ignored — agents may add helpers.
8
+ //
9
+ // Every entity has a JSON envelope `{schema, ...payload}`; the `schema`
10
+ // discriminator is the only addressable identity in tests.
11
+ import { promises as fs } from "node:fs";
12
+ import * as fsSync from "node:fs";
13
+ import * as path from "node:path";
14
+ import { isTask, isPlan, isPrd, isOkConfig, isOkIndex, } from "./schemas.js";
15
+ import { nowIso } from "./ids.js";
16
+ export const OK_DIR = ".ok";
17
+ export const CONFIG_FILE = "config.json";
18
+ export const INDEX_FILE = "index.json";
19
+ export const TASKS_DIR = "tasks";
20
+ export const PLANS_DIR = "plans";
21
+ export const PRDS_DIR = "prds";
22
+ export const SESSIONS_DIR = "sessions";
23
+ export const LOCKS_DIR = "locks";
24
+ /** Resolve all well-known paths under `<root>/.ok/`. */
25
+ export function paths(root) {
26
+ const okRoot = path.join(root, OK_DIR);
27
+ return {
28
+ root: okRoot,
29
+ configFile: path.join(okRoot, CONFIG_FILE),
30
+ indexFile: path.join(okRoot, INDEX_FILE),
31
+ tasksDir: path.join(okRoot, TASKS_DIR),
32
+ plansDir: path.join(okRoot, PLANS_DIR),
33
+ prdsDir: path.join(okRoot, PRDS_DIR),
34
+ sessionsDir: path.join(okRoot, SESSIONS_DIR),
35
+ locksDir: path.join(okRoot, LOCKS_DIR),
36
+ };
37
+ }
38
+ /** Read the JSON at `p` and narrow it; throws if missing or malformed. */
39
+ export async function readJson(p, narrow) {
40
+ const raw = await fs.readFile(p, "utf-8");
41
+ let parsed;
42
+ try {
43
+ parsed = JSON.parse(raw);
44
+ }
45
+ catch (e) {
46
+ throw new Error(`invalid JSON in ${p}: ${e.message}`);
47
+ }
48
+ if (!narrow(parsed)) {
49
+ throw new Error(`invalid shape in ${p} (failed schema check). ` +
50
+ `If this file was created by a bug or external tool, you can remove it with: rm "${p}"`);
51
+ }
52
+ return parsed;
53
+ }
54
+ /** Best-effort reader; returns `undefined` instead of throwing on missing files. */
55
+ export async function readJsonOptional(p, narrow) {
56
+ try {
57
+ return await readJson(p, narrow);
58
+ }
59
+ catch (e) {
60
+ if (e?.code === "ENOENT")
61
+ return undefined;
62
+ throw e;
63
+ }
64
+ }
65
+ /** Atomic JSON write (tmp + rename). */
66
+ export async function writeJson(p, body) {
67
+ const tmp = `${p}.tmp-${process.pid}-${Date.now()}`;
68
+ const data = JSON.stringify(body, null, 2);
69
+ let fh;
70
+ try {
71
+ fh = await fs.open(tmp, "w");
72
+ await fh.writeFile(data, "utf-8");
73
+ await fh.sync();
74
+ }
75
+ finally {
76
+ if (fh)
77
+ await fh.close();
78
+ }
79
+ await fs.rename(tmp, p);
80
+ }
81
+ export async function ensureDirs(p) {
82
+ for (const dir of [p.root, p.tasksDir, p.plansDir, p.prdsDir, p.sessionsDir, p.locksDir]) {
83
+ await fs.mkdir(dir, { recursive: true });
84
+ }
85
+ }
86
+ // ─── Typed getters/setters ──────────────────────────────────────────────────
87
+ export async function readConfig(p) {
88
+ return readJsonOptional(p.configFile, isOkConfig);
89
+ }
90
+ export async function writeConfig(p, cfg) {
91
+ await ensureDirs(p);
92
+ await writeJson(p.configFile, cfg);
93
+ }
94
+ export async function readIndex(p) {
95
+ return readJsonOptional(p.indexFile, isOkIndex);
96
+ }
97
+ export async function writeIndex(p, idx) {
98
+ await writeJson(p.indexFile, idx);
99
+ }
100
+ export async function listDir(p, prefix) {
101
+ let names;
102
+ try {
103
+ names = await fs.readdir(p);
104
+ }
105
+ catch (e) {
106
+ if (e?.code === "ENOENT")
107
+ return [];
108
+ throw e;
109
+ }
110
+ return names
111
+ .filter((n) => n.startsWith(`${prefix}-`) && n.endsWith(".json"))
112
+ .sort();
113
+ }
114
+ export async function readTask(p, id) {
115
+ if (!/^tsk-[A-Za-z0-9_-]+$/.test(id))
116
+ throw new Error(`invalid task id: ${id}`);
117
+ return readJsonOptional(path.join(p.tasksDir, `${id}.json`), isTask);
118
+ }
119
+ export async function writeTask(p, task) {
120
+ await fs.mkdir(p.tasksDir, { recursive: true });
121
+ await writeJson(path.join(p.tasksDir, `${task.id}.json`), task);
122
+ }
123
+ export async function listTasks(p) {
124
+ const files = await listDir(p.tasksDir, "tsk");
125
+ const out = [];
126
+ for (const f of files) {
127
+ const v = await readJsonOptional(path.join(p.tasksDir, f), isTask);
128
+ if (v)
129
+ out.push(v);
130
+ }
131
+ return out;
132
+ }
133
+ export async function readPlan(p, id) {
134
+ if (!/^pln-[A-Za-z0-9_-]+$/.test(id))
135
+ throw new Error(`invalid plan id: ${id}`);
136
+ return readJsonOptional(path.join(p.plansDir, `${id}.json`), isPlan);
137
+ }
138
+ export async function writePlan(p, plan) {
139
+ await fs.mkdir(p.plansDir, { recursive: true });
140
+ await writeJson(path.join(p.plansDir, `${plan.id}.json`), plan);
141
+ }
142
+ export async function listPlans(p) {
143
+ const files = await listDir(p.plansDir, "pln");
144
+ const out = [];
145
+ for (const f of files) {
146
+ const v = await readJsonOptional(path.join(p.plansDir, f), isPlan);
147
+ if (v)
148
+ out.push(v);
149
+ }
150
+ return out;
151
+ }
152
+ export async function readPrd(p, id) {
153
+ if (!/^prd-[A-Za-z0-9_-]+$/.test(id))
154
+ throw new Error(`invalid prd id: ${id}`);
155
+ return readJsonOptional(path.join(p.prdsDir, `${id}.json`), isPrd);
156
+ }
157
+ export async function writePrd(p, prd) {
158
+ await fs.mkdir(p.prdsDir, { recursive: true });
159
+ await writeJson(path.join(p.prdsDir, `${prd.id}.json`), prd);
160
+ }
161
+ export async function listPrds(p) {
162
+ const files = await listDir(p.prdsDir, "prd");
163
+ const out = [];
164
+ for (const f of files) {
165
+ const v = await readJsonOptional(path.join(p.prdsDir, f), isPrd);
166
+ if (v)
167
+ out.push(v);
168
+ }
169
+ return out;
170
+ }
171
+ // ─── Index builder ──────────────────────────────────────────────────────────
172
+ /** Rebuild `.ok/index.json` from the filesystem. */
173
+ export async function rebuildIndex(p) {
174
+ const [tasks, plans, prds] = await Promise.all([listTasks(p), listPlans(p), listPrds(p)]);
175
+ const toEntry = (e) => ({
176
+ id: e.id,
177
+ status: e.status,
178
+ title: e.title,
179
+ updatedAt: e.updatedAt,
180
+ });
181
+ const idx = {
182
+ schema: "ok.index.v1",
183
+ tasks: tasks
184
+ .map(toEntry)
185
+ .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0)),
186
+ plans: plans
187
+ .map(toEntry)
188
+ .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0)),
189
+ prds: prds
190
+ .map(toEntry)
191
+ .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0)),
192
+ updatedAt: nowIso(),
193
+ };
194
+ await writeIndex(p, idx);
195
+ return idx;
196
+ }
197
+ /** True when `.ok/config.json` is present at the project root. */
198
+ export function hasOkDir(root) {
199
+ return fsSync.existsSync(path.join(root, OK_DIR, CONFIG_FILE));
200
+ }
201
+ /**
202
+ * Initialise `.ok/` in the project root if missing. Idempotent.
203
+ * Returns the resolved paths either way.
204
+ */
205
+ export async function initIfMissing(root) {
206
+ const p = paths(root);
207
+ await ensureDirs(p);
208
+ if (!(await readConfig(p))) {
209
+ const now = nowIso();
210
+ const cfg = {
211
+ schema: "ok.config.v1",
212
+ version: 1,
213
+ createdAt: now,
214
+ updatedAt: now,
215
+ };
216
+ await writeConfig(p, cfg);
217
+ }
218
+ if (!(await readIndex(p))) {
219
+ await rebuildIndex(p);
220
+ }
221
+ return p;
222
+ }