Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
26 changes: 26 additions & 0 deletions scripts/goal-metric-review-test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
12 changes: 10 additions & 2 deletions src/state/goals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -295,7 +302,8 @@ export class GoalStore {
ORDER BY g.updated_at ASC`,
)
.all<GoalRow>(tenant)
.map(toGoal);
.map(toGoal)
.filter((g) => !g.metric || this.metricStatus(g.id, now)?.verdict === 'achieved');
}

/**
Expand Down
38 changes: 33 additions & 5 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down Expand Up @@ -10411,6 +10418,9 @@ function GoalsPage({ me, goalId, nav, backTo }: { me: Member; goalId: string; na
const [goals, setGoals] = useState<Goal[] | null>(null)
const [counts, setCounts] = useState<GoalCounts>({ draft: 0, active: 0, achieved: 0, abandoned: 0 })
const [progress, setProgress] = useState<Record<string, GoalProgress>>({})
/** 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<Record<string, GoalMetricStatus>>({})
const [q, setQ] = useState('')
const [fStatus, setFStatus] = useState<GoalStatus | ''>('') // '' = all
const [autoPlan, setAutoPlan] = useState(false) // scheduler auto-plans stuck goals (owner/admin toggle)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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('')
Expand Down Expand Up @@ -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' && (
<div className="rounded-md border bg-muted/30 p-3 text-[13px] text-muted-foreground">
All {detail.progress?.counted} filed task{detail.progress?.counted === 1 ? '' : 's'} are done, but{' '}
{detail.goal.metric.name} is at{' '}
<span className="font-medium text-foreground tabular-nums">
{detail.metricStatus?.latest ? detail.metricStatus.latest.value : '—'}
{detail.goal.metric.target != null ? ` of ${detail.goal.metric.target}` : ''}
</span>
{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.
</div>
)

/** 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) && (
<div className="space-y-2 rounded-md border border-emerald-500/40 bg-emerald-500/10 p-3">
<div className="flex items-start gap-2 text-[13px] text-emerald-800 dark:text-emerald-300">
<Check className="mt-0.5 h-4 w-4 shrink-0" />
Expand Down Expand Up @@ -10635,6 +10662,7 @@ function GoalsPage({ me, goalId, nav, backTo }: { me: Member; goalId: string; na
<div className="space-y-3.5">
{isAdmin && <GoalUpdateProposalsCard goalId={detail.goal.id} members={members} onResolved={() => { load(); refreshDetail(detail.goal.id) }} />}
{signOffBox()}
{workDoneNote()}
<Field label="Status">
{isAdmin ? (
<Select value={detail.goal.status} onValueChange={(v) => v && patch(detail.goal.id, { status: v as GoalStatus })}>
Expand Down Expand Up @@ -10879,7 +10907,7 @@ function GoalsPage({ me, goalId, nav, backTo }: { me: Member; goalId: string; na
const roomView = () => {
if (!detail) return null
const g = detail.goal
const ready = goalComplete(g, detail.progress)
const ready = goalComplete(g, detail.progress, detail.metricStatus)
const tabBtn = (id: GoalTab, label: ReactNode) => (
<a href={navHref('goals', id === 'tasks' ? g.id : `${g.id}/${id}`)} onClick={onNavClick(() => openGoalTab(g.id, id))} className={`-mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-[13px] font-medium no-underline transition-colors ${roomTab === id ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}`}>{label}</a>
)
Expand Down Expand Up @@ -10949,7 +10977,7 @@ function GoalsPage({ me, goalId, nav, backTo }: { me: Member; goalId: string; na
const row = (g: Goal) => {
const dm = dueMeta(g.dueAt, g.status === 'achieved' || g.status === 'abandoned' ? 'done' : 'todo')
const p = progress[g.id]
const done = goalComplete(g, p)
const done = goalComplete(g, p, metrics[g.id])
// An active goal nobody has filed work under isn't "0%" — it's unplanned. Say so, so the two very
// different empty states (nothing planned vs. planned-and-finished) don't both read as a bare dash.
const unplanned = g.status === 'active' && (!p || p.total === 0)
Expand Down
Loading