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.
- package/README.md +58 -0
- package/bin/ai4kanban.mjs +379 -0
- package/package.json +37 -0
- package/skill/SKILL.md +253 -0
- package/skill/config.md +32 -0
- package/skill/kanban.mjs +1670 -0
- package/skill/references/add-task.md +79 -0
- package/skill/references/auto-refine.md +32 -0
- package/skill/references/document-feature.md +31 -0
- package/skill/references/local-ui.md +34 -0
- package/skill/references/module-map.md +29 -0
- package/skill/references/presets/indie-hacker.md +24 -0
- package/skill/references/presets/validate-on-reddit.md +82 -0
- package/skill/references/propose.md +67 -0
- package/skill/references/prune-memory.md +29 -0
- package/skill/references/recurring-task.md +93 -0
- package/skill/references/refine.md +69 -0
- package/skill/references/resolve.md +56 -0
- package/skill/references/update.md +70 -0
package/skill/kanban.mjs
ADDED
|
@@ -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()
|