@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,270 @@
|
|
|
1
|
+
// OpenKan — filesystem watcher using Node's built-in fs.watch.
|
|
2
|
+
import { watch as fsWatch } from "node:fs";
|
|
3
|
+
import { readdirSync, statSync } from "node:fs";
|
|
4
|
+
import { join, relative, isAbsolute } from "node:path";
|
|
5
|
+
// ─── Default ignore list ─────────────────────────────────────────────────────
|
|
6
|
+
const DEFAULT_IGNORED = [
|
|
7
|
+
/server\.lock$/,
|
|
8
|
+
/server\.pid$/,
|
|
9
|
+
/server\.log$/,
|
|
10
|
+
/changelog\.jsonl$/,
|
|
11
|
+
/[/\\]node_modules[/\\]/, // matches node_modules/ or node_modules\ anywhere in path
|
|
12
|
+
];
|
|
13
|
+
function defaultIgnore(absPath) {
|
|
14
|
+
const normalized = absPath.replace(/\\/g, "/"); // Windows path normalisation
|
|
15
|
+
return DEFAULT_IGNORED.some(re => re.test(normalized)) || normalized.endsWith(".tmp");
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* An AsyncIterable that yields filesystem change events from one or more fs.watch
|
|
19
|
+
* handles. Debouncing coalesces rapid write+rename bursts into single deliveries.
|
|
20
|
+
*/
|
|
21
|
+
class WatchEventIterable {
|
|
22
|
+
queue = [];
|
|
23
|
+
root;
|
|
24
|
+
debounceMs;
|
|
25
|
+
ignoreFn;
|
|
26
|
+
stats;
|
|
27
|
+
closed;
|
|
28
|
+
closeWatchers;
|
|
29
|
+
resolveNext = null;
|
|
30
|
+
debounceTimer = null;
|
|
31
|
+
/** Set when close() is explicitly called — no more events accepted. */
|
|
32
|
+
done = false;
|
|
33
|
+
/** Set when a consumer exits (return() called) — blocks new events but allows reuse. */
|
|
34
|
+
closing = false;
|
|
35
|
+
constructor(root, debounceMs, ignoreFn, stats, closed, closeWatchers) {
|
|
36
|
+
this.root = root;
|
|
37
|
+
this.debounceMs = debounceMs;
|
|
38
|
+
this.ignoreFn = ignoreFn;
|
|
39
|
+
this.stats = stats;
|
|
40
|
+
this.closed = closed;
|
|
41
|
+
this.closeWatchers = closeWatchers;
|
|
42
|
+
}
|
|
43
|
+
/** Called by fs.watch callbacks to enqueue an event. */
|
|
44
|
+
pushEvent(kind, absPath) {
|
|
45
|
+
if (this.closed())
|
|
46
|
+
return;
|
|
47
|
+
if (this.closing)
|
|
48
|
+
return; // close() was called; no more events accepted
|
|
49
|
+
this.stats.started += 1;
|
|
50
|
+
if (this.ignoreFn(absPath)) {
|
|
51
|
+
this.stats.filtered += 1;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
this.queue.push({ kind, absPath });
|
|
55
|
+
if (this.resolveNext) {
|
|
56
|
+
this.resolveNext();
|
|
57
|
+
this.resolveNext = null;
|
|
58
|
+
}
|
|
59
|
+
this.scheduleFlush();
|
|
60
|
+
}
|
|
61
|
+
scheduleFlush() {
|
|
62
|
+
if (this.debounceTimer !== null)
|
|
63
|
+
clearTimeout(this.debounceTimer);
|
|
64
|
+
this.debounceTimer = setTimeout(() => {
|
|
65
|
+
this.debounceTimer = null;
|
|
66
|
+
if (this.queue.length === 0)
|
|
67
|
+
return;
|
|
68
|
+
if (this.resolveNext) {
|
|
69
|
+
this.resolveNext();
|
|
70
|
+
this.resolveNext = null;
|
|
71
|
+
}
|
|
72
|
+
}, this.debounceMs);
|
|
73
|
+
}
|
|
74
|
+
async next() {
|
|
75
|
+
// Always drain any pending events first.
|
|
76
|
+
if (this.queue.length > 0) {
|
|
77
|
+
const ev = this.queue.shift();
|
|
78
|
+
this.stats.delivered += 1;
|
|
79
|
+
return {
|
|
80
|
+
done: false,
|
|
81
|
+
value: {
|
|
82
|
+
kind: ev.kind,
|
|
83
|
+
path: relative(this.root, ev.absPath).replace(/\\/g, "/"),
|
|
84
|
+
absPath: ev.absPath,
|
|
85
|
+
ts: new Date().toISOString(),
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (this.done)
|
|
90
|
+
return { done: true, value: undefined };
|
|
91
|
+
// Wait for next event.
|
|
92
|
+
let resolveWait = null;
|
|
93
|
+
const waitForEvent = new Promise(r => { resolveWait = r; });
|
|
94
|
+
this.resolveNext = () => { if (resolveWait) {
|
|
95
|
+
resolveWait();
|
|
96
|
+
resolveWait = null;
|
|
97
|
+
} };
|
|
98
|
+
// Poll for done=true every 5ms so we don't wait forever on a closed watcher.
|
|
99
|
+
const pollDone = setInterval(() => {
|
|
100
|
+
if (this.done && resolveWait) {
|
|
101
|
+
resolveWait();
|
|
102
|
+
resolveWait = null;
|
|
103
|
+
}
|
|
104
|
+
}, 5);
|
|
105
|
+
await waitForEvent;
|
|
106
|
+
clearInterval(pollDone);
|
|
107
|
+
this.resolveNext = null;
|
|
108
|
+
// Drain queue even if done=true (e.g., events arrived just before done was set).
|
|
109
|
+
if (this.queue.length > 0) {
|
|
110
|
+
const ev = this.queue.shift();
|
|
111
|
+
this.stats.delivered += 1;
|
|
112
|
+
return {
|
|
113
|
+
done: false,
|
|
114
|
+
value: {
|
|
115
|
+
kind: ev.kind,
|
|
116
|
+
path: relative(this.root, ev.absPath).replace(/\\/g, "/"),
|
|
117
|
+
absPath: ev.absPath,
|
|
118
|
+
ts: new Date().toISOString(),
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
// If closing (consumer exited), return done=true so for-await exits permanently.
|
|
123
|
+
// If done=true (close() was called), also return done=true.
|
|
124
|
+
return { done: this.closing || this.done, value: undefined };
|
|
125
|
+
}
|
|
126
|
+
[Symbol.asyncIterator]() {
|
|
127
|
+
return this;
|
|
128
|
+
}
|
|
129
|
+
/** Called by for-await when the consumer exits (timeout or normal end). */
|
|
130
|
+
async return() {
|
|
131
|
+
// Only permanently close if the queue is empty. If events are pending (e.g., a
|
|
132
|
+
// debounced event arrived just before this return() call), keep the watcher
|
|
133
|
+
// alive so the next consumer can drain them. Only close watchers if done=true.
|
|
134
|
+
if (this.queue.length === 0) {
|
|
135
|
+
this.closing = true; // block new events; watchers stay open until close()
|
|
136
|
+
}
|
|
137
|
+
if (this.resolveNext) {
|
|
138
|
+
this.resolveNext();
|
|
139
|
+
this.resolveNext = null;
|
|
140
|
+
}
|
|
141
|
+
if (this.debounceTimer !== null) {
|
|
142
|
+
clearTimeout(this.debounceTimer);
|
|
143
|
+
this.debounceTimer = null;
|
|
144
|
+
}
|
|
145
|
+
return { done: true, value: undefined };
|
|
146
|
+
}
|
|
147
|
+
/** Mark iteration as permanently done and clean up watchers. */
|
|
148
|
+
destroy() {
|
|
149
|
+
this.done = true;
|
|
150
|
+
this.closing = true;
|
|
151
|
+
this.closeWatchers();
|
|
152
|
+
if (this.resolveNext) {
|
|
153
|
+
this.resolveNext();
|
|
154
|
+
this.resolveNext = null;
|
|
155
|
+
}
|
|
156
|
+
if (this.debounceTimer !== null) {
|
|
157
|
+
clearTimeout(this.debounceTimer);
|
|
158
|
+
this.debounceTimer = null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Watch a directory for filesystem changes.
|
|
164
|
+
*
|
|
165
|
+
* Uses `fs.watch` with `recursive: true` on Linux/macOS. On Windows (where
|
|
166
|
+
* recursive is not supported), falls back to a manual readdirRecursive walk
|
|
167
|
+
* and watches each sub-directory individually.
|
|
168
|
+
*
|
|
169
|
+
* Events are debounced (default 100 ms) to coalesce rapid write+rename bursts.
|
|
170
|
+
* A default filter ignores server.lock, server.pid, server.log, changelog.jsonl,
|
|
171
|
+
* node_modules, and any .tmp file. Additional ignores can be passed via options.
|
|
172
|
+
*/
|
|
173
|
+
export function watch(options) {
|
|
174
|
+
const root = isAbsolute(options.root) ? options.root : join(process.cwd(), options.root);
|
|
175
|
+
const debounceMs = options.debounceMs ?? 100;
|
|
176
|
+
const extraIgnore = options.ignore ?? (() => false);
|
|
177
|
+
const ignoreFn = (absPath) => defaultIgnore(absPath) || extraIgnore(absPath);
|
|
178
|
+
const stats = { started: 0, delivered: 0, filtered: 0 };
|
|
179
|
+
let closedFlag = false;
|
|
180
|
+
function isClosed() { return closedFlag; }
|
|
181
|
+
// ── Try recursive watch (Linux / macOS) ─────────────────────────────────────
|
|
182
|
+
let recursiveWatchers = [];
|
|
183
|
+
try {
|
|
184
|
+
const w = fsWatch(root, { recursive: true, persistent: true }, () => { });
|
|
185
|
+
recursiveWatchers = [w];
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
recursiveWatchers = []; // not supported; use fallback
|
|
189
|
+
}
|
|
190
|
+
if (recursiveWatchers.length > 0) {
|
|
191
|
+
// Recursive watch works.
|
|
192
|
+
const watcher = recursiveWatchers[0];
|
|
193
|
+
const iterable = new WatchEventIterable(root, debounceMs, ignoreFn, stats, isClosed, () => { for (const w of recursiveWatchers)
|
|
194
|
+
w.close(); });
|
|
195
|
+
watcher.on("change", (eventName, filename) => {
|
|
196
|
+
if (!filename || closedFlag)
|
|
197
|
+
return;
|
|
198
|
+
const kind = eventName === "rename" ? "rename" : "change";
|
|
199
|
+
iterable.pushEvent(kind, join(root, filename));
|
|
200
|
+
});
|
|
201
|
+
watcher.on("error", () => { });
|
|
202
|
+
return {
|
|
203
|
+
events: iterable,
|
|
204
|
+
close() {
|
|
205
|
+
closedFlag = true;
|
|
206
|
+
iterable.destroy();
|
|
207
|
+
},
|
|
208
|
+
stats,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
// ── Fallback: manual recursive walk + per-directory watches (Windows) ───────
|
|
212
|
+
const dirsToWatch = [root];
|
|
213
|
+
function walkDirs(dir) {
|
|
214
|
+
let entries;
|
|
215
|
+
try {
|
|
216
|
+
entries = readdirSync(dir);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
for (const entry of entries) {
|
|
222
|
+
if (entry === "node_modules")
|
|
223
|
+
continue;
|
|
224
|
+
const abs = join(dir, entry);
|
|
225
|
+
let st;
|
|
226
|
+
try {
|
|
227
|
+
st = statSync(abs);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (st.isDirectory()) {
|
|
233
|
+
dirsToWatch.push(abs);
|
|
234
|
+
walkDirs(abs);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
walkDirs(root);
|
|
239
|
+
const perDirWatchers = [];
|
|
240
|
+
const iterable = new WatchEventIterable(root, debounceMs, ignoreFn, stats, isClosed, () => { for (const w of perDirWatchers)
|
|
241
|
+
w.close(); });
|
|
242
|
+
for (const dir of dirsToWatch) {
|
|
243
|
+
try {
|
|
244
|
+
const w = fsWatch(dir, (eventName, filename) => {
|
|
245
|
+
if (!filename || closedFlag)
|
|
246
|
+
return;
|
|
247
|
+
const kind = eventName === "rename" ? "rename" : "change";
|
|
248
|
+
iterable.pushEvent(kind, join(dir, filename));
|
|
249
|
+
});
|
|
250
|
+
w.on("error", () => { });
|
|
251
|
+
perDirWatchers.push(w);
|
|
252
|
+
}
|
|
253
|
+
catch { /* directory deleted between stat and watch */ }
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
events: iterable,
|
|
257
|
+
close() {
|
|
258
|
+
closedFlag = true;
|
|
259
|
+
iterable.destroy();
|
|
260
|
+
},
|
|
261
|
+
stats,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Return the absolute path of a task's source file, or null if the task has no source.
|
|
266
|
+
* The returned path is suitable for comparison against WatchEvent.absPath.
|
|
267
|
+
*/
|
|
268
|
+
export function sourcePathOfTask(t, kanbanDir) {
|
|
269
|
+
return t.source?.path ? join(kanbanDir, "..", t.source.path) : null;
|
|
270
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { listPrds, readPrd, writePrd, rebuildIndex, paths } from "../storage.js";
|
|
2
|
+
import { parseArgs, flagString, flagBool } from "../ids.js";
|
|
3
|
+
import { touch } from "../schemas.js";
|
|
4
|
+
const statuses = ['open', 'in_progress', 'met', 'dropped'];
|
|
5
|
+
export async function runGoal(args) {
|
|
6
|
+
const [sub, ...rest] = args;
|
|
7
|
+
const { positionals, flags } = parseArgs(rest);
|
|
8
|
+
const p = paths(process.cwd());
|
|
9
|
+
const output = (value) => process.stdout.write(`${typeof value === 'string' && !flagBool(flags, 'json') ? value : JSON.stringify(value, null, 2)}\n`);
|
|
10
|
+
if (sub === 'list') {
|
|
11
|
+
const prdId = flagString(flags, 'prd') || positionals[0];
|
|
12
|
+
const status = flagString(flags, 'status');
|
|
13
|
+
if (status && !statuses.includes(status))
|
|
14
|
+
throw new Error(`status must be ${statuses.join('|')}`);
|
|
15
|
+
const prds = prdId ? [await readPrd(p, prdId)] : await listPrds(p);
|
|
16
|
+
if (prds.some(prd => !prd))
|
|
17
|
+
throw new Error(`no such PRD: ${prdId}`);
|
|
18
|
+
const goals = prds.flatMap(prd => prd.goals.map(goal => ({ ...goal, prd: prd.id }))).filter(goal => !status || goal.status === status);
|
|
19
|
+
if (flagBool(flags, 'json'))
|
|
20
|
+
output(goals);
|
|
21
|
+
else
|
|
22
|
+
output(goals.map(goal => `${goal.prd}/${goal.id} ${goal.status} ${goal.text}`).join('\n') || '(no goals)');
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
if (!['add', 'show', 'update'].includes(sub))
|
|
26
|
+
throw new Error('Usage: openkan goal list [--prd ID] [--json] | add <prd-id> <text> | show <prd-id> <goal-id> | update <prd-id> <goal-id> --status open|in_progress|met|dropped [--text TEXT]');
|
|
27
|
+
const [prdId, goalId, ...extra] = positionals;
|
|
28
|
+
if (!prdId || !goalId || (sub !== 'add' && extra.length))
|
|
29
|
+
throw new Error(`Usage: openkan goal ${sub} <prd-id> <${sub === 'add' ? 'text' : 'goal-id'}>`);
|
|
30
|
+
const prd = await readPrd(p, prdId);
|
|
31
|
+
if (!prd)
|
|
32
|
+
throw new Error(`no such PRD: ${prdId}`);
|
|
33
|
+
let goal;
|
|
34
|
+
if (sub === 'add') {
|
|
35
|
+
const number = Math.max(0, ...prd.goals.map(g => Number(g.id.match(/^g(\d+)$/)?.[1] || 0))) + 1;
|
|
36
|
+
goal = { id: `g${number}`, text: [goalId, ...extra].join(' '), status: 'open' };
|
|
37
|
+
prd.goals.push(goal);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
const found = prd.goals.find(g => g.id === goalId);
|
|
41
|
+
if (!found)
|
|
42
|
+
throw new Error(`no such goal: ${prdId}/${goalId}`);
|
|
43
|
+
goal = found;
|
|
44
|
+
if (sub === 'update') {
|
|
45
|
+
const status = flagString(flags, 'status');
|
|
46
|
+
const text = flagString(flags, 'text');
|
|
47
|
+
if (!status && text === undefined)
|
|
48
|
+
throw new Error('goal update requires --status or --text');
|
|
49
|
+
if (status && !statuses.includes(status))
|
|
50
|
+
throw new Error(`status must be ${statuses.join('|')}`);
|
|
51
|
+
if (text !== undefined && !text.trim())
|
|
52
|
+
throw new Error('goal text must not be empty');
|
|
53
|
+
if (status)
|
|
54
|
+
goal.status = status;
|
|
55
|
+
if (text !== undefined)
|
|
56
|
+
goal.text = text;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (sub !== 'show') {
|
|
60
|
+
await writePrd(p, touch(prd));
|
|
61
|
+
await rebuildIndex(p);
|
|
62
|
+
}
|
|
63
|
+
output(goal);
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// ok/commands/index.ts — `ok index` and `ok doctor`.
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { isTask, isPlan, isPrd, validateTask, validatePlan, validatePrd, } from "../schemas.js";
|
|
5
|
+
import { initIfMissing, rebuildIndex, paths as computePaths, } from "../storage.js";
|
|
6
|
+
async function paths() {
|
|
7
|
+
const p = computePaths(process.cwd());
|
|
8
|
+
return initIfMissing(process.cwd());
|
|
9
|
+
}
|
|
10
|
+
export async function runIndex() {
|
|
11
|
+
const p = await paths();
|
|
12
|
+
const idx = await rebuildIndex(p);
|
|
13
|
+
process.stdout.write(`tasks: ${idx.tasks.length}, plans: ${idx.plans.length}, prds: ${idx.prds.length}\n`);
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
|
16
|
+
async function readMaybeJson(p) {
|
|
17
|
+
try {
|
|
18
|
+
const raw = await fs.readFile(p, "utf-8");
|
|
19
|
+
return JSON.parse(raw);
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
if (e?.code === "ENOENT")
|
|
23
|
+
return undefined;
|
|
24
|
+
return { __parseError: e.message };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function runDoctor() {
|
|
28
|
+
const p = await paths();
|
|
29
|
+
const issues = [];
|
|
30
|
+
for (const file of await fs.readdir(p.tasksDir).catch(() => [])) {
|
|
31
|
+
if (!file.endsWith(".json"))
|
|
32
|
+
continue;
|
|
33
|
+
const full = path.join(p.tasksDir, file);
|
|
34
|
+
const v = await readMaybeJson(full);
|
|
35
|
+
if (v === undefined)
|
|
36
|
+
continue;
|
|
37
|
+
if (v && typeof v === "object" && "__parseError" in v) {
|
|
38
|
+
issues.push({ file, reason: `JSON parse error: ${v.__parseError}` });
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (!isTask(v)) {
|
|
42
|
+
const err = validateTask(v) ?? { reason: "schema mismatch" };
|
|
43
|
+
issues.push({ file, ...err });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const file of await fs.readdir(p.plansDir).catch(() => [])) {
|
|
47
|
+
if (!file.endsWith(".json"))
|
|
48
|
+
continue;
|
|
49
|
+
const full = path.join(p.plansDir, file);
|
|
50
|
+
const v = await readMaybeJson(full);
|
|
51
|
+
if (v === undefined)
|
|
52
|
+
continue;
|
|
53
|
+
if (v && typeof v === "object" && "__parseError" in v) {
|
|
54
|
+
issues.push({ file, reason: `JSON parse error: ${v.__parseError}` });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!isPlan(v)) {
|
|
58
|
+
const err = validatePlan(v) ?? { reason: "schema mismatch" };
|
|
59
|
+
issues.push({ file, ...err });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const file of await fs.readdir(p.prdsDir).catch(() => [])) {
|
|
63
|
+
if (!file.endsWith(".json"))
|
|
64
|
+
continue;
|
|
65
|
+
const full = path.join(p.prdsDir, file);
|
|
66
|
+
const v = await readMaybeJson(full);
|
|
67
|
+
if (v === undefined)
|
|
68
|
+
continue;
|
|
69
|
+
if (v && typeof v === "object" && "__parseError" in v) {
|
|
70
|
+
issues.push({ file, reason: `JSON parse error: ${v.__parseError}` });
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (!isPrd(v)) {
|
|
74
|
+
const err = validatePrd(v) ?? { reason: "schema mismatch" };
|
|
75
|
+
issues.push({ file, ...err });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (issues.length === 0) {
|
|
79
|
+
process.stdout.write("ok doctor: 0 issues\n");
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
process.stdout.write(`ok doctor: ${issues.length} issue(s)\n`);
|
|
83
|
+
for (const issue of issues) {
|
|
84
|
+
process.stdout.write(` ${issue.file}${issue.id ? ` (${issue.id})` : ""}: ${issue.reason}\n`);
|
|
85
|
+
}
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// ok/commands/init.ts — `ok init` — create .ok/ in cwd.
|
|
2
|
+
import { initIfMissing } from "../storage.js";
|
|
3
|
+
export async function cmdInit() {
|
|
4
|
+
const root = process.cwd();
|
|
5
|
+
const p = await initIfMissing(root);
|
|
6
|
+
process.stdout.write(`.ok/ initialised at ${p.root}\n`);
|
|
7
|
+
process.stdout.write(" config.json\n");
|
|
8
|
+
process.stdout.write(" index.json\n");
|
|
9
|
+
process.stdout.write(` ${p.tasksDir.replace(`${p.root}/`, "")}/\n`);
|
|
10
|
+
process.stdout.write(` ${p.plansDir.replace(`${p.root}/`, "")}/\n`);
|
|
11
|
+
process.stdout.write(` ${p.prdsDir.replace(`${p.root}/`, "")}/\n`);
|
|
12
|
+
process.stdout.write(` ${p.sessionsDir.replace(`${p.root}/`, "")}/\n`);
|
|
13
|
+
process.stdout.write(` ${p.locksDir.replace(`${p.root}/`, "")}/\n`);
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// ok/commands/plan.ts — `ok plan add|list|show|update`.
|
|
2
|
+
import { touch, byUpdatedDesc, } from "../schemas.js";
|
|
3
|
+
import { initIfMissing, readPlan, writePlan, listPlans, readTask, writeTask, readConfig, rebuildIndex, paths as computePaths, } from "../storage.js";
|
|
4
|
+
import { newId, nowIso, parseArgs, flagString, flagCsv, flagBool } from "../ids.js";
|
|
5
|
+
const STATUSES = ["draft", "active", "blocked", "complete", "abandoned"];
|
|
6
|
+
function parseStatus(v) {
|
|
7
|
+
if (!v)
|
|
8
|
+
return undefined;
|
|
9
|
+
if (!STATUSES.includes(v)) {
|
|
10
|
+
throw new Error(`status must be one of ${STATUSES.join("|")}`);
|
|
11
|
+
}
|
|
12
|
+
return v;
|
|
13
|
+
}
|
|
14
|
+
async function paths() {
|
|
15
|
+
const p = computePaths(process.cwd());
|
|
16
|
+
if (!(await readConfig(p))) {
|
|
17
|
+
return initIfMissing(process.cwd());
|
|
18
|
+
}
|
|
19
|
+
return p;
|
|
20
|
+
}
|
|
21
|
+
function printTable(rows) {
|
|
22
|
+
if (rows.length === 0) {
|
|
23
|
+
process.stdout.write("(no plans)\n");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const w = Math.max(2, ...rows.map((r) => r.id.length));
|
|
27
|
+
for (const r of rows) {
|
|
28
|
+
process.stdout.write(`${r.id.padEnd(w)} ${r.status.padEnd(10)} ${r.updatedAt} ${r.title}\n`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export async function runPlan(args) {
|
|
32
|
+
const [sub, ...rest] = args;
|
|
33
|
+
switch (sub) {
|
|
34
|
+
case "add": return cmdPlanAdd(rest);
|
|
35
|
+
case "list": return cmdPlanList(rest);
|
|
36
|
+
case "show": return cmdPlanShow(rest);
|
|
37
|
+
case "update": return cmdPlanUpdate(rest);
|
|
38
|
+
default:
|
|
39
|
+
process.stderr.write("usage: ok plan <add|list|show|update>\n");
|
|
40
|
+
return 2;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function cmdPlanAdd(args) {
|
|
44
|
+
const { positionals, flags } = parseArgs(args);
|
|
45
|
+
if (positionals.length === 0) {
|
|
46
|
+
process.stderr.write("usage: ok plan add <title> [--summary …] [--prd <id>] [--tasks t1,t2,…]\n");
|
|
47
|
+
return 2;
|
|
48
|
+
}
|
|
49
|
+
const title = positionals.join(" ");
|
|
50
|
+
const summary = flagString(flags, "summary") ?? title;
|
|
51
|
+
const prd = flagString(flags, "prd");
|
|
52
|
+
const tasks = flagCsv(flags, "tasks");
|
|
53
|
+
const acceptance = flagCsv(flags, "acceptance");
|
|
54
|
+
const phase = flagString(flags, "phase");
|
|
55
|
+
const p = await paths();
|
|
56
|
+
const now = nowIso();
|
|
57
|
+
const plan = {
|
|
58
|
+
schema: "ok.plan.v1",
|
|
59
|
+
id: newId("pln"),
|
|
60
|
+
title,
|
|
61
|
+
summary,
|
|
62
|
+
status: "draft",
|
|
63
|
+
tasks,
|
|
64
|
+
acceptance,
|
|
65
|
+
createdAt: now,
|
|
66
|
+
updatedAt: now,
|
|
67
|
+
};
|
|
68
|
+
if (prd)
|
|
69
|
+
plan.prd = prd;
|
|
70
|
+
if (phase)
|
|
71
|
+
plan.phase = phase;
|
|
72
|
+
await writePlan(p, plan);
|
|
73
|
+
// Backlink: set plan on each referenced task if not already set.
|
|
74
|
+
for (const tid of tasks) {
|
|
75
|
+
const t = await readTask(p, tid);
|
|
76
|
+
if (t && !t.plan) {
|
|
77
|
+
await writeTask(p, touch({ ...t, plan: plan.id }));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
await rebuildIndex(p);
|
|
81
|
+
process.stdout.write(`${plan.id}\n`);
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
async function cmdPlanList(args) {
|
|
85
|
+
const { flags } = parseArgs(args);
|
|
86
|
+
const p = await paths();
|
|
87
|
+
let plans = await listPlans(p);
|
|
88
|
+
const status = parseStatus(flagString(flags, "status"));
|
|
89
|
+
if (status)
|
|
90
|
+
plans = plans.filter((pl) => pl.status === status);
|
|
91
|
+
const prd = flagString(flags, "prd");
|
|
92
|
+
if (prd)
|
|
93
|
+
plans = plans.filter((pl) => pl.prd === prd);
|
|
94
|
+
plans = byUpdatedDesc(plans);
|
|
95
|
+
if (flagBool(flags, "json")) {
|
|
96
|
+
process.stdout.write(JSON.stringify(plans, null, 2) + "\n");
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
printTable(plans.map((pl) => ({ id: pl.id, status: pl.status, title: pl.title, updatedAt: pl.updatedAt })));
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
async function cmdPlanShow(args) {
|
|
103
|
+
const { positionals, flags } = parseArgs(args);
|
|
104
|
+
if (positionals.length !== 1) {
|
|
105
|
+
process.stderr.write("usage: ok plan show <id> [--json]\n");
|
|
106
|
+
return 2;
|
|
107
|
+
}
|
|
108
|
+
const p = await paths();
|
|
109
|
+
const plan = await readPlan(p, positionals[0]);
|
|
110
|
+
if (!plan) {
|
|
111
|
+
process.stderr.write(`no such plan: ${positionals[0]}\n`);
|
|
112
|
+
return 1;
|
|
113
|
+
}
|
|
114
|
+
if (flagBool(flags, "json")) {
|
|
115
|
+
process.stdout.write(JSON.stringify(plan, null, 2) + "\n");
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
for (const [k, v] of Object.entries(plan)) {
|
|
119
|
+
process.stdout.write(`${k}: ${typeof v === "string" ? v : JSON.stringify(v)}\n`);
|
|
120
|
+
}
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
async function cmdPlanUpdate(args) {
|
|
124
|
+
const { positionals, flags } = parseArgs(args);
|
|
125
|
+
if (positionals.length !== 1) {
|
|
126
|
+
process.stderr.write("usage: ok plan update <id> [--status …] [--phase …] [--tasks t1,t2,…] [--append-task t]\n");
|
|
127
|
+
return 2;
|
|
128
|
+
}
|
|
129
|
+
const p = await paths();
|
|
130
|
+
const existing = await readPlan(p, positionals[0]);
|
|
131
|
+
if (!existing) {
|
|
132
|
+
process.stderr.write(`no such plan: ${positionals[0]}\n`);
|
|
133
|
+
return 1;
|
|
134
|
+
}
|
|
135
|
+
let next = touch(existing);
|
|
136
|
+
const status = parseStatus(flagString(flags, "status"));
|
|
137
|
+
if (status)
|
|
138
|
+
next.status = status;
|
|
139
|
+
const phase = flagString(flags, "phase");
|
|
140
|
+
if (phase !== undefined)
|
|
141
|
+
next.phase = phase;
|
|
142
|
+
const tasksCsv = flagString(flags, "tasks");
|
|
143
|
+
if (tasksCsv !== undefined)
|
|
144
|
+
next.tasks = flagCsv({ tasks: tasksCsv }, "tasks");
|
|
145
|
+
const appendTask = flagString(flags, "append-task");
|
|
146
|
+
if (appendTask)
|
|
147
|
+
next.tasks = [...next.tasks, appendTask];
|
|
148
|
+
const appendTasks = flagCsv(flags, "append-tasks");
|
|
149
|
+
for (const t of appendTasks)
|
|
150
|
+
next.tasks = [...next.tasks, t];
|
|
151
|
+
await writePlan(p, next);
|
|
152
|
+
await rebuildIndex(p);
|
|
153
|
+
process.stdout.write(`${next.id}\n`);
|
|
154
|
+
return 0;
|
|
155
|
+
}
|