Skip to content
Draft
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
1 change: 1 addition & 0 deletions .claude/skills/verify-berthd/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ The maintained list of what a person can do in the app, and how to prove each on
| Back and forward: the window strip's arrows, ⌘[ and ⌘], the mouse's buttons; archiving or removing the worktree in front goes back, not Home; the dashboard keeps its scroll | [navigation-history.md](navigation-history.md) | `navigation-history`; `app/e2e/back-forward.spec.ts` asserts it |
| Shortcuts in the keyboard's layout (⌘Å and ⌘¨ for Back and Forward on Norwegian) in tooltips, the Keyboard shortcuts sheet, Settings and ⌘K; the sheet's descriptions fit their lines | [navigation-history.md](navigation-history.md) (`history-layout`) | `keyboard-layout`, `keyboard-layout-us`; `app/e2e/back-forward.spec.ts` asserts it |
| Stopping agents from the Agent Dashboard: one, the picked ones, a column, Clean up; archiving their worktrees where it's safe | [dashboard-stop.md](dashboard-stop.md) | `dashboard-stop-archive`; `app/e2e/dashboard-stop-archive.spec.ts` asserts it |
| The board's columns and counts, and Needs you's sub-states: the preset, the editor, a card tagged not sorted, an agent's edit spreading to a second box, a real form moving when its answer arrives, a wait already open sorted when the set turns on | [dashboard-sub-states.md](dashboard-sub-states.md) | `dashboard-sub-states`; `dashboard-sub-states-live` and `dashboard-sub-states-open-waits` (real boxes, `--agent`); `app/e2e/dashboard-sub-states.spec.ts` asserts it |
| Plugins: built-in and your own, what they add (screens, sidebar items, widgets, panels) | [plugins.md](plugins.md) | `plugin-nav-badges` (sidebar items); `plugin-attach-live` (`api.attach`, a real box, `--agent`) |
| Home's widget grid | [home-widgets.md](home-widgets.md) | none yet; `app/e2e/home-widgets.spec.ts` asserts it |
| Questions in chat: an agent's form of questions as a card, answered on a real box; kept with Retry and Copy when it can't be | [chat-questions.md](chat-questions.md) | `chat-questions-live` (a real box, `--agent`); `app/e2e/chat.spec.ts` on the mock |
Expand Down
45 changes: 45 additions & 0 deletions .claude/skills/verify-berthd/features/dashboard-sub-states.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# The board's columns, and the sub-states of Needs you

The Agent Dashboard (`app/src/views/dashboard/dashboard-view.tsx`) has four columns: Needs you, Working, Done and Idle, each with a title, a count and an empty message, oldest in its state first. An optional set of sub-states (`internal/box/substates.go`, `app/src/lib/substates.ts`) replaces Needs you with one amber column each, in the set's order. `agent_state` stays `waiting`: only `agent_sub_state` says which. With no set, nothing changes anywhere.

## Sub-features

- `columns`: the four columns and their counts, with no set. The grid wraps on a narrow window.
- `preset`: Settings → Agents → **Needs you columns** → **Use Needs a decision and Needs you to act**. The board then has both, amber, with their own counts.
- `not-sorted`: a waiting agent with no sub-state, or one the set doesn't have, sits in the first column with a small **Not sorted** tag (`data-testid=not-sorted`). The board passes the tag to the card as a line of its own (`children`), so any card style that takes children shows it. `placeSession` decides, unit tested in `app/src/lib/substates.test.ts`.
- `editor`: add, rename, move up and down, remove, then Save. Nothing half made saves. At most 6. Removing all restores Needs you.
- `warnings`: an older box ("runs an older berthd"), a box with turn check off, and who changed what last.
- `spread`: an edit on one box reaches the others through the app (`lib/substates-sync.ts`), gated on the `substates` capability. A box keeps only a newer stamped set.
- `placing`: end of turn: the verdict's request as ever, then with a waiting verdict a second one for the sub-state, so descriptions never reach the verdict. A form: one request on its words, default until the answer, then `agent.substate`. A permission prompt: the default by rule, no request, no tag. Waits already open when the set or turn check changes, or that the box never tried before it started, are sorted then, one request each (`sortOpenWaits`, `TestWaitsOpenBeforeASetAreSortedWhenItIsTurnedOn`): a form by its words, any other wait by the agent's last message.
- `surfaces`: the field reads the same through `GET sessions`, `berthd sessions --json` and MCP's `berth_sessions` (`sub_state`). Edits work over `/v1/substates`, `berthd substates` and MCP's `berth_substates`, including from a box's own agent; `/v1/turncheck` stays closed to it.

## How to get to it (user POV)

- ⌘J opens the Agent Dashboard. Settings (the gear) → Agents, under Turn check, has **Needs you columns**.

## Driving it with drive.mjs

- Mock: `node .claude/skills/verify-berthd/scripts/drive.mjs dashboard-sub-states --port $PORT` checks the four columns, turns on the preset, checks both columns' counts and the tagged card from the older box (`gpu`), removes the set and checks the four come back. `app/e2e/dashboard-sub-states.spec.ts` asserts it and the editor.
- Real boxes, with a real Claude Code and the real provider:

```sh
export VERIFY_BOX_DIR=/tmp/vb-NNNN
.claude/skills/verify-berthd/scripts/sub-states-box.sh up # two boxes (devq, devr), turn check on devq, a structured Claude
eval "$(.claude/skills/verify-berthd/scripts/dev-box.sh env)"
node .claude/skills/verify-berthd/scripts/drive.mjs dashboard-sub-states-live --port $PORT --agent http://127.0.0.1:9378
.claude/skills/verify-berthd/scripts/sub-states-box.sh down
```

`dashboard-sub-states-open-waits` (after a fresh `up`, since it starts from no set) has the Claude ask for a key with no set, waits past the form window, turns on the preset in Settings, and checks the wait lands in Needs you to act, untagged, with one `agent.substate` and no second `agent.waiting`.

In `dashboard-sub-states-live`, an agent's edit over MCP shows in Settings with who made it and spreads to `devr`; a real turn that asks for a choice lands in `decision`; a real form reads `decision → act`, with one `agent.waiting` and one `agent.substate`; the field agrees on the API, the CLI and MCP; clearing the set restores four columns on both boxes. The gateway key comes from `VERIFY_TURNCHECK_KEY_FILE` (default the box's own `turncheck.key`) and goes over stdin, never a command line.
- The classifier: `go test ./internal/box -run TestSubStateReplays` replays recorded gateway answers (`testdata/turncheck/substate-*.json`). Re-record with `BERTH_TURNCHECK_RECORD_KEY_FILE=<key file>`, then grep the fixtures for the key.

## Gotchas

- Live runs need `labs=0` in the scenario's params. With Labs on, `nav-home` is New task, and Home's Pull requests widget shows the signed-in person's real repositories, so never put that screen in a public PR.
- Unset every `BERTH_*` before running these from inside an agent session, or the worker's `BERTH_DRIVER=structured` turns the dev box's hooks off.
- Claude Code's terminal reports a form as a `permission` wait with tool `AskUserQuestion`, then again about 6 seconds later as a notification with no tool. It's one wait: `sub.From` keeps the first Seq, and the repeat says where the wait is. The structured driver says `question` once.
- A sync that fails retries on its own every 15 seconds, and a hook's refusal (403) isn't retried until the set or the box's info changes.
- A form's real answer can come back under 0.65 and go to Not sorted: the live scenario's question is worded to be a clear task. That's a model's answer, not a bug.
- The mock's only capable box is `devl`, so its `checkout-fix` card is placed and `gpu`'s waiting card is tagged.
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// dashboard-sub-states-live: the sub-states of Needs you on real boxes, with
// a real Claude Code and the real turn check provider. Needs a dev box with
// turn check on and a second box paired (../sub-states-box.sh up). An agent's
// edit over MCP shows in Settings and spreads to the second box; a real form
// lands in the default and moves when the answer arrives; the field reads the
// same through the API, `berthd sessions --json` and MCP; removing the set
// brings the four columns back. See ../../features/dashboard-sub-states.md.
import { execFileSync } from "node:child_process";
import { globSync, readFileSync } from "node:fs";
import { join } from "node:path";

