ai4kanban 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/bin/ai4kanban.mjs +14 -6
  2. package/package.json +1 -1
  3. package/skill/SKILL.md +58 -32
  4. package/skill/commands/card.mjs +335 -0
  5. package/skill/commands/init.mjs +292 -0
  6. package/skill/commands/list.mjs +99 -0
  7. package/skill/commands/misc.mjs +105 -0
  8. package/skill/commands/release.mjs +132 -0
  9. package/skill/commands/remove.mjs +230 -0
  10. package/skill/commands/setup.mjs +58 -0
  11. package/skill/config.md +3 -0
  12. package/skill/kanban.mjs +129 -1519
  13. package/skill/lib/cadence.mjs +96 -0
  14. package/skill/lib/cards.mjs +114 -0
  15. package/skill/lib/frontmatter.mjs +136 -0
  16. package/skill/lib/memory.mjs +130 -0
  17. package/skill/lib/metrics.mjs +39 -0
  18. package/skill/lib/paths.mjs +67 -0
  19. package/skill/lib/questions.mjs +242 -0
  20. package/skill/lib/readme.mjs +113 -0
  21. package/skill/lib/reconcile.mjs +114 -0
  22. package/skill/lib/recurring.mjs +61 -0
  23. package/skill/lib/releases.mjs +468 -0
  24. package/skill/lib/setup.mjs +196 -0
  25. package/skill/lib/validate.mjs +165 -0
  26. package/skill/lib/yaml.mjs +25 -0
  27. package/skill/references/add-task.md +4 -1
  28. package/skill/references/auto-refine.md +0 -14
  29. package/skill/references/module-map.md +17 -1
  30. package/skill/references/propose.md +49 -59
  31. package/skill/references/prune-memory.md +5 -4
  32. package/skill/references/recurring-task.md +41 -69
  33. package/skill/references/refine.md +4 -2
  34. package/skill/references/reject.md +26 -0
  35. package/skill/references/releases.md +31 -0
  36. package/skill/references/resolve.md +38 -10
  37. package/skill/references/setup.md +87 -0
  38. package/skill/references/update.md +5 -2
