kilo-loop/new-plan.mjs
2026-09-27 16:48:10 +03:00

203 lines
6.4 KiB
JavaScript

#!/usr/bin/env node
/**
* Deterministic plan scaffolder for this repo.
*
* 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/<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'
const DEFAULT_DIR = path.join('.kilo', 'plans')
const TEMPLATE = path.join('.kilo', 'templates', 'plan.md')
const CHECKBOX_RE = /^\s*[-*]\s+\[[ xX]\]/m
/**
* Built-in fallback template, used when the project has no
* `.kilo/templates/plan.md`. Guarantees a kilo-loop-compatible plan everywhere.
*/
const DEFAULT_TEMPLATE = `# {{TITLE}}
> **Status:** draft.
## Stages (kilo-loop)
<!-- One checkbox per deliverable. A plan is discovered only when it contains
\`- [ ]\` items. Directives: \`:: verify: <cmd>\` and \`:: acceptance: <text>\`.
Mark \`[x]\` only after the stage is actually verified. -->
- [ ] Stage 1 — <outcome> :: acceptance: <how to confirm>
- [ ] Stage 2 — <outcome> :: acceptance: <how to confirm>
---
## 0. Goal
<!-- What we build and why; scope boundaries. -->
## 1. Current state (verified facts)
<!-- Facts with \`file:line\`, no assumptions. -->
## 2. Decisions
| # | Decision | Choice |
|---|----------|--------|
| D1 | | |
## 3. Data model / contracts
## 4. Backend API
## 5. Frontend
## 6. Verification
<!-- Exact commands/URLs and expected evidence. -->
## 7. Risks
| Risk | Mitigation |
|---|---|
| | |
`
function usage() {
const txt = fs.readFileSync(new URL(import.meta.url), 'utf8')
const block = txt.match(/\/\*\*([\s\S]*?)\*\//)
if (block) console.log(block[1].replace(/^\s*\* ?/gm, '').trim())
}
function fail(msg) {
console.error(`new-plan: ${msg}`)
process.exit(1)
}
function slugify(s) {
return s
.toLowerCase()
.normalize('NFKD')
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'plan'
}
/** Walk up from `start` until a directory containing `.kilo` (or `.git`) is found. */
function findProject(start) {
let dir = path.resolve(start)
for (;;) {
if (fs.existsSync(path.join(dir, '.kilo')) || fs.existsSync(path.join(dir, '.git'))) return dir
const parent = path.dirname(dir)
if (parent === dir) return path.resolve(start)
dir = parent
}
}
/** `.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, 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
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
else if (a === '--force') out.force = true
else if (a === '-h' || a === '--help') {
usage()
process.exit(0)
} else out.title.push(a)
}
out.title = out.title.join(' ').trim()
return out
}
const args = parseArgs(process.argv.slice(2))
if (!args.title) {
usage()
fail('a plan title is required')
}
const project = args.project ? path.resolve(args.project) : findProject(process.cwd())
const templatePath = path.join(project, TEMPLATE)
let body
if (fs.existsSync(templatePath)) {
body = fs.readFileSync(templatePath, 'utf8')
} else {
console.error(`new-plan: no template at ${rel(templatePath)} — using the built-in default`)
body = DEFAULT_TEMPLATE
}
if (!body.includes('{{TITLE}}')) fail(`template is missing the {{TITLE}} placeholder`)
const slug = slugify(args.slug || args.title)
const epoch = args.epoch || Date.now()
const fileName = `${epoch}-${slug}.md`
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
.replaceAll('{{TITLE}}', args.title)
.replaceAll('{{SLUG}}', slug)
.replaceAll('{{EPOCH}}', String(epoch))
.replaceAll('{{DATE}}', new Date(epoch).toISOString())
// Defensive: keep plans discoverable even if the template is edited badly.
if (!CHECKBOX_RE.test(body)) {
body += `\n## Stages (kilo-loop)\n\n- [ ] Complete the plan :: acceptance: goal achieved\n`
console.error('new-plan: template had no checkbox stages — injected a default Stages section')
}
if (args.print) {
process.stdout.write(body)
process.exit(0)
}
fs.mkdirSync(targetDir, { recursive: true })
if (fs.existsSync(targetPath) && !args.force) fail(`already exists: ${rel(targetPath)} (use --force)`)
fs.writeFileSync(targetPath, body)
console.log(rel(targetPath))
function rel(abs) {
return path.relative(project, abs).split(path.sep).join('/')
}