diff --git a/README.md b/README.md index 922e7bb..923d737 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,11 @@ toggles a `topbar--` class): (warning, not failure); - **done** — a static green line; **idle** — plain header. +Every duration is an integer multiple of a 1.4s beat, so where a state pairs two +animations (e.g. the running sweep + sheen) they repeat on a common period and +stay phase-locked instead of slowly drifting apart. Keep new timings on that beat +grid. + All movement is disabled under `prefers-reduced-motion: reduce` (the state colour stays). See `public/styles.css` (`topbar--*`) and `topbarState()` in `public/app.js`. diff --git a/public/styles.css b/public/styles.css index 1d8de1c..60ce00c 100644 --- a/public/styles.css +++ b/public/styles.css @@ -44,7 +44,12 @@ body { display: flex; flex-direction: column; } /* ---------- top-bar activity indicator (pure CSS) ---------- ::after = dim track sitting on the bottom border ::before = the moving / solid segment that signals the state - JS only toggles a `topbar--` class. */ + JS only toggles a `topbar--` class. + + All durations below are integer multiples of a 1.4s beat. Two infinite + animations with an arbitrary ratio (e.g. 4.6s vs 2.8s = 23:14) only re-align + every 64s, so they visibly drift; keeping every animation on the same beat + grid makes the pair repeat exactly and stay phase-locked. */ .topbar::before, .topbar::after { content: ''; @@ -68,28 +73,28 @@ body { display: flex; flex-direction: column; } rgba(88, 166, 255, 0.13) 66%, rgba(88, 166, 255, 0) 100%); background-size: 240% 100%; - animation: topbar-sheen 4.6s linear infinite; + animation: topbar-sheen 5.6s linear infinite; /* 4 × beat = 2 × sweep → locked */ } .topbar--running::after { opacity: 1; background: rgba(88, 166, 255, 0.22); } .topbar--running::before { opacity: 1; background: linear-gradient(90deg, rgba(88, 166, 255, 0) 0%, var(--accent) 22%, #7ee787 56%, var(--green) 80%, rgba(63, 185, 80, 0) 100%); box-shadow: 0 0 12px rgba(88, 166, 255, 0.8), 0 0 5px rgba(126, 231, 135, 0.95); - animation: topbar-sweep 2.8s cubic-bezier(0.45, 0, 0.35, 1) infinite; + animation: topbar-sweep 2.8s cubic-bezier(0.45, 0, 0.35, 1) infinite; /* 2 × beat */ } /* waiting for operator input — pulsing purple */ .topbar--waiting { background-image: linear-gradient(90deg, rgba(188, 140, 255, 0.07), rgba(188, 140, 255, 0.12), rgba(188, 140, 255, 0.07)); background-size: 220% 100%; - animation: topbar-sheen 2.8s linear infinite; + animation: topbar-sheen 2.8s linear infinite; /* 2 × beat */ } .topbar--waiting::before { opacity: 1; width: 100%; transform: none; background: linear-gradient(90deg, rgba(188, 140, 255, 0.15), var(--purple), rgba(188, 140, 255, 0.15)); - animation: topbar-breathe 1.6s ease-in-out infinite; + animation: topbar-breathe 1.4s ease-in-out infinite; /* 1 × beat (2 per sheen) */ } /* paused — static amber baseline (deliberately not animating) */ @@ -111,7 +116,7 @@ body { display: flex; flex-direction: column; } /* stopped / stale-ish warnings — light amber tint */ .topbar--warn { background-image: linear-gradient(180deg, rgba(210, 153, 34, 0.13), rgba(210, 153, 34, 0.04) 72%, transparent); - animation: topbar-warn-pulse 2.6s ease-in-out infinite; + animation: topbar-warn-pulse 2.8s ease-in-out infinite; /* 2 × beat */ } .topbar--warn::before { opacity: 1; @@ -124,7 +129,7 @@ body { display: flex; flex-direction: column; } /* error / done-with-errors / stale — light red tint + pulsing red line */ .topbar--error { background-image: linear-gradient(180deg, rgba(248, 81, 73, 0.17), rgba(248, 81, 73, 0.05) 74%, transparent); - animation: topbar-error-pulse 1.8s ease-in-out infinite; + animation: topbar-error-pulse 2.8s ease-in-out infinite; /* 2 × beat */ } .topbar--error::before { opacity: 1; @@ -132,7 +137,7 @@ body { display: flex; flex-direction: column; } transform: none; background: var(--red); box-shadow: 0 0 12px rgba(248, 81, 73, 0.8); - animation: topbar-breathe 1.4s ease-in-out infinite; + animation: topbar-breathe 1.4s ease-in-out infinite; /* 1 × beat (2 per pulse) */ } @keyframes topbar-sweep { diff --git a/src/goal.mjs b/src/goal.mjs index 105dcbb..d1a014c 100644 --- a/src/goal.mjs +++ b/src/goal.mjs @@ -48,6 +48,19 @@ function slug(text) { .slice(0, 48) } +/** + * Plan authors commonly wrap a directive value in inline-code backticks, e.g. + * `:: verify: `pnpm test``. The loop passes the value to the shell verbatim, and + * a surrounding pair of backticks turns it into a command substitution — the + * gate then runs the command's *stdout* as a command (exit 127 on "Passed! …"). + * Unwrap a single backtick-delimited span so the gate runs the intended command. + */ +export function unwrapCode(value) { + const text = String(value ?? '').trim() + const m = text.match(/^`([^`]*)`$/) + return m ? m[1].trim() : text +} + /** * Load a goal from --goal-text, a .json file, or a .md file with "- [ ]" checkboxes. */ @@ -73,8 +86,8 @@ export function loadGoal(config) { title: s.title || s.name || `Stage ${i + 1}`, details: s.details || s.description || '', done: Boolean(s.done), - verify: s.verify || s.verifyCommand || '', - acceptance: s.acceptance || s.acceptanceCriteria || '', + verify: unwrapCode(s.verify || s.verifyCommand || ''), + acceptance: unwrapCode(s.acceptance || s.acceptanceCriteria || ''), model: s.model || '', agent: s.agent || '', variant: s.variant || '', @@ -100,7 +113,7 @@ export function loadGoal(config) { const directives = {} for (const part of directiveParts) { const dm = part.match(/^\s*(verify|acceptance)\s*:\s*(.+?)\s*$/i) - if (dm) directives[dm[1].toLowerCase()] = dm[2] + if (dm) directives[dm[1].toLowerCase()] = unwrapCode(dm[2]) } const title = titlePart.replace(/[*_`]/g, '').trim() stages.push({ diff --git a/src/orchestrator.mjs b/src/orchestrator.mjs index 1c10a96..72462f6 100644 --- a/src/orchestrator.mjs +++ b/src/orchestrator.mjs @@ -576,14 +576,13 @@ export class Orchestrator { rec.verify = verifyResult cls = { ...cls, verify: verifyResult } if (verifyResult.ok) { - this.completedStages.add(stage?.id || cls.stageId) + this._completeStage(goal, stage, cls.stageId) stageAdvanced = true this.verifyFailures.delete(stage?.id || cls.stageId) - if (stage) this._syncGoalStage(goal, stage) if (config.review && stage && !this.reviewedStages.has(stage.id) && !config.dryRun) { const review = await this._runReviewer(goal, stage, iteration) if (review?.blocked) { - this.completedStages.delete(stage?.id || cls.stageId) + this._reopenStage(goal, stage, cls.stageId) stageAdvanced = false cls = { ...cls, @@ -615,9 +614,8 @@ export class Orchestrator { } } else { // No verify command: trust the agent, but it is not independently checked. - this.completedStages.add(stage?.id || cls.stageId) + this._completeStage(goal, stage, cls.stageId) stageAdvanced = true - if (stage) this._syncGoalStage(goal, stage) } } @@ -774,6 +772,37 @@ export class Orchestrator { } } + /** + * Mark a stage complete: record it in the in-memory goal, tick the goal file + * and publish the refreshed stage list into run state so the dashboard's + * "Stages" strip updates the moment an iteration advances — not only when a + * later run reloads the goal file from disk. + */ + _completeStage(goal, stage, fallbackId) { + const id = stage?.id || fallbackId + if (id) this.completedStages.add(id) + if (stage) stage.done = true + if (stage) this._syncGoalStage(goal, stage) + this._publishStages(goal) + } + + /** Revert a stage completion (used when the reviewer blocks a stage). */ + _reopenStage(goal, stage, fallbackId) { + const id = stage?.id || fallbackId + if (id) this.completedStages.delete(id) + if (stage) stage.done = false + this._publishStages(goal) + } + + /** Push the live per-stage done state into run state for the UI snapshot. */ + _publishStages(goal) { + const stages = (goal?.stages || []).map((s) => ({ + ...s, + done: Boolean(s.done || this.completedStages.has(s.id)), + })) + this.state.update({ stages }) + } + async _runReviewer(goal, stage, iteration) { const { config, state } = this const reviewPath = path.join(loopPaths(config.project).dir, 'review.json') diff --git a/src/verify.mjs b/src/verify.mjs index abf69af..da24422 100644 --- a/src/verify.mjs +++ b/src/verify.mjs @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process' +import { unwrapCode } from './goal.mjs' /** * Independent verification for a stage. The loop runs the command itself and @@ -13,7 +14,7 @@ const MAX_CAPTURE = 20000 * --verify / config.verify. */ export function verifyCommandFor(stage, config = {}) { - const cmd = (stage?.verify || config.verify || '').trim() + const cmd = unwrapCode(stage?.verify || config.verify || '') return cmd || null } diff --git a/test/goal.test.mjs b/test/goal.test.mjs index 89bedd4..6e8e38a 100644 --- a/test/goal.test.mjs +++ b/test/goal.test.mjs @@ -40,6 +40,26 @@ test('loadGoal parses json stages', () => { 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) diff --git a/test/orchestrator-stages.test.mjs b/test/orchestrator-stages.test.mjs new file mode 100644 index 0000000..cea7091 --- /dev/null +++ b/test/orchestrator-stages.test.mjs @@ -0,0 +1,116 @@ +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 { Orchestrator } from '../src/orchestrator.mjs' +import { loopPaths } from '../src/goal.mjs' + +const sink = { write: () => true } + +function makeProject(stages) { + const project = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-stages-')) + fs.mkdirSync(path.join(project, '.kilocode-loop'), { recursive: true }) + fs.writeFileSync(path.join(project, 'goal.json'), JSON.stringify({ title: 'Test goal', stages })) + return project +} + +function makeConfig(project, overrides = {}) { + return { + project, + goal: 'goal.json', + goalText: '', + iterations: 3, + agent: 'code-design', + model: '', + variant: '', + thinking: false, + auto: true, + sharedContext: false, + hitl: 'off', + confirm: false, + port: 7997, + host: '127.0.0.1', + runId: 'stages-run', + dryRun: false, + keepOpen: false, + quiet: true, + color: false, + promptExtra: '', + maxIterationMinutes: 0, + contextWarnTokens: 150000, + verify: '', + verifyTimeoutMinutes: 30, + maxCost: 0, + maxTokens: 0, + maxStaleIterations: 0, + checkpoint: false, + guard: true, + redact: true, + review: false, + reviewAgent: '', + autoFreshTokens: 0, + notify: '', + syncGoal: true, + ...overrides, + } +} + +/** + * Deterministic runner that reports its current stage as done on every + * iteration, so the loop advances one stage per iteration. + */ +async function alwaysDoneRunner({ config, prompt }) { + const iteration = Number(prompt.match(/iteration (\d+)\//)?.[1] || 1) + fs.writeFileSync( + loopPaths(config.project).progress, + JSON.stringify({ + iteration, + topic: `advance ${iteration}`, + stageId: `reported-${iteration}`, + stageStatus: 'done', + percent: iteration * 10, + updatedAt: new Date().toISOString(), + }), + ) + return { + sessionID: `ses_test_${iteration}`, + exitCode: 0, + error: null, + interrupted: false, + durationMs: 1, + texts: [`iteration ${iteration} done`], + usage: { input: 1, output: 1, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 2 }, + cost: 0, + } +} + +test('live stage progress reaches run state (dashboard "Stages" strip)', async () => { + const project = makeProject([ + { id: 's1', title: 'One' }, + { id: 's2', title: 'Two' }, + { id: 's3', title: 'Three' }, + ]) + const final = await new Orchestrator(makeConfig(project, { runner: alwaysDoneRunner }), { out: sink }).run() + + assert.equal(final.status, 'done') + assert.deepEqual( + final.stages.map((s) => s.done), + [true, true, true], + 'every completed stage must be reported as done in run state', + ) +}) + +test('a single-iteration advance is published before the next iteration starts', async () => { + const project = makeProject([ + { id: 's1', title: 'One' }, + { id: 's2', title: 'Two' }, + ]) + const orchestrator = new Orchestrator(makeConfig(project, { iterations: 1, runner: alwaysDoneRunner }), { out: sink }) + const seen = [] + orchestrator.onReport = () => seen.push(orchestrator.state.state.stages.map((s) => s.done)) + await orchestrator.run() + + assert.deepEqual(seen[0], [true, false], 'the first stage is done in run state right after iteration 1') +}) diff --git a/test/verify.test.mjs b/test/verify.test.mjs index 311a20a..cf91e33 100644 --- a/test/verify.test.mjs +++ b/test/verify.test.mjs @@ -9,6 +9,15 @@ test('verifyCommandFor prefers the stage command over the global default', () => assert.equal(verifyCommandFor({}, {}), null) }) +test('verifyCommandFor unwraps inline-code backticks from a verify value', () => { + // `:: verify: \`pnpm test\`` must run `pnpm test`, not a command substitution + // whose stdout ("Test run for …") is then executed as a command (exit 127). + assert.equal(verifyCommandFor({ verify: '`pnpm test`' }, {}), 'pnpm test') + assert.equal(verifyCommandFor({}, { verify: '`node --test`' }), 'node --test') + // An inner pair of backticks is legitimate shell syntax: leave it alone. + assert.equal(verifyCommandFor({ verify: 'echo "`date`"' }, {}), 'echo "`date`"') +}) + test('runVerify reports success and captures output', async () => { const res = await runVerify({ command: 'echo hello-verify', cwd: process.cwd() }) assert.equal(res.ok, true)