export const live = true;
export const params = { sidebar: "list", labs: "0" };

const dir = process.env.VERIFY_BOX_DIR ?? "/tmp/verify-berthd-box";
const root = execFileSync("git", ["rev-parse", "--show-toplevel"]).toString().trim();
const boxEnv = { ...process.env, BERTH_HOME: join(dir, "box-home"), BERTH_USER_DIR: join(dir, "box-user"), TMUX_TMPDIR: join(dir, "tmux") };
for (const k of ["BERTH_SESSION", "BERTH_DRIVER", "BERTH_LOCATION", "BERTH_WORKTREE_NAME"]) delete boxEnv[k];

// One MCP call, as an agent on the box makes it: `berthd mcp` on its socket.
function mcp(tool, args, caller) {
const lines = [
{ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18" } },
{ jsonrpc: "2.0", method: "notifications/initialized" },
{ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: tool, arguments: args } },
];
const out = execFileSync(join(root, "bin/berthd"), ["mcp"], { input: lines.map((l) => JSON.stringify(l)).join("\n") + "\n", env: { ...boxEnv, ...(caller ? { BERTH_SESSION: caller } : {}) } }).toString();
const answer = out.trim().split("\n").map((l) => JSON.parse(l)).find((m) => m.id === 2);
const text = answer.result.content[0].text;
if (answer.result.isError) throw new Error(`${tool}: ${text}`);
return JSON.parse(text);
}
const box = (...a) => JSON.parse(execFileSync(join(root, "bin/berthd"), a, { env: boxEnv }).toString());

const FORM = {
questions: [{ header: "API key", question: "I can't reach the acme mail provider's dashboard from here. Please open it, create a read-only API key, and store it by running scripts/set-key.sh in your terminal. Tell me when you've done it.", multiSelect: false, options: [{ label: "Done", description: "The key is stored." }, { label: "Stuck", description: "I couldn't do it." }] }],
};
const PROMPT = `Connect the acme shop to the acme mail provider. You can't reach its dashboard, so first have me make the key: use the AskUserQuestion tool exactly once with this input, before anything else: ${JSON.stringify(FORM)}. When it's answered, reply with one word and stop.`;