package/bin/ai4kanban.mjs CHANGED
@@ -167,8 +167,8 @@ function repairBoard(skillDir, root) {
167
167
  moveLegacyMemory(board)
168
168
  sayDid()
169
169
  // `init` on an existing board is the repair step: it adds what an older version never
170
- // wrote (config.md, modules.md, the memory paths, the goal's `reviewed:` field) and never
171
- // touches a file that's already filled in.
170
+ // wrote (config.md, modules.md, releases.md, the memory paths, the goal's `reviewed:`
171
+ // field) and never touches a file that's already filled in.
172
172
  runKanban(skillDir, root, ['init'])
173
173
  dropModuleGoals(board)
174
174
  checkConfig(board)
@@ -259,12 +259,20 @@ function cmdInstall(root, tracks) {
259
259
  runKanban(placed[0].dest, root, ['init', ...tracks])
260
260
  sayNotes()
261
261
  say('')
262
- say('Next, and only an agent can do these:')
263
- say(' 1. fill in docs/kanban/config.md from what the repo tells you')
264
- say(' 2. write docs/kanban/modules.md (the skill\'s references/module-map.md says how)')
265
- say(' 3. propose the first 3 tasks')
262
+ // One instruction, not a list of steps. The steps are on the board now, in
263
+ // docs/kanban/setup-checklist.md and two lists would drift. Setup picks up at the
264
+ // first unticked box, so this same line restarts it wherever it stops.
265
+ say('Next, paste this into your coding agent to finish setup:')
266
+ say('')
267
+ say(` ${SETUP_INSTRUCTION}`)
266
268
  }
267
269
 
270
+ // The line the user copies into their coding harness to run setup's agent steps. One
271
+ // wording for every harness, and the same one the local board UI shows on its setup bar
272
+ // (kanban-ui/lib/agent.ts) — the two are separate packages, so it is repeated here rather
273
+ // than shared. Keep them in step.
274
+ const SETUP_INSTRUCTION = '/kanban. Set up this board — follow docs/kanban/setup-checklist.md.'
275
+
268
276
  function cmdUpdate(root) {
269
277
  say(`ai4kanban ${VERSION} — updating ${root}`)
270
278
  const placed = placeSkill(root, 'update')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai4kanban",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Set up and update AI4Kanban in one command — copies the skill into your project and scaffolds the Markdown board under docs/kanban/.",
5
5
  "keywords": [
6
6
  "kanban",
package/skill/SKILL.md CHANGED
@@ -1,17 +1,18 @@
1
1
  ---
2
2
  name: kanban
3
3
  description: Use to propose new tasks, add a task, mark one done, or push a task one step forward. Manages the file-based task board in docs/kanban/ — blockers, roadmap tracks, archive, and global task ids. Triggers on "propose new tasks", "what's on the backlog", "add a task", "this is done", "refine", "resolve", "dive deeper".
4
- argument-hint: "[propose | add <task> | refine <id> | resolve <id> | done <id> | reject <id>]"
4
+ argument-hint: "[propose [count] | add <task> | refine <id> | resolve <id> | done <id> | reject <id>]"
5
5
  ---
6
6
 
7
7
  The task board lives in `docs/kanban/`. Read it before suggesting or adding work.
8
8
 
9
9
  ## Configuration
10
10
 
11
- **Read `docs/kanban/config.md` first** — it carries your project's settings: name, tracks,
12
- planning sources, reference docs, optional preset. `kanban init` seeds it; install fills it
13
- in; until then its defaults apply. It lives with your board, so an update leaves it
14
- untouched the skill folder (`SKILL.md`, `kanban.mjs`, `references/`) is upstream-owned and
11
+ **Read `docs/kanban/config.md` before proposing, adding, or refining** — it carries your
12
+ project's settings: name, tracks, planning sources, reference docs, optional preset.
13
+ `kanban init` seeds it; install fills it in; until then its defaults apply. It lives with
14
+ your board, so an update leaves it
15
+ untouched — the skill folder (`SKILL.md`, `kanban.mjs` with its `lib/` and `commands/`, `references/`) is upstream-owned and
15
16
  overwritten wholesale (see "Updating the skill"). "Your tracks / planning sources / reference
16
17
  docs" below mean this file.
17
18
 
@@ -59,7 +60,7 @@ docs/kanban/
59
60
  │ ├── README.md the index — read it first
60
61
  │ ├── blockers/ hard blockers; they gate the next milestone — clear them first
61
62
  │ ├── <track>/ one folder per track (see Configuration), one card per file
62
- │ └── recurring/ jobs on a cadence (see "Recurring task") — never archived
63
+ │ └── recurring/ jobs we repeat (see "Recurring task") — never archived
63
64
  ├── memory/ all memory — see "The memory set"
64
65
  │ ├── readme.md, decisions.md, rejected.md, redesign.md
65
66
  │ │ the four-file set for the project as a whole
@@ -69,6 +70,10 @@ docs/kanban/
69
70
  ├── modules.md one line per module — install writes it, propose reads it (see "The
70
71
  │ module map")
71
72
  ├── config.md your project's settings (see Configuration) — seeded by init, yours to fill
73
+ ├── releases.md the open releases, in the order they ship — one line each (see "Releases")
74
+ ├── setup-checklist.md
75
+ │ setup's own steps, while setup is unfinished (see "Setup") — the
76
+ │ last tick deletes it; no file means the board is set up
72
77
  ├── next-id the next free task id — NEVER edit by hand; only the script writes it
73
78
  └── metrics.csv one row per day: completed, created, rejected — script-kept; never touch
74
79
  ```
@@ -80,44 +85,64 @@ create, update, migrate, archive, or reject a task. It allocates ids, writes a c
80
85
  **frontmatter**, moves/removes task files, keeps the README index, and records the daily
81
86
  metric.
82
87
 
83
- Point `KB="node .claude/skills/kanban/kanban.mjs"`. Set it once and run every command from
84
- the repo root as `${KB} <command>`:
88
+ Point `KB="node <this skill's folder>/kanban.mjs"` the folder this SKILL.md sits in, which
89
+ differs per agent (`.claude/skills/kanban/`, `.agents/skills/kanban/`, a plugin path). Set it
90
+ once and run every command from the repo root as `${KB} <command>`:
85
91
 
86
92
  ```
87
93
  ${KB} init [track...] # scaffold docs/kanban/ (tracks default to feature bug research)
88
- # re-run to repair an older board: adds missing config.md, modules.md, memory paths
94
+ # re-run to repair an older board: adds missing config.md, modules.md, releases.md, .gitignore, memory paths
89
95
  ${KB} create [--count N] # allocate N ids (default 1), prints them
90
96
  ${KB} create --title ".." --track <track> [--priority high|med|low] [--roi high|med|low] \
91
- [--blocked-by 1,2] [--related 3] [--modules skill,site] [--question ".."] [--slug ..]
97
+ [--release v1] [--blocked-by 1,2] [--related 3] [--modules skill,site] [--question ".."] [--slug ..]
92
98
  # scaffold ONE card: frontmatter + body template + README entry; then fill only the body
93
- ${KB} update <id> [--priority ..] [--roi ..] [--track ..] [--slug ..] \
94
- [--blocked-by ..] [--related ..] [--modules ..] [--question ..] \
95
- [--drop-question 1,3] [--clear-questions]
96
- # rewrite a card's frontmatter; --track moves it, --slug renames
99
+ ${KB} update <id> [--priority ..] [--roi ..] [--status ..] [--release ..] [--track ..] [--slug ..] \
100
+ [--blocked-by ..] [--related ..] [--modules ..]
101
+ # rewrite a card's frontmatter fields; --track moves it, --slug renames
102
+ ${KB} update-questions <id> [--append ".."] [--update <n> ".."] [--drop 1,3] [--clear]
103
+ # patch open questions one op at a time — nothing rewrites the list whole;
104
+ # options for a pick-one question: references/resolve.md
105
+ ${KB} list [--module <m>] # the open cards at a glance: id, title, meta, summary, file path;
106
+ # --module narrows it to the cards tagged with one module
97
107
  ${KB} archive <id> # finish task <id>
98
- ${KB} reject <id> # reject task <id>
99
- ${KB} run <id> # record one run of recurring task <id> (card kept)
100
108
  ${KB} peek # current next-id, no bump
101
109
  ${KB} help # full usage
102
110
  ```
103
111
 
104
- **Never hand-write a card's frontmatter.** Use `create`/`update` for the meta
105
- (title, track, priority, roi, blocked_by, related, modules, questions); use Write/Edit only
106
- for the card **body**.
112
+ **Never hand-write a card's frontmatter.** Use `create`/`update`/`update-questions` for the
113
+ meta (title, track, priority, roi, status, release, blocked_by, related, modules,
114
+ questions); use Write/Edit only for the card **body**.
107
115
 
108
116
  Tag a card with `--modules` (see `docs/kanban/modules.md`);
109
117
  optional — a task can touch two modules or none. Add a new line to modules.md according to `module-map.md`
110
118
  if you find no match.
111
119
 
120
+ ## Releases
121
+
122
+ A release is a version this project is planning; the open ones live in
123
+ `docs/kanban/releases.md`, one line each, in ship order. A card names the release it ships
124
+ in via `--release`; a card that names none is simply in no release. Releases are optional —
125
+ a board that never plans a version works exactly as it does without one. Full guide in
126
+ `references/releases.md` — read it before running any `release` command or setting
127
+ `--release`.
128
+
112
129
  ## Task id
113
130
 
114
131
  Every task's id is the number at the front of its filename (`04-plan-cap-enforcement.md` →
115
132
  id 4). Ids are global and never reused; only the script's `create` allocates them.
116
133
 
134
+ ## Setup
135
+
136
+ `docs/kanban/setup-checklist.md` being there says setup is unfinished — the last tick
137
+ deletes it. While it's there, no flow creates cards; setup's own last step is the only
138
+ exception. Full guide in `references/setup.md`.
139
+
117
140
  ## Propose new tasks
118
141
 
119
- When the user asks to propose work, pick **one module** and propose **3 new tasks
120
- inside it** — work nobody has planned yet. Full guide in `references/propose.md`.
142
+ When the user asks to propose work, pick **one module** and propose new tasks inside it —
143
+ work nobody has planned yet, each one short-term and its own single card (never a group
144
+ task). **3** of them unless the user names a count; **10** is the cap. Full guide in
145
+ `references/propose.md`.
121
146
 
122
147
  ## Add a task
123
148
 
@@ -172,11 +197,7 @@ Then run `${KB} archive <id>` to record the completion.
172
197
 
173
198
  ## Reject an idea
174
199
 
175
- Rejecting is rare. When you (or the user) turn down an idea, add a short line to
176
- `rejected.md` (see "The memory set") — under the topic that fits; start a new topic heading if none
177
- fits. Format: `- **<idea name>** — <why we said no, one line>.`
178
-
179
- Then run `${KB} reject <id>` to remove the card.
200
+ Rejecting is rare, and it deletes the card. Full guide in `references/reject.md`.
180
201
 
181
202
  ## Record a redesign
182
203
 
@@ -192,8 +213,9 @@ A track is the bucket a task lives in — one folder per track under `todo/`. Yo
192
213
 
193
214
  ## Recurring task
194
215
 
195
- A recurring task is a job we repeat on a cadence (e.g. a weekly report), not a
196
- one-shot. Full guide in `references/recurring-task.md`.
216
+ A recurring task is a job we repeat (e.g. a weekly report), not a one-shot. Adding one
217
+ and running one are both in `references/recurring-task.md`; the job itself is the card's
218
+ own `## Process`.
197
219
 
198
220
  ## Run the board locally
199
221
 
@@ -231,14 +253,18 @@ soon as the module is known — `init` does it for every module already on the m
231
253
  update flow does it for the rest, and any flow about to write a note runs it first.
232
254
 
233
255
  **`goal.md` sits outside the set, at the board root only** — the long-term goal, horizon,
234
- and roadmap in the user's words; the agent never writes the goal, except a frontmatter line
235
- `reviewed: strong | good | weak`.
236
- This field says whether the goal is clear enough to plan from `weak` only when apparent (missing, still the template, too vague to judge a proposal against).
256
+ and roadmap in the user's words. It starts empty; the agent never writes the goal, except
257
+ the frontmatter line `reviewed: strong | good | pending | weak` — how clear the goal is to
258
+ plan from. `weak` only when apparent (missing, empty, too vague to judge a proposal
259
+ against). `pending` means written but not judged yet: the script and the local UI set it
260
+ when a goal is saved, and you replace it the next time you read the goal. Judge it at the
261
+ `goal` setup step and on every propose run — never stop to ask the user about it.
237
262
 
238
263
  ## Auto-pruning
239
264
 
240
265
  To compress the memory set — the project-wide copy and each module copy — down to
241
- planning-useful summaries, follow `references/prune-memory.md`.
266
+ planning-useful summaries, follow `references/prune-memory.md`. The board ships with a
267
+ recurring card that does this — never create one.
242
268
 
243
269
  ## Document a change
244
270
 
@@ -0,0 +1,335 @@
1
+ // ---- create / update / update-questions / tag ------------------------------
2
+ //
3
+ // The commands that write a live card's frontmatter. The script owns the meta;
4
+ // Write/Edit are for the body only.
5
+
6
+ import fs from 'node:fs'
7
+ import path from 'node:path'
8
+
9
+ import { die, warn, rel, readNextId, writeNextId, TODO } from '../lib/paths.mjs'
10
+ import { bumpMetric } from '../lib/metrics.mjs'
11
+ import { parseFlags, slugify, validLevel, validStatus, validTrack, validModules, parseModuleList, parseIdList, normalizeRelease } from '../lib/validate.mjs'
12
+ import { QUESTION_TAGS, parseQuestion, formatQuestion, warnBadQuestionTags, collectQuestions, parseQuestionOps, parseQuestionPositions } from '../lib/questions.mjs'
13
+ import { serializeFrontmatter, parseFrontmatter } from '../lib/frontmatter.mjs'
14
+ import { CADENCE_FORMS, formatCadence, parseCadence } from '../lib/cadence.mjs'
15
+ import { locate, enclosingGroupRoot, isRecurringCard } from '../lib/cards.mjs'
16
+ import { RECURRING } from '../lib/recurring.mjs'
17
+ import { validRelease, setSubtreeRelease } from '../lib/releases.mjs'
18
+ import { readmeHeadingFor, addReadmeRef, stripReadmeRefs, repointReadmeLink } from '../lib/readme.mjs'
19
+ import { reconcileBoard } from '../lib/reconcile.mjs'
20
+
21
+ // A todo item in any accepted form: `- [ ]`, `- []`, `- [x]`, `* [X]`, … — the shape
22
+ // counts, not the literal string.
23
+ const TODO_ITEM = /^[ \t]*[-*+][ \t]*\[[ xX]?\]/m
24
+
25
+ function defaultBody() {
26
+ return [
27
+ '<one short line: what to do and why it matters.>',
28
+ '',
29
+ '## Scope',
30
+ '- <the concrete steps>',
31
+ '',
32
+ '## Todo',
33
+ '- [ ] every task must have todos — replace this line with the real steps.',
34
+ '',
35
+ ].join('\n')
36
+ }
37
+
38
+ // How often a recurring card repeats, as `--cadence` gives it: one of the forms in
39
+ // lib/cadence.mjs, written back in that module's own spelling so every card reads the
40
+ // same. An empty value is "no cadence" — the card goes back to running only when a
41
+ // human clicks Run. Anything the grammar doesn't cover is refused with the accepted
42
+ // forms, never written half-parsed.
43
+ function cadenceFlag(raw) {
44
+ if (raw === true) die(`--cadence needs a value: ${CADENCE_FORMS}. Use --cadence "" to clear it.`)
45
+ const text = String(raw).trim()
46
+ if (!text) return ''
47
+ const parsed = parseCadence(text)
48
+ if (!parsed) die(`--cadence "${text}" isn't a cadence. Accepted: ${CADENCE_FORMS}`)
49
+ return formatCadence(parsed)
50
+ }
51
+
52
+ const CREATE_FLAGS = ['title', 'track', 'priority', 'roi', 'release', 'blocked-by', 'related', 'modules', 'question', 'option', 'mode', 'recommended-option', 'slug', 'count', 'no-body', 'cadence']
53
+
54
+ // Two modes:
55
+ // bare `create [--count N]` → allocate ids and print them (group-task setup).
56
+ // card mode `create --title ... --track ...` → allocate ONE id, write the card's
57
+ // frontmatter + a body template, and index it. The script owns the meta;
58
+ // fill the body with your editor and leave the frontmatter alone.
59
+ export function cmdCreate(args) {
60
+ const { flags, positional, order } = parseFlags(args, CREATE_FLAGS)
61
+ if (positional.length) die(`create takes options, not positional args (got "${positional.join(' ')}")`)
62
+
63
+ if (flags.title === undefined) {
64
+ for (const bad of ['track', 'priority', 'roi', 'release', 'blocked-by', 'related', 'modules', 'question', 'option', 'mode', 'recommended-option', 'slug', 'no-body', 'cadence']) {
65
+ if (flags[bad] !== undefined) die(`--${bad} needs --title (that's card mode). Without --title, create only allocates ids.`)
66
+ }
67
+ const count = flags.count !== undefined ? Number(flags.count) : 1
68
+ if (!Number.isInteger(count) || count < 1) die('--count must be a positive integer')
69
+ const start = readNextId()
70
+ const ids = Array.from({ length: count }, (_, k) => start + k)
71
+ writeNextId(start + count)
72
+ bumpMetric('created', count)
73
+ console.log(ids.join('\n'))
74
+ reconcileBoard()
75
+ return
76
+ }
77
+
78
+ // --- card mode ---
79
+ if (flags.count !== undefined) die("--count can't be combined with --title (card mode makes exactly one card)")
80
+ const title = String(flags.title).trim()
81
+ if (!title) die('--title must not be empty')
82
+ if (flags.track === undefined) die('--track is required in card mode (e.g. --track feature)')
83
+ const track = String(flags.track).trim()
84
+ validTrack(track)
85
+ const priority = flags.priority !== undefined ? String(flags.priority) : 'med'
86
+ validLevel(priority, 'priority')
87
+ const roi = flags.roi !== undefined ? String(flags.roi) : 'med'
88
+ validLevel(roi, 'roi')
89
+ // No --release means no release: the card is wanted, not promised to a version. Any
90
+ // other value has to name a release on the list — a typo must not invent a version.
91
+ const release = validRelease(normalizeRelease(flags.release))
92
+ const start = readNextId()
93
+ const blocked_by = flags['blocked-by'] !== undefined ? parseIdList(flags['blocked-by'], 'blocked-by', start) : []
94
+ const related = flags.related !== undefined ? parseIdList(flags.related, 'related', start) : []
95
+ const modules = flags.modules !== undefined ? validModules(parseModuleList(flags.modules)) : []
96
+ // Only a card that repeats can have a cadence — a one-shot task is built once.
97
+ let cadence = ''
98
+ if (flags.cadence !== undefined) {
99
+ if (track !== RECURRING) die(`--cadence is for recurring cards only (--track ${RECURRING}); a one-shot task is built once, not repeated.`)
100
+ cadence = cadenceFlag(flags.cadence)
101
+ }
102
+ const questions = collectQuestions(order)
103
+ warnBadQuestionTags(questions)
104
+ const slug = slugify(flags.slug !== undefined ? flags.slug : title)
105
+ const fileRel = path.join(track, `${start}-${slug}.md`)
106
+ const file = path.join(TODO, fileRel)
107
+ if (fs.existsSync(file)) die(`${rel(file)} already exists — pick a different --slug`)
108
+
109
+ // validation passed → allocate + write
110
+ writeNextId(start + 1)
111
+ bumpMetric('created')
112
+ const meta = { title, track, priority, roi, status: 'todo', release, blocked_by, related, modules, cadence, questions }
113
+ const body = flags['no-body'] ? '' : defaultBody()
114
+ fs.writeFileSync(file, serializeFrontmatter(meta) + '\n\n' + body)
115
+ const indexed = addReadmeRef(track, start, title, fileRel)
116
+ console.log(start)
117
+ console.log(` wrote ${rel(file)} — frontmatter is set; fill the body with your editor, leave the frontmatter to the script`)
118
+ if (!TODO_ITEM.test(body)) warn(`#${start} has no todos — every task needs a \`- [ ]\` list under ## Todo`)
119
+ if (indexed) console.log(` indexed under "## ${readmeHeadingFor(track)}"`)
120
+ reconcileBoard()
121
+ }
122
+
123
+ const UPDATE_FLAGS = ['title', 'track', 'priority', 'roi', 'status', 'release', 'blocked-by', 'related', 'modules', 'slug', 'cadence']
124
+
125
+ // Rewrite a card's frontmatter fields. Also the sanctioned way to move a card between
126
+ // tracks (--track moves the file + fixes the index) or rename it (--slug). Body is
127
+ // untouched, and so is the question list — that's cmdUpdateQuestions' job.
128
+ export function cmdUpdate(args) {
129
+ const { flags, positional } = parseFlags(args, UPDATE_FLAGS)
130
+ const id = Number(positional[0])
131
+ if (!Number.isInteger(id)) die('need a numeric task id: update <id> [--field value ...]')
132
+ const found = locate(id)
133
+ if (!found) die(`no task with id ${id} under ${rel(TODO)}`)
134
+ const file = found.kind === 'group' ? path.join(found.target, 'root.md') : found.target
135
+ const { meta, body } = parseFrontmatter(fs.readFileSync(file, 'utf8'))
136
+ if (!meta) die(`${rel(file)} has no frontmatter — run \`migrate\` first`)
137
+
138
+ const changes = []
139
+ if (flags.title !== undefined) {
140
+ const t = String(flags.title).trim()
141
+ if (!t) die('--title must not be empty')
142
+ meta.title = t
143
+ changes.push('title')
144
+ }
145
+ if (flags.priority !== undefined) {
146
+ validLevel(String(flags.priority), 'priority')
147
+ meta.priority = String(flags.priority)
148
+ changes.push('priority')
149
+ }
150
+ if (flags.roi !== undefined) {
151
+ validLevel(String(flags.roi), 'roi')
152
+ meta.roi = String(flags.roi)
153
+ changes.push('roi')
154
+ }
155
+ if (flags.status !== undefined) {
156
+ validStatus(String(flags.status))
157
+ meta.status = String(flags.status)
158
+ changes.push('status')
159
+ }
160
+ // `--release ""` — an empty value — takes the card back out of a version.
161
+ if (flags.release !== undefined) {
162
+ meta.release = validRelease(normalizeRelease(flags.release))
163
+ changes.push(`release→${meta.release || '(none)'}`)
164
+ }
165
+ const ceiling = readNextId()
166
+ if (flags['blocked-by'] !== undefined) {
167
+ meta.blocked_by = parseIdList(flags['blocked-by'], 'blocked-by', ceiling)
168
+ changes.push('blocked_by')
169
+ }
170
+ if (flags.related !== undefined) {
171
+ meta.related = parseIdList(flags.related, 'related', ceiling)
172
+ changes.push('related')
173
+ }
174
+ if (flags.modules !== undefined) {
175
+ meta.modules = validModules(parseModuleList(flags.modules))
176
+ changes.push('modules')
177
+ }
178
+ // How often the card repeats, and so whether the local UI runs it in the
179
+ // background at all. `--cadence ""` clears it and the card goes back to
180
+ // running only when someone clicks Run.
181
+ if (flags.cadence !== undefined) {
182
+ if (!isRecurringCard(found)) die(`#${id} is not recurring (${found.rel} is not under ${RECURRING}/) — only a card that repeats can have a cadence.`)
183
+ meta.cadence = cadenceFlag(flags.cadence)
184
+ changes.push(`cadence→${meta.cadence || '(none)'}`)
185
+ }
186
+ // A `ready` card has no open questions by definition (see STATUSES). Open questions
187
+ // mean the plan is not settled, so a `--status ready` with them pending lands as
188
+ // `todo`. This holds the invariant no matter who set the status.
189
+ if (meta.questions.length > 0 && meta.status === 'ready') {
190
+ meta.status = 'todo'
191
+ changes.push('status→todo (open questions)')
192
+ }
193
+
194
+ // A card's track is the folder its file sits in — right for a standalone card
195
+ // (skill/06 → skill), a group subtask (<group>/skill/21 → skill), a blocker,
196
+ // and a recurring card alike. A group root's own folder is the group, not a
197
+ // track, so its frontmatter value stands.
198
+ const curRel = path.relative(TODO, file)
199
+ const curTrack = found.kind === 'group' ? meta.track : path.basename(path.dirname(file))
200
+ const isSubtask = found.kind === 'file' && enclosingGroupRoot(file) !== null
201
+ let newTrack = curTrack
202
+ if (flags.track !== undefined) {
203
+ if (found.kind === 'group') die('moving a group task between tracks by script is not supported — move the folder by hand')
204
+ if (isSubtask) die('moving a group subtask between tracks by script is not supported — move the file by hand')
205
+ newTrack = String(flags.track).trim()
206
+ validTrack(newTrack)
207
+ }
208
+ let base = path.basename(file)
209
+ if (flags.slug !== undefined) {
210
+ if (found.kind === 'group') die('renaming a group root by script is not supported')
211
+ base = `${id}-${slugify(flags.slug)}.md`
212
+ }
213
+ meta.track = newTrack
214
+ // A card moved out of recurring/ leaves its cadence behind: nothing runs a
215
+ // one-shot task on a schedule, so the line would only mislead whoever reads it.
216
+ if (flags.track !== undefined && newTrack !== RECURRING && meta.cadence) {
217
+ meta.cadence = ''
218
+ changes.push('cadence cleared (no longer recurring)')
219
+ }
220
+ // Only a standalone card can change folders (--track). A subtask and a group
221
+ // root stay in their own folder; --slug at most renames the file there.
222
+ const standalone = found.kind === 'file' && !isSubtask
223
+ const destRel = standalone ? path.join(newTrack, base) : path.join(path.dirname(curRel), base)
224
+ const dest = path.join(TODO, destRel)
225
+ const moving = dest !== file
226
+ if (moving && fs.existsSync(dest)) die(`${rel(dest)} already exists`)
227
+
228
+ fs.writeFileSync(file, serializeFrontmatter(meta) + '\n' + body)
229
+ // Putting a group root in a release puts the whole group in it — every subtask, and
230
+ // the subtasks of a nested group too. Taking the root out takes them all out. Done
231
+ // after the root is written so the group ends up on one release either way.
232
+ if (flags.release !== undefined && found.kind === 'group') {
233
+ const ids = setSubtreeRelease(found.target, meta.release)
234
+ if (ids.length) changes.push(`release on ${ids.length} subtask${ids.length === 1 ? '' : 's'} (${ids.map((n) => `#${n}`).join(', ')})`)
235
+ }
236
+ if (moving) fs.renameSync(file, dest)
237
+ if (isSubtask) {
238
+ // A subtask never owns a top-level README entry — fix its nested bullet in place.
239
+ if (moving || changes.includes('title')) repointReadmeLink(id, curRel, destRel, meta.title)
240
+ if (moving) changes.push(`renamed → ${destRel.split(path.sep).join('/')}`)
241
+ } else if (moving) {
242
+ stripReadmeRefs({ kind: 'file', rel: curRel })
243
+ addReadmeRef(newTrack, id, meta.title, destRel)
244
+ changes.push(`moved → ${destRel.split(path.sep).join('/')}`)
245
+ } else if (changes.includes('title')) {
246
+ stripReadmeRefs({ kind: 'file', rel: curRel })
247
+ addReadmeRef(curTrack, id, meta.title, curRel)
248
+ }
249
+ console.log(`updated #${id}: ${changes.join(', ') || '(nothing changed)'}`)
250
+ }
251
+
252
+ // Patch a card's open-question list. Every op edits in place — append one, rewrite
253
+ // one by position, drop answered ones, clear the list — so handing a single question
254
+ // to the user never means re-passing its siblings (wholesale rewrites silently lost
255
+ // options that weren't re-typed). Ops apply in the order they were typed, and a
256
+ // position is read against the list as it stands when its op runs.
257
+ export function cmdUpdateQuestions(args) {
258
+ const [idRaw, ...rest] = args
259
+ const id = Number(idRaw)
260
+ if (!Number.isInteger(id)) die('need a numeric task id: update-questions <id> [ops]')
261
+ const ops = parseQuestionOps(rest)
262
+ const found = locate(id)
263
+ if (!found) die(`no task with id ${id} under ${rel(TODO)}`)
264
+ const file = found.kind === 'group' ? path.join(found.target, 'root.md') : found.target
265
+ const { meta, body } = parseFrontmatter(fs.readFileSync(file, 'utf8'))
266
+ if (!meta) die(`${rel(file)} has no frontmatter — run \`migrate\` first`)
267
+
268
+ const changes = []
269
+ for (const op of ops) {
270
+ if (op.kind === 'clear') {
271
+ meta.questions = []
272
+ changes.push('cleared')
273
+ } else if (op.kind === 'drop') {
274
+ const ns = parseQuestionPositions(op.ns, meta.questions.length, 'drop')
275
+ meta.questions = meta.questions.filter((_, i) => !ns.includes(i + 1))
276
+ changes.push(`dropped ${ns.join(',')}`)
277
+ } else if (op.kind === 'append') {
278
+ meta.questions.push(op.question)
279
+ changes.push('appended')
280
+ } else {
281
+ const [n] = parseQuestionPositions(String(op.n), meta.questions.length, 'update')
282
+ meta.questions[n - 1] = op.question
283
+ changes.push(`rewrote ${n}`)
284
+ }
285
+ }
286
+ warnBadQuestionTags(meta.questions)
287
+ // The same invariant cmdUpdate holds: a `ready` card has no open questions.
288
+ if (meta.questions.length > 0 && meta.status === 'ready') {
289
+ meta.status = 'todo'
290
+ changes.push('status→todo (open questions)')
291
+ }
292
+ fs.writeFileSync(file, serializeFrontmatter(meta) + '\n' + body)
293
+ console.log(`updated #${id} questions: ${changes.join(', ')} (${meta.questions.length} open)`)
294
+ }
295
+
296
+ // Set (or clear) the tag on open questions, so the auto-refine loop can hand a
297
+ // batch of questions to the human in one call without rewriting the whole list.
298
+ // `nRaw` is one 1-based position or a comma-separated list (`1,2,3`); `tag` is
299
+ // user | none (none strips any tag). Reads and rewrites the frontmatter
300
+ // through the same path as `update`, so byte layout and group-root handling stay
301
+ // identical.
302
+ export function cmdTag(args) {
303
+ const [idRaw, nRaw, tagRaw] = args
304
+ const id = Number(idRaw)
305
+ if (!Number.isInteger(id)) die('need a numeric task id: tag <id> <n[,n...]> <user|none>')
306
+ const ns = String(nRaw || '')
307
+ .split(',')
308
+ .map((s) => s.trim())
309
+ .filter((s) => s.length > 0)
310
+ .map(Number)
311
+ if (ns.length === 0 || ns.some((n) => !Number.isInteger(n) || n < 1)) {
312
+ die('need one or more 1-based question numbers: tag <id> <n[,n...]> <user|none>')
313
+ }
314
+ const tag = String(tagRaw || '').toLowerCase()
315
+ if (tag !== 'none' && !QUESTION_TAGS.includes(tag)) {
316
+ die(`tag must be one of ${QUESTION_TAGS.join(' | ')} | none (got "${tagRaw}")`)
317
+ }
318
+ const found = locate(id)
319
+ if (!found) die(`no task with id ${id} under ${rel(TODO)}`)
320
+ const file = found.kind === 'group' ? path.join(found.target, 'root.md') : found.target
321
+ const { meta, body } = parseFrontmatter(fs.readFileSync(file, 'utf8'))
322
+ if (!meta) die(`${rel(file)} has no frontmatter — run \`migrate\` first`)
323
+ const over = ns.find((n) => n > meta.questions.length)
324
+ if (over !== undefined) {
325
+ die(`#${id} has ${meta.questions.length} open question(s) — there's no question ${over} to tag.`)
326
+ }
327
+ for (const n of ns) {
328
+ const q = meta.questions[n - 1]
329
+ const { text } = parseQuestion(q.text)
330
+ q.text = formatQuestion(tag === 'none' ? null : tag, text)
331
+ }
332
+ fs.writeFileSync(file, serializeFrontmatter(meta) + '\n' + body)
333
+ const label = tag === 'none' ? '(untagged)' : `[${tag}]`
334
+ console.log(`tagged #${id} question${ns.length > 1 ? 's' : ''} ${ns.join(', ')} as ${label}`)
335
+ }