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