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.
- package/bin/ai4kanban.mjs +14 -6
- package/package.json +1 -1
- package/skill/SKILL.md +58 -32
- package/skill/commands/card.mjs +335 -0
- package/skill/commands/init.mjs +292 -0
- package/skill/commands/list.mjs +99 -0
- package/skill/commands/misc.mjs +105 -0
- package/skill/commands/release.mjs +132 -0
- package/skill/commands/remove.mjs +230 -0
- package/skill/commands/setup.mjs +58 -0
- package/skill/config.md +3 -0
- package/skill/kanban.mjs +129 -1519
- package/skill/lib/cadence.mjs +96 -0
- package/skill/lib/cards.mjs +114 -0
- package/skill/lib/frontmatter.mjs +136 -0
- package/skill/lib/memory.mjs +130 -0
- package/skill/lib/metrics.mjs +39 -0
- package/skill/lib/paths.mjs +67 -0
- package/skill/lib/questions.mjs +242 -0
- package/skill/lib/readme.mjs +113 -0
- package/skill/lib/reconcile.mjs +114 -0
- package/skill/lib/recurring.mjs +61 -0
- package/skill/lib/releases.mjs +468 -0
- package/skill/lib/setup.mjs +196 -0
- package/skill/lib/validate.mjs +165 -0
- package/skill/lib/yaml.mjs +25 -0
- package/skill/references/add-task.md +4 -1
- package/skill/references/auto-refine.md +0 -14
- package/skill/references/module-map.md +17 -1
- package/skill/references/propose.md +49 -59
- package/skill/references/prune-memory.md +5 -4
- package/skill/references/recurring-task.md +41 -69
- package/skill/references/refine.md +4 -2
- package/skill/references/reject.md +26 -0
- package/skill/references/releases.md +31 -0
- package/skill/references/resolve.md +38 -10
- package/skill/references/setup.md +87 -0
- 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:`
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
say('
|
|
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
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`
|
|
12
|
-
planning sources, reference docs, optional preset.
|
|
13
|
-
in; until then its defaults apply. It lives with
|
|
14
|
-
|
|
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
|
|
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
|
|
84
|
-
|
|
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 ..]
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
105
|
-
(title, track, priority, roi, blocked_by, related, modules,
|
|
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
|
|
120
|
-
|
|
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
|
|
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
|
|
196
|
-
one
|
|
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
|
|
235
|
-
`reviewed: strong | good | weak
|
|
236
|
-
|
|
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
|
+
}
|