From 0481ed551c211be06b02c420b1c08d56155627d8 Mon Sep 17 00:00:00 2001 From: Vikas Singhal Date: Tue, 6 Oct 2026 20:05:39 +0530 Subject: [PATCH] fix(goals): "ready to close" asks the number, not the task list (v0.456.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A goal was proposed for closing — and its owner carded that it was finished — as soon as every filed task was done, with no reference to its metric. On the instapods clicks goal that put a green "Ready to close" pill beside a metric panel reading "59 of 100 · Not moving", and an Inbox card saying the goal was finished while it was 41 clicks short. Task completion is the weakest evidence a measured goal has, so it no longer gets a vote: readyToClose (and the scheduler sweep that reads it) excludes a goal with a metric unless its verdict is `achieved`; the console derives the same way. A goal with no metric is unchanged. The quieter fact is still said, because it is worth knowing: a measured goal whose filed work ran out gets a muted line that the plan ran out before the number did — close it only if the outcome is good enough, or plan the gap. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 +++ package-lock.json | 4 +-- package.json | 2 +- scripts/goal-metric-review-test.cjs | 26 ++++++++++++++++++++ src/state/goals.ts | 12 +++++++-- web/src/App.tsx | 38 +++++++++++++++++++++++++---- 6 files changed, 76 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23ab9560..e3d43535 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ new version heading in the same commit. ## [Unreleased] +## [0.456.1] - 2026-10-06 +### Fixed +- **"Ready to close" asked the task list instead of the number.** A goal was proposed for closing — and its owner carded that it was finished — as soon as every task filed under it was done, with no reference to its metric. On the instapods clicks goal that put a green "Ready to close" pill directly beside a metric panel reading "59 of 100 · Not moving": the same page contradicting itself, and an Inbox card saying the goal was finished when it was 41 clicks short. Task completion is the weakest evidence a measured goal has, so it no longer gets a vote: `GoalStore.readyToClose` (and therefore the scheduler's completion sweep) now excludes a goal with a metric unless its verdict is `achieved`, and the console derives the same way. A goal with NO metric is unchanged. The quieter fact is still said — a measured goal whose filed work ran out gets a muted "all filed tasks are done, but the number is at X of Y — close it only if the outcome is good enough, or plan the gap", because nobody having planned the rest is worth knowing; it just is not a close. Pinned by `scripts/goal-metric-review-test.cjs`. + ## [0.456.0] - 2026-10-06 ### Added - **The goal's number, with the bets that ran under it.** The Bets tab opens with a chart of the goal's readings against its target, and a rail beneath it on the same x scale showing each bet as the WINDOW it occupied — a bet is a period, not a moment, so the question "which attempt was live while the number did that?" is answerable at a glance. Crosshair snaps to the nearest reading; bar colour carries state and the wrapped key underneath carries identity (never colour alone), which is also what keeps a label from being clipped by the right edge. Deliberately no trend line and no projection: inventing a line nobody measured is the same sin as letting an agent grade its own bet. diff --git a/package-lock.json b/package-lock.json index 94e80c61..c1a2b45e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.456.0", + "version": "0.456.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.456.0", + "version": "0.456.1", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 3b522eeb..0691000c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.456.0", + "version": "0.456.1", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", diff --git a/scripts/goal-metric-review-test.cjs b/scripts/goal-metric-review-test.cjs index bba04ab5..c80cf59c 100755 --- a/scripts/goal-metric-review-test.cjs +++ b/scripts/goal-metric-review-test.cjs @@ -126,6 +126,32 @@ const DAY = 86_400_000; assert(aos.goals.events(up.id).some((e) => /measured 18000/.test(e.body || '')), 'a reading also lands on the goal timeline, so a measured goal is never reported "stuck"'); server.close(); + console.log('\n\x1b[1mX) "Ready to close" asks the NUMBER, not the task list\x1b[0m'); + // Live (instapods clicks goal): every filed task was done, so the room offered "Ready to close" and the + // sweep carded the owner that the goal was finished — beside a metric panel reading 59 of 100, "Not + // moving". Task completion is the weakest evidence a measured goal has; it does not get a vote. + const mkTask = (goalId, status) => { + const t = aos.tasks.create({ tenant: aos.tenant, title: `work for ${goalId}`, goalId, createdBy: owner.id, owner: owner.id }); + if (status !== 'todo') aos.tasks.update(t.id, { status, by: owner.id }); + return t; + }; + const unmeasuredGoal = mkGoal('Ship the thing'); // no metric at all + mkTask(unmeasuredGoal.id, 'done'); + const measuredGoal = mkGoal('Clicks to 100', { name: 'clicks', unit: '/day', target: 100, baseline: 50, direction: 'up', everyDays: 1 }); + feed(measuredGoal, [[6, 57], [4, 55], [2, 56], [0, 59]]); // nowhere near target + mkTask(measuredGoal.id, 'done'); + const reachedGoal = mkGoal('Latency under 200', { name: 'p95', unit: 'ms', target: 200, baseline: 400, direction: 'down', everyDays: 1 }); + feed(reachedGoal, [[6, 380], [4, 300], [2, 240], [0, 180]]); // target reached (down is better) + mkTask(reachedGoal.id, 'done'); + + const ready = aos.goals.readyToClose(aos.tenant, NOW).map((g) => g.id); + assert(ready.includes(unmeasuredGoal.id), 'a goal with NO metric still closes on its tasks — unchanged', ready); + assert(!ready.includes(measuredGoal.id), 'a measured goal whose number is nowhere near target is NOT offered', { ready, verdict: aos.goals.metricStatus(measuredGoal.id, NOW).verdict }); + assert(ready.includes(reachedGoal.id), 'a measured goal that reached its target IS offered', ready); + // And the sweep that cards the owner reads the same list, so the card stops too. + const before = aos.db.prepare("SELECT COUNT(*) AS n FROM messages WHERE session_id = ?").get(`system:goal-ready-${measuredGoal.id}`); + assert(!before || before.n === 0, 'no "goal is finished" card for the measured goal', before); + registry.stopAll(); fs.rmSync(HOME, { recursive: true, force: true }); console.log(`\n${fail ? '\x1b[31m' : '\x1b[32m'}${pass} passed, ${fail} failed\x1b[0m\n`); diff --git a/src/state/goals.ts b/src/state/goals.ts index 4184e5bd..adc8c5c4 100644 --- a/src/state/goals.ts +++ b/src/state/goals.ts @@ -283,8 +283,15 @@ export class GoalStore { * does NOT self-close, because "all the filed tasks are done" is not the same claim as "the outcome was * achieved" — the plan may simply have been incomplete. So this is a proposal set: the console flags it * and the owner confirms (or plans the gap). Oldest-idle first. + * + * ⚠ A MEASURED goal is excluded unless its number says `achieved`. Task completion is the weakest + * evidence a measured goal has, and offering to close one on it reads as the product contradicting + * itself: live on the instapods clicks goal, "Ready to close" sat beside a metric panel reading + * "59 of 100 · Not moving", and the same rule had already carded the owner that the goal was finished. + * A goal that bothered to say what number it is about is judged on that number — which is the whole + * reason `metric` exists (see {@link metricStatus}). */ - readyToClose(tenant: string): Goal[] { + readyToClose(tenant: string, now = Date.now()): Goal[] { return this.db .prepare( `SELECT * FROM goals g WHERE g.tenant = ? AND g.status = 'active' @@ -295,7 +302,8 @@ export class GoalStore { ORDER BY g.updated_at ASC`, ) .all(tenant) - .map(toGoal); + .map(toGoal) + .filter((g) => !g.metric || this.metricStatus(g.id, now)?.verdict === 'achieved'); } /** diff --git a/web/src/App.tsx b/web/src/App.tsx index d2077c55..ed6def46 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -9635,8 +9635,15 @@ function GoalStatusPill({ status, ready }: { status: GoalStatus; ready?: boolean // status, never stored. It stays `active` until a human signs it off: "all the filed work is done" is a // weaker claim than "the outcome was achieved" (the plan may simply have been incomplete), and goal state // is human-owned. So the UI proposes the close; the owner confirms it or plans the gap. -const goalComplete = (g: Goal, p?: GoalProgress): boolean => +// +// ⚠ On a MEASURED goal the tasks do not get a vote: the number does. Task completion is the weakest +// evidence such a goal has, and proposing a close on it put "Ready to close" directly beside a metric +// panel reading "59 of 100 · Not moving" (live, instapods clicks goal). `taskWorkDone` is kept for the +// quieter "the filed work ran out" line, which is still worth saying — it is just not a close. +const taskWorkDone = (g: Goal, p?: GoalProgress): boolean => g.status === 'active' && !!p && p.counted > 0 && p.done === p.counted +const goalComplete = (g: Goal, p?: GoalProgress, st?: GoalMetricStatus | null): boolean => + taskWorkDone(g, p) && (!g.metric || st?.verdict === 'achieved') // Derived-progress meter for a goal (share of its linked tasks that are done). A thin emerald bar + // a "done/total tasks" caption; renders nothing when the goal has no linked tasks. function GoalProgressBar({ p, className = '' }: { p?: GoalProgress; className?: string }) { @@ -10411,6 +10418,9 @@ function GoalsPage({ me, goalId, nav, backTo }: { me: Member; goalId: string; na const [goals, setGoals] = useState(null) const [counts, setCounts] = useState({ draft: 0, active: 0, achieved: 0, abandoned: 0 }) const [progress, setProgress] = useState>({}) + /** Per-goal metric verdicts, already on the list payload — the close proposal is derived from these, + * never from task progress alone (see `goalComplete`). */ + const [metrics, setMetrics] = useState>({}) const [q, setQ] = useState('') const [fStatus, setFStatus] = useState('') // '' = all const [autoPlan, setAutoPlan] = useState(false) // scheduler auto-plans stuck goals (owner/admin toggle) @@ -10472,6 +10482,7 @@ function GoalsPage({ me, goalId, nav, backTo }: { me: Member; goalId: string; na setGoals(r.goals ?? []) if (r.counts) setCounts(r.counts) setProgress(r.progress ?? {}) + setMetrics(r.metrics ?? {}) if (typeof r.autoPlan === 'boolean') setAutoPlan(r.autoPlan) } // Owner/admin toggle: let the scheduler auto-draft plans for stuck goals. Optimistic — revert on error. @@ -10499,7 +10510,7 @@ function GoalsPage({ me, goalId, nav, backTo }: { me: Member; goalId: string; na const visible = goals ?? [] // Goals finished in fact but still open — the banner's subject. Derived from the list in view (the // default "All" filter shows every one; a narrowed filter scopes the call-to-action to what you're on). - const ready = visible.filter((g) => goalComplete(g, progress[g.id])) + const ready = visible.filter((g) => goalComplete(g, progress[g.id], metrics[g.id])) const create = async () => { setHint('') @@ -10596,9 +10607,25 @@ function GoalsPage({ me, goalId, nav, backTo }: { me: Member; goalId: string; na // sign-off decision. So the detail is a full-page room (the same shape as the task room): the work as // the main column, the goal's own state as a sidebar. No modal, and the tab lives in the URL. + /** The quieter half of the same fact: on a MEASURED goal whose number is not there yet, the filed work + * running out is worth saying — it means nobody has planned the rest — but it is not a close. */ + const workDoneNote = () => detail && detail.goal.metric && taskWorkDone(detail.goal, detail.progress) + && detail.metricStatus?.verdict !== 'achieved' && ( +
+ All {detail.progress?.counted} filed task{detail.progress?.counted === 1 ? '' : 's'} are done, but{' '} + {detail.goal.metric.name} is at{' '} + + {detail.metricStatus?.latest ? detail.metricStatus.latest.value : '—'} + {detail.goal.metric.target != null ? ` of ${detail.goal.metric.target}` : ''} + + {detail.goal.metric.unit ? ` ${detail.goal.metric.unit}` : ''}. The plan ran out before the number did — + close it only if you have decided the outcome is good enough, or plan the gap. +
+ ) + /** Sign-off box: the goal's work is all done, so put the decision in front of the person rather than * leaving them to find `achieved` in the status dropdown. Rendered at the top of the sidebar. */ - const signOffBox = () => detail && isAdmin && goalComplete(detail.goal, detail.progress) && ( + const signOffBox = () => detail && isAdmin && goalComplete(detail.goal, detail.progress, detail.metricStatus) && (
@@ -10635,6 +10662,7 @@ function GoalsPage({ me, goalId, nav, backTo }: { me: Member; goalId: string; na
{isAdmin && { load(); refreshDetail(detail.goal.id) }} />} {signOffBox()} + {workDoneNote()} {isAdmin ? (