import { test } from 'node:test' import assert from 'node:assert/strict' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { deleteGoalFile, discoverGoalFiles, listGoalSummaries, loadGoalSummary, resolveGoalPath, setGlobalVerify, setGoalAllDone, setGoalVerify, } from '../src/goals.mjs' function tmpProject() { return fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-goals-')) } test('discoverGoalFiles finds curated goals, the legacy goal, and checkbox plans', () => { const project = tmpProject() fs.mkdirSync(path.join(project, '.kilocode-loop', 'goals'), { recursive: true }) fs.writeFileSync(path.join(project, '.kilocode-loop', 'goals', 'a.json'), JSON.stringify({ title: 'A', stages: [] })) fs.writeFileSync(path.join(project, '.kilocode-loop', 'goals', 'b.md'), '# B\n\n- [ ] one\n') fs.writeFileSync(path.join(project, '.kilocode-loop', 'goal.json'), JSON.stringify({ title: 'Legacy', stages: [] })) fs.mkdirSync(path.join(project, 'plans'), { recursive: true }) fs.writeFileSync(path.join(project, 'plans', 'with.md'), '# P\n\n- [ ] step\n') fs.writeFileSync(path.join(project, 'plans', 'without.md'), '# Q\n\nno checkboxes here\n') const found = discoverGoalFiles(project).map((f) => path.relative(project, f).split(path.sep).join('/')) assert.ok(found.includes('.kilocode-loop/goals/a.json')) assert.ok(found.includes('.kilocode-loop/goals/b.md')) assert.ok(found.includes('.kilocode-loop/goal.json')) assert.ok(found.includes('plans/with.md')) 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') fs.mkdirSync(path.dirname(goalFile), { recursive: true }) fs.writeFileSync( goalFile, JSON.stringify({ title: 'My plan', stages: [ { id: 's1', title: 'One', done: true, verify: 'pnpm test' }, { id: 's2', title: 'Two' }, ], }), ) fs.writeFileSync(path.join(project, '.kilocode-loop', 'config.json'), JSON.stringify({ verify: 'dotnet test' })) const runDir = path.join(project, '.kilocode-loop', 'runs', 'r1') fs.mkdirSync(runDir, { recursive: true }) fs.writeFileSync( path.join(runDir, 'state.json'), JSON.stringify({ runId: 'r1', startedAt: '2026-09-13T00:00:00Z', status: 'done', goal: { title: 'My plan', source: goalFile }, iterations: [{ index: 1 }], totals: { cost: 0.5, tokens: { total: 1234 } }, }), ) const summary = loadGoalSummary(project, '.kilocode-loop/goal.json') assert.equal(summary.title, 'My plan') assert.equal(summary.total, 2) assert.equal(summary.done, 1) assert.equal(summary.percent, 50) assert.equal(summary.globalVerify, 'dotnet test') assert.equal(summary.stages[0].verify, 'pnpm test') assert.equal(summary.lastRun.runId, 'r1') assert.equal(summary.lastRun.status, 'done') }) test('setGoalVerify edits a JSON stage and the JSON global verify', () => { const project = tmpProject() const file = path.join(project, 'goal.json') fs.writeFileSync(file, JSON.stringify({ title: 'T', stages: [{ id: 's1', title: 'One' }, { id: 's2', title: 'Two' }] })) assert.equal(setGoalVerify(project, 'goal.json', { stageIndex: 1, verify: 'pnpm build' }).ok, true) let data = JSON.parse(fs.readFileSync(file, 'utf8')) assert.equal(data.stages[1].verify, 'pnpm build') assert.equal(setGoalVerify(project, 'goal.json', { verify: 'dotnet test' }).ok, true) data = JSON.parse(fs.readFileSync(file, 'utf8')) assert.equal(data.verify, 'dotnet test') }) test('setGoalVerify adds and replaces the directive on a markdown stage', () => { const project = tmpProject() const file = path.join(project, 'plan.md') fs.writeFileSync(file, '# P\n\n- [ ] First :: acceptance: green\n- [ ] Second\n') assert.equal(setGoalVerify(project, 'plan.md', { stageIndex: 0, verify: 'pnpm test' }).ok, true) let text = fs.readFileSync(file, 'utf8') assert.match(text, /- \[ \] First :: acceptance: green :: verify: pnpm test/) assert.equal(setGoalVerify(project, 'plan.md', { stageIndex: 0, verify: 'pnpm build' }).ok, true) text = fs.readFileSync(file, 'utf8') assert.match(text, /verify: pnpm build/) assert.doesNotMatch(text, /pnpm test/) assert.match(text, /- \[ \] Second/) }) test('setGlobalVerify writes .kilocode-loop/config.json', () => { const project = tmpProject() assert.equal(setGlobalVerify(project, 'dotnet test').ok, true) const config = JSON.parse(fs.readFileSync(path.join(project, '.kilocode-loop', 'config.json'), 'utf8')) assert.equal(config.verify, 'dotnet test') }) test('setGoalAllDone marks and clears markdown checkboxes, keeping directives', () => { const project = tmpProject() const file = path.join(project, 'plan.md') fs.writeFileSync(file, '# P\n\n- [ ] First :: verify: pnpm test\n- [x] Second\n- plain note\n') assert.equal(setGoalAllDone(project, 'plan.md', { done: true }).ok, true) let text = fs.readFileSync(file, 'utf8') assert.match(text, /- \[x\] First :: verify: pnpm test/) assert.match(text, /- \[x\] Second/) assert.match(text, /- plain note/) assert.equal(loadGoalSummary(project, 'plan.md').percent, 100) assert.equal(setGoalAllDone(project, 'plan.md', { done: false }).ok, true) text = fs.readFileSync(file, 'utf8') assert.match(text, /- \[ \] First :: verify: pnpm test/) assert.match(text, /- \[ \] Second/) assert.equal(loadGoalSummary(project, 'plan.md').percent, 0) }) test('setGoalAllDone marks and clears JSON stages', () => { const project = tmpProject() const file = path.join(project, 'goal.json') fs.writeFileSync(file, JSON.stringify({ title: 'T', stages: [{ id: 's1', title: 'One' }, { id: 's2', title: 'Two', done: true }] })) assert.equal(setGoalAllDone(project, 'goal.json', { done: true }).ok, true) let data = JSON.parse(fs.readFileSync(file, 'utf8')) assert.deepEqual(data.stages.map((s) => s.done), [true, true]) assert.equal(setGoalAllDone(project, 'goal.json', { done: false }).ok, true) data = JSON.parse(fs.readFileSync(file, 'utf8')) assert.deepEqual(data.stages.map((s) => s.done), [false, false]) }) test('setGoalAllDone rejects a plan without checkboxes or stages', () => { const project = tmpProject() fs.writeFileSync(path.join(project, 'empty.md'), '# P\n\nno checkboxes\n') const result = setGoalAllDone(project, 'empty.md', { done: true }) assert.equal(result.ok, false) assert.match(result.error, /no checkbox stages/) }) test('deleteGoalFile moves a plan to .kilocode-loop/trash (recoverable)', () => { const project = tmpProject() const file = path.join(project, 'plans', 'x.md') fs.mkdirSync(path.dirname(file), { recursive: true }) fs.writeFileSync(file, '# X\n\n- [ ] one\n') const result = deleteGoalFile(project, 'plans/x.md') assert.equal(result.ok, true) assert.equal(fs.existsSync(file), false) const trashed = path.join(project, result.trashed) assert.ok(fs.existsSync(trashed)) assert.match(fs.readFileSync(trashed, 'utf8'), /one/) }) test('deleteGoalFile purges when asked', () => { const project = tmpProject() const file = path.join(project, 'gone.json') fs.writeFileSync(file, JSON.stringify({ title: 'G', stages: [] })) const result = deleteGoalFile(project, 'gone.json', { purge: true }) assert.equal(result.ok, true) assert.equal(result.purged, true) assert.equal(fs.existsSync(file), false) assert.equal(fs.existsSync(path.join(project, '.kilocode-loop', 'trash')), false) }) test('resolveGoalPath rejects paths outside the project', () => { const project = tmpProject() assert.throws(() => resolveGoalPath(project, '../../etc/passwd'), /inside the project/) assert.throws(() => resolveGoalPath(project, 'missing.json'), /not found/) }) test('listGoalSummaries skips broken goal files', () => { const project = tmpProject() fs.mkdirSync(path.join(project, '.kilocode-loop', 'goals'), { recursive: true }) fs.writeFileSync(path.join(project, '.kilocode-loop', 'goals', 'bad.json'), '{ not json') fs.writeFileSync(path.join(project, '.kilocode-loop', 'goals', 'ok.json'), JSON.stringify({ title: 'OK', stages: [{ id: 's1', title: 'x' }] })) const list = listGoalSummaries(project) assert.equal(list.length, 1) assert.equal(list[0].title, 'OK') })