visual fixes

This commit is contained in:
Vitali sharp8n 2026-09-14 01:20:42 +03:00
parent e1239f3d93
commit d7c22ca452
8 changed files with 215 additions and 17 deletions

View file

@ -157,6 +157,11 @@ toggles a `topbar--<state>` class):
(warning, not failure); (warning, not failure);
- **done** — a static green line; **idle** — plain header. - **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 All movement is disabled under `prefers-reduced-motion: reduce` (the state colour
stays). See `public/styles.css` (`topbar--*`) and `topbarState()` in stays). See `public/styles.css` (`topbar--*`) and `topbarState()` in
`public/app.js`. `public/app.js`.

View file

@ -44,7 +44,12 @@ body { display: flex; flex-direction: column; }
/* ---------- top-bar activity indicator (pure CSS) ---------- /* ---------- top-bar activity indicator (pure CSS) ----------
::after = dim track sitting on the bottom border ::after = dim track sitting on the bottom border
::before = the moving / solid segment that signals the state ::before = the moving / solid segment that signals the state
JS only toggles a `topbar--<state>` class. */ JS only toggles a `topbar--<state>` 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::before,
.topbar::after { .topbar::after {
content: ''; content: '';
@ -68,28 +73,28 @@ body { display: flex; flex-direction: column; }
rgba(88, 166, 255, 0.13) 66%, rgba(88, 166, 255, 0.13) 66%,
rgba(88, 166, 255, 0) 100%); rgba(88, 166, 255, 0) 100%);
background-size: 240% 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::after { opacity: 1; background: rgba(88, 166, 255, 0.22); }
.topbar--running::before { .topbar--running::before {
opacity: 1; 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%); 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); 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 */ /* waiting for operator input — pulsing purple */
.topbar--waiting { .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-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%; background-size: 220% 100%;
animation: topbar-sheen 2.8s linear infinite; animation: topbar-sheen 2.8s linear infinite; /* 2 × beat */
} }
.topbar--waiting::before { .topbar--waiting::before {
opacity: 1; opacity: 1;
width: 100%; width: 100%;
transform: none; transform: none;
background: linear-gradient(90deg, rgba(188, 140, 255, 0.15), var(--purple), rgba(188, 140, 255, 0.15)); 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) */ /* paused — static amber baseline (deliberately not animating) */
@ -111,7 +116,7 @@ body { display: flex; flex-direction: column; }
/* stopped / stale-ish warnings — light amber tint */ /* stopped / stale-ish warnings — light amber tint */
.topbar--warn { .topbar--warn {
background-image: linear-gradient(180deg, rgba(210, 153, 34, 0.13), rgba(210, 153, 34, 0.04) 72%, transparent); 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 { .topbar--warn::before {
opacity: 1; opacity: 1;
@ -124,7 +129,7 @@ body { display: flex; flex-direction: column; }
/* error / done-with-errors / stale — light red tint + pulsing red line */ /* error / done-with-errors / stale — light red tint + pulsing red line */
.topbar--error { .topbar--error {
background-image: linear-gradient(180deg, rgba(248, 81, 73, 0.17), rgba(248, 81, 73, 0.05) 74%, transparent); 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 { .topbar--error::before {
opacity: 1; opacity: 1;
@ -132,7 +137,7 @@ body { display: flex; flex-direction: column; }
transform: none; transform: none;
background: var(--red); background: var(--red);
box-shadow: 0 0 12px rgba(248, 81, 73, 0.8); 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 { @keyframes topbar-sweep {

View file

@ -48,6 +48,19 @@ function slug(text) {
.slice(0, 48) .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. * 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}`, title: s.title || s.name || `Stage ${i + 1}`,
details: s.details || s.description || '', details: s.details || s.description || '',
done: Boolean(s.done), done: Boolean(s.done),
verify: s.verify || s.verifyCommand || '', verify: unwrapCode(s.verify || s.verifyCommand || ''),
acceptance: s.acceptance || s.acceptanceCriteria || '', acceptance: unwrapCode(s.acceptance || s.acceptanceCriteria || ''),
model: s.model || '', model: s.model || '',
agent: s.agent || '', agent: s.agent || '',
variant: s.variant || '', variant: s.variant || '',
@ -100,7 +113,7 @@ export function loadGoal(config) {
const directives = {} const directives = {}
for (const part of directiveParts) { for (const part of directiveParts) {
const dm = part.match(/^\s*(verify|acceptance)\s*:\s*(.+?)\s*$/i) 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() const title = titlePart.replace(/[*_`]/g, '').trim()
stages.push({ stages.push({

View file

@ -576,14 +576,13 @@ export class Orchestrator {
rec.verify = verifyResult rec.verify = verifyResult
cls = { ...cls, verify: verifyResult } cls = { ...cls, verify: verifyResult }
if (verifyResult.ok) { if (verifyResult.ok) {
this.completedStages.add(stage?.id || cls.stageId) this._completeStage(goal, stage, cls.stageId)
stageAdvanced = true stageAdvanced = true
this.verifyFailures.delete(stage?.id || cls.stageId) this.verifyFailures.delete(stage?.id || cls.stageId)
if (stage) this._syncGoalStage(goal, stage)
if (config.review && stage && !this.reviewedStages.has(stage.id) && !config.dryRun) { if (config.review && stage && !this.reviewedStages.has(stage.id) && !config.dryRun) {
const review = await this._runReviewer(goal, stage, iteration) const review = await this._runReviewer(goal, stage, iteration)
if (review?.blocked) { if (review?.blocked) {
this.completedStages.delete(stage?.id || cls.stageId) this._reopenStage(goal, stage, cls.stageId)
stageAdvanced = false stageAdvanced = false
cls = { cls = {
...cls, ...cls,
@ -615,9 +614,8 @@ export class Orchestrator {
} }
} else { } else {
// No verify command: trust the agent, but it is not independently checked. // 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 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) { async _runReviewer(goal, stage, iteration) {
const { config, state } = this const { config, state } = this
const reviewPath = path.join(loopPaths(config.project).dir, 'review.json') const reviewPath = path.join(loopPaths(config.project).dir, 'review.json')

View file

@ -1,4 +1,5 @@
import { spawn } from 'node:child_process' import { spawn } from 'node:child_process'
import { unwrapCode } from './goal.mjs'
/** /**
* Independent verification for a stage. The loop runs the command itself and * Independent verification for a stage. The loop runs the command itself and
@ -13,7 +14,7 @@ const MAX_CAPTURE = 20000
* --verify / config.verify. * --verify / config.verify.
*/ */
export function verifyCommandFor(stage, config = {}) { export function verifyCommandFor(stage, config = {}) {
const cmd = (stage?.verify || config.verify || '').trim() const cmd = unwrapCode(stage?.verify || config.verify || '')
return cmd || null return cmd || null
} }

View file

@ -40,6 +40,26 @@ test('loadGoal parses json stages', () => {
assert.equal(goal.stages[0].id, 'a') 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', () => { test('loadGoal falls back to a single stage for inline text', () => {
const goal = loadGoal({ project: '/tmp', goal: '', goalText: 'Do the thing' }) const goal = loadGoal({ project: '/tmp', goal: '', goalText: 'Do the thing' })
assert.equal(goal.stages.length, 1) assert.equal(goal.stages.length, 1)

View file

@ -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')
})

View file

@ -9,6 +9,15 @@ test('verifyCommandFor prefers the stage command over the global default', () =>
assert.equal(verifyCommandFor({}, {}), null) 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 () => { test('runVerify reports success and captures output', async () => {
const res = await runVerify({ command: 'echo hello-verify', cwd: process.cwd() }) const res = await runVerify({ command: 'echo hello-verify', cwd: process.cwd() })
assert.equal(res.ok, true) assert.equal(res.ok, true)