diff --git a/README.md b/README.md index 923d737..1396214 100644 --- a/README.md +++ b/README.md @@ -552,14 +552,22 @@ Plans must contain `- [ ]` checkbox stages to be discovered at all. Generate one from the project's template instead of hand-writing the format: ```bash -kilo-loop new-plan "My plan title" # writes .kilo/plans/-.md -kilo-loop new-plan "My plan title" --print # preview without writing +kilo-loop new-plan "My plan title" # writes .kilo/plans//-.md +kilo-loop new-plan "My plan title" --version v1.3 # target an explicit version folder +kilo-loop new-plan "My plan title" --print # preview without writing ``` +Plans are grouped into version subfolders (`v1.0/`, `v1.1/`, `v1.2/`, ...) and +discovery recurses into them (skipping `archive/`, hidden dirs and `README.md`). +The target folder is, in order of precedence: an explicit `--dir` / `--version`, +the version named in `.kilo/plans/CURRENT` (e.g. `v1.2`), then the flat +`.kilo/plans`. Checkboxes inside ```code fences``` are ignored, so a file whose +only `- [ ]` lines are an embedded template is not treated as a goal. + The template is `/.kilo/templates/plan.md` (a sensible default is used if the project has none). Optional flags: `--slug `, `--epoch `, `--dir `, -`--project

`, `--print`, `--force`. The scaffolder is also available directly -at `tools/kilocode-loop/new-plan.mjs`. +`--version `, `--project

`, `--print`, `--force`. The scaffolder is also +available directly at `tools/kilocode-loop/new-plan.mjs`. ## CLI reference diff --git a/new-plan.mjs b/new-plan.mjs index 3023a30..394010d 100644 --- a/new-plan.mjs +++ b/new-plan.mjs @@ -2,24 +2,32 @@ /** * Deterministic plan scaffolder for this repo. * - * Creates `.kilo/plans/-.md` from `.kilo/templates/plan.md` so - * every new plan is kilo-loop compatible by construction (contains `- [ ]` - * stages). kilo-loop only discovers markdown plans with checkbox stages. + * Creates `.kilo/plans/v/-.md` from + * `.kilo/templates/plan.md` so every new plan is kilo-loop compatible by + * construction (contains `- [ ]` stages). kilo-loop discovers markdown plans + * with checkbox stages, recursing into version subfolders. + * + * The target version folder is, in order of precedence: + * 1. an explicit `--dir` / `--version`; + * 2. the version named in `.kilo/plans/CURRENT` (e.g. `v1.2`); + * 3. the flat `.kilo/plans` (no versioning). * * Usage: * node tools/kilocode-loop/new-plan.mjs "My plan title" + * node tools/kilocode-loop/new-plan.mjs "My plan title" --version v1.3 * node tools/kilocode-loop/new-plan.mjs "My plan title" --slug my-plan * node tools/kilocode-loop/new-plan.mjs "My plan title" --print * node tools/kilocode-loop/new-plan.mjs "My plan title" --force * * Options: - * --slug filename slug (default: slugified title) - * --epoch timestamp for the filename (default: now); useful for tests - * --dir target directory (default: .kilo/plans) - * --project

project root (default: git/cwd discovery upward) - * --print print the plan to stdout instead of writing a file - * --force overwrite an existing file - * -h, --help show this help + * --slug filename slug (default: slugified title) + * --epoch timestamp for the filename (default: now); useful for tests + * --dir target directory (default: .kilo/plans/ or .kilo/plans) + * --version target version folder, e.g. v1.2 (shorthand for --dir .kilo/plans/v1.2) + * --project

project root (default: git/cwd discovery upward) + * --print print the plan to stdout instead of writing a file + * --force overwrite an existing file + * -h, --help show this help */ import fs from 'node:fs' import path from 'node:path' @@ -109,12 +117,26 @@ function findProject(start) { } } +/** `.kilo/plans/` when a valid version marker exists, else the flat `.kilo/plans`. */ +function resolveCurrentPlanDir(project) { + try { + const current = fs.readFileSync(path.join(project, DEFAULT_DIR, 'CURRENT'), 'utf8').trim() + if (/^v[\w.\-]+$/.test(current)) return path.join(DEFAULT_DIR, current) + } catch { + /* no marker — fall back to the flat directory */ + } + return DEFAULT_DIR +} + function parseArgs(argv) { - const out = { title: [], slug: '', dir: DEFAULT_DIR, project: '', print: false, force: false, epoch: 0 } + const out = { title: [], slug: '', dir: DEFAULT_DIR, dirSet: false, version: '', project: '', print: false, force: false, epoch: 0 } for (let i = 0; i < argv.length; i++) { const a = argv[i] if (a === '--slug') out.slug = argv[++i] ?? '' - else if (a === '--dir') out.dir = argv[++i] ?? DEFAULT_DIR + else if (a === '--dir') { + out.dir = argv[++i] ?? DEFAULT_DIR + out.dirSet = true + } else if (a === '--version') out.version = argv[++i] ?? '' else if (a === '--project') out.project = argv[++i] ?? '' else if (a === '--epoch') out.epoch = Number(argv[++i]) || 0 else if (a === '--print') out.print = true @@ -149,7 +171,8 @@ if (!body.includes('{{TITLE}}')) fail(`template is missing the {{TITLE}} placeho const slug = slugify(args.slug || args.title) const epoch = args.epoch || Date.now() const fileName = `${epoch}-${slug}.md` -const targetDir = path.resolve(project, args.dir) +const defaultDir = args.version ? path.join(DEFAULT_DIR, args.version) : resolveCurrentPlanDir(project) +const targetDir = path.resolve(project, args.dirSet ? args.dir : defaultDir) const targetPath = path.join(targetDir, fileName) body = body diff --git a/src/goal.mjs b/src/goal.mjs index d1a014c..83e153e 100644 --- a/src/goal.mjs +++ b/src/goal.mjs @@ -1,6 +1,8 @@ import fs from 'node:fs' import path from 'node:path' +import { stripFencedBlocks } from './markdown.mjs' + export function loopPaths(project) { const dir = path.join(project, '.kilocode-loop') return { @@ -103,7 +105,7 @@ export function loadGoal(config) { // Markdown: title = first heading, stages = checkbox list items. // A stage may carry directives after "::", e.g. // - [ ] Rebuild the parser :: verify: pnpm test :: acceptance: 0 failures - const lines = raw.split('\n') + const lines = stripFencedBlocks(raw).split('\n') const titleLine = lines.find((l) => /^#\s+/.test(l)) const stages = [] for (const line of lines) { diff --git a/src/goals.mjs b/src/goals.mjs index 23ad571..698f01b 100644 --- a/src/goals.mjs +++ b/src/goals.mjs @@ -2,6 +2,7 @@ import fs from 'node:fs' import path from 'node:path' import { loadGoal } from './goal.mjs' +import { stripFencedBlocks } from './markdown.mjs' import { listRuns } from './state.mjs' /** @@ -29,24 +30,42 @@ function readJsonIfExists(file) { } } +const IGNORE_DIRS = new Set(['archive', 'node_modules']) +const IGNORE_FILES = new Set(['readme.md']) + function hasCheckbox(file) { try { - return /^\s*[-*]\s+\[[ xX]\]/m.test(fs.readFileSync(file, 'utf8')) + return /^\s*[-*]\s+\[[ xX]\]/m.test(stripFencedBlocks(fs.readFileSync(file, 'utf8'))) } catch { return false } } -function listDir(dir, exts, { requireCheckbox = false } = {}) { +function listDir(dir, exts, { requireCheckbox = false, recurse = false } = {}) { + let entries try { - return fs - .readdirSync(dir, { withFileTypes: true }) - .filter((e) => e.isFile() && exts.includes(path.extname(e.name).toLowerCase())) - .map((e) => path.join(dir, e.name)) - .filter((f) => !requireCheckbox || hasCheckbox(f)) + entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return [] } + const out = [] + for (const e of entries) { + const full = path.join(dir, e.name) + if (e.isDirectory()) { + // Plans are grouped into version subfolders (`v1.0/`, `v1.1/`, `v1.2/`, ...). + // Recurse into them, but never into archives, hidden or tooling dirs. + if (recurse && !e.name.startsWith('.') && !IGNORE_DIRS.has(e.name)) { + out.push(...listDir(full, exts, { requireCheckbox, recurse })) + } + continue + } + if (!e.isFile()) continue + if (IGNORE_FILES.has(e.name.toLowerCase())) continue + if (!exts.includes(path.extname(e.name).toLowerCase())) continue + if (requireCheckbox && !hasCheckbox(full)) continue + out.push(full) + } + return out } /** Absolute paths of every runnable goal/plan under a project. */ @@ -55,7 +74,7 @@ export function discoverGoalFiles(project) { for (const f of listDir(path.join(project, CURATED_DIR), ['.json', '.md'])) found.add(f) for (const f of LEGACY.map((r) => path.join(project, r))) if (fs.existsSync(f)) found.add(f) for (const dir of PLAN_DIRS) { - for (const f of listDir(path.join(project, dir), ['.md'], { requireCheckbox: true })) found.add(f) + for (const f of listDir(path.join(project, dir), ['.md'], { requireCheckbox: true, recurse: true })) found.add(f) } return [...found].sort() } diff --git a/src/markdown.mjs b/src/markdown.mjs new file mode 100644 index 0000000..18d03a7 --- /dev/null +++ b/src/markdown.mjs @@ -0,0 +1,25 @@ +/** + * Markdown helpers shared by the goal registry (`goals.mjs`) and the goal + * parser (`goal.mjs`). + */ + +/** + * Remove fenced code blocks (```...``` / ~~~...~~~) so that checkbox-like lines + * inside an embedded template, example or PR checklist are not mistaken for + * plan stages. A file whose only `- [ ]` items are fenced is therefore treated + * as prose (not a runnable goal). + */ +export function stripFencedBlocks(text) { + const out = [] + let fence = null + for (const line of String(text).split('\n')) { + const m = line.match(/^\s*(`{3,}|~{3,})/) + if (m) { + if (fence === null) fence = m[1][0] + else if (m[1][0] === fence) fence = null + continue + } + if (fence === null) out.push(line) + } + return out.join('\n') +} diff --git a/test/goals.test.mjs b/test/goals.test.mjs index 3e913dc..2b1fbb2 100644 --- a/test/goals.test.mjs +++ b/test/goals.test.mjs @@ -37,6 +37,40 @@ test('discoverGoalFiles finds curated goals, the legacy goal, and checkbox plans assert.ok(!found.includes('plans/without.md')) }) +test('discoverGoalFiles recurses into version subfolders but skips archives and README', () => { + const project = tmpProject() + const mk = (rel, body) => { + const f = path.join(project, rel) + fs.mkdirSync(path.dirname(f), { recursive: true }) + fs.writeFileSync(f, body) + } + mk('plans/v1.0/done.md', '# Done\n\n- [x] shipped\n') + mk('plans/v1.2/open.md', '# Open\n\n- [ ] todo\n') + mk('plans/archive/old.md', '# Old\n\n- [ ] stale\n') + mk('plans/v1.2/README.md', '# v1.2\n\nnotes only\n') + mk('plans/v1.0/notes.md', '# Notes\n\nno checkboxes\n') + + const found = discoverGoalFiles(project).map((f) => path.relative(project, f).split(path.sep).join('/')) + assert.ok(found.includes('plans/v1.0/done.md')) + assert.ok(found.includes('plans/v1.2/open.md')) + assert.ok(!found.includes('plans/archive/old.md')) + assert.ok(!found.includes('plans/v1.2/README.md')) + assert.ok(!found.includes('plans/v1.0/notes.md')) +}) + +test('discoverGoalFiles ignores checkboxes inside fenced code blocks', () => { + const project = tmpProject() + fs.mkdirSync(path.join(project, 'plans', 'v1.0'), { recursive: true }) + fs.writeFileSync( + path.join(project, 'plans', 'v1.0', 'playbook.md'), + '# Playbook\n\n```markdown\n- [ ] copy this into a PR\n- [ ] and this\n```\n', + ) + fs.writeFileSync(path.join(project, 'plans', 'v1.0', 'real.md'), '# Real\n\n- [ ] do it\n') + const found = discoverGoalFiles(project).map((f) => path.relative(project, f).split(path.sep).join('/')) + assert.ok(found.includes('plans/v1.0/real.md')) + assert.ok(!found.includes('plans/v1.0/playbook.md')) +}) + test('loadGoalSummary computes completion, verify and the last run', () => { const project = tmpProject() const goalFile = path.join(project, '.kilocode-loop', 'goal.json') diff --git a/test/new-plan.test.mjs b/test/new-plan.test.mjs index f382d09..adfd98a 100644 --- a/test/new-plan.test.mjs +++ b/test/new-plan.test.mjs @@ -55,6 +55,24 @@ test('new-plan --print does not write a file', () => { assert.equal(fs.existsSync(path.join(project, '.kilo', 'plans')), false) }) +test('new-plan targets the CURRENT version folder when a marker exists', () => { + const project = tmpProject() + fs.mkdirSync(path.join(project, '.kilo', 'plans'), { recursive: true }) + fs.writeFileSync(path.join(project, '.kilo', 'plans', 'CURRENT'), 'v1.2\n') + + const out = run(['Versioned', '--epoch', '1700000000000'], project).trim() + assert.equal(out, '.kilo/plans/v1.2/1700000000000-versioned.md') + // Recursive discovery must still find it. + const abs = path.join(project, out) + assert.ok(discoverGoalFiles(project).some((f) => path.resolve(f) === path.resolve(abs))) +}) + +test('new-plan --version targets an explicit version folder', () => { + const project = tmpProject() + const out = run(['Next', '--version', 'v1.3', '--epoch', '1700000000000'], project).trim() + assert.equal(out, '.kilo/plans/v1.3/1700000000000-next.md') +}) + test('new-plan refuses to overwrite without --force', () => { const project = tmpProject() const first = run(['Same', '--slug', 'same', '--epoch', '1700000000000'], project).trim()