kilo-loop/new-plan.mjs
2026-09-13 22:38:01 +03:00

180 lines
5.3 KiB
JavaScript

#!/usr/bin/env node
/**
* 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.
*
* Usage:
* node tools/kilocode-loop/new-plan.mjs "My plan title"
* 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
*/
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
}
}
function parseArgs(argv) {
const out = { title: [], slug: '', dir: DEFAULT_DIR, 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 === '--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 targetDir = path.resolve(project, args.dir)
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('/')
}