export async function run({ page, shot, step, expect, call }) {
const json = (path, init) => call(path, init);
const sub = (b) => json(`/v1/boxes/${b}/api/substates`);
const session = async () => (await json("/v1/boxes/devq/api/sessions")).find((s) => s.name === "ask");

await step("With no sub-states, the board has its four columns");
await page.getByTestId("nav-home").click();
const board = page.getByTestId("dashboard-board");
await expect(board.getByRole("region")).toHaveText([/Needs you/, /Working/, /Done/, /Idle/], { timeout: 30000 });
await shot("four-columns");
if ((await session()).agent_sub_state !== undefined) throw new Error("a field with no set");

await step("An agent on devq turns on the preset over MCP; nothing but that box was told");
const edited = mcp("berth_substates", { action: "preset" }, "ask");
if (edited.states.map((s) => s.id).join() !== "decision,act") throw new Error(JSON.stringify(edited));

await step("It spreads to devr through the app, with the same stamp");
await expect.poll(async () => (await sub("devr")).revision, { timeout: 30000 }).toBe(edited.revision);
const onDevr = await sub("devr");
if (onDevr.changed_by !== "ask") throw new Error(`devr says it was ${onDevr.changed_by}`);

await step("The board now has two amber columns in Needs you's place");
await expect(board.getByRole("region")).toHaveText([/Needs a decision/, /Needs you to act/, /Working/, /Done/, /Idle/], { timeout: 30000 });

await step("Settings shows who changed what");
await page.getByTestId("nav-settings").click();
await page.getByTestId("settings-nav-agents").click();
const panel = page.getByTestId("substates");
await expect(panel.getByRole("textbox", { name: "Name of column 1" })).toHaveValue("Needs a decision");
await expect(panel.getByTestId("substates-changed")).toContainText("turned on the preset, by ask");
await panel.scrollIntoViewIfNeeded();
await shot("settings-after-mcp-edit");
await page.getByRole("button", { name: "Back to app" }).click();

await step("A real turn that ends asking for a choice lands in Needs a decision");
await call("/v1/boxes/devq/api/sessions/ask/send", { method: "POST", body: JSON.stringify({ text: 'Without using any tool, end your turn with exactly this message: "I can do the acme cart refactor two ways. Option A keeps the old API and is smaller. Option B moves to the new API and takes longer. Which do you prefer?"', when: "idle" }) });
await expect.poll(async () => { const s = await session(); return `${s.agent_state}/${s.agent_sub_state}`; }, { timeout: 120000, intervals: [500] }).toBe("waiting/decision");

await step("A real Claude asks a form: it waits, in the default, until the answer");
const before = (await session()).state_seq ?? 0;
await call("/v1/boxes/devq/api/sessions/ask/send", { method: "POST", body: JSON.stringify({ text: PROMPT, when: "now" }) });
await expect.poll(async () => { const s = await session(); return s.agent_state === "waiting" && (s.state_seq ?? 0) > before; }, { timeout: 120000, intervals: [100] }).toBe(true);
const seen = [];
await expect.poll(async () => { const s = await session(); if (seen.at(-1) !== s.agent_sub_state) seen.push(s.agent_sub_state); return s.agent_sub_state; }, { timeout: 30000, intervals: [50] }).toBe("act");
await step(`It read as: ${seen.join(" → ")}`);

await step("It moved with no new wait: one agent.waiting, one agent.substate");
const events = globSync(join(dir, "box-home", "**", "journal", "*.jsonl")).flatMap((f) => readFileSync(f, "utf8").trim().split("\n").map((l) => JSON.parse(l)));
const mine = events.filter((e) => e.data?.session === "ask" && e.seq > before);
const waits = mine.filter((e) => e.type === "agent.waiting").length;
const moved = mine.filter((e) => e.type === "agent.substate");
if (moved.length !== 1 || moved[0].data.sub_state !== "act") throw new Error(`agent.substate: ${JSON.stringify(moved)}`);
await step(`${waits} agent.waiting, ${moved.length} agent.substate`);

await step("The field reads the same through the API, the CLI and MCP");
const api = (await session()).agent_sub_state;
const cli = box("sessions", "--json").find((s) => s.name === "ask")?.agent_sub_state;
const tool = mcp("berth_sessions", {}).find((s) => s.name === "ask")?.sub_state;
if (api !== "act" || cli !== "act" || tool !== "act") throw new Error(`api ${api}, cli ${cli}, mcp ${tool}`);

await step("The card is in Needs you to act, untagged, and the state is still waiting");
await page.getByTestId("nav-home").click();
const act = board.getByRole("region", { name: "Needs you to act" });
await expect(act.locator("article")).toHaveCount(1, { timeout: 30000 });
await expect(act.getByTestId("not-sorted")).toHaveCount(0);
if ((await session()).agent_state !== "waiting") throw new Error("not waiting");
// Cards fade in as they arrive: the shot, and this check, wait for that.
const card = act.locator("article");
await expect.poll(() => card.evaluate((el) => { let o = 1; for (let n = el; n; n = n.parentElement) o *= Number(getComputedStyle(n).opacity); return o; }), { timeout: 15000 }).toBe(1);
await expect(card).toContainText("acme");
await shot("two-columns-real-form");

await step("Removing the set brings the four columns back, on both boxes");
mcp("berth_substates", { action: "clear" }, "ask");
await expect(board.getByRole("region")).toHaveText([/Needs you/, /Working/, /Done/, /Idle/], { timeout: 30000 });
await expect.poll(async () => (await sub("devr")).states.length, { timeout: 30000 }).toBe(0);
await expect.poll(async () => (await session()).agent_sub_state, { timeout: 15000 }).toBeUndefined();
await shot("four-columns-after");
}
Loading