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 { classifyProgress, clearHandoff, extractQuestionsFromText, loadGoal, normaliseQuestions, readHandoff, resetProgress, readProgress, buildIterationPrompt, } from '../src/goal.mjs' function tmpProject() { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-loop-')) return dir } test('loadGoal parses markdown checkboxes', () => { const project = tmpProject() const file = path.join(project, 'goal.md') fs.writeFileSync(file, '# My goal\n\nBody text.\n\n- [x] done one\n- [ ] next one\n') const goal = loadGoal({ project, goal: 'goal.md', goalText: '' }) assert.equal(goal.title, 'My goal') assert.equal(goal.stages.length, 2) assert.equal(goal.stages[0].done, true) assert.equal(goal.stages[1].title, 'next one') }) test('loadGoal parses json stages', () => { const project = tmpProject() const file = path.join(project, 'goal.json') fs.writeFileSync(file, JSON.stringify({ title: 'T', stages: [{ id: 'a', title: 'A' }] })) const goal = loadGoal({ project, goal: 'goal.json', goalText: '' }) assert.equal(goal.stages[0].id, 'a') }) test('loadGoal unwraps inline-code backticks around verify commands', () => { const project = tmpProject() const file = path.join(project, 'goal.md') fs.writeFileSync( file, '# G\n\n- [ ] One :: verify: `pnpm test` :: acceptance: ok\n- [ ] Two :: verify: pnpm build :: acceptance: ok\n', ) const goal = loadGoal({ project, goal: 'goal.md', goalText: '' }) assert.equal(goal.stages[0].verify, 'pnpm test') assert.equal(goal.stages[1].verify, 'pnpm build') }) test('loadGoal unwraps inline-code backticks in json verify', () => { const project = tmpProject() const file = path.join(project, 'goal.json') fs.writeFileSync(file, JSON.stringify({ title: 'T', stages: [{ id: 'a', title: 'A', verify: '`pnpm test`' }] })) const goal = loadGoal({ project, goal: 'goal.json', goalText: '' }) assert.equal(goal.stages[0].verify, 'pnpm test') }) test('loadGoal falls back to a single stage for inline text', () => { const goal = loadGoal({ project: '/tmp', goal: '', goalText: 'Do the thing' }) assert.equal(goal.stages.length, 1) assert.equal(goal.title, 'Do the thing') }) test('classifyProgress prefers explicit percent and normalises questions', () => { const goal = { title: 'G', stages: [{ id: 's1', title: 'One' }, { id: 's2', title: 'Two' }] } const cls = classifyProgress( { topic: 'topic', percent: 42, stageId: 's2', stageStatus: 'in_progress', nextStage: 'Two', questions: ['Why?'] }, goal, 1, ) assert.equal(cls.percent, 42) assert.equal(cls.topic, 'topic') assert.equal(cls.nextStage, 'Two') assert.equal(cls.needsInput, true) assert.equal(cls.questions[0].text, 'Why?') }) test('classifyProgress derives percent from completed stages', () => { const goal = { title: 'G', stages: [{ id: 's1', title: 'One' }, { id: 's2', title: 'Two' }] } const cls = classifyProgress({ stageId: 's1', stageStatus: 'done' }, goal, 1) assert.equal(cls.percent, 50) assert.equal(cls.stageStatus, 'done') }) test('classifyProgress clamps out-of-range percentages', () => { const goal = { title: 'G', stages: [{ id: 's1', title: 'One' }] } assert.equal(classifyProgress({ percent: 900 }, goal, 1).percent, 100) assert.equal(classifyProgress({ percent: -5 }, goal, 1).percent, 0) }) test('normaliseQuestions handles strings and objects, filters blanks', () => { const qs = normaliseQuestions(['a?', { id: 'x', text: 'b?', options: [1, 2] }, { text: ' ' }]) assert.equal(qs.length, 2) assert.deepEqual(qs[1].options, ['1', '2']) }) test('extractQuestionsFromText pulls question lines only', () => { const qs = extractQuestionsFromText(['Line one\ndoes this work?\nhttp://x?y=1\nAnother question?']) assert.equal(qs.length, 2) assert.match(qs[0].text, /does this work\?/) }) test('resetProgress + readProgress round-trip', () => { const project = tmpProject() resetProgress({ project }, 'run-1', 3) const p = readProgress({ project }) assert.equal(p.runId, 'run-1') assert.equal(p.iteration, 3) }) test('buildIterationPrompt embeds the progress path and answers', () => { const project = tmpProject() const goal = { title: 'G', description: 'd', stages: [{ id: 's1', title: 'One' }] } const prompt = buildIterationPrompt({ config: { project, promptExtra: '' }, goal, iteration: 2, total: 5, progress: { stageId: 's1', summary: 'prev', iteration: 1 }, answers: [{ question: 'Q?', answer: 'A!' }], currentStageIndex: 0, }) assert.match(prompt, /iteration 2\/5/i) assert.match(prompt, /progress\.json/) assert.match(prompt, /Q:/) assert.match(prompt, /A!/) }) test('buildIterationPrompt embeds the custom instruction before the goal', () => { const project = tmpProject() const goal = { title: 'G', description: 'd', stages: [{ id: 's1', title: 'One' }] } const prompt = buildIterationPrompt({ config: { project, promptExtra: 'Always run pnpm test before reporting done.' }, goal, iteration: 1, total: 3, progress: null, answers: [], currentStageIndex: 0, }) assert.match(prompt, /## Operator instructions for this run/) assert.match(prompt, /Always run pnpm test/) assert.ok(prompt.indexOf('## Operator instructions') < prompt.indexOf('## Goal')) const plain = buildIterationPrompt({ config: { project, promptExtra: ' ' }, goal, iteration: 1, total: 3, progress: null, answers: [], currentStageIndex: 0, }) assert.doesNotMatch(plain, /## Operator instructions/) }) test('readHandoff + clearHandoff round-trip', () => { const project = tmpProject() assert.equal(readHandoff({ project }), null) fs.mkdirSync(path.join(project, '.kilocode-loop'), { recursive: true }) fs.writeFileSync(path.join(project, '.kilocode-loop', 'handoff.md'), ' packet ') assert.equal(readHandoff({ project }), 'packet') clearHandoff({ project }) assert.equal(readHandoff({ project }), null) }) test('buildIterationPrompt in fresh mode embeds the handoff packet and file list', () => { const project = tmpProject() const goal = { title: 'G', description: 'd', stages: [{ id: 's1', title: 'One' }] } const prompt = buildIterationPrompt({ config: { project, promptExtra: '' }, goal, iteration: 2, total: 5, progress: { stageId: 's1', summary: 'prev', iteration: 1, nextStage: 'Two' }, answers: [], currentStageIndex: 0, sharedContext: false, handoff: '### Decision\nUse q-select.', filesChanged: ['src/a.vue'], }) assert.match(prompt, /fresh session/i) assert.match(prompt, /Context handoff/) assert.match(prompt, /handoff\.md/) assert.match(prompt, /Use q-select/) assert.match(prompt, /src\/a\.vue/) }) test('buildIterationPrompt in shared mode omits the fresh-context handoff', () => { const project = tmpProject() const goal = { title: 'G', description: 'd', stages: [{ id: 's1', title: 'One' }] } const prompt = buildIterationPrompt({ config: { project, promptExtra: '' }, goal, iteration: 1, total: 2, progress: null, answers: [], currentStageIndex: 0, sharedContext: true, }) assert.doesNotMatch(prompt, /fresh session/i) assert.doesNotMatch(prompt, /Handoff to the next iteration/) })