visual fixes
This commit is contained in:
parent
e1239f3d93
commit
d7c22ca452
|
|
@ -157,6 +157,11 @@ toggles a `topbar--<state>` 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`.
|
||||
|
|
|
|||
|
|
@ -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--<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::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 {
|
||||
|
|
|
|||
19
src/goal.mjs
19
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({
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
116
test/orchestrator-stages.test.mjs
Normal file
116
test/orchestrator-stages.test.mjs
Normal 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')
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue