Skip to content

Briefing

The one document a briefing is, and how it is assembled (#2243, capability #2224). (Back to reference index.)

A briefing is what BoB has done, is doing and will do across the whole portfolio, plus what only the human can unblock — built from source data with links, deterministically, so the page (#2244) and the Telegram message (#2245) are views over one document and never prose of their own. bob brief is the collector; briefing/1 is the contract it emits; --send is the Telegram delivery.

Terminal window
bob brief # the human rendering, every gap named
bob brief --json # the briefing/1 document
bob brief --json --since 2026-09-15T06:00:00Z # an explicit window start
bob brief --offline # local files only: no forge, no engines
bob brief --write # also store the document where /briefings lists it
bob brief --send # store it and post the headline + page link through bob notify
bob brief --send --scheduled # the same, unless this machine's declared quiet hours hold (#2246)
bob brief --install-timer # the bob-brief systemd timer from the declared cadence (#2246)
bob brief --write --retain-days 30 # store, then prune stored briefings older than 30 days
bob brief --html # render the page; stdout is one line and a URL (#2791)
bob brief --index # regenerate the briefings index from the brief issues (#2792)
# of every registered project, open first (#3102); its Project
# column and filter name each brief's scope (#3040)
bob brief --propose # what should be done first on each brief — report only (#2818)
bob brief --propose --apply # record each proposal (or abstention) on its brief issue
bob brief --area area:fleet # one area's page — a view; an issue only for a decision (#2820, #3101)
bob brief --area all --apply # every area: label's page, a brief issue only where a decision is
bob brief --project flows # one brief for one registered project, in its own repo (#3040)
bob brief --project all --apply # every registered project, each brief filed or updated
bob brief --publish # what publishing the generated pages would change — report only (#2872)
bob brief --publish --apply # copy them to the declared serving repo, commit, push, deploy
bob brief --reconcile # which brief actions the tracker already resolved — report only (#3100)
bob brief --reconcile --apply # tick them, move the state label, close finished briefs, republish the index;
# also briefs every area that drained (#3169)
bob brief --completions # which areas drained since their last completion briefing — report only (#3169)
bob brief --completions --apply # file, write, publish and announce an area-completion briefing for each
bob brief --completions --project . --apply # the project this directory is in (what a warp run starts)
Option Effect
--json The briefing/1 document on stdout (schemas/briefing.schema.json).
--write Store the document in the briefings directory as <generated_at>.json — see Storing.
--send Store it (implies --write), then post the headline and the page link through bob notify; the exit code is bob notify’s — see Telegram delivery.
--install-timer / --uninstall-timer / --timer-status The bob-brief systemd user timer rendered from the declaration — see Schedule and retention. --machine <id> names the entry, --dry-run writes and runs nothing, --json prints brief-timer/1.
--scheduled A scheduled briefing (the timer, the end of bob tick): inside the declared quiet hours nothing is made or stored and the exit is 0; an invalid declaration is exit 2 — see Schedule and retention.
--retain-days <N> After a write, prune stored briefings older than N days — wins over the declared retention_days.
--url <http://host:port> The Project Monitor base the page link uses instead of this machine’s tailnet address and the project’s ledger dashboard port.
--on-quiet message|skip What a quiet briefing sends: one line (default; BOB_BRIEF_ON_QUIET declares it) or nothing.
--since <ISO> The window start for Done — wins over the previous briefing.
--now <ISO> The clock every window and date comparison uses (tests, replays).
--offline Read local files only — every gh source, rituals and rotations become offline gaps.
--snapshots <dir> The fleet snapshot directory (default $BOB_FLEET_SNAPSHOTS, else $BOB_HOME/fleet/snapshots).
--ticks <dir> This machine’s tick digests (default $BOB_HOME/fleet/ticks).
--briefings <dir> Where previous briefings are read from and stored (default $BOB_BRIEFINGS, else this machine’s declared bob_workload.brief.dir, else $BOB_HOME/briefings).
--propose Ask the decision seam for each brief’s next action — see Proposing. Report-only; gathers no briefing.
--apply With --propose, record each proposal or abstention on its brief issue.
--issue <NN> With --propose, one brief instead of every open one.
--limit <N> With --propose, cap how many briefs are asked about.
--backend <name> With --propose, override the decision backend (jev / deterministic / off).
--area <label> One area page — see Area pages. --area all walks every area:* label. A brief issue is filed only for an area that carries a decision; with --scheduled the pass writes as --apply does.
--project <name> One project brief — see Project briefs. --project all walks every registered project; an unknown name exits 2 with the known ones. With --scheduled it runs after the portfolio briefing is delivered.
--reconcile Tick every brief action whose named issues have all closed, and close the finished briefs — see Reconcile. Report-only; --apply writes. Exit 3 when the forge answers nothing.
--publish Compare the generated pages with the declared serving target — see Publishing. Report-only; --apply publishes, --deploy also redeploys an unchanged set.

Exit codes: 0 built and every source read in full · 2 usage error · 3 built with at least one gaps[] entry. Exit 3 still prints the complete document — a gap is a fact about one source, never a failed briefing. With --send the exit code is bob notify’s (0 delivered · 3 no bridge, the message printed · 4 transport error) and the gaps ride in the message and on stderr — see Telegram delivery.

schemas/briefing.schema.json, proved both ways under make check-schemas (section 13: the committed sample validates, the rejected fixture is refused). additionalProperties is refused at every level, so a renderer can rely on exactly these keys.

Key What it holds
schema briefing/1.
generated_at When the document was built — the clock every comparison used.
machine {id, label} of the building machine as machines.json names it, or null when the host is not in the registry.
window {since, until, basis, previous} — see The window.
sections needs_you, done, doing, will_do — each an array of items.
counts The four section lengths, so a headline never counts the arrays itself.
sources {<source name>: {status: ok | gap, items}} — what each source yielded.
gaps [{source, reason, detail}] — every source that could not be read or answered partially, one entry each.

Every item is {kind, project, title, link, machine, at, ref, detail}. kind is a closed set and each kind belongs to one section:

Section Kinds From
needs_you todo, pr-review, blocked-run, harness-defect, rotation-overdue, ritual-overdue, area-complete, backup-missed, ask-source open todos (a completed todo awaits the consume path, #779, and is not listed), non-draft open PRs, warp runs held in blocked / budget_exceeded (linked to the preflight todo), open harness defects (#1838, linked to the first issue), overdue credential rotations (#2223), overdue rituals (#2222), held area-completion briefings still brief:new or brief:acting — the line names what is in the way (#3169, #3804), declared D1 backups past their scheduled run, one line per missed run however many checkouts report it (#3086)
done pr-merged, req-closed, bug-closed, release, tick, area-shipped merged PRs, closed req and bug issues, releases, tick digests (#2054), area-completion briefings whose verdict is SHIP, filed brief:done (#3804) — within the window
doing warp-session, pr-draft active warp sessions on any machine (this machine live, every other from its snapshot), draft PRs
will_do plan-item, ritual-due, rotation-due the next 3 unchecked items of every open flight plan (#1068), rituals due or upcoming, rotations due or upcoming

project is the registered project name (projects.json), the repo name when the repo is not registered on the building machine, or null for a portfolio-wide item such as a rotation. link is a URL or null — never text pretending to be one; only sources that carry no URL (a remote session with no issue, a rotation with no runbook URL, a ritual never held) are unlinked, and the renderer prints such a line unlinked. machine is set where the item is machine-bound (a session, a tick, a blocked run); at is when it happened, started, was filed or is due; ref is owner/repo#N, a tag or a credential id; detail is one qualifier (a priority, a phase, a token count, the plan an item comes from).

The window applies to Done only. Needs you lists whatever is open, however old — a todo from June still needs the human.

The order is part of the contract, so the same inputs and clock give the same bytes: needs_you grouped by kind in the order above, oldest waiting first; done newest first; doing by kind then machine and project; will_do plan items in plan order (plans by repo and number), then rituals and rotations by due date. Ties break by machine, project, ref and title.

Thirteen sources, each read once and each failing alone. sources names them; gaps[] uses the same names.

Source Read Yields
gh:merged-prs gh search prs --merged --merged-at >=<since> over the registry’s owners pr-merged
gh:closed-reqs / gh:closed-bugs gh search issues --label req|bug --state closed --closed >=<since> req-closed, bug-closed
gh:open-todos gh search issues --label todo --state open todo
gh:open-prs gh search prs --state open pr-review (non-draft), pr-draft (draft)
gh:releases one gh api graphql query aliasing every registered repo’s newest 5 releases release
gh:flight-plans gh search issues --label flight-plan --state open (the body’s unchecked - [ ] #N lines) plan-item
gh:area-completions gh search issues --label brief --state open -- brief:area-complete (#3169) area-complete, area-shipped
snapshots every machine’s newest bob-fleet-snapshot/1 under the snapshot directory warp-session, blocked-run, harness-defect, backup-missed (a project whose backups.verdict is stale; unknown makes no line), a remote machine’s tick
"warp" this machine’s live warp-drive state per registered project, through scripts/fleet/project-live.js (the fleet feed’s reader) plus the issue the state file records on requirement warp-session, blocked-run
ticks this machine’s tick-digest/1 files tick
rituals ritual.js due --all --json (bob-rituals-due/1) ritual-overdue, ritual-due
rotations cred-check.js due --no-file --json (bob-rotation-due/1) rotation-overdue, rotation-due
ask-sources asks/sources.js --json (bob-asks-sources/1, bob asks sources) ask-source: one per filing path whose share of asks closed without completed exceeds _workflow.ask_moot_threshold (#3810). Its ref is the source name and it has no link

The gh calls go through the gh shim (bin/gh), so the forge cache serves them when the forge is down (#1749). Owners come from the projects registered on the building machine whose origin names a GitHub repo; a search row from a repo that is not registered here is still a project (its repo name). The subprocess readers accept a complete document over a non-zero exit code — cred-check.js due exits 3 on unknown with a full document, which is data, not a gap.

A source that is missing, unreachable, invalid or partial is one gaps[] entry {source, reason, detail}; the other sections are built regardless. reason is a closed enum:

Reason When
gh-missing / unauthenticated / rate-limited / unavailable The gh outcome, by the #1968 names — detail carries the stderr tail.
timeout The read overran its budget (20 s per gh call, 120 s per engine).
invalid The source answered, but not with its contract (non-JSON, a wrong schema, a wrong shape).
truncated A full search page (100 rows) — the rows are consumed and the section may be incomplete.
not-measured Nothing to read: no projects.json, no registered repo, no snapshot or tick directory, an engine script not beside brief.js.
stale A machine’s newest snapshot is older than the stale window (BOB_FLEET_STALE_SECONDS). Named snapshots:<machine>.
never A fleet machine has never published a snapshot. Named snapshots:<machine>; the building machine is exempt (its rows are live).
offline --offline skipped the read.
error The reader itself threw.

window.since is, in order: --since (basis: explicit); the generated_at of the newest briefing/1 file in the briefings directory (basis: previous-briefing, previous naming the file); else 24 h before generated_at (basis: default). window.until is generated_at. --write and --send store a briefing there under its clock name, so each sent briefing is the next one’s window start; the schedule and retention that keep the directory are declared — see Schedule and retention. .claude/briefings/ is declared machine-local in scripts/lib/derived-paths.json, so a briefing never lands in a repo and never counts as in-flight work at a checkpoint.

Where a briefing is read: on a phone, over the tailnet. The Project Monitor (the dashboard server bob dash serve runs, the one bob warp viz and bob viz open) serves three routes on the same port and bind as every other page — no port, listener or DNS of their own, so the tailnet-only posture (decision #1773, exposure posture) holds by construction:

Route What it serves
/briefings The page (scripts/dashboard/briefings-page.html): the latest briefing, or the one ?b=<file> names, and the index of past ones beside it.
/briefings.json bob-briefings/1: the index — every stored briefing/1 under the briefings directory, newest by its own generated_at (never by file name), each row a headline (file, generated_at, machine, window, the document’s counts, its gaps count); the newest carried inline as latest (latest_file names it) so one read paints the page; unreadable[] naming every file that is not a briefing with its reason; reason when the directory itself could not be read.
/briefings/<file> One stored briefing by its file name — a plain name in the directory, never a path: 400 on a path, 404 on a name that is not a briefing.
/bob/briefings.json bob-briefings/1 extended for bob viz’s Briefings pane (#2926): the index above plus areas[] — each open brief-labelled issue carrying a brief:area marker, with its number, title, area, url, page (the brief:page marker), page_url (the evidence block’s published link), status (its brief: label), regenerated date, age_days and evidence counts (open, approved, todos, prs, closed_since) — stale_after_days (7), generated_at, and sources.portfolio / sources["area-briefs"], each {status: ok | unavailable | not-measured, reason, fix, count}. It also carries projects[] (#3040) — each open project brief found by one gh search issues over the BoB checkout’s owner, deduplicated against the area read, with its project, repo, number, title, url, page, page_url, status, regenerated, age_days and evidence counts — and sources["project-briefs"] in the same shape. An unreadable forge is the #1968 gh reason with its fix, and the portfolio rows still list.

The directory is $BOB_BRIEFINGS, else this machine’s declared bob_workload.brief.dir, else $BOB_HOME/briefings — one resolution (scripts/brief/schedule.js) shared with bob brief, so a declared directory is the one the index lists. The server re-reads it at most once per DASH_BRIEFINGS_REFRESH_S (default 300), so a briefing written later appears within one interval. The schedule and retention that fill and trim it are declared; the page reads what is there, and says no briefings directory or no briefings stored rather than showing an empty page as if nothing ever happened. node scripts/dashboard/briefings.js [--json] [--dir DIR] prints the same index at the terminal.

scripts/dashboard/bob-briefings.js renders it (served as /bob/briefings.js, byte-identical to the tested file): Needs you first, then Done, Doing, Will do; each section head carries the document’s counts (never the array’s length — a missing count is not measured, never 0); a section longer than eight items shows its head and folds the rest under “N more”, so the top of every section is on screen before any tail; every item links to its issue, PR, run or release, and an item whose source has no URL says (no link) rather than printing text that looks like one; a blocked run, harness defect or overdue rotation/ritual reads in the bad colour and a live session in the active one, each also a word; every gap is named under the sections with its reason and detail beside a N of M sources read in full line. An order control in the briefing’s head (#3040) flips each section between as listed and by project — project name (case-folded) then date, newest first, a portfolio-wide item after every named project — and keeps the choice in the URL (?order=project); every item shows its project either way, or says portfolio. The index lists every stored briefing newest first, each linking to itself on the page with its counts and gaps, the one shown marked. Pure, like the Reports pane (#2073): no clock, no fetch, no DOM; every value from a document is escaped and a link that is not http(s) is not a link.

Open it with bob viz --page briefings (--tailnet prints the URL a phone opens); /bob and /briefings link each other in their headers. The page is held to the bob viz design checklist by tests/test-briefings-page.js — tokens for every size, a hairline only under a section head, colour only for a state, the page painting its own ground and ink, one column at phone width with every long line wrapping — and node scripts/dashboard/bob-viz-shots.js renders it at 400 wide from the committed fixture in both themes:

the briefing page at phone width, light theme

the briefing page at phone width, dark theme

The message is the prompt to look: short, with the counts that matter and one link. bob brief --send builds the briefing, stores it (a link must point at a stored document), composes at most five lines from it and posts them through bob notify (#2048) — one sender, so the bridge configuration, the secret handling and the degradation are bob notify’s, never a second copy.

Before the briefing, --send notices each missed backup not yet noticed (#3086): scripts/backup/notice.js --apply sends one bob notify per missed run found in the snapshots, deduplicated with bob tick’s in $BOB_HOME/.backup-notices.json, and says backup notices — N sent on stderr. The briefing itself lists the miss as a backup-missed line under Needs you. See Dev lifecycle.

Terminal window
bob brief --send # store, then post the headline and the page link
bob brief --send --on-quiet skip # a quiet briefing sends nothing
bob brief --send --url http://farm-01.tail.ts.net:6952 # the page base the link uses (default: resolved)
bob brief --write # store only — what --send does before it composes

The message, line by line — a view over the document, never prose of its own (composeMessage(doc, {link, linkReason, onQuiet}) in scripts/brief/brief.js, pure):

Line Content When
1 Needs you: N · <machine>, <clock> — the needs-you count first always
2 Done N · Doing N · Will do N [· N gaps] — the headline counts from the document’s counts, never the arrays always
3 Top: <title> (<project>, <detail>) — the first needs_you item, the title clipped to 120 characters something needs you
4 that item’s link it has one
5 the tailnet link to this briefing on the page, or page: <why there is none> always, last

Five lines is the contract: the composer refuses a longer message rather than truncating one. When nothing needs the human the Top lines are absent (three lines); when an item is unlinked its link line is absent.

Quiet. A briefing with an empty window and nothing needing the human — counts.needs_you and counts.done both 0 — sends one line: Quiet: nothing needs you, nothing done since <window start>, followed by what is still in flight (Doing N, Will do N), the gap count and the page link. Under --on-quiet skip (or BOB_BRIEF_ON_QUIET=skip, the declared default a schedule can set; the flag wins) a quiet briefing sends nothing: the document is still stored, stderr says quiet — nothing sent (on-quiet skip), exit 0. --on-quiet message is the default.

The link is http://<tailnet host>:<dashboard port>/briefings?b=<file> — the ?b= the page’s own index uses (#2244), so the message opens the exact briefing it summarises, not whichever is newest by the time it is read. The host is this machine’s Tailscale address the way bob viz --page briefings --tailnet binds it (scripts/dashboard/bind.js: the MagicDNS name, else the IP — never a guess, never a wider bind); the port is the dashboard slot of the project whose monitor serves the page — the nearest .git/.claude ancestor of the working directory, else the BoB source checkout, exactly how bob viz chooses — read from the port ledger, never allocated (a briefing does not write the ledger). --url http://host:port stands in for both. A link that cannot be resolved (Tailscale stopped, no ledger band, no project) is sent as its reason on the last line (page: tailnet address: …) — never text pretending to be a URL — and named on stderr.

Delivery is bob notify <message> --level warn|info --session-id bob-brief (scripts/rdb/notify.sh beside the engine): level warn when something needs you, info otherwise; the bridge’s project label is whatever bob notify resolves. With --send the exit code is bob notify’s, because a scheduled sender needs the delivery verdict: 0 delivered (or quiet and skipped) · 3 no bridge configured — bob notify prints the message instead, so it is never lost · 4 transport error (curl failed or a non-2xx answer) · 2 usage. The gaps that would have made a plain bob brief exit 3 ride in the message (· N gaps) and on stderr (bob brief: N gaps — gh:open-todos rate-limited, …); the briefing is still complete and stored. Without --json, bob notify’s output is stdout — the delivered line, or the printed message when there is no bridge; with --json the document is stdout and every delivery line is stderr. The secret is never printed by either.

Storing (--write, implied by --send). The document lands in the briefings directory ($BOB_BRIEFINGS, else the declared bob_workload.brief.dir, else $BOB_HOME/briefings) as <generated_at>.json with the milliseconds dropped and the colons replaced — 2026-09-16T06-00-00Z.json — so the directory sorts by clock and the name is one the page serves by name; it is written whole (a rename over a temp file), so neither the page nor the next run reads half a document. The path is reported on stderr (bob brief: wrote …), never as a key: briefing/1 refuses extra keys. The stored briefing is the next run’s window start. The schedule that runs --send, the quiet hours and the retention are declared — see Schedule and retention.

When and where briefings are made is declared once, in this machine’s machines.json entry (the operator repo), beside the farm tick’s schedule — bob_workload.brief, validated by schemas/machines.schema.json and read by scripts/brief/schedule.js. Never a hand-typed crontab:

"bob_workload": {
"source": "~/projects/bigbrain",
"bob_home_channel": "nightly",
"tick": { "schedule": "daily 02:30 America/Los_Angeles" },
"brief": { "schedule": "daily 07:00 America/Los_Angeles", "quiet_hours": "22:00-07:00", "retention_days": 90 }
}
Key Meaning
schedule daily HH:MM [Area/Zone] — the cadence the bob-brief timer fires on; no zone is the host’s local time. Required.
quiet_hours HH:MM-HH:MM, half-open, may wrap midnight, read in the schedule’s zone. A scheduled briefing (--scheduled: the timer, the end of a tick) is neither made nor stored inside it — not stored either, so the next briefing’s window still covers everything since the last one sent. On demand (bob brief without --scheduled) it does not apply.
retention_days After each write, stored briefings older than this are pruned — only briefing/1 documents past the cutoff, never a file that is not a briefing, never the newest (the next window starts from it). Absent in a brief block = 90; --retain-days wins.
dir Where they are stored, ~- or $HOME-relative, never machine-absolute (#1924). Absent = $BOB_HOME/briefings. $BOB_BRIEFINGS overrides it; /briefings reads the same resolution.
projects "all" — a scheduled briefing also writes every registered project’s brief (bob brief --project all --apply) after the portfolio briefing is made and delivered, on the same cadence and inside the same quiet hours (#3040). Its outcome goes to stderr and never changes the portfolio’s delivery or exit code; a quiet project is reported. Absent = portfolio only; bob brief --send --scheduled --project all asks for it on one run.

Every reader fails open. A machine with no machines.json entry, no bob_workload or no brief block is undeclared — no schedule, no quiet hours, no retention — and bob brief works there on demand exactly as before: the portfolio view comes from the forge and the fleet snapshots every machine publishes (#415), so a laptop briefs without farm-01. A brief block that is present but invalid is named on stderr; a --scheduled run then refuses (exit 2) so a broken schedule is noticed, while an on-demand run still briefs.

The timer. bob brief --install-timer renders the declaration through the shared unit renderer (templates/systemd/render.js) into two systemd user units under ~/.config/systemd/user/ and enables the timer (scripts/brief/brief-timer.js, Linux only, idempotent — a unit already on disk is unchanged):

Unit From the declaration
bob-brief.timer OnCalendar=*-*-* 07:00:00 America/Los_Angeles from schedule; Persistent=true, so a briefing missed while the machine was off fires on the next boot.
bob-brief.service ExecStart=%h/.claude/bin/bob brief --send --scheduled — the deployed bob, in bob_workload.source; exit 3 (no bridge, the message printed) counts as success. The header comment names the quiet hours and retention it runs under.
Terminal window
bob brief --install-timer --dry-run # the units the declaration renders — writes nothing
bob brief --install-timer # install + enable (idempotent)
bob brief --timer-status # declared vs installed: absent / current / differs, timer state, linger
bob brief --uninstall-timer # disable it and remove both units

A machine that declares no brief block (or an invalid one) is refused with exit 2 naming what is missing; macOS is refused — bob brief --send works on demand there. Exit 1 is a failed systemctl step.

The end of a tick. bob tick makes a briefing after its digest through the same path — bob brief --send --scheduled — so the declared quiet hours apply to both. It is never a tick step: the outcome lands in the digest’s briefing and a briefing that could not be sent never fails the tick (Farm tick).

Proposing the next action (--propose, #2818)

Section titled “Proposing the next action (--propose, #2818)”

A brief arrives with a recommendation rather than a blank page. For each brief-labelled issue, bob brief --propose asks one question — of the next actions this brief lists, which should be taken first, or is none of them the right move? — and records the answer on the issue.

Terminal window
bob brief --propose # every open brief, report only
bob brief --propose --issue 2803 # one brief
bob brief --propose --apply # record the answers on the issues

Three rules govern it, and each is a refusal rather than a feature:

It goes through the seam. The call is scripts/lib/decide.js (#2817) as caller brief-next-action, which declares in decide-callers.json the six state fields that may leave the machine — title, summary, priority, age, state and the action texts. The page, the comments and the rest of the body stay here, and the seam refuses the call outright if that ever stops being true. There is no jev.evaluate() in scripts/brief/; the backend is configuration. See the decide() seam.

It is fail-visible. brief-next-action declares fail: 'visible', so an undecided answer is not silence. The block reads No proposal — no-key, in exactly the place a proposal would have been, and the index renders it with the reason. A judge that abstains quietly reads as a judge that approved.

It never ticks and never transitions. #2789’s model is that the human’s tick is the state transition. Confidence is recorded as its route — act, confirm or human — so the human can see how firmly the proposal is held, and that is all it does. A machine that set brief:acting because it felt confident would make its opinion indistinguishable from the human’s decision.

The proposal is one marker-delimited block in the issue body, replaced in place on every run: it sits directly above the tick list it is about, it is idempotent by construction, and the index projects it with no second forge call per brief. Its audit trail is the issue’s own edit history.

<!-- brief:proposal -->
## Proposed next action
**1. Back-fill the session ledger from transcripts before grading anything**
Confidence 0.84 → **act** (held firmly).
_Proposed by `jev` (jev-1.13.0) at 2026-09-21T02:00:00Z. A proposal never ticks a
box and never moves the state label — the human's tick is the transition (#2818)._
<!-- /brief:proposal -->

Exit codes: 0 ran — a proposal, a none, and an abstention are all outcomes · 2 usage error · 3 the forge could not be read.

tests/test-brief-propose.js (60 assertions) runs with no key and no network: the deterministic backend answers and gh is a stub. It pins that only the declared state is built (the seam is the judge, refusing the same caller with one extra field), that an action keeps the number of its line so ticking one never renumbers the rest, that the block is idempotent and ticks nothing, that an abstention is written rather than swallowed, and that nothing is written without --apply.

--area is BoB’s first generated producer: one page per area:* label, saying what is open in the area, what is stuck, and what the next move is. The union of those pages is the portfolio’s next moves.

An area page is a view, not a brief. It is a report, and a report belongs on a page. On 2026-09-21 --area all filed one issue per area label: 48 of them, 84% of the briefings pile, each restating the tracker’s counts and asking for no decision. #3098 closed all 48. So --area now renders the page and nothing else, unless the area carries a decision (below).

Terminal window
bob brief --area area:fleet # report only — nothing written
bob brief --area all --apply # every area's page; a brief issue only where a decision is
bob brief --area area:fleet --json # the area-brief/1 evidence packet alone
bob brief --scheduled --area all # the same as --apply, inside the declared hours

The terminal says one line and a URL per area, then a totals line:

area:briefings 12 open · page only — no decision — https://…/area-briefings.html
area:fleet 9 open · filed #3140 for 2 decisions — https://…/area-fleet.html
area:ci 4 open · area:ci no longer needs a decision — reconcile will close it (#3001) — https://…/area-ci.html
3 areas · 0 empty · 3 pages · 1 filed · 0 updated · 1 no longer need a decision

An area gets a brief issue only when its packet holds at least one decision item: an item the tracker cannot resolve by itself. decisionItems() in scripts/brief/area.js is the one place this is defined. An item is listed once, under the first rule it matches:

Kind Fires when
needs-clarification an open item carries needs-clarification
blocked an open item carries blocked
todo an open todo without completed
untyped an open issue with no cap / req / bug / task / todo (or business-case / use-case) label, excluding the brief itself
cap-no-approved-children a cap open longer than the staleness window (DECISION_STALE_DAYS, 30 days) with no approved req, bug or task in the area naming it Part of #N

When an area files, its issue’s ## Next action list names exactly those items, and each line starts with the item’s number (#14 is a human todo not yet completed — …), so bob brief --reconcile (#3100) can close the brief once they resolve. The issue takes the highest priority among its decisions. The area page lists the same items in its Decisions section. If there are none, the page says that is why the area has no issue.

No re-filing. An area with an open brief issue gets that issue updated: the evidence block is rewritten and the decisions are merged in (see below). A second issue is never filed. A closed brief does not stand in: if an area needs a decision again after its brief closed, a new brief is filed.

A brief whose decision is gone is reported, never closed. When an area still has an open brief issue but no decision item, the run writes nothing and prints area:<slug> no longer needs a decision — reconcile will close it. Closing is --reconcile’s job. This command never closes an issue.

The collection is mechanical; the judgement is derived. Every number on an area page is a count of issues the forge returned, and every candidate next move names the evidence that put it there. Nothing is narrated. If a candidate is wrong, it is a rule you can point at, which is the only kind of wrong worth having on a planning surface. The candidates are on the page; they are not tick items. The rules, in the order they are considered:

Rule Fires when The move
unreadable source any gh call failed Re-run with a working forge — the counts are lower bounds
human todo an open todo carries the label Clear it; nothing automated moves past a todo
hot bug an open bug at p1/p2 Fix it
drain req/bug/task + approved Drain them — what /warp-drive picks up
decompose cap + approved Break it into requirements — warp-drive never picks up a capability
land an open PR carries the label Land it — finished work not yet counted
groom req/bug/task without approved Groom it — invisible to /warp-drive until then
approve cap without approved Approve or reject it — nothing beneath it can be groomed
triage an open item with no work-type label Give it one — no tool picks it up and no report counts it
stuck open ≥ 90 days Re-decide it: three months untouched is a decision never made
no rule matched open work, nothing above fired Say so — an empty list would read as “nothing to do”
empty nothing open, nothing closed, no gap Decide whether the area is finished or dormant

The area’s priority is the highest priority of the work open in it. It is derived, never a default. An area with nothing open carries none.

An empty area is stated, never skipped. --area all prints each one, and it gets no page and no issue.

Regenerating never destroys a human’s work. On an area brief issue, the evidence block is rewritten in place and the ## Next action list is merged: every existing line keeps its tick, new decisions are appended, and a line the evidence no longer supports is reported rather than deleted out from under whoever was about to act on it.

An area brief issue carries brief + brief:new + its area label + the priority of its decisions, and one <!-- brief:area area:x --> marker so it can never be matched by guessing at a title. Its page is area-<slug>.html, and #2818’s proposer runs over it unchanged. In the index, area pages are listed apart from the briefs, in their own Areas section: a link per area with its generated date (the page’s <meta name="generated"> clock), and no state or priority. An area page never counts toward the open-brief count; an area brief issue is a brief row like any other.

Exit codes: 0 collected · 2 usage error · 3 the forge could not be read.

tests/test-brief-area.js (67 assertions) runs against a stubbed gh: the packet and its counts, an unreadable source recorded as a gap rather than a silent zero, each candidate rule against evidence built to trigger it, the page’s purity and its handling of a hostile title and a javascript: URL, the merge that keeps a tick and reports what is unsupported, and report-only until --apply. tests/test-brief-area-views.js (52 assertions) covers the view model: page-only rendering for a no-decision area, filing for each decision-item kind with exactly those items as the Next action list, no re-filing over an open area brief, a stale brief reported and never closed, the index’s Areas section, and a scheduled area pass against a fake gh on PATH.

The portfolio briefing spans every project in one document; an area brief spans one label in the BoB repo. A project brief is the per-project projection: one brief per registered project, over that project’s own repo, on exactly the area brief’s model — the same collection, candidate rules, page, merge and brief-issue model, because it is the same generator (scripts/brief/area.js) over a project scope (scripts/brief/project.js).

Terminal window
bob brief --project flows # report only
bob brief --project all # every registered project
bob brief --project all --apply # file or update each project's brief issue
bob brief --project flows --json # the project-brief/1 run document

Which projects. The registered projects on this machine — projects.json, each resolved to its GitHub origin. Names match case-insensitively; an unknown name exits 2 and lists every known one. A project with no GitHub origin is reported as having nothing to brief from, never guessed at.

What is read. Every gh call names the project’s repo (--repo owner/name). Open and closed issues are kept only when they are work — cap, req, bug, task, todo, business-case, use-case — because a whole repo’s untyped issues are its chunk reports, session summaries and briefs, not its work. On top of the area sources a project brief reads its open flight plans, its releases in the window and this machine’s warp run on it; an unreadable source is a named gap, a project not checked out here says not measured, and a run at blocked or budget_exceeded becomes the first candidate move.

Where it lives. The brief issue is filed in the project’s repo: brief + brief:new + the derived priority, no area label (the repo is its scope), titled Project: <name>, claiming its project with <!-- brief:project <name> --> and its page with <!-- brief:page project-<name>.html -->. The page is written beside the other briefs as www/bob/briefings/project-<name>.html — flat, like area-<slug>.html, so the index lists it and --publish carries it without a second path. Each project repo needs the brief and brief-state labels; scripts/bootstrap-labels.sh already provisions them, so bob init / bob ready do.

A quiet project is reported, never skipped silently. A project with nothing opened, closed, released or in review in the window is printed as quiet and no brief is written — the same rule as an empty area, with “nothing new” as the test because a project always has open work.

On the schedule. bob brief --send --scheduled --project all, or a declared bob_workload.brief.projects: "all" (see Schedule and retention), runs the project briefs after the portfolio briefing is delivered: the portfolio’s stdout, delivery and exit code are exactly what they were, and the project report goes to stderr.

In the index. bob brief --index gives every row a Project: portfolio, area:<slug> or the project’s name — read from the issue’s brief:area / brief:project marker, or, for a page with no issue, from its generated name (area-<slug>.html, project-<name>.html). The column sorts client-side like Date, Pri and State, and a Project filter above the table (shown when more than one project has a brief) narrows it to one project’s briefs, counting what it shows; the choice rides in the fragment (#project=flows) so a link carries it. tests/test-brief-index.js runs the page’s own script against a stub DOM for both.

In bob viz. The Briefings pane lists project briefs beside portfolio briefings and area briefs, each with its repo#number, state, needs-you count (the brief’s todos) and links; it sorts by project, and the selected project’s own brief lists first — see bob viz → Briefings.

Exit codes: 0 ran · 2 unknown project or no projects.json · 3 the forge could not be read.

tests/test-brief-project.js runs against a stubbed gh: resolution and the unknown-name list, --repo on every read, reports excluded from the counts, the plans / releases / warp sources and their gaps, the issue filed in the project repo with the right labels and markers, an existing brief found and merged without losing a tick, quiet and origin-less projects reported, and the projects declaration.

bob brief --index reads the brief issues of every registered, active project that has a GitHub origin, not only the repo it runs in. Each brief stays in its own project’s repo, and the index reads it there.

Which projects. The registry that bob brief --project uses (#3040): projects.json in the operator repo (#3432), derived from the operator’s manifests by scripts/fleet/projects-derive.js (#2066) and read through registeredProjects() in scripts/brief/brief.js, never from a list in the code. A project marked active: false is skipped. The slug is the checkout’s origin, and two registrations of one repo are read once. An active project that is not checked out on this machine, or has no GitHub origin, cannot be read. It is named in a note under the table, not dropped.

How it reads. One gh issue list --repo <slug> --label brief --state all per project. Six run at a time, each with the gh timeout (20 s), so the whole registry (about 40 repos) reads in a few seconds, well inside a bob tick step. A repo with no brief issues answers [] and contributes nothing. Without a projects.json, the index falls back to reading the current repo alone.

Which project a row belongs to. A brief’s brief:area / brief:project marker wins. A brief without one belongs to the project whose repo it was read from, except in the repo the index is generated in: its unmarked briefs are portfolio. A brief from another repo shows the repo in its Issue link (bodmail#592), since #592 alone is ambiguous.

Area pages are views (#3101). Every area-<slug>.html is listed in its own Areas section below the briefs: a link per area with its generated date, and no state or priority. An area page no issue claims is not a brief row, so it never counts as open and never as a page with no issue. --index --json reports them as areas. An area brief issue, filed for a decision, stays a brief row.

Open first. Open briefs are those whose state is not marked terminal in scripts/brief/states.json (brief:new, brief:acting, or a page with no issue yet). They form the main table. Done and parked briefs are folded into one collapsed group headed with their count (55 closed briefs). Each table sorts on its own, so a sort cannot pull a closed brief into the open list. The Project filter narrows both tables and updates the fold’s count, but it never opens the fold: only the reader does.

Gaps are rows. A project whose issues could not be read is a row at the top of the open table, in the form example-awards: not read — forge <state>. The state is the classified gh outcome: unavailable, timeout, rate-limited, unauthenticated, gh-missing or invalid. The byline and a stat tile report N of M projects read. --json carries the same read as projects: { total, read, gaps[], no_origin[] }, and each gap is also printed to stderr.

tests/test-brief-index-portfolio.js covers this against a stubbed gh: the multi-repo read with --repo on every call, the project label on each row, open-first ordering with the closed fold and its count, the gap row for an unreadable repo, the empty repo, the inactive and origin-less projects, and --index end to end over a registry fixture.

The brief pages have two homes. They are generated into www/bob/briefings/ in the operator repo (#4263), found through operatorPath('brief-pages') in scripts/lib/operator-resolve.js like every other piece of operator data, by --html, --index, --area, --project and --completions, whatever directory the command runs from (#3154, #3278). --publish publishes that same directory. The pages name the operator’s projects and clients, so the BoB source checkout is never their home: run from any project, BoB’s own included, none of them writes a www/ into it. With no operator repo resolved, nothing is written: --html, --index and --publish exit 2 naming the fix (bob operator init <dir> --link), and --area, --project, --completions and --reconcile write no page and say so. --html-dir <dir> still names any other directory explicitly.

No dead links (#3568). The index is built from the forge and the bundle from one machine’s working tree, so the two can disagree. A brief issue whose brief:page names a file that is not in the directory is still listed, with its title unlinked, a page missing tag and a stat tile that counts it. --publish checks every relative link in index.html against the bundle and names each missing one (missing: <page>). A report-only run exits 3; --apply publishes nothing and exits 3. They are served from the operator’s site. bob brief --publish is a producer for the one site publisher (#3103): it builds a site-publish/1 bundle and hands it to bob site publish. The bundle holds the generated pages (top level, dotfiles excluded), the brief section, the producer bob brief and this checkout’s commit. The publisher owns the target, the Access gate on every served host (#2895), the Cloudflare account (#2984), the commit, the push and the deploy. The whole contract is in the Site Publish reference.

The brief section is the one the operator’s config/site-publish.json declares with "producer": "bob brief", else its only section, else bob/briefings. For this operator it is paulirv/iwpi-static → public/bob/briefings, behind Access. Until 2026-12-31 the older config/brief-publish.json is read as a fallback and mapped onto that one section, with a warning.

bob brief --publish reports without writing. --apply publishes and --apply --deploy redeploys an unchanged set. The document is the site publisher’s site-publish/1, and so are the exit codes: 0 reported / published / unchanged · 2 usage · 3 undeclared, invalid, refused or a git failure · 4 the deploy failed after the push.

--html, --index and --area end their one-line output with published (<repo>@<branch>) or generated, not published — <why>. The check is offline: it compares against the checkout’s last fetch.

Nothing used to close a brief when its work was done. When #3098 groomed the pile, six of its seven narrative briefs were already resolved, but nobody had moved their labels. A brief’s next actions name issues, so whether an action is resolved can be read from the tracker. bob brief --reconcile does that for every open brief issue across the portfolio, reading each project’s repo the way the index does.

Turning it off (#3978). _workflow.brief_reconcile: false turns off bob brief --reconcile and bob tick step 9b for the project. Each then reports verdict off and reads nothing. Reconcile and the completions run as background callers of the forge, so at the gh shim’s per-minute ceiling they wait and interactive work goes first.

What counts as resolved. Reconcile reads the ## Next action checklist of each open brief. An unticked item that names one or more issues is resolved when every issue it names is closed. A merged or closed PR counts as closed. Three forms are recognised:

  • #NN, an issue in the brief’s own repo
  • owner/repo#NN, an issue anywhere
  • a github.com/<owner>/<repo>/issues/NN or /pull/NN URL

A todo it names counts only when it carries completed, which is the TODO lifecycle rule. A todo closed without completed was cancelled, not done. An item that names no issue is never ticked; it is reported as needs judgment. A checkbox under any other heading is not an action. Every named issue is resolved in batched gh api graphql calls. An issue the forge does not know is not found and never resolved.

Report, then apply. Without --apply it prints one line per open brief and changes nothing:

#3098: 3/5 actions resolved → would move brief:new → brief:acting; 2 waiting on open issues
example-co/example-cms#1428: 0/6 actions resolved → 6 waiting on open issues
5 open briefs; 1 would change — report only, pass --apply to write

--apply does four things:

  1. Ticks. It re-reads the body, ticks each resolved line and writes the body back with --body-file, never with an inline sed. A line is ticked only if it is still [ ] and its text is unchanged, so an edit made since the read is never mis-ticked.
  2. Comments. It adds one comment per brief naming each tick and the closed issue that justified it.
  3. Moves the state. Every move goes through the transition table in scripts/brief/states.json, so there is no second definition of the states:
    • When every action is resolved (ticked by hand or now), the brief moves to brief:done and is closed with a comment giving the evidence. An open brief already labelled brief:done is only closed.
    • When some actions are resolved, a brief:new brief moves to brief:acting.
    • A move the table does not allow is reported (stays brief:parked (states.json has no brief:parked → brief:done)) and not made. Reconcile never moves a terminal brief (parked, done) back to acting. A parked brief still gets its ticks.
    • An unlabelled brief is read as brief:new. A brief with no next actions never moves.
    • If a tick fails to land, the brief is not moved or closed.
  4. Rebuilds and publishes the index. After any --apply that changed a brief, the index is regenerated (the same rebuildIndex as --index) and published through the declared publish path. When nothing changed, nothing is published, and it prints index: nothing changed — index not regenerated, nothing published. The index is written to www/bob/briefings/ in the operator repo (#4263) whatever directory the command runs from, because it lists every project’s briefs (#3154); --index resolves it the same way. With no operator repo and no --html-dir, it regenerates and publishes nothing and says so.

A dead forge changes nothing. If the brief lists cannot be read, or the issues they name cannot be resolved, it prints reconcile: skipped (forge <state>), exits 3 and writes nothing, because a half-read tracker must never close a brief. A single project repo that cannot be read is named in the closing line and its briefs are left alone.

--json emits brief-reconcile/1:

  • verdict: ok or skipped
  • applied, forge, changed
  • projects: {total, read, gaps}
  • briefs[], one per open brief:
    • issue, repo, ref, state, total, resolved, would_tick, needs_judgment
    • actions[], each with its refs[] (ref, state, todo, completed, resolved, why) and resolution (ticked · resolved · open · needs-judgment)
    • transition: {from, to, close, allowed, reason} or null
    • applied: {ticked, commented, labelled, closed}
    • error
  • index: {regenerated, file, published, note} under --apply
  • error: {code, state, message} when skipped

Who calls it. /groom no longer runs it (#4395): grooming never touches briefs. bob tick runs bob brief --reconcile --apply --json once per cycle, before the briefing. The result lands in the digest as brief_reconcile and as a “Briefs reconciled” row. It is never a pipeline step: a skip (exit 3) never fails or strikes a tick. A dry-run tick runs it report-only.

tests/test-brief-reconcile.js covers the parser (single and multiple refs, cross-repo refs and URLs, a todo with and without completed, an unreferenced item), the report and apply paths against a gh stub, the all-resolved close, the parked brief the table will not move, a failed tick blocking the close, the index being rebuilt and published only after a change, and the forge-down exit 3.

An area page is a view of an area at any moment. An area-completion briefing is written once, when the area’s work runs out. It answers one question from one page: is this ready to ship to the laptops or to clients? Nobody has to ask for it. scripts/brief/completion.js detects and collects; scripts/brief/completion-render.js writes the narrative, the page and the issue body.

One run reads the merged PRs and the CI runs once per project, however many areas it briefs: the PR list from the earliest area’s start date, filtered per area (#3978). _workflow.area_completion_briefs: false turns the completions off, whether they come from a warp run, bob brief --completions or bob brief --reconcile.

When an area is complete. Decided in one place, detect():

  • no open req, bug or task in the area carries approved; and
  • at least one req, bug or task in it closed after its last completion briefing. An area that never had one counts only closures in the last 30 days, so the first run does not brief every area that finished long ago.

proposed, deferred and unapproved items never hold an area open; the briefing lists them as what is left. A closure labelled wontfix, duplicate, invalid or not-planned is listed as dropped, not shipped. A todo is neither work nor remainder.

One briefing per completion. The issue claims its completion with <!-- brief:area-complete area:<slug> v<N> through=<ISO> -->, where through is the newest closure it covers. The next detection counts only closures after it, so re-running files nothing. An area that reopens and drains again gets v<N+1>. The marker is re-read just before the write, so two runs racing each other (a tick and a warp boundary) still file one. When the brief issues, the open issues or the closed issues of a project cannot be read, no area in it is decided.

Where detection runs.

Trigger What runs
A warp run’s item boundary (promoted / promote_skipped) and its entry to session_ending bob brief --completions --project . --apply, started detached by the engine; the run never waits for it. Its output is .claude/.area-completions.log. _workflow.area_completion_briefs: false turns it off (Workflow configuration); BOB_AREA_COMPLETIONS=0 turns it off for one process.
bob tick Through bob brief --reconcile --apply, across every registered project. The tick digest’s briefs line reports how many were filed.
bob brief --reconcile The same detection; the outcome is completions on the brief-reconcile/1 document. It never changes reconcile’s verdict or exit.
bob brief --completions On demand; report only without --apply. --project <name>|.|all (default all), --area area:<slug>.

Every project. Detection reads each registered project (projects.json) with a GitHub origin: three gh reads per project whatever the number of areas, plus, for a complete area, its merged PRs and default-branch CI runs, for a project with a held area its open flight plans (one read), and git log / git tag in the project’s checkout when this machine has one. The issue is filed in the project’s own repo, title Area complete: <slug> (v<N>) — <project>, with the labels its verdict earns (News or an ask). It carries the brief:page marker and never the brief:area or brief:project view markers, so the area and project generators never adopt it. The page is www/bob/briefings/project-<name>-area-<slug>.html in the operator repo (#4263).

Content. Built from evidence, in this order on the page:

Section From
Release readiness the first nightly and stable tags that contain the newest commit behind the area; a ship / hold verdict by stated rules; upgrade steps from the changed surfaces
What shipped each closed item with the merged PR whose text says Fixes #N, the commits whose subject names #N, and its acceptance criteria ticked out of total
What changed for the operator and for end users (narrative), and the changed files by surface: commands, hooks, schema, config, migrations, skills, docs, tests, scripts
Tests and CI completed runs on the default branch inside the area’s window; a workflow is red only when its newest run failed; quarantined bugs
Risks, gaps and rollback narrative over the evidence, including every source that was not read
What is left open items in the area that are not approved, with why (proposed, deferred, follow-up bug)
Decisions needed for a HOLD: the release decision first, then any red run and each item left — the issue’s tickable ## Next action list. A SHIP has none

The verdict is hold when CI is red on the default branch, when the work is not on a stable tag (or no commit names it, so no tag can be shown), or when an open bug is left in the area; otherwise ship. Upgrade steps come from a table in upgradeSteps(): for BoB, bob update, a session restart when hooks changed, bob ready when templates or manifests changed, and make check in the operator repo when a schema changed; for any other project, its promotion path; for either, a migration step after a backup when a migration shipped.

The verdict decides what the briefing is.

Verdict Filed with What it is
SHIP brief, brief:done, the area. No priority. News. It is listed under Done in the portfolio briefing for the window it was filed in (kind area-shipped). bob asks does not list it, it has no ## Next action tick list, and no bob notify line is sent for it.
HOLD brief, brief:new, the area, and a priority An ask. The first line of the issue body is **Held: <what is in the way>.**, and the portfolio briefing lists it under Needs you as Area <slug> is held: <what is in the way>.

A HOLD’s priority is p1-critical when an open flight-plan issue in the project lists an item (ticked or not) that carries the area’s label: a plan is waiting on the held area. Otherwise it is p3-medium. The packet records the answer as release.priority and the plans as release.waiting_plans. When the flight plans cannot be read, the priority is p3-medium and the read is a named gap.

Briefings filed before this rule. Every run of bob brief --completions also looks for open SHIP briefings still filed as asks: open, carrying a completion marker and **Release: SHIP**, and labelled brief:new or with no state label. With --apply each one is moved to brief:done and loses its priority label, with one gh issue edit. Each is reported: a line <project> <area> v<N>: SHIP already filed (#N) — re-labelled brief:new + p2-high → brief:done (would re-label without --apply), a relabelled row per project on the area-completion-run/1 document, and counts.relabelled / counts.to_relabel. A HOLD, and a briefing a human moved to brief:acting or brief:parked, is left alone. A second run changes nothing.

The narrative. A model writes it over the evidence packet and nothing else: headless claude -p with no tools, handed the packet, the writing styleguide rules and the verdict it must not contradict. It answers one JSON object; an answer that leaves out a section, or whose shipped prose links no item, is refused. BOB_BRIEF_NARRATOR names a different command, and BOB_BRIEF_NARRATE=0 turns it off. A narrator that cannot run is a named narrator gap: the page says No narrative where the prose would be, and still carries every piece of evidence.

Delivery. After filing, the run rebuilds the index and publishes the pages once through bob site publish, whose Access gate decides; a refusal is recorded on the result, never thrown. Each HOLD is one bob notify line that names what is in the way, with its page link (or the issue link when the page did not publish). A briefing appears on the /briefings index (it is a brief issue claiming a page) and on the bob viz Briefings pane (kind completion, with the verdict and brief state as its status). A HOLD is under Needs you in the next portfolio briefing until its state is brief:done or brief:parked; a SHIP is under Done.

Unattended. Nothing here prompts. A source it cannot read is a named gap and the briefing is still produced; a failure to file or publish is recorded per completion. From a warp run it is detached, so it never blocks the run.

Documents. completion.js --json prints area-completion-scan/1 (each project’s areas with their state, plus an area-completion/1 evidence packet per new completion); --html-dir DIR previews each page without filing. bob brief --completions --json prints area-completion-run/1: per project, what each completion did (filed, issue, url, verdict, narrated, gaps, error), plus published, index and notified.

A brief is one GitHub Issue with the brief label, exactly one state label (brief:new, brief:acting, brief:done or brief:parked) and exactly one priority label (p1-critical..p4-low). scripts/brief/states.json declares that vocabulary and its transition table once, and scripts/bootstrap-labels.sh copies it into every repo. make check-brief-labels (scripts/checks/check-brief-labels.js) checks three things:

  • The copy. A label that is missing from the bootstrap script, an extra one, a colour or description that differs, a transition to an undeclared state, or a vocabulary with no terminal state.
  • The phases. A brief is an ask, so each state names the phase of the ask lifecycle it is: brief:new is queued, brief:acting is presented, brief:done is done and brief:parked is declined. A state with no phase, or one scripts/asks/machine.json doesn’t declare, is no-phase. A brief transition whose two phases the machine joins by no event is unmapped-transition.
  • The issues. Every brief issue must carry exactly one state and one priority. Zero or two of either is state-count / priority-count drift, and a brief:* label the vocabulary doesn’t declare is undeclared-state.

The issue check reads the tracker, not the checkout. With $CI set, no gh, or a forge that can’t be read, it is reported as skipped with the reason and never fails the run. --issues-file <json> reads a recorded issue list instead, and --no-issues turns the check off. --states-file <json> checks another vocabulary file. Exit codes: 0 clean · 1 drift · 2 usage error.

buildBriefing(inputs) in scripts/brief/brief.js is pure — sources and a clock in, briefing/1 out — and collect(opts) does the reading with every reader injectable (gh, subprocess, projects, registry, machine). make test-brief runs tests/test-brief.js: the schema both ways; every source landing in its section linked; the window exclusions; the order under shuffled source rows; the CLI under a temp BOB_HOME (offline gaps, the three window bases, exit 2 on usage); one named gap per failed source with the other sections intact; and the collector on recorded fixtures under tests/fixtures/brief/ — the six gh search answers and the releases GraphQL answer as gh printed them, a fleet snapshot, and the rituals-due and rotation-due documents as the engines printed them — validated against the schema and identical on a second run. A briefing runs to hundreds of KB, so the CLI sets process.exitCode and lets stdout drain rather than exiting after the write (which truncates a piped document); the test pins that by source. The Telegram message (#2245) is proved on fixture documents — the committed sample composes to its five exact lines, the counts are the document’s, a long title is clipped, nothing-needs-you drops the Top lines, a quiet document is one line or nothing under skip, an unresolvable link is its reason — and --send under a temp BOB_HOME with a planted blocked-run snapshot: no bridge exits 3 with the message printed, a curl stub on PATH captures exactly the composed text at severity warning with the secret never printed, quiet sends one line at info or nothing under --on-quiet skip / BOB_BRIEF_ON_QUIET=skip with the briefing still stored, and a bad --url or --on-quiet is exit 2. No test sends a real message.

make test-brief-completion runs tests/test-brief-completion.js (#3169) end to end against a scratch project with tags and a fake gh and narrator on PATH: every detection state (open, complete, briefed, reopened as v2, quiet before the baseline, a proposed-only remainder), a source that cannot be read, the evidence packet, every page section, the narrator’s gap and quality bar, filing and idempotence through bob brief --completions --apply, a second project in its own repo, --reconcile, the warp engine’s detached start, the Briefings pane and the portfolio item.

make test-briefings-page runs tests/test-briefings-page.js (#2244): the loader’s order by clock, the inline latest, the named unreadable files, the by-name read that refuses a path; the renderer against the committed sample with a committed snapshot (tests/fixtures/bob-viz/briefings.html); the page stylesheet against the design checklist at phone width and in every theme; the tailnet posture (one listener, no address or port in any new file); and a real dashboard server serving every route.