Commit Graph
87 Commits
Author SHA1 Message Date
istosandClaude Opus 5 b690060be2 Finishing a phase finishes its cards, and clears up after them
Merge & clean up on a phase card now ends the phase: after the merge into
main has actually succeeded, every member the phase merged moves to done/
and its workspace is cleared the way completing an ordinary card clears
its own — worktree removed, local branch deleted, branch on the remote
deleted.

Only what the phase merged. A member is swept when its card settled into
review/ or done/ and its branch is contained in the phase branch (or
there was never a branch to bring) — the same pair the runner reads a
member as merged by. One that halted, was held or was walked back keeps
its card, its worktree and its branch: there is work in them.

Nothing uncommitted is thrown away: a member's worktree comes out without
--force, and a dirty one is reported in the ticker and kept with its
branch rather than forced.

One ending, told once. taskfiles.move_together moves the cards in a
single commit naming all of them (`board: 47, 52 → done with phase 53`,
so it publishes in team mode like any other board commit), and the moves
are marked quiet so the watcher does not also scroll five identical move
lines behind the one line the ending gets.

The other endings are untouched: "just move the card" and archiving the
phase card move no member, because neither puts anything in main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 10:08:00 +02:00
istosandClaude Opus 5 8fbfc1085d A phase card does not move while its work runs
Dragging a phase card between stages while one of its members has an
agent in it is a move nobody can mean: the card lands somewhere its
branch, its worktree and its live agent are not. The board refuses it,
and names the way through rather than the wall — which member is
working, and that ‖ hold stops the phase and the agent it has in flight
while leaving the branch, the merges and every worktree as they were.

- phases.assert_not_working() is the refusal: it reads the one file
  first, so an ordinary card never reaches the question, then resolves
  the phase's list and asks what is actually running.
- agents.working_on() answers that from the processes themselves, not
  from the registry's status alone — the reaper flips that a moment
  after a run ends, and a rule that only refuses must not hold a card
  hostage to a run that has already died. stop_phase() now reads the
  same helper.
- httpd asks it on /api/move, /api/archive (archiving is a move) and
  /api/task/complete, so a stale page cannot get past it.
- The toast wraps, is bounded to the viewport and stays up for as long
  as its text takes to read: a refusal you cannot finish reading is the
  wall this was written against.

What still moves: a phase between members, a halted phase, a held one,
a member card, and every ordinary card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 09:23:23 +02:00
istosandClaude Opus 4.8 99fb248615 Phases view: a held run sorts above the phases nobody has started
laneRank() lumped a held (stopped) phase in with unstarted phases at
the same rank, so an actionable "stopped — held by …" lane could sort
below phases nobody has started. The snapshot already carries
snap.stopped, so give a held run its own rank just below running,
keeping it near the top where a person can resume it. Addresses the
Copilot review point on PR #48.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 09:06:10 +02:00
istosandClaude Opus 5 a3388ed2ea The Phases view: a swimlane each
Card 56 took a phase's members off the Board; this is where they went. A fourth view beside Board, Sessions and Focus, holding one lane per phase card — every phase there is, not only the running ones, because a phase whose cards are all merged and which is waiting on its own PR still owns them.

A lane is a head, five stage columns and the phase's log. The head carries the phase, its progress, the member in flight and its own controls — hold, the phase branch, the phase card — and nothing that ends a phase, which stays a board move on the phase card. Four columns are the board's stages; the fifth is 'Merged in', not done/, because a member merged into the phase branch is finished as far as the phase is concerned and is not in main yet. Which members the branch holds is the runner's last pass while a phase runs, and the phase log once there are no more passes, so a phase in review/ still draws the work where it landed. The cards are the cards: cardFor() builds them here exactly as it builds them for the Board, agent line, chips, position-in-the-run chip and hover actions intact. The phase log sits under the lane, the runner's decisions in the order it made them, read off the card's own '## Phase log' — the only thing that can tell "not reached yet" from "started and ended badly".

A halted lane says so at the top of itself, names the card it stopped on, and offers run again beneath the reason. That action left the Board's phase card for the same reason: clearing a halt should mean having read what caused it, so the card offers a way into the view instead, and hold, which needs nothing read. The crossing the other way is the switcher, which carries how many phases are running and an alarm mark while one has halted — on every view, alongside the toast and the ticker line that already fire.

Covered by tests/test_phases_view.py: collect() reads a throwaway tasks/ tree and the page's own lane rules run over that reading in node (ordering, placement, the last column, the log, the all-merged phase in review/), plus source-level wiring invariants for the DOM half. test_phase_watch, test_card_actions and test_scroll_kept updated where this moves what they pin. Full suite green: 939 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 08:55:17 +02:00
istos 3365919ca4 Phase members leave the Board view
The Board view stops drawing a phase's members: the phase card stands for them there, and the columns go back to counting what you can see. Nothing is deleted, moved or marked — a member keeps its stage, its file, its agent and its actions.

heldByPhase()/columnCards()/columnNote() in board.html: the count is the length of the list drawn, and '+2 in phases' beside it says where the rest went, only where members are actually hidden. Membership is the only thing that hides a card, so removing it is the un-hiding: an archived phase card and an edited '## Cards' list stop deriving it, and a phase in done/ holds nothing. A card wearing phase drift is never hidden. The phase card carries the summary it now owes: a '1 of 2 merged' chip opening its own sheet, the member in flight on its activity line, and a halt worn in --alarm. Only the Board hides; Sessions, Focus, the header's live chip and the tab title still see an agent working on a hidden member.

Covered by tests/test_phase_members_hidden.py — collect() reads a throwaway tasks/ tree and the page's own rules run over that reading in node, plus source-level wiring invariants. Full suite green.
2026-08-02 07:56:59 +02:00
istos 661dd89af4 An archive chip on the card, where the card is
Every card in backlog/, to-do/ and done/ gets a ⌸ chip at the end of its
footer row — the tray's own glyph — arming on the first click and
archiving on the second, through the routes and the ARCHIVE_FROM guard
that already existed. In-progress and review cards do not get one: the
board offers what it will do.

