new plan system optimizations
This commit is contained in:
parent
d7c22ca452
commit
dbde587ee9
16
README.md
16
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/<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
|
||||
|
||||
|
|
|
|||
49
new-plan.mjs
49
new-plan.mjs
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
25
src/markdown.mjs
Normal 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')
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue