180 lines
5.3 KiB
JavaScript
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('/')
|
|
}
|