Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
88b6a1d
feat(goal2): keep a checklist of the user's requests
klNuno Oct 18, 2026
96649f1
Merge remote-tracking branch 'refs/remotes/origin/main' into boite/go…
klNuno Oct 18, 2026
d9d5453
Merge remote-tracking branch 'refs/remotes/origin/main' into boite/go…
klNuno Oct 18, 2026
e9170ce
fix(goal2): queue fake checklist passes and keep the model row for th…
klNuno Oct 18, 2026
8f11e77
feat(goal2): show the checklist in the Tasks panel, one line in the g…
klNuno Oct 18, 2026
4c3db05
Merge remote-tracking branch 'refs/remotes/origin/main' into boite/go…
klNuno Oct 18, 2026
cc679a7
Merge remote-tracking branch 'refs/remotes/origin/main' into boite/go…
klNuno Oct 18, 2026
ab40bdb
feat(goal2): list reviews in the Tasks panel beside the checklist
klNuno Oct 18, 2026
1723a7c
Merge remote-tracking branch 'refs/remotes/origin/main' into boite/go…
klNuno Oct 18, 2026
c2bc8ae
Merge remote-tracking branch 'origin/main' into boite/goal2-tracker
klNuno Oct 19, 2026
6b5f749
Merge remote-tracking branch 'origin/main' into boite/goal2-tracker
klNuno Oct 19, 2026
b3ef0d6
Merge remote-tracking branch 'origin/main' into boite/goal2-tracker
klNuno Oct 19, 2026
4456ff9
Merge remote-tracking branch 'origin/main' into boite/goal2-tracker
klNuno Oct 19, 2026
170659a
Merge remote-tracking branch 'origin/main' into boite/goal2-tracker
klNuno Oct 19, 2026
c15f872
fix(goal2): show the checklist section while the first list is writte…
klNuno Oct 19, 2026
b8c922a
feat(goal2): show the checklist under its goal, dropped items folded
klNuno Oct 19, 2026
ea4c249
style(goal2): the panel's checklist progress in the goal bar's accent
klNuno Oct 19, 2026
1628a01
Merge branch 'main' into boite/goal2-tracker
klNuno Oct 19, 2026
d305b1d
Merge remote-tracking branch 'origin/main' into boite/goal2-tracker
klNuno Oct 19, 2026
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
3 changes: 2 additions & 1 deletion docs/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,8 @@ cancel it. A goal continues through scheduled turns until the agent emits
`[BOITE_GOAL_COMPLETE]` on its own line. The prompt requests that marker only
after verification. `[BOITE_GOAL_BLOCKED]`, an error or Escape pauses it.
With the Reviewed goals experiment, `/goal2` turns that marker into a claim
that a fresh reviewer conversation must pass ([experiments](experiments.md#reviewed-goals)).
that a fresh reviewer conversation must pass, and keeps a checklist of the
user's requests that each turn hears ([experiments](experiments.md#the-checklist)).
A blocked goal is marked `blocked`: the bar asks for an answer, and the user's
next message, other than a native `/command`, resumes the goal once that reply's
turn ends. Escape also pauses a loop between runs. The activity bar has pause, resume,
Expand Down
83 changes: 79 additions & 4 deletions docs/experiments.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,16 +118,84 @@ Reviewing during a review, and draws one dot per verdict plus a pulsing dot for
the running review. A dot opens its reviewer conversation. The details list
each verdict with its findings.

Before a reviewer or a tracker conversation starts on a model its agent has
not listed since the last account change, the core reads that agent's models
on the account the child will use, as `boite delegate spawn` does, once per
review; the start then goes through the scheduler, so a goal replaced or paused
meanwhile is not reviewed on the old read. An account update forgets Claude's list, and a
review that started without reading it again paused the goal with "the agent
has not listed this model".

Reviewers are not subagents. Like workflow steps, they need no enabled team, a
paused team holds them, and their answer goes to the goal, never to the parent
as mail. They are not listed in the Subagents tab. Pausing, removing or
completing the goal by hand stops the running reviewer, and resuming a goal
paused during its review starts a new review. A core restart keeps the
verdicts and drops the interrupted review; the resume starts a fresh one.

### The checklist

A reviewed goal also keeps the checklist of what the user asked for, so the
agent works on the user's requests rather than on its own plan
(`goal-tracker.ts`, `goal-tracker-prompt.ts`). A tracker, one more child
conversation of the thread with none of its history, runs short passes, one at
a time, in the order they were asked for:

1. When the goal starts, it writes the list from the objective: one item per
distinct request, in the user's language, at most 12 words, a verb and a
visible result, with the user's own words it came from. Corrections ("or
rather", "but", `mot*`) keep their final meaning.
2. Each later message of the user, a new prompt or a steer, adds, drops or
changes items. The tracker answers with additions, cancellations and
changes; the core applies them, so ids never move and a dropped item stays,
struck through.
3. After each finished turn of the conversation, the agent's or the user's, it
reads a digest of that turn (one line per tool call, the failed ones marked,
and the agent's last answer) and marks the open items the turn did. An
item the turn never touched stays open, whatever the answer claims. The
goal's next turn or review waits for that check.

The next goal turn hears what is still open, as JSON data, in place of the
usual line about the agent's own task list. A completion claim with an open
item goes back to the agent before any review runs, and says so; each item
holds a claim back twice at most, then the review decides alone with the whole
list, statuses included, in its prompt.

The tracker runs on `Settings.goalTracker`, picked under the switch in
Settings, Experiments: a provider, a model and its reasoning level, stored by
the core of that machine. Automatic (`null`) takes the newest Opus at medium on
Claude or the newest Sol at high on Codex (`GOAL_TRACKER_DEFAULTS`), the
conversation's own agent first, else the conversation's own model. A small
model is never chosen by itself: on nine real prompts of 900 to 4000
characters (2026-10-18), Haiku 5.5 at xhigh dropped or inverted about two
requests per long prompt, the same ones on every run, where Opus 5.5 at medium
dropped none. On Claude the tracker starts without the user's guides, servers
and hooks and keeps only `Read`; on every agent its rules tell it to answer
from the text alone.

`ThreadActivity.goal.checklist` holds the tracker conversation, its model, the
phase (`writing`, `checking` or `idle`), the items with their status (`open`,
`done`, `cancelled`), whether a later message `added` or `changed` them, how
often each `bounced` a claim, and the last pass's `error`. The goal bar keeps
one line: the first open item and `done/total`, dropped items left out. On the
owner's app that line opens the side panel's Tasks tab. There the checklist
sits under the goal it breaks down, in the same section: a progress bar with
`done/total`, then the items in the order asked, done ones ticked and struck,
the next open one marked. Dropped items wait folded under a "1 dropped" line,
and an icon beside it opens the tracker conversation. The Reviews section lists
each review, newest first, with what it found. A paired device, which has no
Tasks tab, unfolds the same list in the bar instead. A press on an item shows
its source. An item that bounced twice says the final review decides it. The
screen leaves out the `added` and `changed` flags. A pass
that fails, answers without its JSON block or runs past five minutes leaves the
list as it was with the reason, and the goal goes on; a first list is tried
three times at most. A core restart keeps the list and the tracker; work no
pass had read yet is lost.

This version does not run checks itself before the review: the reviewer runs
them and spends tokens doing so. It has no token budget and no detection of
the same finding repeated: the five-review stop bounds a goal instead.
the same finding repeated: the five-review stop bounds a goal instead. The
tracker adds one pass per user message and per finished turn.

## Edge scrollbars

Expand Down Expand Up @@ -204,9 +272,16 @@ No live provider login is required.

`goal-review.test.ts` drives reviewed goals through the real core with the
echo driver: a failed review then a pass, the five-review stop and the reply
that allows one more, pause and resume during a review, and a restart. UI unit
tests cover the `/goal2` switch, the goal bar's verdicts and the fake core's
reviewer.
that allows one more, pause and resume during a review, and a restart.
`goal-tracker.test.ts` drives the checklist the same way: the first list, the
checks and what the next turn hears, a claim sent back twice then reviewed,
the user's additions and cancellations, the model Settings names, a tracker
answer without JSON, a tracker stuck on a pass, a goal finished by hand during
a pass, models read again after an account change, and a restart. The opt-in
`goal-tracker.live.test.ts` (`BOITE_E2E_CLAUDE=1`) runs one real goal on Haiku
with the checklist on Automatic. UI unit tests cover the `/goal2` switch,
the checklist model row, the goal bar's verdicts and checklist, and the fake
core's reviewer and tracker.

`whip.test.ts` covers the toggle, persistence, desktop and phone layout, drop and rethrow, whole
interface movement, rope drawing and release, cancellation and reduced motion. `shell.test.ts` verifies
Expand Down
45 changes: 45 additions & 0 deletions packages/contracts/src/goal-tracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { ModelInfo, Protocol } from './index.ts';
import { newestModel } from './title-models.ts';

/**
* The models a `/goal2` checklist is written and checked with when Settings
* names none: the newest Opus at medium on Claude, else the newest Sol at high
* on Codex. The conversation's own agent goes first when it is one of them;
* with neither signed in, the conversation's own model keeps the list. A small
* model is never picked by itself: on long prompts it dropped requests and
* turned some into their opposite.
*/
export interface GoalTrackerRule {
protocol: Protocol;
/** Matched against model ids; the newest listed one wins. */
family: RegExp;
effort: string;
}

export const GOAL_TRACKER_DEFAULTS: readonly GoalTrackerRule[] = [
{ protocol: 'claude-sdk', family: /^claude-opus-\d/i, effort: 'medium' },
{ protocol: 'codex-appserver', family: /^gpt-[\d.]+-sol$/i, effort: 'high' },
];

/** The default rule's model and effort among one agent's models, or null when it lists none of them. */
export function defaultTrackerModel(protocol: Protocol, models: readonly Pick<ModelInfo, 'id'>[]): { model: string; effort: string } | null {
for (const rule of GOAL_TRACKER_DEFAULTS) {
if (rule.protocol !== protocol) continue;
const model = newestModel(models, rule.family);
if (model !== null) return { model, effort: rule.effort };
}
return null;
}

/** The effort a picked model starts on: the default rule's for its family, else the model's own default. */
export function trackerEffort(model: Pick<ModelInfo, 'id' | 'effort'>): string | null {
const levels = model.effort?.levels.map((level) => level.id) ?? [];
const rule = GOAL_TRACKER_DEFAULTS.find((entry) => entry.family.test(model.id));
if (rule && levels.includes(rule.effort)) return rule.effort;
return model.effort?.default ?? null;
}

/** Items a checklist keeps; a longer list is cut, its last items dropped. */
export const GOAL_CHECKLIST_MAX_ITEMS = 80;
export const GOAL_CHECKLIST_TEXT_MAX = 160;
export const GOAL_CHECKLIST_SOURCE_MAX = 300;
3 changes: 2 additions & 1 deletion packages/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,8 @@ export type RpcEventName = keyof RpcEvents;

export { attachmentError, answerAttachmentError } from './attachment-validation.ts';
export { AUTO_COMPACT_MOMENTS, AUTO_COMPACT_TOKENS, BROWSER_ORIGINS_MAX, CACHE_KEEPALIVE_HOURS, MODEL_FAVORITES_MAX, checkSettingsPatch, compareModelFavorites, keepLaterFavorites, type AutoCompact, type AutoCompactMoment, type CacheKeepalive, type SettingsPatchCheck } from './settings-validation.ts';
export { TITLE_MODEL_DEFAULTS, defaultTitleModel } from './title-models.ts';
export { TITLE_MODEL_DEFAULTS, defaultTitleModel, newestModel } from './title-models.ts';
export { GOAL_TRACKER_DEFAULTS, GOAL_CHECKLIST_MAX_ITEMS, GOAL_CHECKLIST_SOURCE_MAX, GOAL_CHECKLIST_TEXT_MAX, defaultTrackerModel, trackerEffort, type GoalTrackerRule } from './goal-tracker.ts';
export { DEVICE_METHODS, DEVICE_EVENTS, AGENT_EVENTS } from './access.ts';
export {
APP_LANGUAGES, CLIENT_APP_PATTERN, CLIENT_LABELS_MAX, CLIENT_LABEL_CHARS, EXPERIMENT_IDS, REQUESTABLE_SETTINGS, REQUESTABLE_SETTING_KEYS, SETTING_REASON_MAX, SETTINGS_PAGES, SETTINGS_SECTION_PATTERN,
Expand Down
11 changes: 10 additions & 1 deletion packages/contracts/src/settings-validation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ModelFavorite, ModelFavorites, Settings, TitleModel } from './index.ts';
import type { GoalTrackerModel, ModelFavorite, ModelFavorites, Settings, TitleModel } from './index.ts';
import { BROWSER_PROFILE_NAME_MAX, BROWSER_PROFILES_MAX, browserProfileIdError, DEFAULT_BROWSER_PROFILE, PRIVATE_BROWSER_PROFILE, type BrowserProfile } from './browser';

// Both supported hosts expose URL; contracts otherwise need no DOM or Node types.
Expand Down Expand Up @@ -274,6 +274,15 @@ export function checkSettingsPatch(patch: Partial<Settings>): SettingsPatchCheck
}
next.titleModel = { providerId: text(providerId), model: text(model) };
}
if (patch.goalTracker !== undefined && patch.goalTracker !== null) {
const { providerId, model, effort } = (typeof patch.goalTracker === 'object' ? patch.goalTracker : {}) as Partial<GoalTrackerModel>;
const text = (value: unknown) => (typeof value === 'string' ? value.trim() : '');
const level = effort === null || effort === undefined ? null : text(effort);
if (text(providerId).length === 0 || text(model).length === 0 || text(model).length > TITLE_MODEL_MAX || level === '' || (level?.length ?? 0) > 40) {
return { ok: false, field: 'goalTracker', message: `goalTracker must be null or { providerId, model, effort }: two non-empty strings, the model at most ${TITLE_MODEL_MAX} characters, and the effort a level name or null` };
}
next.goalTracker = { providerId: text(providerId), model: text(model), effort: level };
}
if (patch.autoCompact !== undefined && patch.autoCompact !== null) {
const { tokens, moments } = (typeof patch.autoCompact === 'object' ? patch.autoCompact : {}) as Partial<AutoCompact>;
const TOKENS = AUTO_COMPACT_TOKENS, MOMENTS: readonly AutoCompactMoment[] = AUTO_COMPACT_MOMENTS;
Expand Down
13 changes: 13 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ export interface Settings {
* longer write one falls back to that too.
*/
titleModel?: TitleModel | null;
/**
* The model that writes and checks every `/goal2` checklist. Null or
* missing: `GOAL_TRACKER_DEFAULTS`, else the conversation's own model.
*/
goalTracker?: GoalTrackerModel | null;
/**
* When the core compacts a conversation by itself, between two turns. Null
* or missing: never, each agent keeps its own behaviour. Missing on older cores.
Expand Down Expand Up @@ -174,6 +179,14 @@ export interface TitleModel {
model: string;
}

/** One provider's model and reasoning level, picked in Settings, Experiments to keep `/goal2` checklists. */
export interface GoalTrackerModel {
providerId: ProviderId;
model: string;
/** One of the model's levels; null for a model without them. */
effort: string | null;
}

export interface SettingsRpcMethods {
/**
* A settings card on the agent's thread (`boite settings request|open`),
Expand Down
46 changes: 45 additions & 1 deletion packages/contracts/src/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,20 +378,64 @@ export interface GoalReview {
verdicts: GoalVerdict[];
}

/** One thing the user asked for in a `/goal2`, as its tracker wrote it. */
export interface GoalChecklistItem {
/** From 1, in the order the items were written; never reused. */
id: number;
/** A verb and a visible result in the user's language, at most `GOAL_CHECKLIST_TEXT_MAX` characters. */
text: string;
/** The words of the user's message it comes from, at most `GOAL_CHECKLIST_SOURCE_MAX` characters. */
source: string;
/** `done`: a check saw it done. `cancelled`: a later message of the user dropped it. */
status: 'open' | 'done' | 'cancelled';
/** Added by a message after the objective. */
added?: boolean;
/** Changed by a message after the objective. */
changed?: boolean;
/** Completion claims sent back to the agent because this item was still open; at 2 it no longer holds one. */
bounced?: number;
}

/**
* A `/goal2`'s checklist. A tracker conversation, a child of the goal's
* thread with none of its history, writes it from the objective, updates it
* on every later message of the user, and after each turn of the agent marks
* what the turn did. What stays open goes to the agent's next goal turn; a
* completion claim with an open item goes back to the agent before any review.
* The tracker reads only what Boite hands it: it runs nothing and edits nothing.
*/
export interface GoalChecklist {
/** The tracker conversation; null before it starts or when it could not. */
threadId: ThreadId | null;
/** `writing`: reading the objective or a new message. `checking`: reading the agent's last turn. */
phase: 'idle' | 'writing' | 'checking';
items: GoalChecklistItem[];
/** The tracker's provider, model and effort, as `claude/claude-opus-5-5`, for the panel. */
model: string | null;
effort: string | null;
/** Why the last pass gave nothing; the goal goes on without it. */
error: string | null;
}

/** What an automated goal or loop turn carries on its user message, for the driver prompt. */
export interface ActivityTurn {
kind: 'goal' | 'loop';
iteration: number;
/** A reviewed goal's first turn after a failed review: what the reviewer reported. */
review?: { round: number; feedback: string };
/**
* A `/goal2` turn after its tracker's check: the checklist items still open.
* `bounced`: the agent claimed completion with them open, so no review ran.
*/
checklist?: { open: string[]; bounced: boolean };
}

export interface ThreadActivity {
/**
* `blocked`: the agent ended a goal turn with `[BOITE_GOAL_BLOCKED]` and
* waits for the user. The goal is paused; the user's next message resumes it.
*/
goal: { objective: string; status: 'active' | 'paused' | 'complete'; iterations: number; error: string | null; dismissed?: boolean; blocked?: boolean; review?: GoalReview } | null;
goal: { objective: string; status: 'active' | 'paused' | 'complete'; iterations: number; error: string | null; dismissed?: boolean; blocked?: boolean; review?: GoalReview; checklist?: GoalChecklist } | null;
loop: { prompt: string; intervalMs: number; maxIterations?: number | null; status: 'active' | 'paused' | 'complete'; iterations: number; nextRunAt: number | null; error: string | null; history?: ActivityIteration[] } | null;
tasks: AgentTask[];
tasksDismissed?: boolean;
Expand Down
12 changes: 9 additions & 3 deletions packages/contracts/src/title-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,15 @@ export function defaultTitleModel(
const rule = TITLE_MODEL_DEFAULTS[provider.id] ?? TITLE_MODEL_DEFAULTS[BY_PROTOCOL[provider.protocol] ?? ''];
if (rule === undefined) return null;
for (const family of rule.families) {
const listed = models.filter((model) => family.test(model.id) && !model.id.includes('['));
if (listed.length === 0) continue;
return listed.reduce((best, model) => (newer(version(model.id), version(best.id)) > 0 ? model : best)).id;
const newest = newestModel(models, family);
if (newest !== null) return newest;
}
return rule.fallback;
}

/** The newest listed model of a family, a variant such as `[1m]` left out; null when none is listed. */
export function newestModel(models: readonly Pick<ModelInfo, 'id'>[], family: RegExp): string | null {
const listed = models.filter((model) => family.test(model.id) && !model.id.includes('['));
if (listed.length === 0) return null;
return listed.reduce((best, model) => (newer(version(model.id), version(best.id)) > 0 ? model : best)).id;
}
Loading
Loading