new plan system optimizations

This commit is contained in:
Vitali sharp8n 2026-09-27 16:48:10 +03:00
parent d7c22ca452
commit dbde587ee9
7 changed files with 155 additions and 26 deletions

View file

@ -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/<epoch-ms>-<slug>.md
kilo-loop new-plan "My plan title" --print # preview without writing
kilo-loop new-plan "My plan title" # writes .kilo/plans/<CURRENT>/<epoch-ms>-<slug>.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 `<project>/.kilo/templates/plan.md` (a sensible default is used if
the project has none). Optional flags: `--slug <s>`, `--epoch <ms>`, `--dir <d>`,
`--project <p>`, `--print`, `--force`. The scaffolder is also available directly
at `tools/kilocode-loop/new-plan.mjs`.
`--version <v>`, `--project <p>`, `--print`, `--force`. The scaffolder is also
available directly at `tools/kilocode-loop/new-plan.mjs`.
## CLI reference

View file

@ -2,24 +2,32 @@
/**
* Deterministic plan scaffolder for this repo.
*
* Creates `.kilo/plans/<epoch-ms>-<slug>.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<current>/<epoch-ms>-<slug>.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 <s> filename slug (default: slugified title)
* --epoch <ms> timestamp for the filename (default: now); useful for tests
* --dir <d> target directory (default: .kilo/plans)
* --project <p> 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 <s> filename slug (default: slugified title)
* --epoch <ms> timestamp for the filename (default: now); useful for tests
* --dir <d> target directory (default: .kilo/plans/<CURRENT> or .kilo/plans)
* --version <v> target version folder, e.g. v1.2 (shorthand for --dir .kilo/plans/v1.2)
* --project <p> 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/<CURRENT>` 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

View file

@ -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) {

View file

@ -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()
}

25
src/markdown.mjs Normal file
View file

@ -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')
}

View file

@ -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')

View file

@ -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()