Committed by hand after the run that wrote it ended mid-turn without
committing. The work is the agent's; the suite passes on it (875 tests)
and it is unchanged from what the run left staged.
2026-08-01 19:57:03 +02:00
istos 419daef5e6 A redraw keeps where you were looking
renderBoard() starts with `board.innerHTML = ''` and rebuilds every
column, so each column's scrolling .drop was a brand-new node on every
pass — and a new node's scrollTop is 0. render() runs on every SSE frame
and a working agent emits an event per tool use, so a column being read
snapped back to the top several times a minute: worst exactly when there
is most to read. The same wipe threw away #board's horizontal position,
and the same demolition costs the session timeline, the sessions rail,
the Focus view, the drawer, and the activity log's place whenever it was
not stuck to the bottom.

Rather than a reconciling render — the real cure, and a far larger change
to the way the page works — the position is paid for separately: note
where each scroller was before the wipe, put it back once the new nodes
are in, both halves inside the same synchronous render so nothing
flashes. Keys are stable names rather than nodes (the stage slug for a
column, the session id for a timeline), because the node is what does not
survive. Restoring clamps, so a column whose cards moved on lands at its
new bottom instead of past it, and one now shorter than its own viewport
lands at the top instead of at a negative offset.

A `v:` prefix marks a key belonging to a view, and switching views drops
those: coming back is a fresh look, not a stale offset from a previous
visit. The activity log spans every view, so its key carries no prefix
and outlives the switch. The two behaviours that were already right are
left alone and now have tests holding them there — the log still follows
the newest line when it was stuck to the bottom (that reading runs after
the restore, so it wins), and the drawer still opens at the top when you
select a different card, which falls out of keying the drawer per
document rather than being fought for.

The helpers run for real under node, as tests/test_drawer_markdown.py
already does with md(); the source-level invariants beside them hold the
mark/restore pairs in order around each wipe, and one of them fails if a
new scrolling element is added to the CSS without a renderer keeping its
place.

    python3 -m unittest discover -s tests   → 854 tests, ok
2026-08-01 17:15:30 +02:00
istosandClaude Opus 5 463b35bdaf A card joins a phase without the file being opened
Phases are meant to arrive whole — members listed, dependencies filled in, readable in a diff before any of it runs. This is the other case: the card you decide belongs after all.

⟶ phase sits on backlog/ and to-do/ cards that are not already in a phase and are not phase cards themselves, and opens a sheet naming the phase cards waiting in to-do/ with what each already holds. Picking one appends `- <n> — <title>` to the end of that phase's ## Cards — the way a person writes it, because the section is authored and read by hand and a machine-shaped line is how a format stops being pleasant.

Only to-do/. A phase in in-progress/ is running: its branch exists and its members are being worked in the order the list had when it started, so appending mid-flight is a different feature with different questions. Offer it and someone finds that out the hard way. No phase waiting there and the action is absent rather than present and empty.

Nothing else moves. One line into the phase card, nothing at all into the card added — membership runs one direction and joining a phase is not a commitment to start it. The append goes out through append_to_section, the same door the phase log uses, so it commits itself under BOARD_COMMIT_MOVES, reaches the other boards, and reads the phase card off the disk rather than off a render: two boards adding to one phase produce two lines, not a lost one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 11:21:27 +02:00
istosandClaude Opus 5 c49f5bd014 The board shows a phase running, and shows it stopping
A phase card inherits the PR chip, CI, the review actions and the
merge-and-clean-up sheet by being a card. Three things it does not
inherit, and this is them.

The runner learns to stop. `stopped — held by <name>` joins `halted` and
`run started` in the phase log, and one reader (`run_state`) answers
where a phase is from the last line that says: running, halted, stopped
or never run. `stop_phase` writes that line — so the next beat stands
down — and holds the member agent in flight, because a phase that said
it had stopped while its agent kept working would be lying about the one
thing it was asked. Nothing is unwound: the branch, every card merged
into it and every worktree are left exactly as they were. A halted phase
can be held too, which is the other half of the halt's promise that it
holds until the phase is run again or stopped.

The halt is now told three times, as a dead run is: the state, the
ticker line it already had, and a toast — rare, actionable, and the
whole argument for starting a phase and walking away. Every advance is
narrated: the member that came up green, the merge, the next start.

