Skip to content

feat(bin): reconcile diverged main and add grooming, review-page, and isolation tooling - #38

Merged
trillium merged 61 commits into
mainfrom
fm/main-reconcile-optA
Aug 2, 2026
Merged

feat(bin): reconcile diverged main and add grooming, review-page, and isolation tooling#38
trillium merged 61 commits into
mainfrom
fm/main-reconcile-optA

Conversation

@trillium

@trillium trillium commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Intent

Reconcile firstmate's diverged main branch (Option A: real merge, no reset/squash) preserving 22 local-only commits, 32 commits from a prior upstream-fork sync, and ~30 of origin's own merged PRs including a beads-authority migration; resolve conflicts by intent, keep both sides' functionality, verify no content silently dropped, full tests + lint green, open a PR against origin/main

What Changed

  • Adds a set of new firstmate tooling scripts under bin/: fm-groom (proactive ideas-store work generator), fm-review-page/fm-review-decision (render review-store items to visitable pages and route captain decisions), fm-idea-mine (mine chat history for uncaptured ideas), fm-isolated-launch (HOME-isolated claude sessions), fm-herdr-spur (herdr→firstmate completion bridge), and per-account Claude launchers with a new fm-spawn --account flag.
  • Introduces a beads dispatch lifecycle through spawn/brief hook extension points (fm-spawn-hooks.d/beads.sh, fm-brief.sh --beads), fixes --beads arg parsing and a double bead stamp in fm-spawn.sh, and expands fm-teardown.sh bead-close handling.
  • Merges the diverged origin/main history (Option A real merge preserving local-only, prior upstream-sync, and ~30 origin PR commits): semantic lifecycle supervision state, completion-runway/auth-preflight quota-aware dispatch, herdr focus-flash and workspace-placement fixes, Calm animation refinements, plus accompanying docs (docs/scripts.md, README, AGENTS.md, herdr-navigation skill) and extensive new/updated test suites.

Risk Assessment

⚠️ Medium: The novel code (3 fix commits + merge conflict resolutions across 22 files) is clean and correct on inspection, but this is a very large merge carrying acknowledged pre-existing test failures and hand-resolved conflicts whose full behavioral equivalence can't be exhaustively hand-verified, so it warrants the pipeline's test gate before merge.

Testing

Ran coverage guard and changed suites, all pass; one real-Herdr concurrency E2E flaky locally.

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

⏭️ **Rebase** - skipped

Push main to origin, or rebase your branch onto origin/main, before gating.

⚠️ **Review** - 1 info
  • ℹ️ bin/fm-teardown.sh:1682 - The merge commit (3121197) message states the branch was landed with 7 known-failing tests: 5 real-Herdr E2E flakiness, plus 2 asserted to be local-only tooling gaps (missing @earendil-works/pi-coding-agent npm package, and an installed tasks-axi 0.2.2 predating its public-followup subcommand). The claim that these will not manifest in CI is the author's assertion, not verified here. The pipeline's dedicated test step should confirm; if the CI environment has the same tasks-axi version or Herdr timing sensitivity, these may recur.
✅ **Test** - passed

✅ No issues found.

  • bin/fm-test-run.sh --check-coverage
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Summary by CodeRabbit

  • New Features
    • Added isolated Claude launches and separate account support.
    • Added automated idea mining, grooming, review pages, and review-decision workflows.
    • Added Herdr agent monitoring and workspace navigation guidance.
    • Added Beads lifecycle integration for task dispatch and completion tracking.
  • Bug Fixes
    • Improved Herdr cleanup safety, focus preservation, locking, rollback, and record retention.
    • Improved completion-aware dispatch decisions using runway and headroom signals.
  • Documentation
    • Expanded setup, configuration, backend behavior, dispatch, and tool documentation.
  • Tests
    • Added broad coverage for new workflows, integrations, and Herdr reliability improvements.

trillium and others added 30 commits July 27, 2026 19:32
fm-herdr-spur.sh: detached daemon that watches herdr agent status via the
native pane.agent_status_changed event stream (reusing herdr-eventwait.py) and,
on a working->idle/done edge for a tracked EXTERNAL agent, enqueues a check
wake into state/.wake-queue so fm-wake-drain surfaces it. Fills the gap where
parlay-spawned herdr agents have no firstmate status file or turn-end hook.
Poll fallback via herdr agent list when events incapable. Debounced, keyed by
agent name, configurable via --agent / config/herdr-spur.agents / all-agents.
Read-only against herdr; bash 3.2 safe.
Grooms ready ideas into dispatched work without the captain writing a brief.
Per idea: formulate a concrete runnable brief via PAI Inference (plain prompts
to dodge PromptGuard), classify safe (research/design/prototype) vs escalate
(merge/deploy/production), then dispatch safe via fm-spawn scout or file unsafe
to the review store.

Safety rails: OFF by default (dry-run unless FM_GROOM_ENABLED=1), rate-limited
(FM_GROOM_MAX_IN_FLIGHT), bounded per run (FM_GROOM_MAX_PER_RUN), idempotent
(groom:* state label), fail-safe classify (any error escalates). 7 hermetic
tests cover the rails; shellcheck-clean.
bin/fm-review-page.ts: for each open review item, write a self-contained,
phone-readable HTML page under ~/pulse-pages/review/<id>/ served by Pulse,
plus an index at the review root. --all sweeps every open item; positional
ids render specific ones. Own dependency-free markdown renderer (headings,
fenced code, lists, blockquote, links, bold/italic) in the GitHub-dark
house style matching existing pulse-pages. Wires each page URL back onto the
item (page_url metadata + a Page: note), idempotently. Read-only on item
content except that safe append; parameterized by env for scheduling.
tests/fm-review-page.test.sh: network-free suite stubbing the review CLI on a
fakebin PATH (read verbs emit canned JSON, write verbs log calls) and writing
pages to a temp FM_REVIEW_PAGE_OUT. Covers single-id render, --all sweep over
N items, idempotent re-render (no dup dirs, note skipped on unchanged url),
artifact link rendering (url/brain/branch), markdown body rendering, wire-back
assertions, empty-queue index, unknown id, and the no-args usage error.
shellcheck-clean under the canonical whole-set invocation.
…nderer

The inline renderer used a text placeholder to protect `code` spans across the
bold/italic/link passes; that placeholder corrupted into NUL bytes and leaked
bare CODE0/CODE1 tokens into rendered pages (visible on the live fm-groom item).
Replace it with a split-based renderer: split on the code-span capture group so
even indices are prose (escaped + emphasized) and odd indices are raw code
(escaped, wrapped in <code>). No placeholder token can survive to output.

Adds a regression test with a code-span-heavy body (adjacent spans, em-dashes,
a multi-span list line, parenthesized spans) asserting every span renders and no
CODE placeholder leaks.
The firstmate-side half of the interactive review loop. Given <id> <verdict>
[comment], it durably (a) enqueues a check-kind wake into state/.wake-queue via
the sanctioned fm_wake_append helper, keyed review-decision:<id>, so
fm-wake-drain surfaces it on the next supervision cycle; (b) annotates the item
via 'review note'; (c) appends a JSONL audit record. Fails LOUDLY (non-zero) if
the wake or annotation cannot land — never a silent ok (robots-5l8). 8 hermetic
tests cover every verdict, the fail-loud path, and --stdin comments.
Turn read-only review pages into decision surfaces. Every page now carries an
Approve/Decline/Comment panel that POSTs same-origin to /api/review/decision
with in-page success/error feedback, plus a structured
What/Why/Stakes/Recommendation/Artifact breakdown parsed from the body so the
decision is answerable in place. An already-recorded 'Captain decision:' note
renders as a standing-decision banner. Phone-friendly (48px tap targets, no
horizontal scroll, self-contained inline JS/CSS).

Also fixes the double-nav-bar bug: portal's injectShell stacked /_pulse/nav.js
on top of the page's own .topbar. The page now emits
<meta name="pulse-shell" content="off"> to opt out — exactly one top bar,
matching how /status and /plans compose. 5 new tests (13 total).
Snapshot of uncommitted local edits (AGENTS.md, .claude/settings.json,
.codex/hooks.json) so the working tree is clean for merging origin/main.
Reversible; preserves local work per the never-discard rule.
…ential

HOME override alone doesn't stop Claude Code's ancestor-directory CLAUDE.md
walk from re-loading ~/.claude/CLAUDE.md, since firstmate's repo is nested
under the real home dir. Launch from a detached worktree mirror under
/private/tmp instead (refreshed to HEAD each run), with FM_ROOT_OVERRIDE so
bin/ scripts still resolve real state/data/config/projects. Also implements
the previously-comment-only keychain credential seeding for first-run auth.

Root-caused via a background agent's /context-verified test; confirmed
independently by checking the mirror's CLAUDE.md symlink and ancestor chain.
…autonomy

The isolated session was still stopping for a tool-approval dialog on every
command (e.g. bin/fm-session-start.sh) because its fresh $HOME had no
bypass-permissions state, unlike ordinary crewmates which fm-spawn.sh already
launches with --dangerously-skip-permissions. Fix: seed settings.json
(permissions.defaultMode=bypassPermissions + skipDangerousModePermissionPrompt,
re-applied every launch) and .claude.json's bypassPermissionsModeAccepted, plus
add --dangerously-skip-permissions to the exec line for parity with
fm-spawn.sh:319. Verified live: pane now shows 'bypass permissions on' at
startup and runs a command with zero approval prompt.
The isolated session overrides HOME, but ~18 federated store wrapper
scripts (brain, robots, task, decisions, ...) hardcode
BEADS_DIR=$HOME/data/<store>/.beads at runtime, keyed off the actual
process HOME rather than a baked-in path. Under isolation that resolved
to a nonexistent path instead of the real Dolt-backed stores.

Symlink $ISOLATED_HOME/data -> the real ~/data so those wrappers reach
the real federated stores. Exposes only store data, not any PAI
CLAUDE.md/hooks/skills/agent config - none of that lives under data/.

Verified live: HOME inside the isolated session reported as the
isolated home, yet 'brain list' and 'robots list' returned real open
issues from the actual stores.
…ints

Add bin/fm-bead-stamp.sh (fail-open: stamps dispatch=sent + assigns a
linked bead on spawn) and a --beads <id> flag on fm-brief.sh and
fm-spawn.sh.

All bead-specific logic lives in two new hook directories rather than
being spliced directly into fm-brief.sh/fm-spawn.sh, so those files
stay a pure addition target with minimal upstream conflict surface:

- bin/fm-brief-hooks.d/beads.sh: sourced before fm-brief.sh writes the
  Brief section; emits the Bead Receipt (dispatch=claimed on brief
  read) and Bead Closure (close the bead before done:) sections.
- bin/fm-spawn-hooks.d/beads.sh: sourced after a successful spawn;
  stamps the bead via fm-bead-stamp.sh and registers a watcher check
  that polls the bead for status=closed and wakes firstmate for
  teardown.

Both hook loops run each hook in its own subshell so a hook's `exit`
never terminates the calling script, keeping every hook fail-open by
construction. fm-spawn.sh records beads_id= in state/<id>.meta when
set. --beads is rejected for --secondmate on both scripts.
- bin/claude-account.sh: standalone launcher for per-account Claude Code
  isolation (CLAUDE_CONFIG_DIR, flock-serialized shared-config symlinks,
  onboarding/trust-dialog pre-write, settings.json flag pre-write).
- bin/claude-1.sh, bin/claude-2.sh: one-line direct launchers.
- fm-spawn.sh --account <N>: records account=N in meta, sets
  CLAUDE_TRUST_DIR to the task worktree, launches through
  claude-account.sh N. Optional; absent behavior is unchanged.
- docs/configuration.md: Multi-account Claude Code section.
- tests/claude-account.test.sh, tests/fm-spawn-account.test.sh.
…G.md

Adds .agents/skills/herdr-navigation/SKILL.md so agents know how to
navigate herdr panes using existing primitives (herdr pane current,
neighbor, split, send-text, list). Agents commonly don't know these
exist; the skill surfaces them with working examples.

Also adds CHANGELOG.md to .gitignore — it is a generated session
activity log, not source content.
…enguid#1204)

* fix(guard): allow session-local todo tools in the primary

The delegation-shape guard denied TaskCreate and TaskUpdate because their
normalized names contain the `task` stem. Those tools write only the harness's
session-local todo list, which has no executor: it spawns no agent, allocates
no worktree, registers no schedule, and starts nothing that outlives the
session. That is not the unaccounted work the guard exists to stop, so the stem
match was a false positive, and the deny text told the primary to run
bin/fm-brief.sh and bin/fm-spawn.sh to create a todo entry.

Add a separately-reasoned PLAN_ONLY_TOOLS exact-name exclusion rather than
widening OBSERVE_ONLY_TOOLS, whose documented contract is tools that only
observe or stop existing work. Both lists stay exact-name so neither can widen
by substring.

Tests cover the two allowed names and six near-miss names that a substring or
shortened-stem widening would release; both mutations were watched red.

* no-mistakes(review): drop session-local todo tools from recommended deny list

