ai4kanban 0.4.1

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.
@@ -0,0 +1,1670 @@
1
+ #!/usr/bin/env node
2
+ // Kanban board bookkeeping. The ONLY sanctioned writer of docs/kanban/next-id.
3
+ //
4
+ // Handles the id-touching moves so the board stays consistent:
5
+ // init — scaffold a fresh docs/kanban/ board (folders, the project-wide memory set in memory/, config.md)
6
+ // memory-init — lazily scaffold a module's memory path with the four-file set (no goal.md)
7
+ // create — allocate task id(s); with --title, also write the card's frontmatter + index it
8
+ // update — rewrite a card's frontmatter (priority/roi/links/questions, move track, rename)
9
+ // tag — set/clear the [user] tag on one open question (auto-refine triage)
10
+ // migrate — convert old bold-header cards to the frontmatter meta format
11
+ // archive — move a finished task's file/folder into docs/kanban/.archive/, strip its
12
+ // README entry, drop its id from other cards' blocked_by/related, record "completed"
13
+ // reject — same, but delete the file/folder, record "rejected"
14
+ // run — record one run of a recurring task (+1 completed); keep the card
15
+ //
16
+ // It is the ONLY sanctioned writer of a card's frontmatter — Write/Edit are for the body
17
+ // only. It also keeps docs/kanban/metrics.csv (one row per day: completed, created, rejected).
18
+ //
19
+ // Usage:
20
+ // node kanban.mjs init [track...] scaffold docs/kanban/ (folders, memory/, config.md)
21
+ // node kanban.mjs memory-init <module> lazily scaffold memory/<module>/ with the four-file set
22
+ // node kanban.mjs create [--count N] allocate N ids (default 1), print them
23
+ // node kanban.mjs create --title T --track K [opts] scaffold one card (frontmatter + body template + index)
24
+ // node kanban.mjs update <id> [opts] rewrite a card's frontmatter / move / rename
25
+ // node kanban.mjs tag <id> <n[,n...]> <user|none> set/clear the tag on one or more open questions
26
+ // node kanban.mjs migrate [--dry-run] convert old bold-header cards to frontmatter
27
+ // node kanban.mjs archive <id> finish task <id> (move card to .archive/ + README + metric)
28
+ // node kanban.mjs reject <id> reject task <id> (delete card + README + metric)
29
+ // node kanban.mjs run <id> record one run of recurring task <id> (+1 completed, card kept)
30
+ // node kanban.mjs peek print the current next-id (no bump)
31
+ // node kanban.mjs metrics print the metrics CSV
32
+
33
+ import fs from 'node:fs'
34
+ import path from 'node:path'
35
+ import { fileURLToPath } from 'node:url'
36
+
37
+ // Released version of the skill. Do NOT hand-edit — it's stamped from the repo's root
38
+ // VERSION file by scripts/sync-version.mjs (the one number for the whole repo; see
39
+ // PUBLISHING.md). It's baked in because this file is copied into installed projects away
40
+ // from the manifests, so `version` can answer without anything fetched.
41
+ const SKILL_VERSION = '0.4.1'
42
+
43
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
44
+ // The board lives at <repo>/docs/kanban. Every command runs from the repo root (SKILL.md
45
+ // says so), so the working directory IS the repo root — this holds whether the script is a
46
+ // copy under .claude/skills/kanban/ or read-only in a plugin cache. Deriving the root from
47
+ // cwd (not from where this file sits) is what lets `/plugin install` + `kanban init` work
48
+ // with nothing copied into the project.
49
+ const REPO_ROOT = process.cwd()
50
+ const KANBAN = path.join(REPO_ROOT, 'docs', 'kanban')
51
+ const TODO = path.join(KANBAN, 'todo')
52
+ const ARCHIVE = path.join(KANBAN, '.archive')
53
+ const NEXT_ID = path.join(KANBAN, 'next-id')
54
+ const README = path.join(TODO, 'README.md')
55
+ const METRICS = path.join(KANBAN, 'metrics.csv')
56
+ const MODULES_MD = path.join(KANBAN, 'modules.md')
57
+ const CONFIG = path.join(KANBAN, 'config.md')
58
+ // All memory lives under docs/kanban/memory/: the project-wide set sits in this folder
59
+ // itself, each module's set in a subfolder named after the module.
60
+ const MEMORY = path.join(KANBAN, 'memory')
61
+ // The one goal file — board root only, never per module (see PROJECT_MEMORY_SET).
62
+ const GOAL = path.join(MEMORY, 'goal.md')
63
+
64
+ function die(msg) {
65
+ console.error(`kanban: ${msg}`)
66
+ process.exit(1)
67
+ }
68
+
69
+ function readNextId() {
70
+ if (!fs.existsSync(NEXT_ID)) die(`missing ${rel(NEXT_ID)}`)
71
+ const value = fs.readFileSync(NEXT_ID, 'utf8').trim()
72
+ if (!/^\d+$/.test(value)) die(`${rel(NEXT_ID)} is not a plain number: "${value}"`)
73
+ return Number(value)
74
+ }
75
+
76
+ function writeNextId(value) {
77
+ fs.writeFileSync(NEXT_ID, `${value}\n`)
78
+ }
79
+
80
+ const SELF = fileURLToPath(import.meta.url)
81
+ const rel = (p) => path.relative(REPO_ROOT, p) || p
82
+
83
+ // ---- metrics ---------------------------------------------------------------
84
+
85
+ const COLUMNS = ['completed', 'created', 'rejected']
86
+
87
+ function today() {
88
+ return new Date().toISOString().slice(0, 10)
89
+ }
90
+
91
+ function bumpMetric(kind, amount = 1) {
92
+ const day = today()
93
+ let rows = []
94
+ if (fs.existsSync(METRICS)) {
95
+ rows = fs
96
+ .readFileSync(METRICS, 'utf8')
97
+ .trim()
98
+ .split('\n')
99
+ .slice(1) // drop header
100
+ .filter(Boolean)
101
+ .map((line) => {
102
+ const [date, ...counts] = line.split(',')
103
+ const row = { date }
104
+ COLUMNS.forEach((c, i) => (row[c] = Number(counts[i] || 0)))
105
+ return row
106
+ })
107
+ }
108
+ let row = rows.find((r) => r.date === day)
109
+ if (!row) {
110
+ row = { date: day, completed: 0, created: 0, rejected: 0 }
111
+ rows.push(row)
112
+ }
113
+ row[kind] += amount
114
+ const out = ['date,' + COLUMNS.join(',')]
115
+ for (const r of rows) out.push([r.date, ...COLUMNS.map((c) => r[c])].join(','))
116
+ fs.writeFileSync(METRICS, out.join('\n') + '\n')
117
+ }
118
+
119
+ // ---- locate a task by id ---------------------------------------------------
120
+
121
+ function walkMd(dir, acc = []) {
122
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
123
+ const full = path.join(dir, entry.name)
124
+ if (entry.isDirectory()) walkMd(full, acc)
125
+ else if (entry.name.endsWith('.md')) acc.push(full)
126
+ }
127
+ return acc
128
+ }
129
+
130
+ function walkDirs(dir, acc = []) {
131
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
132
+ if (!entry.isDirectory()) continue
133
+ const full = path.join(dir, entry.name)
134
+ acc.push(full)
135
+ walkDirs(full, acc)
136
+ }
137
+ return acc
138
+ }
139
+
140
+ const idPrefix = (name) => {
141
+ const m = name.match(/^(\d+)-/)
142
+ return m ? Number(m[1]) : null
143
+ }
144
+
145
+ // Returns { kind: 'group'|'file', target, rel } or null.
146
+ // group — an id-prefixed folder holding a root.md tracking card; target is the folder.
147
+ // found at any depth, so a recurring folder-task (its card plus sibling docs)
148
+ // resolves the same way a top-level group root does.
149
+ // file — a single card (standalone or a group's subtask); target is the file.
150
+ function locate(id) {
151
+ const groupDir = walkDirs(TODO).find(
152
+ (d) => idPrefix(path.basename(d)) === id && fs.existsSync(path.join(d, 'root.md')),
153
+ )
154
+ if (groupDir) {
155
+ return { kind: 'group', target: groupDir, rel: path.relative(TODO, groupDir) }
156
+ }
157
+ const hit = walkMd(TODO).find((f) => idPrefix(path.basename(f)) === id)
158
+ if (hit) return { kind: 'file', target: hit, rel: path.relative(TODO, hit) }
159
+ return null
160
+ }
161
+
162
+ // If `file` is a subtask nested inside a group task, return that group's root.md
163
+ // (the nearest ancestor folder holding one). Null for a standalone card. Used so
164
+ // archiving a subtask can tick it off in the group's tracking card.
165
+ function enclosingGroupRoot(file) {
166
+ let dir = path.dirname(file)
167
+ while (dir.startsWith(TODO) && dir !== TODO) {
168
+ const root = path.join(dir, 'root.md')
169
+ if (fs.existsSync(root) && root !== file) return root
170
+ dir = path.dirname(dir)
171
+ }
172
+ return null
173
+ }
174
+
175
+ // Reflect a subtask's fate in its group's root.md ## Todo. `action` is 'tick' (archive:
176
+ // flip `- [ ] … #id` to `- [x]`) or 'strike' (reject: wrap the item text in ~~…~~, leaving
177
+ // the box). Matches the first bullet whose text references `#id` — `#id\b` keeps #1 from
178
+ // matching #14 — and skips a line already in the target state. Returns true if a line
179
+ // changed, false if there's no matching subtask line to mark.
180
+ function markSubtask(rootFile, id, action) {
181
+ const lines = fs.readFileSync(rootFile, 'utf8').split('\n')
182
+ for (let i = 0; i < lines.length; i++) {
183
+ const line = lines[i]
184
+ if (action === 'tick') {
185
+ const re = new RegExp(`^(\\s*[-*]\\s*\\[) \\](.*#${id}\\b)`)
186
+ if (re.test(line)) {
187
+ lines[i] = line.replace(re, '$1x]$2')
188
+ fs.writeFileSync(rootFile, lines.join('\n'))
189
+ return true
190
+ }
191
+ } else {
192
+ // strike: a bullet (with or without a checkbox) referencing #id, not already struck
193
+ const re = new RegExp(`^(\\s*[-*]\\s+(?:\\[[ xX]\\]\\s+)?)(.*#${id}\\b.*)$`)
194
+ const m = line.match(re)
195
+ if (m && !m[2].includes('~~')) {
196
+ lines[i] = `${m[1]}~~${m[2]}~~`
197
+ fs.writeFileSync(rootFile, lines.join('\n'))
198
+ return true
199
+ }
200
+ }
201
+ }
202
+ return false
203
+ }
204
+
205
+ // ---- strip README entries --------------------------------------------------
206
+
207
+ const isTableRow = (line) => /^\s*\|/.test(line)
208
+ const isBulletStart = (line) => /^\s*[-*] /.test(line)
209
+ const bulletIndent = (line) => line.match(/^(\s*)/)[1].length
210
+
211
+ // Removes every README entry that LINKS the task's own path. Cross-mentions of the id
212
+ // as bare `#id` text in other cards' prose carry no link, so they are left untouched.
213
+ // A single-line bullet or table row drops on its own; a multi-line group bullet drops
214
+ // with its wrapped continuation lines.
215
+ function stripReadmeRefs(target) {
216
+ if (!fs.existsSync(README)) return []
217
+ const needle = target.kind === 'group' ? `](${target.rel}/` : `](${target.rel})`
218
+ const lines = fs.readFileSync(README, 'utf8').split('\n')
219
+ const out = []
220
+ const removed = []
221
+ let i = 0
222
+ while (i < lines.length) {
223
+ const line = lines[i]
224
+ if (line.includes(needle) && (isTableRow(line) || isBulletStart(line))) {
225
+ removed.push(line.trim())
226
+ if (isBulletStart(line)) {
227
+ const indent = bulletIndent(line)
228
+ i++
229
+ // consume wrapped continuation lines (indented deeper, not a new bullet)
230
+ while (
231
+ i < lines.length &&
232
+ lines[i].trim() !== '' &&
233
+ !isBulletStart(lines[i]) &&
234
+ !/^\s*#/.test(lines[i]) &&
235
+ !isTableRow(lines[i]) &&
236
+ bulletIndent(lines[i]) > indent
237
+ ) {
238
+ i++
239
+ }
240
+ } else {
241
+ i++ // table row
242
+ }
243
+ continue
244
+ }
245
+ out.push(line)
246
+ i++
247
+ }
248
+ if (removed.length) fs.writeFileSync(README, out.join('\n'))
249
+ return removed
250
+ }
251
+
252
+ // ---- drop cross-references -------------------------------------------------
253
+
254
+ // Remove `id` from every other card's `blocked_by`/`related`. Run when a card leaves the
255
+ // board (archive or reject): the id is gone, so a card still listing it is blocked by
256
+ // nothing and pointing at nothing. Without this the board keeps a card "blocked" forever
257
+ // and reconcileCrossRefs can only warn about it.
258
+ //
259
+ // Edits the two list lines in place rather than re-serializing the frontmatter, so a card
260
+ // this script never wrote keeps whatever else it has. Only the inline `[1, 2]` form the
261
+ // script writes is matched — a hand-written block list falls through to the reconcile
262
+ // warning instead of being silently missed.
263
+ const REF_LIST = /^(blocked_by|related):\s*\[(.*)\]\s*$/
264
+
265
+ function dropCrossRefs(id) {
266
+ const touched = []
267
+ for (const file of walkMd(TODO)) {
268
+ if (path.basename(file) === 'README.md') continue
269
+ const lines = fs.readFileSync(file, 'utf8').split('\n')
270
+ if (lines[0].trim() !== '---') continue
271
+ let end = 1
272
+ while (end < lines.length && lines[end].trim() !== '---') end++
273
+ if (end >= lines.length) continue // no closing fence — not frontmatter
274
+ const fields = []
275
+ for (let i = 1; i < end; i++) {
276
+ const m = lines[i].match(REF_LIST)
277
+ if (!m) continue
278
+ const refs = m[2].split(',').map((s) => s.trim()).filter(Boolean)
279
+ const kept = refs.filter((s) => Number(s.replace(/^#/, '')) !== id)
280
+ if (kept.length === refs.length) continue
281
+ lines[i] = `${m[1]}: [${kept.join(', ')}]`
282
+ fields.push(m[1])
283
+ }
284
+ if (!fields.length) continue
285
+ fs.writeFileSync(file, lines.join('\n'))
286
+ touched.push(`${path.relative(TODO, file).split(path.sep).join('/')} (${fields.join(', ')})`)
287
+ }
288
+ return touched
289
+ }
290
+
291
+ // ---- flags + validation (guards against hallucinated meta) -----------------
292
+
293
+ // Minimal flag parser. `--key value` sets a string; a repeated `--key` builds an
294
+ // array; a `--key` with no following value (or followed by another `--`) is a
295
+ // boolean. An unknown flag is a hard error so a mistyped/hallucinated option can't
296
+ // be silently ignored.
297
+ function parseFlags(args, allowed) {
298
+ const flags = {}
299
+ const positional = []
300
+ // Every flag in the order it was typed. Most commands read `flags`, where a
301
+ // repeated flag collapses into a list; the question flags need the order too,
302
+ // because --options/--pick/--recommend belong to the --question before them.
303
+ const order = []
304
+ for (let i = 0; i < args.length; i++) {
305
+ const a = args[i]
306
+ if (a.startsWith('--')) {
307
+ const key = a.slice(2)
308
+ if (allowed && !allowed.includes(key)) {
309
+ die(`unknown option "--${key}". allowed: ${allowed.map((f) => '--' + f).join(', ')}`)
310
+ }
311
+ const next = args[i + 1]
312
+ if (next === undefined || next.startsWith('--')) {
313
+ flags[key] = true
314
+ order.push([key, true])
315
+ } else {
316
+ if (flags[key] === undefined) flags[key] = next
317
+ else if (Array.isArray(flags[key])) flags[key].push(next)
318
+ else flags[key] = [flags[key], next]
319
+ order.push([key, next])
320
+ i++
321
+ }
322
+ } else {
323
+ positional.push(a)
324
+ }
325
+ }
326
+ return { flags, positional, order }
327
+ }
328
+
329
+ function slugify(s) {
330
+ const out = String(s)
331
+ .toLowerCase()
332
+ .replace(/[^a-z0-9]+/g, '-')
333
+ .replace(/^-+|-+$/g, '')
334
+ .slice(0, 60)
335
+ .replace(/-+$/g, '')
336
+ return out || 'task'
337
+ }
338
+
339
+ const LEVELS = ['high', 'med', 'low']
340
+
341
+ function validLevel(v, name) {
342
+ if (!LEVELS.includes(v)) die(`--${name} must be one of ${LEVELS.join(' | ')} (got "${v}")`)
343
+ }
344
+
345
+ // The stages a card can rest in, in order: `todo` (raw), `ready` (plan concrete,
346
+ // no open questions, someone could start now), `implementing`. `reject`/`archive`
347
+ // take the card off the board, so they are not statuses — a live run's action is
348
+ // tracked in the UI registry, not here. A missing status reads as `todo`, so cards
349
+ // written before this field still parse.
350
+ const STATUSES = ['todo', 'ready', 'implementing']
351
+
352
+ function validStatus(v) {
353
+ if (!STATUSES.includes(v)) die(`--status must be one of ${STATUSES.join(' | ')} (got "${v}")`)
354
+ }
355
+
356
+ function trackNames() {
357
+ return fs
358
+ .readdirSync(TODO, { withFileTypes: true })
359
+ .filter((e) => e.isDirectory())
360
+ .map((e) => e.name)
361
+ }
362
+
363
+ function validTrack(track) {
364
+ const dir = path.join(TODO, track)
365
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
366
+ die(
367
+ `unknown track "${track}". existing tracks: ${trackNames().join(', ') || '(none)'}. ` +
368
+ `make the folder first or pick one of these — don't invent a track.`,
369
+ )
370
+ }
371
+ }
372
+
373
+ // The module map (docs/kanban/modules.md) lists the project's parts, one per line,
374
+ // each led by its **bolded name**. Parse just that bolded name at the front of a line —
375
+ // nothing else on the line. Returns null when there's no map yet (a pre-map install), so
376
+ // callers can skip the field instead of failing.
377
+ function moduleNames() {
378
+ if (!fs.existsSync(MODULES_MD)) return null
379
+ const names = []
380
+ for (const line of fs.readFileSync(MODULES_MD, 'utf8').split('\n')) {
381
+ const m = line.match(/^\s*[-*]\s+\*\*([^*]+)\*\*/)
382
+ if (m) names.push(m[1].trim())
383
+ }
384
+ return names
385
+ }
386
+
387
+ // Split a --modules value (repeatable and/or comma-separated) into a clean name list.
388
+ function parseModuleList(raw) {
389
+ return (Array.isArray(raw) ? raw : [raw])
390
+ .flatMap((s) => String(s).split(','))
391
+ .map((s) => s.trim())
392
+ .filter(Boolean)
393
+ }
394
+
395
+ // Validate tags against the module map, the same way --track checks the track folders.
396
+ // No map yet → the field is skipped (returns []), not an error, so a pre-map install still
397
+ // works. An unknown name is a hard error whose message lists the known names and says how
398
+ // to add one — that message is the whole refresh path, so a new module gets on the map the
399
+ // moment someone tags a card with it.
400
+ function validModules(mods) {
401
+ const known = moduleNames()
402
+ if (known === null) {
403
+ if (mods.length) warn(`no ${rel(MODULES_MD)} yet — skipping --modules ${mods.join(', ')}.`)
404
+ return []
405
+ }
406
+ const unknown = mods.filter((mod) => !known.includes(mod))
407
+ if (unknown.length) {
408
+ die(
409
+ `unknown module(s): ${unknown.join(', ')}. known modules: ${known.join(', ') || '(none)'}. ` +
410
+ `if this really is a new part of the project, add a line to ${rel(MODULES_MD)} first ` +
411
+ `(\`- **<name>** — <what it is>.\`), then tag the card — that line is how the map grows.`,
412
+ )
413
+ }
414
+ return mods
415
+ }
416
+
417
+ // Ids must be plain numbers already allocated (< ceiling). Rejects invented ids
418
+ // like #999 that were never handed out.
419
+ function parseIdList(raw, name, ceiling) {
420
+ const parts = (Array.isArray(raw) ? raw : [raw])
421
+ .flatMap((s) => String(s).split(','))
422
+ .map((s) => s.trim().replace(/^#/, ''))
423
+ .filter(Boolean)
424
+ return parts.map((p) => {
425
+ if (!/^\d+$/.test(p)) die(`--${name} takes task ids (numbers), got "${p}"`)
426
+ const n = Number(p)
427
+ if (n < 1 || n >= ceiling) {
428
+ die(`--${name} points at #${n}, not a real task id (ids so far go up to ${ceiling - 1}). don't invent ids.`)
429
+ }
430
+ return n
431
+ })
432
+ }
433
+
434
+ // ---- frontmatter read/write ------------------------------------------------
435
+
436
+ function yamlScalar(s) {
437
+ s = String(s)
438
+ if (s === '') return '""'
439
+ // Quote anything that could confuse a YAML reader; otherwise keep it plain.
440
+ if (/^[-?:,[\]{}#&*!|>'"%@`]/.test(s) || /:\s/.test(s) || /[\n"]/.test(s) || /^\s|\s$/.test(s)) {
441
+ return JSON.stringify(s)
442
+ }
443
+ return s
444
+ }
445
+
446
+ function serializeFrontmatter(m) {
447
+ const out = ['---']
448
+ out.push(`title: ${yamlScalar(m.title)}`)
449
+ out.push(`track: ${yamlScalar(m.track)}`)
450
+ out.push(`priority: ${m.priority}`)
451
+ out.push(`roi: ${m.roi}`)
452
+ out.push(`status: ${STATUSES.includes(m.status) ? m.status : 'todo'}`)
453
+ out.push(`blocked_by: [${(m.blocked_by || []).join(', ')}]`)
454
+ out.push(`related: [${(m.related || []).join(', ')}]`)
455
+ out.push(`modules: [${(m.modules || []).join(', ')}]`)
456
+ if (!m.questions || m.questions.length === 0) out.push('questions: []')
457
+ else {
458
+ out.push('questions:')
459
+ for (const raw of m.questions) {
460
+ const q = normalizeQuestion(raw)
461
+ if (!hasOptions(q)) {
462
+ out.push(` - ${yamlScalar(q.text)}`)
463
+ continue
464
+ }
465
+ out.push(` - question: ${yamlScalar(q.text)}`)
466
+ out.push(` pick: ${q.pick}`)
467
+ out.push(' options:')
468
+ for (const o of q.options) out.push(` - ${yamlScalar(o)}`)
469
+ out.push(` recommend: [${q.recommend.join(', ')}]`)
470
+ }
471
+ }
472
+ out.push('---')
473
+ return out.join('\n')
474
+ }
475
+
476
+ function unquote(v) {
477
+ v = String(v).trim()
478
+ if (v.startsWith('"') && v.endsWith('"')) {
479
+ try {
480
+ return JSON.parse(v)
481
+ } catch {
482
+ return v.slice(1, -1)
483
+ }
484
+ }
485
+ if (v.startsWith("'") && v.endsWith("'")) return v.slice(1, -1)
486
+ return v
487
+ }
488
+
489
+ // Parse the leading `--- ... ---` block into a meta object; returns the rest as body.
490
+ // Only needs to read what this script (and `migrate`) write.
491
+ function parseFrontmatter(text) {
492
+ const lines = text.split('\n')
493
+ if (lines[0].trim() !== '---') return { meta: null, body: text }
494
+ let i = 1
495
+ const fm = []
496
+ while (i < lines.length && lines[i].trim() !== '---') {
497
+ fm.push(lines[i])
498
+ i++
499
+ }
500
+ if (i >= lines.length) return { meta: null, body: text }
501
+ const meta = {}
502
+ for (let j = 0; j < fm.length; j++) {
503
+ const m = fm[j].match(/^([A-Za-z_]+):\s*(.*)$/)
504
+ if (!m) continue
505
+ const key = m[1]
506
+ const val = m[2]
507
+ // `questions:` holds two shapes at once — a plain line and an options block —
508
+ // so it gets its own reader instead of the generic list branch below.
509
+ if (key === 'questions') {
510
+ if (val === '') {
511
+ const block = []
512
+ while (j + 1 < fm.length && /^\s/.test(fm[j + 1]) && fm[j + 1].trim() !== '') {
513
+ block.push(fm[j + 1])
514
+ j++
515
+ }
516
+ meta.questions = parseQuestionsBlock(block)
517
+ } else {
518
+ meta.questions = val.trim() === '[]' ? [] : [normalizeQuestion(unquote(val))]
519
+ }
520
+ continue
521
+ }
522
+ if (val === '') {
523
+ const items = []
524
+ while (j + 1 < fm.length && /^\s*-\s+/.test(fm[j + 1])) {
525
+ items.push(unquote(fm[j + 1].replace(/^\s*-\s+/, '')))
526
+ j++
527
+ }
528
+ meta[key] = items
529
+ } else if (val.startsWith('[')) {
530
+ const inner = val.slice(1, val.lastIndexOf(']'))
531
+ meta[key] = inner.split(',').map((s) => s.trim()).filter(Boolean).map(unquote)
532
+ } else {
533
+ meta[key] = unquote(val)
534
+ }
535
+ }
536
+ for (const k of ['blocked_by', 'related']) {
537
+ if (Array.isArray(meta[k])) {
538
+ meta[k] = meta[k].map((x) => Number(String(x).replace(/^#/, ''))).filter((n) => Number.isInteger(n))
539
+ } else {
540
+ meta[k] = []
541
+ }
542
+ }
543
+ if (!Array.isArray(meta.questions)) meta.questions = meta.questions ? [normalizeQuestion(meta.questions)] : []
544
+ // modules is an optional string list; a card written before this field parses as [].
545
+ if (!Array.isArray(meta.modules)) meta.modules = []
546
+ return { meta, body: lines.slice(i + 1).join('\n') }
547
+ }
548
+
549
+ // ---- questions -------------------------------------------------------------
550
+ //
551
+ // A question comes in two shapes. A PLAIN one is a single line the user answers
552
+ // in a text box — what every card has always written. An OPTIONS one carries
553
+ // choices the user ticks instead of reading them out of a sentence:
554
+ //
555
+ // questions:
556
+ // - a plain question stays one line
557
+ // - question: Where should the board live?
558
+ // pick: one
559
+ // options:
560
+ // - local files — simple
561
+ // - GitHub Projects — syncs with issues
562
+ // recommend: [1]
563
+ //
564
+ // `pick: one` lets the user tick one option, `pick: many` as many as they want.
565
+ // `recommend` holds 1-based positions into `options` — the ones the resolve
566
+ // dialog opens already ticked; `[]` means nothing is pre-ticked. An option is
567
+ // one short line with its reason inside it; there is no note field beside it.
568
+ //
569
+ // In memory every question is an object: `{ text }` for a plain one, plus
570
+ // `pick`, `options` and `recommend` when it has options. Kept in step with
571
+ // kanban-ui/lib/frontmatter.ts and kanban-ui/lib/questions.ts.
572
+ const PICKS = ['one', 'many']
573
+
574
+ function hasOptions(q) {
575
+ return Array.isArray(q.options) && q.options.length > 0
576
+ }
577
+
578
+ // Read any accepted form — a plain string, or the mapping the block above parses
579
+ // into — as one question object. An options list shorter than one entry reads as
580
+ // a plain question, so a half-written card still opens.
581
+ function normalizeQuestion(raw) {
582
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
583
+ const text = String(raw.question ?? raw.text ?? '')
584
+ const options = (Array.isArray(raw.options) ? raw.options : []).map((o) => String(o).trim()).filter(Boolean)
585
+ if (options.length === 0) return { text }
586
+ const pick = PICKS.includes(String(raw.pick)) ? String(raw.pick) : 'one'
587
+ const recommend = (Array.isArray(raw.recommend) ? raw.recommend : [])
588
+ .map(Number)
589
+ .filter((n) => Number.isInteger(n) && n >= 1 && n <= options.length)
590
+ return { text, pick, options, recommend: pick === 'one' ? recommend.slice(0, 1) : recommend }
591
+ }
592
+ return { text: String(raw) }
593
+ }
594
+
595
+ // Read the indented block under `questions:`. An item is either `- <text>` (plain)
596
+ // or `- question: <text>` followed by its `pick:`, `options:` and `recommend:` lines.
597
+ function parseQuestionsBlock(lines) {
598
+ const out = []
599
+ for (let i = 0; i < lines.length; i++) {
600
+ const m = lines[i].match(/^\s*-\s+([\s\S]*)$/)
601
+ if (!m) continue
602
+ const head = m[1]
603
+ const opened = head.match(/^question:\s*(.*)$/)
604
+ if (!opened) {
605
+ out.push({ text: unquote(head) })
606
+ continue
607
+ }
608
+ const q = { question: unquote(opened[1]), options: [], recommend: [] }
609
+ // Keep reading this question's fields until the next `- ` item. The option
610
+ // lines are `- ` items themselves, so they're consumed as they're found.
611
+ while (i + 1 < lines.length && !/^\s*-\s/.test(lines[i + 1])) {
612
+ const field = lines[i + 1].match(/^\s*([A-Za-z_]+):\s*(.*)$/)
613
+ i++
614
+ if (!field) continue
615
+ const [, key, val] = field
616
+ if (key === 'options') {
617
+ while (i + 1 < lines.length && /^\s*-\s+/.test(lines[i + 1])) {
618
+ q.options.push(unquote(lines[i + 1].replace(/^\s*-\s+/, '')))
619
+ i++
620
+ }
621
+ } else if (key === 'recommend') {
622
+ q.recommend = val
623
+ .replace(/^\[|\]$/g, '')
624
+ .split(',')
625
+ .map((s) => Number(s.trim()))
626
+ .filter((n) => Number.isInteger(n))
627
+ } else if (key === 'pick') {
628
+ q.pick = unquote(val)
629
+ }
630
+ }
631
+ out.push(normalizeQuestion(q))
632
+ }
633
+ return out
634
+ }
635
+
636
+ // ---- question tags ---------------------------------------------------------
637
+ //
638
+ // An open question may lead with a `[user] ...` tag token — a judgment call the
639
+ // human must make. No token means untagged: freshly raised, not yet triaged.
640
+ // There is no tag for an answered question — answering removes it from the list.
641
+ // The tag lives at the front of the question's text, on both shapes; parseQuestion
642
+ // splits it off, formatQuestion puts it back. Kept in step with parseQuestion in
643
+ // kanban-ui/lib/questions.ts.
644
+ const QUESTION_TAGS = ['user']
645
+
646
+ function parseQuestion(raw) {
647
+ const m = String(raw).match(/^\[(user)\]\s+([\s\S]*)$/)
648
+ return m ? { tag: m[1], text: m[2] } : { tag: null, text: String(raw) }
649
+ }
650
+
651
+ function formatQuestion(tag, text) {
652
+ return tag ? `[${tag}] ${text}` : text
653
+ }
654
+
655
+ // Warn (don't fail) when a question leads with a `[...]` token that isn't a known
656
+ // tag — almost always a typo like `[users]` that would silently read as text.
657
+ function warnBadQuestionTags(questions) {
658
+ for (const q of questions) {
659
+ const m = String(q.text).match(/^\[([^\]]+)\]\s/)
660
+ if (m && !QUESTION_TAGS.includes(m[1].toLowerCase())) {
661
+ warn(`question tag "[${m[1]}]" isn't recognised — use [user] (or no tag). Stored as literal text.`)
662
+ }
663
+ }
664
+ }
665
+
666
+ // Build the question list from the flags as they were typed: each `--question`
667
+ // starts a new one, and `--options`, `--pick` and `--recommend` after it belong
668
+ // to that question. A question with no `--options` stays plain.
669
+ function collectQuestions(order) {
670
+ const out = []
671
+ const current = (flag) => {
672
+ const q = out[out.length - 1]
673
+ if (!q) die(`--${flag} must come after the --question it belongs to`)
674
+ return q
675
+ }
676
+ for (const [key, value] of order) {
677
+ if (key === 'question') {
678
+ const text = value === true ? '' : String(value).trim()
679
+ if (!text) die('--question must not be empty')
680
+ out.push({ question: text, options: [], recommend: [] })
681
+ } else if (key === 'options') {
682
+ const q = current('options')
683
+ if (q.options.length) die(`one --options per --question (got a second for "${q.question}")`)
684
+ const opts = String(value)
685
+ .split('|')
686
+ .map((s) => s.trim())
687
+ .filter(Boolean)
688
+ if (opts.length < 2) die('--options needs at least 2 choices, separated by "|" (e.g. "local files — simple | GitHub Projects — syncs with issues")')
689
+ q.options = opts
690
+ } else if (key === 'pick') {
691
+ const q = current('pick')
692
+ const p = String(value).toLowerCase()
693
+ if (!PICKS.includes(p)) die(`--pick must be one | many (got "${value}")`)
694
+ q.pick = p
695
+ } else if (key === 'recommend') {
696
+ const q = current('recommend')
697
+ const ns = String(value)
698
+ .split(',')
699
+ .map((s) => s.trim())
700
+ .filter(Boolean)
701
+ .map(Number)
702
+ if (ns.length === 0 || ns.some((n) => !Number.isInteger(n) || n < 1)) {
703
+ die('--recommend needs one or more 1-based option numbers (e.g. 1 or 1,3)')
704
+ }
705
+ q.recommend = ns
706
+ }
707
+ }
708
+ for (const q of out) {
709
+ if (q.options.length === 0) {
710
+ if (q.pick !== undefined || q.recommend.length) {
711
+ die(`--pick and --recommend only apply to a question with --options ("${q.question}")`)
712
+ }
713
+ continue
714
+ }
715
+ q.pick = q.pick || 'one'
716
+ const over = q.recommend.find((n) => n > q.options.length)
717
+ if (over !== undefined) die(`--recommend points at option ${over}, but that question has ${q.options.length}`)
718
+ if (q.pick === 'one' && q.recommend.length > 1) die('--pick one takes at most one --recommend option')
719
+ }
720
+ return out.map(normalizeQuestion)
721
+ }
722
+
723
+ // One or more 1-based question positions (`1` or `1,3`), validated against the
724
+ // card's open-question count.
725
+ function parseQuestionPositions(raw, count, flagName) {
726
+ const ns = String(raw)
727
+ .split(',')
728
+ .map((s) => s.trim())
729
+ .filter((s) => s.length > 0)
730
+ .map(Number)
731
+ if (ns.length === 0 || ns.some((n) => !Number.isInteger(n) || n < 1)) {
732
+ die(`--${flagName} needs one or more 1-based question numbers (e.g. 1 or 1,3)`)
733
+ }
734
+ const over = ns.find((n) => n > count)
735
+ if (over !== undefined) die(`the card has ${count} open question(s) — there's no question ${over}`)
736
+ return ns
737
+ }
738
+
739
+ // A todo item in any accepted form: `- [ ]`, `- []`, `- [x]`, `* [X]`, … — the shape
740
+ // counts, not the literal string.
741
+ const TODO_ITEM = /^[ \t]*[-*+][ \t]*\[[ xX]?\]/m
742
+
743
+ function defaultBody() {
744
+ return [
745
+ '<one short line: what to do and why it matters.>',
746
+ '',
747
+ '## Scope',
748
+ '- <the concrete steps>',
749
+ '',
750
+ '## Todo',
751
+ '- [ ] every task must have todos — replace this line with the real steps.',
752
+ '',
753
+ ].join('\n')
754
+ }
755
+
756
+ // ---- README index entries --------------------------------------------------
757
+
758
+ const readmeHeadingFor = (track) => (track === 'blockers' ? 'Blockers' : track)
759
+
760
+ // Insert a card's bullet under its track heading, replacing a `_(none)_` placeholder
761
+ // or appending after the section's last bullet. Adds the section if it's missing.
762
+ function addReadmeRef(track, id, title, relPath) {
763
+ if (!fs.existsSync(README)) return false
764
+ const link = relPath.split(path.sep).join('/')
765
+ const bullet = `- [#${id} ${title}](${link})`
766
+ const heading = `## ${readmeHeadingFor(track)}`
767
+ let lines = fs.readFileSync(README, 'utf8').split('\n')
768
+ const hi = lines.findIndex((l) => l.trim().toLowerCase() === heading.toLowerCase())
769
+ if (hi === -1) {
770
+ while (lines.length && lines[lines.length - 1].trim() === '') lines.pop()
771
+ lines.push('', heading, '', bullet)
772
+ fs.writeFileSync(README, lines.join('\n') + '\n')
773
+ return true
774
+ }
775
+ let end = hi + 1
776
+ while (end < lines.length && !/^##\s/.test(lines[end])) end++
777
+ const noneRel = lines.slice(hi + 1, end).findIndex((l) => l.trim() === '_(none)_')
778
+ if (noneRel !== -1) {
779
+ lines[hi + 1 + noneRel] = bullet
780
+ } else {
781
+ let lastBullet = -1
782
+ for (let k = hi + 1; k < end; k++) if (/^\s*-\s/.test(lines[k])) lastBullet = k
783
+ const at = lastBullet !== -1 ? lastBullet + 1 : lines[hi + 1] === '' ? hi + 2 : hi + 1
784
+ lines.splice(at, 0, bullet)
785
+ }
786
+ fs.writeFileSync(README, lines.join('\n'))
787
+ return true
788
+ }
789
+
790
+ const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
791
+
792
+ // Fix a README link to task #id in place, after a subtask rename or retitle. A
793
+ // subtask's only README line is the nested bullet under its group root, so the
794
+ // bullet keeps its position — only the link text and target change. No-op when
795
+ // the README doesn't link the card.
796
+ function repointReadmeLink(id, oldRel, newRel, title) {
797
+ if (!fs.existsSync(README)) return false
798
+ const oldLink = oldRel.split(path.sep).join('/')
799
+ const newLink = newRel.split(path.sep).join('/')
800
+ const linkRe = new RegExp(`\\[#${id}\\b[^\\]]*\\]\\(${escapeRegex(oldLink)}\\)`)
801
+ const lines = fs.readFileSync(README, 'utf8').split('\n')
802
+ let changed = false
803
+ for (let i = 0; i < lines.length; i++) {
804
+ if (!lines[i].includes(`](${oldLink})`)) continue
805
+ const next = linkRe.test(lines[i])
806
+ ? lines[i].replace(linkRe, () => `[#${id} ${title}](${newLink})`)
807
+ : lines[i].split(`](${oldLink})`).join(`](${newLink})`)
808
+ if (next !== lines[i]) {
809
+ lines[i] = next
810
+ changed = true
811
+ }
812
+ }
813
+ if (changed) fs.writeFileSync(README, lines.join('\n'))
814
+ return changed
815
+ }
816
+
817
+ // ---- init ------------------------------------------------------------------
818
+
819
+ // Default tracks when `init` is run with no track args. Swap by passing your own,
820
+ // e.g. `init growth validation building`. Keep in step with the SKILL.md defaults.
821
+ const DEFAULT_TRACKS = ['feature', 'bug', 'research']
822
+
823
+ // The memory file set — the same four files fill a memory path at either level:
824
+ // `memory/` itself (the project-wide memory, covering the whole project) and each
825
+ // module's own path at `memory/<module>/`. Each starter is a short header that tells the
826
+ // next reader what the file is for; the flows fill in the rest over time. Plain language,
827
+ // to match the skill.
828
+ const MEMORY_SET = {
829
+ 'readme.md': `# Shipped
830
+
831
+ User-facing work that has shipped, one line each — a link to the published doc that
832
+ covers it, or a plain-words note.
833
+
834
+ _(nothing recorded yet — the first finished task fills it in.)_
835
+ `,
836
+ 'decisions.md': `# Decisions
837
+
838
+ Settled answers to cards' open questions, grouped by topic. Keep only **user-facing**
839
+ calls that guide future planning — what a user can see, do, or would care about.
840
+ Internal detail stays on the card.
841
+ `,
842
+ 'redesign.md': `# Redesign
843
+
844
+ Design mistakes to avoid when writing a card, grouped by topic. One entry each: the
845
+ mistake, then the design we actually want. Read before writing or reviewing a card.
846
+ `,
847
+ 'rejected.md': `# Rejected
848
+
849
+ Ideas we turned down, grouped by topic. One line each: the idea, and why we said no. Read
850
+ before proposing so you don't re-suggest them.
851
+ `,
852
+ }
853
+
854
+ // `goal.md` is the board root's alone. The project has one direction, and every flow
855
+ // judges a card against that one file — a per-module copy would only split it. So the
856
+ // project-wide path gets these five files; a module path gets the four above.
857
+ // It seeds `reviewed: weak` — a fresh template is not a goal to plan from.
858
+ const PROJECT_MEMORY_SET = {
859
+ ...MEMORY_SET,
860
+ 'goal.md': `---
861
+ reviewed: weak
862
+ ---
863
+
864
+ # Goal
865
+
866
+ Where this is headed, in the user's own words: the long-term goal, the horizon it aims
867
+ at, and the roadmap of what comes next, roughly in order. Not this week's work — that's
868
+ the cards on the board. The user owns this file; the agent seeds it but does not invent
869
+ the goal.
870
+
871
+ _(not filled in yet — the user writes this.)_
872
+ `,
873
+ }
874
+
875
+ // The blank module map. `init` seeds it, the module-map flow fills it in from the repo.
876
+ // It ships empty on purpose: only someone who has read the repo can name its parts, and
877
+ // a made-up line here would validate a card's --modules tag against a lie. An empty map
878
+ // still parses — moduleNames() reads it as "no modules yet", so --modules is a hard error
879
+ // naming what's known, which is the prompt to add the line.
880
+ const MODULES_TEMPLATE = `# Modules
881
+
882
+ What this project is made of — one line per module, each led by its **bolded name**, then
883
+ what it is and the paths it covers. A module is a part of the product that grows on its
884
+ own, judged by meaning, not by folder.
885
+
886
+ If a line here disagrees with the repo you just read, fix the line.
887
+
888
+ _(not filled in yet — build the map from the repo before tagging a card.)_
889
+ `
890
+
891
+ function boardReadme(tracks) {
892
+ const sections = ['## Blockers', '', '_(none)_', '']
893
+ for (const t of tracks) sections.push(`## ${t}`, '', '_(none)_', '')
894
+ return `# Board
895
+
896
+ Open tasks for the kanban board. One card per file. Ids are global and never reused —
897
+ the number at the front of a filename is the task id.
898
+
899
+ Blockers gate the next milestone; clear them first. Everything else sits under a track.
900
+
901
+ ${sections.join('\n')}`
902
+ }
903
+
904
+ // Seed the per-project config at docs/kanban/config.md from the blank template shipped
905
+ // beside this script. The config lives WITH the board (not in the skill folder) so a
906
+ // read-only plugin cache still yields an editable, per-project file. Idempotent: it never
907
+ // overwrites a config that's already there, so a filled-in config survives a re-run.
908
+ function writeConfigIfMissing() {
909
+ if (fs.existsSync(CONFIG)) return false
910
+ const template = path.join(SCRIPT_DIR, 'config.md')
911
+ if (!fs.existsSync(template)) die(`missing config template at ${template}`)
912
+ fs.mkdirSync(KANBAN, { recursive: true })
913
+ fs.copyFileSync(template, CONFIG)
914
+ return true
915
+ }
916
+
917
+ // Seed the blank module map. Same contract as the config: idempotent, and it never
918
+ // touches a map that's already there, so a filled-in map survives a re-run — that's what
919
+ // makes it safe for `init` to double as the repair step for a board made before the map
920
+ // existed.
921
+ function writeModulesIfMissing() {
922
+ if (fs.existsSync(MODULES_MD)) return false
923
+ fs.mkdirSync(KANBAN, { recursive: true })
924
+ fs.writeFileSync(MODULES_MD, MODULES_TEMPLATE)
925
+ return true
926
+ }
927
+
928
+ function cmdInit(args) {
929
+ const tracks = args.length ? args : DEFAULT_TRACKS
930
+ for (const t of tracks) {
931
+ if (!/^[a-z0-9][a-z0-9-]*$/i.test(t)) {
932
+ die(`bad track name "${t}" — use letters, digits, and dashes (a folder name)`)
933
+ }
934
+ }
935
+ if (fs.existsSync(KANBAN)) {
936
+ // Re-running `init` is the repair step for a board made by an older version: it adds
937
+ // whatever that version never wrote — docs/kanban/config.md if the board predates the
938
+ // move out of the skill folder, modules.md if it predates the module map — and never
939
+ // touches either one once it's filled in.
940
+ const added = [writeConfigIfMissing() && rel(CONFIG), writeModulesIfMissing() && rel(MODULES_MD)].filter(Boolean)
941
+ // The project-wide memory, then every module already on the map, so a board whose map
942
+ // is filled in is fully repaired by this one command. A map seeded blank a line above
943
+ // names nothing yet — those paths are made once the map is written.
944
+ const scaffolded = []
945
+ const project = scaffoldProjectMemory()
946
+ if (project) scaffolded.push(project)
947
+ for (const m of moduleNames() || []) {
948
+ // A hand-written map can carry a name no folder can take. Warn and skip rather than
949
+ // die: one odd line shouldn't stop the rest of the repair.
950
+ if (!MODULE_NAME_RE.test(m)) {
951
+ warn(`skipping module "${m}" — a name needs letters, digits, and dashes to be a folder`)
952
+ continue
953
+ }
954
+ const done = scaffoldMemoryPath(m)
955
+ if (done) scaffolded.push(done)
956
+ }
957
+ // A board made before the `reviewed:` field gets it here, seeded weak.
958
+ const goalRepaired = ensureGoalReviewed()
959
+ console.log(
960
+ added.length
961
+ ? `board already exists at ${rel(KANBAN)}/ — added the missing ${added.join(', ')} (safe to re-run)`
962
+ : `board already exists at ${rel(KANBAN)}/ — ${scaffolded.length || goalRepaired ? 'board files all present' : 'nothing to do'} (safe to re-run)`,
963
+ )
964
+ for (const s of scaffolded) console.log(` memory path ${rel(s.dir)}/ — ${s.fresh ? 'created' : `added ${s.made.join(', ')}`}`)
965
+ if (goalRepaired) console.log(` added \`reviewed: weak\` to ${rel(GOAL)} — the agent judges the goal and edits the field`)
966
+ if (added.includes(rel(MODULES_MD))) {
967
+ console.log(` next: fill in ${rel(MODULES_MD)} (see "The module map"), then re-run init for the memory paths`)
968
+ }
969
+ return
970
+ }
971
+ fs.mkdirSync(path.join(TODO, 'blockers'), { recursive: true })
972
+ for (const t of tracks) fs.mkdirSync(path.join(TODO, t), { recursive: true })
973
+ fs.writeFileSync(README, boardReadme(tracks))
974
+ scaffoldProjectMemory()
975
+ writeConfigIfMissing()
976
+ writeModulesIfMissing()
977
+ writeNextId(1)
978
+ console.log(`initialised board at ${rel(KANBAN)}/`)
979
+ console.log(` tracks: ${tracks.join(', ')}`)
980
+ console.log(` next: fill the Configuration in ${rel(CONFIG)} and the map in ${rel(MODULES_MD)},`)
981
+ console.log(` then \`create\` your first task`)
982
+ }
983
+
984
+ // A module name doubles as a folder name under memory/, so it's held to the same shape as
985
+ // a track's.
986
+ const MODULE_NAME_RE = /^[a-z0-9][a-z0-9-]*$/i
987
+
988
+ // Scaffold one module's memory path with the four-file set — no `goal.md`, that one is
989
+ // the board root's alone. Idempotent: creates only what's missing, so it's safe to call
990
+ // whenever a module's name is known — `init` calls it for the whole map, and a flow about
991
+ // to write a note calls it first. Keyed by the module's bolded name in modules.md, passed
992
+ // verbatim as the folder name. Returns what it created so callers can report; `null`
993
+ // means the path was already complete.
994
+ function scaffoldMemoryPath(module) {
995
+ return scaffoldMemoryDir(path.join(MEMORY, module), MEMORY_SET)
996
+ }
997
+
998
+ // The same scaffold, one level up: the project-wide set in `memory/` itself — the four
999
+ // files plus `goal.md`.
1000
+ function scaffoldProjectMemory() {
1001
+ return scaffoldMemoryDir(MEMORY, PROJECT_MEMORY_SET)
1002
+ }
1003
+
1004
+ function scaffoldMemoryDir(dir, set) {
1005
+ const existed = fs.existsSync(dir)
1006
+ fs.mkdirSync(dir, { recursive: true })
1007
+ const made = []
1008
+ for (const [name, body] of Object.entries(set)) {
1009
+ const file = path.join(dir, name)
1010
+ if (!fs.existsSync(file)) {
1011
+ fs.writeFileSync(file, body)
1012
+ made.push(name)
1013
+ }
1014
+ }
1015
+ if (!existed) return { dir, made, fresh: true }
1016
+ return made.length ? { dir, made, fresh: false } : null
1017
+ }
1018
+
1019
+ function cmdMemoryInit(module) {
1020
+ if (!module) die('memory-init needs a module name (its bolded name in modules.md)')
1021
+ if (!MODULE_NAME_RE.test(module)) {
1022
+ die(`bad module name "${module}" — use letters, digits, and dashes (a folder name)`)
1023
+ }
1024
+ const done = scaffoldMemoryPath(module)
1025
+ if (!done) console.log(`${rel(path.join(MEMORY, module))}/ already has the full set — nothing to do`)
1026
+ else if (done.fresh) console.log(`created memory path ${rel(done.dir)}/ with the four-file set`)
1027
+ else console.log(`filled in missing files in ${rel(done.dir)}/: ${done.made.join(', ')}`)
1028
+ }
1029
+
1030
+ // ---- goal review -----------------------------------------------------------
1031
+ //
1032
+ // `goal.md`'s frontmatter carries one machine-readable field: `reviewed: strong | good | weak`
1033
+ // — whether the goal is clear enough to plan from. The agent judges and edits the field
1034
+ // itself; this script only seeds it (`init`'s scaffold and repair), never sets a judged
1035
+ // value. Reading never fails: a missing file, a missing field, or a bad value all count
1036
+ // as `weak`, and the board keeps working either way.
1037
+
1038
+ const GOAL_REVIEW_VALUES = ['strong', 'good', 'weak']
1039
+
1040
+ // The value as written, or null when the file has no valid field — the init repair
1041
+ // reads null as "add the field"; everyone else reads it as weak.
1042
+ function readGoalReviewFrom(text) {
1043
+ const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---/)
1044
+ const line = fm && fm[1].match(/^reviewed:[ \t]*(.+?)[ \t]*$/m)
1045
+ const v = line && unquote(line[1])
1046
+ return GOAL_REVIEW_VALUES.includes(v) ? v : null
1047
+ }
1048
+
1049
+ // Set the field without disturbing the rest of the file: replace the `reviewed:` line
1050
+ // in place, add it to a frontmatter block that lacks it, or open a new block on a file
1051
+ // that has none. The goal text itself is never touched.
1052
+ function writeGoalReviewInto(text, value) {
1053
+ const fm = text.match(/^---\r?\n([\s\S]*?\r?\n)---/)
1054
+ if (!fm) return `---\nreviewed: ${value}\n---\n\n${text}`
1055
+ const inner = /^reviewed:.*$/m.test(fm[1])
1056
+ ? fm[1].replace(/^reviewed:.*$/m, () => `reviewed: ${value}`)
1057
+ : `reviewed: ${value}\n${fm[1]}`
1058
+ return `---\n${inner}---${text.slice(fm[0].length)}`
1059
+ }
1060
+
1061
+ // The `reviewed:` field arrived after boards existed. `init`'s repair pass adds it to a
1062
+ // goal.md that lacks it — seeded `weak`, exactly what the missing field already reads
1063
+ // as — and never touches a value that's set, so a judged goal survives a re-run.
1064
+ function ensureGoalReviewed() {
1065
+ if (!fs.existsSync(GOAL)) return false
1066
+ const text = fs.readFileSync(GOAL, 'utf8')
1067
+ if (readGoalReviewFrom(text)) return false
1068
+ fs.writeFileSync(GOAL, writeGoalReviewInto(text, 'weak'))
1069
+ return true
1070
+ }
1071
+
1072
+ // ---- commands --------------------------------------------------------------
1073
+
1074
+ const CREATE_FLAGS = ['title', 'track', 'priority', 'roi', 'blocked-by', 'related', 'modules', 'question', 'options', 'pick', 'recommend', 'slug', 'count', 'no-body']
1075
+
1076
+ // Two modes:
1077
+ // bare `create [--count N]` → allocate ids and print them (group-task setup).
1078
+ // card mode `create --title ... --track ...` → allocate ONE id, write the card's
1079
+ // frontmatter + a body template, and index it. The script owns the meta;
1080
+ // fill the body with your editor and leave the frontmatter alone.
1081
+ function cmdCreate(args) {
1082
+ const { flags, positional } = parseFlags(args, CREATE_FLAGS)
1083
+ if (positional.length) die(`create takes options, not positional args (got "${positional.join(' ')}")`)
1084
+
1085
+ if (flags.title === undefined) {
1086
+ for (const bad of ['track', 'priority', 'roi', 'blocked-by', 'related', 'modules', 'question', 'slug', 'no-body']) {
1087
+ if (flags[bad] !== undefined) die(`--${bad} needs --title (that's card mode). Without --title, create only allocates ids.`)
1088
+ }
1089
+ const count = flags.count !== undefined ? Number(flags.count) : 1
1090
+ if (!Number.isInteger(count) || count < 1) die('--count must be a positive integer')
1091
+ const start = readNextId()
1092
+ const ids = Array.from({ length: count }, (_, k) => start + k)
1093
+ writeNextId(start + count)
1094
+ bumpMetric('created', count)
1095
+ console.log(ids.join('\n'))
1096
+ reconcileBoard()
1097
+ return
1098
+ }
1099
+
1100
+ // --- card mode ---
1101
+ if (flags.count !== undefined) die("--count can't be combined with --title (card mode makes exactly one card)")
1102
+ const title = String(flags.title).trim()
1103
+ if (!title) die('--title must not be empty')
1104
+ if (flags.track === undefined) die('--track is required in card mode (e.g. --track feature)')
1105
+ const track = String(flags.track).trim()
1106
+ validTrack(track)
1107
+ const priority = flags.priority !== undefined ? String(flags.priority) : 'med'
1108
+ validLevel(priority, 'priority')
1109
+ const roi = flags.roi !== undefined ? String(flags.roi) : 'med'
1110
+ validLevel(roi, 'roi')
1111
+ const start = readNextId()
1112
+ const blocked_by = flags['blocked-by'] !== undefined ? parseIdList(flags['blocked-by'], 'blocked-by', start) : []
1113
+ const related = flags.related !== undefined ? parseIdList(flags.related, 'related', start) : []
1114
+ const modules = flags.modules !== undefined ? validModules(parseModuleList(flags.modules)) : []
1115
+ const questions = flags.question !== undefined ? (Array.isArray(flags.question) ? flags.question : [flags.question]).map(String) : []
1116
+ warnBadQuestionTags(questions)
1117
+ const slug = slugify(flags.slug !== undefined ? flags.slug : title)
1118
+ const fileRel = path.join(track, `${start}-${slug}.md`)
1119
+ const file = path.join(TODO, fileRel)
1120
+ if (fs.existsSync(file)) die(`${rel(file)} already exists — pick a different --slug`)
1121
+
1122
+ // validation passed → allocate + write
1123
+ writeNextId(start + 1)
1124
+ bumpMetric('created')
1125
+ const meta = { title, track, priority, roi, status: 'todo', blocked_by, related, modules, questions }
1126
+ const body = flags['no-body'] ? '' : defaultBody()
1127
+ fs.writeFileSync(file, serializeFrontmatter(meta) + '\n\n' + body)
1128
+ const indexed = addReadmeRef(track, start, title, fileRel)
1129
+ console.log(start)
1130
+ console.log(` wrote ${rel(file)} — frontmatter is set; fill the body with your editor, leave the frontmatter to the script`)
1131
+ if (!TODO_ITEM.test(body)) warn(`#${start} has no todos — every task needs a \`- [ ]\` list under ## Todo`)
1132
+ if (indexed) console.log(` indexed under "## ${readmeHeadingFor(track)}"`)
1133
+ reconcileBoard()
1134
+ }
1135
+
1136
+ const UPDATE_FLAGS = ['title', 'track', 'priority', 'roi', 'status', 'blocked-by', 'related', 'modules', 'question', 'drop-question', 'clear-questions', 'slug']
1137
+
1138
+ // Rewrite a card's frontmatter. Also the sanctioned way to move a card between tracks
1139
+ // (--track moves the file + fixes the index) or rename it (--slug). Body is untouched.
1140
+ function cmdUpdate(args) {
1141
+ const { flags, positional } = parseFlags(args, UPDATE_FLAGS)
1142
+ const id = Number(positional[0])
1143
+ if (!Number.isInteger(id)) die('need a numeric task id: update <id> [--field value ...]')
1144
+ const found = locate(id)
1145
+ if (!found) die(`no task with id ${id} under ${rel(TODO)}`)
1146
+ const file = found.kind === 'group' ? path.join(found.target, 'root.md') : found.target
1147
+ const { meta, body } = parseFrontmatter(fs.readFileSync(file, 'utf8'))
1148
+ if (!meta) die(`${rel(file)} has no frontmatter — run \`migrate\` first`)
1149
+
1150
+ const changes = []
1151
+ if (flags.title !== undefined) {
1152
+ const t = String(flags.title).trim()
1153
+ if (!t) die('--title must not be empty')
1154
+ meta.title = t
1155
+ changes.push('title')
1156
+ }
1157
+ if (flags.priority !== undefined) {
1158
+ validLevel(String(flags.priority), 'priority')
1159
+ meta.priority = String(flags.priority)
1160
+ changes.push('priority')
1161
+ }
1162
+ if (flags.roi !== undefined) {
1163
+ validLevel(String(flags.roi), 'roi')
1164
+ meta.roi = String(flags.roi)
1165
+ changes.push('roi')
1166
+ }
1167
+ if (flags.status !== undefined) {
1168
+ validStatus(String(flags.status))
1169
+ meta.status = String(flags.status)
1170
+ changes.push('status')
1171
+ }
1172
+ const ceiling = readNextId()
1173
+ if (flags['blocked-by'] !== undefined) {
1174
+ meta.blocked_by = parseIdList(flags['blocked-by'], 'blocked-by', ceiling)
1175
+ changes.push('blocked_by')
1176
+ }
1177
+ if (flags.related !== undefined) {
1178
+ meta.related = parseIdList(flags.related, 'related', ceiling)
1179
+ changes.push('related')
1180
+ }
1181
+ if (flags.modules !== undefined) {
1182
+ meta.modules = validModules(parseModuleList(flags.modules))
1183
+ changes.push('modules')
1184
+ }
1185
+ if (flags['clear-questions']) {
1186
+ meta.questions = []
1187
+ changes.push('questions')
1188
+ }
1189
+ if (flags['drop-question'] !== undefined) {
1190
+ if (flags.question !== undefined || flags['clear-questions']) {
1191
+ die('--drop-question cannot combine with --question or --clear-questions — pick one')
1192
+ }
1193
+ const raw = Array.isArray(flags['drop-question']) ? flags['drop-question'].join(',') : flags['drop-question']
1194
+ const ns = parseQuestionPositions(raw, meta.questions.length, 'drop-question')
1195
+ meta.questions = meta.questions.filter((_, i) => !ns.includes(i + 1))
1196
+ changes.push('questions')
1197
+ }
1198
+ if (flags.question !== undefined) {
1199
+ meta.questions = (Array.isArray(flags.question) ? flags.question : [flags.question]).map(String)
1200
+ warnBadQuestionTags(meta.questions)
1201
+ changes.push('questions')
1202
+ }
1203
+
1204
+ // A `ready` card has no open questions by definition (see STATUSES). Adding one
1205
+ // means the plan is no longer settled, so drop it back to `todo`. This holds the
1206
+ // invariant no matter who adds the question (refine review, resolve, the UI).
1207
+ if (meta.questions.length > 0 && meta.status === 'ready') {
1208
+ meta.status = 'todo'
1209
+ changes.push('status→todo (open questions)')
1210
+ }
1211
+
1212
+ // A card's track is the folder its file sits in — right for a standalone card
1213
+ // (skill/06 → skill), a group subtask (<group>/skill/21 → skill), a blocker,
1214
+ // and a recurring card alike. A group root's own folder is the group, not a
1215
+ // track, so its frontmatter value stands.
1216
+ const curRel = path.relative(TODO, file)
1217
+ const curTrack = found.kind === 'group' ? meta.track : path.basename(path.dirname(file))
1218
+ const isSubtask = found.kind === 'file' && enclosingGroupRoot(file) !== null
1219
+ let newTrack = curTrack
1220
+ if (flags.track !== undefined) {
1221
+ if (found.kind === 'group') die('moving a group task between tracks by script is not supported — move the folder by hand')
1222
+ if (isSubtask) die('moving a group subtask between tracks by script is not supported — move the file by hand')
1223
+ newTrack = String(flags.track).trim()
1224
+ validTrack(newTrack)
1225
+ }
1226
+ let base = path.basename(file)
1227
+ if (flags.slug !== undefined) {
1228
+ if (found.kind === 'group') die('renaming a group root by script is not supported')
1229
+ base = `${id}-${slugify(flags.slug)}.md`
1230
+ }
1231
+ meta.track = newTrack
1232
+ // Only a standalone card can change folders (--track). A subtask and a group
1233
+ // root stay in their own folder; --slug at most renames the file there.
1234
+ const standalone = found.kind === 'file' && !isSubtask
1235
+ const destRel = standalone ? path.join(newTrack, base) : path.join(path.dirname(curRel), base)
1236
+ const dest = path.join(TODO, destRel)
1237
+ const moving = dest !== file
1238
+ if (moving && fs.existsSync(dest)) die(`${rel(dest)} already exists`)
1239
+
1240
+ fs.writeFileSync(file, serializeFrontmatter(meta) + '\n' + body)
1241
+ if (moving) fs.renameSync(file, dest)
1242
+ if (isSubtask) {
1243
+ // A subtask never owns a top-level README entry — fix its nested bullet in place.
1244
+ if (moving || changes.includes('title')) repointReadmeLink(id, curRel, destRel, meta.title)
1245
+ if (moving) changes.push(`renamed → ${destRel.split(path.sep).join('/')}`)
1246
+ } else if (moving) {
1247
+ stripReadmeRefs({ kind: 'file', rel: curRel })
1248
+ addReadmeRef(newTrack, id, meta.title, destRel)
1249
+ changes.push(`moved → ${destRel.split(path.sep).join('/')}`)
1250
+ } else if (changes.includes('title')) {
1251
+ stripReadmeRefs({ kind: 'file', rel: curRel })
1252
+ addReadmeRef(curTrack, id, meta.title, curRel)
1253
+ }
1254
+ console.log(`updated #${id}: ${changes.join(', ') || '(nothing changed)'}`)
1255
+ }
1256
+
1257
+ // Set (or clear) the tag on open questions, so the auto-refine loop can hand a
1258
+ // batch of questions to the human in one call without rewriting the whole list.
1259
+ // `nRaw` is one 1-based position or a comma-separated list (`1,2,3`); `tag` is
1260
+ // user | none (none strips any tag). Reads and rewrites the frontmatter
1261
+ // through the same path as `update`, so byte layout and group-root handling stay
1262
+ // identical.
1263
+ function cmdTag(args) {
1264
+ const [idRaw, nRaw, tagRaw] = args
1265
+ const id = Number(idRaw)
1266
+ if (!Number.isInteger(id)) die('need a numeric task id: tag <id> <n[,n...]> <user|none>')
1267
+ const ns = String(nRaw || '')
1268
+ .split(',')
1269
+ .map((s) => s.trim())
1270
+ .filter((s) => s.length > 0)
1271
+ .map(Number)
1272
+ if (ns.length === 0 || ns.some((n) => !Number.isInteger(n) || n < 1)) {
1273
+ die('need one or more 1-based question numbers: tag <id> <n[,n...]> <user|none>')
1274
+ }
1275
+ const tag = String(tagRaw || '').toLowerCase()
1276
+ if (tag !== 'none' && !QUESTION_TAGS.includes(tag)) {
1277
+ die(`tag must be one of ${QUESTION_TAGS.join(' | ')} | none (got "${tagRaw}")`)
1278
+ }
1279
+ const found = locate(id)
1280
+ if (!found) die(`no task with id ${id} under ${rel(TODO)}`)
1281
+ const file = found.kind === 'group' ? path.join(found.target, 'root.md') : found.target
1282
+ const { meta, body } = parseFrontmatter(fs.readFileSync(file, 'utf8'))
1283
+ if (!meta) die(`${rel(file)} has no frontmatter — run \`migrate\` first`)
1284
+ const over = ns.find((n) => n > meta.questions.length)
1285
+ if (over !== undefined) {
1286
+ die(`#${id} has ${meta.questions.length} open question(s) — there's no question ${over} to tag.`)
1287
+ }
1288
+ for (const n of ns) {
1289
+ const { text } = parseQuestion(meta.questions[n - 1])
1290
+ meta.questions[n - 1] = formatQuestion(tag === 'none' ? null : tag, text)
1291
+ }
1292
+ fs.writeFileSync(file, serializeFrontmatter(meta) + '\n' + body)
1293
+ const label = tag === 'none' ? '(untagged)' : `[${tag}]`
1294
+ console.log(`tagged #${id} question${ns.length > 1 ? 's' : ''} ${ns.join(', ')} as ${label}`)
1295
+ }
1296
+
1297
+ // ---- migrate old cards to frontmatter --------------------------------------
1298
+
1299
+ // Pull meta out of the old bold-line header. Missing fields fall back to safe
1300
+ // defaults (empty lists, med level) rather than guessing.
1301
+ function extractOldMeta(text, file) {
1302
+ const grab = (re) => {
1303
+ const m = text.match(re)
1304
+ return m ? m[1].trim() : null
1305
+ }
1306
+ const folderTrack = path.relative(TODO, file).split(path.sep)[0]
1307
+ const title = grab(/^#\s+(.+)$/m) || slugify(path.basename(file, '.md').replace(/^\d+-/, '')).replace(/-/g, ' ')
1308
+ const track = (grab(/\*\*Track:\*\*\s*([^·|\n]+?)\s*(?:·|\||\n|$)/) || folderTrack).toLowerCase()
1309
+ const norm = (v) => {
1310
+ v = (v || '').toLowerCase()
1311
+ return LEVELS.includes(v) ? v : 'med'
1312
+ }
1313
+ const ids = (raw) => (raw && !/none/i.test(raw) ? (raw.match(/\d+/g) || []).map(Number) : [])
1314
+ return {
1315
+ title,
1316
+ track,
1317
+ priority: norm(grab(/\*\*Priority:\*\*\s*([^·|\n]+?)\s*(?:·|\||\n|$)/)),
1318
+ roi: norm(grab(/\*\*ROI:\*\*\s*([^·|\n]+?)\s*(?:·|\||\n|$)/)),
1319
+ status: 'todo',
1320
+ blocked_by: ids(grab(/\*\*Blocked by:\*\*\s*([^·|\n]+?)\s*(?:·|\||\n|$)/)),
1321
+ related: ids(grab(/\*\*Related:\*\*\s*([^·|\n]+?)\s*(?:·|\||\n|$)/)),
1322
+ questions: [],
1323
+ }
1324
+ }
1325
+
1326
+ // Drop the leading H1 + bold meta lines; keep the body below them.
1327
+ function stripOldHeader(text) {
1328
+ const lines = text.split('\n')
1329
+ let lastMeta = -1
1330
+ for (let k = 0; k < lines.length && k < 8; k++) {
1331
+ if (/^#\s/.test(lines[k]) || /^\*\*(Track|Priority|ROI|Blocked by|Related):\*\*/.test(lines[k])) lastMeta = k
1332
+ }
1333
+ const rest = lines.slice(lastMeta + 1)
1334
+ while (rest.length && rest[0].trim() === '') rest.shift()
1335
+ return rest.join('\n')
1336
+ }
1337
+
1338
+ function cmdMigrate(args) {
1339
+ const { flags } = parseFlags(args, ['dry-run', 'dry'])
1340
+ const dry = !!(flags['dry-run'] || flags.dry)
1341
+ const files = walkMd(TODO).filter((f) => path.basename(f) !== 'README.md')
1342
+ let changed = 0
1343
+ let skipped = 0
1344
+ for (const file of files) {
1345
+ const text = fs.readFileSync(file, 'utf8')
1346
+ if (text.trimStart().startsWith('---')) {
1347
+ skipped++
1348
+ continue
1349
+ }
1350
+ const meta = extractOldMeta(text, file)
1351
+ const out = serializeFrontmatter(meta) + '\n\n' + stripOldHeader(text).replace(/^\n+/, '')
1352
+ if (dry) console.log(`would migrate ${rel(file)} (track=${meta.track} priority=${meta.priority} roi=${meta.roi})`)
1353
+ else {
1354
+ fs.writeFileSync(file, out.endsWith('\n') ? out : out + '\n')
1355
+ console.log(`migrated ${rel(file)}`)
1356
+ }
1357
+ changed++
1358
+ }
1359
+ console.log(`\n${dry ? '(dry run) ' : ''}${changed} card(s) ${dry ? 'to migrate' : 'migrated'}, ${skipped} already frontmatter`)
1360
+ }
1361
+
1362
+ // Where a finished card goes. It sits next to `todo/`, not inside it: everything that
1363
+ // walks the board reads every folder under `todo/` without skipping dot-names, so an
1364
+ // archive folder there would show up as a track column and finished cards would look
1365
+ // open. Flat — no track subfolders — because ids are never reused so names never
1366
+ // collide, and each card still names its track in its own frontmatter. Nothing reads
1367
+ // this folder; it is a git history store, not part of the memory set.
1368
+ function archiveDest(found) {
1369
+ const dest = path.join(ARCHIVE, path.basename(found.target))
1370
+ // Only reachable if someone moved a file here by hand. Never overwrite finished work.
1371
+ if (fs.existsSync(dest)) die(`${rel(dest)} already exists — move it aside first, then archive again`)
1372
+ return dest
1373
+ }
1374
+
1375
+ function cmdRemove(id, metric) {
1376
+ if (!Number.isInteger(id)) die('need a numeric task id')
1377
+ const found = locate(id)
1378
+ if (!found) die(`no task with id ${id} under ${rel(TODO)}`)
1379
+ // Archive keeps the card (moved out of todo/), reject deletes it. Resolve the
1380
+ // destination before anything is written, so a name clash fails with the board
1381
+ // untouched rather than half-updated.
1382
+ const dest = metric === 'completed' ? archiveDest(found) : null
1383
+ const removedRefs = stripReadmeRefs(found)
1384
+ // A subtask's fate is reflected in its group's root.md ## Todo, so the tracking card
1385
+ // stays accurate after the subtask file is gone: archive ticks it done, reject strikes
1386
+ // it out. Warn if the subtask isn't listed there, so the stale checklist gets noticed.
1387
+ const groupRoot = found.kind === 'file' ? enclosingGroupRoot(found.target) : null
1388
+ let marked = null
1389
+ if (groupRoot) {
1390
+ const action = metric === 'completed' ? 'tick' : 'strike'
1391
+ if (markSubtask(groupRoot, id, action)) marked = action
1392
+ else warn(`#${id} isn't listed in ${rel(groupRoot)} ## Todo — nothing to ${action === 'tick' ? 'tick off' : 'strike out'}.`)
1393
+ }
1394
+ if (dest) {
1395
+ fs.mkdirSync(ARCHIVE, { recursive: true })
1396
+ fs.renameSync(found.target, dest)
1397
+ } else if (found.kind === 'group') {
1398
+ fs.rmSync(found.target, { recursive: true, force: true })
1399
+ } else {
1400
+ fs.rmSync(found.target)
1401
+ }
1402
+ // The card is off the board now, so every blocked_by/related pointing at it is stale.
1403
+ // Runs after the move/delete, so the card's own frontmatter is already out of `todo/`.
1404
+ const unlinked = dropCrossRefs(id)
1405
+ bumpMetric(metric)
1406
+ const what = found.kind === 'group' ? `folder ${found.rel}/` : `file ${found.rel}`
1407
+ if (dest) console.log(`archived #${id}: moved ${what} → ${rel(dest)}${found.kind === 'group' ? '/' : ''}`)
1408
+ else console.log(`rejected #${id}: removed ${what}`)
1409
+ if (removedRefs.length) console.log(` dropped ${removedRefs.length} README entry(ies)`)
1410
+ else console.log(' no README entry (subtask or untracked)')
1411
+ if (marked) console.log(` ${marked === 'tick' ? 'ticked' : 'struck'} #${id} in ${rel(groupRoot)}`)
1412
+ for (const card of unlinked) console.log(` unlinked #${id} from ${card}`)
1413
+ }
1414
+
1415
+ // A recurring task never archives — each run bumps "completed" but the card stays,
1416
+ // so its ## Process can be refined toward less human effort on the next run. Recurring
1417
+ // cards live in the `recurring/` folder, parallel to the track folders; the guard keys
1418
+ // off that so a one-shot task can't be run.
1419
+ function isRecurringCard(found) {
1420
+ return found.rel.split(path.sep)[0] === 'recurring'
1421
+ }
1422
+
1423
+ // Print the installed version — the released number baked into this file when it was
1424
+ // published. `npx ai4kanban update` reads the same number to say which version it moved
1425
+ // you from.
1426
+ function cmdVersion() {
1427
+ console.log(`ai4kanban ${SKILL_VERSION}`)
1428
+ }
1429
+
1430
+ function cmdRun(id) {
1431
+ if (!Number.isInteger(id)) die('need a numeric task id')
1432
+ const found = locate(id)
1433
+ if (!found) die(`no task with id ${id} under ${rel(TODO)}`)
1434
+ if (!isRecurringCard(found)) {
1435
+ die(`#${id} is not recurring (${found.rel} is not under recurring/). Use \`archive\` for one-shot tasks.`)
1436
+ }
1437
+ bumpMetric('completed')
1438
+ console.log(`ran #${id}: +1 completed (card kept — recurring)`)
1439
+ console.log(' next: fold this run into the card\'s ## Process; log unrepeatable asks in its open-questions file')
1440
+ reconcileBoard()
1441
+ }
1442
+
1443
+ // ---- board integrity (run after create/run) --------------------------------
1444
+ //
1445
+ // A safety net for cards moved, renamed, or removed by hand (Write/Edit/mv instead of
1446
+ // the script), which leaves the README index and cross-references stale. It NEVER fails
1447
+ // the command — the id was already handed out and the board change already happened, so
1448
+ // a broken link must not block it. Warnings go to stderr so `create`'s stdout (the id)
1449
+ // stays clean for callers. It does two things:
1450
+ // • repoints a README link whose target file vanished but whose id still has a card
1451
+ // elsewhere on disk (a hand-move/rename) — the only auto-fix, and
1452
+ // • warns about what it can't safely repair: an index entry for an id with no card, a
1453
+ // top-level card with no index entry, or a blocked_by/related pointing at a task
1454
+ // that's no longer on the board.
1455
+ function warn(msg) {
1456
+ console.error(`kanban: warning — ${msg}`)
1457
+ }
1458
+
1459
+ // A README entry links a card as `[#id title](relpath)`. Grab the id and the path.
1460
+ const README_LINK = /\[#(\d+)\b[^\]]*\]\(([^)]+)\)/
1461
+
1462
+ // Every task id that still has a card on disk (standalone, subtask, or group root).
1463
+ function liveIds() {
1464
+ const ids = new Set()
1465
+ for (const file of walkMd(TODO)) {
1466
+ const base = path.basename(file)
1467
+ if (base === 'README.md') continue
1468
+ const id = base === 'root.md' ? idPrefix(path.basename(path.dirname(file))) : idPrefix(base)
1469
+ if (id != null) ids.add(id)
1470
+ }
1471
+ return ids
1472
+ }
1473
+
1474
+ // Cards that OWN a top-level README entry: a group root, or a card directly in a track
1475
+ // folder. Nested subtasks are only optionally indexed, so they aren't required here.
1476
+ function indexableCards() {
1477
+ const out = []
1478
+ for (const dir of walkDirs(TODO)) {
1479
+ const id = idPrefix(path.basename(dir))
1480
+ if (id != null && fs.existsSync(path.join(dir, 'root.md'))) {
1481
+ out.push({ id, rel: path.join(path.relative(TODO, dir), 'root.md').split(path.sep).join('/') })
1482
+ }
1483
+ }
1484
+ for (const file of walkMd(TODO)) {
1485
+ const base = path.basename(file)
1486
+ if (base === 'README.md' || base === 'root.md') continue
1487
+ const relTODO = path.relative(TODO, file)
1488
+ const segs = relTODO.split(path.sep)
1489
+ if (segs.length !== 2) continue // nested subtask — optional
1490
+ if (segs[0] === 'recurring') continue // recurring cards aren't board-index tasks
1491
+ const id = idPrefix(base)
1492
+ if (id != null) out.push({ id, rel: segs.join('/') })
1493
+ }
1494
+ return out
1495
+ }
1496
+
1497
+ // Repoint stale README links to where the id actually lives now; warn on the rest.
1498
+ function reconcileReadmeLinks() {
1499
+ const lines = fs.readFileSync(README, 'utf8').split('\n')
1500
+ const indexed = new Set()
1501
+ let fixed = 0
1502
+ for (let i = 0; i < lines.length; i++) {
1503
+ const m = lines[i].match(README_LINK)
1504
+ if (!m) continue
1505
+ const id = Number(m[1])
1506
+ const linkPath = m[2]
1507
+ indexed.add(id)
1508
+ if (fs.existsSync(path.join(TODO, linkPath))) continue // link is live
1509
+ const found = locate(id)
1510
+ if (!found) {
1511
+ warn(`README links #${id} → ${linkPath}, but no card with that id exists (removed by hand?). Drop the entry or restore the file.`)
1512
+ continue
1513
+ }
1514
+ const want = (found.kind === 'group' ? path.join(found.rel, 'root.md') : found.rel).split(path.sep).join('/')
1515
+ if (want === linkPath) continue
1516
+ lines[i] = lines[i].replace(`(${linkPath})`, `(${want})`)
1517
+ warn(`README link #${id} pointed at missing ${linkPath} → repointed to ${want}.`)
1518
+ fixed++
1519
+ }
1520
+ if (fixed) fs.writeFileSync(README, lines.join('\n'))
1521
+ for (const c of indexableCards()) {
1522
+ if (!indexed.has(c.id)) {
1523
+ warn(`card ${c.rel} (#${c.id}) is not in the README index. Add it under its track heading.`)
1524
+ }
1525
+ }
1526
+ }
1527
+
1528
+ // Flag blocked_by/related that point at a task no longer on the board.
1529
+ function reconcileCrossRefs() {
1530
+ const live = liveIds()
1531
+ for (const file of walkMd(TODO)) {
1532
+ const base = path.basename(file)
1533
+ if (base === 'README.md') continue
1534
+ const ownerId = base === 'root.md' ? idPrefix(path.basename(path.dirname(file))) : idPrefix(base)
1535
+ if (ownerId == null) continue
1536
+ const { meta } = parseFrontmatter(fs.readFileSync(file, 'utf8'))
1537
+ if (!meta) continue
1538
+ for (const field of ['blocked_by', 'related']) {
1539
+ for (const ref of meta[field] || []) {
1540
+ if (!live.has(ref)) {
1541
+ warn(`#${ownerId} ${field} #${ref}, which is no longer on the board (archived/rejected?). Fix it with \`update ${ownerId}\`.`)
1542
+ }
1543
+ }
1544
+ }
1545
+ }
1546
+ }
1547
+
1548
+ function reconcileBoard() {
1549
+ if (!fs.existsSync(README)) return
1550
+ reconcileReadmeLinks()
1551
+ reconcileCrossRefs()
1552
+ }
1553
+
1554
+ function main() {
1555
+ const [cmd, ...rest] = process.argv.slice(2)
1556
+ switch (cmd) {
1557
+ case 'init':
1558
+ return cmdInit(rest)
1559
+ case 'memory-init':
1560
+ return cmdMemoryInit(rest[0])
1561
+ case 'create':
1562
+ return cmdCreate(rest)
1563
+ case 'update':
1564
+ return cmdUpdate(rest)
1565
+ case 'tag':
1566
+ return cmdTag(rest)
1567
+ case 'migrate':
1568
+ return cmdMigrate(rest)
1569
+ case 'archive':
1570
+ return cmdRemove(Number(rest[0]), 'completed')
1571
+ case 'reject':
1572
+ return cmdRemove(Number(rest[0]), 'rejected')
1573
+ case 'run':
1574
+ return cmdRun(Number(rest[0]))
1575
+ case 'peek':
1576
+ return console.log(readNextId())
1577
+ case 'version':
1578
+ case '--version':
1579
+ case '-v':
1580
+ return cmdVersion()
1581
+ case 'metrics':
1582
+ return console.log(fs.existsSync(METRICS) ? fs.readFileSync(METRICS, 'utf8').trim() : '(no metrics yet)')
1583
+ case 'help':
1584
+ case '-h':
1585
+ case '--help':
1586
+ case undefined:
1587
+ return console.log(HELP)
1588
+ default: {
1589
+ const guess = nearestCommand(cmd)
1590
+ const hint = guess ? ` Did you mean \`${guess}\`?` : ''
1591
+ console.error(`kanban: unknown command "${cmd}".${hint}\n`)
1592
+ console.error(HELP)
1593
+ process.exit(1)
1594
+ }
1595
+ }
1596
+ }
1597
+
1598
+ const COMMANDS = ['init', 'memory-init', 'create', 'update', 'tag', 'migrate', 'archive', 'reject', 'run', 'peek', 'version', 'metrics', 'help']
1599
+
1600
+ const HELP = `kanban — the only sanctioned writer of docs/kanban/next-id.
1601
+
1602
+ Usage: node ${rel(SELF)} <command> [args]
1603
+
1604
+ init [track...] scaffold docs/kanban/ (folders, the project-wide memory set in memory/, and a blank
1605
+ config.md); tracks default to feature bug research. On an existing
1606
+ board it only adds config.md if it's missing (safe to re-run).
1607
+ memory-init <module> lazily scaffold docs/kanban/memory/<module>/ with the four-file set
1608
+ (readme, decisions, redesign, rejected — goal.md lives only at the
1609
+ board root). Idempotent — run it before the first write to a
1610
+ module's memory.
1611
+ create [--count N] allocate N task ids (default 1), advance next-id, print them
1612
+ create --title T --track K [opts]
1613
+ scaffold ONE card: write its frontmatter + a body template, index it.
1614
+ opts: --priority high|med|low (default med), --roi high|med|low
1615
+ (default med), --blocked-by 1,2, --related 3, --modules skill,site
1616
+ (validated against modules.md), --question "..." (repeatable),
1617
+ --slug my-slug, --no-body.
1618
+ The script owns the frontmatter — fill only the body by hand.
1619
+ update <id> [opts] rewrite a card's frontmatter (same opts as create, plus
1620
+ --status todo|ready|implementing, --drop-question n[,n...]
1621
+ to remove answered questions by 1-based position, and
1622
+ --clear-questions to remove them all; --question replaces
1623
+ the whole list). --track moves the card + fixes the index;
1624
+ --slug renames it. Body is left untouched. A question may
1625
+ carry a leading [user] tag marking it as the human's
1626
+ judgment call.
1627
+ tag <id> <n[,n...]> <t> set the tag on one or more open questions (1-based, e.g.
1628
+ 1 or 1,2,3): user | none (none strips it). Used by the
1629
+ auto-refine loop to hand questions to the human without
1630
+ rewriting the list.
1631
+ migrate [--dry-run] convert old bold-header cards to frontmatter; missing meta falls
1632
+ back to empty / med. Skips cards that already have frontmatter.
1633
+ archive <id> finish task <id>: move its file/folder into docs/kanban/.archive/,
1634
+ strip its README entry, drop the id from every other card's
1635
+ blocked_by/related, count completed
1636
+ reject <id> reject task <id>: same, but delete the file/folder, count rejected
1637
+ run <id> record one run of recurring task <id>: +1 completed, card kept (no archive)
1638
+ peek print the current next-id (no bump)
1639
+ version print the installed skill version (+ source stamp if present)
1640
+ metrics print docs/kanban/metrics.csv
1641
+ help show this
1642
+
1643
+ Never edit next-id or metrics.csv by hand — let the script write them. Never hand-write a
1644
+ card's frontmatter — use create/update. Write/Edit are only for the card body.`
1645
+
1646
+ // Levenshtein-based suggestion so a mistyped command auto-corrects to the closest match.
1647
+ function editDistance(a, b) {
1648
+ const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)])
1649
+ for (let j = 0; j <= b.length; j++) dp[0][j] = j
1650
+ for (let i = 1; i <= a.length; i++)
1651
+ for (let j = 1; j <= b.length; j++)
1652
+ dp[i][j] =
1653
+ a[i - 1] === b[j - 1]
1654
+ ? dp[i - 1][j - 1]
1655
+ : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
1656
+ return dp[a.length][b.length]
1657
+ }
1658
+
1659
+ function nearestCommand(input) {
1660
+ let best = null
1661
+ let bestDist = Infinity
1662
+ for (const c of COMMANDS) {
1663
+ const d = editDistance(input, c)
1664
+ if (d < bestDist) [best, bestDist] = [c, d]
1665
+ }
1666
+ // Only suggest when it's a plausible typo, not a wildly different word.
1667
+ return bestDist <= Math.max(2, Math.ceil(best.length / 2)) ? best : null
1668
+ }
1669
+
1670
+ main()