The page: run phase (take over on someone else's) in the slot start work
occupies, hold while it runs, and a header chip beside the agents chip —
breathing accent with the phase, its progress and the card in flight,
alarm and holding when it halts, absent entirely when there is nothing
to say. One chip per phase, because two phases could in principle run at
once and a chip that showed one of them silently would be worse than
none. Opening a phase card lists its members in run order with each
one's stage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:32:32 +02:00
istosandClaude Opus 4.8 1f8f5f6fab Address Copilot review: no-identity beat, unwritable log, remote phase base
Three points from the PR #41 Copilot review, each a robustness gap on a path
the happy case never takes:

- phases._mine() gated the beat on the assignee even when this checkout has
  no git name — but agents.claim_for_launch() cannot gate a launch there and
  lets it through, so a phase could start (branch cut, run recorded) and then
  advance nowhere. _mine() now treats "no local identity" as the lone actor,
  matching the launch it mirrors.

- phases._record() ignored whether the log line landed. The log is the
  durable memory a restart reads to tell "already started" from "not reached
  yet"; a launch or merge with no line behind it is what a restart repeats.
  _record() now raises _Halt when the write fails — before the action it was
  meant to precede — split from a best-effort _write_log() the halt path and
  _start() use so recording a halt can never itself raise.

- github._pr_base() switched a member PR's base to the phase branch only when
  it existed locally. A board that did not run the phase knows it only through
  the remote (sync fetches origin/main and nothing else), so _pr_base() now
  also honours a phase branch the remote carries, and _open_pr() only pushes
  the base when it is a local branch.

Four new tests in tests/test_phase_runs.py cover each. Full suite green (740).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-01 10:03:08 +02:00
istosandClaude Opus 5 da8984d0e6 A phase runs itself, on a branch of its own
Starting a phase cuts phase/<stem> from the newest main it can see and
works the list into it: each member branched from the phase's tip, run
headless, merged back when its checks are green, the next one started.
At the end one PR into main, for a human. The human gate moves from
every card to the phase boundary, and the promise survives: the board
merges into a branch it created, inside a scope you opened.

The runner is a beat, not an agent — everything it decides is already
structured state, and an agent paid to poll would be the wrong tool at
the wrong price. It holds no registry of where a phase is. Two durable
things carry the memory, and the board already writes both: git, where
a member is finished when its branch is contained in the phase branch,
and the card, which grows a ## Phase log the runner adds one line to
per decision. The log is what tells "this member has run and it ended
badly" from "the phase has not reached it yet" — without it a
restarted board would relaunch a run that died.

Containment alone is not enough to call a member merged: a clean exit
that committed nothing leaves an empty branch that is contained. The
card has to have settled into review/ too, or a broken launch would
hide exactly where it always tries to.

Five conditions halt, each already a visible state on the card, and a
halt is written once and then held. Running the phase again is the
person's decision and is what clears it — the run is scoped to its own
log line, so a member whose run died is launchable again. A dependency
that has not landed is a wait, not a halt.

Merges are additive throughout: main into the phase branch on every
beat so a long run does not drift into one enormous conflict, members
into it as they go green, nothing rebased and nothing force-pushed. A
conflict aborts, leaves the branch as it was, and halts naming the
files that collided.

The actor rule decides who runs it, written where it already lives:
the phase card's assignee. A replica renders the phase and advances
nothing. Reachable through /api/phase/run and the ticker; the header
chip and the card actions are a separate card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:43:57 +02:00
istosandClaude Opus 5 ecde8abb53 A member of a phase branches from the phase, not from main
Two pieces the runner needs, in the modules that own them.

taskfiles grows a third door: append_to_section, one line under one
heading. The phase log is a running record, and append_to_task would
scatter its lines through the file as other sections landed between
them — the record would stop being readable in the one place a person
looks. It commits like every other board-made write.

agents learns where a phase member starts. That is the whole reason a
phase has a branch: related cards run one after another, so card two
branched from main could not see card one's work while card one sat
unmerged in review/ — it would conflict, or quietly build the same
thing twice. A card in no phase, or one whose phase has not been
started, takes the ordinary fresh branch point, and the ticker names
the unusual base as it already does.

claim_for_launch loses its underscore: a phase run claims its card the
same way starting work on one does, from another module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:43:35 +02:00
istosandClaude Opus 5 9621437479 A phase is a card that lists its cards
`**Type:** Phase` plus a `## Cards` section is the whole model. The phase
card is the only authority on membership: it lists its members in run
order, and a member's phase and position are derived from that list
rather than stored on the member — one direction, one place to edit.

- taskfiles.py reads the section (numbers only; `31`, `#31` and `031` are
  one card, indented lines are a member's own continuation) and parses
  `Depends on` at last, into the numbers it names. Nothing acts on it:
  the list says what runs next, the line says whether it may.
- collect() weaves the board: members resolved in document order, each
  member given its phase and 3/5 position. What will not resolve is
  flagged rather than skipped — a number no card has, a card two phases
  both list (both flagged), a card listed twice, a line naming no number,
  a phase listed by a phase.
- board.html gives a member card a `⟶ <phase> 3/5` chip in the footer
  row beside CI and PR ↗, opening the phase card, and wears phase drift
  as a pill next to status drift.

Nothing runs yet — no branch, no launches, no advancing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:01:37 +02:00
Ronald AshriandGitHub 5d3f22717f Merge pull request #39 from 12vectors/task/37-sync-resolves-its-remote-and-says-when-it-cannot
37 — Sync resolves its remote like the rest of the board, and says so when it cannot
2026-08-01 08:47:15 +02:00
istosandClaude Opus 5 58b658389b sync: ride the resolved remote, and say when there is none
REMOTE/UPSTREAM were module constants, so a team-mode board whose remote
is named anything but origin synced nothing at all — silently, with a
healthy header. The remote is now resolved per use (config's answer, the
one PRs already honour) and threaded through fetch, publish, replay and
integrate; every message names whatever was resolved.

The silence is the other half. Where _converge()/push_now() returned a
bare 'no-origin' that both callers discarded, _remote() now _note()s at
stalled level first: no remote at all names both fixes (add one, or set
BOARD_GIT_REMOTE), and a BOARD_GIT_REMOTE naming a remote this checkout
does not have stalls naming it rather than quietly using another. It
clears with a recovery line like the offline path, and install() asks at
startup so the chip is there from first paint, not the second beat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 07:33:39 +02:00
istosandClaude Opus 5 6829136f42 start: a pinned port stays pinned across a restart
The free-port probe bound without SO_REUSEADDR while the board's own
ThreadingHTTPServer sets it, so the socket a just-stopped board left in
TIME_WAIT read as "taken by something else": a routine stop/start walked
the board to the next port and wrote that over the user's BOARD_PORT pin.
The probe now binds exactly as the server does, which is the whole race.

Behind it, a held port gets a few seconds (BOARD_PORT_WAIT, 5s) to clear
before the walk, re-asking is_our_board each beat — a restart races its
own predecessor far more often than a stranger takes the port. Walking
off a pinned port still persists, since the hooks and agents read
BOARD_PORT and must reach the live board, but it now says so in full:
the right file (manager/local/.env, not manager/.env), old → new, and
how to reclaim the pin.

Tested end to end against a scratch host with a stub board.py, over real
sockets: a genuine TIME_WAIT remnant, a listener that lets go mid-wait,
a listener that does not, and our own board answering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 07:32:54 +02:00
istos 4660d0cb34 0.3-alpha
And un-pin the landing page's version test: it named 0.2-alpha as the
string to replace in VERSION, so the first bump after it was written
failed the suite. It reads the file now, which is what the test is about
in the first place.
2026-07-31 17:28:20 +02:00
Ronald AshriandGitHub 385acae35a Merge pull request #36 from 12vectors/task/44-archiving-a-card-reaches-git
44 — Archiving a card reaches git, and so does every other write the board makes to a task file
2026-07-31 17:22:25 +02:00
Ronald AshriandGitHub 97f6343275 Merge pull request #37 from 12vectors/task/38-a-card-being-completed-looks-like-it
38 — A card being merged and cleaned up looks like it, and holds still
2026-07-31 17:22:16 +02:00
istos 97d63216b6 site: take the six doors off the landing page
The page is now three things: what bench is, what installing costs, and
why to bother. The header nav is the way into the docs.

Removing them takes their CSS with them (.doors/.door* in the base sheet
and in all three breakpoints) and two tests that existed only for them —
the six-are-distinct check here and TheDoorsOpenOntoArticles in
test_site_pages. The hero-buttons test survives its class, renamed to say
what it now guards: the landing page's only links into the docs.

One test needed retargeting rather than deleting.
test_it_fails_before_anything_is_written removed /concepts/team-mode/
from the manifest to prove a dead internal link stops the build before
anything is written — but the link it relied on was a door. It now aims
at /guides/install/, which the hero button still hard-links.

Side effect worth having: the two reasons in "Why bench?" that nearly
repeated doors 03 and 04 no longer sit above them.
2026-07-31 17:20:49 +02:00
istos a1124ec991 tests: the claim, the refusal, and the release on every exit
Against a real repo with a real branch: a real merge, a real conflict,
the wrong-branch refusal, a crash, and a second complete arriving from
inside the first one's merge — which is refused, starts no second merge,
and does not release the run it lost to. Plus the registry's own rules
and the card-face invariants in board.html.
2026-07-31 16:57:17 +02:00
istosandClaude Opus 5 b462b83a7f Archiving a card reaches git, like every other task-file write
An archive renamed the file on disk and stopped there, leaving an
uncommitted deletion of a tracked file — exactly what sync._clean()
refuses to run over, so one drag onto the tray stalled every later board
commit, kept the archive off the other boards, and left the card in a
single working tree. Appended agent reports had the same gap.

taskfiles now has one door out of a stage directory, _relocate(): it
registers the expectation, writes, moves, and commits under the same
COMMIT_MOVES gate a move uses — so archive, unarchive and move all commit
by construction rather than by each caller remembering. The messages read
like the others (`board: <n> -> archived (<name>)`, and the stage slug on
the way back) and carry the `board: ` prefix sync's piggyback guard
requires; routing through _commit means state.task_committed() fires, so
the event-driven push publishes it.

The second door is append_to_task(), which writes into a card where it
stands and commits via commit_edit(); agents._file_report() uses it
instead of touching config.TASKS itself, so a run's closing report is
recorded rather than left modified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 16:57:09 +02:00
istos d3a43ef1ae install: detect the test command instead of asking for it
The third setup question wanted an answer about a repo the person may
have just cloned, thirty seconds in, before anything had explained why
the board needed one. The file that names a project's ecosystem usually
names its runner too, so it is read off the project: package.json → npm
test, Cargo.toml → cargo test, go.mod → go test ./..., a
pyproject/setup.py/tests/ → python3 -m unittest.

Nothing recognisable writes BOARD_AGENT_COMMANDS empty rather than the
example's Python default, which would be quietly wrong in most repos —
and a wrong prefix denies an agent exactly the way an empty one does, so
absent beats wrong here.

Two details the first attempt got wrong, both now covered:

- Detection runs *before* the questions. Placed after them, a Ctrl-D
  part-way through skipped it and left the example's default.
- "Keep what the project already said" reads the existing .env, not the
  example-seeded defaults dict — which already carries the example's
  answer, so every first run looked like it had one and detection never
  ran.

The landing page's terminal, README.md and AGENTS.md follow: two
questions now, and AGENTS.md documents the detection table.
2026-07-31 16:47:30 +02:00
Ronald AshriandGitHub 028635e3fc Merge pull request #35 from 12vectors/task/45-a-past-agent-session-is-not-you
45 — A past agent session is labelled "You", because "You" is what the board says when it does not know
2026-07-31 16:40:50 +02:00
Ronald AshriandGitHub 09cf11e73c Merge pull request #34 from 12vectors/task/42-the-report-keeps-its-headline
42 — A long closing report loses its head, which is where it says what happened
2026-07-31 16:40:40 +02:00
Ronald AshriandGitHub edd78136fc Merge pull request #33 from 12vectors/task/36-the-reference-section
36 — Add the reference section: settings and the contracts
2026-07-31 16:35:27 +02:00
istosandClaude Opus 5 f428ee0468 sessions: persist who a session was, so a replayed run is not "You"
"You" was the else-branch of session_label: anything the board could not
attribute to an agent it attributed to the person. Every session read back
from disk was one of those, because the agent id lived only in the session
registry and never reached the persisted events — so past agent runs came
back wearing the human's label, carrying their own closing reports
underneath it.

Identity is now a small whole file beside each event log
(state/sessions/<id>.who.json): agent id, the agent's name, the model it
rode, and the task. A file rather than a key on the events, because the
logs are append-only JSONL whose first line every reader takes for an
event — and because the name and the model are nowhere in the stream, so
this is the only thing a restart can read them back from. It is rewritten
only when what the board knows changes, which also covers an agent id that
arrives on a later event.

load_disk_sessions() reads it back, and the label now has three registers
instead of two: the agent's name (persisted, so a restart no longer costs
it), "You" only for a session positively recorded as carrying no agent,
and a neutral "Session · <id>" for a log written before any of this was
recorded. Old logs are not retro-attributed in either direction.

agentFor() in board.html falls back to the persisted identity when this
board no longer holds the live record, so a replayed agent session wears
its model chip from what was written rather than from what happens to be
in memory. What depends on liveness (Hold, the worktree branch) finds
nothing there and stays silent, as before.

tests/test_session_identity.py drives the real ingest → persist → reload
path and the page's own chip functions in node.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 16:35:19 +02:00
Ronald AshriandGitHub 9d94897d45 Merge pull request #32 from 12vectors/task/39-the-tab-says-whether-agents-are-working
39 — The browser tab says whether agents are working
2026-07-31 16:35:06 +02:00
istos a1b17c85cf merge main into task/36: the board shot and the reference section
One conflict, in the 480px block of site.css: main added the landing
page's .shot rules there while this branch widened the prose padding
selector to cover .page-reference. Both belong — the shot is home-only,
the padding is every long-form layout — so the resolution keeps main's
two rules and the branch's wider selector.

Everything else merged clean: the reference layout, its four routes and
the settings parser do not touch what the landing page changed.
2026-07-31 16:34:24 +02:00
istosandClaude Opus 5 38745ae0b3 site: add the reference section, generated from the settings file
The third design layout (1c Logbook) had no page. This lands it, and
with it the four routes a reader reaches for when the guides have
stopped answering: /reference/settings and the three contracts a
project implements against.

The settings page is parsed, not transcribed. site/build.py grows a
second way to make a body — a page may say "generate": "settings"
instead of a "from" heading — and manager/core/.env.example is read on
every build: its blank-line blocks become the page's entries, a comment
block documents the keys directly under it, and the file's own
NAME=value lines are the defaults shown. A key with no comment above it,
or one set twice, stops the build naming the route; a settings page that
disagrees with the settings file is worse than no settings page.

Generated bodies render with raw HTML off. The file writes
<git user.name> meaning a placeholder, and a parser honouring HTML
swallows it — it was never written to be markdown.

The reference layout is the article's three columns with the contents
gutter given over to a console: every entry on the page once more in the
machine register, keys with their defaults on the settings page and
headings on a sliced one. It links the same anchors "On this page"
carries, so the 1080px step folds the whole column away and the contents
strip hands them back — no third copy of anything, and the responsive
rules already in the sheet cover it.

The contract pages are slices like every other page: the adapter
contract and its event schema out of manager/core/adapters/README.md,
the driver and local-command contracts out of AGENTS.md. Renaming any of
those headings stops the build naming its route.

tests/test_site_reference.py holds the promise: add a key to
.env.example with its comment, rebuild, and it is on the page with its
default, with nothing in site/ edited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 16:28:53 +02:00
istosandClaude Opus 5 4f4004d561 tests: the report clip, with card 32 as the regression
33 tests over reports.report: the head survives, the tail survives, the
elision says in words what it cut and names the log, cuts land on line
boundaries, a short report is passed through unchanged, the four marker
lines still parse, and the two windows never duplicate a line — checked
across a sweep of caps, which is where an off-by-one would put a
paragraph into the permanent record twice.

The fixture is card 32's report at the size it was (3,619 bytes). Its
log is gitignored state that did not survive into this worktree, so the
file is that report reassembled: the 3,000 characters the old clip kept
are verbatim from its card in tasks/done/, and the 619 it discarded are
rebuilt from the quotation in card 42, which is where they survived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 16:27:47 +02:00
istos ea93a2479d site: put the board above the fold
The page claimed 'mission control' and showed a terminal. It now shows
the board itself, directly under the hero, on bench's own repo with
three agents working — the claim demonstrated rather than described.

The terminal gives up the height for it: the .env preamble and the
Ctrl-C line go, the install command and the three setup questions stay.
tests/test_site_landing.py's TRANSCRIPT follows, and still holds every
remaining line against the source that prints it.

The shot goes through stamp() like the stylesheet, so its url carries a
?v=<hash>. Without that, /static/*'s year-long immutable cache would
make the screenshot unreplaceable. 1600px wide and palettised to 256
colours — 164K rather than 452K, and a flat dark UI loses nothing to it.

img{max-width:100%} is now a base rule rather than only .prose img: the
shot lives outside the article body, where that rule never reached.
2026-07-31 16:27:02 +02:00
istos 3bc7e6ec7e site: let the analytics beacon through img-src
Fathom sends its pageview as an image request. script-src named the
origin and img-src did not, so the script loaded and the one thing it
loads to do was blocked — visible only in the browser console, with a
200 on every response and no pageview at the other end.

The console said it plainly:

  Loading the image 'https://cdn.usefathom.com/?h=...&sid=ZPKDEHCV...'
  violates the following Content-Security-Policy directive: img-src
  'self'

A test now asserts the origin appears under all three directives it
actually uses, because nothing on this side of the wire can tell that it
does not.
2026-07-31 16:15:50 +02:00
istos 72a0d4195f board: put the running-agent count in the tab title
A running agent was visible on the board and nowhere else, so switching
tabs — the normal thing to do while one works for minutes — left bench
saying the same string whether three agents ran or none.

The count now leads the title (`2◌ · bench · bench`), ahead of even the
project: the reasoning that put the project first (truncation eats the
tail) applies harder to the one state you want from a tab you are not
looking at. The mark is the in-flight glyph the CI and copilot chips
already wear, not an emoji, and a quiet board keeps the plain title byte
for byte.

Both the tab and the header chip now count through one runningAgents(),
so they cannot disagree, and the title is written only when the string
actually changed — render() runs on every SSE frame.

Behaviour is covered in node (skipped where node is absent); the wiring
that keeps one filter and one writer is a source-level invariant, as
elsewhere for board.html.
2026-07-31 16:12:47 +02:00
istos d5c999fa79 site: keep maintainers' notes out of the build
The host uploads the assets directory whole, so a .md left in static/
becomes a public url — static/fonts/README.md was live, telling readers
how to refresh fonts they cannot write to. copy_static skips it, and
internal_targets skips it too: the link checker and the copier have to
agree, or a link to a skipped file would pass the build and 404 on the
site. The licences are .txt and still ship, which is the point of
committing them.

Card 43 records the other thing today's run turned up: the suite reads
the developer's own local/.env, so two sync tests fail here and pass in
CI.
2026-07-31 15:56:02 +02:00
istos 662ecc001f site: add Fathom analytics, and fix the caching rule it sits beside
Two changes to what the live site sends.

Analytics: Fathom's tag goes in every template's <head>, deferred. It is
cookieless and collects nothing about a person, so no consent banner —
but it is a third party, so the CSP names cdn.usefathom.com for script
and connect rather than opening the door generally, and the tests that
said 'no script at all' now say 'no script this site depends on, and no
origin nobody chose'.

Caching: the host concatenates a header two matching rules both set
rather than overriding, so /* and /static/* each setting Cache-Control
sent 'max-age=0, must-revalidate, max-age=31536000, immutable' on the
stylesheet — first max-age wins, and the year-long cache never happened.
Measured on the live site, which is where the from-memory assumption in
task 32 said to check it. Now /static/* is the only rule that sets it
and HTML takes the host's revalidating default; the post-deploy checks
in site/README.md verify both ends.
2026-07-31 15:39:46 +02:00
istos db3b56b737 merge main into task/35: keep both rails' folded strips and 34's lede
article.html: 34 added the lede under the h1, 35 added the folded
contents strip in the same place. Both belong, and site.css settles the
order — its lede adjacency names .menu-contents + p, so the strip sits
between the title and the lede.

Two things the merge exposed rather than caused:

- $toc and $sidebar were spelled inside article.html's own comments,
  and string.Template substitutes there too, so every page carried a
  second copy of both rails inside a comment (28 toc links for 7
  headings). Doubled to $$toc / $$sidebar, as the template contract
  already said to.
- test_site_pages assumed one rendering of each rail. Both are now
  written twice by design, so the tests assert every rendering agrees
  with the page instead of assuming there is one.

The card file keeps main's location: the board owns a card's stage, and
main had already moved 35 to review/.
2026-07-31 15:27:00 +02:00
istosandClaude Opus 5 d25221bb74 The drawer reads a wrapped list as one item
md() split a block into physical lines and made each one a unit. Task
files are hard-wrapped at ~74 columns, so the second line of an item
became its own bullet, `- [ ]` rendered as a literal bracket pair,
nested lists flattened, and prose kept the author's ragged edge as
<br>. "Enough for these task files" was exactly what it was not.

Lists are now grouped into logical items before rendering: a new item
begins only at a marker, and a line without one is continuation text
joined with a space. Indentation is honoured — a marker past its level
opens a nested list, a shallower one closes back to the level that
fits — and one entry point serves both bullets and ordered lists, so
an <ol> nests under a <ul> the same way. Task-list items render as a
glyph in a span, never an <input>: the file is the source of truth and
the drawer is not an editor. A ticked box reads as settled (--calm);
an open one stays neutral. Paragraphs and blockquotes join their
source lines with a space, so prose reflows to the drawer's width.

Fences, tables, headings and rules are untouched, including the fence
state machine that spans blocks.

The tests lift esc() and md() out of the page and run them under node,
because the renderer is a pure function and its output is what to
assert on; node is not a bench dependency, so those checks skip when
it is absent and source-level invariants cover the shape of the fix.
One check renders every card on the board plus AGENTS.md and asserts
one bullet per source marker — the acceptance criterion applied to the
whole corpus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 15:01:26 +02:00
istosandClaude Opus 5 33aebd90d6 site: make the docs read on a phone
The design is drawn at a fixed 1180px with three-column articles and a
two-column hero, and the site's first traffic is a link pasted into a
chat and opened on a handset. This is what happens below that width —
three max-width steps, and nothing that takes effect at or above the
width the design defines.

The two columns an article loses come back as <details> strips: "On
this page" folds under the title at 1080px, where the gutter goes, and
the section nav becomes a menu under the masthead at 760px, where the
sidebar goes. Both are filled from the same $sidebar and $toc as the
columns they replace, so a folded copy cannot say something the column
does not — and one menu that opens and closes needs no script, which
keeps the site's promise of shipping none.

The rest is what the acceptance asks for: a table becomes its own
scroller with an edge shadow that appears only while there is more to
the right; a token that cannot break, breaks; fenced code keeps
scrolling inside its own block; the hero and title steps come down
without leaving Zilla Slab; everything a finger has to hit is --tap
(44px) tall; and the masthead, footer and hero actions wrap instead of
widening the page.

The one thing that would have changed desktop is guarded rather than
noticed later: the contents strip is a sibling between the h1 and the
body's first paragraph even when display:none, so the lede rule names
it too.

tests/test_site_responsive.py mechanises all four promises, including
the one that matters most — every media query is a max-width below the
design.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 14:57:53 +02:00
istosandClaude Opus 5 7ab45f49fd site: the guides and concept pages, in the 1a Harbour layout
Fills the middle of the site. Nine routes, every body a heading slice of
AGENTS.md or README.md, and the article layout given the furniture the
design calls for.

The manifest gains the two concepts nothing covered: /concepts/task-files/
(the header format, from AGENTS.md's own section) and /concepts/adapters/
(the adapter summary, which is the other half of the three-layer law).
/concepts/stages/ now runs through "Moving a task", because the five
directories and moving between them are one idea.

The layout:

- A lede under the title — the one sentence an article authors, taken
  from the manifest's `description` or an explicit `lede` where the two
  want different words. A slice starts mid-document; a reader arriving
  from the nav is owed a line saying what they are looking at.
- Prev/next at the foot, walking the sidebar's own order so the arrows
  and the rail cannot disagree. Pages with no section (the landing page,
  the 404) are not on the flow.
- "Edit this page on GitHub" anchors to the section the page was cut
  from, built from the same `from` heading the slice starts at.

Two bugs the new pages found:

- string.Template substitutes inside HTML comments, so a comment naming
  the body placeholder emitted the whole body twice and closed itself
  early on the first `-->` in it.
- Promotion could produce a second <h1>. A slice that deliberately runs
  past its own section carries headings at the `from` level, and those
  promoted to h1 on a page that already had one. Promotion now stops at
  h2, where they read as peers — which is what putting them on one page
  said in the first place.

tests/test_site_pages.py covers the furniture on the real built site:
the routes, the layout, the sidebar marking one page, the contents list
being exactly the body's own h2s in order, the prev/next chain end to
end, the edit link's anchor, the six landing-page doors, and a table, a
fenced block and a nested list surviving the renderer. The scratch-repo
helper now copies every file a slice links to, since the builder checks
those exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 14:29:49 +02:00
istosandClaude Opus 5 274ad6b2c6 site: the landing page, on things bench really does
Turn design 1b (Dockside) into `/`: a terminal hero, the claim README.md
already makes, six doors, and a strip at the foot. The layout is task
31's; what this commit is really about is that nothing factual on the
page is typed by hand.

- **Two facts are read, not written.** `site/build.py` reads the install
  one-liner out of README.md's "Install into a repo" block and the
  version out of `manager/core/VERSION`, and offers them to the template
  as `$install_block` and `$version`. A renamed section, a missing
  VERSION or an install section that lost its command block stops the
  build, exactly as a renamed heading already did. `pages.json` loses
  its `version` key, and a build refuses one if it comes back.

- **A dead internal link stops the build.** Every href a rendered page
  emits — a door as much as a link inside a slice — must resolve to a
  route in the manifest or a file in `static/`/`root/`. The check runs
  after rendering and before writing, so a bad link leaves the last good
  build standing rather than shipping a 404 with a nice typeface.

- **Six real doors.** `pages.json` grows the routes they open: install
  and first run, the five stages, agents on the board, PRs and review,
  team mode, the three-layer law. They are heading slices, i.e. the
  stub routes task 33 expected and task 34 will re-cut.

- **The terminal is a transcript.** README.md's own command, then lines
  install.py and board.py really print, with the abridgement declared in
  the terminal's title bar. `tests/test_site_landing.py` holds every one
  of those lines against the source that prints it, so a reworded prompt
  fails the suite instead of quietly making the page fiction.

- **No fake telemetry.** Turn 1's "most opened this week" strip becomes
  the version, read from VERSION, and a link to the releases.

The landing page no longer carries a generated body, so the tests that
read one from it now read `/concepts/stages/`, and the two scratch repos
in the suite copy VERSION alongside the markdown.

python3 -m unittest: 407 tests, OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:10:25 +02:00
istosandClaude Opus 5 6c06583109 site: serve bench.12vectors.com from a Cloudflare Worker
site/wrangler.jsonc puts site/dist/ behind bench.12vectors.com as static
assets. No `main`: the site is files, and a Worker with no script is the
cheapest correct way to serve them.

  html_handling      force-trailing-slash, so /x redirects to /x/ — the
                     url the pages link and rel=canonical names. One
                     page, one address; no url ends in .html.
  not_found_handling 404-page, so an unknown path gets dist/404.html
                     with a 404 status rather than the landing page
                     with a 200.
  routes             bench.12vectors.com as a custom domain. Cloudflare
                     takes the hostname at the zone level and makes the
                     DNS record; nothing else on 12vectors.com moves.

site/root/_headers carries the response policy. HTML revalidates on
every view, so a deploy is visible on the next reload without anyone
clearing a cache; /static/* is kept for a year and never re-checked,
which is safe because the stylesheet and icon urls carry a hash of their
contents. The general rule is written first and the specific one second,
so a host that merged the two instead of overriding would still land on
max-age=0 — the safe side. Alongside it the baseline a public page owes:
nosniff, a referrer policy, a year of HSTS without preload,
X-Frame-Options, and a default-src 'none' CSP that makes "no analytics,
no third-party anything" something the browser enforces rather than
something a test asserted once.

Deploys are run by hand, as releases already are — no Cloudflare token
in repository secrets, no first deploy pipeline. site/README.md names
the account, the Worker, the route and the four-command sequence, plus
the four things to check after a deploy that no test here can reach.

The tests cover everything before Cloudflare: that the config says what
the site needs, that the build writes the files it names, and that
wrangler.jsonc, pages.json and README.md cannot drift apart about which
domain this is. A live response is not among them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 11:47:12 +02:00
istosandClaude Opus 5 ad639a33d3 site: a 404 page, a root/ tree, and stamped asset urls
Three things the build owed the host before the site could have an
address, all of them visible in site/dist/ rather than in a deploy
script.

A 404 page. Cloudflare's not-found handling serves a literal 404.html
from the root of the assets directory, so a route may now name an .html
file instead of ending in "/" — the one that does is /404.html, a normal
manifest entry with its own layout. It is the site's design and the
site's nav, with a way back to the landing page, a noindex and no
canonical. A null section keeps it off the nav it renders.

site/root/, copied verbatim to the TOP of the build the way static/ is
copied to a subdirectory of it. _headers is what it holds; the host
reads that file from the root and nowhere else. A file in root/ that a
route would also write stops the build rather than letting one silently
win.

Stamped urls for the stylesheet and the icon: the templates link them
through $stylesheet and $icon, which carry a ?v=<hash> of the file's own
contents. Nothing in static/ is renamed, so the tree and the /static/*
glob stay as they were — but the url changes when the file does, which
is what will make a year-long immutable cache safe.

Tests ask the builder where a route lands rather than keeping their own
copy of the rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 11:46:56 +02:00
istosandClaude Opus 5 1f8a98eb5f tests: cover the site build, and say out loud that site/ never ships
The generated site's whole value is that drift is loud, so the tests
are mostly about drift: a heading renamed in AGENTS.md must fail the
build naming the route and the heading, a slice emptied to its heading
must fail rather than emit a page, and a heading inside a code fence
must never match — AGENTS.md fences a task template whose first line is
'# Task title', and matching that would slice the document in half.

Alongside: link rewriting (GitHub blob, link_routes overrides, dead and
escaping links refused), manifest validation, the output-directory
guard, and the acceptance criterion that no built page fetches anything
third-party — mechanised as 'every rel that opens a connection is
same-origin', with rel=canonical excluded because it is a statement,
not a fetch.

The release artifact test now asserts site/ is absent from bench.tar.gz.
The manifest already excludes it by saying nothing about it, but
correct-by-omission is exactly what a future manifest edit undoes
silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 11:30:43 +02:00
istosandClaude Opus 5 635205486b A first run writes local/.env, asking what it cannot guess
A project could run bench for months without a manager/local/.env:
everything fell back to core/.env.example, so the two settings that
change what bench is — claim-on-move and syncing through origin/main —
were invisible to anyone who had not read that file.

install.py now writes it on a first run. It asks three questions no
default can be right about (solo or team, which agent adapter, the
project's test command) and writes core/.env.example with the answers
substituted into their lines, comments and all keys intact — so the
written file is where the project reads what else it can change.

- Runs after first_boot_clean: .env is one of the two things the
  first-boot guard reads, so writing it earlier would skip the clean.
- Never asks without a terminal on stdin. install.py sits on the path
  of start.sh, update.sh and every hook, so no TTY prints one line and
  carries on rather than blocking a board start on an invisible prompt.
  --dry-run reports the questions and writes nothing.
- An existing .env is never touched; --setup is the only way back to
  the questions, pre-filling from the current file and rewriting it in
  place, so start.sh's fallback BOARD_PORT line survives.
- Bare Enter takes every default (the result is the example verbatim,
  i.e. today's behaviour exactly); Ctrl-D skips the rest.

start.sh needed no change — it already calls install.py before the port
dance, which is the right order. Tests drive the interactive runs over a
real pty and the non-interactive ones with /dev/null on stdin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 07:55:57 +02:00
istosandClaude Opus 5 47779a35bc Assert the review launch's flag list whole, and say why it moves
The MultiEdit deny rule was already dropped in the hotfix; this is task
10's follow-up half — the guard that catches the next fossil.

The claude adapter's review branch spells "cannot edit files" in the
vendor's own tool names, and that roster moves: a rule naming a tool the
installed CLI does not have is refused at startup, so the launch dies
before the agent speaks. Membership assertions could not see that
happening, so the stub-binary test now asserts each mode's flag list
literally (settings payload elided) — a deny name added or renamed shows
up as a diff a reviewer must re-verify against the installed CLI. The
run script carries the same warning where the list actually lives.

The helper drops a leaked AGENT_MODEL, since --model would otherwise
appear in an argv now compared whole; test_agent_model.py owns that flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:34:19 +02:00
istosandClaude Opus 5 d94b6ee423 A failed run is a state the card wears
An agent that exits non-zero was the least-handled outcome on the board:
one ticker line that scrolled away, a card that looked exactly as it did
before the launch, and the log's contents — usually the whole story — left
on disk. Three launches died in an API outage and the board said nothing a
person would notice.

So the outcome is recorded on the run: exit code, ended-at, and the
cleaned tail of its log as the excerpt (a launch that died before the
agent spoke says so rather than showing blank). From that the card takes
the alarm border and a `run failed` pill, with the excerpt on hover and in
full in the card sheet; a toast fires, because failures are rare and
actionable; and the ticker line stays, now naming what the log ended on.

Every headless kind lands in _finish, so work, act-pr, PR review and the
relevance check are all covered. The state is scoped to the run and the
stage: the next launch supersedes it, and the watcher drops it when the
card moves, so nothing follows a card into review/.

The way is cleared for the relaunch too: a failed run with no commits has
its worktree and empty branch removed — the reasoning a decline already
uses — so ▸ start work works without a hand `git worktree remove`. A
failed run with commits keeps its worktree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 13:10:26 +02:00
Ronald AshriandGitHub 64ad311267 Merge pull request #20 from 12vectors/task/21-shebang-means-executable
21 — A shebang means executable: fix the shipped modes and test the invariant
2026-07-30 12:51:27 +02:00
istos 053e1d7d71 A model chip beside every agent name
Task 12 recorded which model each launch rode, but it surfaced only at
the tail of the session-detail metadata line, where nobody's eye lands.
With work and review models diverging per intent, "which brain did this"
is a routine review question, so the answer now sits beside the name that
did it.

One component, modelChip(), rendered wherever a name identifies a run:
the sessions list row, the session-detail header, the Focus header and
the working card's agent line. It borrows the session-id hash's register
— small, mono, dim — and takes no colour, because a model is not a state.
The vendor prefix a name repeats is dropped for the chip (claude-opus-4-8
→ opus-4-8, anthropic/x → x) with the whole string on hover; an
unfamiliar name is shown as recorded rather than guessed at.

A launch that never knew its model wears no chip at all — an inherited
default, or a session replayed from disk after a restart. The two
metadata lines that used to carry the model keep only what the chip
cannot say: "model inherited", and only when it genuinely is.

Tests lift the two functions out of the page and run them in node (skipped
where node is absent) for the shortening, escaping and silence; placement
and register stay source-level invariants, as board.html has no frontend
test runner.
2026-07-30 12:04:36 +02:00
istosandClaude Opus 5 eec69ba575 A shebang means executable: test the invariant, cut the patch
install.py shipped mode 644 in v0.1-alpha, so the README one-liner's
./install.py was permission-denied on every install. The repo modes and
update.sh's repair line were hotfixed already (all 14 shebang'd tracked
files are 100755; update.sh:165 names install.py) — this is the guard
that keeps them that way, and the patch release that heals the field.

- tests/test_release_artifact.py: the invariant, read from the tar
  header rather than the repo — every member whose content starts `#!`
  must carry the exec bit, failing by name. No exception list: there is
  no shipped file that legitimately may not be run, and gaining one
  means editing the test with a reason. Two tests keep it honest: the
  guard is proven to bite by repacking the real artifact with
  install.py's mode stripped, and an unpacked release must run
  ./install.py as a program, not via python3.
- tests/test_update_from_release.py: an install whose install.py is
  mode 644 — the shape v0.1-alpha left in the field — is executable
  again after any update. `cp` onto an existing file keeps the
  destination's mode, so the chmod line is the only thing healing it;
  removing that line fails this test.
- manager/core/VERSION → 0.1-alpha.1: cutting the patch is the honest
  move over a release note telling users to work around it.
- manager/core/release-manifest: the invariant, stated where the
  shipping list lives.

Verified: python3 -m unittest discover -s tests (267 tests, OK). Both
new assertions were watched failing first — a build-side `chmod -x` on
the staged install.py, and update.sh with install.py dropped from its
chmod list — then restored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 12:00:01 +02:00