* no-mistakes: apply CI fixes
…claude pid (kunchenguid#1206)

* fix(session-lock): resolve Claude bg-spare ancestry to the outermost claude pid

fm_harness_ancestry_pid() previously returned the first ancestor process
whose command matched a verified harness name. Claude Code's Stop hook
fires as a bg-spare worker several levels below the session's actual
lock-owning claude process (hook shell -> claude bg-spare ->
claude bg-pty-host -> claude -> claude(lock)), so the first match was
the bg-spare worker, not the lock owner. fm_session_lock_owned_by_self()
then never matched state/.lock, and the Claude Stop auto-arm silently
treated its own primary session as an unrelated live owner and never
armed the watcher.

The walk now keeps going past a claude-named match, looking for a still
more ancestral claude-named match, and stops the instant a non-match
follows an already-found match (bounding it to a contiguous run rather
than the literal ancestry top, so an unrelated claude-named process
further up the real process tree is never mistaken for part of this
session's own nested chain). Every other harness keeps the original
first-match-wins behavior, since e.g. Pi's shared signed-wrapper
ancestry actually holds the session at the inner engine pid, not an
outer wrapper pid. Hop limit raised from 8 to 16 to cover the deeper
bg-spare chain.

* no-mistakes(review): Add nested-claude-ancestry regression test; fix nudge doc depth claim

* no-mistakes: apply CI fixes
* fix: confirm watcher startup on MSYS

* no-mistakes(review): gate MSYS arm ready timeout, cache uname, harden locale test

* no-mistakes(review): validate OpenCode ready timeout, make uname cache internal
…d#1195)

* fix(spawn): forward firstmate's CLAUDE_CONFIG_DIR to claude crewmates

Crewmate panes are created by a long-lived tmux/herdr daemon that does not
inherit firstmate's current environment. When firstmate runs under a non-default
CLAUDE_CONFIG_DIR (for example a work-vs-personal subscription split), a bare
`claude` in the crewmate pane fell back to the default ~/.claude store and
launched unauthenticated, blocking the crewmate before it could do any work.

fm-spawn now prefixes the claude launch with firstmate's own resolved
CLAUDE_CONFIG_DIR when set, so the crewmate uses the same credential/config
store firstmate is authenticated with. An unset value is the single-store
default and adds no prefix; non-claude harnesses are unaffected.

Adds three tests in fm-spawn-dispatch-profile.test.sh (forwarded-when-set,
omitted-when-unset, non-claude-ignored) and pins CLAUDE_CONFIG_DIR in the test
helper so launch assertions no longer depend on the developer's environment.

* no-mistakes: apply CI fixes
…guid#1233)

* fix: preserve dispatch harness identity

* no-mistakes(review): Fix Grok counterfactual tuple validation

* no-mistakes(document): Scope dispatch authentication to selected tuple

* fix: restore dispatch instruction budget

* no-mistakes(review): Scope dispatch authentication after candidate selection
* fix(bin): handle dash-leading harness process names (#2)

* fix: handle dash-leading harness process names

* no-mistakes(review): Make dash-leading harness regression hermetic

* fix: preserve secondmate reply routes across relative homes

Resolve relative home, data, and state inputs before durable charter generation, and fail when caller-relative directories cannot be resolved.

Use absolute paths at the related spawn, AFK daemon, and X-mode cross-process handoffs so later processes cannot reinterpret them from another working directory.

* no-mistakes(review): Preserve absolute overrides and normalize relative durable paths

* no-mistakes(review): Normalize relative home before deriving durable paths

* no-mistakes(document): Document relative durable-path normalization

* no-mistakes(review): Captain: Ignore inherited CDPATH during relative path normalization

* no-mistakes(lint): Fix empty CDPATH assignments for ShellCheck
* Add internal status skill

* no-mistakes(document): register /status skill in documentation-audiences inventory

* no-mistakes(lint): replace grep|wc -l with grep -c in status skill test

* test: silence literal status skill patterns

* Refactor bearings default to chat-only

---------

Co-authored-by: Kun Chen <[email protected]>
kunchenguid and others added 21 commits July 30, 2026 02:32
…al coverage (kunchenguid#1304)

The second assertion in fm-gitignore-config.test.sh (added by kunchenguid#1261) greps
.gitignore for a specific spelling of the config/ ignore pattern. It fails
on a semantically equivalent pattern like config/** and does not prove Git
actually ignores anything, per the completed source-content-test audit.

Replace it with a real git check-ignore control test on a generated
unrelated path, and strengthen the existing directory-coverage test with
generated unpredictable direct and nested config/ paths.
)

* Add bounded startup memory curation

* no-mistakes(review): Record reproducible stow verification evidence

* no-mistakes(review): Validate inherited secondmate stow evidence

* no-mistakes(document): Document editable startup-memory budget propagation
* fix(herdr): place workers in the launching agent's exact workspace

Herdr enforces no workspace-label uniqueness, and spawn resolved its
container by taking the FIRST workspace whose label matched the home
label. With two workspaces both labeled "firstmate", a worker launched
from the second one was created in the first, so it appeared in a
different space than the Firstmate the captain was watching.

Reproduced end to end on Herdr 0.7.5 protocol 17 by running the real
bin/fm-spawn.sh inside a launcher pane in the second "firstmate"
workspace: the worker landed in w1 while its launcher was in w2, with an
unrelated third workspace focused throughout, which also rules out any
dependence on the focused workspace.

Placement now binds to the launching process's own Herdr identity. Herdr
injects HERDR_PANE_ID, HERDR_SESSION, and HERDR_SOCKET_PATH into every
process it manages a pane for, and fm_backend_herdr_launcher_identity
resolves that pane's current owning tab and workspace live from Herdr,
cross-checking the pane against its tab and confirming the workspace
exists exactly once in the session. The injected HERDR_TAB_ID and
HERDR_WORKSPACE_ID are creation-time snapshots and are deliberately not
read as current identity. Labels are no longer placement authority.

A claimed parent identity that is unreadable, contradictory, stale, or
from another named session or Herdr server stops the spawn before any
worker endpoint exists, rather than degrading to a label search. A
launcher with no Herdr ancestry has no workspace to inherit and keeps
the per-home labeled container, which must now resolve to exactly one
workspace; two same-labeled candidates refuse instead of adopting
either. A --secondmate launch keeps standing up that home's own
workspace by design.

With presentation spaces enabled, the projected child is created and
bound under that same exact parent and anchors its ordering on it, so a
duplicated home label no longer makes the layout ambiguous. Projection,
focus restoration, restart binding, and quarantine rules are unchanged,
and children are never collapsed into the parent. tmux, Zellij, cmux,
Orca, and the away-mode daemon terminal were each inspected and are not
affected: none resolves a container by searching mutable labels.

tests/fm-backend-herdr-launcher-workspace-e2e.test.sh drives the real
spawn and teardown against an isolated Herdr lab, with its headline case
running fm-spawn.sh inside a real Herdr pane so the identity comes from
Herdr's own injection. The refusal matrix and the ordering anchor are
covered deterministically in tests/fm-backend-herdr.test.sh.

Eight existing real-Herdr suites inherited the developer terminal's own
Herdr pane into their isolated lab sessions, which the new cross-session
check correctly refuses. tests/herdr-test-safety.sh now owns
herdr_forget_inherited_pane and those suites call it, so what they assert
no longer depends on where they were launched from.

Two unrelated fixes found along the way. tests/fm-secondmate-harness.test.sh
had the same class of environment leak through CLAUDECODE, which outranks
PI_CODING_AGENT in bin/fm-harness.sh and made its pi-signed ancestry case
resolve "claude" whenever the suite ran inside Claude Code. And
fm-spawn.sh's usage() printed a fixed line range that had already been
truncating its own help mid-sentence.

* no-mistakes(review): Enforce exact Herdr launcher and projection identity

* no-mistakes(document): Document exact Herdr launcher workspace placement
* feat(calm): replace Pi's working row with an animated ship while Calm is on

While Calm is active and one logical agent run is under way, Calm now hides
Pi's built-in working row and renders a small two-row SSHHIP-derived boat in
its place. When Calm is off, Pi's stock working row is left untouched.

The presentation uses only public Pi extension API: setWorkingVisible(false)
plus a temporary setWidget() component whose render(width) owns the responsive
geometry and whose timer requests a TUI render. Visibility follows agent_start
through agent_settled, so the boat does not flicker between tool calls,
automatic continuations, retries, or compaction inside the same run, and
settle, abort, and failure all reach the same cleanup.

fm-calm.ts stays the sole owner of the presentation choice and the only caller
of setWorkingVisible(); the new lib owns the sprite geometry and widget.

* no-mistakes(review): Guarded Calm-off lifecycle visibility writes; focused tests pass

* no-mistakes(test): Fixed Calm E2E wait to include tmux scrollback

* no-mistakes(document): Document Calm working boat behavior

* no-mistakes: apply CI fixes

* feat(calm): slow the Calm boat, animate blue water, and make the sail directional

The boat now moves one column every 880ms while a bounded fixed-cell water phase
advances every 220ms, so the water ripples several times between boat steps and
the presentation reads as calm. One scheduler drives both clocks and disposing
the widget stops them together; ticks rather than wall-clock timestamps drive
every state change, so tests seek animation time exactly.

Colors are standard ANSI foreground codes instead of theme lookups: blue for
every water cell and yellow for the complete boat, each run closed with a
default-foreground reset so nothing bleeds into padding or later frames. ANSI
bytes never enter geometry, so visible width stays exact.

The mainsail is directional and trails aft of the mast: <| travelling right and
|> travelling left. Direction reverses the moment the boat lands on an endpoint,
so the endpoint frame already shows the new heading and no frame at or after a
bounce shows the previous sail.

* test(calm): wait for the Ctrl+O expansion redraw this block asserts

* docs(calm): record the revised working-presentation verification evidence

* no-mistakes(document): Fix Calm feasibility document EOF whitespace
…henguid#1349)

* fix(dispatch): scope candidate authentication to its own surface

A locally expired timestamp in one credential store was reported to the
captain as a sign-out, including for dispatch candidates that never read
that store. A `harness=pi, model=xai/grok-*` candidate authenticates
through Pi's own xAI credential, but the only Grok quota reading
available was gated on the standalone Grok CLI's separate token, whose
expiry clock drifts independently. The always-loaded intake rule then
turned that unreadable quota into a mandatory captain escalation.

Add `bin/fm-auth-preflight.sh` as the deterministic owner of the parts
that must not depend on agent memory: it resolves a tuple's
authentication surface from quota-axi's own emitted auth sources rather
than from a harness or model name, so another harness's CLI can never
gate a candidate that does not use it. A vendor CLI is launched only
when the tuple's own harness owns the credential store under test and a
non-destructive discovery command is registered for it, which today is
`grok models` alone. That probe runs at most once with stdin closed and
a hard timeout, reads its verdict from the first stdout line because the
command exits 0 either way, treats unrecognized output as indeterminate,
and never invokes login, logout, or the interactive TUI. Quota is read
at most twice, and unknown headroom never makes a candidate ineligible
on its own.

Update the dispatch procedure to match: usable authentication with
unmeasurable headroom stays eligible at lower preference with the
unknown disclosed, and stop-and-report is reserved for unresolved
authentication, an unresolved relationship, or malformed configuration.
Record that Grok's `credits.remaining` is a prepaid balance rather than
window headroom.

Gate quota-axi at 0.1.16 in bootstrap, the first build reporting
per-credential auth sources. A stale install previously passed the
presence check silently, which is why a fix published two days earlier
was still not in effect.

Replace the orphaned quota-array-dispatch fixtures, which encoded a
`provider: "xai"` shape the tool never emits and had no consumer, with
fixtures shaped like real 0.1.16 output that the new suite drives the
script against. The suite asserts the verdict and, separately, which
vendor CLIs were launched, so a Pi/xAI candidate reaching the Grok CLI
fails. Map `tests/fixtures/<dir>` to its consuming suite so a fixture
change selects the right tests instead of refusing.

* refactor(bootstrap): give the quota-axi floor one owner

The floor was stated twice - once in bootstrap's gate and once inline in
the auth preflight - so bumping it needed two edits that could drift.
Move it to bin/fm-quota-axi-lib.sh alongside its rationale, matching the
existing tasks-axi library, and derive the comparison from the constant
so the number appears exactly once. Bootstrap turns a failing check into
the operator diagnostic; the preflight refuses to emit an unscoped
verdict. Map the new library to both consuming suites so a bump re-runs
them, and record that any usable source means the surface authenticates.

* no-mistakes(review): Captain: bound quota checks and removed Python dependency

* no-mistakes(review): Captain: enforce conservative headroom and exact preflight retry

* no-mistakes(review): Captain: preserve OpenCode eligibility without auth-surface guessing

* no-mistakes(review): Captain: reject malformed OpenCode model relationships

* no-mistakes(review): Captain: exempt verified unmodeled tuples from intake escalation

* no-mistakes(document): Updated dispatch authentication documentation

* no-mistakes: apply CI fixes
…nchenguid#1350)

* feat(x-mode): reconcile promised public replies deterministically

A promised final reply in an X or Discord thread was only kept while the
primary remembered it. Compaction or restart erased that memory, so a typed
public-followup obligation could sit at pending-work after its PR merged and
the original thread never got its reply.

Make the promise durable state instead:

- bin/fm-public-followup-emit.sh reports a typed terminal work result (source
  home, work id, generation, outcome, safe deliverables, bounded public-safe
  text) into the owning home's private inbox. The event id is derived from
  that identity tuple, so duplicate reports and restart replay converge with
  no coordination, and nothing ever parses a free-form done: sentence.
- bin/fm-public-followup.sh registers a commitment, reconciles events through
  tasks-axi public-followup, and runs the idempotent delivery sequence
  (begin-delivery with the payload hash, post, record the posted receipt or a
  typed error) against the stored platform and opaque thread binding. A
  delivery interrupted between post and receipt refuses rather than risk a
  second public reply.
- Session start surfaces unresolved commitments from disk, the existing relay
  poll surfaces a new terminal-result set once, and teardown refuses while
  this home still owes a public reply for that exact work.

tasks-axi public-followup remains the only owner of the obligation state
machine, state/x-context/ the only owner of the private request context, and
fm-x-reply.sh the only thing that posts. Its new optional --receipt-file is
the one addition there, so a caller can record how many messages were sent.

A home that never opted into the myfirstmate relay gates out on a single
[ -f "$FM_HOME/.env" ] test: no tasks-axi call, no backlog or context scan,
no output, and no artifact. Evidence in docs/verification/public-followup.md.

* no-mistakes(review): Hardened public-followup reconciliation and ownership guards

* no-mistakes(review): Hardened typed terminal cleanup and receipt reconciliation

* no-mistakes(review): Automated typed-delivery cleanup and strict backlog validation

* no-mistakes(review): Fail-closed parent resolution and registration-safe delivery

* no-mistakes(review): Harden relay gating and validate secondmate bindings

* no-mistakes(review): Use owner-aware single-gate teardown protection

* no-mistakes(document): Correct public-followup documentation drift

* no-mistakes(lint): Quote done literals to fix ShellCheck warnings

* no-mistakes: apply CI fixes
…chenguid#1327)

* feat: add semantic busy-state contract owner and event writer

One owner (bin/fm-busy-lib.sh) for the captain-approved semantic
busy-state redesign: a per-task gen-bound record written only by
bin/fm-busy-event.sh, per-harness trusted-source classification with
explicit source attribution, busy/idle/unknown/dead semantics where
missing, malformed, stale, or untrusted semantic data is unknown -
never idle - and endpoint death is the only process-level override.
The Grok-only rendered-tail fallback and the standalone-Kimi
verification gate live behind the same classifier.

* feat: arm busy-state at spawn and convert Pi to the semantic extension path

fm-spawn arms the busy-state contract for converted adapters and seeds
busy/fm-spawn (the launch brief is a submitted turn). The Pi/pi-signed
per-task extension now reports agent_start -> busy and agent_settled ->
idle confirmed by ctx.isIdle(), covering auto-retries, compaction
retries, tool loops, and queued continuations, while turn_end stays a
wake notification touch. Teardown removes the new record, gen sidecar,
and lock. Live-verified on Pi 0.82.0: seed -> agent-start busy ->
agent-settled idle with the marker still touched.

* feat: convert OpenCode to the semantic session.status plugin path

The per-task plugin (renamed .opencode/plugins/fm-busy-state.js) now
classifies from OpenCode's semantic session.status events - busy and
retry are active, idle is inactive - latched to the worker's own
session so a subagent child session can never clear the worker's busy
state. The session.idle marker touch stays a wake notification.
Teardown removes both the new and the legacy plugin filenames.
Live-verified on OpenCode 1.17.18 in a real TUI pane: seed ->
session-busy -> session-status-idle.

* feat: convert Claude to the full lifecycle hooks path

The per-task settings.local.json now wires UserPromptSubmit -> busy
and Stop, StopFailure, and SessionEnd -> idle, so API-error and
shutdown turn ends can never strand a busy record; Stop keeps the
turn-ended notification touch. A refused (stale-gen) event exits 0 and
stays silent so Claude's own lifecycle is never broken. Live-verified
on Claude Code 2.1.220: UserPromptSubmit fires for the argv launch
prompt, Stop closes each turn, a mid-stream Escape interrupt fires no
closing hook, and the firstmate-controlled idle/fm-interrupt clear
resolves it.

* feat: gate Codex busy state behind verified semantic sources

The approved contract prefers Codex's app-server turn lifecycle with
capability negotiation and sanctions its lifecycle hooks as the
intermediate. Live probes on codex-cli 0.145.0 show neither is usable
for a pane worker: the app-server daemon is unreachable for a TUI
thread and refuses to start outside the managed standalone install,
and firstmate-written project hooks never fired (interactive with
directory trust granted, and exec, both with
--dangerously-bypass-hook-trust) while global hooks fired in the same
runs. Codex therefore classifies unknown codex-unverified behind an
explicit probe rather than falling back to idle or footer text, and
fm-spawn installs no unverified Codex wiring.

* feat: gate standalone Kimi busy state on live verification

Standalone Kimi has no installed binary here, so per the approved
contract its semantic path stays guarded and it classifies unknown
kimi-unverified rather than idle - and never from its locale-sensitive
moon-phase spinner, which the redesign forbids inventing as a state
source. The gate records the preferred source order (Wire prompt
request lifetime, which brackets a turn and reports cancellation, then
the documented hooks including Interrupt because Stop does not fire on
interrupts) and the exact evidence required to open it. Arming without
wiring would seed a busy record nothing could clear, so both land
together behind the same gate.

* feat: route busy consumers through the contract and drop the global OR

The watcher, crew-state reader, and away-mode daemon now decide busy
state through bin/fm-busy-lib.sh: only an exact busy verdict counts as
working, and unknown never becomes working or a silent idle, so a crew
whose semantic state is missing, malformed, stale, or unverified
surfaces instead of being absorbed. Crew-state reports the producing
source in its detail. The watcher's global OR regex default is gone;
Grok keeps its isolated fallback inside the contract. The daemon's
supervisor-pane reader stays rendered-text - that pane is not a
recorded task - but is now scoped to firstmate's own detected harness
instead of every vendor signature. Secondmate pending-reply
observation is deliberately unchanged and documented as a
delivery-confirmation signal, not task state.

* docs: point busy-state documentation at the single contract owner

Adds a maintainer-architecture section naming bin/fm-busy-lib.sh as
the owner of what busy means, with per-adapter sources, the
unknown-never-idle rule, the endpoint-death override, and the two
rendered-text readers that deliberately stay outside the contract.
Replaces the stale regex-first prose in architecture, tmux-backend,
herdr-backend, and configuration; converts the harness-adapters
per-harness rows from UI signatures to the semantic source each
harness uses; and records the live verification evidence, including
why Codex and standalone Kimi stay unknown.

* fix: arm away-launch signal handlers before acquiring the lifecycle lock

fm_afk_launch_main acquired its lock and only then installed the EXIT,
INT, and TERM traps. A signal arriving in that window terminated the
process by default action and left the lock directory behind, which
blocks the next away-mode launch until the stale-owner reclaim path
clears it. The release helper only removes a lock this process owns,
so the handlers are now armed first. The accompanying test also killed
the child whether or not the lock had appeared and sampled cleanup the
instant wait returned; it now requires the lock, then allows a bounded
settle, so it proves the guarantee instead of racing it.

* test: align fleet, Kimi, lifecycle, and detection suites with the contract

The fleet snapshot and wake-daemon lifecycle fixtures now prove a
working crew through its own semantic busy-state record instead of
rendered pane text, which is what those consumers read. The Kimi
watcher test asserts the approved contract directly: a standalone Kimi
task classifies unknown rather than matching its moon-phase spinner,
while Grok's isolated fallback still classifies only Grok. The
pi-signed detection cases clear ambient harness markers, fixing a
pre-existing failure where the running session's own CLAUDECODE
outranked the fixture's marker.

* fix: stop teardown from deleting a project's own Codex hooks file

An intermediate revision wired Codex through a firstmate-written
<worktree>/.codex/hooks.json, and teardown removed it alongside the
other generated wiring. The Codex wiring was dropped when its probes
came back unverified, so that removal now targets a file firstmate
never creates - and a project may legitimately track its own
.codex/hooks.json, which teardown would then delete from a pooled
worktree.

* fix: keep busy-record parsing from disturbing its sourcing caller

The record parser split fields with set -- under a temporary noglob,
which clobbers a sourcing caller's positional parameters and restores
glob expansion even when the caller had disabled it. The watcher, the
daemon, and the crew-state reader all source this library, so it now
reads fields with read -a, which never globs and never touches caller
state.

* docs: state exactly which Claude hook paths were reproduced live

The busy-state record listed all four wired Claude hooks in the source
column, which could read as a claim that every one fired during the
pass. UserPromptSubmit and Stop did; StopFailure and SessionEnd are
wired from hook names confirmed present in the installed binary, but
the abnormal turn ends they cover were not reproduced.

* test: let reset_fakes own the crew-state busy-text fixture lifecycle

The Grok fallback case set FM_FAKE_BUSY_TEXT and cleared it inline, so
the variable's lifetime was owned by one test rather than by the
shared reset that every other fake already uses.

* no-mistakes(review): Fix semantic busy-state lifecycle races

* no-mistakes(review): Make busy-state retirement idempotent

* no-mistakes(review): Enforce semantic state boundaries for status and injection

* no-mistakes(review): Restore harness-scoped away-mode busy guard

* no-mistakes(document): Refresh semantic busy-state documentation

* no-mistakes: apply CI fixes
…d#1356)

* fix(calm): resume working boat from frozen column across runs

Keep one extension-owned boat animation for the Pi session so settling
freezes column and direction, the next working period resumes there
without hidden-time jumps, and only a fresh session resets to the left edge.

* no-mistakes(review): Freeze Calm boat from last rendered state

* no-mistakes(document): Document Calm boat continuity contract
* fix(dispatch): judge candidate provider relations instead of rejecting them

Firstmate deterministically dropped supported Pi candidates in the
openai-codex family. bin/fm-auth-preflight.sh resolved a harness=pi tuple's
credential surface by constructing the source id `pi:<model-prefix>`, so
`pi + openai-codex/gpt-5.6-terra` looked for a `pi:openai-codex` source. That
source does not exist, because Pi's Codex family authenticates through the
Codex store quota-axi already lists as `auth-json`/`cli-rpc`. The tuple
returned `eligible=no reason=surface-unresolved` while the Pi catalog listed
the model and the Codex provider reported fresh, usable credentials with 64
effective percent remaining on its all-model scope.

The prefix construction was only ever valid where Pi holds its own credential
(`pi:xai`, `pi:kimi-coding`), which is why every previously configured Pi tuple
resolved and the defect stayed hidden until a Codex-family Pi model was
configured.

Retire dispatch eligibility from deterministic shell. The dispatching first
mate now establishes model support and provider family from each harness's
authoritative catalog, applies quota at the granularity the vendor supplies,
and shows that reasoning. Provider-level and all-model evidence bounds every
model established in that family; a named-model window bounds only its own
model. Missing model-level quota, a missing auth source, unmeasurable headroom,
and unmodeled authentication are disclosed uncertainty. Only concrete
contradictory evidence blocks a candidate.

Replace the preflight with bin/fm-vendor-auth-probe.sh, which keeps the
captain's approved bounded probe envelope without any routing knowledge: it
takes no harness, model, or provider, reads no quota, renders no verdict, and
holds only a fixed-argv safety allowlist. Its behavior suite proves the absent
identity surface, the untouched quota, the uniform exit status, the fixed argv
with stdin closed, and a real bound even when the configured bound is zero.

Also fixed along the way: a zero FM_*_TIMEOUT silently removed the hard bound,
the pinned Grok version had drifted to 0.2.117, and --changed selection refused
outright on any deleted bin/ script.

AGENTS.md section 4 and quota-array-dispatch own the corrected policy,
harness-adapters gets the catalog-responsibility correction, and
docs/verification/dispatch-auth.md records the 2026-07-30 evidence on
Pi 0.82.0, quota-axi 0.1.16, and grok 0.2.117.

* no-mistakes(review): Reject all-zero vendor probe timeouts
* docs: add captain-authorized inherent red-check merge exception

Keep the default red-PR ban and own one always-loaded exception in the
merge-authority section: captain-explicit PR or bounded batch plus exact
check, only when the failure is inherent to the selected delivery path.
Yolo cannot activate it; final head and the full current check suite must
be verified; other substantive failures remain non-waivable.

* docs: replace narrow red-check exception with captain precedence

Supersede the inherent failing-check merge exception with one always-loaded
Firstmate-local rule: a current explicit concrete captain instruction
overrides a conflicting Firstmate-written standing rule only within exact
scope, never above platform/system/developer instructions. Keep the ordinary
red-PR default and yolo boundary; point section 7 at the section 1 owner.
* fix: give validation-time captain overrides a supersession sequence

The Validate section let a captain instruction that completely
invalidates the work being validated keep the same task and worker, but
never said how: the adjacent rule flatly bans hand-editing, committing,
aborting, or restarting during an active run with no carve-out, so a
worker facing full invalidation had no sanctioned path forward.

Add the missing sequence: cancel through no-mistakes axi's abort
command, confirm the run has stopped through axi status, recover branch
ownership through axi sync's guarded recovery, only then replace the
obsolete work, and validate once against the final head. The existing
ban on hand-editing an active run now cross-references this sequence
instead of contradicting it.

* no-mistakes(review): Make validation custody recovery conditional

* no-mistakes(document): Clarify validation supersession abort exception

* fix: keep obsolete pipeline commits out of the superseded deliverable

The review-applied fix made custody recovery conditional on
branch_sync.next_action.code, but left an open gap: recovering custody
settles who owns the branch, not what content ships. As written, a
worker could recover an obsolete run's branch and build the
replacement on top of its now-irrelevant commits instead of from the
correct pre-invalidation base, carrying obsolete content into the
final deliverable.

Make that explicit: custody recovery settles ownership, not content,
so the worker replaces obsolete work from the correct base and keeps
the obsolete run's commits out of what gets validated and shipped.

* no-mistakes(test): Restore minimal pre-invalidation replacement instruction

* fix: dedupe redundant "replace the obsolete work" restatement

Line 309 already says the worker replaces the obsolete work from the
correct pre-invalidation base, excluding the obsolete commits. The
closing sentence restated "replace the obsolete work" again before
gating the final validation run, layering the same fact twice instead
of stating it once.

Trim the closing sentence to just the ownership gate and the
single-run-against-final-head requirement it uniquely adds.
* fix: bind explicit --backend to exact-task authority

A Herdr-backed second mate carried a prior one-task --backend tmux
exception forward by analogy, so its child landed in tmux and never
appeared under the second mate in Herdr. Runtime detection was correct;
the authority surface was not.

docs/configuration.md now owns that an explicit --backend is authorized
only for that exact task. AGENTS.md and fm-spawn help point there.

* no-mistakes(document): Consolidate backend selection authorization documentation
…unchenguid#1229)

* fix: remove projected workspaces through Herdr's focus-preserving pane-death path

Herdr 0.7.5's explicit close of a workspace-emptying last pane moves the
attached client's focus to a neighbor workspace, flashing the captain's
whole window and routing in-flight keystrokes to the wrong pane until
Firstmate's exact-tab restore masks it 56-197 ms later.

Teardown and cleanup now plan a workspace-emptying close as a focus-safe
removal: verify the close empties the workspace, reposition the doomed
workspace behind the focused one through the verified workspace.move
transport when it sits before a non-last focused workspace, prove the pane
holds one lone idle shell, and end that shell so Herdr removes the emptied
workspace through its focus-preserving pane-death path. Any ambiguity or
failure falls back to the plain close behind the existing restore backstop,
and fm_backend_herdr_kill applies the same plan for non-projected removals.

Two conditions proven on real hardware are encoded in the adapter: BSD ps
reports a login shell's comm as "-zsh", and an idle shell transiently
hosts a prompt helper right after a workspace.move relayout, absorbed by a
bounded strict-sample settle window in the idle-shell proof, now the single
owner shared with session-start cleanup.

An isolated-lab regression reproduces the raw steal on 0.7.5 and proves the
plan removes a doomed workspace with zero wrong-focus samples and no
corrective focus; unit fixtures cover the position, edge, ambiguity, move
and kill failure, escalation, and transient-helper cases. Upstream fixes
(#1877 explicit close, #1912 pane death) are merged but unreleased; once
released the plan degrades to a harmless reorder-then-remove.

* no-mistakes(review): Confirm pane death from structured not-found responses

* no-mistakes(review): Serialize Herdr kills and sample focus continuously

* no-mistakes(review): Synchronize Herdr focus evidence output

* no-mistakes(review): Refuse unlocked Herdr pane closes

* no-mistakes(document): Correct Herdr focus-safety documentation

* no-mistakes: apply CI fixes

* fix: never erase a Herdr task's records while its pane survives a refused close

A transient presentation-lock contention could produce a completed teardown
while the exact Herdr pane stayed alive as an unowned restored shell: the
kill refused the unlocked close (correctly), returned success, the warning
was suppressed, and cleanup erased the task's status, turn-end, and
metadata records after the isolated copy had already been returned.

Teardown now acquires the named-session presentation lock before anything
destructive: a contended lock refuses up front while the isolated copy, the
task branch, every durable record, and the endpoint are all intact for a
plain rerun, and the projected and flat close paths both run under that one
held lock instead of acquiring their own. Durable records are erased only
once the exact pane is confirmed gone through its structured presence; a
refused, skipped, or failed close retains every record with a visible,
retryable error, and after a skipped close (unresolvable lock path) only a
structured pane_not_found counts as gone - unknown never does.

The teardown regression drives a live contending lock holder end to end:
the refusal touches nothing (no worktree return, no branch drop, no close
attempt), and the retry after release returns the copy, closes the pane
under the lock, and removes the records. The unconfirmed projected close
now refuses with records retained, and the structured-presence gate has a
strict/default unit matrix.

* no-mistakes(review): Require structured pane-not-found before Herdr record removal

* no-mistakes(document): Correct Herdr record-retention verification date

* fix: refuse ambiguity, revalidate SIGKILL ownership, and roll back failed removals

Three accepted-contract corrections from the post-CI personal review of the
Herdr keep-spaces focus-flash mitigation.

Ambiguous endpoint identity no longer counts as a confirmed-gone pane: a
missing or malformed target refuses record removal in the structured
presence gate, and teardown treats missing confirmation machinery as a
refusal instead of skipping the gate, so only an exact structured
pane_not_found ever erases durable task records.

The pane-death SIGKILL escalation re-reads the exact pane's process
information and refuses to signal unless the same shell pid still passes
the strict bare-idle ownership proof, so a pid that exited and was reused
by an unrelated process is never signaled; the refused escalation falls
back to the plain close with the unrelated process untouched.

A reposition whose removal is not confirmed no longer outlives the attempt:
the emptying-close plan records the verified pre-move order and original
index whenever it invokes the mover, and both close owners restore the
exact original workspace order through a second verified move, under the
same held session lock, before reporting the close as failed.

Each defect was reproduced first: the unit matrix documented malformed
identity as gone, the PID-reuse regression showed SIGKILL reaching a
disowned pid, and the rollback regression showed a single unrestored move.
Teardown-level regressions cover unparseable presence retention alongside
the strict identity matrix.

* no-mistakes(review): Require confirmed Herdr removal and resolvable teardown locks

* no-mistakes(review): Enforce structured Herdr closes and teardown preflight

* no-mistakes(review): Preflight explicit Herdr close confirmation helper

* no-mistakes(document): Document Herdr rollback failure semantics

* no-mistakes(review): Captain, harden recursive Herdr teardown safety

* no-mistakes(document): Document recursive Herdr teardown evidence

* fix: retain nested secondmate home when a recursive child cleanup fails

Captain-decided Option A correction for nm-askuser-flash-r6, found during
complete-diff rereview of the merged head.

cleanup_firstmate_home_children's recursive secondmate branch called
itself for a nested child's home without checking the result, then
unconditionally removed that home right after. remove_firstmate_home
ends in an unconditional recursive delete with no check for leftover
records, so a nested secondmate whose own Herdr grandchild failed its
confirmed-gone check would have its entire home - retained grandchild
records included - erased by the very next line.

Guard the recursive call the same way every other fallible call in this
function already is: || return 1, skipping remove_firstmate_home and
leaving the nested home and its records for a safe rerun.

Empirically, fm-teardown.sh's set -eu already halted the script on the
prior unguarded call before reaching removal (verified by hand with the
guard reverted, under both this session's bash and stock macOS bash
3.2) - the reachable behavior was already correct. The explicit guard
is still applied exactly as decided: it matches every sibling call site
in the function, and it stops the correctness of this path depending on
errexit's well-known fragility under refactors (a wrapping if/&&, or a
future subshell) rather than on an explicit check.

Adds a teardown-level regression building on the existing direct-child
Herdr fixtures: a top-level secondmate contains a nested secondmate,
whose own Herdr child's close goes unconfirmed. Proves through the
public fm-teardown.sh interface that the nested home, the nested
secondmate's own record, and the grandchild's metadata and status all
survive, and that the top-level secondmate's record survives too.

* no-mistakes(document): Document nested Herdr teardown retention
…d#1431)

* fix(dispatch): prioritize quota completion runway

* no-mistakes(document): Document completion-aware quota runway selection
…uid#1447)

* Preserve task contract in no-mistakes intent

* no-mistakes(review): Preserve complete current task contract in no-mistakes intent
Default for exploratory changes during ship tasks:
1. Commit the work (never discard)
2. Mark with [quality:needs-review]
3. File tracking task for curation

Preserves thought, keeps decisions durable, makes exploration visible.
…henguid

# Conflicts:
#	.gitignore
#	AGENTS.md
#	bin/fm-spawn.sh
Local main and origin/main (trillium/firstmate fork) diverged into 54
ahead / 38 behind commits. This merges both histories forward rather
than resetting or squashing, preserving:

- 22 genuinely local-only commits (files that exist nowhere on
  origin): claude-account.sh, fm-idea-mine.ts, fm-review-decision.sh,
  fm-isolated-launch.sh, fm-groom.sh, fm-review-page.ts,
  fm-herdr-spur.sh, and their tests/docs.
- 32 commits from a deliberate prior upstream-fork sync
  (kunchenguid/firstmate) that origin also lacks.
- ~30 of origin's own merged PRs that local never pulled, including
  the task-store/beads-authority migration (Stages 2-5) and its
  write-queue resilience layer.

22 files had genuine three-way conflicts, resolved by intent rather
than mechanically: both sides' functionality was kept wherever both
were live and non-contradictory, and the more-evolved side was kept
where one side had already superseded the other (e.g. the Herdr
Mate naming convention, the beads-authority write-queue fallback, and
AGENTS.md's captain-instruction-precedence section). No content was
silently dropped; add/add conflicts (fm-bead-stamp.sh,
fm-brief-hooks.d/beads.sh, etc.) were merged as strict supersets.

Also fixes a pre-existing documentation-audience classification gap
(docs/documentation-audiences.json was missing two local-only files,
.agents/skills/herdr-navigation/SKILL.md and
docs/examples/herdr-spur.agents) that predates this merge but would
otherwise fail CI's audience-check gate.

Verified: full shellcheck lint clean (bin/fm-lint.sh), git diff
--check clean, zero remaining conflict markers, all 22 local-only
files and beads-migration files present and intact. Full regression
suite run (123 tests): 7 failures, every one confirmed pre-existing
by reproducing identically against unmodified pre-merge main in an
isolated scratch worktree, not merge-introduced. Five are real-Herdr
E2E timing/process-shape flakiness inherent to those tests' reliance
on precise real-time terminal state capture. Two are local-machine
tooling gaps that would not manifest in CI: a missing
@earendil-works/pi-coding-agent npm package, and an installed
tasks-axi binary (0.2.2) that predates its public-followup
subcommand.
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@trillium, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 48 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6b85d761-d5db-4922-863b-ffd23ed2953c

📥 Commits

Reviewing files that changed from the base of the PR and between 5f73644 and c2f8f16.

📒 Files selected for processing (1)
  • bin/fm-spawn-hooks.d/beads.sh
📝 Walkthrough

Walkthrough

This PR adds Herdr focus-safe cleanup, Claude account and home isolation, Beads lifecycle hooks, idea and review automation, completion-aware dispatch guidance, an external-agent completion bridge, and supporting tests and documentation.

Changes

Dispatch contracts and workflow guidance

Layer / File(s) Summary
Completion-aware dispatch and authority rules
.agents/skills/*, AGENTS.md, docs/architecture.md, docs/configuration.md, docs/verification/dispatch-auth.md
Dispatch now uses effective headroom, usable runway, completion horizon, reasoning requirements, and explicit uncertainty. Authority, validation supersession, ownership, and semantic busy-state rules are documented.
Runtime hooks and test classification
.claude/settings.json, .codex/hooks.json, bin/fm-test-*.sh, .github/workflows/ci.yml
Beads hooks cover session, compaction, and prompt events. Live dispatch tests are classified separately. The Bearings count is updated to 41.

Claude isolation and Beads lifecycle

Layer / File(s) Summary
Claude account and home isolation
bin/claude*.sh, bin/fm-isolated-launch.sh, tests/claude-account.test.sh, tests/fm-spawn-account.test.sh
Claude credentials, trust state, settings, and detached worktrees can be isolated by home or account.
Beads spawn and brief hooks
bin/fm-brief.sh, bin/fm-spawn.sh, bin/fm-spawn-hooks.d/beads.sh, tests/fm-beads-hooks.test.sh
Briefs and spawns validate Beads IDs, expose hook metadata, stamp dispatched work, and register fail-open closure watchers.

Idea, grooming, and review workflows

Layer / File(s) Summary
Idea mining and grooming
bin/fm-idea-mine.ts, bin/fm-groom*.sh, tests/fm-idea-mine.test.sh, tests/fm-groom.test.sh
Transcript mining performs bounded two-pass inference, deduplication, scoring, idempotency, and filing. Grooming classifies ready ideas and dispatches or escalates them.
Review pages and decisions
bin/fm-review-page.ts, bin/fm-review-decision.sh, tests/fm-review-page.test.sh, tests/fm-review-decision.test.sh
Review items render as standalone HTML pages with decision controls. Decisions update review notes, wake queues, and audit records.

Herdr focus-safe cleanup

Layer / File(s) Summary
Verified pane and workspace cleanup
bin/backends/herdr.sh, bin/fm-herdr-session-cleanup.sh, bin/fm-teardown.sh, tests/fm-backend-herdr.test.sh, tests/fm-teardown.test.sh
Cleanup uses session locks, exact pane identity, idle-shell PID proofs, pane-death or confirmed close, workspace rollback, and durable-record retention when removal is ambiguous.
Herdr completion bridge and verification
bin/fm-herdr-spur.sh, docs/examples/herdr-spur.agents, tests/fm-backend-herdr-focus-flash-e2e.test.sh, tests/fm-backend-herdr-presentation-e2e.test.sh, docs/herdr-backend.md, docs/verification/runtime-backends.md
The completion bridge monitors external Herdr agents and enqueues wakes. End-to-end tests verify focus preservation and workspace removal. Documentation records protocol compatibility and teardown evidence.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 47.24% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately identifies the main reconciliation and tooling changes, including grooming, review-page, and isolation features.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fm/main-reconcile-optA

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (19)
bin/fm-review-page.ts-824-835 (1)

824-835: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The error message drops the HTTP status.

Line 832 concatenates an empty string literal, so the fallback text renders as request failed (). The status never reaches the captain. res is not in scope in the second .then, so the status has to travel through the first one.

🐛 Proposed fix
         }).then(function (res) {
-          return res.json().then(function (data) { return { ok: res.ok, data: data }; });
+          return res.json().catch(function () { return null; }).then(function (data) {
+            return { ok: res.ok, status: res.status, data: data };
+          });
         }).then(function (r) {
           if (r.ok && r.data && r.data.ok) {
             setFeedback('ok', 'Recorded — the first mate has been notified.');
             var ts = new Date().toISOString().replace(/\\.\\d+Z$/, 'Z');
             lockAfterDecision(verdict, comment, ts);
           } else {
-            var err = (r.data && r.data.error) ? r.data.error : ('request failed (' + '' + ')');
+            var err = (r.data && r.data.error) ? r.data.error : ('request failed (' + r.status + ')');
             setFeedback('err', 'Could not record that: ' + err);
             setButtons(false);
           }

The added .catch also keeps a non-JSON error response (for example an HTML 502 page) on the in-page error path instead of the network .catch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/fm-review-page.ts` around lines 824 - 835, Preserve the HTTP status
through the promise chain in the fetch handling around the first `.then` and the
`r` response object by including it in the object returned after `res.json()`,
then use that status in the fallback error text instead of the empty string.
Also ensure JSON parsing or non-JSON responses remain on the existing in-page
error path rather than reaching the network `.catch`.
bin/fm-review-decision.sh-99-115 (1)

99-115: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Escape the remaining JSON control characters.

json_escape handles ", \, \n, \r, and \t. Every other character passes through unchanged, including the control characters U+0000-U+001F. RFC 8259 forbids raw control characters inside a JSON string. COMMENT is captain-supplied text that arrives through the Pulse endpoint, so a pasted body can contain \b, \f, or an escape byte. The resulting line in .decisions.jsonl is then unparseable, and jq rejects that audit record.

Add the two named escapes and a \uXXXX fallback for the rest of the control range.

🐛 Proposed fix
     case "$ch" in
       '"')  out+='\"' ;;
       '\')  out+='\\' ;;
       $'\n') out+='\n' ;;
       $'\r') out+='\r' ;;
       $'\t') out+='\t' ;;
+      $'\b') out+='\b' ;;
+      $'\f') out+='\f' ;;
+      [[:cntrl:]]) printf -v out '%s\\u%04x' "$out" "'$ch" ;;
       *) out+="$ch" ;;
     esac
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/fm-review-decision.sh` around lines 99 - 115, Update json_escape to
explicitly escape backspace and form-feed using their JSON short escapes, and
encode every remaining character in the U+0000–U+001F range as a four-digit
\uXXXX sequence. Preserve the existing handling for quotes, backslashes,
newline, carriage return, and tab, while leaving non-control characters
unchanged.
AGENTS.md-623-645 (1)

623-645: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Consolidate the duplicate Beads sections.

AGENTS.md already defines ## Beads Issue Tracker at Line 568. The added block starts at Line 624 with the same heading and repeats the workflow. This triggers markdownlint MD024 and creates two sources of truth. Keep one canonical section. Put Codex-specific details in a short subsection or a pointer.

As per coding guidelines, keep AGENTS.md limited to knowledge useful to almost every future agent session; point to authoritative files instead of repeating codebase details, and prefer rewriting or pruning over appending.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@AGENTS.md` around lines 623 - 645, Consolidate the duplicate Beads Issue
Tracker content in AGENTS.md by retaining the existing canonical section and
removing the repeated generated block. Preserve only essential Codex-specific
guidance as a brief subsection or pointer to the Beads skill and authoritative
workflow documentation, ensuring the file has a single Beads heading and no
repeated instructions.

Sources: Coding guidelines, Linters/SAST tools

docs/configuration.md-271-273 (1)

271-273: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a shell language to the login code fence.

The fenced block starts at Line 271 without a language identifier. markdownlint reports MD040. Change the opening fence to sh or bash.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/configuration.md` around lines 271 - 273, Update the fenced code block
containing the CLAUDE_CONFIG_DIR login command to specify a shell language
identifier, such as sh or bash, on its opening fence to satisfy markdownlint
MD040.

Source: Linters/SAST tools

docs/scripts.md-32-32 (1)

32-32: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the duplicate fm-bead-stamp.sh row.

fm-bead-stamp.sh appears twice in the table, at Line 32 and at Line 56, with two different purpose clauses. Line 4 states that each row is one purpose clause only. Keep one row and merge the clauses.

✏️ Proposed fix
-| `fm-bead-stamp.sh`       | Stamp a linked bead's dispatch and lifecycle state when a task is spawned with --beads or auto-linked under the beads backend |
+| `fm-bead-stamp.sh`       | Fail-open stamp of a linked bead's dispatch and lifecycle state after a spawn with `--beads` or a beads-backend auto-link |

Then drop the second row:

-| `fm-bead-stamp.sh`       | Fail-open beads dispatch stamp: set `dispatch=sent` and assign a linked bead after spawn |

Also applies to: 56-56

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/scripts.md` at line 32, Remove the duplicate fm-bead-stamp.sh entry from
the scripts table, retaining a single row and merging both purpose clauses into
that row. Preserve the table’s one-row-per-script and one-purpose-clause
conventions.
docs/examples/herdr-spur.agents-8-10 (1)

8-10: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Comment out the example agent names.

Lines 9-10 are active entries, not comments. Line 1 tells the operator to copy this file to config/herdr-spur.agents. A plain copy therefore restricts the spur to muse and parlay-dev. Per Lines 4-6 that also disables auto-track. Prefix both names with # so a copied file keeps the documented default until the operator edits it.

🛠️ Proposed fix
 # Example: only spur firstmate for these two external agents.
-muse
-parlay-dev
+#muse
+#parlay-dev
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/examples/herdr-spur.agents` around lines 8 - 10, Comment out the example
agent entries in the herdr-spur configuration by prefixing both muse and
parlay-dev with #. Keep the explanatory comment unchanged so copying the file
preserves the documented default behavior until operators explicitly enable
specific agents.
bin/backends/herdr.sh-836-846 (1)

836-846: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Guard an empty before_order before the mover runs.

before_order is computed at Line 837 but never validated. If the jq extraction fails, the value is empty. The mover still runs at Line 838, and the recorded move record then carries an empty order field. fm_backend_herdr_emptying_move_rollback rejects that record as malformed at Line 881, so a real reorder can persist with only a warning. Abort to the plain plan when before_order is empty, before any mutation.

🛡️ Proposed guard
     before_order=$(printf '%s' "$list" | jq -c '[.result.workspaces[].workspace_id]' 2>/dev/null)
+    if [ -z "$before_order" ]; then
+      echo "warning: herdr presentation cleanup could not record the pre-move workspace order; closing without the focus-safe removal path" >&2
+      printf 'plain\n'
+      return 0
+    fi
     if response=$("$mover" "$socket" "$ws_id" "$len" 2>/dev/null); then
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/backends/herdr.sh` around lines 836 - 846, Validate that before_order is
non-empty immediately after it is computed in the workspace move flow, before
invoking the mover or recording the move. If extraction fails or returns empty,
abort this move and continue through the existing plain-plan path, ensuring no
mutation or malformed rollback record is produced.
bin/fm-teardown.sh-1287-1307 (1)

1287-1307: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Record the Herdr lock before installing the EXIT handler.

fm_lock_try_acquire "$lock_path" can succeed between leaving the retry loop and trap teardown_release_herdr_locks EXIT, so a signal in that window can leave the lock unreleased. Record the acquisition state first, then arm the EXIT handler, and use that state to release only the acquired lock in teardown_release_herdr_locks.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/fm-teardown.sh` around lines 1287 - 1307, Record the successfully
acquired Herdr lock in TEARDOWN_HERDR_LOCK_RECORDS before installing the EXIT
trap, closing the signal window in the acquisition path around
fm_lock_try_acquire and teardown_release_herdr_locks. Update
teardown_release_herdr_locks to release only locks recorded as acquired, while
preserving the existing verification and retry behavior.
tests/fm-backend-herdr.test.sh-2455-2458 (1)

2455-2458: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Guard the boundary range against a reversed sed address.

agent_line + 1 and close_line - 1 form the range. If the close directly follows the agent recheck, agent_line + 1 equals close_line, so the start address exceeds the end address. sed -n 'N,Mp' with N > M prints line N, which is the pane close line itself. That line does not match the grep -Ev allowlist, so boundary_mutations becomes non-empty and the test reports a mutation that does not exist.

The current fixture places a tab list between the two calls, so the range is valid today. The assertion inverts as soon as the adapter stops making that intermediate call, which is the strictest and most correct behavior.

🐛 Proposed guard
-  boundary_mutations=$(sed -n "$((agent_line + 1)),$((close_line - 1))p" "$log" \
-    | grep -Ev $'\x1f(tab\x1flist|pane\x1flist|workspace\x1flist)' || true)
-  [ -z "$boundary_mutations" ] \
-    || fail "reclaim mutated between the old pane agent recheck and the close: $boundary_mutations"
+  boundary_mutations=
+  if [ "$((agent_line + 1))" -le "$((close_line - 1))" ]; then
+    boundary_mutations=$(sed -n "$((agent_line + 1)),$((close_line - 1))p" "$log" \
+      | grep -Ev $'\x1f(tab\x1flist|pane\x1flist|workspace\x1flist)' || true)
+  fi
+  [ -z "$boundary_mutations" ] \
+    || fail "reclaim mutated between the old pane agent recheck and the close: $boundary_mutations"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/fm-backend-herdr.test.sh` around lines 2455 - 2458, Guard the boundary
extraction around boundary_mutations so it runs only when agent_line + 1 is less
than or equal to close_line - 1; otherwise treat the range as empty. Preserve
the existing grep filtering and mutation failure behavior for valid ranges,
preventing sed from printing the pane close line when no lines exist between the
calls.
tests/fm-teardown.test.sh-1537-1561 (1)

1537-1561: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the test root for Herdr adapter resolution.

Run the mutated $case_dir/test-root/bin/fm-teardown.sh$ under the test root, not FM_ROOT_OVERRIDE="$ROOT"$, so fm_backend_source herdr cannot load the pristine bin/backends/herdr.sh. As written, missing-adapter, missing-parser$, and missing-explicit-close-helper` can source the unmutated adapter and report false positives.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/fm-teardown.test.sh` around lines 1537 - 1561, Update the teardown
invocation for the mutated test binary in the case setup so FM_ROOT_OVERRIDE
points to the case-specific test root rather than the pristine ROOT. Preserve
the existing state, config, fake Herdr environment, and PATH overrides, ensuring
fm_backend_source herdr resolves the mutated adapter for missing-adapter,
missing-parser, and missing-explicit-close-helper modes.
tests/fm-herdr-session-cleanup.test.sh-31-47 (1)

31-47: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add the comm case to the fake ps.

fm_backend_herdr_pane_idle_shell_pid only uses the argv path for this test, but fm_backend_herdr_death_close_pane resamples the same pid via fm_backend_herdr_pid_is_bare_shell and reads ps -p <pid> -o comm= there. Without a *-o comm= case, the command returns exit code 4 for that argument list, so the session-cleanup path can fail on kill -HUP rather than the fixture being able to complete the idle-shell shutdown path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/fm-herdr-session-cleanup.test.sh` around lines 31 - 47, Add a matching
ps fixture branch in FAKE_PS for the “-p 67 -o comm=” invocation, returning the
expected bare-shell command name so fm_backend_herdr_pid_is_bare_shell can
complete during fm_backend_herdr_death_close_pane. Keep the existing argv and
process-list cases unchanged.
bin/claude-account.sh-48-54 (1)

48-54: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Handle a dangling symlink at dest.

[ ! -e "$dest" ] follows symlinks. If dest is a symlink whose target was removed from ~/.claude/, the test returns true, ln -s then fails with "File exists", and set -e aborts the launcher while the bootstrap lock is held. Test for the link itself as well, and replace the dangling link.

🐛 Proposed fix
 for item in commands hooks skills mcp-configs settings.json settings.local.json rules agents; do
   src="$HOME/.claude/$item"
   dest="$CLAUDE_CONFIG_DIR/$item"
-  if [ -e "$src" ] && [ ! -e "$dest" ]; then
+  if [ -L "$dest" ] && [ ! -e "$dest" ]; then
+    rm -f "$dest"
+  fi
+  if [ -e "$src" ] && [ ! -e "$dest" ] && [ ! -L "$dest" ]; then
     ln -s "$src" "$dest"
   fi
 done
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/claude-account.sh` around lines 48 - 54, Update the destination existence
check in the loop over commands, hooks, skills, and related entries to detect
dangling symlinks by testing the link itself as well as its target. When dest is
a dangling symlink, remove or replace that link before running ln -s; preserve
the existing behavior for valid destinations and missing sources.
bin/fm-isolated-launch.sh-76-78 (1)

76-78: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

--help truncates the header before the usage and environment sections.

sed -n '2,58p' stops at line 58. In the current file, line 58 is the first # Usage: line, so --help omits the second usage form, the Environment overrides block, and the forwarding note. The fixed line range also breaks again on every header edit. Print the leading comment block until the first non-comment line instead.

🐛 Proposed fix
 fm_isolated_launch_usage() {
-  sed -n '2,58p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
+  awk '
+    NR == 1 { next }
+    /^`#/` { sub(/^# ?/, ""); print; next }
+    { exit }
+  ' "${BASH_SOURCE[0]}"
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/fm-isolated-launch.sh` around lines 76 - 78, Update
fm_isolated_launch_usage to print the complete leading comment block through the
first non-comment line instead of using the fixed sed range 2,58. Preserve the
existing comment-prefix stripping so --help includes all usage, environment, and
forwarding sections and remains resilient to header edits.
bin/fm-brief.sh-145-173 (1)

145-173: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Reject missing task IDs before reading POS[0].

bin/fm-brief.sh runs under set -eu, and ID=${POS[0]} is executed before any guard for ${#POS[@]}. A caller that provides only flags leaves POS empty, so the script exits with bash’s unbound variable error instead of the script usage/error path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/fm-brief.sh` around lines 145 - 173, Validate that POS contains at least
one positional argument before assigning ID from POS[0]. Add this guard
immediately before the ID assignment, using the script’s existing usage/error
path so flag-only invocations produce a deliberate missing-task-ID error instead
of a set -u failure.
bin/fm-isolated-launch.sh-115-123 (1)

115-123: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Create the credential temp file with restrictive permissions before writing the token.

The redirection creates .credentials.json.tmp with the current umask, and security writes the OAuth token into it. chmod 600 runs only after the write completes. With a default umask of 022, the token is readable by other local users during that window. Set the umask before the redirection so the file is never group- or world-readable.

🔒️ Proposed fix
 if [ ! -f "$ISOLATED_HOME/.claude/.credentials.json" ] && command -v security >/dev/null 2>&1; then
-  if security find-generic-password -a "$USER" -s "Claude Code-credentials" -w \
-       > "$ISOLATED_HOME/.claude/.credentials.json.tmp" 2>/dev/null; then
-    chmod 600 "$ISOLATED_HOME/.claude/.credentials.json.tmp"
+  if (umask 077 && security find-generic-password -a "$USER" -s "Claude Code-credentials" -w \
+       > "$ISOLATED_HOME/.claude/.credentials.json.tmp" 2>/dev/null); then
     mv "$ISOLATED_HOME/.claude/.credentials.json.tmp" "$ISOLATED_HOME/.claude/.credentials.json"
   else
     rm -f "$ISOLATED_HOME/.claude/.credentials.json.tmp"
   fi
 fi
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/fm-isolated-launch.sh` around lines 115 - 123, Update the credential
extraction block around security find-generic-password so the temporary file is
created with restrictive permissions before the token is redirected into it. Set
a restrictive umask for the command or enclosing operation before the
redirection, while preserving the existing chmod, successful mv, and cleanup
behavior.
bin/fm-groom.sh-225-238 (1)

225-238: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The first in-flight guard is dead code.

The condition at line 232 is [ $((in_flight + dispatched_this_run)) -ge "$MAX_IN_FLIGHT" ]. The condition at line 225 is the same test plus [ "$dispatched_this_run" -ge 1 ]. Line 225 is therefore strictly narrower than line 232, and both bodies are identical. Any idea that reaches line 225 and matches also matches line 232. The first block can never change the outcome.

Delete the first block.

♻️ Proposed fix
   if [ "$verdict" = "safe" ]; then
     # (4) SAFE -> dispatch (rate-limited). If we're at the in-flight cap, defer.
-    if [ "$dispatched_this_run" -ge 1 ] && [ $((in_flight + dispatched_this_run)) -ge "$MAX_IN_FLIGHT" ]; then
-      action="DEFER dispatch (in-flight cap $MAX_IN_FLIGHT reached)"
-      if [ "$JSON" -eq 1 ]; then emit_json "$id" "$title" "$verdict" "deferred" "$brief" "$rationale"
-      else emit_human "$id" "$title" "$verdict" "$action" "$brief" "$rationale"; fi
-      acted=$((acted + 1))
-      continue
-    fi
     if [ $((in_flight + dispatched_this_run)) -ge "$MAX_IN_FLIGHT" ]; then

If the first block was meant to express a different rail, state that rail explicitly; as written the two are indistinguishable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/fm-groom.sh` around lines 225 - 238, Remove the redundant first in-flight
guard before the remaining cap check in the dispatch flow. Keep the broader `[
$((in_flight + dispatched_this_run)) -ge "$MAX_IN_FLIGHT" ]` guard and its
existing defer output unchanged.
tests/claude-account.test.sh-105-112 (1)

105-112: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The leak assertion greps for a string the shared files never contain.

Lines 105-106 write {"shared":"leak"} into the shared ~/.claude/.credentials.json and ~/.claude/.claude.json. Line 111 then asserts that the account's .claude.json does not contain acct1-secret. acct1-secret is the account's own credential token from line 28. It is not the content of either planted shared file. The launcher generates .claude.json from the Python block, so that token can never appear there. The assertion therefore passes unconditionally and does not detect a shared-file leak.

Grep for the planted marker instead.

🐛 Proposed fix
-  assert_no_grep "acct1-secret" "$home/.claude-homes/account1/.claude.json" \
-    ".claude.json should not have been overwritten by any shared file"
+  assert_no_grep "leak" "$home/.claude-homes/account1/.claude.json" \
+    ".claude.json should not have been seeded from the shared ~/.claude/.claude.json"
+  assert_no_grep "leak" "$home/.claude-homes/account1/.claude/.credentials.json" \
+    ".credentials.json should not have been seeded from the shared ~/.claude/.credentials.json"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/claude-account.test.sh` around lines 105 - 112, Update the leak
assertion in the account-launch test to search for the planted shared-file
marker "shared":"leak" rather than the account-specific token "acct1-secret".
Keep the existing symlink checks and target the generated account .claude.json
path so the assertion detects content copied from either shared file.
bin/fm-groom.sh-78-78 (1)

78-78: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The fixed sed range prints set -eu into the help output.

Line 52 is set -eu, and the range is 2,52p. The help text therefore ends with a stray set -eu line. The range also breaks silently every time the header block grows. bin/fm-spawn.sh lines 166-169 record this exact failure and derive the range instead.

🐛 Proposed fix
-usage() { sed -n '2,52p' "$0" | sed 's/^# \{0,1\}//'; }
+# Derived rather than a fixed line range, which truncates or overruns whenever
+# the header comment block above changes length (see bin/fm-spawn.sh usage()).
+usage() { sed -n '2,${/^`#/`!q;p;}' "$0" | sed 's/^# \{0,1\}//'; }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/fm-groom.sh` at line 78, Update the usage() function in fm-groom.sh to
derive the help-text range from the script’s header markers or structure instead
of using the fixed 2,52p range, excluding the set -eu line and keeping help
output accurate when the header grows. Follow the dynamic range approach used by
fm-spawn.sh.
bin/fm-spawn.sh-287-300 (1)

287-300: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Refuse --account for secondmate spawns.

The secondmate usage form does not document --account, but fm-spawn.sh accepts --account=2 --harness=claude --secondmate, resolves Claude as the harness, and then mutates the Claude launch wrapper and CLAUDE_TRUST_DIR. Add the same kind-specific rejection used for --beads, or document secondmate support explicitly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/fm-spawn.sh` around lines 287 - 300, Reject --account when KIND is
secondmate, alongside the existing --beads kind-specific validation in the
argument checks. Preserve account validation for supported crewmate ship or
scout spawns and prevent secondmate flow from mutating the Claude launch wrapper
or CLAUDE_TRUST_DIR.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.codex/hooks.json:
- Around line 13-21: Update .codex/hooks.json entries at lines 13-21 and 41-75:
guard every bd codex-hook command, including SessionStart, PreCompact,
PostCompact, and UserPromptSubmit, with command -v bd so missing dependencies
fail open, and add a 10-second timeout to each hook; prioritize UserPromptSubmit
while applying the same change to all four entries.

In `@bin/fm-groom-json-field.sh`:
- Around line 25-27: Remove the immediate process.exit(0) after
process.stdout.write in the JSON field output path, allowing stdout to flush
fully before the script terminates; alternatively, move termination into the
write completion callback while preserving the existing successful output
behavior.

In `@bin/fm-groom-lib.sh`:
- Around line 114-125: Update the classification flow around fm_groom_infer and
verdict handling to inspect the combined title and brief for the high-risk terms
deploy, merge, credential, rotate, delete, or migrate before accepting the model
verdict. Force verdict=escalate when any term matches, regardless of classifier
output; otherwise preserve the existing safe/escalate parsing and fail-safe
behavior.

In `@bin/fm-groom.sh`:
- Around line 270-280: Update the review CLI branch around REVIEW_BIN and the
review q invocation so a failed or non-zero review q leaves the idea unmarked
and retryable. Preserve the non-fatal behavior required by set -eu by capturing
an empty rid on failure, then only run the groom state update, note, and “FILED
review item” action when rid is non-empty; otherwise use the same retryable
outcome as the missing-CLI branch.
- Around line 240-253: Update the task_id construction in the grooming dispatch
flow to lowercase the idea id before sanitizing it, then reject the dispatch
when the sanitized value is empty or when the resulting task_id is already in
use, including an existing state/<task-id>.meta or task data directory. Preserve
the existing idempotency and accounting behavior by performing these checks
before creating the brief or spawning the crewmate.

In `@bin/fm-herdr-spur.sh`:
- Around line 259-301: Track an explicit subscription acknowledgement flag in
fm_herdr_spur_event_block, set it when the reader emits `@subscribed`, and return
a nonzero failure status if the stream ends without that acknowledgement.
Preserve the existing event processing and successful return path when
`@subscribed` is observed so the caller’s existing sleep/backoff fallback handles
reader or subscription failures.

In `@bin/fm-idea-mine.ts`:
- Around line 797-817: Update fileHarvest to accept the mined harvest data,
build the complete review body after the idea-filing loop when all filed and
skipped IDs are known, and pass that body directly to createIssue instead of
"PLACEHOLDER". Remove the subsequent review update logic in main so the review
is created only once with its final content while preserving the existing
idempotency flow.

In `@bin/fm-spawn-hooks.d/beads.sh`:
- Around line 29-49: Validate FM_HOOK_BEADS_ID and FM_HOOK_ID in the beads hook
before constructing CHECK and interpolating either value, accepting only
characters in [A-Za-z0-9._-] and exiting on invalid input. Update the generated
script’s task show invocation to quote the interpolated bead ID so it is passed
as one argument, while preserving the existing check behavior.

In `@tests/fm-review-page.test.sh`:
- Around line 94-104: Update the run_tool function to explicitly set
FM_REVIEW_PAGE_NO_WIRE to its enabled/default wire-back value when invoking the
tool, preventing inherited environment values from altering test behavior. Keep
the existing FM_REVIEW_BIN, FM_REVIEW_PAGE_OUT, and FM_REVIEW_PAGE_BASE_URL
setup unchanged.

---

Minor comments:
In `@AGENTS.md`:
- Around line 623-645: Consolidate the duplicate Beads Issue Tracker content in
AGENTS.md by retaining the existing canonical section and removing the repeated
generated block. Preserve only essential Codex-specific guidance as a brief
subsection or pointer to the Beads skill and authoritative workflow
documentation, ensuring the file has a single Beads heading and no repeated
instructions.

In `@bin/backends/herdr.sh`:
- Around line 836-846: Validate that before_order is non-empty immediately after
it is computed in the workspace move flow, before invoking the mover or
recording the move. If extraction fails or returns empty, abort this move and
continue through the existing plain-plan path, ensuring no mutation or malformed
rollback record is produced.

In `@bin/claude-account.sh`:
- Around line 48-54: Update the destination existence check in the loop over
commands, hooks, skills, and related entries to detect dangling symlinks by
testing the link itself as well as its target. When dest is a dangling symlink,
remove or replace that link before running ln -s; preserve the existing behavior
for valid destinations and missing sources.

In `@bin/fm-brief.sh`:
- Around line 145-173: Validate that POS contains at least one positional
argument before assigning ID from POS[0]. Add this guard immediately before the
ID assignment, using the script’s existing usage/error path so flag-only
invocations produce a deliberate missing-task-ID error instead of a set -u
failure.

In `@bin/fm-groom.sh`:
- Around line 225-238: Remove the redundant first in-flight guard before the
remaining cap check in the dispatch flow. Keep the broader `[ $((in_flight +
dispatched_this_run)) -ge "$MAX_IN_FLIGHT" ]` guard and its existing defer
output unchanged.
- Line 78: Update the usage() function in fm-groom.sh to derive the help-text
range from the script’s header markers or structure instead of using the fixed
2,52p range, excluding the set -eu line and keeping help output accurate when
the header grows. Follow the dynamic range approach used by fm-spawn.sh.

In `@bin/fm-isolated-launch.sh`:
- Around line 76-78: Update fm_isolated_launch_usage to print the complete
leading comment block through the first non-comment line instead of using the
fixed sed range 2,58. Preserve the existing comment-prefix stripping so --help
includes all usage, environment, and forwarding sections and remains resilient
to header edits.
- Around line 115-123: Update the credential extraction block around security
find-generic-password so the temporary file is created with restrictive
permissions before the token is redirected into it. Set a restrictive umask for
the command or enclosing operation before the redirection, while preserving the
existing chmod, successful mv, and cleanup behavior.

In `@bin/fm-review-decision.sh`:
- Around line 99-115: Update json_escape to explicitly escape backspace and
form-feed using their JSON short escapes, and encode every remaining character
in the U+0000–U+001F range as a four-digit \uXXXX sequence. Preserve the
existing handling for quotes, backslashes, newline, carriage return, and tab,
while leaving non-control characters unchanged.

In `@bin/fm-review-page.ts`:
- Around line 824-835: Preserve the HTTP status through the promise chain in the
fetch handling around the first `.then` and the `r` response object by including
it in the object returned after `res.json()`, then use that status in the
fallback error text instead of the empty string. Also ensure JSON parsing or
non-JSON responses remain on the existing in-page error path rather than
reaching the network `.catch`.

In `@bin/fm-spawn.sh`:
- Around line 287-300: Reject --account when KIND is secondmate, alongside the
existing --beads kind-specific validation in the argument checks. Preserve
account validation for supported crewmate ship or scout spawns and prevent
secondmate flow from mutating the Claude launch wrapper or CLAUDE_TRUST_DIR.

In `@bin/fm-teardown.sh`:
- Around line 1287-1307: Record the successfully acquired Herdr lock in
TEARDOWN_HERDR_LOCK_RECORDS before installing the EXIT trap, closing the signal
window in the acquisition path around fm_lock_try_acquire and
teardown_release_herdr_locks. Update teardown_release_herdr_locks to release
only locks recorded as acquired, while preserving the existing verification and
retry behavior.

In `@docs/configuration.md`:
- Around line 271-273: Update the fenced code block containing the
CLAUDE_CONFIG_DIR login command to specify a shell language identifier, such as
sh or bash, on its opening fence to satisfy markdownlint MD040.

In `@docs/examples/herdr-spur.agents`:
- Around line 8-10: Comment out the example agent entries in the herdr-spur
configuration by prefixing both muse and parlay-dev with #. Keep the explanatory
comment unchanged so copying the file preserves the documented default behavior
until operators explicitly enable specific agents.

In `@docs/scripts.md`:
- Line 32: Remove the duplicate fm-bead-stamp.sh entry from the scripts table,
retaining a single row and merging both purpose clauses into that row. Preserve
the table’s one-row-per-script and one-purpose-clause conventions.

In `@tests/claude-account.test.sh`:
- Around line 105-112: Update the leak assertion in the account-launch test to
search for the planted shared-file marker "shared":"leak" rather than the
account-specific token "acct1-secret". Keep the existing symlink checks and
target the generated account .claude.json path so the assertion detects content
copied from either shared file.

In `@tests/fm-backend-herdr.test.sh`:
- Around line 2455-2458: Guard the boundary extraction around boundary_mutations
so it runs only when agent_line + 1 is less than or equal to close_line - 1;
otherwise treat the range as empty. Preserve the existing grep filtering and
mutation failure behavior for valid ranges, preventing sed from printing the
pane close line when no lines exist between the calls.

In `@tests/fm-herdr-session-cleanup.test.sh`:
- Around line 31-47: Add a matching ps fixture branch in FAKE_PS for the “-p 67
-o comm=” invocation, returning the expected bare-shell command name so
fm_backend_herdr_pid_is_bare_shell can complete during
fm_backend_herdr_death_close_pane. Keep the existing argv and process-list cases
unchanged.

In `@tests/fm-teardown.test.sh`:
- Around line 1537-1561: Update the teardown invocation for the mutated test
binary in the case setup so FM_ROOT_OVERRIDE points to the case-specific test
root rather than the pristine ROOT. Preserve the existing state, config, fake
Herdr environment, and PATH overrides, ensuring fm_backend_source herdr resolves
the mutated adapter for missing-adapter, missing-parser, and
missing-explicit-close-helper modes.

---

Nitpick comments:
In @.claude/settings.json:
- Around line 13-20: Update the hook command associated with “bd prime
--hook-json” to first verify that the bd executable is available on PATH, and
only run bd prime when it is; otherwise exit silently. Preserve the existing
hook behavior when bd is installed and follow the file’s established
shell-command style.

In `@bin/backends/herdr.sh`:
- Around line 2763-2793: The plan parsing, death-or-explicit close handling,
repositioned-workspace confirmation, and rollback logic is duplicated between
the shown flow and fm_backend_herdr_projection_close_pane_focus_preserving.
Extract this shared sequence into one helper that returns the close status, then
have both callers invoke it while retaining their existing focus-restoration and
return-code policies.

In `@bin/claude-account.sh`:
- Line 62: Add a python3 availability guard before the bootstrap heredoc in
bin/claude-account.sh, using the existing command -v pattern from
bin/fm-isolated-launch.sh. If python3 is unavailable, emit a clear message and
either exit appropriately or skip the Python bootstrap so execution can continue
to exec claude.

In `@bin/fm-brief.sh`:
- Line 518: Rewrite the instruction near the no-mistakes `--intent` handling as
separate short sentences. State independently that it must preserve relevant `#
Task` content, include later accepted requirements, keep only each requirement’s
current accepted form, retain direct requirements rather than a diff summary,
and exclude generic scaffold boilerplate unless task-specific.
- Around line 175-179: In the BEADS_ID validation block, simplify the case
pattern and nested test so the validation rule is expressed directly: allow an
empty ID, but reject non-empty IDs containing characters outside A-Za-z0-9._-.
Preserve the existing error message and exit behavior.

In `@bin/fm-groom-lib.sh`:
- Around line 47-55: Update fm_groom_infer to bound both inference command
branches with timeout when the timeout utility is available, preserving the
existing FM_GROOM_INFERENCE and bun execution paths. Use the existing
standard-tier 30-second ceiling, and fall back to the current unwrapped commands
when timeout is unavailable so callers continue receiving the command’s non-zero
status on timeout.

In `@bin/fm-groom.sh`:
- Around line 257-262: Update the spawn invocation in the dispatch loop around
SPAWN_BIN to redirect its output to a per-task log file instead of /dev/null.
Include that log file’s name or path in the failure action assigned to action,
while preserving the existing success and failure handling.
- Line 96: Extend the prerequisite validation block in bin/fm-groom.sh alongside
the existing IDEAS_BIN check to run command -v for SPAWN_BIN before any grooming
dispatch or idempotency marker is written. Preserve the current fail-fast
error-and-exit behavior and use SPAWN_BIN’s configured value in the diagnostic.

In `@bin/fm-isolated-launch.sh`:
- Around line 155-165: Update the Python block that edits .claude.json to catch
JSON parsing or file-load failures using the same try/except pattern as the
preceding settings block, defaulting to an empty object on failure while
preserving all existing keys on successful parses before setting
bypassPermissionsModeAccepted.

In `@bin/fm-review-page.ts`:
- Around line 558-582: Rename the local regex-escaped key variable in the loop
over spec, currently named esc, to a distinct name and update both headRe and
inlineRe template expressions to use it, preserving the module-level esc() HTML
escaper name.
- Around line 1045-1111: Update the HELP text to document main’s exit codes 0,
1, 2, and 3. Propagate missing-ID information from fetchByIds to main, and
return a non-zero status when any requested ID is absent, while preserving the
existing all-miss behavior and successful rendering of found items. Ensure --all
is unaffected and the partial-miss result is clearly reported to batch callers.

In `@bin/fm-spawn.sh`:
- Around line 1913-1925: Update the hook contract header near the existing
“sourced” documentation to explicitly state that hooks must contain
Bash-compatible code because their shebangs are ignored when sourced. Keep the
current executable-file check, subshell isolation, and fail-open behavior
unchanged.

In `@docs/herdr-backend.md`:
- Line 112: Split the sentence at the transition from removal confirmation to
rollback behavior. Keep the move-to-last ordering and exact-workspace
confirmation claims in the first sentence, then state separately that an
unconfirmed removal makes one verified rollback attempt under the same session
lock to restore the workspace to its exact original position.

In `@tests/claude-account.test.sh`:
- Around line 116-126: Update test_prewrites_onboarding_and_trust_dialog to
include status in its local variable declaration alongside rec, home, fakebin,
log, and trust_dir, ensuring the subsequent exit-code assignment remains scoped
to the test.

In `@tests/fm-backend-herdr-focus-flash-e2e.test.sh`:
- Around line 140-151: Throttle the background sampler loop in the subshell that
checks B_OPERATION_ACTIVE by adding a short delay on each iteration, including
iterations without a sample. Use an approximately 10 ms tick before the next
loop iteration so focus_snapshot is not called in a tight loop while preserving
the existing sampling and UNREADABLE handling.

In `@tests/fm-backend-herdr.test.sh`:
- Around line 1508-1512: Update the SIGHUP-trapping fixtures in
tests/fm-backend-herdr.test.sh at lines 1508-1512, 1544-1548, 1613-1617, and
1657-1661 to add exec before sleep 300 in each bash command, so bgpid directly
identifies the process being signaled; apply this consistently in
test_projection_close_death_escalates_sigkill_after_sighup_survival,
test_projection_close_death_failure_falls_back_to_plain_close,
test_projection_close_death_never_sigkills_a_reused_pid, and
assert_projection_close_failed_removal_rolls_back_the_reposition.

In `@tests/fm-beads-hooks.test.sh`:
- Around line 126-134: Add coverage adjacent to
test_brief_beads_rejects_invalid_id for the missing-value and option-as-value
--beads paths, asserting exit code 1 and the “requires a value” message, and for
the --beads=<id> form, asserting successful execution and a rendered “# Bead
Receipt” section in the generated brief.
- Around line 265-291: Add a test case alongside
test_spawn_hook_is_fail_open_without_task_cli and
test_spawn_hook_noop_without_beads_id that sets FM_HOOK_BEADS_ID but omits
FM_HOOK_STATE (while providing the other required context), then invokes
beads.sh and captures output and status. Assert exit status 0 and the
missing-context warning text, covering the fail-open branch for empty
FM_HOOK_ID, FM_HOOK_STATE, or FM_HOOK_ROOT.

In `@tests/fm-brief.test.sh`:
- Around line 237-242: Reuse the existing brief variable in the assertions
around the direct-intent case instead of repeating the literal brief path. After
changing id for the direct project, reassign brief to the corresponding brief.md
before the second assert_no_grep, and update the pass message to mention both
authority assertions and the --intent contract.

In `@tests/fm-groom.test.sh`:
- Around line 203-212: Add two safety-path test cases in tests/fm-groom.test.sh:
seed enough state/groom-*.meta files to reach FM_GROOM_MAX_IN_FLIGHT, then
assert run_groom reports a deferred outcome; also make the review mock exit
non-zero for an unsafe idea and assert the expected failure behavior while
verifying the idea is still marked groom:escalated. Reuse the existing
reset_mock_state, run_groom, and assertion patterns.

In `@tests/fm-idea-mine.test.sh`:
- Around line 237-255: Add a `--force` scenario to
`test_idempotency_marker_skips_unchanged_tail` (or a dedicated adjacent test)
that reruns the unchanged transcript with the force option, verifies filing
occurs despite the marker, and confirms the normal non-forced rerun still
remains unchanged. Exercise the `!cfg.force` decision in `fm-idea-mine.ts`
without altering the existing marker-hit assertions.

In `@tests/fm-review-decision.test.sh`:
- Around line 237-244: Add a final completion-marker echo after the
t_missing_args call in the test sequence, matching the sibling test files’
format and clearly indicating that all fm-review-decision tests passed.
- Around line 191-202: The t_note_failure test currently verifies only the
non-zero result and diagnostic; extend it to assert that the queue retains the
failed decision’s review-decision wake after run_decision returns. Use the
existing queue inspection/assertion helpers and verify the key for review-znt
remains present, covering the wake enqueued before review note failure.

In `@tests/fm-review-page.test.sh`:
- Around line 465-477: Add a test case to the listed suite in
tests/fm-review-page.test.sh that exercises the wire-back failure path in
bin/fm-review-page.ts: stub the relevant wire-back verb to fail while allowing
page writes to complete, then assert the command exits with status 3. Follow the
existing single-verb failure stub pattern from fm-review-decision.test.sh and
preserve the other cases unchanged.

In `@tests/fm-spawn-account.test.sh`:
- Around line 141-160: Add a test covering the equals-sign syntax
`--account=<N>` in the account-flag test suite, mirroring the existing positive
or rejection assertions and verifying the same behavior as the space-separated
form. Define the test near `test_account_flag_rejects_non_positive_integer` and
invoke it with the other account flag tests.

In `@tests/fm-teardown.test.sh`:
- Around line 1445-1455: Bound the background holder’s wait loop in the shell
launched by the contending-lock setup, using a finite iteration counter or
timeout while waiting for $RELEASE. Ensure the holder exits even when the parent
test fails before creating the release marker, while preserving normal lock
release when the marker appears.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 50467f5b-c620-48f8-b0db-b04689442037

📥 Commits

Reviewing files that changed from the base of the PR and between 36eb824 and f45def6.

📒 Files selected for processing (55)
  • .agents/skills/firstmate-coding-guidelines/SKILL.md
  • .agents/skills/harness-adapters/SKILL.md
  • .agents/skills/herdr-navigation/SKILL.md
  • .agents/skills/quota-array-dispatch/SKILL.md
  • .claude/settings.json
  • .codex/hooks.json
  • .github/workflows/ci.yml
  • .gitignore
  • AGENTS.md
  • README.md
  • bin/backends/herdr.sh
  • bin/claude-1.sh
  • bin/claude-2.sh
  • bin/claude-account.sh
  • bin/fm-brief.sh
  • bin/fm-groom-json-field.sh
  • bin/fm-groom-lib.sh
  • bin/fm-groom.sh
  • bin/fm-herdr-session-cleanup.sh
  • bin/fm-herdr-spur.sh
  • bin/fm-idea-mine.ts
  • bin/fm-isolated-launch.sh
  • bin/fm-review-decision.sh
  • bin/fm-review-page.ts
  • bin/fm-spawn-hooks.d/beads.sh
  • bin/fm-spawn.sh
  • bin/fm-teardown.sh
  • bin/fm-test-isolation-proof.sh
  • bin/fm-test-run.sh
  • docs/architecture.md
  • docs/configuration.md
  • docs/documentation-audiences.json
  • docs/examples/crew-dispatch.json
  • docs/examples/herdr-spur.agents
  • docs/herdr-backend.md
  • docs/scripts.md
  • docs/verification/dispatch-auth.md
  • docs/verification/runtime-backends.md
  • tests/claude-account.test.sh
  • tests/fm-backend-herdr-focus-flash-e2e.test.sh
  • tests/fm-backend-herdr-presentation-e2e.test.sh
  • tests/fm-backend-herdr.test.sh
  • tests/fm-beads-hooks.test.sh
  • tests/fm-bearings-snapshot.test.sh
  • tests/fm-brief.test.sh
  • tests/fm-groom.test.sh
  • tests/fm-herdr-session-cleanup-e2e.test.sh
  • tests/fm-herdr-session-cleanup.test.sh
  • tests/fm-idea-mine.test.sh
  • tests/fm-quota-array-dispatch-live-e2e.test.sh
  • tests/fm-review-decision.test.sh
  • tests/fm-review-page.test.sh
  • tests/fm-session-start.test.sh
  • tests/fm-spawn-account.test.sh
  • tests/fm-teardown.test.sh
💤 Files with no reviewable changes (1)
  • tests/fm-bearings-snapshot.test.sh

Comment thread .codex/hooks.json
Comment thread bin/fm-groom-json-field.sh Outdated
Comment thread bin/fm-groom-lib.sh
Comment on lines +114 to +125
user=$(printf 'Task title: %s\n\nTask description: %s\n\nAnswer now.' "$title" "$brief")
# standard tier (sonnet, 30s): the fast/haiku 15s ceiling is too tight for the
# safety reasoning, and a timeout would fail-safe to escalate -- costing the
# captain a needless review item. Classification reliability is worth one tier up.
out=$(fm_groom_infer standard "$system" "$user") || return 1
# ASCII-only on purpose: the verdict vocabulary is "safe"/"escalate".
# shellcheck disable=SC2018,SC2019
verdict=$(printf '%s\n' "$out" | sed -e '/./,$!d' | head -1 | tr 'A-Z' 'a-z' | tr -cd 'a-z')
case "$verdict" in
safe|escalate) : ;;
*) verdict=escalate ;; # fail-safe: anything unparseable escalates
esac

@coderabbitai coderabbitai Bot Aug 2, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Identify every writer into the ideas store to establish the trust boundary for groom classification.
set -u
rg -n -C4 'ideas (create|add|q|new)\b' --hidden -g '!.git' || true
fd -H -t f -e ts -e sh . bin | xargs rg -n -C3 'ideas\b' 2>/dev/null | head -100

Repository: trillium/firstmate

Length of output: 156


🏁 Script executed:

#!/bin/bash
set -u
printf 'Repository files matching fm-groom:\n'
git ls-files | rg '(^|/)fm-groom(-lib)?\.sh$|bin/' || true

printf '\nOutline bin/fm-groom-lib.sh:\n'
ast-grep outline bin/fm-groom-lib.sh 2>/dev/null || true

printf '\nRelevant bin/fm-groom-lib.sh lines 1-160:\n'
sed -n '1,160p' bin/fm-groom-lib.sh | nl -ba

printf '\nSearch for ideas/store/writer terms:\n'
rg -n -C3 'ideas|store\b|idea|brief|title|groom' --hidden -g '!node_modules' --glob '*.sh' --glob '*.ts' . | head -200

Repository: trillium/firstmate

Length of output: 17192


🏁 Script executed:

#!/bin/bash
set -u

printf 'bin/fm-idea-mine.ts outline:\n'
ast-grep outline bin/fm-idea-mine.ts 2>/dev/null || true

printf '\nbin/fm-idea-mine.ts relevant commands:\n'
rg -n -C4 'command|create|mine|capture|title|brief|description|FM_IDEA|MINE' bin/fm-idea-mine.ts

printf '\nbin/fm-groom-lib.sh full relevant content:\n'
sed -n '1,220p' bin/fm-groom-lib.sh

printf '\nbin/fm-groom.sh relevant command handling and dispatch call:\n'
rg -n -C5 'groom-.*lib|groom_category|groom_id|action|safe|escalate|FM_GROOM|dispatch|spawn|review' bin/fm-groom.sh

printf '\nAll deterministic files containing ideas command name creation paths:\n'
python3 - <<'PY'
from pathlib import Path
roots = Path('.')
patterns=[]
for p in roots.rglob('**'):
    if p.is_file() and not any(part in {'.git','node_modules'} for part in p.parts):
        try:
            txt=p.read_text(errors='ignore')
        except Exception:
            continue
        if 'create' in txt and 'ideas' in txt:
            patterns.append(str(p))
print('\n'.join(patterns))
PY

printf '\nSearch for mined chat/mine/capture flows:\n'
rg -n -i -C4 'mine|captur|scrape|mining|mined|chat.*capture|capture.*chat|prompt.*title|shorten|brief' bin tests README.md 2>/dev/null | head -250

Repository: trillium/firstmate

Length of output: 39679


🏁 Script executed:

#!/bin/bash
set -u

printf 'bin/fm-idea-mine.ts lines 250-330 (sanitizeText and mineTranscript):\n'
sed -n '250,330p' bin/fm-idea-mine.ts

printf '\nbin/fm-idea-mine.ts lines 559-790 (candidate generation, evaluation, createIssue):\n'
sed -n '559,790p' bin/fm-idea-mine.ts

printf '\nbin/fm-idea-mine.ts lines 900-975 (main and direct run):\n'
sed -n '900,975p' bin/fm-idea-mine.ts

printf '\nFind all write paths into ideas data by command strings:\n'
python3 - <<'PY'
from pathlib import Path
terms = ["ideas create", 'ideas "create"', "ideas add", 'ideas "add"', "ideas new", "fm-idea-mine", "writeMind", "createMind", "set-id", "stateDir", "ID_STORE_PATH", "ID_DATA_PATH"]
for file in Path('.').rglob('*'):
    if not file.is_file() or '.git' in file.parts or 'node_modules' in file.parts:
        continue
    if file.suffix not in {'.sh','.ts','.mjs','.js','.json'}:
        continue
    text = file.read_text(errors='ignore')
    if any(term in text for term in terms):
        print(file)
PY

Repository: trillium/firstmate

Length of output: 15775


🏁 Script executed:

#!/bin/bash
set -u

printf 'bin/fm-idea-mine.ts lines 800-900:\n'
sed -n '800,900p' bin/fm-idea-mine.ts

printf '\nRegex-style evidence check for store write paths:\n'
python3 - <<'PY'
from pathlib import Path
import re
for path in Path('.').rglob('*'):
    if not path.is_file() or '.git' in path.parts or 'node_modules' in path.parts:
        continue
    if path.suffix not in {'.sh', '.ts', '.mjs', '.js', '.json'}:
        continue
    text = path.read_text(errors='ignore')
    for expr in [
        r'(["\'])ideas\s+\1\s+\.create\b',
        r'\bcreateIssue\s*\(\s*this\.cfg\.ideasCmd',
        r'fm_idea_mine|idea-mine|IDEA_MINE|mineTranscript|generateCandidates|eval',
    ]:
        if re.search(expr, text):
            print(f"{path}: {expr}")
            break
PY

printf '\nBehavioral probe: classify would accept classifier-directed title/brief without deterministic rail:\n'
python3 - <<'PY'
cases = [
    ("safe deploy from local", "this is safe"),
    ("deploy to production", "safe\nok"),
    ("credential rotate", "safe\nbecause ok"),
    ("safe escalate", "safe"),
    ("migrate production data", "safe\nreason"),
]
for title, out in cases:
    verdict = out.strip().splitlines()[0].lower().strip() if out.strip().splitlines() else ""
    verdict = "".join(c for c in verdict if "a" <= c <= "z")
    route = "safe" if verdict == "safe" else "escalate"
    has_high_risk = any(t in {title, out} for t in ["deploy", "credential", "rotate", "delete", "migrate", "merge"])
    print(f"title={title!r} out={out!r} -> verdict={verdict!r} route={route} has_high_risk={has_high_risk}")
PY

Repository: trillium/firstmate

Length of output: 6342


Add a deterministic escalate rail before the model verdict.

fm-idea-mine.ts mines chat/assistant history and writes ideas with ideas create, so fm-groom.sh can classify mined idea text. bin/fm-groom-lib.sh sends that title and the generated brief to fm_groom_classify, and bin/fm-groom.sh dispatches any safe verdict. A confident first-line safe from classifier-directed wording bypasses the other fail-safes. Add deterministic pre-filtering for high-risk terms — deploy, merge, credential, rotate, delete, migrate — and force escalate, regardless of the model answer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/fm-groom-lib.sh` around lines 114 - 125, Update the classification flow
around fm_groom_infer and verdict handling to inspect the combined title and
brief for the high-risk terms deploy, merge, credential, rotate, delete, or
migrate before accepting the model verdict. Force verdict=escalate when any term
matches, regardless of classifier output; otherwise preserve the existing
safe/escalate parsing and fail-safe behavior.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — deferring this one intentionally. fm-groom is off-by-default so there's no live exposure today, and the deny-list vs. fail-safe classifier design here is a security-policy call being escalated to the project owner rather than decided in this PR. Tracked as follow-up: fm-groom-classifier-escalate-rail (captain-held). Not blocking this merge.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@trillium, acknowledged. I will treat this finding as intentionally deferred and non-blocking for this PR.

The captain-held follow-up fm-groom-classifier-escalate-rail should decide whether to add deterministic escalation terms before classifier output is accepted.

You are interacting with an AI system.

Comment thread bin/fm-groom.sh Outdated
Comment thread bin/fm-groom.sh
Comment thread bin/fm-herdr-spur.sh
Comment thread bin/fm-idea-mine.ts Outdated
Comment thread bin/fm-spawn-hooks.d/beads.sh
Comment thread tests/fm-review-page.test.sh
The hook-task-a8 positive-case assertion used stat -f '%Lp' X 2>/dev/null || stat -c '%a' X.
On Linux, GNU stat's -f flag means filesystem-status mode, not a format flag.
It processes %Lp and X as two separate file targets, fails on the literal %Lp target (suppressed by 2>/dev/null), but still prints X's filesystem info to stdout, then exits nonzero.
That nonzero exit triggers the || fallback too, so the captured perm becomes the concatenation of the filesystem-info blob and the correct "700" from the GNU form, never matching "700" alone.
Every other stat-portability check in this codebase already branches on uname first; this one instance used the fragile blind-fallback idiom instead.
Match the established convention.
@trillium trillium changed the title feat(bin): reconcile diverged main with local, upstream, and origin history feat(bin): reconcile diverged main and add grooming, review-page, and isolation tooling Aug 2, 2026
Six independent correctness/reliability bugs surfaced by CodeRabbit's
review of PR #38, fixed to satisfy the CodeRabbit-clean merge gate:

- .codex/hooks.json: guard the four bd codex-hook entries with
  `command -v bd` and a 10s timeout, matching the fail-open pattern
  every other hook in the file already follows.
- fm-groom-json-field.sh: exit from the stdout.write completion
  callback instead of immediately after, since Bun's pipe writes are
  async and an immediate process.exit(0) can truncate the field value.
- fm-groom.sh: lowercase idea ids before sanitizing to a task_id (was
  dropping uppercase, causing distinct ideas to collide on one
  task_id and overwrite each other's brief); refuse to proceed on an
  empty or already-used task_id.
- fm-groom.sh: leave an idea unmarked for retry when `review q\| fails,
  instead of unconditionally writing the permanent groom=escalated
  label and losing the idea with no review item filed.
- fm-herdr-spur.sh: track whether the reader actually emitted
  @subscribed and return a failure code when it did not, so a
  persistent subscribe failure engages the existing sleep/backoff
  fallback instead of busy-looping python3 respawns.
- fm-idea-mine.ts: build the harvest review body before creating the
  review item instead of filing it with a literal "PLACEHOLDER" body
  and patching it after; a failed patch no longer leaves a permanent
  placeholder review item.

Also pins FM_REVIEW_PAGE_NO_WIRE in tests/fm-review-page.test.sh's
run_tool so an inherited env value can't silently flip wire-back
behavior and make an assertion pass or fail for the wrong reason.

Verified: tests/fm-review-page.test.sh, tests/fm-groom.test.sh,
tests/fm-idea-mine.test.sh, tests/fm-turnend-guard.test.sh all pass;
bin/fm-lint.sh clean.

Two remaining CodeRabbit findings on this review pass are
security-sensitive (bin/fm-groom-lib.sh:125 - deterministic escalate
rail for high-risk classifier terms; bin/fm-spawn-hooks.d/beads.sh:49
- unquoted env-sourced id interpolation into a generated script) and
are routed to firstmate for a decision rather than self-applied.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@bin/fm-groom.sh`:
- Around line 283-286: The escalation flow around the IDEAS_BIN set-state call
must not report success when durable persistence fails. Verify that the state
update succeeds before setting action to “FILED”; if it fails after review
creation, persist $rid through a recoverable retry path and reconcile that
record before the next review q invocation, preventing duplicate review items.
- Around line 281-290: Guard every durable idea mutation in bin/fm-groom.sh,
including the armed-branch set-state and note calls, behind successful
bin/fm-lock.sh acquisition and fm_session_lock_owned_by_self verification for
the current home. Make the real-file action path fail read-only when ownership
is unavailable, while preserving non-mutating grooming behavior and existing
mutation flow after ownership is confirmed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5da87b7f-c150-459f-8fba-2218084f53e1

📥 Commits

Reviewing files that changed from the base of the PR and between 4e054be and 5f73644.

📒 Files selected for processing (9)
  • .codex/hooks.json
  • README.md
  • bin/fm-groom-json-field.sh
  • bin/fm-groom.sh
  • bin/fm-herdr-spur.sh
  • bin/fm-idea-mine.ts
  • docs/configuration.md
  • docs/scripts.md
  • tests/fm-review-page.test.sh
💤 Files with no reviewable changes (1)
  • docs/scripts.md
🚧 Files skipped from review as they are similar to previous changes (6)
  • README.md
  • bin/fm-groom-json-field.sh
  • docs/configuration.md
  • .codex/hooks.json
  • bin/fm-herdr-spur.sh
  • bin/fm-idea-mine.ts

Comment thread bin/fm-groom.sh
Comment on lines +281 to +290
--body-file - 2>/dev/null) || rid=
if [ -n "$rid" ]; then
"$IDEAS_BIN" set-state "$id" "$GROOM_DIM=escalated" \
--reason "fm-groom filed review item $rid" >/dev/null 2>&1 || true
"$IDEAS_BIN" note "$id" "fm-groom escalated to review item $rid: $rationale" >/dev/null 2>&1 || true
action="FILED review item $rid"
else
# Never mark an idea handled when no review item exists; the groom
# label is permanent and would drop the captain's decision silently.
action="REVIEW FILING FAILED; idea left unmarked for retry"

@coderabbitai coderabbitai Bot Aug 2, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,180p' bin/fm-groom.sh
rg -n -C 5 'lock|session|set-state|note' bin/fm-groom.sh

Repository: trillium/firstmate

Length of output: 13395


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## fm-groom ideas mutation sites"
rg -n -C 3 '\$IDEAS_BIN\s*(set-state|note|label)' bin/fm-groom.sh

echo
echo "## lock-related symbols in bin/*"
rg -n -i '\b(session[_-]?lock|lock_file|lock\(|acquire[^A-Za-z]|per-home|HOME|FM_HOME)' bin || true

echo
echo "## candidate lock scripts"
fd -t f -i 'lock|session' bin || true

echo
echo "## line count and tail around groom loop"
wc -l bin/fm-groom.sh
sed -n '180,330p' bin/fm-groom.sh

Repository: trillium/firstmate

Length of output: 189


🏁 Script executed:

#!/bin/bash
set -u

echo "## fm-groom ideas mutation sites"
rg -n -C 3 'IDEAS_BIN\s*(set-state|note|label)' bin/fm-groom.sh || true

echo
echo "## lock-related symbols in bin/*"
rg -n -i '\b(session[_-]?lock|lock_file|lock\(|acquire[^A-Za-z]|per-home|HOME|FM_HOME)' bin || true

echo
echo "## candidate lock scripts"
fd -t f -i 'lock|session' bin || true

echo
echo "## line count and tail around groom loop"
wc -l bin/fm-groom.sh
sed -n '180,330p' bin/fm-groom.sh

Repository: trillium/firstmate

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -u

echo "## fm-groom raw occurrences"
rg -n -C 3 'set-state|note|label' bin/fm-groom.sh || true

echo
echo "## fm-lock script outline and key ranges"
wc -l bin/fm-lock.sh bin/fm-session-lock-lib.sh
sed -n '1,220p' bin/fm-lock.sh
sed -n '1,170p' bin/fm-session-lock-lib.sh

echo
echo "## current fm-lock usage with context"
rg -n -C 4 'bin/fm-lock\.sh|fm-lock|session-lock|session_lock' bin/fm-groom.sh bin/fm-lock.sh bin/fm-session-lock-lib.sh bin/fm-*.sh | head -n 220

Repository: trillium/firstmate

Length of output: 27749


Mutate ideas only after per-home session-lock ownership is verified.

bin/fm-groom.sh writes ideas set-state and ideas note directly in the armed branch at lines 262, 264, 283, and 285, but it does not use bin/fm-lock.sh or fm_session_lock_owned_by_self. Since idea state is durable shared state, make the real-file action path fail read-only unless bin/fm-lock.sh has acquired and verified the lock.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/fm-groom.sh` around lines 281 - 290, Guard every durable idea mutation in
bin/fm-groom.sh, including the armed-branch set-state and note calls, behind
successful bin/fm-lock.sh acquisition and fm_session_lock_owned_by_self
verification for the current home. Make the real-file action path fail read-only
when ownership is unavailable, while preserving non-mutating grooming behavior
and existing mutation flow after ownership is confirmed.

Source: Coding guidelines

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — deferring this one intentionally, not fixing in this PR. fm-groom is off-by-default so there's no live exposure today. Consolidated into the captain-held follow-up fm-groom-classifier-escalate-rail, which now tracks fm-groom's full pre-enable hardening design (lock-ownership verification for durable ideas-store mutations, alongside the deny-list/fail-safe classifier question). Not blocking this merge.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@trillium, acknowledged. The finding remains deferred to fm-groom-classifier-escalate-rail.

fm-groom is off by default in this PR. The follow-up scope includes session-lock ownership verification for durable ideas-store mutations and the classifier escalation rail. This does not block this merge.

🐇

You are interacting with an AI system.

Comment thread bin/fm-groom.sh
Comment on lines +283 to +286
"$IDEAS_BIN" set-state "$id" "$GROOM_DIM=escalated" \
--reason "fm-groom filed review item $rid" >/dev/null 2>&1 || true
"$IDEAS_BIN" note "$id" "fm-groom escalated to review item $rid: $rationale" >/dev/null 2>&1 || true
action="FILED review item $rid"

@coderabbitai coderabbitai Bot Aug 2, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not report completion when the durable state update fails.

If Line 283 fails, the idea remains eligible for grooming, but Line 286 reports a completed escalation. A later run can file another review item because no durable record associates the idea with $rid.

Persist and verify the escalation state before reporting success. If persistence fails after review creation, record $rid in a recoverable retry path and reconcile it before another review q call.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/fm-groom.sh` around lines 283 - 286, The escalation flow around the
IDEAS_BIN set-state call must not report success when durable persistence fails.
Verify that the state update succeeds before setting action to “FILED”; if it
fails after review creation, persist $rid through a recoverable retry path and
reconcile that record before the next review q invocation, preventing duplicate
review items.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — deferring this one intentionally, not fixing in this PR. fm-groom is off-by-default so there's no live exposure today. Consolidated into the captain-held follow-up fm-groom-classifier-escalate-rail, which now tracks fm-groom's full pre-enable hardening design (durable escalation-state persistence/verification, alongside the deny-list/fail-safe classifier question). Not blocking this merge.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@trillium, acknowledged. The finding remains valid and is deferred intentionally.

Complete fm-groom-classifier-escalate-rail before enabling FM_GROOM_ENABLED=1. The follow-up must prevent duplicate review creation after failed escalation-state persistence.

Would you like me to create a GitHub issue for the follow-up, or is it already tracked elsewhere?


✏️ Learnings added
Learnt from: trillium
Repo: trillium/firstmate PR: 38
File: bin/fm-groom.sh:283-286
Timestamp: 2026-08-02T22:45:47.774Z
Learning: In `bin/fm-groom.sh`, `fm-groom` remains off by default. Before any production use of `FM_GROOM_ENABLED=1`, the captain-held `fm-groom-classifier-escalate-rail` follow-up must implement and verify durable escalation-state persistence with retry reconciliation after review-item creation, and resolve the deny-list/fail-safe classifier policy.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

CodeRabbit flagged that the beads spawn hook interpolated BEADS_ID and
TASK_ID into a generated check script and a task-show command with no
charset validation and no quoting. The unquoted `task show $BEADS_ID`
inside the heredoc let a crafted bead id inject extra arguments or
shell metacharacters into the generated script, and neither id was
restricted before being used to build the $STATE/$TASK_ID.check.sh
path.

This path is live, not theoretical: under config/backlog-backend=beads,
fm_beads_resolve_or_create auto-resolves/mints a bead id that reaches
this hook with no character validation, and the bare positional task
id ($ID) reaches it the same way. Only the explicit --beads CLI-arg
path validates its input today (fm-spawn.sh:288).

Add the same [A-Za-z0-9._-] charset guard CodeRabbit's diff used,
covering both ids before either is used to build a path or a command,
and quote the interpolated id in the generated task-show call. The
guard restricts TASK_ID before $STATE/$TASK_ID.check.sh is built, so
that path can no longer contain `/` or `..` either.

Verified: tests/fm-beads-hooks.test.sh (16/16), tests/fm-brief.test.sh,
and tests/fm-spawn-beads.test.sh all pass with no regressions;
bin/fm-lint.sh clean under pinned shellcheck 0.11.0.
@trillium
trillium merged commit e46a16e into main Aug 2, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants